14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 574 | Support configurable shell-tool aliases for exec_command |
Retro: #574 — Support configurable shell-tool aliases for exec_command
Stage: Planning (2026-07-13T18:30:00Z)
Session summary
Planned Phase 11 Step 3 (batch "shell-tool-aliases" tail): the enforcement gate that consumes the shellTools config landed in #580.
Produced a 5-cycle plan (0574-gate-aliased-shell-tools-bash-stack.md) routing an aliased shell tool (e.g. exec_command) through the full bash stack at parity with native bash — command decomposition, wrapper flooring, the <unparseable-bash-command> fail-closed sentinel, bash path + external-directory token gates, bash: rules — plus full workdir parity (effective resolve base + external_directory gating).
Observations
- Third-party issue, but operator-adopted.
#574was filed bymarcinkardas(gh user isgotgenes), so theask-userdirection gate was mandatory. The operator confirmed implement as roadmapped and full workdir parity now — the direction was already settled by the roadmap (Step 3) and by#580shipping the config half explicitly for this. The operator drilled into two design questions before deciding (how it is configured; howworkdirrelates to cwd), so the plan front-loads a concrete config example and the workdir/cwd model. - Key architectural finding:
workdirneeds almost no rearchitecture.PathNormalizerbakes the session cwd for the containment boundary only; the resolve base is threaded per-token as the walk'sEffectiveBase.offset(how inlinecdalready works). Soworkdiris "an implicit leadingcd <workdir>" — reuse the existing cd-fold machinery. Two contained additions in the bash parse layer: seed the walk's initialEffectiveBasefromworkdir(factorderiveBaseFromCdTargetout offoldCd), and addworkdir's ownAccessPathto the external set when outside cwd (a realcd /etcflags/etcvia its argument token; the seed has none, so add it). No change to containment,AccessPath, or theexternal_directorypolicy — the gate flags workdir with no signature change. - Single dispatch point,
classifyToolKindleft alone. The alias consult is a separate function —resolveShellInvocation(toolName, input, aliases) → { command, workdir } | nullintool-kind.ts— because it needs config and returns a richer product than aToolKind. KeepingclassifyToolKindconfig-free preserves its ADR-0002 string boundary and its config-free presentation/manager consumers. Native bash routes through the same seam ({ command: input.command, workdir: undefined }), so the two bash gates drop their hardcodedtoolName === "bash"/input.commandderivations. - The real weight is aliasing plumbing, not workdir.
The bash gates and pipeline hardcode
toolName === "bash"/input.commandin a handful of sites; threading the resolvedShellInvocationthrough them (steps 2–3) is the bulk.workdir(step 4) is a small, reuse-heavy parse-layer seam. - Presentation nuance: an aliased shell command must present on the
bashsurface (so a session "allow" writes abash:rule and the decision value is the command) while keeping the invoked tool name (exec_command) in the review log — the plan threads the effective shell command/surface intodescribeToolGate/deriveDecisionValue/deriveSuggestionValue. fallow dead-codebatch trap (from#580): the step-1resolveShellInvocationexport has no consumer until step 3. The plan's batch note runs thedead-codegate at the step-3 boundary, not step-1 — the same speculative-export class#580's retro flagged.- Open question deferred to TDD: whether
normalizeInput's bash branch needs alias-awareness depends on whether any consumer routes an aliased(toolName, input)throughpermission-manager.checkPermission(the enforcement path is the gate pipeline, which usesresolveShellInvocationdirectly). Traced in step 3; no follow-up filed pre-emptively. - Release: ship now — batch tail; landing Step 3 cuts the release carrying both the deferred
#580feat:and this step'sfeat:commits. Next step:/tdd-plan(this plan has test cycles).
Stage: Implementation — TDD (2026-07-13T18:20:00Z)
Session summary
Implemented all five planned TDD cycles plus one preparatory refactor, landing the shell-tool aliasing enforcement: resolveShellInvocation dispatch point, BashProgram owning its source command, pipeline consumption, full workdir parity, and docs/roadmap.
Test count 2387 → 2418 (+31); pnpm run check, root pnpm run lint, and pnpm fallow dead-code all green.
Pre-completion reviewer: WARN (no FAILs) — both warnings addressed with fast-follow commits before shipping.
Observations
- Mid-implementation design pivot (operator-prompted): dropped the
commandparameter forBashProgram.commandText(). The plan's step 2 threaded acommandparameter into the two bash gates. The operator flagged thatcommandis redundant state — a projection of the shell invocation that always co-travels withbashProgram(the parsed form of the same command). The right collaborator isBashProgramitself (it is constructed from the command), so it now exposescommandText()and the gates read it, dropping the parameter. Since step 2 was unpushed, Igit reset --mixed HEAD~1to drop it and folded the mechanism into the step-3 feat. Net: the two bash gates kept their original(tcc, bashProgram, resolver)signature (no churn, native-bash regression suites unmodified), and the redundant-param smell never shipped. workdirneeded almost no rearchitecture — as the plan predicted,PathNormalizeralready separates the containment boundary (baked session cwd) from the resolve base (per-token walk offset), soworkdiris "an implicit leadingcd <workdir>": seed the walk's initialEffectiveBase(reusing thederiveBaseFromCdTargethelper the prep refactor extracted fromfoldCd) and add the workdir's ownAccessPathto the external set when outside cwd. Containment stays measured against the session cwd, so a workdir outside cwd cannot widen the sandbox — pinned by tests asserting both the workdir and a relative token resolve/flag correctly.- Deviations from the plan's Module-Level Changes (all sound, not gaps):
input-normalizer.tsandtool-input-path.tswere not modified — the open question resolved to "not needed": the enforcement path is the gate pipeline (which consultsresolveShellInvocationdirectly), and the advisory service (bash-advisory-check.ts) resolvesbashby explicit command string, so neither sees an aliased(toolName, input).helpers.ts(deriveDecisionValue) was not modified —tool.ts'sdescribeToolGatethreads the effectivebashgateSurfaceinto it, so no change was needed there.config/config.example.jsonwas not modified — theshellToolsblock already shipped in#580.ShellToolAliaswas not reintroduced (the plan's Non-Goal suggested it) —resolveShellInvocationuses indexed access onShellToolsConfig, so a named export would have re-tripped the#580speculative-export/fallowtrap.
- Pre-completion WARNs, both addressed before shipping:
- The plan promised aliased-tool cases for the
#490/#481wrapper-flooring and#452fail-closed security invariants, which were missing (they held structurally via sharedresolveBashCommandCheck). Added two integration tests (sudo→<indirection-bash-wrapper>,bash -c→<opaque-bash-wrapper>) through the real parse (d7d19d2e), so a futuretoolName === "bash"special-case in the flooring path is caught. - The package skill was silent on the new dispatch point — added a
shellTools/resolveShellInvocationgate-parity paragraph (c6881403).
- The plan promised aliased-tool cases for the
- Parallel-session interaction: a peer session landed
#583retro notes on sharedmainbetween my prep refactor and step 1; my prep refactor rode ontoorigin/mainvia that push, leaving five unpushed commits. History stayed linear; no conflict. - Release: ship now — batch "shell-tool-aliases" tail; landing this cuts the release carrying both the deferred
#580feat:and this issue'sfeat:commits. Next step:/ship-issue.
Stage: Final Retrospective (2026-07-13T23:40:00Z)
Session summary
Shipped #574 end-to-end in one continuous session (plan → TDD → ship → retro): @gotgenes/pi-permission-system v20.6.0, closing the "shell-tool-aliases" batch (Phase 11 Steps 2–3) alongside #580.
An aliased shell tool (exec_command) is now gated at full parity with native bash — command decomposition, wrapper flooring, fail-closed sentinel, bash: rules, and workdir-as-base + external_directory gating — landed across a prep refactor and six commits, +31 tests, CI green on 5523df76.
Two operator design interventions and the pre-completion gate each improved the result before it shipped.
Observations
What went well
- A single Socratic operator question produced a strictly better design with zero shipped churn.
Mid-TDD, the operator asked whether the
commandparameter "should instead be some state in an object that would be a good collaborator" — a redirect delivered as a question, not a correction. Because the step-2 commit was unpushed,git reset --mixed HEAD~1dropped it and foldedBashProgram.commandText()into the feat, so the two bash gates kept their original(tcc, bashProgram, resolver)signature: the redundant param never shipped and the native-bash regression suites needed zero churn. Recovering from a plan-level design miss with no residue is the notable win. - The pre-completion reviewer earned its keep.
Structural-sharing reasoning had rationalized away the aliased-tool wrapper-flooring / fail-closed tests ("they hold because
resolveBashCommandCheckis shared"), but the plan's own TDD Red list had promised them as the mitigation for two security invariants. The fresh-context gate flagged the gap; two fast-follow commits (d7d19d2e,c6881403) closed it before ship, so a futuretoolName === "bash"special-case in the flooring path is now caught by a test. - The
workdir-as-implicit-cdread held from plan to code. Recognizing at plan time thatPathNormalizeralready separates the containment boundary (baked session cwd) from the resolve base (per-token walk offset) meantworkdirneeded no rearchitecture — seed the initialEffectiveBase(via thederiveBaseFromCdTargetthe prep refactor extracted) and add the workdir's ownAccessPathto the external set. Containment stayed measured against the session cwd, so a workdir outside cwd cannot widen the sandbox.
What caused friction (agent side)
wrong-abstraction(user-caught) — the plan's Design Overview threaded three facets of one concept (shell,command,bashProgram) as separate parameters, wherecommandis a projection ofbashProgram(the parsed form of that same command). The right home wasBashProgramowning its source command. Neither the plan-timedesign-reviewpass nor thetidy-first-assessorflagged it; the operator did. Impact: onegit reset --mixed HEAD~1plus a reshape of the step-2/step-3 boundary — no shipped churn (caught pre-push), but the redundant design was committed once before the redirect.scope-drift(self-identified via the pre-completion gate) — the plan's TDD step-3 Red list explicitly named the aliased-tool wrapper-flooring and fail-closed test cases, but they were skipped during implementation on the rationale that the behavior holds structurally. Impact: two fast-follow commits before ship; no rework beyond that.instruction-violation(self-identified) — named a shell-loop variablestatusin the release-PR poll loop; zsh reserves$statusread-only, so the first poll aborted withread-only variable: status.AGENTS.mddocuments this exact rule ("do not name itstatus… usestate/rc"). Impact: one re-run (~15 s); fixed tostateimmediately.
What caused friction (user side)
- None material — both operator interventions (the
workdir/cwd questions at plan time, thecommand-redundancy question at TDD time) were strategic redirects framed as questions, which is the ideal intervention style: each steered a cleaner outcome without dictating the mechanism. If anything, thecommand-redundancy insight could have surfaced at the plan-review gate rather than after step 2 committed — but that is an agent-side design-review gap, not a user one.
Diagnostic details
- Model-performance correlation — both subagents (
tidy-first-assessor,pre-completion-reviewer) ran onanthropic/claude-sonnet-5, appropriate for read + judgment work; the main session ran onanthropic/claude-opus-4-8with asonnet-5stretch. No reasoning-weak-on-judgment or costly-on-mechanical mismatch. - Escalation-delay tracking — no
rabbit-holesequences; the zshstatuserror was resolved in one follow-up call, and the release-PRUNSTABLE/IN_PROGRESScheck was handled by the documented wait-then-retry protocol (no prematuregh pr mergefallback while a check was running). - Unused-tool detection — no gaps;
colgrepdrove the planning exploration, and thetidy-first-assessor/pre-completion-reviewersubagents both ran and both produced actionable findings. - Feedback-loop gap analysis — verification ran incrementally (per-cycle
tsc+ the affected test file, full suite + rootlint+fallow dead-codeat each step boundary), not end-only; the pre-completion gate ran after the last step as designed. No gap.
Changes made
- Appended this Final Retrospective stage entry to
packages/pi-permission-system/docs/retro/0574-gate-aliased-shell-tools-bash-stack.md. - Added a projection parameter check to
.pi/skills/design-review/SKILL.md(check #1, Dependency width): flag a parameter derivable from a sibling parameter and give the value to the object that owns it — thecommand/bashProgramredundancy the operator caught mid-TDD. Rationale lives here in the retro; the skill carries the rule plus a one-line example with aRefs #574pointer.