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

22 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
309 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.tsLocalPermissionsService.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.tsresolveBashCommandCheck(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.tscollectCommands(node: TSNode): BashCommand[] walks the AST into command units (chains + nested substitutions/subshells, #306); pure over TSNode.
  • src/access-intent/bash/parser.tsgetParser = memoizeAsyncWithRetry(initParser); TSParser.parse is synchronous once initialized.
  • src/access-intent/bash/program.tsBashProgram.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.tsAgentPrepHandler.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:

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):

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:

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:

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:

// 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:

// 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, warmresolveBashCommandCheck 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.tsparseBashCommandsSync(command): BashCommand[] | null.
  • src/bash-advisory-check.tsresolveBashAdvisoryCheck(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).