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

30 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
574 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 pointresolveShellInvocation(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.tsclassifyToolKind(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.tsToolCallGatePipeline.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.tsBashProgram.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.tsnormalizeInput(toolName, input, mcpServerNames) maps a raw invocation to { surface, values, resultExtras }; the bash branch reads record.command.
  • src/access-intent/tool-input-path.tsgetPathBearingToolPath (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.tsdescribeToolGate 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):

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:

// 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:

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)

// 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.tsparse(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.tsdescribeToolGate (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.tsderiveDecisionValue 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 flooringsudo/bash -c/eval/… floor allowask. 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 casesfeat(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.