mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
---
|
||||
issue: 308
|
||||
issue_title: "Introduce a structured BashCommand model and parse the bash command once per tool_call"
|
||||
---
|
||||
|
||||
# Structured BashCommand model and parse-once injection
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The three bash permission gates each parse the same command independently and each apply a slightly different AST descent policy, and the unit of bash command-pattern policy is a flat `string[]` re-derived per feature.
|
||||
That divergence is the bug surface: [#301] arose because the command-pattern walk descended differently than the path walk, and [#306] is re-tuning that same descent.
|
||||
This issue introduces a structured `BashCommand` model for the command-pattern slice and makes all three bash gates share a single parse, so the queued bash work ([#306], [#307]) becomes a consumer of one representation instead of a fourth bespoke walk.
|
||||
|
||||
This is the behavior-preserving enabling refactor — the same move [#304] made one level shallower.
|
||||
No permission decision changes.
|
||||
|
||||
## Goals
|
||||
|
||||
- Introduce a `BashCommand` value object and `BashProgram.commands(): BashCommand[]`, replacing the flat `topLevelCommands(): string[]`, consumed by the command-pattern decomposition.
|
||||
- Parse the bash command once per `tool_call` and inject the parsed `BashProgram` into all three bash gates, retiring the three independent `parse()` calls.
|
||||
- Preserve every permission decision exactly — the existing suites (the 1000-line extractor suite, both gate suites, the tool-call integration suite, the manager suite) stay green unchanged.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing any permission decision — strictly behavior-preserving.
|
||||
- Evaluating commands nested inside command substitution, process substitution, or subshells ([#306]) — that descent lands on top of this model.
|
||||
- Migrating the path / external-directory slices to per-command resolution, or effective-working-directory projection ([#307]).
|
||||
- Unifying the synchronous advisory `checkPermission` / RPC path with the gate's decomposed fidelity ([#309]).
|
||||
- Structured (name + argv) rule matching — bash rules remain text/glob matched against command text.
|
||||
- Retiring the `extractTokensForPathRules` / `extractExternalPathsFromBashCommand` facades — they are kept as the seam exercised by `test/bash-external-directory.test.ts` (the [#304] lift-and-shift decision).
|
||||
- Adding a `context`, `name`, `argv`, `pathCandidates`, or `effectiveCwd` field to `BashCommand` — those are added by their consuming issues ([#306], [#307]); a field nothing reads is a fallow-flagged maintenance trap.
|
||||
|
||||
## Background
|
||||
|
||||
Relevant modules:
|
||||
|
||||
- `src/handlers/gates/bash-program.ts` — `BashProgram.parse(command)` walks the AST once into `rawTokens` + `leadingCdTarget` + `topLevelCommandTexts`, and exposes `pathTokens()`, `externalPaths(cwd)`, and `topLevelCommands()`.
|
||||
The private constructor + static `parse()` factory defeats fallow's syntactic analysis, so each public method carries a `// fallow-ignore-next-line unused-class-member` suppression ([#304] retro).
|
||||
- `src/handlers/gates/bash-command.ts` — `resolveBashCommandCheck(command, agentName, sessionRules, checkPermission, decompose?)` decomposes a chain (via the injectable `decompose`, defaulting to `BashProgram.parse(cmd).topLevelCommands()`), evaluates each unit on the `bash` surface, and combines with `pickMostRestrictive`, falling back to the whole command when decomposition is empty.
|
||||
- `src/handlers/gates/bash-path-extractor.ts` — the `extractTokensForPathRules` / `extractExternalPathsFromBashCommand` facades, each `await BashProgram.parse(command)` then call a slice.
|
||||
- `src/handlers/gates/bash-path.ts` / `bash-external-directory.ts` — the two path-bearing bash gates; each takes `(tcc, checkPermission, getSessionRuleset)`, reads the command from `tcc.input`, and calls a facade (which parses).
|
||||
- `src/handlers/permission-gate-handler.ts` — `handleToolCall` builds an ordered `gateProducers` array; three producers are bash-specific (`describeBashExternalDirectoryGate`, `describeBashPathGate`, and the inline command-pattern producer calling `resolveBashCommandCheck`).
|
||||
- `test/bash-external-directory.test.ts` — 1027 lines, ~90 call sites against the two extractor facades directly; the load-bearing characterization suite for `externalPaths` / `pathTokens`.
|
||||
|
||||
Constraints from `AGENTS.md` / the package skill that apply:
|
||||
|
||||
- Default to least privilege; silent over-matching is a permission bypass — a behavior-preserving refactor must not change a single decision.
|
||||
- Do not add a declared field that nothing reads at runtime — fallow flags it and it is a maintenance trap.
|
||||
- New `BashProgram` methods need the `// fallow-ignore-next-line unused-class-member` suppression (singular kind, no trailing prose).
|
||||
- Run `pnpm run check` immediately after each interface-changing step — behavior-preserving signature changes are caught first by the type checker, not the suite.
|
||||
|
||||
AST facts (verified empirically during planning):
|
||||
|
||||
| Input | Tree | `topLevelCommands()` today |
|
||||
| ------------------ | ----------------------------------------------------------- | -------------------------- |
|
||||
| `cd a && cd b` | `program > list > [command, &&, command]` | `cd a`, `cd b` |
|
||||
| `cat f \| grep b` | `program > pipeline > [command, \|, command]` | `cat f`, `grep b` |
|
||||
| `npm i > out.txt` | `program > redirected_statement > [command, file_redirect]` | `npm i` |
|
||||
| `{ cd a; cat b; }` | `program > compound_statement > [command, command]` | `{ cd a; cat b; }` (whole) |
|
||||
| `( cd a && rm x )` | `program > subshell > list` | `( cd a && rm x )` (whole) |
|
||||
|
||||
The `compound_statement` and `subshell` rows are why `commands()` enumerates the same top-level units `topLevelCommands()` does today — emitting compound statements whole — rather than every `command` node in the tree.
|
||||
Descending those is [#306]'s job, not this issue's.
|
||||
|
||||
## Design Overview
|
||||
|
||||
Decision model: the command-pattern unit is promoted from a bare string to a one-field `BashCommand`, and the three bash gates stop owning the parse — the handler parses once and injects the `BashProgram`.
|
||||
Both moves are behavior-preserving; the value is the extension seam and the single parse, not new behavior.
|
||||
|
||||
### `BashCommand` and `commands()`
|
||||
|
||||
```typescript
|
||||
// src/handlers/gates/bash-program.ts
|
||||
|
||||
/**
|
||||
* One command-pattern unit of a parsed bash program.
|
||||
*
|
||||
* Minimal by design — `text` is the simple-command (or whole compound
|
||||
* statement) string matched against the bash rules. The type is the stable
|
||||
* extension point: #306 adds an execution `context`, #307 adds per-command
|
||||
* path candidates and an effective working directory.
|
||||
*/
|
||||
export interface BashCommand {
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export class BashProgram {
|
||||
// …
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
commands(): BashCommand[] {
|
||||
return this.topLevelCommandTexts.map((text) => ({ text }));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`topLevelCommands()` and its `fallow-ignore` line are removed; `commands()` takes their place over the same stored `topLevelCommandTexts`.
|
||||
The enumeration walker (`collectTopLevelCommandTexts`) is unchanged.
|
||||
|
||||
### Command-pattern resolver consumes units, not a parse
|
||||
|
||||
`resolveBashCommandCheck` is changed from "parse a command string internally" to "combine a caller-supplied unit list," so the handler owns decomposition from the single shared parse and the resolver becomes a pure combiner:
|
||||
|
||||
```typescript
|
||||
// src/handlers/gates/bash-command.ts
|
||||
export async function resolveBashCommandCheck(
|
||||
command: string, // retained for the empty-units fallback
|
||||
units: string[], // = program.commands().map((c) => c.text)
|
||||
agentName: string | undefined,
|
||||
sessionRules: Rule[],
|
||||
checkPermission: CheckPermissionFn,
|
||||
): Promise<PermissionCheckResult> {
|
||||
const results = units.map((unit) =>
|
||||
checkPermission("bash", { command: unit }, agentName, sessionRules),
|
||||
);
|
||||
return (
|
||||
pickMostRestrictive(results) ??
|
||||
checkPermission("bash", { command }, agentName, sessionRules)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The injectable `decompose` parameter and the private `decomposeTopLevelCommands` helper are removed — decomposition now happens once in the handler.
|
||||
The `?? checkPermission(command)` fallback is preserved, so the empty-units case stays never-weaker.
|
||||
The function no longer needs to be `async` for parsing, but stays `async` to keep the handler's `await` call site and the gate-producer signature unchanged.
|
||||
|
||||
### Parse-once injection in the handler
|
||||
|
||||
```typescript
|
||||
// src/handlers/permission-gate-handler.ts (sketch)
|
||||
const command = getNonEmptyString(toRecord(tcc.input).command);
|
||||
const bashProgram =
|
||||
tcc.toolName === "bash" && command ? await BashProgram.parse(command) : null;
|
||||
|
||||
// bash-specific producers receive the shared program:
|
||||
() => describeBashExternalDirectoryGate(tcc, bashProgram, checkPermission, getSessionRuleset),
|
||||
() => describeBashPathGate(tcc, bashProgram, checkPermission, getSessionRuleset),
|
||||
async () =>
|
||||
tcc.toolName === "bash" && bashProgram
|
||||
? describeToolGateFor(
|
||||
await resolveBashCommandCheck(
|
||||
command ?? "",
|
||||
bashProgram.commands().map((c) => c.text),
|
||||
tcc.agentName ?? undefined,
|
||||
getSessionRuleset(),
|
||||
checkPermission,
|
||||
),
|
||||
)
|
||||
: describeToolGateFor(checkPermission(tcc.toolName, tcc.input, …)),
|
||||
```
|
||||
|
||||
The two path gates gain a `bashProgram: BashProgram | null` parameter and call `bashProgram.externalPaths(cwd)` / `bashProgram.pathTokens()` directly instead of the facade.
|
||||
They keep their existing `tcc.toolName !== "bash"` / `!command` early-returns; with `bashProgram === null` the gate returns `null` as before.
|
||||
|
||||
### Design-review notes
|
||||
|
||||
- Dependency width: each bash gate genuinely uses the injected `BashProgram` (one slice each), so the new parameter is a real dependency, not a bag.
|
||||
- Law of Demeter: gates call `bashProgram.externalPaths(cwd)` / `.pathTokens()` — a method on the injected collaborator, not a reach-through.
|
||||
- Parameter relay: `bashProgram` flows handler → gate (the endpoint), not threaded through intermediaries.
|
||||
- `BashCommand` is a one-field type; that is intentional (the extension seam) and fallow-clean because `text` is read by the resolver.
|
||||
- The extractor facades are kept (test-only seam after this issue); fully retiring them by migrating the 1027-line suite onto `BashProgram` methods is a deferred cleanup, not part of this behavior-preserving refactor.
|
||||
|
||||
## Module-Level Changes
|
||||
|
||||
1. `src/handlers/gates/bash-program.ts` — add `export interface BashCommand`; replace the `topLevelCommands()` method (and its `fallow-ignore` line) with `commands(): BashCommand[]`; the stored `topLevelCommandTexts` field and `collectTopLevelCommandTexts` walker are unchanged.
|
||||
2. `src/handlers/gates/bash-command.ts` — change `resolveBashCommandCheck` to accept `(command, units, agentName, sessionRules, checkPermission)`; remove the `decompose` parameter and the `decomposeTopLevelCommands` helper.
|
||||
3. `src/handlers/permission-gate-handler.ts` — parse `bashProgram` once when `tcc.toolName === "bash"` and a command is present; pass it to the two path gates; compute `units` from `bashProgram.commands()` and pass them to `resolveBashCommandCheck`.
|
||||
4. `src/handlers/gates/bash-external-directory.ts` — add a `bashProgram: BashProgram | null` parameter; replace `extractExternalPathsFromBashCommand(command, cwd)` with `bashProgram.externalPaths(cwd)`; return `null` when `bashProgram` is `null`.
|
||||
5. `src/handlers/gates/bash-path.ts` — add a `bashProgram: BashProgram | null` parameter; replace `extractTokensForPathRules(command)` with `bashProgram.pathTokens()`; return `null` when `bashProgram` is `null`.
|
||||
6. `src/handlers/gates/bash-path-extractor.ts` — unchanged (kept for `test/bash-external-directory.test.ts`).
|
||||
7. `test/handlers/gates/bash-program.test.ts` — rename the `topLevelCommands` describe and update its assertions to the `commands(): BashCommand[]` shape (`[{ text: "…" }, …]`).
|
||||
8. `test/handlers/gates/bash-command.test.ts` — pass `units` directly instead of a `decompose` stub; add the `command` fallback argument.
|
||||
9. `test/handlers/gates/bash-external-directory.test.ts` and `test/handlers/gates/bash-path.test.ts` — construct a `BashProgram` (real `parse`) and pass it to the gate under test.
|
||||
10. `test/handlers/tool-call.test.ts` — verify the bash chain / single-command integration tests still pass through the handler's single parse (assertions unchanged).
|
||||
11. `docs/architecture/architecture.md` — update the `bash-program.ts` and `bash-command.ts` listing lines: `commands(): BashCommand[]` (not `topLevelCommands()`), and note the gate handler parses once and injects the `BashProgram`.
|
||||
|
||||
No `pkg:*` doc under `docs/configuration.md` or `README.md` changes — behavior is unchanged.
|
||||
`docs/architecture/v3-architecture.md` is historical narrative and is left unchanged ([#304] retro).
|
||||
|
||||
## Test Impact Analysis
|
||||
|
||||
1. New unit coverage enabled: `commands()` returns typed `BashCommand[]` entries — the renamed `bash-program.test.ts` cases assert the object shape (`{ text }`), documenting the seam.
|
||||
No genuinely new behavior is exercised; the enumeration is unchanged.
|
||||
2. Tests that become redundant: none — no assertion is removed.
|
||||
The `decompose`-stub indirection in `bash-command.test.ts` is replaced by passing `units` directly, which is a simplification, not a coverage loss.
|
||||
3. Tests that must stay as-is: `test/bash-external-directory.test.ts` (the 1027-line characterization suite) proves `externalPaths` / `pathTokens` outputs are unchanged — the strongest behavior-preservation signal for the parse-once move; both gate suites and the tool-call integration suite confirm the injected program produces identical gate decisions.
|
||||
|
||||
## TDD Order
|
||||
|
||||
1. `test: model bash command-pattern units as BashCommand` — add `BashCommand` and `commands()` to `bash-program.ts`, remove `topLevelCommands()`; update `resolveBashCommandCheck`'s default decompose to `commands().map((c) => c.text)`; rename/update `bash-program.test.ts` to the object shape.
|
||||
Single atomic commit: removing `topLevelCommands()` breaks its sole consumer and its tests at the type level, so the method swap, the consumer update, and the test update land together.
|
||||
Use `feat:` is wrong (no behavior change) — use `refactor:`.
|
||||
Run `pnpm run check`.
|
||||
2. `refactor: inject the shared BashProgram into the bash path gates` — parse `bashProgram` once in the handler; add the `bashProgram` parameter to `describeBashExternalDirectoryGate` and `describeBashPathGate`, calling `externalPaths`/`pathTokens` on it; update `test/handlers/gates/bash-external-directory.test.ts` and `bash-path.test.ts` to pass a parsed program.
|
||||
The signature change and its call sites (handler + gate tests) must land together.
|
||||
Run `pnpm run check`.
|
||||
3. `refactor: evaluate bash command units from the shared parse` — change `resolveBashCommandCheck` to `(command, units, …)`, remove `decompose` and `decomposeTopLevelCommands`; the handler passes `bashProgram.commands().map((c) => c.text)`; update `bash-command.test.ts` to pass units; confirm `tool-call.test.ts` stays green.
|
||||
Run `pnpm run check`; run the full suite (`resolveBashCommandCheck` is a shared helper).
|
||||
4. `docs: update architecture listing for the BashCommand model and parse-once` — update the two `architecture.md` lines.
|
||||
|
||||
After step 3, a bash `tool_call` parses the command exactly once.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. Parse-once changes the decision for some command — Mitigation: the gates call the identical slice methods on the same parse; the 1027-line extractor suite, both gate suites, and the tool-call integration suite assert unchanged outputs.
|
||||
Run the full suite after step 3.
|
||||
2. The extractor facades become production-dead and fallow flags them — Mitigation: `test/bash-external-directory.test.ts` imports both, and fallow treats test files as consumers ([#301] retro), so they stay live; retaining them is the explicit [#304] lift-and-shift decision.
|
||||
3. `commands()` is flagged by fallow as an unused class member (private-ctor false positive) — Mitigation: carry the `// fallow-ignore-next-line unused-class-member` suppression (singular kind, no trailing prose) exactly as `topLevelCommands()` did.
|
||||
4. Gate-signature change ripples to mocks beyond the gate suites — Mitigation: grep for every constructor of the gate-call arguments; the gates are called only from the handler and their own suites.
|
||||
Run `pnpm run check` after steps 1–3.
|
||||
5. This refactor ships stacked under [#306]'s release — Mitigation: like [#304] under [#301], note at ship time that release-please omits `refactor:` commits from the changelog, so [#308] must be closed explicitly when [#306] ships (the `/ship-issue` stacked-enabler check covers this).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether `resolveBashCommandCheck` should take `units: string[]` or `commands: BashCommand[]` directly.
|
||||
The plan passes `string[]` to keep the resolver decoupled from the model shape and its tests trivial; revisit if [#306] needs the per-unit `context` inside the resolver (it evaluates by `text`, so likely not).
|
||||
|
||||
[#301]: https://github.com/gotgenes/pi-packages/issues/301
|
||||
[#304]: https://github.com/gotgenes/pi-packages/issues/304
|
||||
[#306]: https://github.com/gotgenes/pi-packages/issues/306
|
||||
[#307]: https://github.com/gotgenes/pi-packages/issues/307
|
||||
[#309]: https://github.com/gotgenes/pi-packages/issues/309
|
||||
Reference in New Issue
Block a user