Files
nucleic/Sources/NucleicProtocol/Approval.swift
T
abkslmandClaude Opus 4.8 94273b5de5 Sandbox: host build/run mode (host_exec tool)
Adds a per-project "Allow host build/run" sandbox capability that lets a
containerized agent request to build and run executables on the host
machine, escaping the Linux sandbox — e.g. compiling and running a macOS
binary the container can't.

- New `host_exec` MCP tool on the approval server, advertised only when the
  session opts in. Pre-allowed via --allowedTools so the call reaches our
  handler directly rather than Claude's permission path: the handler is the
  sole gate, so `auto` mode can never auto-approve it.
- Every host command surfaces an explicit approval (risk .hostExec) and runs
  on the host via /bin/zsh -lc in the session worktree only after approval.
  An explicit "Allow for Session" choice grants the rest of the session;
  auto-approve never sets that — only a deliberate user choice does.
- ProjectSandbox.allowHostExec (off by default) with tolerant decoding so
  rows persisted before the field default to false instead of dropping the
  whole sandbox config.
- Threaded allowHostExec through RunSpec/ResumeSpec/SessionController; Mac
  Project Settings toggle; Mac ApprovalBar "Allow for Session" button; iOS
  risk styling/biometric gate for .hostExec.
- Tests: host_exec advertised/served only when registered + refused
  otherwise; allowHostExec round-trip and legacy-JSON default-to-false.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-13 16:52:01 -07:00

118 lines
3.8 KiB
Swift

import Foundation
// MARK: - Approvals (BACKEND_PROTOCOL §4)
public enum Risk: String, Sendable, Codable {
case readOnly, write, execute, network, destructive, unknown
/// Run a command on the host machine, outside the sandbox container (HOST_EXEC).
/// The highest-trust gate: always surfaced, never auto-approved.
case hostExec
}
public enum AlwaysScope: String, Sendable, Codable {
/// This exact tool + input, this session.
case session
/// Any call to this tool, this session.
case toolName
/// Tool + argument pattern (e.g. Bash(git *)).
case toolNameWithPattern
}
public struct ApprovalRequest: Sendable, Codable, Equatable, Identifiable {
public let id: ApprovalID
public let sessionID: SessionID
public let toolCallID: String?
public let toolName: String
public let input: JSONValue
public let title: String
public let risk: Risk
public let suggested: Decision?
public let createdAt: Date
public init(
id: ApprovalID,
sessionID: SessionID,
toolCallID: String?,
toolName: String,
input: JSONValue,
title: String,
risk: Risk,
suggested: Decision? = nil,
createdAt: Date
) {
self.id = id
self.sessionID = sessionID
self.toolCallID = toolCallID
self.toolName = toolName
self.input = input
self.title = title
self.risk = risk
self.suggested = suggested
self.createdAt = createdAt
}
}
public enum Decision: Sendable, Equatable {
case allow(updatedInput: JSONValue? = nil)
case allowAlways(AlwaysScope)
case deny(reason: String?)
/// Deny + stop the whole run.
case cancelRun
}
extension Decision: Codable {
private enum CodingKeys: String, CodingKey {
case type, updatedInput, scope, reason
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "allow":
self = .allow(updatedInput: try container.decodeIfPresent(JSONValue.self, forKey: .updatedInput))
case "allowAlways":
self = .allowAlways(try container.decode(AlwaysScope.self, forKey: .scope))
case "deny":
self = .deny(reason: try container.decodeIfPresent(String.self, forKey: .reason))
case "cancelRun":
self = .cancelRun
default:
throw DecodingError.dataCorruptedError(
forKey: .type, in: container, debugDescription: "Unknown decision type \(type)")
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .allow(let updatedInput):
try container.encode("allow", forKey: .type)
try container.encodeIfPresent(updatedInput, forKey: .updatedInput)
case .allowAlways(let scope):
try container.encode("allowAlways", forKey: .type)
try container.encode(scope, forKey: .scope)
case .deny(let reason):
try container.encode("deny", forKey: .type)
try container.encodeIfPresent(reason, forKey: .reason)
case .cancelRun:
try container.encode("cancelRun", forKey: .type)
}
}
}
public struct ApprovalResolved: Sendable, Codable, Equatable {
public let id: ApprovalID
public let decision: Decision
/// "mac-ui" | "iphone:<device>" | "always-rule" — first responder wins.
public let decidedBy: String
public let decidedAt: Date
public init(id: ApprovalID, decision: Decision, decidedBy: String, decidedAt: Date) {
self.id = id
self.decision = decision
self.decidedBy = decidedBy
self.decidedAt = decidedAt
}
}