--- issue: 309 issue_title: "Unify the advisory checkPermission/RPC bash path with the gate's decomposed fidelity" --- # Advisory bash decomposition parity ## Release Recommendation **Release:** ship independently Architecture roadmap Phase 10 Step 4 (`docs/architecture/architecture.md`) tags this issue `Release: independent`, and the release-type note classes it a behavior change that cuts a release (`feat:`, not a hidden `refactor:`). It is not part of any batch — Steps 3–6 are each independently releasable — so it ships on its own once green. ## Problem Statement The bash enforcement gate decomposes a chained or nested command and evaluates each sub-command, so `cd /repo && npm install x` denies on the `npm *` rule (most-restrictive wins). The synchronous advisory path — `LocalPermissionsService.checkPermission("bash", …)` (`src/permissions-service.ts`) — instead matches bash as a single whole string: `buildAccessIntentForSurface` emits one `{ kind: "tool", surface: "bash", input: { command } }` intent, so the whole string matches the leading `cd *` and returns `allow`. So the same question — "would this bash command be allowed?" — gets two different answers depending on which door you ask at. This is not an enforcement gap (the gate is already decomposed and consistent after #306); it is an *advisory* consistency gap. Other extensions and pre-flight checks querying the published `PermissionsService` get a lower-fidelity answer than the gate will actually enforce. The constraint: `PermissionsService.checkPermission` is synchronous by contract (returns `PermissionCheckResult`, not a `Promise`), and external extensions depend on that. The gate's decomposition is async only because the tree-sitter parser initializes lazily (`await getParser()`). Unifying fidelity therefore means making the *parse* synchronous after a warm-up, not making the public API async. ## Goals - Route the advisory bash query through the same decomposed orchestration the gate uses (`resolveBashCommandCheck` over `BashCommand[]`), so a chained/nested advisory query returns the gate's most-restrictive decision. - Warm the tree-sitter parser at `before_agent_start` and expose a synchronous parse for callers that run after warm-up. - Preserve the synchronous `checkPermission` contract — no `Promise` in the public signature. - Degrade gracefully in the pre-warm window: a bash advisory query before the parser is warm falls back to the pre-#309 whole-string match rather than blocking. - Behavior change is a **strengthening** of the advisory answer (a previously-`allow` chained command may now return `deny`/`ask`), aligning advisory with enforcement; ship as `feat:` with a release note (per the owner's roadmap decision — no external consumer exercises bash advisory queries yet, so this is `feat:`, not `feat!:`). ## Non-Goals - **Changing enforcement.** The gate is already decomposed (#301, #306); this touches only the advisory path. - **Making `checkPermission` async.** The sync contract is preserved; only the parse becomes synchronous (after warm-up). - **Structured (name + argv) rule matching.** Bash rules stay text/glob-matched against command text. - **Decomposing the forwarded-request serving path** (`servingPolicy.check` in `index.ts`, the `ForwardedRequestServer`). A forwarded child request already carries the child gate's decomposed sub-command decision; the roadmap Step 4 target names only `permissions-service.ts`, not the serving path. - **The bash path / external-directory advisory surfaces.** An advisory `checkPermission("bash", cmd)` answers only the bash command-pattern surface, exactly as today — it does not run the derived path/external-directory gates the enforcement pipeline runs. - **Modifying `input-normalizer.ts`'s `buildAccessIntentForSurface`.** The roadmap target text mentions it, but the design keeps the intent builder pure and routes the decompose-or-fallback decision in the service layer; `buildAccessIntentForSurface` remains the fallback intent builder for non-bash surfaces (see Design Overview). ## Background Relevant existing modules (current `main`, post-#308/#306/#531): - `src/permissions-service.ts` — `LocalPermissionsService.checkPermission(surface, value?, agentName?)` builds an intent via `buildAccessIntentForSurface` and calls `this.resolver.resolve(intent)`. The bash branch produces a whole-string `tool` intent. The RPC channel the issue references was removed in #531 (`Symbol.for()` service accessor is now the sole cross-extension policy surface), so "service + RPC" collapses to just the service. - `src/handlers/gates/bash-command.ts` — `resolveBashCommandCheck(command, commands: BashCommand[], agentName, resolver)` is the **already-shared** decompose-and-combine orchestrator the issue's step 2 asked for (#308 landed it): pure, synchronous, checks each unit on the `bash` surface, floors opaque wrappers to `ask`, tags nested-command context, and picks most-restrictive. It also owns the #452 fail-closed edge (non-empty command that parses to zero units → `` ask) and the trivially-empty passthrough. - `src/access-intent/bash/command-enumeration.ts` — `collectCommands(node: TSNode): BashCommand[]` walks the AST into command units (chains + nested substitutions/subshells, #306); pure over `TSNode`. - `src/access-intent/bash/parser.ts` — `getParser = memoizeAsyncWithRetry(initParser)`; `TSParser.parse` is synchronous once initialized. - `src/access-intent/bash/program.ts` — `BashProgram.parse(command, normalizer, …)` async factory used by the gate pipeline; produces all three slices. The advisory path needs only `commands()`, so it will not build a full `BashProgram`. - `src/handlers/before-agent-start.ts` — `AgentPrepHandler.handle` runs on the async `before_agent_start` hook, which precedes any tool call. - `src/handlers/gates/tool-call-gate-pipeline.ts` — the gate consumer: `await BashProgram.parse(...)` then `resolveBashCommandCheck(command, bashProgram.commands(), …)`. Note the gate feeds the **raw** command to the parser (tree-sitter skips comment nodes during enumeration); it does not pre-strip comments. The advisory path mirrors this. AGENTS.md / package-SKILL constraints that apply: - **Module-scoped state persists across same-cwd session switches** (per the package SKILL). The warmed-parser cache is read-only/stateless, so persisting it is safe and strictly beneficial (a later same-cwd session starts warm). Do not park permission-relevant state at module level. - **Least privilege / fail-closed** — the cold-start fallback must never be *weaker* than the current whole-string behavior; when warm, the decomposed path inherits `resolveBashCommandCheck`'s #452 fail-closed. - Mark the roadmap step complete (`✅` on the Step 4 heading and its Mermaid node) in the implementation doc-update commit, not a deferred ship commit. ## Design Overview Three seams: (1) a warm-parser lifecycle with a synchronous accessor, (2) a synchronous bash-command parse plus a decompose-or-fallback resolver, (3) service routing and a `before_agent_start` warm-up trigger. ### 1. Warm-parser lifecycle (`access-intent/bash/parser.ts`) Add a module-level cache of the resolved parser and a warm-up that populates it: ```ts let warmedParser: TSParser | null = null; // Best-effort: on failure the sync accessor stays cold and callers fall back. // Idempotent + cheap after the first success, so calling it every turn is free. export async function warmBashParser(): Promise { if (warmedParser) return; try { warmedParser = await getParser(); } catch { // leave cold → advisory falls back to whole-string } } export function getWarmBashParser(): TSParser | null { return warmedParser; } // Test-only: reset module state so cold/warm cases are isolatable. export function resetWarmBashParser(): void { warmedParser = null; } ``` `getParser` (the async memoized factory) is unchanged and still drives the gate's `BashProgram.parse`. ### 2. Synchronous command parse + advisory resolver New `src/access-intent/bash/sync-commands.ts` — warm-parser-backed synchronous enumeration (no path slices, no normalizer; command-pattern surface only): ```ts export function parseBashCommandsSync(command: string): BashCommand[] | null { const parser = getWarmBashParser(); if (!parser) return null; // cold → caller falls back to whole-string const tree = parser.parse(command); if (!tree) return []; try { return collectCommands(tree.rootNode); } finally { tree.delete(); } } ``` New `src/bash-advisory-check.ts` — the decompose-or-fallback resolver, reusing the gate's orchestrator so advisory and enforcement can never drift: ```ts export function resolveBashAdvisoryCheck( command: string, agentName: string | undefined, resolver: ScopedPermissionResolver, ): PermissionCheckResult { const commands = parseBashCommandsSync(command); if (commands === null) { // Pre-warm window: preserve pre-#309 whole-string advisory behavior. return resolver.resolve({ kind: "tool", surface: "bash", input: { command }, agentName, }); } return resolveBashCommandCheck(command, commands, agentName, resolver); } ``` `ScopedPermissionResolver` is the `{ resolve(intent) }` role (ISP-clean); the service's resolver view satisfies it. `bash-advisory-check.ts` lives at the service layer (top-level `src/`) because it imports `resolveBashCommandCheck` from `handlers/gates/` — keeping this composition out of `access-intent/` avoids a domain→handler layer inversion. ### 3. Service routing + warm-up trigger `LocalPermissionsService.checkPermission` branches bash to the new resolver; every other surface is unchanged: ```ts checkPermission(surface, value, agentName) { if (surface === "bash") { return resolveBashAdvisoryCheck(value ?? "", agentName, this.resolver); } const intent = buildAccessIntentForSurface( surface, value, this.session.getPathNormalizer(), agentName, ); return this.resolver.resolve(intent); } ``` Warm-up trigger injected into `AgentPrepHandler` (roadmap names `before-agent-start.ts` as the trigger site) as a `() => void` collaborator, called fire-and-forget at the top of `handle` so it never delays agent start: ```ts // index.ts wiring: new AgentPrepHandler(session, resolver, toolRegistry, () => { void warmBashParser(); }); // AgentPrepHandler.handle: this.warmParser(); ``` Fire-and-forget (not awaited) is deliberate: the issue accepts a bounded pre-warm window with graceful fallback, and the idempotent early-return makes the every-turn call free after the first success. ### Consumer call-site sketch (Tell-Don't-Ask check) The service tells the resolver-view to answer; `resolveBashAdvisoryCheck` owns the decompose-vs-fallback decision internally rather than the service asking `getWarmBashParser()` and branching: ```ts // service (no reach-through): service.checkPermission("bash", "cd /repo && npm install x"); // → resolveBashAdvisoryCheck(cmd, agent, resolver) // → parseBashCommandsSync(cmd) // warm: ["cd /repo", "npm install x"] // → resolveBashCommandCheck(...) // deny (npm *) wins ``` No new Law-of-Demeter chain, no output arguments, no mutation. ### Edge cases - **Value-less bash query** (`checkPermission("bash")`) → `resolveBashAdvisoryCheck("", …)`. Warm: `collectCommands("")` → `[]` → `resolveBashCommandCheck` trivially-empty passthrough resolves `{ command: "" }`. Cold: whole-string fallback on `{ command: "" }`. Consistent. - **Unparseable non-empty command, warm** → `resolveBashCommandCheck` fails closed to `` ask (#452 parity on the advisory path). - **Opaque wrapper** (`bash -c "…"`), warm → floored to `ask` via the enumerator's `opaque` flag, exactly as the gate. - **Cold start** → whole-string tool intent, i.e. pre-#309 advisory behavior (never weaker than before). ### Design-review checklist result - Dependency width: `AgentPrepHandler` gains one narrow `() => void` param (4 total) — acceptable, no field cluster. - LoD / output args / scattered resets: none introduced. - ISP: `resolveBashAdvisoryCheck` and `parseBashCommandsSync` take only what they read (`{ resolve }`, a `string`). - No new repeated discriminator (the `surface === "bash"` branch is a single dispatch site in the service). ## Module-Level Changes Added: - `src/access-intent/bash/sync-commands.ts` — `parseBashCommandsSync(command): BashCommand[] | null`. - `src/bash-advisory-check.ts` — `resolveBashAdvisoryCheck(command, agentName, resolver): PermissionCheckResult`. - `test/access-intent/bash/sync-commands.test.ts`, `test/bash-advisory-check.test.ts`. Changed: - `src/access-intent/bash/parser.ts` — add `warmBashParser()`, `getWarmBashParser()`, `resetWarmBashParser()` (test-only) and the `warmedParser` module cache. - `src/handlers/before-agent-start.ts` — inject `warmParser: () => void` (4th ctor param) and call it fire-and-forget in `handle`. - `src/permissions-service.ts` — bash surface routes to `resolveBashAdvisoryCheck`; non-bash unchanged. - `src/index.ts` — pass `() => { void warmBashParser(); }` to `AgentPrepHandler`. - `test/access-intent/bash/parser.test.ts` — add warm-up + sync-accessor cases (with `resetWarmBashParser()` in `beforeEach`). - `test/permissions-service.test.ts` — mock `#src/bash-advisory-check`; assert the bash surface delegates to `resolveBashAdvisoryCheck(command, agentName, resolver)`; re-point the existing "non-path surface → tool intent" assertion to a non-bash surface (e.g. `skill`) so it still covers the `buildAccessIntentForSurface` path. - `test/handlers/before-agent-start.test.ts` — update the `makeSetup` `new AgentPrepHandler(...)` call to pass a `vi.fn()` warm trigger and assert it is invoked on `handle`. Docs (in the implementation doc-update commit): - `docs/cross-extension-api.md` — under `#### checkPermission`, note that a bash `value` containing a chained/nested command is decomposed and evaluated most-restrictive at parity with the gate (a previously-`allow` chain may return `deny`/`ask`), with a cold-start whole-string fallback in the brief pre-warm window. - `docs/architecture/architecture.md` — - update the `parser.ts` inline listing (line ~751) to add `warmBashParser` / `getWarmBashParser` (+ `resetWarmBashParser` test hook); - update the `before-agent-start.ts` listing (~762) to note the warm-up trigger; - update the `permissions-service.ts` listing (~787) to note bash advisory decomposition; - add `sync-commands.ts` under the `access-intent/bash/` tree and `bash-advisory-check.ts` under the flat `src/` listing; - mark **Step 4 ✅** on its heading (~938) and its Mermaid node (~978). Grep sweep performed (removed/added symbol references): no symbols are removed or renamed — all changes are additive plus one service branch. `checkPermission` and `resolveBashCommandCheck` are referenced in `docs/architecture/architecture.md` and `docs/cross-extension-api.md` (both listed above); no `.pi/skills/package-*/SKILL.md` prose describes the advisory-whole-string behavior as a named mechanism to reword. ## Test Impact Analysis This is an additive change (plus one service branch), not an extraction, so the extraction-specific lens is light: 1. **New unit tests enabled:** - `sync-commands.test.ts` — cold (`getWarmBashParser()` null → `parseBashCommandsSync` returns `null`); warm (`await warmBashParser()` → chained command yields multiple `BashCommand[]` units, comment-only yields trivially-empty). - `bash-advisory-check.test.ts` — warm chained command → most-restrictive `deny` wins; cold → single whole-string `tool` resolve; opaque wrapper floored to `ask`; unparseable non-empty warm → ``. These directly test the seam that was previously untestable (there was no sync parse). 2. **Existing tests that become redundant:** none removed. `permissions-service.test.ts`'s bash assertion changes from "asserts a whole-string tool intent" to "asserts delegation to `resolveBashAdvisoryCheck`" — the decomposition behavior itself is covered at the lower `bash-advisory-check` layer, so the service test narrows to delegation only. 3. **Tests that must stay as-is:** `bash-command.test.ts` / `bash-command-metamorphic.test.ts` (they pin `resolveBashCommandCheck`, now shared by both callers); `command-enumeration` tests; the gate-pipeline bash tests (enforcement path unchanged). ## Invariants at risk Both prior steps' invariants live behind existing tests; reusing their code on the advisory path preserves them: - **#308 — `resolveBashCommandCheck` is a pure combiner over `BashCommand[]`.** Pinned by `test/handlers/gates/bash-command.test.ts`. The advisory path calls it with sync-parsed `commands`; purity is unchanged. - **#306 — nested commands (substitutions/subshells) are enumerated and never weaken the decision.** Pinned by `command-enumeration` + `bash-command-metamorphic` tests; the advisory path reuses `collectCommands`. - **#452 — a non-empty command parsing to zero units fails closed to `` ask.** Pinned in `bash-command.test.ts`; inherited by the warm advisory path, and newly asserted in `bash-advisory-check.test.ts`. - **Cold-start floor** — the fallback must not be weaker than pre-#309 whole-string. New assertion in `bash-advisory-check.test.ts` (cold → single whole-string resolve). ## TDD Order 1. **Warm-parser lifecycle + sync command parse.** Red: `parser.test.ts` (`getWarmBashParser()` null before warm; populated after `await warmBashParser()`; `resetWarmBashParser()` clears) and new `sync-commands.test.ts` (cold → `null`; warm → decomposed `BashCommand[]`; comment-only → trivially-empty; chained → multiple units). Green: add the warm-up/accessor to `parser.ts` and `sync-commands.ts`. Commit: `feat(pi-permission-system): add warm tree-sitter parser and sync bash-command parse` 2. **Advisory decompose-or-fallback resolver.** Red: `bash-advisory-check.test.ts` — warm chained → `deny` wins (most-restrictive); cold → single whole-string `tool` resolve; opaque wrapper floored to `ask`; unparseable non-empty warm → ``. Use `resetWarmBashParser()` / `warmBashParser()` to select cold vs warm per case. Green: add `src/bash-advisory-check.ts`. Commit: `feat(pi-permission-system): add bash advisory decompose-or-fallback resolver` 3. **Route the service bash query through it.** Red: `permissions-service.test.ts` — mock `#src/bash-advisory-check`; assert `checkPermission("bash", cmd, agent)` delegates to `resolveBashAdvisoryCheck(cmd, agent, resolver)`; re-point the existing non-path-surface assertion to `skill` so `buildAccessIntentForSurface` stays covered; keep the "returns resolver result" contract. Green: branch bash in `LocalPermissionsService.checkPermission`. Note the public-semantics strengthening in the commit body (advisory bash answers now decomposed/most-restrictive at gate parity; no `Promise` in the signature). Commit: `feat(pi-permission-system): decompose advisory bash checkPermission at gate parity` 4. **Warm the parser on `before_agent_start`.** Red: `before-agent-start.test.ts` — assert the injected warm trigger is invoked on `handle`; update `makeSetup` to pass a `vi.fn()` as the 4th `AgentPrepHandler` arg. Green: add the `warmParser: () => void` ctor param + fire-and-forget call in `AgentPrepHandler.handle`; wire `() => { void warmBashParser(); }` in `index.ts`. Commit: `feat(pi-permission-system): warm bash parser on before_agent_start` 5. **Docs + roadmap.** Update `docs/cross-extension-api.md` (checkPermission bash decomposition note) and `docs/architecture/architecture.md` (inline listings for `parser.ts` / `before-agent-start.ts` / `permissions-service.ts`, add the two new modules to the tree, mark **Step 4 ✅** on heading + Mermaid node). Commit: `docs(pi-permission-system): document advisory bash decomposition and complete roadmap step 4` ## Risks and Mitigations - **Warm-up race (pre-warm window).** A bash advisory query before the parser warms briefly reintroduces dual fidelity. Mitigation: `parseBashCommandsSync` returns `null` when cold and `resolveBashAdvisoryCheck` falls back to the exact pre-#309 whole-string match — never weaker, and the window closes on the first `before_agent_start` (which precedes any tool call). Warm-up is idempotent so subsequent turns stay warm. - **Public-semantics change.** Advisory bash answers become decomposed/most-restrictive, so a chained command that previously returned `allow` may now return `deny`/`ask`. Mitigation: it is a strengthening that aligns advisory with enforcement; ship `feat:` with a release note (owner decision — no external consumer exercises bash advisory queries yet), and update `docs/cross-extension-api.md`. - **Warm-up failure poisoning the sync path.** A tree-sitter WASM init failure must not throw out of `before_agent_start`. Mitigation: `warmBashParser` swallows errors (best-effort); the sync accessor stays cold and the advisory path falls back. `getParser`'s `memoizeAsyncWithRetry` still retries on the next call. - **Cross-test module-state leakage.** `warmedParser` persists across tests in a file (and across same-cwd sessions in production). Mitigation: `resetWarmBashParser()` in `beforeEach` for the parser/sync-commands/advisory tests; the service test mocks `bash-advisory-check` entirely and never touches real parser state. ## Open Questions - None blocking. The forwarded-request serving path (`servingPolicy.check`) is deliberately out of scope (Non-Goals); if a future consumer needs decomposed serving decisions, that is a separate issue — not filed now (speculative).