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]>
118 lines
3.8 KiB
Swift
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
|
|
}
|
|
}
|