refactor(macos): split exec approvals handler
This commit is contained in:
@@ -259,6 +259,20 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private enum ExecHostExecutor {
|
private enum ExecHostExecutor {
|
||||||
|
private struct ExecApprovalContext {
|
||||||
|
let command: [String]
|
||||||
|
let displayCommand: String
|
||||||
|
let trimmedAgent: String?
|
||||||
|
let approvals: ExecApprovalsResolved
|
||||||
|
let security: ExecSecurity
|
||||||
|
let ask: ExecAsk
|
||||||
|
let autoAllowSkills: Bool
|
||||||
|
let env: [String: String]?
|
||||||
|
let resolution: ExecCommandResolution?
|
||||||
|
let allowlistMatch: ExecAllowlistEntry?
|
||||||
|
let skillAllow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
@@ -277,14 +291,93 @@ private enum ExecHostExecutor {
|
|||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return ExecHostResponse(
|
return self.errorResponse(
|
||||||
type: "exec-res",
|
code: "INVALID_REQUEST",
|
||||||
id: UUID().uuidString,
|
message: "command required",
|
||||||
ok: false,
|
reason: "invalid")
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let context = await self.buildContext(request: request, command: command)
|
||||||
|
if context.security == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DISABLED: security=deny",
|
||||||
|
reason: "security=deny")
|
||||||
|
}
|
||||||
|
|
||||||
|
let approvalDecision = request.approvalDecision
|
||||||
|
if approvalDecision == .deny {
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
var approvedByAsk = approvalDecision != nil
|
||||||
|
if self.requiresAsk(
|
||||||
|
ask: context.ask,
|
||||||
|
security: context.security,
|
||||||
|
allowlistMatch: context.allowlistMatch,
|
||||||
|
skillAllow: context.skillAllow),
|
||||||
|
approvalDecision == nil
|
||||||
|
{
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: context.displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: context.security.rawValue,
|
||||||
|
ask: context.ask.rawValue,
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied",
|
||||||
|
reason: "user-denied")
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
self.persistAllowlistEntry(decision: decision, context: context)
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.persistAllowlistEntry(decision: approvalDecision, context: context)
|
||||||
|
|
||||||
|
if context.security == .allowlist,
|
||||||
|
context.allowlistMatch == nil,
|
||||||
|
!context.skillAllow,
|
||||||
|
!approvedByAsk
|
||||||
|
{
|
||||||
|
return self.errorResponse(
|
||||||
|
code: "UNAVAILABLE",
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
||||||
|
reason: "allowlist-miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = context.allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: context.trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: context.displayCommand,
|
||||||
|
resolvedPath: context.resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) {
|
||||||
|
return errorResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.runCommand(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: context.env,
|
||||||
|
timeoutMs: request.timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext {
|
||||||
let displayCommand = ExecCommandFormatter.displayString(
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
for: command,
|
for: command,
|
||||||
rawCommand: request.rawCommand)
|
rawCommand: request.rawCommand)
|
||||||
@@ -310,122 +403,72 @@ private enum ExecHostExecutor {
|
|||||||
} else {
|
} else {
|
||||||
skillAllow = false
|
skillAllow = false
|
||||||
}
|
}
|
||||||
|
return ExecApprovalContext(
|
||||||
|
command: command,
|
||||||
|
displayCommand: displayCommand,
|
||||||
|
trimmedAgent: trimmedAgent,
|
||||||
|
approvals: approvals,
|
||||||
|
security: security,
|
||||||
|
ask: ask,
|
||||||
|
autoAllowSkills: autoAllowSkills,
|
||||||
|
env: env,
|
||||||
|
resolution: resolution,
|
||||||
|
allowlistMatch: allowlistMatch,
|
||||||
|
skillAllow: skillAllow)
|
||||||
|
}
|
||||||
|
|
||||||
if security == .deny {
|
private static func requiresAsk(
|
||||||
return ExecHostResponse(
|
ask: ExecAsk,
|
||||||
type: "exec-res",
|
security: ExecSecurity,
|
||||||
id: UUID().uuidString,
|
allowlistMatch: ExecAllowlistEntry?,
|
||||||
ok: false,
|
skillAllow: Bool) -> Bool
|
||||||
payload: nil,
|
{
|
||||||
error: ExecHostError(
|
if ask == .always { return true }
|
||||||
code: "UNAVAILABLE",
|
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||||
message: "SYSTEM_RUN_DISABLED: security=deny",
|
return false
|
||||||
reason: "security=deny"))
|
}
|
||||||
|
|
||||||
|
private static func persistAllowlistEntry(
|
||||||
|
decision: ExecApprovalDecision?,
|
||||||
|
context: ExecApprovalContext)
|
||||||
|
{
|
||||||
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
|
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
private static func allowlistPattern(
|
||||||
if ask == .always { return true }
|
command: [String],
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
resolution: ExecCommandResolution?) -> String?
|
||||||
return false
|
{
|
||||||
}()
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
return pattern.isEmpty ? nil : pattern
|
||||||
|
}
|
||||||
|
|
||||||
let approvalDecision = request.approvalDecision
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
if approvalDecision == .deny {
|
guard needsScreenRecording == true else { return nil }
|
||||||
return ExecHostResponse(
|
let authorized = await PermissionManager
|
||||||
type: "exec-res",
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
id: UUID().uuidString,
|
if authorized { return nil }
|
||||||
ok: false,
|
return self.errorResponse(
|
||||||
payload: nil,
|
code: "UNAVAILABLE",
|
||||||
error: ExecHostError(
|
message: "PERMISSION_MISSING: screenRecording",
|
||||||
code: "UNAVAILABLE",
|
reason: "permission:screenRecording")
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
}
|
||||||
reason: "user-denied"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
private static func runCommand(
|
||||||
if requiresAsk, approvalDecision == nil {
|
command: [String],
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
cwd: String?,
|
||||||
ExecApprovalPromptRequest(
|
env: [String: String]?,
|
||||||
command: displayCommand,
|
timeoutMs: Int?) async -> ExecHostResponse
|
||||||
cwd: request.cwd,
|
{
|
||||||
host: "node",
|
let timeoutSec = timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
|
||||||
case .deny:
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: user denied",
|
|
||||||
reason: "user-denied"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if approvalDecision == .allowAlways, security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "SYSTEM_RUN_DENIED: allowlist miss",
|
|
||||||
reason: "allowlist-miss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.needsScreenRecording == true {
|
|
||||||
let authorized = await PermissionManager
|
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
|
||||||
if !authorized {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(
|
|
||||||
code: "UNAVAILABLE",
|
|
||||||
message: "PERMISSION_MISSING: screenRecording",
|
|
||||||
reason: "permission:screenRecording"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
|
||||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
await ShellExecutor.runDetailed(
|
await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: request.cwd,
|
cwd: cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
}.value
|
}.value
|
||||||
@@ -436,7 +479,24 @@ private enum ExecHostExecutor {
|
|||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
stderr: result.stderr,
|
stderr: result.stderr,
|
||||||
error: result.errorMessage)
|
error: result.errorMessage)
|
||||||
return ExecHostResponse(
|
return self.successResponse(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func errorResponse(
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
reason: String?) -> ExecHostResponse
|
||||||
|
{
|
||||||
|
ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: code, message: message, reason: reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func successResponse(_ payload: ExecHostRunResult) -> ExecHostResponse {
|
||||||
|
ExecHostResponse(
|
||||||
type: "exec-res",
|
type: "exec-res",
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user