diff --git a/src/local-agent-claude.test.ts b/src/local-agent-claude.test.ts index eb1185cc2..b9ff7bf3e 100644 --- a/src/local-agent-claude.test.ts +++ b/src/local-agent-claude.test.ts @@ -273,6 +273,13 @@ test("authority settings map each write mode to permissions and sandbox", () => assert.ok((readOnlyPermissions.deny as string[]).includes("Bash")); // SAFETY: The surrounding checks or construction already match this type. assert.ok((readOnlyPermissions.deny as string[]).includes("Edit")); + + // A nested agent or another file tool must not reopen the writes the parent is denied. + for (const tool of ["Write", "MultiEdit", "NotebookEdit", "EnterWorktree", "Agent", "Task", "Workflow", "CronCreate", "RemoteTrigger"]) { + // SAFETY: The surrounding checks or construction already match this type. + assert.ok((readOnlyPermissions.deny as string[]).includes(tool), tool); + } + assert.deepEqual( // SAFETY: The surrounding checks or construction already match this type. ((readOnlySettings.sandbox as JsonObject).filesystem as JsonObject).allowWrite, diff --git a/src/local-agent-claude.ts b/src/local-agent-claude.ts index ac2ae6567..04aae26e2 100644 --- a/src/local-agent-claude.ts +++ b/src/local-agent-claude.ts @@ -23,6 +23,24 @@ import type { type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto"; +// Read-only turns deny every tool that writes locally (including EnterWorktree, which creates a Git worktree) and +// every tool that starts other agents now or later (Agent, Task, Workflow, CronCreate, RemoteTrigger): those agents +// do not inherit the parent's denies and could do the editing it is refused. The sandbox's denyWrite only covers +// Bash, not Claude's built-in file tools. +const CLAUDE_READ_ONLY_DENIED_TOOLS = [ + "Bash", + "Edit", + "Write", + "MultiEdit", + "NotebookEdit", + "EnterWorktree", + "Agent", + "Task", + "Workflow", + "CronCreate", + "RemoteTrigger", +] as const; + const CLAUDE_WORKSPACE_ALLOWED_TOOLS = [ // allowedTools is passed as a session/CLI rule, so `/` is anchored to the query cwd. "Read(/**)", @@ -386,7 +404,7 @@ function claudeAuthorityOptions( const permissions = { defaultMode: "dontAsk", - deny: allowed ? [] : ["Bash", "Edit"], + deny: allowed ? [] : [...CLAUDE_READ_ONLY_DENIED_TOOLS], }; const sandbox = { diff --git a/test/agents-replay/transcripts/claude/read-only.jsonl b/test/agents-replay/transcripts/claude/read-only.jsonl index 3297f9e6d..1a4544f84 100644 --- a/test/agents-replay/transcripts/claude/read-only.jsonl +++ b/test/agents-replay/transcripts/claude/read-only.jsonl @@ -1,6 +1,6 @@ -{"type":"meta","provider":"claude","scenario":"read-only","args":["--output-format","stream-json","--verbose","--input-format","stream-json","--model","haiku","--allowedTools","Read(/**),Edit(/**),Bash","--permission-mode","dontAsk","--settings","{\"permissions\":{\"defaultMode\":\"dontAsk\",\"deny\":[\"Bash\",\"Edit\"]},\"sandbox\":{\"enabled\":true,\"failIfUnavailable\":true,\"autoAllowBashIfSandboxed\":true,\"allowUnsandboxedCommands\":false,\"filesystem\":{\"allowWrite\":[],\"denyWrite\":[\"$WORKSPACE\"]}}}"],"version":"2.1.200 (Claude Code)","recordedAt":"2026-10-26T07:10:31.902Z"} +{"type":"meta","provider":"claude","scenario":"read-only","args":["--output-format","stream-json","--verbose","--input-format","stream-json","--model","haiku","--allowedTools","Read(/**),Edit(/**),Bash","--permission-mode","dontAsk","--settings","{\"permissions\":{\"defaultMode\":\"dontAsk\",\"deny\":[\"Bash\",\"Edit\",\"Write\",\"MultiEdit\",\"NotebookEdit\",\"EnterWorktree\",\"Agent\",\"Task\",\"Workflow\",\"CronCreate\",\"RemoteTrigger\"]},\"sandbox\":{\"enabled\":true,\"failIfUnavailable\":true,\"autoAllowBashIfSandboxed\":true,\"allowUnsandboxedCommands\":false,\"filesystem\":{\"allowWrite\":[],\"denyWrite\":[\"$WORKSPACE\"]}}}"],"version":"2.1.200 (Claude Code)","recordedAt":"2026-10-26T07:10:31.902Z"} {"dir":"in","ms":113,"frame":{"request_id":"5w08fedkj87","type":"control_request","request":{"subtype":"initialize","systemPrompt":[""]}}} -{"dir":"in","ms":113,"frame":{"request_id":"5v6gd7b4kd6","type":"control_request","request":{"subtype":"apply_flag_settings","settings":{"permissions":{"defaultMode":"dontAsk","deny":["Bash","Edit"]},"sandbox":{"enabled":true,"failIfUnavailable":true,"autoAllowBashIfSandboxed":true,"allowUnsandboxedCommands":false,"filesystem":{"allowWrite":[],"denyWrite":["$WORKSPACE"]}}}}}} +{"dir":"in","ms":113,"frame":{"request_id":"5v6gd7b4kd6","type":"control_request","request":{"subtype":"apply_flag_settings","settings":{"permissions":{"defaultMode":"dontAsk","deny":["Bash","Edit","Write","MultiEdit","NotebookEdit","EnterWorktree","Agent","Task","Workflow","CronCreate","RemoteTrigger"]},"sandbox":{"enabled":true,"failIfUnavailable":true,"autoAllowBashIfSandboxed":true,"allowUnsandboxedCommands":false,"filesystem":{"allowWrite":[],"denyWrite":["$WORKSPACE"]}}}}}} {"dir":"out","ms":1154,"frame":{"type":"control_response","response":{"subtype":"success","request_id":"5w08fedkj87","response":{"commands":[],"agents":[],"output_style":"default","available_output_styles":["default","Proactive","Explanatory","Learning"],"models":[{"value":"default","resolvedModel":"claude-opus-4-8[1m]","displayName":"Default (recommended)","description":"Opus 4.8 with 1M context · Best for everyday, complex tasks","supportsEffort":true,"supportedEffortLevels":["low","medium","high","xhigh","max"],"supportsAdaptiveThinking":true,"supportsFastMode":true,"supportsAutoMode":true},{"value":"opus[1m]","resolvedModel":"claude-opus-4-8[1m]","displayName":"Opus","description":"Opus 4.8 with 1M context · Best for everyday, complex tasks","supportsEffort":true,"supportedEffortLevels":["low","medium","high","xhigh","max"],"supportsAdaptiveThinking":true,"supportsFastMode":true,"supportsAutoMode":true},{"value":"claude-fable-5[1m]","resolvedModel":"claude-fable-5","displayName":"Fable","description":"Fable 5 · Most capable for your hardest and longest-running tasks","supportsEffort":true,"supportedEffortLevels":["low","medium","high","xhigh","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true},{"value":"sonnet","resolvedModel":"claude-sonnet-5","displayName":"Sonnet","description":"Sonnet 5 · Efficient for routine tasks","supportsEffort":true,"supportedEffortLevels":["low","medium","high","xhigh","max"],"supportsAdaptiveThinking":true,"supportsAutoMode":true},{"value":"haiku","resolvedModel":"claude-haiku-4-5-20251001","displayName":"Haiku","description":"Haiku 4.5 · Fastest for quick answers"}],"account":{},"pid":0,"feedback_survey_config":{"minTimeBeforeFeedbackMs":600000,"minTimeBetweenFeedbackMs":43200000,"minTimeBetweenGlobalFeedbackMs":43200000,"minUserTurnsBeforeFeedback":5,"minUserTurnsBetweenFeedback":25,"hideThanksAfterMs":3000,"onForModels":["*"],"probability":0.05,"lastSurveyShownTime":null}}}}} {"dir":"out","ms":1160,"frame":{"type":"control_response","response":{"subtype":"success","request_id":"5v6gd7b4kd6"}}} {"dir":"in","ms":1161,"frame":{"request_id":"zpcjtu4elhn","type":"control_request","request":{"subtype":"set_permission_mode","mode":"dontAsk"}}}