28 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 452 | Bash permission gates silently fail after model changes, denial events, or session compaction git add/commit/push/gh pr create bypass all rules |
Make the bash permission gate fail closed instead of silently allowing
Release Recommendation
Release: ship independently
This issue is not part of any architecture-roadmap batch (no (#452) reference in docs/architecture/architecture.md, no Release batches subsection).
It is a self-contained security hardening fix and ships on its own.
Problem Statement
A third-party reporter (k0valik) observed that the bash permission gate intermittently stops intercepting git add / git commit / git push / gh pr create, letting them run with no review-log entry at all, despite an explicit "git *": "ask" rule.
The reported triggers — a rapid model_change cascade, a user denial, and session compaction — are correlated from production logs but are not locally reproducible, and the reporter's own five ranked root-cause theories are speculative.
The investigation (see Background) found that the report bundles several distinct concerns.
Rather than chase an unreproducible trigger, this plan fixes the confirmable defect class: the gate is fail-open in several places, which contradicts the package's own stated invariant ("Default to least privilege — when in doubt, prompt (ask), do not silently allow").
Once the gate fails closed and records every error, the worst case for any present or future bug becomes a visible ask/block — never an invisible allow — and the one mechanism I could not reproduce becomes diagnosable on recurrence.
Goals
- Make
PermissionGateHandler.handleToolCallfail closed: any thrown error blocks the tool and writes a review-log entry, rather than letting the SDK pass the command ungated. - Make the bash tool gate fail closed when a non-empty command parses to zero command units: default to
askinstead of resolving the opaque whole-command string (which letscd X && git pushride a permissive top-level*). - Make the tree-sitter parser resilient: a transient init failure must not poison the parser for the process lifetime (no cached rejected promise).
- Surface the config footgun: emit a non-fatal config warning when a permissive top-level
*: allowis set with nobash*policy, so bash silently inheritsallow. - Make the boundary structurally fail-closed: register a single
tool_calladapter that is the only SDK-facing entry point, owns thetry/catch → block, and is the only place an internalGateOutcomeis translated to the SDK result shape — so "we didn't decide" can never silently mean "allow." - Make every tool call traceable: guarantee exactly one terminal decision per call, add a
debugLog-gated per-call trace and asession_shutdowndecision summary, so an evaluated-and-allowed call is distinguishable from a never-evaluated one without hand-reconciling logs. - Add totality tests: a metamorphic property (wrapping any
ask/denycommand incd X && …must not weaken the decision) and a boundary contract test (a throwing handler must block), to catch the fail-open class in development rather than production. - This change is breaking (more restrictive): commands that previously passed silently on the error path or via the empty-parse fallback will now block or prompt.
Use
fix!:with aBREAKING CHANGE:footer on the behavior-changing commits.
Non-Goals
- Reproducing or directly fixing the specific
model_change-cascade / denial / compaction triggers — they are addressed indirectly by making the gate fail closed and observable, not by a targeted mechanism fix. - The
git-vs-rmasymmetry (somegitcommands bypass whilermstays gated in the same period). I could not reconcile this from the source; it is documented as diagnosable-on-recurrence (the new review-log entries will pinpoint it) and deferred to a follow-up issue only if it recurs with new logs. - The reporter's suggested unconditional
console.loginstrumentation on everyhandleToolCall— A5 instead adds adebugLog-gated per-call trace plus asession_shutdownsummary, so the trace is available on demand without per-call spam in normal operation. - Full cross-artifact reconciliation against Pi's session JSONL — A5's in-process counters are the cheaper first tier; reading Pi's session file is a deferred follow-up (see Open Questions).
- Any change to the
/permission-systemcommand, the config schema (no new field), or the merge precedence model.
Background
Relevant modules and the verified findings behind each fix:
src/handlers/permission-gate-handler.ts—handleToolCallhas no try/catch. The SDK's dispatcher (@earendil-works/pi-coding-agentdist/core/extensions/runner.js,emitToolCall) callsawait handler(event, ctx)with no try/catch — unlikeemitUserBashdirectly below it, which catches and continues. So a thrownhandleToolCallproduces no{ block: true }result and the command is not blocked, with nothing logged. This is the keystone defect (A1): it converts every other latent error into a silent, trace-less bypass.src/handlers/gates/bash-program.ts—parserPromise ??= initParser()caches a rejected promise forever ifinitParser()ever rejects. Every latergetParser()re-throws, and via A1 that is a permanent silent bypass.config.loaded(emitted fromConfigStore.refresh()) re-reads config without re-running the factory module, so a reload looks like recovery but does not clear the module-scoped promise — matching "once broken, stays broken until process restart" (A2).src/handlers/gates/bash-command.ts—resolveBashCommandCheckfalls back to resolving the whole command string whenBashProgram.parseyields zero command units.cd /repo && git pushmatches nogit *rule and falls through to a top-level*: allow, producing a silent pass (A3). When parse succeeds, the chain is split into[cd /repo, git push]andpickMostRestrictivecorrectly returnsask, so this bypass is only reachable through the empty-parse path.src/config-loader.ts— config issues collected duringloadAndMergeConfigsflow throughmergeResult.issuesand are surfaced byConfigStore.refresh()viactx.ui.notify(warning, "warning")and theconfig.loadeddebug entry. The shippedconfig/config.example.jsonsetsbash.*: askand is safe; a config with a permissive top-level*: allowand nobash*removes the net that would otherwise catch A3 (A4).
Constraints from AGENTS.md / package skill that apply:
- "Default to least privilege — when in doubt, prompt (
ask), do not silently allow." — this fix aligns the code with that invariant. - "When removing a config field, keep the loader tolerant" — N/A; no field is removed, A4 only adds a derived warning.
- Keep schema, example config,
docs/configuration.md,README.md, and SKILL aligned.
Theories ruled out (do not plan around these)
- "Handler deregistration after model changes" — contradicted by the reporter's own data:
rm/nodestay gated during bypass, so the handler is firing. - "Tree-sitter concurrent corruption mid-parse" — implausible: JS is single-threaded and
parser.parse()is synchronous with no interleavingawait. Only a transient init failure (A2) is real. - "A denial permanently poisons handler state" — no code path mutates shared/module state on denial; denial returns
{ block: true }cleanly.
Design Overview
Five defense-in-depth changes plus totality tests. A1 is the structural keystone — it closes the whole fail-open class at one boundary; A2–A4 fix the specific defects that boundary would otherwise have to absorb; A5 makes the now-guaranteed decision observable. Each is individually correct; together they guarantee no silent allow.
A1 — Fail-closed boundary adapter (single chokepoint)
The fail-open holes exist because "allow" is the implicit default at five different exits: the pipeline's trailing return { action: "allow" }, GateRunner.run's null/bypass allow, the handler's {} return, applyPermissionGate's fall-through, and — critically — a thrown handler, which the SDK does not convert to a block.
Rather than patch each exit, close the class at one boundary.
Introduce a single SDK-facing adapter that is the only function registered for pi.on("tool_call").
It is the sole place an internal decision is translated to the SDK result shape, and it owns the try/catch → block:
// src/handlers/tool-call-boundary.ts
/** The only tool_call handler the SDK sees. Guarantees fail-closed: a thrown
* gate becomes a Block, and the internal GateOutcome → SDK-shape translation
* happens here and nowhere else. */
export function createFailClosedToolCall(
gate: (event: unknown, ctx: ExtensionContext) => Promise<GateOutcome>,
reporter: DecisionReporter,
audit: DecisionAudit,
): (event: unknown, ctx: ExtensionContext) => Promise<{ block?: true; reason?: string }> {
return async (event, ctx) => {
try {
const outcome = await gate(event, ctx);
audit.recordDecision(outcome.action);
return outcome.action === "block" ? { block: true, reason: outcome.reason } : {};
} catch (error) {
audit.recordError();
reporter.writeReviewLog("permission_request.blocked", {
toolName: bestEffortToolName(event),
command: bestEffortCommand(event),
resolution: "gate_error",
error: error instanceof Error ? error.message : String(error),
});
return { block: true, reason: formatGateErrorReason(error) };
}
};
}
Correspondingly, PermissionGateHandler.handleToolCall changes its return type from the loose SDK shape ({ block?: true; reason? }) to the internal total type GateOutcome ({ action: "allow" } | { action: "block"; reason }, already defined in handlers/gates/types.ts).
Its validation-block path returns { action: "block", reason } instead of { block: true, reason }.
The domain handler is now SDK-shape-free, and the reporter (plus the new audit) dependency lives on the boundary, not the handler — so the handler constructor does not widen.
Use the DecisionReporter interface type, not the concrete GateDecisionReporter (DIP / narrow-interface rule).
index.ts registers pi.on("tool_call", createFailClosedToolCall((e, c) => gates.handleToolCall(e, c), reporter, audit)).
The catch helpers (bestEffortToolName, bestEffortCommand, formatGateErrorReason) read from the raw event defensively and never throw, so a failure inside session.activate still logs and blocks.
Fail-closed choice = block (not ask) for an unexpected exception: the command may be unknown and the prompt infrastructure itself may be what threw — block is the unambiguous safe outcome for an internal error.
A2 — Resilient parser init
Extract a small, pure, unit-testable helper and use it for the parser cache:
// src/async-cache.ts
/** Memoize an async factory, but drop a rejected result so the next call retries. */
export function memoizeAsyncWithRetry<T>(factory: () => Promise<T>): () => Promise<T> {
let cached: Promise<T> | null = null;
return () => {
cached ??= factory().catch((error) => {
cached = null; // poisoned result cleared → next call re-attempts
throw error;
});
return cached;
};
}
bash-program.ts replaces the module-scoped parserPromise + getParser with const getParser = memoizeAsyncWithRetry(initParser).
On success the behavior is identical (single shared parser); on a transient init failure the next tool call retries instead of inheriting a permanently rejected promise.
A parser-init failure no longer needs a dedicated health signal: the throw from getParser() propagates to the A1 boundary, which records it as a gate_error review-log entry — so the failure is visible (and the tool blocked) for free.
This is why a separate "parser health" mechanism is intentionally not added.
A3 — Fail-closed empty-parse fallback
In resolveBashCommandCheck, when commands is empty:
- If
commandis empty, whitespace-only, or comment-only → resolve the whole string as before (genuinely nothing to gate). - Otherwise (a non-empty command that parsed to zero command units — a parse anomaly or an opaque program) → return a synthetic
askresult, fail closed.
if (commands.length === 0) {
if (isTriviallyEmptyCommand(command)) {
return resolver.resolve("bash", { command }, agentName);
}
return {
state: "ask",
toolName: "bash",
source: "bash",
origin: "builtin",
command,
matchedPattern: "<unparseable-bash-command>",
} satisfies PermissionCheckResult;
}
The sentinel matchedPattern makes the path visible in the review log when the gate runs — directly addressing the "no trace" complaint — without injecting a logger into this pure function.
The non-empty chain path (the commands.map(...) → pickMostRestrictive branch, #301 / #306) is unchanged.
A4 — Config footgun warning
Add a pure detector run against the merged permission map (where the final composed top-level * and bash surface are both known):
// returns one issue string, or undefined
export function detectPermissiveBashFallback(
permission: FlatPermissionConfig | undefined,
): string | undefined;
It warns when permission["*"] === "allow" (or a deny-with-reason that resolves to allow — not applicable, so a plain "allow" check suffices) and the bash surface either is absent or is an object with no "*" key.
A bash value that is the bare string "allow"/"ask"/"deny" (shorthand for { "*": … }) counts as having an explicit bash * and does not warn.
Call it in loadAndMergeConfigs after the merge and push its result onto allIssues, so it rides the existing mergeResult.issues → ctx.ui.notify path.
Consumer call-site sketch (A4 wiring)
// config-loader.ts, end of loadAndMergeConfigs, after `merged` is final
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
return { merged, issues: allIssues };
No reach-through: the detector takes the plain map and returns a string; loadAndMergeConfigs owns the push.
A5 — Decision-per-call trace and shutdown summary
The reporter could not distinguish "evaluated and allowed" from "never evaluated" because the allow path writes nothing, and the user-facing review log intentionally stays quiet on allow (noise control). With A1 the boundary now produces exactly one terminal decision per call; make that decision observable without flooding the review log.
A DecisionAudit collaborator (owned by the boundary) holds per-session counters: toolCalls, allowed, blocked, errors.
// src/decision-audit.ts
export class DecisionAudit {
recordDecision(action: "allow" | "block"): void; // also bumps toolCalls
recordError(): void; // also bumps toolCalls
writeSummary(logger: PermissionSystemLogger): void;
}
- When
debugLogis enabled, the boundary writes one compactpermission.decisiondebug entry per call (tool, action, matched pattern) — a full trace on demand, off by default. - On
session_shutdown(already hooked bySessionLifecycleHandler),writeSummaryemits onepermission.session_summarydebug line with the counters.toolCalls !== allowed + blocked + errorsis an invariant violation logged at warning level — a cheap structural self-check that flags any future regression that re-opens a silent path.
This is in-process self-audit. Full reconciliation against Pi's own session JSONL (the cross-artifact check the reporter did by hand) needs to read Pi's session file and is a deferred follow-up (see Open Questions).
Module-Level Changes
src/handlers/tool-call-boundary.ts— new module:createFailClosedToolCall(gate, reporter, audit)(the solepi.on("tool_call")target) plus the defensivebestEffortToolName/bestEffortCommand/formatGateErrorReasonhelpers.src/decision-audit.ts— new module:DecisionAudit(counters +recordDecision/recordError/writeSummary).src/handlers/permission-gate-handler.ts— changehandleToolCall's return type from{ block?: true; reason? }to the internal totalGateOutcome; the validation-block path returns{ action: "block", reason }. No constructor change (the reporter lives on the boundary).src/handlers/lifecycle.ts—SessionLifecycleHandlergains theDecisionAudit(injected) and callsaudit.writeSummary(logger)inhandleSessionShutdown.src/index.ts— constructDecisionAudit; registerpi.on("tool_call", createFailClosedToolCall((e, c) => gates.handleToolCall(e, c), reporter, audit))instead of the bare handler; passauditintoSessionLifecycleHandler.src/async-cache.ts— new module exportingmemoizeAsyncWithRetry.src/handlers/gates/bash-program.ts— replaceparserPromise+getParser()withmemoizeAsyncWithRetry(initParser); remove the now-dead module-scopedlet parserPromise.src/handlers/gates/bash-command.ts— add the empty-commands fail-closed branch and theisTriviallyEmptyCommandhelper.src/config-loader.ts— add and exportdetectPermissiveBashFallback; call it inloadAndMergeConfigs.docs/configuration.md— document the new fail-closed behavior (gate errors block, unparseable bash commands prompt) and the recommendation to setbash.*explicitly; describe the new config warning.README.md— if it summarizes gate behavior or config recommendations, add thebash.*note (grep first; update only if present)..pi/skills/package-pi-permission-system/SKILL.md— add a short note under Debugging that the gate now fails closed and emits agate_errorreview entry, and that an unparseable bash command resolves toask(<unparseable-bash-command>sentinel).config/config.example.json— verify only; it already setsbash.*: ask, no change expected.
Greps performed / to confirm during implementation:
- The only consumer breakage is internal:
handleToolCall's return-type change ({ block?: true }→GateOutcome) breaks every test that asserts the SDK shape (tool-call.test.ts,tool-call-events.test.ts) — fold those updates into the A1 step (they now assertGateOutcomefrom the handler, or{ block: true }/{}from the boundary). getParser/parserPromiseare file-local tobash-program.ts(no external importers) — confirm before deleting thelet.- Grep
docs/for any sample review-log output or documented "allow"/fallback wording that the newgate_error/<unparseable-bash-command>entries would make stale.
Test Impact Analysis
- New unit tests enabled by these changes:
test/async-cache.test.ts—memoizeAsyncWithRetry: caches on success (single factory call across N calls); drops a rejected result so the next call re-invokes the factory; surfaces the rejection to the caller each time it fails.test/handlers/gates/bash-command.test.ts— emptycommands+ non-empty command →askwith the sentinelmatchedPattern; emptycommands+ whitespace/comment-only command → whole-string resolve (unchanged).test/handlers/tool-call-boundary.test.ts(new) — the boundary contract: anallowGateOutcome→{}; ablockoutcome →{ block: true, reason }; a throwing gate →{ block: true }plus agate_errorreview-log entry andaudit.recordError(). A header comment cites that the SDK'semitToolCalllacks a try/catch (unlikeemitUserBash), documenting why the boundary must absorb the throw.test/handlers/gates/bash-command-metamorphic.test.ts(new) — the totality property: for a table ofask/denycommands,resolveBashCommandCheckovercd /x && <cmd>yields a decision no weaker than the bare<cmd>(deny ≥ ask ≥ allow). A focused parametrized table over real parse+resolve, not a full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.test/decision-audit.test.ts(new) — counters increment per recorded decision/error;writeSummaryemits the summary line; a forcedtoolCalls !== allowed + blocked + errorsmismatch logs the warning-level invariant violation.test/config-loader.test.ts(or a newdetect-permissive-bash-fallback.test.ts) — detector returns a warning for{*: "allow"}with nobash.*; returnsundefinedwhenbash.*is set, whenbashis a bare string, or when top-level*is notallow.
- Tests that become redundant: none — all additive.
- Tests that must stay as-is: the existing
resolveBashCommandCheckchain / most-restrictive tests (#301 / #306) — they pin that the non-empty path is untouched; themakeHandler-basedtool-call.test.tshappy-path tests pin that the normal allow/block flow is unchanged (updated only for theGateOutcomereturn shape).
Invariants at risk
This change touches surfaces refactored by earlier roadmap steps; keep their pinned tests green.
- #301 / #306 (bash chain evaluation, most-restrictive-wins) — A3 changes only the
commands.length === 0branch. Pinned bytest/handlers/gates/bash-command.test.tschain tests. - #308 (single
BashProgram.parseper evaluate) — A2 changesgetParsercaching, not the parse-once contract. Pinned bytest/handlers/gates/tool-call-gate-pipeline.test.ts. - The
makeHandlerreal-pipeline wiring (#341 / handler-fixtures) — A1 changeshandleToolCall's return type toGateOutcome; updatemakeHandler's callers and thetool-call*.test.tsassertions in the same commit so all existing handler tests keep compiling/passing.makeHandleritself needs no reporter (the reporter moved to the boundary), but the boundary tests construct their own reporter/audit mocks.
TDD Order
- A2 parser resilience.
Red:
test/async-cache.test.tsformemoizeAsyncWithRetry(success-caches, reject-retries, reject-surfaces). Green: addsrc/async-cache.ts; rewirebash-program.tsto use it and delete thelet parserPromise. Runpnpm run check. Commit:fix(pi-permission-system): retry tree-sitter parser init instead of caching a rejected promise (#452). - A4 config footgun warning.
Red: detector tests (warn / no-warn matrix).
Green: add and export
detectPermissiveBashFallback; call it inloadAndMergeConfigs. Commit:feat(pi-permission-system): warn when a permissive top-level "*" leaves bash ungated (#452). - A1 fail-closed boundary +
GateOutcomehandler return. Red: addtest/handlers/tool-call-boundary.test.ts(allow/block/throw contract); updatetool-call.test.ts/tool-call-events.test.tsto the newGateOutcomereturn shape. Green: addsrc/handlers/tool-call-boundary.tsand a minimalsrc/decision-audit.ts(counters only —recordDecision/recordError;writeSummarylands in step 5); changehandleToolCallto returnGateOutcome; register the boundary inindex.ts(same commit — interface + sole call site). Runpnpm run check(return-type change) and the full suite (shared handler fixtures). Commit:fix!(pi-permission-system): route tool calls through a fail-closed boundary (#452)with aBREAKING CHANGE:footer covering the whole fail-closed shift (this commit and step 4) and the remediation: set an explicit permissivebashpolicy (e.g."bash": { "*": "allow" }) to opt back into permissive behavior. - A3 fail-closed empty-parse fallback.
Red:
bash-command.test.tsempty-non-empty →ask; empty-trivial → whole-string resolve; plusbash-command-metamorphic.test.ts(thecd X && <cmd>no-weaker property). Green: add the empty-commands branch +isTriviallyEmptyCommand. Commit:fix(pi-permission-system): prompt instead of allowing an unparseable bash command (#452)(the breaking footer is already carried by step 3; reference it in the body). - A5 decision audit + shutdown summary.
Red:
test/decision-audit.test.ts(counters, summary line, invariant-violation warning); extend the boundary test for thedebugLog-gated per-call trace. Green: completeDecisionAudit.writeSummary; threadauditintoSessionLifecycleHandler.handleSessionShutdownand thedebugLog-gated per-call trace in the boundary; wireauditinindex.ts. Commit:feat(pi-permission-system): trace tool-call decisions and emit a session summary (#452). - Docs + SKILL alignment.
Update
docs/configuration.md,README.md(if applicable), and the package SKILL note (fail-closed boundary,gate_error/<unparseable-bash-command>/permission.session_summaryentries). Commit:docs(pi-permission-system): document fail-closed gate behavior and bash fallback warning (#452).
Run the full package suite (pnpm --filter @gotgenes/pi-permission-system exec vitest run) after steps 3 and 5, which touch shared handler fixtures and composition-root wiring.
Risks and Mitigations
- Risk: blocking on every transient gate error is too aggressive and surprises users.
Mitigation: these errors should be rare; the review-log
gate_errorentry tells the user exactly what happened and the generic reason names the gate. Correctness (no silent allow) outweighs convenience for a permission system. - Risk: A3's
askdefault produces unexpected prompts for unparseable commands in permissive configs. Mitigation: documented in the breaking note with a real opt-out ("bash": { "*": "allow" }); only triggers on the rare empty-parse path. - Risk: the
git-vs-rmasymmetry is a distinct, still-unexplained bug that these changes do not fix. Mitigation: the new review-log entries make any recurrence visible and attributable; a follow-up issue is filed only if it recurs with fresh logs. - Risk: the A1 boundary +
GateOutcomereturn-type change ripples throughtool-call*.test.tsassertions. Mitigation: the change is mechanical (assertGateOutcomefrom the handler, SDK shape from the boundary) and folded into the A1 step; the reporter moving to the boundary keeps the handler constructor from widening. - Risk: A5's audit adds per-call work.
Mitigation: counters are O(1); the per-call trace is gated behind
debugLog; the summary is one line onsession_shutdown. - Risk: scope growth — five changes plus an audit in one issue.
Mitigation: the steps are independently committable; A5 (step 5) is separable to a follow-up if review prefers, but the A1 boundary is the structural keystone and must land here.
A
DecisionAuditstub (counters only) is introduced in step 3 so the boundary signature is stable before A5 completes it.
Open Questions
- Should an unexpected gate error (A1) prompt (
ask) rather than hard-block when the context supports a UI? Deferred: hard-block is the unambiguous fail-closed choice and avoids depending on possibly-broken prompt infrastructure. Revisit if blocking proves disruptive in practice. - Should A4's detector also warn for other surfaces (
mcp,skill) that inherit a permissive top-level*? Deferred to a follow-up; this issue is scoped to the bash bypass. - Full reconciliation of A5's in-process counters against Pi's own session JSONL (the cross-artifact check the reporter performed by hand) is deferred — it requires reading Pi's session file, a heavier mechanism than this issue warrants. The in-process summary is the cheaper first tier.
- Should
GateOutcomegain an explicit first-classaskvariant (todayaskis resolved insideapplyPermissionGate, andGateOutcomecarries onlyallow/block)? Deferred: the two-variant total type is sufficient for the boundary's fail-closed translation; widening it is a larger decision-model refactor better tracked on its own.