30 KiB
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
shellToolsat gate time so an aliased shell tool (e.g.exec_command) is gated at parity with nativebash: command decomposition, wrapper flooring, the<unparseable-bash-command>fail-closed sentinel, bash path + external-directory token gates, andbash:config rules. - Introduce one dispatch point —
resolveShellInvocation(toolName, input, aliases)inaccess-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 hardcodingtoolName === "bash"andinput.command. - Full
workdirparity: the alias'sworkdirArgumentvalue becomes the effective resolve base for the aliased command's relative tokens, andworkdiris itself gated byexternal_directorywhen it resolves outside the session cwd. - Preserve the invoked tool's real name in the review log and prompts (
exec_command, notbash) 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 nativebashand the alias alike), not anexec_command:rule. - Not breaking: with no
shellToolsconfig, 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.
shellToolsshipped in Step 2 (#580); this step only consumes it. Reintroducing theShellToolAliasexport (dropped as a speculative export in #580'se7cc7260) 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) andworkdirArgument(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.
shellToolsonly 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
bashbehavior. The refactor routes nativebashthrough the sameresolveShellInvocationseam, 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): ToolKindis the single dispatch point for "what does this invocation access?", consumed byinput-normalizer,tool-input-path, the tool-call gate pipeline,permission-manager'sderiveSource, and the presentation consumers. It imports onlyPATH_BEARING_TOOLS(AccessPath-free), sopermission-manager.tsmay consume it without breaching the ADR-0002 string boundary.src/handlers/gates/tool-call-gate-pipeline.ts—ToolCallGatePipeline.evaluateparses the bash command once into a sharedBashProgram(guarded byclassifyToolKind(tcc.toolName) === "bash" && command, withcommandread fromtoRecord(tcc.input).command), runs the six gate producers in order, and resolves the per-tool check (routing bash throughresolveBashCommandCheck). Its narrowToolCallGateInputsinterface is whatPermissionSessionsatisfies structurally.src/handlers/gates/bash-path.tsandbash-external-directory.ts— both open withif (tcc.toolName !== "bash") return null;and re-derivecommandfromtoRecord(tcc.input).command. They read their path slices from the injectedBashProgram(pathRuleCandidates()/externalPaths()).src/access-intent/bash/program.ts—BashProgram.parse(command, normalizer, isPromotablePathToken?)parses once (tree-sitter) and eagerly resolves the three slices viaBashPathResolver.src/access-intent/bash/bash-path-resolver.ts— walks the AST once, threading anEffectiveBase({ kind: "known"; offset }|{ kind: "unknown" }) seeded atCWD_BASE = { kind: "known", offset: "" }.foldCdfolds a literalcdtarget into the base vianormalizer.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 viaforPath/forBashToken'sresolveBaseoption and the walk'sEffectiveBase. This separation is what makesworkdira small addition.src/access-intent/input-normalizer.ts—normalizeInput(toolName, input, mcpServerNames)maps a raw invocation to{ surface, values, resultExtras }; the bash branch readsrecord.command.src/access-intent/tool-input-path.ts—getPathBearingToolPath(built-in only) andgetToolInputPath(extension/MCP-aware) extract the file path for the cross-cuttingpath/external_directorygates; both returnnullforbash.src/handlers/gates/tool.ts—describeToolGatebuilds the per-tool descriptor;deriveSuggestionValueandhelpers.ts'sderiveDecisionValuedispatch onclassifyToolKind(tcc.toolName)to pick the decision/suggestion value shape (command / target / path).src/permission-session.ts— exposesgetPathNormalizer,getToolPreviewLimits, etc. to the pipeline;get config()returnsconfigStore.current(), which now carriesshellTools(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
askwith 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 scattertoolName === "bash" || isAlias(...)across the gates. permission-manager.tsmust not importAccessPath; 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 → readinput[alias.commandArgument]as the command and, whenalias.workdirArgumentis set,input[alias.workdirArgument]as the workdir (both viagetNonEmptyString,undefinedwhen absent/empty).- otherwise →
null.
Design notes:
classifyToolKindstays 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 aToolKindstring. This keepsclassifyToolKind'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?".ShellInvocationis a value object the pipeline threads down; the gates never re-readinput.
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:
-
Seed the walk's initial base from
workdir.BashPathResolver.collectPathCandidatesseeds atCWD_BASE = { offset: "" }in one place.BashProgram.parsegains an optionalworkdirand computes the initialEffectiveBasefrom it, reusing the existingcd-target interpretation so absolute / relative / win32-MSYS all behave identically to an inlinecd <workdir>. Factor the target→base fold currently inline infoldCdinto a small reusable helper (deriveBaseFromCdTarget(base, target)) and call it from bothfoldCdand the initial seed. With noworkdir, the seed staysCWD_BASE(native behavior unchanged). -
Add
workdir's ownAccessPathto the external set when it resolves outside the session cwd. A realcd /etc && …flags/etcvia thecdargument token; the seeded base has no such token, soBashProgram.parseexplicitly resolvesworkdir(normalizer.forBashToken(workdir)), and whenisBoundaryOutsideWorkingDirectoryis true, prepends it toresolvedExternalPaths(deduped by canonical form). The existingdescribeBashExternalDirectoryGatethen flags it with no gate-signature change — it just readsexternalPaths().
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
bashsurface (so "allow for session" writes abash:rule, and the decision value is the command) — not fromclassifyToolKind("exec_command")(which isextension→ would yield the tool name); - keep
toolName: tcc.toolName(exec_command) inlogContext/promptDetailsso 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— addShellInvocationinterface +resolveShellInvocation(toolName, input, aliases); importShellToolsConfig(string-only, AccessPath-free — respects ADR-0002). Reintroduce the value-object's field types as needed;classifyToolKinditself is untouched.src/handlers/gates/tool-call-gate-pipeline.ts— resolveshellonce viaresolveShellInvocation(through a newinputs.getShellToolAliases()); parseBashProgramfromshell.commandwith the{ workdir }seed; threadshell?.command ?? nullinto the two bash gates; routeresolvePerToolCheckonshell; passshellinto the per-tool descriptor. Replace bothclassifyToolKind(tcc.toolName) === "bash"sites.src/handlers/gates/tool-call-gate-pipeline.ts(ToolCallGateInputs) — addgetShellToolAliases(): ShellToolsConfig | undefined.src/permission-session.ts— implementgetShellToolAliases()returningthis.config.shellTools.src/handlers/gates/bash-path.ts— signaturedescribeBashPathGate(tcc, command, bashProgram, resolver); drop thetcc.toolName !== "bash"guard and the internaltoRecord(tcc.input).commandread; guardif (!command || !bashProgram) return null.src/handlers/gates/bash-external-directory.ts— same signature change and guard rework; theexternalPaths()read is unchanged (workdir enters viaBashProgram).src/access-intent/bash/program.ts—parse(command, normalizer, isPromotablePathToken?, options?: { workdir?: string }); compute the initial base fromworkdirand add the workdir externalAccessPathwhen outside cwd.src/access-intent/bash/bash-path-resolver.ts— accept an injected initialEffectiveBase(defaultCWD_BASE); factor the target→base fold out offoldCdintoderiveBaseFromCdTargetand reuse it for the workdir seed; optionally expose the workdir-external contribution (or compute it inprogram.ts).src/handlers/gates/tool.ts—describeToolGate(andderiveSuggestionValue) accept the effective shell command/surface so a shell invocation uses thebashsurface + command value while keepingtcc.toolNamein logs.src/handlers/gates/helpers.ts—deriveDecisionValueyields the command for a shell invocation (via the threaded shell command / effective surface), not the tool name.src/access-intent/input-normalizer.ts— thenormalizeInputbash branch is reached by the manager'scheckPermission(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 usesresolveShellInvocationdirectly), and the advisory service resolvesbashby explicit command string, sonormalizeInputmay not need the alias. Decide during TDD step 3 by tracingnormalizeInputcallers; if untouched, note it in the retro.test/*— new + updated gate-parity tests (see TDD Order).config/config.example.json,docs/configuration.md— theshellToolsblock 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— theshellToolsmention 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 nodeS3); update theshellToolshealth-metric row to note gate-parity is tested/live if the wording implies config-only. Norule.ts-type listing changes (noRule/Rulesetfield 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:
- New tests enabled —
resolveShellInvocationunit 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_commandinvocation 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.parseworkdir-seed unit tests (relative token resolves against workdir; workdir-escape adds an external path; absolute token base-independent; no-workdir reproducesCWD_BASE).
- Redundant tests — none removed. The native-bash gate suites stay as-is and become the parity oracle the aliased cases assert against.
- Tests that must stay — the native-bash bash-path / bash-external-directory / pipeline suites genuinely exercise the surface being generalized; they pin that the
resolveShellInvocationrefactor 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
askwith<unparseable-bash-command>. Pinned by the existing bash-command fail-closed tests; add an aliased-tool case so anexec_commandopaque 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 oneBashProgram.parsecall per evaluate. - #490 wrapper flooring —
sudo/bash -c/eval/… floorallow→ask. Pinned by the wrapper-flooring suite; add an aliased-tool case (exec_commandrunningsudo …floors). - #418/#486/#502 path-surface canonical matching — bash path candidates resolve on the
access-pathintent with lexical ∪ canonical aliases. Unchanged; the aliased command's tokens flow through the identicalBashPathResolver. - #533 win32 Git Bash semantics — bash tokens carry MSYS semantics on win32.
The workdir seed reuses
interpretBashCdTarget, so a win32workdir(/c/xdrive-mount,/tmpnon-mount) is interpreted consistently with an inlinecd; 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
-
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;workdirArgumentabsent →workdir: undefined; missing/empty command field →command: ""; unknown tool + no alias →null;aliases: undefined→ native-bash-only. - Green: add
ShellInvocation+resolveShellInvocationtotool-kind.ts(importShellToolsConfig; 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 — ifdead-codeflags it before then, fold step 3's first consumer into this commit, or land steps 1–3 together; see the batch note below).
- Red: unit tests for
-
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.tsto the new(tcc, command, bashProgram, resolver)signature; native-bash expectations unchanged (passinput.commandas the threaded command). - Green: change both gate signatures; drop the
toolName !== "bash"guards and internalcommandre-derivation; guardif (!command || !bashProgram) return null. Update the pipeline's two gate-producer call sites to pass the (stillinput.command-derived, this step) command. - Verify:
pnpm run check, the two gate suites + the pipeline suite green (native behavior identical —refactor:is ahidden: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.
- Red: update
-
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" } }, anexec_commandcall with{ cmd: "npm install" }evaluates againstbash:rules (deny/ask honored), decomposes a chained command, floors asudo/bash -cwrapper, 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 recordstoolName: "exec_command"with the effective command, and a session "allow" writes abash:rule. - Green: add
getShellToolAliasestoToolCallGateInputs+PermissionSession; resolveshellonce in the pipeline and threadshell?.command/shellinto the bash gates,resolvePerToolCheck, and the per-tool descriptor; updatedescribeToolGate/deriveSuggestionValue/deriveDecisionValuefor the effectivebashsurface + command value while preservingtcc.toolNamein logs. TracenormalizeInputcallers; 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 testfor the package,pnpm fallow dead-code.
- Red: pipeline / integration tests — with
-
workdir full parity (
feat(pi-permission-system): resolve and gate aliased shell workdir).- Red:
BashProgram.parseworkdir-seed tests (relative token resolves againstworkdir; workdir-escape/etcadds an external path; absolute token base-independent; no-workdir ==CWD_BASE; win32workdirviawin32PathFlavor) plus a pipeline test that anexec_commandwith{ cmd: "cat passwd", workdir: "/etc" }promptsexternal_directoryfor both/etcand/etc/passwd. - Green: add the
{ workdir }option toBashProgram.parse; factorderiveBaseFromCdTargetout offoldCd; seedBashPathResolver's initialEffectiveBasefromworkdir; add the workdir externalAccessPathwhen outside cwd; passshell.workdirfrom the pipeline. - Verify:
pnpm run check,pnpm -r run test,pnpm fallow dead-code.
- Red:
-
Docs + example + roadmap (
docs(pi-permission-system): document live shellTools enforcement).- Update
docs/configuration.md(andREADME.md/config.example.jsonif they imply config-only) to state the enforcementshellToolsnow 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 nodeS3); update theshellToolshealth-metric row wording to reflect live gate-parity. - Verify:
pnpm exec rumdl checkon the edited markdown;config.example.jsonstill parses/validates.
- Update
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
resolveShellInvocationrefactor — mitigated by step 2 being a purerefactor:with the native-bash suites as the unchanged oracle, and step 1'sresolveShellInvocationreproducing{ 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/etcand/etc/passwdprompt. - Presentation leak (log shows
bashnot the real tool) — mitigated by the log-context assertion (toolName: "exec_command") in step 3 and by keeping the bash gates' existingtoolName: tcc.toolNamelog fields. fallow dead-codeon the step-1 export — mitigated by the batch note (run the gate at the step-3 boundary); the #580 retro flagged this exact class.normalizeInputdivergence — if a consumer routes an aliased(toolName, input)through the manager'scheckPermission, 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)throughpermission-manager.checkPermission(vs. the gate pipeline, which usesresolveShellInvocationdirectly). 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 resolvesbashby explicit command string.