Files
my-pi/pi-permission-system/docs/plans/0574-gate-aliased-shell-tools-bash-stack.md
T

315 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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