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,314 @@
---
issue: 574
issue_title: "Support configurable shell-tool aliases for exec_command"
---
# Gate aliased shell invocations through the bash stack
## Release Recommendation
**Release:** ship now — batch "shell-tool-aliases" tail (this issue completes the batch)
This is Phase 11 Step 3 of the pi-permission-system improvement roadmap, tagged `Release: batch "shell-tool-aliases"`.
The batch has two members — Step 2 ([#580], the `shellTools` config surface, already landed on `main` as a deferred `feat:`) and Step 3 ([#574], this issue, the enforcement gate that consumes it).
Step 3 is the batch tail, so landing it cuts the release that carries both: the deferred Step 2 `feat:` commit and this step's `feat:` commits batch into one `feat(pi-permission-system)` release.
## Problem Statement
`classifyToolKind` answers "what does this invocation access?"
from a closed set of hardcoded built-in tool names.
A tool that carries bash semantics under a different name — `@howaboua/pi-codex-conversion` replaces the native `bash` tool with `exec_command` (`cmd` + optional `workdir`) — is classified as a generic extension tool, so it never receives command decomposition, wrapper flooring ([#490]), bash path / external-directory token gates, or `bash:` config rules.
A user's `bash:` deny rules silently do not apply, and the same shell operation is gated differently depending on which toolset is active — an enforcement gap, not a polish item.
Step 2 ([#580]) delivered the `shellTools` config surface (tool name → `{ commandArgument, workdirArgument? }`) with strict validation, cross-scope merge, and docs, but **nothing reads it yet**.
This step consumes it: once the alias is recorded, the dispatch point must route an aliased invocation through the same enforcement the native `bash` tool gets, at parity.
## Goals
- Consume `shellTools` at gate time so an aliased shell tool (e.g. `exec_command`) is gated at parity with native `bash`: command decomposition, wrapper flooring, the `<unparseable-bash-command>` fail-closed sentinel, bash path + external-directory token gates, and `bash:` config rules.
- Introduce **one dispatch point**`resolveShellInvocation(toolName, input, aliases)` in `access-intent/tool-kind.ts` — that decides "does this invocation carry shell semantics, and what is its command + workdir?"
for native bash and aliased tools alike, so the bash gates stop hardcoding `toolName === "bash"` and `input.command`.
- Full `workdir` parity: the alias's `workdirArgument` value becomes the effective resolve base for the aliased command's relative tokens, and `workdir` is itself gated by `external_directory` when it resolves outside the session cwd.
- Preserve the invoked tool's real name in the review log and prompts (`exec_command`, not `bash`) while recording the effective command — a user must see which tool ran what.
- A session "allow for this session" on an aliased shell command adds a `bash:` session rule (so it applies to native `bash` and the alias alike), not an `exec_command:` rule.
- Not breaking: with no `shellTools` config, every tool is classified and gated exactly as today; the new behavior is inert until a user records an alias.
## Non-Goals
- **No new config surface.**
`shellTools` shipped in Step 2 ([#580]); this step only consumes it.
Reintroducing the `ShellToolAlias` export (dropped as a speculative export in [#580]'s `e7cc7260`) happens here as its first real consumer.
- **No per-tool path-map for the aliased command's non-command fields.**
Only `commandArgument` (the shell command) and `workdirArgument` (the effective base) are consumed; any other input field on the aliased tool is ignored, matching the Step 2 config contract.
- **No tool-removal or toolset lever.**
`shellTools` only ever *tightens* enforcement and is inert when the named tool is unregistered.
Opting out of a shell-aliasing extension is a package-disable concern Pi owns, not a permission change.
- **No change to native `bash` behavior.**
The refactor routes native `bash` through the same `resolveShellInvocation` seam, but its resolved `{ command: input.command, workdir: undefined }` reproduces today's behavior exactly — pinned by the existing bash-gate regression suites.
## Background
Relevant existing modules (from the `package-pi-permission-system` skill and the code):
- `src/access-intent/tool-kind.ts``classifyToolKind(toolName): ToolKind` is the single dispatch point for "what does this invocation access?", consumed by `input-normalizer`, `tool-input-path`, the tool-call gate pipeline, `permission-manager`'s `deriveSource`, and the presentation consumers.
It imports only `PATH_BEARING_TOOLS` (AccessPath-free), so `permission-manager.ts` may consume it without breaching the ADR-0002 string boundary.
- `src/handlers/gates/tool-call-gate-pipeline.ts``ToolCallGatePipeline.evaluate` parses the bash command **once** into a shared `BashProgram` (guarded by `classifyToolKind(tcc.toolName) === "bash" && command`, with `command` read from `toRecord(tcc.input).command`), runs the six gate producers in order, and resolves the per-tool check (routing bash through `resolveBashCommandCheck`).
Its narrow `ToolCallGateInputs` interface is what `PermissionSession` satisfies structurally.
- `src/handlers/gates/bash-path.ts` and `bash-external-directory.ts` — both open with `if (tcc.toolName !== "bash") return null;` and re-derive `command` from `toRecord(tcc.input).command`.
They read their path slices from the injected `BashProgram` (`pathRuleCandidates()` / `externalPaths()`).
- `src/access-intent/bash/program.ts``BashProgram.parse(command, normalizer, isPromotablePathToken?)` parses once (tree-sitter) and eagerly resolves the three slices via `BashPathResolver`.
- `src/access-intent/bash/bash-path-resolver.ts` — walks the AST once, threading an `EffectiveBase` (`{ kind: "known"; offset }` | `{ kind: "unknown" }`) seeded at `CWD_BASE = { kind: "known", offset: "" }`.
`foldCd` folds a literal `cd` target into the base via `normalizer.interpretBashCdTarget`.
`resolveBase(offset)` resolves a relative-or-absolute offset against the baked cwd; containment (`isBoundaryOutsideWorkingDirectory`) always measures against the baked cwd.
- `src/path-normalizer.ts` — bakes the session cwd for the **containment boundary** only (`canonicalCwd`); the **resolve base** is threaded per-token via `forPath`/`forBashToken`'s `resolveBase` option and the walk's `EffectiveBase`.
This separation is what makes `workdir` a small addition.
- `src/access-intent/input-normalizer.ts``normalizeInput(toolName, input, mcpServerNames)` maps a raw invocation to `{ surface, values, resultExtras }`; the bash branch reads `record.command`.
- `src/access-intent/tool-input-path.ts``getPathBearingToolPath` (built-in only) and `getToolInputPath` (extension/MCP-aware) extract the file path for the cross-cutting `path` / `external_directory` gates; both return `null` for `bash`.
- `src/handlers/gates/tool.ts``describeToolGate` builds the per-tool descriptor; `deriveSuggestionValue` and `helpers.ts`'s `deriveDecisionValue` dispatch on `classifyToolKind(tcc.toolName)` to pick the decision/suggestion value shape (command / target / path).
- `src/permission-session.ts` — exposes `getPathNormalizer`, `getToolPreviewLimits`, etc. to the pipeline; `get config()` returns `configStore.current()`, which now carries `shellTools` (Step 2).
Constraints from AGENTS.md / the package skill that apply:
- The gate fails closed ([#452]): a non-empty command that parses to zero command units resolves to `ask` with the `<unparseable-bash-command>` sentinel — this must hold for aliased commands too.
- Default to least privilege; wildcard/over-match is a bypass — new classification must be explicit and tested.
- Keep one dispatch point (OCP): route native bash *and* aliased tools through the same `resolveShellInvocation`, do not scatter `toolName === "bash" || isAlias(...)` across the gates.
- `permission-manager.ts` must not import `AccessPath`; the alias data is plain strings (`ShellToolsConfig`), so it respects the string boundary.
- Keep the invoked tool name in logs (skill: "the review log records both the invoked tool name and the effective command").
## Design Overview
### The single dispatch point
Add to `src/access-intent/tool-kind.ts` (AccessPath-free, string-only — safe for every consumer):
```typescript
import type { ShellToolsConfig } from "#src/config-schema";
/** A shell invocation's effective command and optional working directory. */
export interface ShellInvocation {
/** The shell command string to decompose and gate. */
command: string;
/** The working directory the command runs in, if the tool projects one. */
workdir: string | undefined;
}
/**
* Decide whether a tool invocation carries shell semantics, and if so extract
* its command and working directory. Native `bash` and any tool recorded in
* `shellTools` both yield a {@link ShellInvocation}; every other tool yields
* `null`. The single dispatch point the bash gates consume instead of
* re-deriving `toolName === "bash"` and reading `input.command`.
*/
export function resolveShellInvocation(
toolName: string,
input: unknown,
aliases: ShellToolsConfig | undefined,
): ShellInvocation | null;
```
Behavior:
- `toolName === "bash"``{ command: getNonEmptyString(input.command) ?? "", workdir: undefined }` (native — reproduces today's extraction).
- `aliases?.[toolName]` present → read `input[alias.commandArgument]` as the command and, when `alias.workdirArgument` is set, `input[alias.workdirArgument]` as the workdir (both via `getNonEmptyString`, `undefined` when absent/empty).
- otherwise → `null`.
Design notes:
- `classifyToolKind` stays **unchanged and config-free** — it still answers the static "what kind of built-in is this name?"
question the presentation consumers and the manager need without config.
The alias consult is a **separate** function because it needs config (the alias map) and its product (`{ command, workdir }`) is richer than a `ToolKind` string.
This keeps `classifyToolKind`'s AccessPath-free / config-free contract intact (the ADR-0002 string boundary, the presentation consumers that have no config) while giving the gates one place to ask "is this a shell, and what is it running?".
- `ShellInvocation` is a value object the pipeline threads down; the gates never re-read `input`.
### workdir is an implicit leading `cd`
The `PathNormalizer` bakes the session cwd for the **containment boundary** only; the **resolve base** for a relative token is threaded per-token as the walk's `EffectiveBase.offset` (that is how inline `cd` already shifts the base).
So `workdir` is conceptually "an implicit leading `cd <workdir>`" and reuses that machinery — no rearchitecture of the containment / `AccessPath` / cd-fold layers.
Two contained additions inside the bash parse layer:
1. **Seed the walk's initial base from `workdir`.**
`BashPathResolver.collectPathCandidates` seeds at `CWD_BASE = { offset: "" }` in one place.
`BashProgram.parse` gains an optional `workdir` and computes the initial `EffectiveBase` from it, reusing the existing `cd`-target interpretation so absolute / relative / win32-MSYS all behave identically to an inline `cd <workdir>`.
Factor the target→base fold currently inline in `foldCd` into a small reusable helper (`deriveBaseFromCdTarget(base, target)`) and call it from both `foldCd` and the initial seed.
With no `workdir`, the seed stays `CWD_BASE` (native behavior unchanged).
2. **Add `workdir`'s own `AccessPath` to the external set when it resolves outside the session cwd.**
A real `cd /etc && …` flags `/etc` via the `cd` argument *token*; the seeded base has no such token, so `BashProgram.parse` explicitly resolves `workdir` (`normalizer.forBashToken(workdir)`), and when `isBoundaryOutsideWorkingDirectory` is true, prepends it to `resolvedExternalPaths` (deduped by canonical form).
The existing `describeBashExternalDirectoryGate` then flags it with **no gate-signature change** — it just reads `externalPaths()`.
Containment stays measured against the **session** cwd throughout, so `workdir: "/etc"` with a relative token `passwd` resolves to `/etc/passwd` (correct base) and is flagged external (escaped the session sandbox), and `workdir: "/etc"` itself is flagged.
A `workdir` inside the session cwd shifts the base but produces no external prompt.
### Threading the resolved command into the bash gates
The two bash gates stop hardcoding `toolName === "bash"` and `input.command`.
They accept the resolved `command: string | null` (from `resolveShellInvocation(...)?.command`) and the shared `BashProgram | null`:
```typescript
// describeBashPathGate(tcc, command, bashProgram, resolver)
if (!command || !bashProgram) return null;
// … unchanged: read bashProgram.pathRuleCandidates(), resolve on "path" surface …
```
The pipeline resolves the shell invocation once and threads it:
```typescript
async evaluate(tcc, runner) {
const aliases = this.inputs.getShellToolAliases();
const shell = resolveShellInvocation(tcc.toolName, tcc.input, aliases);
const normalizer = this.inputs.getPathNormalizer();
const bashProgram = shell?.command
? await BashProgram.parse(
shell.command,
normalizer,
this.inputs.getPromotablePathTokenMatcher(tcc.agentName ?? undefined),
{ workdir: shell.workdir },
)
: null;
// bash gates receive shell?.command ?? null and bashProgram
// per-tool gate: shell ? resolveBashCommandCheck(shell.command, bashProgram.commands(), …) : …
}
```
The gate producers pass `shell?.command ?? null` to `describeBashPathGate` / `describeBashExternalDirectoryGate`, and `resolvePerToolCheck` routes on `shell` (not `classifyToolKind === "bash"`).
Because `resolveBashCommandCheck` already emits its intents on the **`bash` surface**, an aliased command is evaluated against `bash:` rules automatically, and the per-tool descriptor's decision/session-approval surface is `bash` (see below).
### Presentation: bash surface, real tool name in logs
For a shell invocation, the per-tool descriptor (`describeToolGate`) must:
- derive its decision value and session-approval pattern from the **command** on the **`bash` surface** (so "allow for session" writes a `bash:` rule, and the decision value is the command) — not from `classifyToolKind("exec_command")` (which is `extension` → would yield the tool name);
- keep `toolName: tcc.toolName` (`exec_command`) in `logContext` / `promptDetails` so the review log shows which tool ran.
`describeToolGate` (and its `deriveSuggestionValue`, plus `helpers.deriveDecisionValue`) therefore need the effective shell command / surface for aliased tools.
Thread an optional `shell: ShellInvocation | null` (or the effective surface + value) into `describeToolGate` so a shell invocation uses `{ surface: "bash", value: command }` for the decision and `SessionApproval.single("bash", pattern)`, while native bash (already `toolName === "bash"`) is unchanged.
The bash path / external-directory gates already set `toolName: tcc.toolName` in their log contexts, so they preserve `exec_command` for free once they run.
### Consumer call-site sketch (pipeline → gates)
```typescript
// pipeline
const shell = resolveShellInvocation(tcc.toolName, tcc.input, aliases); // one consult
// … parse once with workdir seed …
() => describeBashExternalDirectoryGate(tcc, shell?.command ?? null, bashProgram, this.resolver),
() => describeBashPathGate(tcc, shell?.command ?? null, bashProgram, this.resolver),
() => { const { toolCheck } = this.resolvePerToolCheck(tcc, shell, bashProgram, command, normalizer); },
```
This follows Tell-Don't-Ask: the gates receive the resolved command and program; they never reach back into `tcc.input` for the field name or re-classify the tool.
## Module-Level Changes
- `src/access-intent/tool-kind.ts` — add `ShellInvocation` interface + `resolveShellInvocation(toolName, input, aliases)`; import `ShellToolsConfig` (string-only, AccessPath-free — respects ADR-0002).
Reintroduce the value-object's field types as needed; `classifyToolKind` itself is untouched.
- `src/handlers/gates/tool-call-gate-pipeline.ts` — resolve `shell` once via `resolveShellInvocation` (through a new `inputs.getShellToolAliases()`); parse `BashProgram` from `shell.command` with the `{ workdir }` seed; thread `shell?.command ?? null` into the two bash gates; route `resolvePerToolCheck` on `shell`; pass `shell` into the per-tool descriptor.
Replace both `classifyToolKind(tcc.toolName) === "bash"` sites.
- `src/handlers/gates/tool-call-gate-pipeline.ts` (`ToolCallGateInputs`) — add `getShellToolAliases(): ShellToolsConfig | undefined`.
- `src/permission-session.ts` — implement `getShellToolAliases()` returning `this.config.shellTools`.
- `src/handlers/gates/bash-path.ts` — signature `describeBashPathGate(tcc, command, bashProgram, resolver)`; drop the `tcc.toolName !== "bash"` guard and the internal `toRecord(tcc.input).command` read; guard `if (!command || !bashProgram) return null`.
- `src/handlers/gates/bash-external-directory.ts` — same signature change and guard rework; the `externalPaths()` read is unchanged (workdir enters via `BashProgram`).
- `src/access-intent/bash/program.ts``parse(command, normalizer, isPromotablePathToken?, options?: { workdir?: string })`; compute the initial base from `workdir` and add the workdir external `AccessPath` when outside cwd.
- `src/access-intent/bash/bash-path-resolver.ts` — accept an injected initial `EffectiveBase` (default `CWD_BASE`); factor the target→base fold out of `foldCd` into `deriveBaseFromCdTarget` and reuse it for the workdir seed; optionally expose the workdir-external contribution (or compute it in `program.ts`).
- `src/handlers/gates/tool.ts``describeToolGate` (and `deriveSuggestionValue`) accept the effective shell command/surface so a shell invocation uses the `bash` surface + command value while keeping `tcc.toolName` in logs.
- `src/handlers/gates/helpers.ts``deriveDecisionValue` yields the command for a shell invocation (via the threaded shell command / effective surface), not the tool name.
- `src/access-intent/input-normalizer.ts` — the `normalizeInput` bash branch is reached by the manager's `checkPermission(toolName, input)` entry; make its command extraction alias-aware **only if** a consumer routes an aliased `(toolName, input)` through it.
Grep confirms the enforcement path is the gate pipeline (which uses `resolveShellInvocation` directly), and the advisory service resolves `bash` by explicit command string, so `normalizeInput` may not need the alias.
Decide during TDD step 3 by tracing `normalizeInput` callers; if untouched, note it in the retro.
- `test/*` — new + updated gate-parity tests (see TDD Order).
- `config/config.example.json`, `docs/configuration.md` — the `shellTools` block already documents the config; add a short "what enforcement it triggers" note pointing at the bash-parity behavior now that it is live (Step 2 documented the *shape*; Step 3 documents the *effect*).
- `README.md` — the `shellTools` mention already exists (Step 2); update only if it claims "config only / no enforcement".
- `docs/architecture/architecture.md` — mark Phase 11 Step 3 complete (`✅` on the Step 3 heading and Mermaid node `S3`); update the `shellTools` health-metric row to note gate-parity is tested/live if the wording implies config-only.
No `rule.ts`-type listing changes (no `Rule`/`Ruleset` field added).
Grep confirmation performed during planning: the bash gates' `toolName !== "bash"` guards live only in `bash-path.ts` and `bash-external-directory.ts`; the pipeline's two `classifyToolKind === "bash"` sites are the only pipeline-level bash discriminators; `BashProgram.parse` has three call sites (`tool-call-gate-pipeline.ts`, `bash-advisory-check.ts`, `bash-path-extractor.ts`) — the new optional `options` arg is backward-compatible, so the advisory and extractor callers are untouched.
## Test Impact Analysis
This step consumes an existing seam and threads a resolved value; it is not a pure extraction, but the questions still apply:
1. **New tests enabled**
- `resolveShellInvocation` unit tests (native bash, aliased with/without workdir, unknown tool, missing command field, empty fields) — a new pure dispatch point testable in isolation.
- Gate-parity tests: an aliased `exec_command` invocation gets command decomposition, wrapper flooring, the `<unparseable-bash-command>` sentinel, `bash:` rules, bash path + external-directory token gates, and (workdir) relative-base resolution + workdir-escape prompts — asserted against the *same* expectations as native bash.
- `BashProgram.parse` workdir-seed unit tests (relative token resolves against workdir; workdir-escape adds an external path; absolute token base-independent; no-workdir reproduces `CWD_BASE`).
2. **Redundant tests** — none removed.
The native-bash gate suites stay as-is and become the parity oracle the aliased cases assert against.
3. **Tests that must stay** — the native-bash bash-path / bash-external-directory / pipeline suites genuinely exercise the surface being generalized; they pin that the `resolveShellInvocation` refactor did not change native behavior (the `{ command: input.command, workdir: undefined }` path).
## Invariants at risk
This step touches the bash gate pipeline, the bash parse layer, and the per-tool descriptor — surfaces earlier phase steps refactored.
- **[#452] fail-closed sentinel** — a non-empty command parsing to zero command units resolves to `ask` with `<unparseable-bash-command>`.
Pinned by the existing bash-command fail-closed tests; add an aliased-tool case so an `exec_command` opaque payload also fails closed.
- **[#308] parse-once invariant** — the three bash gates share a single `BashProgram`.
Pinned by the pipeline tests; the workdir seed keeps parsing to one `BashProgram.parse` call per evaluate.
- **[#490] wrapper flooring** — `sudo`/`bash -c`/`eval`/… floor `allow``ask`.
Pinned by the wrapper-flooring suite; add an aliased-tool case (`exec_command` running `sudo …` floors).
- **[#418]/[#486]/[#502] path-surface canonical matching** — bash path candidates resolve on the `access-path` intent with lexical canonical aliases.
Unchanged; the aliased command's tokens flow through the identical `BashPathResolver`.
- **[#533] win32 Git Bash semantics** — bash tokens carry MSYS semantics on win32.
The workdir seed reuses `interpretBashCdTarget`, so a win32 `workdir` (`/c/x` drive-mount, `/tmp` non-mount) is interpreted consistently with an inline `cd`; add a win32 workdir-seed test (`win32PathFlavor`).
No earlier step's documented `Outcome:` invariant is regressed — native bash routes through the same seam with identical extraction, and the new behavior is inert without a `shellTools` config.
## TDD Order
1. **Single dispatch point** (`test: add resolveShellInvocation cases``feat(pi-permission-system): add resolveShellInvocation dispatch point`).
- Red: unit tests for `resolveShellInvocation` — native bash yields `{ command, workdir: undefined }`; an aliased tool with `{ commandArgument: "cmd", workdirArgument: "workdir" }` extracts both; `workdirArgument` absent → `workdir: undefined`; missing/empty command field → `command: ""`; unknown tool + no alias → `null`; `aliases: undefined` → native-bash-only.
- Green: add `ShellInvocation` + `resolveShellInvocation` to `tool-kind.ts` (import `ShellToolsConfig`; reintroduce any needed alias field type).
- Verify: `pnpm run check`, the new tests, `pnpm fallow dead-code` (the new export has its consumer added in step 3 — if `dead-code` flags it before then, fold step 3's first consumer into this commit, or land steps 13 together; see the batch note below).
2. **Bash gates consume the resolved command** (`refactor(pi-permission-system): thread resolved command into bash gates`).
- Red: update `bash-path.test.ts` / `bash-external-directory.test.ts` to the new `(tcc, command, bashProgram, resolver)` signature; native-bash expectations unchanged (pass `input.command` as the threaded command).
- Green: change both gate signatures; drop the `toolName !== "bash"` guards and internal `command` re-derivation; guard `if (!command || !bashProgram) return null`.
Update the pipeline's two gate-producer call sites to pass the (still `input.command`-derived, this step) command.
- Verify: `pnpm run check`, the two gate suites + the pipeline suite green (native behavior identical — `refactor:` is a `hidden:` changelog type, correct for a no-behavior-change step).
- Note: this is a lift-and-shift enabling step — native bash still supplies the command; step 3 swaps the source to `resolveShellInvocation`.
3. **Pipeline routes aliased tools through the bash stack** (command-surface parity) (`feat(pi-permission-system): gate aliased shell tools through the bash stack`).
- Red: pipeline / integration tests — with `shellTools: { exec_command: { commandArgument: "cmd" } }`, an `exec_command` call with `{ cmd: "npm install" }` evaluates against `bash:` rules (deny/ask honored), decomposes a chained command, floors a `sudo`/`bash -c` wrapper, fails closed on an opaque payload, and gates an absolute-path token via bash path / external-directory — all against the native-bash oracle.
Assert the review log records `toolName: "exec_command"` with the effective command, and a session "allow" writes a `bash:` rule.
- Green: add `getShellToolAliases` to `ToolCallGateInputs` + `PermissionSession`; resolve `shell` once in the pipeline and thread `shell?.command` / `shell` into the bash gates, `resolvePerToolCheck`, and the per-tool descriptor; update `describeToolGate` / `deriveSuggestionValue` / `deriveDecisionValue` for the effective `bash` surface + command value while preserving `tcc.toolName` in logs.
Trace `normalizeInput` callers; make the bash branch alias-aware only if a real consumer needs it (else leave it and note in retro).
- Verify: `pnpm run check`, `pnpm -r run test` for the package, `pnpm fallow dead-code`.
4. **workdir full parity** (`feat(pi-permission-system): resolve and gate aliased shell workdir`).
- Red: `BashProgram.parse` workdir-seed tests (relative token resolves against `workdir`; workdir-escape `/etc` adds an external path; absolute token base-independent; no-workdir == `CWD_BASE`; win32 `workdir` via `win32PathFlavor`) plus a pipeline test that an `exec_command` with `{ cmd: "cat passwd", workdir: "/etc" }` prompts `external_directory` for both `/etc` and `/etc/passwd`.
- Green: add the `{ workdir }` option to `BashProgram.parse`; factor `deriveBaseFromCdTarget` out of `foldCd`; seed `BashPathResolver`'s initial `EffectiveBase` from `workdir`; add the workdir external `AccessPath` when outside cwd; pass `shell.workdir` from the pipeline.
- Verify: `pnpm run check`, `pnpm -r run test`, `pnpm fallow dead-code`.
5. **Docs + example + roadmap** (`docs(pi-permission-system): document live shellTools enforcement`).
- Update `docs/configuration.md` (and `README.md` / `config.example.json` if they imply config-only) to state the enforcement `shellTools` now triggers (bash parity, workdir base + external_directory gating).
- Mark Phase 11 Step 3 complete in `docs/architecture/architecture.md` (`✅` on the Step 3 heading and Mermaid node `S3`); update the `shellTools` health-metric row wording to reflect live gate-parity.
- Verify: `pnpm exec rumdl check` on the edited markdown; `config.example.json` still parses/validates.
Batch note: steps 14 add `feat:` behavior; the `resolveShellInvocation` export in step 1 has no consumer until step 3, which the `fallow dead-code` gate flags (the [#580] speculative-export lesson).
Either fold step 1's export into step 3, or land steps 13 in close succession and run `fallow dead-code` only after step 3.
Prefer keeping the commits separate but running the `dead-code` gate at the step-3 boundary, not the step-1 boundary.
## Risks and Mitigations
- **Native-bash regression from the `resolveShellInvocation` refactor** — mitigated by step 2 being a pure `refactor:` with the native-bash suites as the unchanged oracle, and step 1's `resolveShellInvocation` reproducing `{ command: input.command, workdir: undefined }` exactly.
- **Silent classification bypass (an aliased tool not routed to bash)** — mitigated by explicit parity tests asserting `bash:` rules, wrapper flooring, and the fail-closed sentinel fire for the aliased tool, against the native oracle.
- **workdir base vs. containment confusion** — the design keeps the containment boundary at the session cwd (baked in the normalizer) and only shifts the resolve base; pinned by the `/etc` + relative-token test asserting *both* `/etc` and `/etc/passwd` prompt.
- **Presentation leak (log shows `bash` not the real tool)** — mitigated by the log-context assertion (`toolName: "exec_command"`) in step 3 and by keeping the bash gates' existing `toolName: tcc.toolName` log fields.
- **`fallow dead-code` on the step-1 export** — mitigated by the batch note (run the gate at the step-3 boundary); the [#580] retro flagged this exact class.
- **`normalizeInput` divergence** — if a consumer routes an aliased `(toolName, input)` through the manager's `checkPermission`, the advisory/manager path could disagree with the gate; mitigated by tracing callers in step 3 and adding alias-awareness only where a real consumer needs it.
## Open Questions
- Whether `normalizeInput`'s bash branch needs alias-awareness depends on whether any consumer routes an aliased `(toolName, input)` through `permission-manager.checkPermission` (vs. the gate pipeline, which uses `resolveShellInvocation` directly).
Resolved during TDD step 3 by tracing callers; recorded in the retro.
No follow-up issue filed pre-emptively — the enforcement path is the gate pipeline, and the advisory service resolves `bash` by explicit command string.
[#308]: https://github.com/gotgenes/pi-packages/issues/308
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#452]: https://github.com/gotgenes/pi-packages/issues/452
[#486]: https://github.com/gotgenes/pi-packages/issues/486
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#502]: https://github.com/gotgenes/pi-packages/issues/502
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#574]: https://github.com/gotgenes/pi-packages/issues/574
[#580]: https://github.com/gotgenes/pi-packages/issues/580