Files
my-pi/pi-permission-system/docs/plans/0309-advisory-bash-decomposition-parity.md
T

320 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 36 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 → `<unparseable-bash-command>` 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<void> {
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 `<unparseable-bash-command>` 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 → `<unparseable-bash-command>`.
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 `<unparseable-bash-command>` 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 → `<unparseable-bash-command>`.
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).