import type { ToolDefinition, PermissionMode, PermissionLevel, PermissionRule, PermissionRestrictions, } from '../shared/index.ts' import type { PermissionRuleEntry } from '../shared/index.ts' import { matchBashRule, compileRule } from './permission-rules' import { loadPermissionConfig, nextMode, clampMode, normalizeRestrictions, ALL_MODES, } from './permission-config' import type { PermissionClassifier } from './permission-classifier' import { recordClassifierRuling } from './permission-audit' /** * A tool that only reads: it cannot modify a file or run anything. * * Named and shared because two modes now depend on the same judgement — `plan` * allows exactly these and nothing else, and `auto` lets them past the classifier. * Two hand-written copies of this list are how the two modes would come to disagree * about what "read-only" means, which is the shape of defect this file has already * been bitten by more than once. * * The `category === 'file'` half is load-bearing rather than redundant: it keeps a * future non-file tool that happens to be called `Read` out of the carve-out. */ function isReadOnlyTool(tool: ToolDefinition): boolean { return tool.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name) } /** * Check if a Bash command is a "verification-only" command that should be * auto-approved in acceptEdits mode. These are non-destructive read/check * operations that form the core of the vibe coding edit→test→fix loop. */ function isVerificationCommand(input: Record): boolean { const cmd = (input.command as string) || '' // A verification command must be a single simple command — any shell // metacharacter (&&, ;, |, >, <, backtick, $) means it can chain a destructive // action (e.g. `cat x && rm -rf ~`), so never auto-approve it. if (/[;&|><`$]/.test(cmd)) return false // Patterns for verification-only commands (no side effects on codebase) const verifyPatterns = [ /\bpnpm\s+test\b/, // test runner /\bpnpm\s+t\b/, // shorthand test /\bpnpm\s+typecheck\b/, // type checking /\bpnpm\s+lint\b/, // linting /\bpnpm\s+format:check\b/, // format check /\bnpm\s+test\b/, // npm test /\bnpm\s+run\s+test\b/, // npm run test /\bvitest\b/, // vitest runner /\bjest\b/, // jest runner /\btsc\s+(?!init)/, // TypeScript compiler (not tsc init) /\btsc\s+--noEmit\b/, // type check only /\beslint\b/, // eslint /\bprettier\s+--check\b/, // prettier check /\bpytest\b/, // python test runner /\bruff\s+check\b/, // python linter /\bcargo\s+test\b/, // rust test /\bcargo\s+check\b/, // rust check /\bgo\s+test\b/, // go test /\bgo\s+vet\b/, // go vet /\bmake\s+test\b/, // make test /\bgit\s+status\b/, // git status (read-only) /\bgit\s+diff\b/, // git diff (read-only) /\bgit\s+log\b/, // git log (read-only) /\bgit\s+branch\b/, // git branch (read-only) /\bls\b/, // list files /\bcat\b/, // read file /\bhead\b/, // read file start /\btail\b/, // read file end /\bwhich\b/, // find binary /\becho\b/, // print text /\bnode\s+-v\b/, // node version /\bpython\s+--version\b/, // python version /\bwhoami\b/, // current user /\bpwd\b/, // current directory ] return verifyPatterns.some((p) => p.test(cmd)) } /** * Which `PermissionLevel` spellings the constructor accepts as an actual * *mode*. Filtered from `ALL_MODES`, **not** `MODE_CYCLE`: `bypassPermissions` * is a legal destination even though it is not part of the Shift+Tab cycle, and * reading the cycle here would quietly demote it to the legacy-level fallback. */ const VALID_MODES: Set = new Set(ALL_MODES) /** * Legacy 3-level spellings, still honoured — and still **silent**, because they * are *mapped* rather than ignored: `'self'`/`'ask'` both meant "let each tool * self-decide" (→ `'default'`), `'bypass'` → `'bypassPermissions'`. * * They predate `PermissionMode` and stay accepted so an old `config.yml` keeps * working. They are also why `setDefaultLevel` cannot use `VALID_MODES` as its * only test: a legacy spelling and a mode name must both count as *recognized*, * while only a value that is neither gets a warning. */ const LEGACY_LEVEL_MODES: Record = { self: 'default', ask: 'default', bypass: 'bypassPermissions', } /** Human-readable mode list for warnings — derived, so no message can hold a stale copy. */ const MODE_LIST = ALL_MODES.join(', ') /** * Why a tool resolved to 'ask' — for rich denial errors (#52). * Mirrors the resolution chain order in `check()` (first match wins). */ export type PermissionDenialReason = | 'deny-rule' // org deny rule — overrides mode, cannot be switched away | 'ask-rule' // explicit ask rule — still requires approval under any mode | 'legacy-rule' // legacy exact-name rule (setRule) | 'mode-baseline' // mode-specific default (acceptEdits/plan) → ask | 'tool-default' // tool.permission === 'ask' | 'system-default' // no rule, no tool permission → fallback ask | 'classifier-deny' // `auto` mode's classifier ruled against the call /** * Which denial reasons `auto` mode's classifier is allowed to rule on — an * **allowlist**, not a denylist, and the direction is the whole point. * * `deny-rule` and `ask-rule` are absent deliberately: those are decisions a human * wrote down. Adding them here would silently turn the classifier into a universal * bypass of every org-level rule — the one thing the mode must never be. A reason * missing from this set therefore fails **closed** (the call stays `'ask'`), which * is why the set is spelled as the reasons that are *permitted*, not the ones that * are not. * * `legacy-rule` is absent for the same reason as the rules: it is an explicit * per-tool decision from `setRule()`. `classifier-deny` is absent because it is not * a *static* reason at all — `explainDenial()` never returns it. */ const CLASSIFIABLE: ReadonlySet = new Set([ 'mode-baseline', 'tool-default', 'system-default', ]) /** * What a tool call actually resolved to, after the classifier has had its say. * * `level` is what the caller acts on (`'ask'` ⇒ blocked). `source` records whether * the decision was the static chain's or the classifier's, so a caller can word the * denial correctly: telling a model "denied" when the classifier was merely * unreachable makes it abandon the task, while the honest reading is "this did not * run, a retry is appropriate". */ export interface ApprovalDecision { level: PermissionLevel source: 'static' | 'classifier' /** Why it is `'ask'`. Present on every denial, from either source. */ denialReason?: PermissionDenialReason /** The classifier's own one-line justification, when it ruled. */ classifierReason?: string /** * `true` ⇒ held back because the classifier could not be reached or its answer * could not be read — **not** a policy decision, and worth retrying. */ retryable?: boolean } export class PermissionSystem { private allowRules: PermissionRuleEntry[] = [] private denyRules: PermissionRuleEntry[] = [] private askRules: PermissionRuleEntry[] = [] /** Malformed `permissionRestrictions` entries from the last set/load — see below. */ private restrictionWarnings: string[] = [] /** Unrecognized `permission:` value from the last `setDefaultLevel` — third of the warning family. */ private levelWarnings: string[] = [] /** Legacy exact-name rules for backward compat (set via setRule with 'self' level). */ private legacyRules = new Map() /** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */ private legacyDefaultFallback: PermissionLevel | null = null private mode: PermissionMode = 'default' // ── Permission cache (P2) ── /** Short-lived cache: toolName+input → permissionLevel. Invalidated on rule/mode change. */ private checkCache = new Map() private cacheMode: PermissionMode | null = null /** * Cache for classifier rulings, same key as `checkCache`. Only **terminal** * rulings are stored — see `resolveApproval`. */ private classifierCache = new Map() /** * The `auto`-mode classifier, when one was handed in. Absent is a legal state * (every other mode ignores it, and `auto` without one fails closed), so nothing * here assumes it exists. */ private classifier: PermissionClassifier | undefined = undefined // ── Org-level restrictions (P0 security) ── private restrictions: PermissionRestrictions | undefined = undefined // ── P1-4: Consecutive block counter (prevents infinite retry loops) ── private consecutiveBlockCount = 0 /** * Shared by both tool loops — `Engine.executeTool` and the sub-agent's own * turn loop (which bypasses the engine and reimplements the permission step). * Public so the second reader cannot drift to its own number. */ static readonly MAX_CONSECUTIVE_BLOCKS = 3 /** Invalidate the permission cache (called on any rule/mode change). */ private invalidateCache(): void { this.checkCache.clear() this.classifierCache.clear() this.cacheMode = null } /** * Hand in the classifier that `auto` mode consults. Separating this from the * constructor keeps the permission system free of provider/registry imports: the * wiring site (the CLI entry, where the registry exists) builds the classifier and * attaches it here. `undefined` removes it, which makes `auto` refuse every gated * call again — fail-closed, not fail-open. * * The seam deliberately lives on the permission system rather than on the engine: * an engine-side setter would be a new engine capability that the daemon would * then have to match or be given a named exemption from * (`test/integrity/daemon-capability-parity.test.ts`). The cost of that choice is * stated where it matters: this guard therefore cannot see whether anyone ever * calls this, which is why the wiring has its own source-side assertion. */ setClassifier(classifier: PermissionClassifier | undefined): void { this.classifier = classifier this.invalidateCache() } /** Whether an `auto`-mode classifier is attached. For diagnostics, not decisions. */ hasClassifier(): boolean { return this.classifier !== undefined } constructor(modeOrLevel: PermissionLevel = 'default') { if (VALID_MODES.has(modeOrLevel)) { this.mode = modeOrLevel as PermissionMode } else { // Legacy values ('ask', 'bypass') → store as fallback, use 'default' mode this.mode = 'default' this.legacyDefaultFallback = modeOrLevel } } // ── Mode management ── setMode(mode: PermissionMode): void { this.mode = clampMode(mode, this.restrictions) this.invalidateCache() } getMode(): PermissionMode { return this.mode } cycleMode(): PermissionMode { this.mode = nextMode(this.mode, this.restrictions) return this.mode } // ── Restrictions (P0: org-level policy gap) ── /** * Apply org-level permission restrictions. Overwrites any previous restrictions. * * The value is **validated and normalized** first: a config typo used to leave the * whole policy silently inert (fail-open). Anything unrecognizable is now reported * via `getInvalidRestrictions()` and pins the mode to the strictest (`P1`). */ setRestrictions(raw: PermissionRestrictions | undefined): void { const { restrictions, invalid } = normalizeRestrictions(raw) this.restrictions = restrictions this.restrictionWarnings = invalid // Re-clamp current mode against new restrictions if (restrictions) { this.mode = clampMode(this.mode, restrictions) } this.invalidateCache() } /** * Malformed `permissionRestrictions` entries, one message each — the sibling of * `getInvalidRules()`. Callers are expected to surface these to stderr; a silent * return here means a policy the operator believes is enforced isn't. */ getInvalidRestrictions(): string[] { return this.restrictionWarnings } getRestrictions(): PermissionRestrictions | undefined { return this.restrictions } /** * P1-4 (v2.1.225 alignment): Increment the consecutive block counter * when a tool is denied. Safety-filter refusals should NOT count. * Returns true if the consecutive block limit has been exceeded. */ incrementBlockCounter(): boolean { this.consecutiveBlockCount++ return this.consecutiveBlockCount >= PermissionSystem.MAX_CONSECUTIVE_BLOCKS } /** Reset the block counter when a tool is successfully executed. */ resetBlockCounter(): void { this.consecutiveBlockCount = 0 } /** Get the current consecutive block count. */ getBlockCount(): number { return this.consecutiveBlockCount } /** * P0-3 (v2.1.223 alignment): Create a permission context for a sub-agent. * * The sub-agent's requested permissionMode is clamped against the parent's * org restrictions (maxAllowedMode, forbiddenModes). Deny rules from the * parent are propagated so org safety policies always apply. * * Returns a new PermissionSystem instance — NOT shared with the parent. */ createSubAgentPermission(agentPermissionMode: string): PermissionSystem { const resolvedMode = this.resolveAgentMode(agentPermissionMode) const subPerm = new PermissionSystem(resolvedMode) // Propagate org restrictions to sub-agent if (this.restrictions) { subPerm.setRestrictions(this.restrictions) } // Propagate deny rules (org safety policies must always apply) for (const denyEntry of this.denyRules) { subPerm.deny(denyEntry.pattern) } // The classifier travels with the `auto` mode, not with the agent: a sub-agent // gets it exactly when its *resolved* mode is `auto`, and not otherwise. That // makes the two natural ways in behave consistently — an agent that names `auto` // explicitly, and one that inherits from a parent already sitting in `auto` // (`resolveAgentMode` reads `inherit` as "the parent's mode"). Inheriting the // label without the engine would be the worst of both: a sub-agent pinned to a // mode whose only substance is a classifier it does not have, refusing every // gated call with a message about a mode that is working fine for its parent. // // No sub-agent lands here by default: the default mode is `default`, so this is // opt-in through the mode itself. What is *not* inherited is any allowance — // `resolvedMode` is already clamped against the org restrictions above, so an // org that caps the mode also removes the classifier. if (resolvedMode === 'auto' && this.classifier) { subPerm.setClassifier(this.classifier) } return subPerm } /** * Resolve an agent's permissionMode string to a clamped PermissionMode. * 'inherit' means "use the parent's current mode". * 'bypass' is treated as an alias for 'bypassPermissions'. */ private resolveAgentMode(agentMode: string): PermissionMode { // Normalize aliases const normalized = agentMode === 'bypass' ? 'bypassPermissions' : agentMode // Hand-written map, so a mode missing from it does not fail to compile: it // falls to the `|| 'default'` below and the agent silently runs narrower than // it asked for. `auto` therefore has to be added here *and* in // `agent/types.ts`'s union — the type does not force either. const modeMap: Record = { bypassPermissions: 'bypassPermissions', auto: 'auto', plan: 'plan', acceptEdits: 'acceptEdits', default: 'default', inherit: this.mode, } const desired: PermissionMode = modeMap[normalized] || 'default' return clampMode(desired, this.restrictions) } // ── Rule management ── allow(rule: string): void { this.allowRules.push(compileRule(rule, 'allow')) this.invalidateCache() } deny(rule: string): void { this.denyRules.push(compileRule(rule, 'deny')) this.invalidateCache() } ask(rule: string): void { this.askRules.push(compileRule(rule, 'ask')) this.invalidateCache() } loadConfig(raw: { mode?: string allow?: string[] deny?: string[] restrictions?: PermissionRestrictions }): void { const config = loadPermissionConfig( raw as Partial<{ mode: PermissionMode allow: string[] deny: string[] restrictions: PermissionRestrictions }>, ) // Same validation as setRestrictions — a config typo must not silently drop the cap if (config.restrictions) { const { restrictions, invalid } = normalizeRestrictions(config.restrictions) this.restrictions = restrictions this.restrictionWarnings = invalid } this.mode = clampMode(config.mode, this.restrictions) this.allowRules = [] this.denyRules = [] this.askRules = [] for (const rule of config.allow) { this.allowRules.push(compileRule(rule, 'allow')) } for (const rule of config.deny) { this.denyRules.push(compileRule(rule, 'deny')) } this.invalidateCache() } // ── Permission check ── /** * Resolution chain (first match wins): * 1. Deny rules → block * 2. Ask rules → require approval * 3. Allow rules → permit * 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', 'self')) * 5. Mode baseline → mode-specific default (overrides tool.permission for explicit modes) * 6. Tool's own permission → tool-specific default (backward compat) * 7. Legacy constructor fallback (when constructed with 'ask'/'bypass') * 8. System default → 'ask' */ check(tool: ToolDefinition, input: Record): PermissionLevel { // ── Guard: reject undefined/null tool (defense-in-depth) ── if (!tool) { return 'ask' // absent tool → safest default } // ── Cache lookup (P2): reuse decision for same tool+mode+input ── const cacheKey = this.cacheKey(tool, input) if (this.cacheMode === this.mode) { const cached = this.checkCache.get(cacheKey) if (cached !== undefined) return cached } else { // Mode changed — invalidate entire cache this.checkCache.clear() this.cacheMode = this.mode } // 1. Check deny rules (always win) for (const rule of this.denyRules) { if (this.ruleMatches(rule, tool, input)) { const result: PermissionLevel = 'ask' this.checkCache.set(cacheKey, result) return result } } // 2. Check ask rules for (const rule of this.askRules) { if (this.ruleMatches(rule, tool, input)) { const result: PermissionLevel = 'ask' this.checkCache.set(cacheKey, result) return result } } // 3. Check allow rules — but an org ceiling still applies (see allowRuleDecision) for (const rule of this.allowRules) { if (this.ruleMatches(rule, tool, input)) { const result = this.allowRuleDecision(tool, input) this.checkCache.set(cacheKey, result) return result } } // 4. Legacy exact-name rules (backward compat) const legacyLevel = this.legacyRules.get(tool.name) if (legacyLevel !== undefined) { this.checkCache.set(cacheKey, legacyLevel) return legacyLevel } // 5. Mode baseline const baseline = this.modeBaseline(tool, input) if (baseline !== 'mode-baseline') { this.checkCache.set(cacheKey, baseline) return baseline } // 6. Tool's own permission level (backward compat fallback) if (tool.permission) { this.checkCache.set(cacheKey, tool.permission) return tool.permission } // 7. Legacy constructor fallback if (this.legacyDefaultFallback) { this.checkCache.set(cacheKey, this.legacyDefaultFallback) return this.legacyDefaultFallback } // 8. System default const result: PermissionLevel = 'ask' this.checkCache.set(cacheKey, result) return result } needsApproval(tool: ToolDefinition, input: Record): boolean { return this.check(tool, input) === 'ask' } isBypassed(tool: ToolDefinition, input: Record): boolean { return this.check(tool, input) === 'bypass' } /** * Explain WHY a tool resolves to 'ask' (rich denial errors — #52). * * Only call after `check()` returns 'ask'; mirrors its resolution order * (deny → ask → legacy → mode → tool → system). Returns the matched * rule pattern when the denial came from a deny/ask rule. */ explainDenial( tool: ToolDefinition, input: Record, ): { reason: PermissionDenialReason; rulePattern?: string } { for (const rule of this.denyRules) { if (this.ruleMatches(rule, tool, input)) { return { reason: 'deny-rule', rulePattern: rule.pattern } } } for (const rule of this.askRules) { if (this.ruleMatches(rule, tool, input)) { return { reason: 'ask-rule', rulePattern: rule.pattern } } } if (this.legacyRules.has(tool.name)) { return { reason: 'legacy-rule' } } if (this.modeBaseline(tool, input) !== 'mode-baseline') { return { reason: 'mode-baseline' } } if (tool.permission) { return { reason: 'tool-default' } } return { reason: 'system-default' } } /** * Same key both caches use. Extracted rather than written twice: two copies of a * cache key would drift, and a key that drifts is a cache that answers for the * wrong call. */ private cacheKey(tool: ToolDefinition, input: Record): string { return tool.name + '|' + JSON.stringify(input, Object.keys(input).sort()) } /** * Resolve a call to a decision, consulting `auto` mode's classifier when — and * only when — the static chain answered `'ask'` for a reason a classifier is * allowed to rule on. * * **The step order below is the security contract, not an implementation * detail.** Each numbered step exists to close a specific way this could go * wrong, and reordering them is how the mode would become a bypass: * * 1. `check()` first, untouched. Everything it decides *without* asking — * `bypassPermissions`, `acceptEdits`, `plan`, allow rules, tool defaults that * are not `'ask'` — is returned verbatim. This is the compatibility guarantee: * non-`'ask'` decisions are byte-for-byte what they were before this method * existed, and the classifier is never even consulted for them. * 2. Only `'ask'` continues, and only for a reason in `CLASSIFIABLE`. A denial * caused by a deny rule, an ask rule, or a legacy exact-name rule stops here * and stays denied. Without this step the classifier would be a universal * bypass of every rule a human wrote. * 3. `auto` without a classifier stops here too, still `'ask'` — fail-closed. * 4. A ruling of "allow" is **not** returned as `'bypass'`. It is re-derived * through `allowRuleDecision()`, the same ceiling-aware path an allow *rule* * takes, so the classifier can never grant more than a rule could and an org's * `maxAllowedMode` caps it automatically. * * A refusal is always `'ask'` — never a new kind of denial. The classifier may * only ever turn a blocked call into a running one; it cannot manufacture a * denial the static chain did not already produce. Read the other way round: it * can only *narrow* what runs, never widen the gate. * * Caching: rulings are cached on the same key as `check()`, but a ruling that came * from an engine failure is **not** cached. Its own verdict says a retry is * appropriate (`retryable`), and a cache would make that false by replaying the * failure without asking anyone. */ async resolveApproval( tool: ToolDefinition, input: Record, opts: { signal?: AbortSignal } = {}, ): Promise { // 1. The static chain decides everything it can decide without asking. const level = this.check(tool, input) if (level !== 'ask') return { level, source: 'static' } // 2. Why it is 'ask' — and may a classifier rule on that reason at all? const { reason } = this.explainDenial(tool, input) if (!CLASSIFIABLE.has(reason)) return { level: 'ask', source: 'static', denialReason: reason } // 3. Only `auto` consults a classifier, and only if one was handed in. if (!this.classifier || this.mode !== 'auto') { return { level: 'ask', source: 'static', denialReason: reason } } const key = this.cacheKey(tool, input) const cached = this.classifierCache.get(key) if (cached) return cached const verdict = await this.classifier.classify({ tool: tool.name, input, mode: this.mode, reason, signal: opts.signal, }) if (verdict.allow) { // 4. An allow is re-derived through the rule path, so the org ceiling applies. const decision: ApprovalDecision = { level: this.allowRuleDecision(tool, input), source: 'classifier', classifierReason: verdict.reason, } // Only cache a ruling that actually let the call through, or one the // classifier refused on policy. (`allowRuleDecision` can still answer 'ask' // under a ceiling — that is a terminal answer too, so it caches.) this.classifierCache.set(key, decision) return this.ruled(tool, decision, 'allow') } const decision: ApprovalDecision = { level: 'ask', source: 'classifier', denialReason: 'classifier-deny', classifierReason: verdict.reason, retryable: verdict.retryable, } // A retryable failure is a statement that asking again is appropriate; caching // it would contradict the field we just set. if (!verdict.retryable) this.classifierCache.set(key, decision) return this.ruled(tool, decision, 'deny') } /** * 记一条裁决,再把**同一个对象**交回去:放行那一支此前是**无声**的,而无人值守的 * 子代理 + 无声放行是最坏的组合(`permission-audit.ts` 文件头有完整的来龙去脉)。 * * 这个私有方法的存在方式就是那条不变量 —— 返回 `source: 'classifier'` 与落一条台账 * 在代码上**分不开**:两处都在这里出口,将来加第三条路也必须过这里。 * * **缓存命中不在此列**(`resolveApproval` 在调用分类器之前就返回了):那时分类器 * 根本没被咨询,写一行等于声称有一个没人做过的裁决。 */ private ruled( tool: ToolDefinition, decision: ApprovalDecision, verdict: 'allow' | 'deny', ): ApprovalDecision { recordClassifierRuling({ mode: this.mode, tool: tool.name, verdict, level: decision.level, reason: decision.classifierReason, retryable: decision.retryable, denialReason: decision.denialReason, }) return decision } // ── Helpers ── private ruleMatches( rule: PermissionRuleEntry, tool: ToolDefinition, input: Record, ): boolean { // Try Bash-style matching first. // // The compound-command rule differs by direction, and the rule's own // `level` is what decides it: a **deny/ask** rule matches if *any* part of // a compound command matches (wide on purpose), while an **allow** rule // matches only if *every* part does. Without that, `Bash(git:*)` grants // `git status && rm -rf ./src` outright — see matchBashRule's `segmentMode`. if (rule.pattern.includes('(')) { return matchBashRule(rule.pattern, tool.name, input, rule.level === 'allow' ? 'all' : 'any') } // Simple tool name match return rule.pattern === tool.name || rule.compiled.test(tool.name) } /** * An allow rule matched — what does it actually grant? * * Without an org ceiling it grants `'bypass'` (unchanged behavior). With * `maxAllowedMode` set, the rule may only grant what **the ceiling's own * baseline** would grant: the ceiling is an upper bound on permissiveness, and * a rule is a *source of permission* — letting it jump over the ceiling is the * same defect one layer in. So `allow: ['Bash']` under a ceiling of * `acceptEdits` still permits verification-only Bash, while `git push` falls * back to approval. * * Decided here, at check time, rather than inside `allow()`: `setRestrictions` * may land **after** the rules are registered (`loadConfig`, sub-agents handed * the same restrictions), and a rule registered before the ceiling would * otherwise keep its old meaning. * * Only `maxAllowedMode` gates this. `forbiddenModes` is about *which mode you * may sit in*, not about what a rule may grant, so a `forbiddenModes`-only * config behaves exactly as before. * * The `'mode-baseline'` sentinel (mode `default`) resolves to `tool.permission` * and stops there — deliberately not continuing to the legacy fallback of * `check()` step 7. That fallback can only ever be *wider* than `'ask'`, and a * ceiling must not hand out more than the un-restricted chain would. */ private allowRuleDecision(tool: ToolDefinition, input: Record): PermissionLevel { const cap = this.restrictions?.maxAllowedMode if (!cap) return 'bypass' const baseline = this.modeBaseline(tool, input, cap) const level = baseline === 'mode-baseline' ? (tool.permission ?? 'ask') : baseline return level === 'ask' ? 'ask' : 'bypass' } private modeBaseline( tool: ToolDefinition, input?: Record, mode: PermissionMode = this.mode, ): PermissionLevel | 'mode-baseline' { switch (mode) { case 'default': // Delegate to tool.permission (backward compat) return 'mode-baseline' case 'acceptEdits': // Reads + file edits free; Bash auto-approved for verification commands if (tool.category === 'file' && tool.name !== 'Bash') { return 'bypass' } if (tool.name === 'Bash') { // Vibe coding fix: auto-approve verification commands // so the edit→test→fix loop isn't interrupted by permission prompts if (input && isVerificationCommand(input)) { return 'bypass' } return 'ask' } return 'ask' case 'plan': // Only reads, no writes or executes return isReadOnlyTool(tool) ? 'bypass' : 'ask' case 'auto': // Reads stay free; everything else is handed to the classifier. // // The tempting one-liner is `return 'ask'` — every call ruled on, which is // what a mode table reading `auto → classify` suggests. Measured against the // actual registry, that one-liner is a broken mode: 20 of the 31 tools // declare `permission: 'self'`, and the list includes **Read, Grep and // Glob**. Gating those makes `auto` the only mode in the ladder that cannot // read a file without an LLM round-trip — every other mode (including // `plan`) allows reads unconditionally — and when the classifier is // unreachable, fail-closed means the agent cannot even read. A gate that // fails catastrophically on the most benign operation is not a conservative // gate; it is a broken one. // // So reads are carved out using `plan`'s own definition of read-only rather // than a second list, and everything else — `Bash`, `Write`, `Edit`, and the // `self`-declared tools that can reach outside this machine (`Git`, // `WebFetch`, `CronCreate`, `Task`, `Memory`, …) — reaches // `resolveApproval`. That is the half the classifier is actually needed for, // and leaving them to auto-approve would be the fail-open version of this // mistake. // // Returning the sentinel `'mode-baseline'` instead would hand those tools to // step 6 of `check()`, i.e. `tool.permission` — `'self'` for all 20, so they // would auto-approve and the classifier would never see them. (20 is counted // from `createToolRegistry()`, not from grep: the literal `permission: 'self'` // also appears in prose comments.) // // This does not contradict "the classifier may only allow, never deny": the // baseline is `'ask'` (what an un-configured Mipham already answers), and a // classifier refusal merely *keeps* that `'ask'`. return isReadOnlyTool(tool) ? 'bypass' : 'ask' case 'bypassPermissions': return 'bypass' default: return 'mode-baseline' } } // ── Legacy compatibility ── /** * Set the default mode from a `permission:` config value. Accepts **both** the * legacy 3-level spellings and any real mode name. * * This used to read `newMode = level === 'bypass' ? 'bypassPermissions' : * 'default'` — i.e. it honoured exactly one string and sent everything else to * `'default'`. Every mode name a user could write in `config.yml` therefore * landed on `default` **silently**: `permission: plan` became a mode that * auto-approves every tool declaring `permission: 'self'` (git, task, * web-fetch, cron, memory, …), so the user believes they narrowed the gate * while it moved the other way; `permission: bypassPermissions` and * `permission: auto` did not do what they say either. No warning, no error, * no way to tell — the same fail-open shape `normalizeRestrictions` was written * to fix, arriving through a different door. Hence the same remedy: honour what * is recognized, pin a safe fallback for what is not, and **say so** through * `getInvalidPermissionMode()`. * * `VALID_MODES` is the discriminator, deliberately the same one the constructor * uses — so a `permission:` value and a `new PermissionSystem(...)` argument * cannot drift apart in which spellings they accept. * * The fallback for an unrecognized value stays `'default'`: the caller asked for * a mode we cannot name, and `default` is the only mode that is not *wider* than * a well-formed request (`plan` is narrower; the rest are comparable or wider). * The org restrictions are applied last, so a clamped mode is what actually lands * — `getMode()` reports the clamped value, never the requested one. */ setDefaultLevel(level: PermissionLevel | PermissionMode): void { const mode: PermissionMode | undefined = VALID_MODES.has(level) ? (level as PermissionMode) : LEGACY_LEVEL_MODES[level] // Only a value that is neither a mode name nor a legacy spelling warns. The // legacy ones are mapped, not dropped, so they have nothing to report. this.levelWarnings = mode ? [] : [ `permission "${String(level)}" is not a permission mode; valid: ${MODE_LIST} (legacy spellings also accepted: ${Object.keys(LEGACY_LEVEL_MODES).join(', ')}). Using "default".`, ] this.mode = clampMode(mode ?? 'default', this.restrictions) this.invalidateCache() } getDefaultLevel(): PermissionLevel { // Legacy constructor fallback takes priority if (this.legacyDefaultFallback) return this.legacyDefaultFallback if (this.mode === 'bypassPermissions') return 'bypass' if (this.mode === 'plan') return 'ask' return 'self' } /** * Unrecognized `permission:` values from the last `setDefaultLevel`, one message * each — third member of the warning family beside `getInvalidRules()` and * `getInvalidRestrictions()`. Callers surface all three to stderr; a silent * return here means the gate is not where the user's config says it is. */ getInvalidPermissionMode(): string[] { return this.levelWarnings } setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void { if (typeof toolNameOrRule === 'string') { const toolName = toolNameOrRule // Remove old entries for this tool this.removeRuleFromArrays(toolName) if (level !== undefined) { this.legacyRules.set(toolName, level) // Also sync to new-style arrays for listRules / new API consistency if (level === 'bypass') this.allow(toolName) else if (level === 'ask') this.ask(toolName) // 'self' is stored only in legacyRules (returns 'self', not 'bypass') } } else { const rule = toolNameOrRule if (rule.pattern) { const entry = compileRule(rule.pattern, rule.level === 'bypass' ? 'allow' : 'ask') if (rule.level === 'bypass') this.allowRules.push(entry) else this.askRules.push(entry) } else { this.removeRuleFromArrays(rule.toolName) this.legacyRules.set(rule.toolName, rule.level) if (rule.level === 'bypass') this.allow(rule.toolName) else if (rule.level === 'ask') this.ask(rule.toolName) } } this.invalidateCache() } /** * Report any rule whose pattern is structurally invalid and can therefore * never match (e.g. `Bash(ls) x`, `Read(foo`, `Bash()`). These are silently * dead today — callers should surface them as invalid settings rather than * let a deny rule fail closed without the user noticing. */ getInvalidRules(): string[] { const invalid: string[] = [] for (const entry of [...this.allowRules, ...this.denyRules, ...this.askRules]) { if (entry.invalid) { invalid.push( `permission rule "${entry.pattern}" is invalid (${entry.invalid}) and will never match`, ) } } return invalid } removeRule(toolName: string): void { this.legacyRules.delete(toolName) this.removeRuleFromArrays(toolName) this.invalidateCache() } private removeRuleFromArrays(toolName: string): void { this.allowRules = this.allowRules.filter((r) => r.pattern !== toolName) this.denyRules = this.denyRules.filter((r) => r.pattern !== toolName) this.askRules = this.askRules.filter((r) => r.pattern !== toolName) } listRules(): Map { const map = new Map(this.legacyRules) for (const r of this.allowRules) { if (!map.has(r.pattern)) map.set(r.pattern, 'bypass') } for (const r of this.denyRules) { if (!map.has(r.pattern)) map.set(r.pattern, 'ask') } for (const r of this.askRules) { if (!map.has(r.pattern)) map.set(r.pattern, 'ask') } return map } getByCategory( tools: Map, category: string, ): Array<{ name: string; level: PermissionLevel }> { const result: Array<{ name: string; level: PermissionLevel }> = [] for (const [name, tool] of tools) { if (tool.category === category) { result.push({ name, level: this.check(tool, {}) }) } } return result } }