You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[N4] Permission modes: plan, acceptEdits and dontAsk, switchable mid-session #215
Users can put an agent in a named permission mode instead of writing rules: plan (look, do not touch), acceptEdits (file edits run without asking) and dontAsk (never pause; what would ask is refused), and switch it in the middle of a session, the way Claude Code users already work ("plan first, then let it edit"). The modes are presets over the existing permission rules and needsApproval, not a second system. Audit 2, matrix row "Permission modes / plan mode" (⚠️ policies), field idea 6.
Current state
Permission rules (LOU-X2) live in src/execution/permissions.ts: PermissionOptions (:78-96) with permissions?: readonly PermissionRule[] and onPermissionDecision; helpers allow(), deny(), ask() (:99-111); checkPermission() (:141-154) returns undefined when neither option is set; PermissionDecisionEntry (:57-75) is the audit entry streamed as permission.decision.
CreateAgentBase extends PermissionOptions (src/createAgent.ts:98) and ExecuteOptions extends PermissionOptions (src/execution/AgentExecutor.ts:190). createAgent() copies permissions / onPermissionDecision into runOptions (src/createAgent.ts:608-611), so resumed runs and sub-agents get them. Sub-agents inherit them through InheritedRuntime (src/execution/subagentRuntime.ts:31-46, which lists 'permissions' and 'onPermissionDecision').
The tool-call gate is gateToolCall() in src/execution/toolCallExecution.ts:156-177: permission rules first (checkPermissionRules(), :243-262; allow/ask become { requiresApproval }, deny a kind: 'denied' rejection via deniedGate(), :265-270), then tool guardrails, then the tool's own needsApproval (checkNeedsApproval(), :276-291); "an allow / ask rule replaces the tool's own ask, not its deny" (:168-170). The audit entry is reported in the finally (:175).
Read-only tools are marked with MCP annotations stored at metadata.mcp.annotations (src/tools/defineTool.ts:34-40, 154; McpToolAnnotations in src/types/tool.ts:69). read_file, list_dir, glob, grep set { readOnlyHint: true, destructiveHint: false } (src/tools/workspace/fsTools.ts:97); so do todo_read (src/tools/built-in/todo.ts:151), current_date and day_name. MCP tools keep the server's annotations (src/types/tool.ts:82-83).
Nothing marks a tool as a file edit: write_file and edit_file (fsTools.ts:129-151) carry no annotation.
exporttypePermissionMode='default'|'plan'|'acceptEdits'|'dontAsk';// added to PermissionOptions:/** Named preset over `permissions` and `needsApproval`. Default 'default'. A function is read at every tool call, so a mode switched mid-run applies to the next call. */
permissionMode?: PermissionMode|(()=>PermissionMode);
and an optional mode?: PermissionMode on PermissionDecisionEntry, set when the mode changed the call's outcome.
What each mode does, applied in gateToolCall() after rules, guardrails and needsApproval have produced a gate (so hooks, deny rules and a tool's own deny always win):
default: no change.
acceptEdits: a call that would pause for approval runs without asking when its tool is a file edit (editsFiles, below). Everything else unchanged.
plan: a call to a tool that is not read-only is denied with kind: 'denied' and the reason The agent is in plan mode: it may read but not change anything. Describe the change instead., even when an allow rule matched. Read-only means readOnlyHint: true, plus ask_question and task (the sub-agent inherits plan mode, so it cannot change anything either). Read-only calls follow the normal gate (they can still ask).
dontAsk: a call that would pause for approval (an ask rule, needsApproval, or ask_question) is denied instead, reason The agent is in dontAsk mode: calls that need approval are refused. Calls approved by an allow rule or needing no approval run.
A permission decision is audited (onPermissionDecision, permission.decision event) whenever a mode is set to something other than 'default', even without permissions; checkPermission()'s early return (permissions.ts:146) must account for it.
Plan mode adds one paragraph to the system prompt of a run that starts in plan mode (same text as the deny reason, phrased as an instruction). Decision: the prompt is set at run start; a mid-run switch is enforced by the gate alone, which is enough because the deny reason tells the model.
File-edit marker: DefineToolOptions.editsFiles?: boolean in src/tools/defineTool.ts, stored on the descriptor as metadata.editsFiles (next to metadata.mcp). write_file and edit_file in src/tools/workspace/fsTools.ts set it. Decision: an explicit marker, not a list of tool names, so a user tool named write_file is not silently auto-approved.
readOnlyHint: true added to load_skill and to each recall_<name> tool, so plan mode can use skills and memory.
Switching:
createAgent({ permissionMode }) (inherited from PermissionOptions), added to runOptions in src/createAgent.ts:608-625, and to InheritedRuntime in src/execution/subagentRuntime.ts.
send() / stream() option permissionMode?: PermissionMode (SendOptions, src/createAgent.ts:455), for that run.
SessionOptions.permissionMode and session.setPermissionMode(mode: PermissionMode): void / session.permissionMode getter on AgentSession (src/session/AgentSession.ts). The session passes a getter to each turn's run, so setPermissionMode() during a turn applies from the next tool call of that turn. A run resumed after an approval through agent.approvals.resolve() reads the session's current mode when the paused run belonged to a session, else the agent's.
The mode is not saved in checkpoints or approval snapshots; a resume in another process uses the mode the resuming agent or session has. Document it.
PermissionMode exported as a type from src/execution/index.ts next to PermissionRule (:31), which reaches the package root.
New page docs/permission-modes.md: what each mode does, a table (mode / read-only tools / file edits / other tools that ask / other tools that do not ask), order of evaluation (hooks, rules, guardrails, needsApproval, then the mode), switching mid-session, sub-agents, the editsFiles marker, resume behavior. One sentence with a link from docs/approvals.md "Permission policies" (no new heading there).
Example examples/plan-mode/ (index.ts, README.md, package.json like examples/doc-qa/), plus its ## [plan-mode](./plan-mode) entry in examples/README.md (checked by examples/README.test.ts) and an example:plan-mode script in package.json: a coding agent over a MemoryWorkspace with createFsTools(), first a plan-mode turn that reads and proposes, then session.setPermissionMode('acceptEdits') and a turn that applies the plan. It runs offline with mockModel by default and against a real model when OPENROUTER_API_KEY is set.
Out:
bypassPermissions and the model-classified auto mode (a classifier model call per tool call is a separate design).
An exit_plan_mode tool that asks the user to approve the plan.
Persisting the mode in checkpoints.
Acceptance criteria
New src/execution/permissionModes.test.ts, offline with mockModel, covering for each mode: a read-only tool, a file-edit tool with needsApproval: true, another tool with needsApproval: true, a tool with no approval, an allow rule, a deny rule, ask_question; plan mode's system-prompt paragraph; the audit entry's mode field and the permission.decision event; a sub-agent run under the lead's plan mode cannot write.
A session test: start in plan, setPermissionMode('acceptEdits') from a tool.start listener during a turn, and the next write_file call of that same turn runs without pausing.
A resume test: a paused session turn resolved after setPermissionMode('dontAsk') follows the new mode for the calls after the approved one.
src/tools/workspace/fsTools.test.ts asserts editsFiles on write_file / edit_file only.
Type tests: permissionMode accepted by createAgent(), send(), agent.session(); npm run test:types passes.
docs/permission-modes.md written; snippets pass npm run docs:verify-snippets; link from docs/approvals.md.
examples/plan-mode/ runs with npx tsx examples/plan-mode/index.ts offline; examples/README.test.ts passes.
CHANGELOG entry (Added); npm run docs:llms run.
For G9: new page permission-modes (slug permission-modes) needs a PAGES entry, navigation in both languages and an Arabic translation.
Live test
Run examples/plan-mode/index.ts once against openai/gpt-4o-mini through OpenRouter (provider from model: 'openrouter/openai/gpt-4o-mini'), maxSteps: 6, short prompts. Check: in plan mode the model's write attempts (if any) come back denied and the workspace is unchanged; after the switch it writes the file. Maximum spend: 0.10 USD. Record it with recordReplay to src/execution/__fixtures__/cassettes/n4-plan-mode.json (the __fixtures__ folders are not published) and add a replay test src/execution/permissionModes.replay.test.ts that runs the same flow from the cassette offline in CI.
Dependencies
None. Touches src/execution/toolCallExecution.ts (also touched by N2, N5, N7 and N13b): do not run in parallel with them; rebase before merging. Touches src/session/AgentSession.ts (also N3a). Not a hub ticket (the loop is not changed, only the gate), but it is model:opus because it is security-relevant.
Notes for the implementer
Keep the existing precedence: a preToolCall hook deny, a deny rule and a tool's own needsApproval deny must still deny in every mode; no mode turns a deny into a run.
checkNeedsApproval() returns { requiresApproval: false } for a tool that is not in the registry (toolCallExecution.ts:281-284); a call to an unknown tool must still end as the normal unknown-tool error in plan mode, not as a plan-mode deny, so the model sees the real problem.
acceptEdits approves only what would otherwise ask. It must not approve a call a guardrail blocked.
dontAsk with an approve callback: the callback is never called (nothing pauses).
ask_question is gated like a needsApproval tool; check how it is wired (src/tools/built-in/askQuestion.ts) before treating it as read-only in plan mode.
The sub-agent task tool: allowing it in plan mode is safe only because the child inherits permissionMode; add the field to InheritedRuntime and test it, or plan mode leaks.
Reading the mode: normalize permissionMode to a function once per run (typeof mode === 'function' ? mode : () => mode ?? 'default').
Round 2 ticket N4. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; put Closes #<this issue> in it.
Goal
Users can put an agent in a named permission mode instead of writing rules:⚠️ policies), field idea 6.
plan(look, do not touch),acceptEdits(file edits run without asking) anddontAsk(never pause; what would ask is refused), and switch it in the middle of a session, the way Claude Code users already work ("plan first, then let it edit"). The modes are presets over the existing permission rules andneedsApproval, not a second system. Audit 2, matrix row "Permission modes / plan mode" (Current state
src/execution/permissions.ts:PermissionOptions(:78-96) withpermissions?: readonly PermissionRule[]andonPermissionDecision; helpersallow(),deny(),ask()(:99-111);checkPermission()(:141-154) returnsundefinedwhen neither option is set;PermissionDecisionEntry(:57-75) is the audit entry streamed aspermission.decision.CreateAgentBase extends PermissionOptions(src/createAgent.ts:98) andExecuteOptions extends PermissionOptions(src/execution/AgentExecutor.ts:190).createAgent()copiespermissions/onPermissionDecisionintorunOptions(src/createAgent.ts:608-611), so resumed runs and sub-agents get them. Sub-agents inherit them throughInheritedRuntime(src/execution/subagentRuntime.ts:31-46, which lists'permissions'and'onPermissionDecision').gateToolCall()insrc/execution/toolCallExecution.ts:156-177: permission rules first (checkPermissionRules(),:243-262;allow/askbecome{ requiresApproval },denyakind: 'denied'rejection viadeniedGate(),:265-270), then tool guardrails, then the tool's ownneedsApproval(checkNeedsApproval(),:276-291); "anallow/askrule replaces the tool's own ask, not its deny" (:168-170). The audit entry is reported in thefinally(:175).metadata.mcp.annotations(src/tools/defineTool.ts:34-40, 154;McpToolAnnotationsinsrc/types/tool.ts:69).read_file,list_dir,glob,grepset{ readOnlyHint: true, destructiveHint: false }(src/tools/workspace/fsTools.ts:97); so dotodo_read(src/tools/built-in/todo.ts:151),current_dateandday_name. MCP tools keep the server's annotations (src/types/tool.ts:82-83).write_fileandedit_file(fsTools.ts:129-151) carry no annotation.ask_question(ASK_QUESTION_TOOL_NAME,src/execution/ApprovalGate.ts:51),load_skill(src/skills/withSkills.ts:11),task(src/subagents/withSubagents.ts:29),recall_<name>/remember_<name>(src/memory/withMemory.ts:28,40).permissionModeinsrc/finds nothing.docs/approvals.md"## Permission policies" (line 62).Scope
In:
src/execution/permissions.ts:mode?: PermissionModeonPermissionDecisionEntry, set when the mode changed the call's outcome.gateToolCall()after rules, guardrails andneedsApprovalhave produced a gate (so hooks,denyrules and a tool's own deny always win):default: no change.acceptEdits: a call that would pause for approval runs without asking when its tool is a file edit (editsFiles, below). Everything else unchanged.plan: a call to a tool that is not read-only is denied withkind: 'denied'and the reasonThe agent is in plan mode: it may read but not change anything. Describe the change instead., even when anallowrule matched. Read-only meansreadOnlyHint: true, plusask_questionandtask(the sub-agent inherits plan mode, so it cannot change anything either). Read-only calls follow the normal gate (they can still ask).dontAsk: a call that would pause for approval (anaskrule,needsApproval, orask_question) is denied instead, reasonThe agent is in dontAsk mode: calls that need approval are refused.Calls approved by anallowrule or needing no approval run.onPermissionDecision,permission.decisionevent) whenever a mode is set to something other than'default', even withoutpermissions;checkPermission()'s early return (permissions.ts:146) must account for it.DefineToolOptions.editsFiles?: booleaninsrc/tools/defineTool.ts, stored on the descriptor asmetadata.editsFiles(next tometadata.mcp).write_fileandedit_fileinsrc/tools/workspace/fsTools.tsset it. Decision: an explicit marker, not a list of tool names, so a user tool namedwrite_fileis not silently auto-approved.readOnlyHint: trueadded toload_skilland to eachrecall_<name>tool, so plan mode can use skills and memory.createAgent({ permissionMode })(inherited fromPermissionOptions), added torunOptionsinsrc/createAgent.ts:608-625, and toInheritedRuntimeinsrc/execution/subagentRuntime.ts.send()/stream()optionpermissionMode?: PermissionMode(SendOptions,src/createAgent.ts:455), for that run.SessionOptions.permissionModeandsession.setPermissionMode(mode: PermissionMode): void/session.permissionModegetter onAgentSession(src/session/AgentSession.ts). The session passes a getter to each turn's run, sosetPermissionMode()during a turn applies from the next tool call of that turn. A run resumed after an approval throughagent.approvals.resolve()reads the session's current mode when the paused run belonged to a session, else the agent's.PermissionModeexported as a type fromsrc/execution/index.tsnext toPermissionRule(:31), which reaches the package root.docs/permission-modes.md: what each mode does, a table (mode / read-only tools / file edits / other tools that ask / other tools that do not ask), order of evaluation (hooks, rules, guardrails,needsApproval, then the mode), switching mid-session, sub-agents, theeditsFilesmarker, resume behavior. One sentence with a link fromdocs/approvals.md"Permission policies" (no new heading there).examples/plan-mode/(index.ts,README.md,package.jsonlikeexamples/doc-qa/), plus its## [plan-mode](./plan-mode)entry inexamples/README.md(checked byexamples/README.test.ts) and anexample:plan-modescript inpackage.json: a coding agent over aMemoryWorkspacewithcreateFsTools(), first a plan-mode turn that reads and proposes, thensession.setPermissionMode('acceptEdits')and a turn that applies the plan. It runs offline withmockModelby default and against a real model whenOPENROUTER_API_KEYis set.Out:
bypassPermissionsand the model-classifiedautomode (a classifier model call per tool call is a separate design).exit_plan_modetool that asks the user to approve the plan.Acceptance criteria
src/execution/permissionModes.test.ts, offline withmockModel, covering for each mode: a read-only tool, a file-edit tool withneedsApproval: true, another tool withneedsApproval: true, a tool with no approval, anallowrule, adenyrule,ask_question; plan mode's system-prompt paragraph; the audit entry'smodefield and thepermission.decisionevent; a sub-agent run under the lead's plan mode cannot write.plan,setPermissionMode('acceptEdits')from atool.startlistener during a turn, and the nextwrite_filecall of that same turn runs without pausing.setPermissionMode('dontAsk')follows the new mode for the calls after the approved one.src/tools/workspace/fsTools.test.tsassertseditsFilesonwrite_file/edit_fileonly.permissionModeaccepted bycreateAgent(),send(),agent.session();npm run test:typespasses.docs/permission-modes.mdwritten; snippets passnpm run docs:verify-snippets; link fromdocs/approvals.md.examples/plan-mode/runs withnpx tsx examples/plan-mode/index.tsoffline;examples/README.test.tspasses.npm run docs:llmsrun.permission-modes(slugpermission-modes) needs aPAGESentry, navigation in both languages and an Arabic translation.Live test
Run
examples/plan-mode/index.tsonce againstopenai/gpt-4o-minithrough OpenRouter (providerfrommodel: 'openrouter/openai/gpt-4o-mini'),maxSteps: 6, short prompts. Check: in plan mode the model's write attempts (if any) come back denied and the workspace is unchanged; after the switch it writes the file. Maximum spend: 0.10 USD. Record it withrecordReplaytosrc/execution/__fixtures__/cassettes/n4-plan-mode.json(the__fixtures__folders are not published) and add a replay testsrc/execution/permissionModes.replay.test.tsthat runs the same flow from the cassette offline in CI.Dependencies
None. Touches
src/execution/toolCallExecution.ts(also touched by N2, N5, N7 and N13b): do not run in parallel with them; rebase before merging. Touchessrc/session/AgentSession.ts(also N3a). Not a hub ticket (the loop is not changed, only the gate), but it ismodel:opusbecause it is security-relevant.Notes for the implementer
preToolCallhook deny, adenyrule and a tool's ownneedsApprovaldeny must still deny in every mode; no mode turns a deny into a run.checkNeedsApproval()returns{ requiresApproval: false }for a tool that is not in the registry (toolCallExecution.ts:281-284); a call to an unknown tool must still end as the normal unknown-tool error in plan mode, not as a plan-mode deny, so the model sees the real problem.acceptEditsapproves only what would otherwise ask. It must not approve a call a guardrail blocked.dontAskwith anapprovecallback: the callback is never called (nothing pauses).ask_questionis gated like aneedsApprovaltool; check how it is wired (src/tools/built-in/askQuestion.ts) before treating it as read-only in plan mode.tasktool: allowing it in plan mode is safe only because the child inheritspermissionMode; add the field toInheritedRuntimeand test it, or plan mode leaks.permissionModeto a function once per run (typeof mode === 'function' ? mode : () => mode ?? 'default').Round 2 ticket
N4. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; putCloses #<this issue>in it.