mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
320 lines
22 KiB
Markdown
320 lines
22 KiB
Markdown
---
|
||
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 → `<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).
|