feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,395 @@
---
issue: 475
issue_title: "pi-permission-system: extract command enumeration and cwd projection; relocate the bash sub-domain (Phase 6 Step 3)"
---
# Extract command enumeration and cwd projection; relocate the bash sub-domain (born-ready BashProgram + cwd type fix)
## Release Recommendation
**Release:** ship now — batch "bash-program-decomposition" tail (this issue completes the batch)
This is Step 3 of the Phase 6 access-intent roadmap and the tail of the `bash-program-decomposition` batch (Steps 1 [#473], 2 [#474], 3 [#475]).
Steps 1 and 2 already landed on `main` with their releases deferred per the mid-batch marker.
Landing Step 3 completes the batch, so the release-please PR should merge rather than stay open.
Caveat to confirm at ship time: every commit here is `refactor:` / `docs:` with no user-facing behavior change (the extension's permission decisions, config surface, and outputs are identical), so release-please will not derive a version bump from the batch alone.
"Ship now" means "nothing is holding the batch back" — if no bumping commit has accumulated, no release is cut, which is correct for an internal-only refactor.
Do not fabricate a `fix:`/`feat:` to force a bump.
## Problem Statement
After Steps 1 and 2, `src/handlers/gates/bash-program.ts` is down to ~695 LOC but still mixes three distinct concerns:
1. the `BashProgram` value-object API (parse once, expose typed slices),
2. command enumeration — chain/substitution/subshell descent that emits each executed command unit, and
3. the effective-working-directory `cd`-fold projection — the stateful AST walk that tags each path candidate with the working directory in force at its position, plus the per-candidate resolution that turns those tagged candidates into external paths and policy values.
The `cd`-fold projection is the subtlest region in the package (the home of the [#307] and [#454] fixes).
It and the command enumeration are each independently testable concerns that do not belong in the value-object file.
The file also still lives under `handlers/gates/`, which inverts the intended dependency direction: the gates should consume the access-intent engine, not host it.
Two design problems surfaced while planning this relocation, and both are folded into this issue:
- **`BashProgram` is not born-ready.**
`parse(command)` stores intermediate `PathCandidate[]` state, then `externalPaths(cwd)` / `pathRuleCandidates(cwd)` re-supply `cwd` on every call to finish the resolution lazily.
But `cwd` is always available at parse time (it is `tcc.cwd`, threaded from `ExtensionContext.cwd`).
An object should be constructed with all the state it needs; `parse(command, cwd)` can resolve eagerly and hand callers finished answers.
- **The `cwd` type is wrongly widened.**
`ToolCallContext.cwd` is typed `string | undefined`, but `ExtensionContext.cwd` is `string` in the SDK (non-optional — the same interface marks `model` and `signal` as `| undefined`, so `cwd`'s presence is deliberate).
The `| undefined` is a type-widening error introduced in this package.
It spawned dead `cwd`-undefined branches in five gates that obscure the real invariant: *if we are evaluating a tool call, `tcc.cwd` is a `string`.*
## Goals
- Extract command enumeration into `src/access-intent/bash/command-enumeration.ts`.
- Extract the `cd`-fold projection — the walk *and* the per-candidate resolution — into `src/access-intent/bash/cwd-projection.ts` (Option B: projection owns the whole lifecycle; `EffectiveBase` / `PathCandidate` never leave the module).
- Make `BashProgram` born-ready: `parse(command, cwd: string)` resolves eagerly; `commands()` / `externalPaths()` / `pathRuleCandidates()` become parameter-free getters over stored resolved arrays.
- Fix the `cwd` type widening: narrow `ToolCallContext.cwd` to `string`, and remove the now-dead `cwd`-undefined branches and obsolete tests across all five gates.
- Relocate the slimmed `BashProgram` to `src/access-intent/bash/program.ts` and `bash-token-classification.ts` to `src/access-intent/bash/token-classification.ts`.
- Repoint all bash gates and tests at `#src/access-intent/bash/...`.
- Sharpen the dependency direction: `handlers/gates/` depends into `access-intent/bash/`, never the reverse.
This is **not a breaking change** to the extension's user-facing surface — no public command, config field, default, schema, or permission output changes.
The changes are internal (module layout, in-package types, computation timing, dead-code removal).
All commits are `refactor:` / `docs:`.
## Non-Goals
- No change to permission decisions, enumeration semantics, the `cd`-fold projection results, classification, or policy resolution — outputs are identical; only *when* they are computed (eager vs lazy) and the internal API shape change.
- No collapse of `BashProgram` from a class to a function-returning-record.
Under eager resolution the class is close to a data holder, but reshaping the value object belongs with Phase 6 Step 4 ([#476]), which already retypes `BashProgram.externalPaths` for the `AccessPath` value object.
- No introduction of the `AccessPath` value object — Step 4.
- No collapse of the two external-directory gates — Step 5.
- No new `index.ts` barrel for `access-intent/bash/` — consumers import the relocated modules directly (matching `parser.ts` / `node-text.ts` / `token-collection.ts`), so fallow does not flag speculative re-exports.
- No migration of the package-level path helpers (`path-utils`, `canonicalize-path`) into `access-intent/` — a later phase.
## Background
Relevant existing modules (all under `packages/pi-permission-system/src/`):
- `handlers/gates/bash-program.ts` — the file being decomposed.
Exports `BashProgram` (class), `BashCommand` (interface), `BashPathRuleCandidate` (interface).
Private: `EffectiveBase`, `PathCandidate`, the enumeration functions, the projection walk, and the per-candidate resolution helpers.
- `access-intent/bash/parser.ts` — lazy tree-sitter-bash parser (`getParser`, `TSNode`).
Seeded by Step 1.
- `access-intent/bash/node-text.ts``resolveNodeText`, `SKIP_SUBTREE_TYPES`, `ARG_NODE_TYPES`.
Seeded by Steps 12.
- `access-intent/bash/token-collection.ts``collectCommandTokens`, `collectPathCandidateTokens`, `collectRedirectTokens`, `extractCommandName`.
Seeded by Step 2.
- `handlers/gates/bash-token-classification.ts``classifyTokenAsPathCandidate` (strict), `classifyTokenAsRuleCandidate` (broad), shared `rejectNonPathToken`.
The `cwd` invariant (verified during planning):
- `ExtensionContext.cwd: string` (`@earendil-works/pi-coding-agent`) — non-optional.
- `permission-gate-handler.ts` builds `ToolCallContext` with `cwd: ctx.cwd`, so `tcc.cwd` is always a `string` at runtime.
- The widened `ToolCallContext.cwd: string | undefined` produced dead `cwd`-undefined handling in five gates: `bash-external-directory.ts` (`|| !tcc.cwd` guard), `external-directory.ts` (`if (!tcc.cwd) return null`), `skill-read.ts` (`if (tcc.cwd === undefined) return null`), `path.ts` (`tcc.cwd ? … : filePath` ternary), `tool.ts` (`tcc.cwd ? … : path` ternary).
Two of these have dedicated "returns null when no CWD" tests (`bash-external-directory.test.ts`, `external-directory.test.ts`) exercising an input that cannot occur.
Consumers of `BashProgram` (all in `handlers/gates/` unless noted):
- `tool-call-gate-pipeline.ts` — calls `BashProgram.parse`, passes the program to the bash gates.
- `bash-command.ts` — imports the `BashCommand` type; the handler decomposes via `program.commands()`.
- `bash-path.ts``describeBashPathGate` calls `program.pathRuleCandidates(tcc.cwd)`.
- `bash-external-directory.ts``describeBashExternalDirectoryGate` calls `program.externalPaths(tcc.cwd)`.
- `bash-path-extractor.ts` — thin facade `extractExternalPathsFromBashCommand(command, cwd: string)` over `BashProgram` (already types `cwd: string`).
AGENTS.md / skill constraints that apply:
- `package-pi-permission-system` skill: the parser is module-scoped state that persists across same-cwd session switches ([earendil-works/pi#5905]); this change does not touch that.
SKILL.md references the classifiers by name (`classifyTokenAsPathCandidate` / `classifyTokenAsRuleCandidate`) but not by file path, so no SKILL.md edit is needed.
- `docs/architecture/architecture.md` carries a source-tree layout block and a Mermaid roadmap graph that reference `bash-program.ts` and `bash-token-classification.ts` by path — those must be updated when the files move.
- Deferred-to-tail work from [#474]: its architecture `Outcome:` line still reads "drops below ~670 LOC" against the actual 695.
Fold the correction into this plan's doc step.
## Design Overview
The design has three parts: the `cwd` type fix (a self-contained correctness change that lands first), the born-ready eager-resolution model, and the Option-B module split.
The facade-scope question (where the projection lives) is resolved to **Option B** because born-ready makes it the only coherent choice — once `parse()` resolves eagerly, there is no call-time orchestration left for the facade to retain, so the projection lives wholly in `cwd-projection.ts`.
### Born-ready data flow — eager resolution at parse time
`parse(command, cwd)` does all the work; the instance stores only finished answers.
`PathCandidate[]` and `EffectiveBase` are local to the projection call and never reach the instance.
```mermaid
flowchart TD
IN["command string + cwd<br/>(cwd: string — always present)"]
IN --> PARSE["BashProgram.parse(command, cwd)<br/>[program.ts]"]
PARSE --> GP["getParser() then parser.parse()<br/>[parser.ts]"]
GP --> TREE["AST tree (rootNode)"]
TREE --> CE["collectCommands(rootNode)<br/>[command-enumeration.ts]"]
TREE --> CP["collectPathCandidates(rootNode)<br/>[cwd-projection.ts walk]"]
CP --> CAND["PathCandidate[] (local, internal to projection)<br/>{token, base: EffectiveBase}"]
CAND --> PE["projectExternalPaths(candidates, cwd)<br/>[cwd-projection.ts]"]
CAND --> PR["projectRuleCandidates(candidates, cwd)<br/>[cwd-projection.ts]"]
CE --> CTOR["new BashProgram(commandUnits,<br/>resolvedExternalPaths, resolvedRuleCandidates)"]
PE --> CTOR
PR --> CTOR
CTOR --> INST["born-ready instance<br/>(holds 3 resolved arrays only)"]
INST --> G1["commands() returns BashCommand[]"]
INST --> G2["pathRuleCandidates() returns BashPathRuleCandidate[]"]
INST --> G3["externalPaths() returns string[]"]
```
The expensive tree-sitter parse still happens once; the projection (cheap, pure) now also runs once at parse time instead of on every slice call.
Outputs are byte-for-byte identical to the lazy design — the slices were already pure functions of `(candidates, cwd)`, and `cwd` is fixed for the call.
### Module layout — Option B, born-ready
```mermaid
flowchart TD
subgraph dom["src/access-intent/bash/ (the cohesive sub-domain)"]
prog["program.ts — BashProgram (born-ready)<br/>parse(command, cwd) + 3 getters (~110 LOC)<br/>stores resolved arrays only; no classifier/path-utils imports"]
proj["cwd-projection.ts<br/>walk + projectExternalPaths/projectRuleCandidates<br/>owns EffectiveBase, PathCandidate, BashPathRuleCandidate (~420 LOC)"]
enm["command-enumeration.ts<br/>collectCommands + descent, owns BashCommand"]
clsf["token-classification.ts (relocated, unchanged)"]
base["parser.ts, node-text.ts, token-collection.ts (already here)"]
end
prog --> enm
prog --> proj
prog --> base
proj --> clsf
proj --> base
proj --> putils["path-utils, canonicalize-path"]
subgraph gates["src/handlers/gates/ (consumers — depend INTO the domain)"]
pipe["tool-call-gate-pipeline.ts → parse(command, tcc.cwd)"]
bcmd["bash-command.ts → program.commands()"]
bpath["bash-path.ts → program.pathRuleCandidates()"]
bext["bash-external-directory.ts → program.externalPaths()"]
bextr["bash-path-extractor.ts (thin facade)"]
end
pipe --> prog
bcmd --> enm
bpath --> prog
bext --> prog
bextr --> prog
```
`EffectiveBase` / `PathCandidate` / `BashPathRuleCandidate` live in `cwd-projection.ts`; `BashCommand` lives in `command-enumeration.ts` (its producer); `program.ts` imports only what its three getters return.
### cwd type fix — dead branches removed
Narrowing `ToolCallContext.cwd` to `string` makes the type checker enforce the invariant and turns five gate branches into compile errors-if-kept (they read a property that is now always present):
| File | Dead branch removed | Replacement |
| ---------------------------- | -------------------------------------------------------------------- | ----------------------------------------------- |
| `bash-external-directory.ts` | `\|\| !tcc.cwd` in the guard | `if (tcc.toolName !== "bash") return null;` |
| `external-directory.ts` | `if (!tcc.cwd) return null;` | removed (guard above it still applies) |
| `skill-read.ts` | `if (tcc.cwd === undefined) return null;` | removed |
| `path.ts` | `tcc.cwd ? normalizePathForComparison(filePath, tcc.cwd) : filePath` | `normalizePathForComparison(filePath, tcc.cwd)` |
| `tool.ts` | `tcc.cwd ? normalizePathForComparison(path, tcc.cwd) : path` | `normalizePathForComparison(path, tcc.cwd)` |
The `getPolicyValuesForRuleCandidate` `if (!cwd) { literal-only }` branch is likewise dead under born-ready (the projection always has `cwd`) and is removed with it.
### Born-ready BashProgram sketch
```typescript
export class BashProgram {
private constructor(
private readonly commandUnits: readonly BashCommand[],
private readonly resolvedExternalPaths: readonly string[],
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
) {}
static async parse(command: string, cwd: string): Promise<BashProgram> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) return new BashProgram([], [], []);
try {
const candidates = collectPathCandidates(tree.rootNode);
return new BashProgram(
collectCommands(tree.rootNode),
projectExternalPaths(candidates, cwd),
projectRuleCandidates(candidates, cwd),
);
} finally {
tree.delete();
}
}
commands(): BashCommand[] { return [...this.commandUnits]; }
externalPaths(): string[] { return [...this.resolvedExternalPaths]; }
pathRuleCandidates(): BashPathRuleCandidate[] { return [...this.resolvedRuleCandidates]; }
}
```
Consumer call sites get *simpler* (no `cwd` argument on the getters):
```typescript
// tool-call-gate-pipeline.ts
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(command, tcc.cwd) // tcc.cwd is now string
: null;
// bash-external-directory.ts → program.externalPaths()
// bash-path.ts → program.pathRuleCandidates()
// bash-path-extractor.ts → (await BashProgram.parse(command, cwd)).externalPaths()
```
### Alternatives considered
- **Facade scope A (facade retains the projection orchestration) and C (a `resolveCandidateBase()` helper)** — both kept the per-candidate loop in the facade with the methods taking `cwd`.
Born-ready eager resolution removes all call-time orchestration, so A/C no longer have anything to retain; Option B is the only coherent layout.
- **Collapse `BashProgram` to a function returning a record** — under eager resolution the class is close to a data holder, so this is a legitimate direction.
Deferred to Step 4 ([#476]), which already reshapes this value object (retyping `externalPaths` for `AccessPath`); doing it here would pre-empt that step.
- **Split the `cwd` type fix into its own prerequisite issue** — considered; the operator chose to land it in #475 (all-in), since born-ready couples the `ToolCallContext` narrowing at the pipeline seam and the five-gate cleanup is small and mechanical.
## Module-Level Changes
New files (all under `src/access-intent/bash/`):
- `command-enumeration.ts``collectCommands`, `collectCommandsInto`, `makeUnit`, `descendCommandChildren`, `collectSubstitutionCommands`, the `COMMAND_ENUM_DESCEND` / `COMMAND_ENUM_SKIP` / `NESTED_EXECUTION_CONTEXTS` tables, and the `BashCommand` interface (the type moves to its producer).
Imports `TSNode` from `parser.ts` and `BashCommandContext` from `#src/types`.
Exports `collectCommands` and `BashCommand`.
- `cwd-projection.ts` — the projection walk (`collectPathCandidates`, `walkForCandidates`, `walkCurrentShellSequence`, `walkPipeline`, `foldPipelineFirstStage`, `foldListExceptTerminal`, `isBackgrounded`, `tagTokens`, `foldCd`, `cdLiteralTarget`, `literalTextOf`, `CWD_BASE`, `UNKNOWN_BASE`), the per-candidate helpers (`getPolicyValuesForRuleCandidate`, `isRelativeCandidate`, both taking `cwd: string`), and the two projection functions `projectExternalPaths(candidates, cwd: string)` / `projectRuleCandidates(candidates, cwd: string)`.
Owns the `EffectiveBase`, `PathCandidate`, and `BashPathRuleCandidate` types.
Drops the dead `if (!cwd)` literal-only branch from `getPolicyValuesForRuleCandidate`.
Imports `TSNode` from `parser.ts`, `ARG_NODE_TYPES` / `SKIP_SUBTREE_TYPES` from `node-text.ts`, the collectors + `extractCommandName` from `token-collection.ts`, the classifiers from `token-classification.ts`, plus `path-utils` and `canonicalize-path`.
Exports `collectPathCandidates`, `projectExternalPaths`, `projectRuleCandidates`, and `BashPathRuleCandidate`.
- `program.ts` — the born-ready `BashProgram` class only (see sketch).
Private constructor takes the three resolved arrays; `parse(command, cwd: string)` resolves eagerly; the three getters are parameter-free.
Imports `getParser` from `parser.ts`, `collectCommands` + `BashCommand` from `command-enumeration.ts`, and `collectPathCandidates` + `projectExternalPaths` + `projectRuleCandidates` + `BashPathRuleCandidate` from `cwd-projection.ts`.
- `token-classification.ts` — relocated `bash-token-classification.ts`, content unchanged except the doc-comment phrase "consumed by `bash-program.ts`" → "consumed by `cwd-projection.ts`".
Removed files:
- `src/handlers/gates/bash-program.ts` — content distributed across the three new files.
- `src/handlers/gates/bash-token-classification.ts` — relocated to `token-classification.ts`.
Changed files — cwd type fix (lands first, Step 1):
- `src/handlers/gates/types.ts``ToolCallContext.cwd: string | undefined``string`.
- `src/handlers/gates/bash-external-directory.ts` — drop `|| !tcc.cwd` from the guard.
- `src/handlers/gates/external-directory.ts` — remove `if (!tcc.cwd) return null;`.
- `src/handlers/gates/skill-read.ts` — remove `if (tcc.cwd === undefined) return null;`.
- `src/handlers/gates/path.ts` — collapse the `tcc.cwd ? … : filePath` ternary.
- `src/handlers/gates/tool.ts` — collapse the `tcc.cwd ? … : path` ternary.
- `test/helpers/gate-fixtures.ts``makeTcc` `cwd` override no longer accepts `undefined` (its default `"/test/project"` stands).
- `test/handlers/gates/bash-external-directory.test.ts` — remove the "returns null when no CWD" test (`makeTcc({ cwd: undefined })`).
- `test/handlers/gates/external-directory.test.ts` — remove the "returns null when no CWD" test.
Grep `ToolCallContext` object literals before the narrowing — only `permission-gate-handler.ts` (uses `ctx.cwd: string`) and `makeTcc` construct one; both already supply a `string`.
Changed files — born-ready signatures (Step 3, with the projection extraction):
- `src/handlers/gates/bash-external-directory.ts``program.externalPaths()` (drop `tcc.cwd` arg; `tcc.cwd` is still read for `getExternalDirectoryPolicyValues` and the descriptor `cwd` field).
- `src/handlers/gates/bash-path.ts``program.pathRuleCandidates()` (drop `tcc.cwd` arg).
- `src/handlers/gates/bash-path-extractor.ts``(await BashProgram.parse(command, cwd)).externalPaths()`; signature unchanged.
- `src/handlers/gates/tool-call-gate-pipeline.ts``BashProgram.parse(command, tcc.cwd)`.
Changed files — import repoints (Step 4, relocation):
- `src/handlers/gates/bash-command.ts``BashCommand` import to `#src/access-intent/bash/command-enumeration`.
- `src/handlers/gates/bash-external-directory.ts`, `bash-path.ts`, `bash-path-extractor.ts`, `tool-call-gate-pipeline.ts``BashProgram` import to `#src/access-intent/bash/program`.
Test files — relocate and/or repoint (Step 4):
- `test/handlers/gates/bash-program.test.ts` to `test/access-intent/bash/program.test.ts`; import to `#src/access-intent/bash/program`.
- `test/handlers/gates/bash-token-classification.test.ts` to `test/access-intent/bash/token-classification.test.ts`; import to `#src/access-intent/bash/token-classification`.
- `test/handlers/gates/bash-external-directory.test.ts`, `bash-path.test.ts`, `bash-command-metamorphic.test.ts`, `test/handlers/external-directory-symlink-acceptance.test.ts``BashProgram` import repoints.
- `test/handlers/gates/tool-call-gate-pipeline.test.ts` — the `vi.mock("#src/handlers/gates/bash-program", …)` factory path to `#src/access-intent/bash/program`; the mock's three methods become zero-arg (no signature change to the mock shape — they already return `[]`).
Born-ready test updates (Step 3): `program.test.ts` (née `bash-program.test.ts`) call sites change from `BashProgram.parse(cmd)` + `.externalPaths(cwd)` / `.pathRuleCandidates(cwd)` to `BashProgram.parse(cmd, cwd)` + parameter-free getters; the "returns the literal token only when no cwd is provided" test is removed (the no-cwd path no longer exists).
`extractExternalPathsFromBashCommand(command, cwd)` keeps its signature, so the ~90 call sites in `test/bash-external-directory.test.ts` are untouched.
Doc updates (`docs/architecture/architecture.md`, Step 5):
- Source-tree layout block: under `access-intent/bash/` add `command-enumeration.ts`, `cwd-projection.ts`, `program.ts`, `token-classification.ts`; remove `bash-program.ts` and `bash-token-classification.ts` from the `handlers/gates/` block.
Update the `program.ts` entry to describe the born-ready value object (parse-time resolution, parameter-free slices).
- Inline `ToolCallContext` listing, if present in the doc's copied gate types, updated to `cwd: string`.
- Mark Phase 6 Step 3 complete: `✅` on the Step 3 heading and the `S3` Mermaid roadmap node.
- Track A narrative note: update to reflect Step 3 landed.
- Fold in the [#474] deferred fix: correct the Step 2 `Outcome:` "drops below ~670 LOC" line.
- Health metrics table: rename the `bash-program.ts` LOC / risk rows to `program.ts` with post-Step-3 actuals.
## Test Impact Analysis
1. **New unit tests the extraction enables.**
Low value, as in Steps 12: `command-enumeration.ts` and `cwd-projection.ts` consume parse-derived `TSNode` trees and `PathCandidate[]`, so isolated tests would mean hand-building tree/candidate fixtures.
The parse-driven `program.test.ts` exercises the walk + projection end to end.
No new isolated unit-test files are required.
2. **Tests that change shape.**
`program.test.ts` parse + slice call sites adopt the born-ready signatures (`parse(cmd, cwd)` + parameter-free getters).
This is mechanical and touches only that file (plus the removed no-cwd case).
3. **Tests removed (dead inputs).**
Three tests assert behavior for `cwd === undefined`, which the narrowed type makes impossible: `bash-external-directory.test.ts` "returns null when no CWD", `external-directory.test.ts` "returns null when no CWD", and `bash-program.test.ts` "returns the literal token only when no cwd is provided".
Removing them is correct — they document an input the SDK never produces.
4. **Tests that must stay as-is.**
Every projection / enumeration / classification assertion pins current behavior; the relocation and born-ready change must keep them green (only call-site shape and import paths change).
## Invariants at risk
A behavior-preserving move must keep every documented invariant green:
- The `cd`-fold projection invariants from [#307] (conservative flagging after a non-literal `cd`) and [#454] (folding a leading current-shell `cd` across a redirect-then-pipe) — pinned by the `externalPaths` cases in `program.test.ts`.
- The never-weaker nested-command enumeration from [#306] — pinned by the `commands()` cases.
- The lexical-vs-canonical return contract from [#418] and cd-aware policy values from [#393] — pinned by the `externalPaths` / `pathRuleCandidates` cases.
These live in tests, not just prose; the eager-resolution change must produce identical arrays.
The removed `cwd`-undefined branches were verified dead against `ExtensionContext.cwd: string`, so their removal does not change production behavior — only the (impossible) undefined-input tests go.
## TDD Order
Each cycle keeps the suite green (behavior-preserving); there is no red phase.
Because the born-ready signature change and the type narrowing each break consumers at the type level, those steps fold the source change, all consumer updates, and all consumer-test updates into one commit.
1. **Fix the `cwd` type widening.**
Narrow `ToolCallContext.cwd` to `string`; remove the dead `cwd`-undefined branches in `bash-external-directory.ts`, `external-directory.ts`, `skill-read.ts`, `path.ts`, `tool.ts`; update `makeTcc`; remove the two "returns null when no CWD" tests.
Run `pnpm run check` + full suite.
Commit `refactor(pi-permission-system): narrow ToolCallContext.cwd to string and drop dead cwd-undefined gate branches`.
2. **Extract command enumeration.**
Create `command-enumeration.ts` with the enumeration functions, tables, and `BashCommand`; remove them from `bash-program.ts`, importing `collectCommands` + `BashCommand` back; repoint `bash-command.ts`'s `BashCommand` import.
Run `pnpm run check` + full suite.
Commit `refactor(pi-permission-system): extract bash command enumeration to its own module`.
3. **Extract the cwd projection and make `BashProgram` born-ready.**
Create `cwd-projection.ts` with the walk, the per-candidate helpers, `projectExternalPaths` / `projectRuleCandidates` (all taking `cwd: string`), and the `EffectiveBase` / `PathCandidate` / `BashPathRuleCandidate` types; drop the dead `if (!cwd)` literal branch.
Rewrite `BashProgram` to born-ready: `parse(command, cwd: string)`, three-array constructor, parameter-free getters.
Update all callers — `bash-external-directory.ts`, `bash-path.ts`, `bash-path-extractor.ts`, `tool-call-gate-pipeline.ts` — and `bash-program.test.ts` (born-ready call sites; remove the no-cwd test).
Run `pnpm run check` + full suite.
Commit `refactor(pi-permission-system): extract bash cwd projection and make BashProgram born-ready`.
4. **Relocate the facade and classifiers into the sub-domain.**
Move `bash-program.ts` to `access-intent/bash/program.ts` and `bash-token-classification.ts` to `access-intent/bash/token-classification.ts`; repoint `cwd-projection.ts`'s classifier import and all gate consumers; relocate `bash-program.test.ts` to `test/access-intent/bash/program.test.ts` and `bash-token-classification.test.ts` to `test/access-intent/bash/token-classification.test.ts`; repoint the remaining test imports and the `tool-call-gate-pipeline.test.ts` `vi.mock` path.
Run `pnpm run check` + full suite + `pnpm run lint` + `pnpm fallow dead-code`.
Commit `refactor(pi-permission-system): relocate bash sub-domain under access-intent/bash`.
5. **Update the architecture doc.**
Apply the layout-block, born-ready `program.ts` description, `ToolCallContext` type note, Step 3 `✅`, Track A, [#474]-deferred `Outcome:` fix, and health-metrics edits.
Commit `docs(pi-permission-system): record Phase 6 Step 3 bash sub-domain relocation`.
Steps 24 may merge if a green intermediate state is awkward, but keep Step 1 first (it is independent and unblocks born-ready) and the doc update (Step 5) last.
## Risks and Mitigations
- **Risk: the born-ready signature change ripples to many call sites.**
`parse` gains a parameter and the three getters lose one, breaking every consumer and test at the type level.
Mitigation: fold the projection extraction, all gate-caller updates, and `program.test.ts` updates into Step 3; `tsc` after the step confirms none were missed.
- **Risk: removing a `cwd`-undefined branch that is actually reachable.**
Mitigation: the invariant is verified against the SDK type (`ExtensionContext.cwd: string`); the narrowed `ToolCallContext.cwd` makes any reachable use a compile error, so `tsc` proves the branches dead.
- **Risk: an import cycle between the new modules.**
Mitigation: every type lives with its producer (`BashCommand` → enumeration, `EffectiveBase`/`PathCandidate`/`BashPathRuleCandidate` → projection), so the graph is acyclic (`program.ts` → both; both → `parser.ts`).
`pnpm run check` after each step confirms.
- **Risk: silently dropping a moved symbol during the large block moves.**
Mitigation: anchor edits on adjacent unique code lines (not decorative rules), re-read each moved region, rely on Biome `noRedeclare` / `noUnusedImports` + `tsc`.
- **Risk: a stale `vi.mock` path silently mocks nothing.**
Mitigation: repoint the `tool-call-gate-pipeline.test.ts` `vi.mock` factory path in Step 4 and confirm the pipeline tests still pass against the mock.
## Open Questions
- None blocking.
Facade scope is resolved to **Option B** (born-ready forces it); the `cwd`-fix scope is resolved to **all-in #475**; the class→function reshape is deferred to Step 4 ([#476]).
- No follow-up issues are filed by this plan: Step 4 ([#476]) covers the `AccessPath` value object and the `BashProgram` reshape; the external-directory gate collapse is Phase 6 Step 5.
[#306]: https://github.com/gotgenes/pi-packages/issues/306
[#307]: https://github.com/gotgenes/pi-packages/issues/307
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#454]: https://github.com/gotgenes/pi-packages/issues/454
[#473]: https://github.com/gotgenes/pi-packages/issues/473
[#474]: https://github.com/gotgenes/pi-packages/issues/474
[#476]: https://github.com/gotgenes/pi-packages/issues/476
[earendil-works/pi#5905]: https://github.com/earendil-works/pi/issues/5905