--- 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 `` 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 `` 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 `" 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 `. 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 `` 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 ``. 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 1–3 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 1–4 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 1–3 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