Skip to content

[N4] Permission modes: plan, acceptEdits and dontAsk, switchable mid-session #215

Description

@LinuxDevil

Goal

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.
  • Built-in tool names relevant here: 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).
  • No permission mode exists: grep for permissionMode in src/ finds nothing.
  • Docs: docs/approvals.md "## Permission policies" (line 62).

Scope

In:

  • In src/execution/permissions.ts:
    export type PermissionMode = '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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    live-testHas a live-model test with a budgetmodel:opusRun loop, security or API design; needs Opusround-2Round 2 plan ticketwave-3Round 2, wave 3

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions