23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 490 | pi-permission-system: floor other indirection wrappers (sudo/env/xargs/find -exec) to ask |
Floor indirection wrappers to ask
Release Recommendation
Release: ship independently
This is Phase 10, Step 5 of the architecture roadmap, tagged Release: independent there (the "Release batches" subsection lists Steps 3–6 as independently releasable; only Steps 1–2 form the "tool-kind-dispatch" batch).
It is a fix: behavior change (a bypass fix that tightens gating), so it cuts a release on its own once landed.
Problem Statement
#481 closed the env-var-prefix and inline-shell (bash -c/eval) bypasses: it strips a leading variable_assignment prefix from each bash command unit and floors an opaque-payload wrapper's allow up to ask.
It deliberately scoped the floor to inline-shell payloads only.
Other common indirection wrappers still let an inner command evade the rule that should gate it, because the wrapper is the command_name and the inner command is a plain argument:
sudo <cmd>— runs<cmd>as another user; the unit text issudo <cmd>, so a<cmd> *rule never matches.env VAR=x <cmd>—envis a real command; the inner<cmd>is an argument.xargs <cmd>,find … -exec <cmd> …— run<cmd>per input / per match (the actual command is constructed at runtime).time,nohup,timeout <dur>,nice— prefix wrappers that run a following command.fd -x <cmd>/fd --exec <cmd>(and-X/--exec-batch) — the modernfindrewrite's per-result exec.
The AST confirms the structural obstacle: every one of these parses as a flat command node — the wrapper is command_name, the inner command and its arguments are sibling word nodes, with no boundary marking where the wrapper's own options end and the inner command begins.
Goals
- Floor each listed indirection wrapper's decision to at least
ask, mirroring #481's opaque-payload floor: anallow(including a permissive top-level*) is clamped up toask, while an explicitdenyoraskrule on the wrapper passes through unchanged. - Cover the always-invoking wrappers by
command_namebasename:sudo,env,xargs,time,nohup,timeout,nice. - Cover the search tools
findandfdonly when an exec flag is present (findwith-exec/-execdir/-ok/-okdir;fdwith-x/--exec/-X/--exec-batch), so a barefind/fdsearch — which runs no subcommand and is extremely common — is not floored. - Keep the wrapper name/flag sets as documented, easily-extensible constants so adding a tool later (see #575) is a one-line change.
- Keep the fix fail-safe and deterministic — it only ever tightens a decision, never loosens one.
This is a behavior change on upgrade with no config edit: a command that was silently auto-allowed (e.g. sudo aws … under aws *: allow, or env FOO=bar aws … under a permissive top-level *) will now prompt.
Following the #481 precedent, it is classified fix: (not fix!:): it only closes a bypass and never weakens an existing decision — there is no prior intended behavior to preserve, the old behavior was the bug.
Non-Goals
- Re-targeting any wrapper at its inner command (the alternative the issue floated for prefix wrappers).
The operator confirmed the floor-all direction on 2026-07-12, superseding the roadmap's earlier 2026-07-10 "re-target prefix wrappers" note.
Re-targeting would require a per-wrapper option-arity table (
sudo -u www-data,env -u NAME,nice -n 10,timeout 10each take a value that a naive scan would mistake for the inner command name); a wrong table silently under-matches, which is exactly the "silent over-/under-matching is a permission bypass" class the package warns against. The floor needs no option tables and is complete and uniform. - Re-parsing
xargs/find -exec/fd -xpayloads to match inner commands against inner rules — floored instead, like the opaque wrappers. - A force-allow escape valve for wrappers.
As with #481's opaque floor, there is no way to auto-allow a floored wrapper (an explicit
allowis clamped toask); this is the accepted cost of the floor-all direction (see Risks) and is the intended safety posture. - Surveying other modern CLI rewrites (GNU
parallel,rust-parallel,sad, …) beyondfd— filed as follow-up #575; the constants are structured to make each addition trivial. - Collecting path candidates from inside wrapper payloads for the
path/external_directorysurfaces — the whole wrapper is floored toask, so the human is prompted and sees the full command.
Background
The relevant code lives in src/access-intent/bash/ and src/handlers/gates/:
command-enumeration.ts—collectCommands(node)walks the parsed AST and emits oneBashCommandper command unit. #481 added theopaque?: booleanflag toBashCommand, the privateisOpaqueWrapperCommand(node)detector, theSHELL_WRAPPER_NAMESset, andcommandUnitText(node)(which strips the leadingvariable_assignmentprefix).makeUnit(text, context, opaque?)attachesopaqueonly when true (to keeptoEqualfixtures clean).handlers/gates/bash-command.ts—resolveBashCommandCheck(command, commands, agentName, resolver)resolves each unit on thebashsurface and combines them withpickMostRestrictive(deny > ask > allow). It already floors anopaqueunit'sallowup to a syntheticaskwith the<opaque-bash-wrapper>sentinel, and fails closed to<unparseable-bash-command>for a non-empty command that parses to zero units (#452).program.ts—BashProgram.commands()re-exportsBashCommandand returns the enumerated units.bash-advisory-check.ts—resolveBashAdvisoryCheckroutes advisorybashservice queries through the same sharedresolveBashCommandCheck, so the floor applies to the advisory surface automatically (#309); no separate change is needed there.
Constraints from the package skill / AGENTS:
- Default to least privilege — when in doubt, prompt; the floor-to-
askdesign follows this directly. - Wildcard matching must be explicit and tested — silent over- or under-matching is a permission bypass.
docs/architecture/architecture.mdnames the enumerator's internal symbols (isOpaqueWrapperCommand,SHELL_WRAPPER_NAMES, theopaqueflag) in prose and records the #490 roadmap step's direction; the package skillSKILL.mddescribes the opaque floor in prose. All must be updated when the enumeration semantics and the recorded direction change.docs/configuration.mdandREADME.mddocument thebashfail-closed behavior; update them for the new wrapper floor.
Design Overview
Generalize the flag to a wrapper-kind discriminant
#481's opaque?: boolean means "floor this unit's allow to ask, with the <opaque-bash-wrapper> sentinel."
The new wrappers floor identically but deserve a distinct audit sentinel — sudo is not an opaque payload, so labeling it <opaque-bash-wrapper> in the review log would be misleading.
Both reasons floor for exactly one cause, so model it as a discriminant rather than two mutually-exclusive booleans (which would make an illegal { opaque, indirection } state representable):
export type WrapperKind = "opaque-payload" | "indirection";
export interface BashCommand {
readonly text: string;
readonly context?: BashCommandContext;
/**
* Set when this unit is a floored wrapper: `"opaque-payload"` for
* `bash -c`/`eval` (#481), `"indirection"` for sudo/env/xargs/find -exec/… (#490).
* Its decision is floored to at least `ask`; the kind selects the audit sentinel.
*/
readonly wrapperKind?: WrapperKind;
}
The enumerator owns the structural classification (which kind, if any); resolution owns the decision policy (the ask floor) and the sentinel mapping — the same separation of concerns #481 established.
Classification (enumerator)
Replace isOpaqueWrapperCommand(node): boolean with classifyWrapperCommand(node): WrapperKind | undefined.
It reads only the command node's own named children (the same shallow walk the existing detector uses): skip leading variable_assignment children, take the first remaining child's basename as the command_name, and collect the rest as argument texts.
Then, in order:
function classifyWrapperCommand(node: TSNode): WrapperKind | undefined {
const { commandName, args } = readWrapperCommand(node);
if (commandName === undefined) return undefined;
if (commandName === "eval") return "opaque-payload";
if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
return "opaque-payload";
}
if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
if (execFlags && args.some((arg) => execFlags.has(arg))) return "indirection";
return undefined;
}
New constants (documented, one-line-extensible):
// Always invoke an inner command; floored by command name alone.
const INDIRECTION_WRAPPER_NAMES = new Set([
"sudo", "env", "xargs", "time", "nohup", "timeout", "nice",
]);
// Search tools that exec a subcommand only when an exec flag is present.
const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
]);
hasShortFlagC(args) preserves the exact #481 short-flag-cluster semantics (a word before -- that starts with -, is not --, and includes c), factored out of the old inline loop.
SHELL_WRAPPER_NAMES is unchanged.
Detection order matters: sudo bash -c "…" classifies as "indirection" (its command_name is sudo), which is correct — the whole unit is floored regardless.
Floor (resolution)
Map the kind to its sentinel and apply the existing clamp:
const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
"opaque-payload": "<opaque-bash-wrapper>",
"indirection": "<indirection-bash-wrapper>",
};
const floored =
cmd.wrapperKind && base.state === "allow"
? { ...base, state: "ask" as const, matchedPattern: WRAPPER_SENTINEL[cmd.wrapperKind] }
: base;
return cmd.context ? { ...floored, commandContext: cmd.context } : floored;
deny/ask pass through unchanged, so an explicit sudo *: deny still denies and pickMostRestrictive keeps deny > ask > allow.
The <opaque-bash-wrapper> sentinel is byte-for-byte preserved, so #481's tests and docs stay green.
Edge cases (accepted, documented)
- A bare
env/time/sudo -lwith no inner command is still floored (it matches by name). Erring towardaskis the least-privilege posture; the minor prompt is accepted. time/sudoin a compound form (time { …; },time (subshell)) may parse with a differentcommand_name; then the wrapper is not flagged, but the inner command/subshell is still enumerated and gated normally — never-weaker.- A clustered
fdshort flag (-ux) or an--exec=-style token is not detected by the exact-token match; missing it does not floor, which is an incomplete fix, not a new bypass (the command resolves through normal rules). - A non-literal
command_name($SHELL -c,"$(which sudo)" …) is not classified — never-weaker.
Module-Level Changes
src/access-intent/bash/command-enumeration.ts- Add and export
WrapperKind; replaceBashCommand.opaque?: booleanwithwrapperKind?: WrapperKind; updatemakeUnit's third parameter towrapperKind?: WrapperKind(attach only when defined). - Replace
isOpaqueWrapperCommandwithclassifyWrapperCommand; extractreadWrapperCommand(node)(command_name basename + arg texts) andhasShortFlagC(args)helpers. - Add
INDIRECTION_WRAPPER_NAMESandEXEC_CONDITIONAL_WRAPPERSconstants; keepSHELL_WRAPPER_NAMESandbasename. - Update the
collectCommandsJSDoc to describe the generalized wrapper flagging.
- Add and export
src/handlers/gates/bash-command.ts— add theWRAPPER_SENTINELmap keyed byWrapperKind; importWrapperKind; floor oncmd.wrapperKindinstead ofcmd.opaque; update the function JSDoc to cover indirection wrappers and the<indirection-bash-wrapper>sentinel.src/access-intent/bash/program.ts— no code change; update thecommands()JSDoc from "flags opaque-payload wrappers withopaque: true" to thewrapperKinddiscriminant covering both kinds. (Re-export ofWrapperKindis optional;bash-command.tsimports it fromcommand-enumeration.tsdirectly.)test/access-intent/bash/program.test.ts— migrate the existingopaque: trueliterals towrapperKind: "opaque-payload"; add anindirectiondescribeblock: each always-invoke wrapper is flaggedwrapperKind: "indirection"; a barefind/fdis not flagged;find … -exec …/fd -x …/fd --exec …/fd -X …are flagged; a plainls/awsis not; an env-prefixedAWS_PROFILE=x sudo aws …is stripped tosudo aws …and flagged.test/access-intent/bash/sync-commands.test.ts— migrate theopaque: trueliteral towrapperKind: "opaque-payload".test/handlers/gates/bash-command.test.ts— migrate theopaque: trueliterals towrapperKind: "opaque-payload"; add an indirection-floordescribe: allow→ask with<indirection-bash-wrapper>, explicit deny stays, explicit ask stays, a non-wrapper allow is not floored.test/bash-advisory-check.test.ts— asserts sentinels only (noopaqueliteral); no change required, but add an advisory indirection-floor case for parity.docs/configuration.md— in "Fail-closed behavior", add a bullet for the indirection-wrapper floor: list the always-invoke wrappers and thefind/fdexec-flag condition, the<indirection-bash-wrapper>sentinel, the allow→ask clamp, and that an explicitdenystill denies.README.md— line 22: extend "an opaquebash -c/evalwrapper" to also mention indirection wrappers (sudo/env/xargs/find -exec/…) prompting.docs/architecture/architecture.md- Step 5 (#490) section: retitle to the floor-all direction, replace the "Direction confirmed 2026-07-10: re-target …" line with a note that the 2026-07-12 decision floors all listed wrappers (superseding the earlier hybrid), extend Target to include
handlers/gates/bash-command.ts+ the doc files, update Outcome, and mark the step✅(heading + theS5Mermaid node). - Health-metrics table row "Indirection-wrapper coverage": change the Phase 10 target from "prefix wrappers re-targeted,
xargs/find -execfloored" to "all listed wrappers floored toask". command-enumeration.tsmodule listing (theaccess-intent/bash/tree): replaceisOpaqueWrapperCommand/opaquewithclassifyWrapperCommand/wrapperKindand note the newINDIRECTION_WRAPPER_NAMES/EXEC_CONDITIONAL_WRAPPERStables.program.tsmodule listing: replace "flags opaque-payload wrappers (bash -c/eval) withopaque: true" with thewrapperKinddiscriminant covering indirection wrappers (#490).
- Step 5 (#490) section: retitle to the floor-all direction, replace the "Direction confirmed 2026-07-10: re-target …" line with a note that the 2026-07-12 decision floors all listed wrappers (superseding the earlier hybrid), extend Target to include
.pi/skills/package-pi-permission-system/SKILL.md— reword the opaque-floor paragraph (the "An opaque-payload wrapper … is flaggedopaque…<opaque-bash-wrapper>" line) to describe thewrapperKinddiscriminant and the sibling<indirection-bash-wrapper>floor for the #490 wrappers.
No removed or renamed exports (collectCommands, BashCommand, resolveBashCommandCheck are unchanged); WrapperKind is a new export.
The renamed symbols (isOpaqueWrapperCommand → classifyWrapperCommand) and the BashCommand.opaque → wrapperKind field are private/internal; a grep confirms their only references are within command-enumeration.ts, bash-command.ts, the four test files above, architecture.md, and SKILL.md — all listed here.
Test Impact Analysis
- New tests enabled.
The
wrapperKindflag is observable onBashProgram.commands(), so each wrapper's classification is unit-testable directly inprogram.test.tswithout going through the full gate. The floor is unit-testable inbash-command.test.tsagainst a mocked resolver, and on the advisory surface inbash-advisory-check.test.ts. - Redundant tests.
None removed — this is additive plus a mechanical field rename.
The existing opaque-wrapper cases stay (renamed to
wrapperKind: "opaque-payload"); they still pin the #481 behavior, which is unchanged. - Tests that must stay as-is.
The chain/substitution/subshell enumeration cases and
bash-command-metamorphic.test.ts(which wraps with acdprefix, not these wrappers) continue to exercise the un-floored path and thedeny > ask > allowcombination.
Invariants at risk
- #481 opaque floor —
bash -c/sh -c/eval(etc.) still floor toaskwith the byte-identical<opaque-bash-wrapper>sentinel. Pinned by the existingbash-command.test.tsopaque-floor cases andbash-advisory-check.test.ts:101; the discriminant migration keeps the sentinel string and these tests green. - #481 env-prefix strip —
commandUnitTextstill strips a leadingvariable_assignmentprefix; it is untouched and composes with the new floor (AWS_PROFILE=x sudo aws …→sudo aws …→ floored). Pinned by the existing prefix-strip cases inprogram.test.tsplus a new composition case. - #452 fail-closed — a non-empty command parsing to zero units still resolves to
ask(<unparseable-bash-command>); the floor adds a sibling sentinel and does not touch the empty-units branch. Pinned by the existing fail-closed tests. - #306 never-weaker nested enumeration — the enclosing command and each nested command are still emitted; the
wrapperKindflag and floor can only tighten. Pinned bybash-command-metamorphic.test.tsand the substitution/subshell cases. - #393 no spurious widening — the floor only narrows (
allow→ask); it never relaxes adeny/ask. Pinned by the new deny-/ask-stays floor cases.
TDD Order
- Generalize the wrapper flag to a
wrapperKinddiscriminant (behavior-preserving refactor). Surfaces:command-enumeration.ts,bash-command.ts, and the four test files. Red — migrate everyopaque: trueliteral inprogram.test.ts,sync-commands.test.ts, andbash-command.test.tstowrapperKind: "opaque-payload"(the type change makes the old literals excess-property errors, so all call sites move in this one step). Green — addWrapperKind, replaceBashCommand.opaquewithwrapperKind, renameisOpaqueWrapperCommand→classifyWrapperCommand(opaque-payload arm only, extractingreadWrapperCommand/hasShortFlagC), and switch the floor to theWRAPPER_SENTINELmap (opaque-payload key only). Same sentinel, same behavior; the suite stays green. Commit:refactor(pi-permission-system): model bash wrapper floor as a kind discriminant (#490). - Floor always-invoke indirection wrappers to
ask. Surfaces:test/access-intent/bash/program.test.ts(flag) andtest/handlers/gates/bash-command.test.ts(floor). Red — flag cases:sudo aws s3 ls,env FOO=bar aws s3 ls,xargs rm,time aws …,nohup aws …,timeout 10 aws …,nice -n 10 aws …,/usr/bin/sudo …(basename) →wrapperKind: "indirection"; a plainaws s3 ls/ls→ unflagged;AWS_PROFILE=x sudo aws …stripped then flagged. Floor cases: an indirection unit resolving toallowbecomesaskwithmatchedPattern: "<indirection-bash-wrapper>"; adenyrule staysdeny; anaskrule staysask; a non-wrapperallowis untouched. Green — addINDIRECTION_WRAPPER_NAMES, extendclassifyWrapperCommand, add the"indirection"sentinel toWRAPPER_SENTINEL. Commit:fix(pi-permission-system): floor sudo/env/xargs/time/nohup/timeout/nice to ask (#490). - Floor
find/fdonly with an exec flag. Surfaces:program.test.ts(flag) andbash-command.test.ts(floor). Red —find . -name '*.py' -exec rm {} \;/find . -execdir …/find . -ok …→"indirection"; a barefind . -name '*.py'→ unflagged;fd -x rm/fd --exec rm/fd -X rm/fd --exec-batch rm→"indirection"; a barefd pattern→ unflagged. Green — addEXEC_CONDITIONAL_WRAPPERS, extendclassifyWrapperCommand. Commit:fix(pi-permission-system): floor find/fd exec wrappers to ask (#490). - Document the new behavior and mark the roadmap step complete.
Update
docs/configuration.md,README.md,docs/architecture/architecture.md(Step 5 direction +✅on heading andS5node, the health-metrics row, and thecommand-enumeration.ts/program.tslistings), and.pi/skills/package-pi-permission-system/SKILL.md. Commit:docs(pi-permission-system): document indirection-wrapper floor and mark roadmap step 5 (#490).
Risks and Mitigations
- Risk: a benign wrapped command (
sudo apt list,env,timeout 5 curl …) now prompts where a permissive policy auto-allowed it, and there is no way to force-allow the wrapper. Mitigation: intended trade-off (fail-safe over convenience), the same posture as #481's opaque floor; documented indocs/configuration.md. A user who trusts the inner command can gate it via an explicitdeny-free specific rule on the whole wrapper string only up toask— force-allow is deliberately unavailable. - Risk: over-broad name matching floors an unrelated command that happens to share a wrapper name.
Mitigation: the always-invoke names are specific commands (
sudo/env/xargs/time/nohup/timeout/nice);find/fdrequire an exec flag, so a bare search is unaffected. - Risk: the
opaque→wrapperKindrename silently drops a floor if a literal migration is missed. Mitigation: the field rename is a type change, so every staleopaque: trueliteral is a compile error caught in Step 1;pnpm run checkgates it. - Risk: the recorded roadmap direction (2026-07-10 hybrid) and this floor-all plan diverge, confusing a future reader. Mitigation: Step 4 rewrites the roadmap Step 5 note to record the 2026-07-12 supersession explicitly.
Open Questions
- Other exec-capable modern rewrites (GNU
parallel,rust-parallel,sad, …) — filed as follow-up #575; deferred so this change ships the confirmed set. - Force-allow escape valve for trusted wrappers — deliberately omitted; revisit only if the floor proves too coarse in practice (the same deferral #481 made for precise inner-command matching).