13 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 575 | pi-permission-system: survey other exec-capable CLI rewrites for indirection-wrapper flooring |
Survey exec-capable CLI rewrites for indirection-wrapper flooring
Release Recommendation
Release: ship independently
Phase 11 Step 6 carries Release: independent in docs/architecture/architecture.md — it is not a member of the "shell-tool-aliases" batch (Steps 2–3) and cuts its own release as a fix: bash-surface hardening, exactly as its predecessor #490 did.
Problem Statement
#490 floored a fixed inventory of indirection wrappers (sudo/env/xargs/time/nohup/timeout/nice, plus find/fd carrying a per-result exec flag) to at least ask, so an inner command cannot ride a permissive allow on the wrapper text.
That inventory was seeded from a fixed list; any exec-capable tool outside it can still launder a payload under a permissive allow — the fail-safe floor is only as good as its inventory (#575).
This issue surveys other modern core-tool rewrites and parallelizers that run an inner command per input or as a subcommand, and extends the wrapper sets with the exec-capable ones.
Goals
- Survey the candidate tools and classify each as exec-capable (floor) or not (reject), recording the decision in the plan.
- Extend
INDIRECTION_WRAPPER_NAMES(src/access-intent/bash/command-enumeration.ts) with the adopted always-invoke wrappers:parallel,rust-parallel,rush,doas,setsid,stdbuf,watch,flock. - Add classifier tests pinning each new wrapper's
wrapperKind: "indirection"flag. - Keep the enumerated inventory in
docs/configuration.mdand the package skill in sync with the new set. - Mark Phase 11 Step 6 complete in
docs/architecture/architecture.mdin the same doc-update commit.
This is a behavior-tightening fix: — a command like parallel rm ::: * that previously matched a permissive allow now prompts (ask).
As with #490, the floor only ever makes a decision more restrictive (allow → ask) and never overrides an explicit deny, so it is not classified breaking (matching the #490 fix: precedent).
Non-Goals
- Re-targeting matching at the inner command (stripping the wrapper prefix).
#490 settled on floor-all after an AST probe showed every wrapper parses as a flat
commandnode with no wrapper/inner-command boundary; that decision stands. - Adopting
gargs(a Go xargs alternative,brentp/gargs). It is exec-capable but declined this round (niche; low prevalence). It can be added later as a one-line set edit if it becomes relevant. - Adding any exec-flag-conditional wrapper to
EXEC_CONDITIONAL_WRAPPERS. None of the adopted tools is flag-gated the wayfind/fdare — each always invokes its inner command as its primary purpose — so all adopted entries go intoINDIRECTION_WRAPPER_NAMES. - Changing the floor mechanism, the
<indirection-bash-wrapper>sentinel, or the advisory path — all reused unchanged from #490.
Background
The relevant code all lives in src/access-intent/bash/command-enumeration.ts:
INDIRECTION_WRAPPER_NAMES: Set<string>— always-invoke wrappers floored by command-name basename alone.EXEC_CONDITIONAL_WRAPPERS: Map<string, ReadonlySet<string>>— search tools (find/fd) floored only when an exec flag is present.classifyWrapperCommand(node)— reads acommandnode's basename + args, returns"indirection"for a member ofINDIRECTION_WRAPPER_NAMES, or for aEXEC_CONDITIONAL_WRAPPERStool carrying a matching exec flag.- The
BashCommand.wrapperKinddiscriminant flows toWRAPPER_SENTINELinsrc/handlers/gates/bash-command.ts, whereresolveBashCommandCheckfloors anallowup toaskand stamps the<indirection-bash-wrapper>review-log pattern.
Per the #490 retro, the floor half needs no code change once the classifier emits "indirection" — the WRAPPER_SENTINEL map already has the indirection key, and the advisory surface (resolveBashAdvisoryCheck) reuses the shared resolveBashCommandCheck, so the floor applies to both the gate and the advisory answer for free.
The entire change is therefore: add 8 strings to one Set, plus tests and docs.
Constraint from the package skill (Debugging section): INDIRECTION_WRAPPER_NAMES and EXEC_CONDITIONAL_WRAPPERS are documented, easily-extensible constants — this issue exercises exactly that extension point.
Constraint from the package skill (roadmap-marking rule): mark the completed roadmap step ✅ on both the heading and its Mermaid node in the implementation doc-update commit, not a deferred ship commit.
Design Overview
Survey results
Each candidate was checked against the criterion "does it run an inner command per input or as a subcommand?"
Adopt into INDIRECTION_WRAPPER_NAMES (always-invoke; floored by basename):
| Tool | Why it execs |
|---|---|
parallel |
GNU parallel: runs a command per input line (like xargs). |
rust-parallel |
Rust parallel/xargs rewrite; runs commands from stdin/args/:::. |
rush |
shenwei356/rush: Go parallel command runner (like GNU parallel/gargs). |
doas |
OpenBSD sudo rewrite; runs a mandatory command as another user. |
setsid |
Runs a following command in a new session. |
stdbuf |
Runs a following COMMAND with modified stream buffering. |
watch |
Executes a command periodically. |
flock |
flock <file> <cmd> wraps a lock around command execution. |
Reject (not exec-capable — no per-result or subcommand exec):
| Tool | Why rejected |
|---|---|
sad |
Batch file search-and-replace (a sed alternative); edits files, execs nothing. |
fselect |
SQL-like file search; its interactive "queries" are internal, no per-result subcommand. |
runiq |
Line dedupe filter; execs nothing. |
gargs |
Exec-capable, but declined this round (see Non-Goals). |
Notes on the adopted set:
- All parse as flat
commandnodes with the inner command visible as arguments, identical to the existingenv/nice/timeoutentries — so basename flooring is correct and no per-wrapper option-arity table is needed. rust-parallelcontains a hyphen;basename("rust-parallel")returns the whole string and matches the set entry exactly (basenameonly splits on/).flockhas a bare-fd form (flock <number>) that runs no command; basename flooring over-floors that rare shell-script form toask. This is the accepted least-privilege posture, consistent with #490's accepted edge that a bareenv/sudo -lis floored too.parallel/rust-parallel/rushtake options and templates but always invoke a command; there is no bare read-only mode to preserve (unlikefind/fd), so they are always-invoke, not exec-conditional.
Code change
A single edit to the set literal:
const INDIRECTION_WRAPPER_NAMES = new Set([
"sudo",
"env",
"xargs",
"time",
"nohup",
"timeout",
"nice",
// Added #575 — exec-capable rewrites and prefix wrappers
"parallel",
"rust-parallel",
"rush",
"doas",
"setsid",
"stdbuf",
"watch",
"flock",
]);
No other production code changes.
classifyWrapperCommand, WRAPPER_SENTINEL, resolveBashCommandCheck, and the advisory path are unchanged.
Module-Level Changes
Production code:
src/access-intent/bash/command-enumeration.ts— add the 8 strings toINDIRECTION_WRAPPER_NAMES(with a#575comment marker).
Tests:
test/access-intent/bash/program.test.ts— extend thedescribe("indirection wrappers")it.eachtable with one row per new wrapper (each assertingwrapperKind: "indirection"), following the existingsudo aws s3 lspattern.
Docs (same commit, doc-update step):
docs/configuration.md(line ~329) — the hard-enumerated indirection-wrapper list (sudo,env,xargs,time,nohup,timeout,nice, …) gains the 8 new names. This is the authoritative user-facing list; it must stay complete..pi/skills/package-pi-permission-system/SKILL.md(Debugging section,INDIRECTION_WRAPPER_NAMES = sudo/env/xargs/time/nohup/timeout/nice) — extend the enumeration to match the code.docs/architecture/architecture.md— mark Phase 11 Step 6✅on both the#### Step 6:heading and theS6Mermaid node; update the Step 6 Outcome to record the adopted inventory (parallel/rust-parallel/rush/doas/setsid/stdbuf/watch/flock) and the rejected candidates (sad/fselect/runiqnon-exec;gargsdeclined).
Docs deliberately not edited (already correct):
README.mdline 22,src/handlers/gates/bash-command.ts:29,src/access-intent/bash/program.ts:100, anddocs/architecture/architecture.mdlines 756/761 all reference the inventory with a trailing…ellipsis or a single example (such as sudo), so they remain accurate without enumerating the new names.- Historical records (
docs/plans/0490-*,docs/plans/0481-*,docs/plans/0521-*, thedocs/retro/*, anddocs/architecture/history/phase-10-*) are frozen and are not edited.
Test Impact Analysis
- New tests enabled: eight new classifier rows in
program.test.ts— one per adopted wrapper — pinningwrapperKind: "indirection". These are the distinguishing coverage: they prove each new name reaches the floor. - Redundant tests: none.
No existing test is invalidated; the change is purely additive to a
Set. - Tests that must stay as-is: the existing
sudo/env/xargs/time/nohup/timeout/nicerows and thefind/fdexec-conditional block continue to exercise the unchanged classification paths. No new floor-behavior test inbash-command.test.tsis needed: once a wrapper flags"indirection", the floor is the identical code path already covered by #490'ssudofloor test — the only genuinely new behavior (name membership) is covered by theprogram.test.tsclassifier rows.
Invariants at risk
Phase 10 Step 5 (#490) established the indirection-floor invariant: a wrapper unit flags wrapperKind: "indirection" and its allow is clamped to ask via WRAPPER_SENTINEL.
This change extends the set that triggers that invariant without altering the invariant itself.
The existing program.test.ts describe("indirection wrappers") and bash-command.test.ts floor tests pin it; the new rows sit alongside them.
No earlier step's Outcome: is regressed — the change only adds set members.
TDD Order
fix:— floor the adopted exec-capable wrappers. Test surface:test/access-intent/bash/program.test.tsdescribe("indirection wrappers"). Red: add oneit.eachrow per new wrapper (parallel rm ::: x,rust-parallel echo,rush echo,doas aws s3 ls,setsid aws s3 ls,stdbuf -oL aws s3 ls,watch ls,flock /tmp/lock aws s3 ls), each expecting{ text, wrapperKind: "indirection" }— fails because the names are not in the set. Green: add the 8 strings toINDIRECTION_WRAPPER_NAMES. Commit:fix(pi-permission-system): floor additional exec-capable indirection wrappers (#575).docs:— sync the enumerated inventory and mark the roadmap step complete. Updatedocs/configuration.mdand.pi/skills/package-pi-permission-system/SKILL.mdenumerations; mark Phase 11 Step 6✅(heading +S6Mermaid node) and record the survey outcome indocs/architecture/architecture.md. Commit:docs(pi-permission-system): record exec-capable wrapper survey and mark Phase 11 Step 6 complete (#575).
Risks and Mitigations
- Risk: over-flooring a legitimate non-exec form (e.g.
flock <fd>,watch-less usage). Mitigation: accepted least-privilege posture, consistent with #490's bare-env/sudo -ledge; anallowis only clamped toask(a prompt), never denied, so the user retains one keypress to proceed. - Risk: a hyphenated command name (
rust-parallel) not matching. Mitigation:basenamesplits only on/, sorust-parallelmatches the set entry verbatim; a classifier test row pins it. - Risk: the enumerated docs drifting from the code set.
Mitigation: the doc-update step edits
configuration.mdand the package skill in the same change; the pre-completion reviewer checks documentation staleness.
Open Questions
None.
The inventory was confirmed with the operator (adopt the 8 always-invoke wrappers; decline gargs; reject sad/fselect/runiq; plan-only rejection notes).