OMP Internals Deep Dive series, post 4. Previous posts dissected the agent loop double while, append-only context, and four compaction strategies; this one examines "can this tool run?" — the approval decision pipeline.
TL;DR
-
Three-layer resolution order (
resolveApproval, lines 120-219):- Tool declaration layer: each tool declares
approval(tier or function), optionalpolicy: allow|deny|prompt,override: true,reason,policyKey. Missing/malformed → defaults toexec(fail-closed) - User override layer:
tools.approval.<tool>: allow|deny|prompt;<policyKey>falls back to tool name (e.g.,xd://device dispatch useswrite) - Mode layer (
APPROVAL_MODE_MAX_TIER, lines 37-41):always-askauto-allowsread;writeauto-allowsread+write;yoloauto-allows all. In yolo,override: truedoesn't force prompt, butpolicy: deny|allow|promptstill takes effect
- Tool declaration layer: each tool declares
-
Iron laws: tool-side
denyand user-sidedenycan never be crossed by mode;formatApprovalDetailstruncates execution details to 2,000 chars for prompt; MCP tools defaultwrite; unknown custom tools defaultexec; subagent runs headless yolo — parenttaskapproval is the only auth boundary -
Bash's tokenized approval:
allowmust cover entire line,deny/promptmatch per segment (&&,;,|,&, newline, subshell); shell control syntax blocksallowbut notdeny/prompt -
Same tool switches read/write:
lsp/dap/computer/write(xd://)usepolicyKeyto map args to user override
Context
You write a custom tool but forget the approval field. When the model calls it, how does omp decide whether to let it run?
Or: you launch with --yolo, expecting everything auto-approves. But a tool internally declares policy: "deny" — does it still get blocked?
This is what omp's approval system solves: three layers, fail-closed defaults, mode ≠ "allow all".
Problem
A simple allow/deny list isn't enough. Reality is messier:
- Tool authors want to declare "this tool needs approval by default, but certain args don't" (e.g.,
write'sxd://device dispatch) - Users want to override specific tools (e.g.,
tools.approval.bash: allow) - Modes provide coarse tiers (always-ask/write/yolo), but can't replace fine-grained control
- Safety: unknown tools, malformed configs, missing fields — must default deny/prompt (fail-closed), never silently allow
omp's answer: three stacked layers, fixed priority, deny always wins.
Solution: Precise Three-Layer Division
Layer 1: Tool Declaration (getToolDecision, lines 80-98)
// lines 80-98
function getToolDecision(tool, args) {
const approval = tool.approval;
const decision = typeof approval === "function" ? approval(args) : approval;
return normalizeDecision(decision);
}
function normalizeDecision(value) {
if (isToolTier(value)) {
return { tier: value, override: false }; // simple tier: read/write/exec
}
if (isObject(value)) {
// object form: { tier, policy, override, reason, policyKey }
const tier = isToolTier(record.tier) ? record.tier : "exec"; // default exec!
const reason = record.reason;
const policy = normalizePolicy(record.policy); // allow/deny/prompt
const policyKey = record.policyKey;
return { tier, override: record.override === true, policy, reason, policyKey };
}
return { tier: "exec", override: false }; // default exec!
}
Key mappings:
| Declaration form | Parsed result |
|---|---|
String "read"/"write"/"exec" | { tier, override: false } |
Object { tier: "write", policy: "deny" } | { tier, policy, override, reason, policyKey } |
Function args => ({ tier: "write", policy: "allow" }) | Dynamic per args |
| Undeclared / malformed / non-object non-string | { tier: "exec", override: false } ← fail-closed |
Why default exec? exec is highest-risk tier (executes code, shell, browser, spawns agent). Any tool that doesn't declare "I'm safe" is assumed most dangerous. Safer than defaulting read and patching holes later.
Layer 2: User Override (lines 127-134)
const policyKey = decision.policyKey ?? tool.name;
const userPolicy = userConfig[policyKey] ? normalizePolicy(userConfig[policyKey]) : undefined;
const fallbackPolicy = (policyKey !== tool.name && !userPolicy && userConfig[tool.name])
? normalizePolicy(userConfig[tool.name]) : undefined;
const effectiveUserPolicy = userPolicy ?? fallbackPolicy;
policyKey: tool can customize "which user config key applies". Defaults to tool name;xd://dispatch usespolicyKey: "write"so user'stools.approval.writeworks- Fallback: if
policyKeyhas no user setting, fall back totools.approval.<tool.name> - Effective policy =
userPolicy(priority) orfallbackPolicy
Layer 3: Mode Tier Ceiling (modeApprovesTier, lines 100-102)
const APPROVAL_MODE_MAX_TIER: Record<ApprovalMode, ToolTier> = {
"always-ask": "read",
write: "write",
yolo: "exec",
};
function modeApprovesTier(mode, tier) {
return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
}
| Mode | Auto-allows tier | Prompts for tier |
|---|---|---|
always-ask | read | write, exec |
write | read, write | exec |
yolo | read, write, exec | (none) |
Note: mode only decides "which tiers auto-allow". Concrete deny/prompt still from layers 1-2.
Full Resolution Flow (resolveApproval, lines 120-219)
export function resolveApproval(tool, args, mode, userConfig) {
const decision = getToolDecision(tool, args); // Layer 1
const policyKey = decision.policyKey ?? tool.name;
const effectiveUserPolicy = ...; // Layer 2
// 1. Tool-side deny: highest priority, uncrossable
if (decision.policy === "deny") return { policy: "deny", source: "tool", ... };
// 2. User-side deny: second priority, uncrossable
if (effectiveUserPolicy === "deny") return { policy: "deny", source: "user", ... };
// 3. Yolo special handling
if (mode === "yolo") {
if (decision.policy) { // tool has policy (allow/deny/prompt)
return { policy: decision.policy, source: "tool", ... };
}
// tool has no policy → check user policy, else allow
return { policy: effectiveUserPolicy ?? "allow", source: effectiveUserPolicy ? "user" : "mode", ... };
}
// 4. Tool-side override=true: tool forces decision (but deny caught at step 1)
if (decision.override) {
return { policy: decision.policy === "allow" ? "allow" : "prompt", override: true, source: "tool", ... };
}
// 5. Tool-side allow/prompt: tool explicitly declared
if (decision.policy === "allow" || decision.policy === "prompt") {
return { policy: decision.policy, source: "tool", ... };
}
// 6. User-side allow/prompt: user override
if (effectiveUserPolicy) {
return { policy: effectiveUserPolicy, source: "user", ... };
}
// 7. Mode-based: only if above undecided
if (modeApprovesTier(mode, decision.tier)) {
return { policy: "allow", source: "mode" };
}
// 8. Default: prompt
return { policy: "prompt", source: "mode", ... };
}
Decision flowchart:
Tool declares deny ──► DENY (uncrossable)
│
├─ Tool declares allow/prompt/override ──► use that policy
│
└─ Tool has no policy
│
├─ User deny ──► DENY (uncrossable)
│
├─ User allow/prompt ──► use that policy
│
└─ User has no policy
│
├─ yolo mode ──► ALLOW (but tool-side policy still applies!)
│
└─ non-yolo ──► tier <= mode ceiling? → ALLOW : PROMPT
Key Design Details
1. policyKey: Same Tool Switches read/write
lsp, dap, computer, write(xd://) decide tier from args, use policyKey to hook user override:
// lsp/servers.ts LSP_READONLY_ACTIONS
approval(args) {
if (LSP_READONLY_ACTIONS.has(args.action)) return "read";
return { tier: "write", policyKey: "lsp" }; // uses tools.approval.lsp
}
Effect: user sets tools.approval.lsp: allow → all LSP actions (incl. write tier) allow; deny → all blocked. No per-action config needed.
2. Bash's Tokenized Approval (bash.ts lines 288-294)
function bashApprovalRuleMatches(command, rule) {
if (rule.approval === "allow") {
if (hasBashApprovalShellControl(command)) return false; // shell control syntax → no allow
return commandMatchesBashApprovalPattern(command, rule.match); // whole line match
}
// deny/prompt: per-segment match
return commandSegmentMatchesBashApprovalPattern(command, rule.match);
}
function commandSegmentMatchesBashApprovalPattern(command, pattern) {
const regex = bashApprovalPatternToRegExp(pattern);
if (regex.test(normalizedCommand)) return true;
return bashCommandSegments(command).some(segment => regex.test(segment));
}
Why allow can't cross && ;?
allow= "I vouch this entire line is safe". Butcd x && rm -rf /—cd xsafe, second part not. Ifallowonly matched first segment, malicious part slips through.- So
allowrequires whole-line match AND no shell control syntax (&&,||,|,&,;, newline, subshell). deny/prompt= "I see danger, I block". Per-segment check;cd x && rm -rf /→rm -rf /triggers deny → whole line blocked.
CRITICAL_BASH_PATTERNS (lines 172-217): hardcoded dangerous command regexes (rm -rf /, chmod -R 777 /, curl | bash, kill -9 1, shutdown, nc -e, etc.) — always trigger deny/prompt regardless of user patterns (checked before pattern rules).
3. formatApprovalDetails: Show Execution Details to User
// lines 267-288
export function formatApprovalPrompt(tool, args, reason) {
const lines = [`Allow tool: ${tool.name}`];
if (tool.name.startsWith("mcp__") && tool.approval === undefined) {
lines.push("Origin: MCP server tool");
}
if (reason) lines.push(`Reason: ${reason}`);
const details = tool.formatApprovalDetails?.(args);
if (details) lines.push(details); // truncated to 2000 chars
return lines.join("\n");
}
Each tool provides formatApprovalDetails(args) returning what to show (bash shows command, edit shows file path + diff). Default truncates at 2000 chars to avoid prompt bloat.
4. MCP Tools Default write; Unknown Custom Tools Default exec
- MCP server tools: declare
approval: "write"(read/write, no exec) - User custom tools without
approval:normalizeDecisionreturns{ tier: "exec" }→ fail-closed
5. checkpoint / rewind: Paired Sister Tools
// tools/index.ts createDefaultTools()
if (tool.name === "checkpoint") tool.approval = "read";
if (tool.name === "rewind") tool.approval = "read";
// registration: forced pairing
checkpoint writes session marker, rewind jumps back. Both must exist — using one alone breaks session tree consistency. Registration enforces pairing.
6. Subagent: Headless yolo, Parent task Is Only Auth Boundary
// task/spawn-policy.ts, task/index.ts
// subagent defaults headless yolo
// parent `task` approval = only auth boundary
// subagent's internal user `prompt` rejects call, doesn't silently allow
// tools.approval.eval not covered by bash.patterns; must set separately to block shell in eval
- Subagent runs isolated session, defaults
--yolo(headless) - Parent
tasktool approval = sole authorization; subagent's internal approvals don't silently escalate evaltool's internal shell needs separatetools.approval.eval, not covered bybash.patterns
Lessons Learned
- Three layers aren't redundant — tool declaration (semantics), user override (preference), mode (coarse tier ceiling) each own a concern; stacking completes the picture
- Fail-closed is the only safe default — undeclared =
exec, malformed =exec, unknown tool =exec. Better over-block than under-block - Deny always wins — tool-side deny, user-side deny can never be crossed by mode, override, yolo. This is the security baseline
policyKeysolves "same tool, different args, different approval" — no need to split into multiple tools; one tool decides tier + policyKey dynamically- Bash's
allowwhole-line match preventscd x && rm -rf /— shell control syntax check centralized inhasBashApprovalShellControl - yolo ≠ ignore all policies — tool-side
policy: deny|prompt, user-sidedeny|promptstill apply in yolo; onlyoverride: trueis ignored in yolo (doesn't force prompt)
References
packages/coding-agent/src/tools/approval.ts—resolveApproval(120-219),getToolDecision(80-98),modeApprovesTier(100-102),APPROVAL_MODE_MAX_TIER(37-41),formatApprovalPrompt(267-288),CRITICAL_BASH_PATTERNS(172-217)packages/coding-agent/src/tools/bash.ts—bashApprovalRuleMatches(288-294),commandSegmentMatchesBashApprovalPattern(276-282),bashCommandSegments(268-272),CRITICAL_BASH_PATTERNSpackages/coding-agent/src/tools/checkpoint.ts—CheckpointTool,RewindToolpaired registrationpackages/coding-agent/src/tools/task/spawn-policy.ts/task/index.ts— subagent headless yolo, parent auth boundarydocs/approval-mode.md— official approval mode docspackages/agent/src/types.ts—AgentTool.approval(834),ToolApproval(729-742),ToolTier(706)
Part of OMP Internals Deep Dive series, post 4. Previous: four compaction strategies. Next: bash tokenized approval details (forthcoming)
Loading...