fix(pi-permission-system): bash 命令枚举下降进入控制流与赋值载荷

for/while/if/case/{ }/函数体此前只按整段文本匹配,
"for d in ...; do n=$(find ...); done" 中的 find 绕过了 find* deny(真实会话复现);
x=$(grep ...)、export X=$(find ...)、! grep ... 同样绕过。

控制流语句仍整段判定,并逐条枚举条件与体内命令;do/else/case 分支容器只下降不单独成单元;
其余语句兜底额外枚举其中的 $(…)。结果只会更严,hard guard 共享同一枚举。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-22 00:14:49 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent dae1a01f23
commit 2b21163a18
6 changed files with 412 additions and 6 deletions
@@ -1,6 +1,7 @@
import {
EXECUTION_HOST_TYPES,
forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution";
import type { TSNode } from "#src/access-intent/bash/parser";
import {
@@ -72,6 +73,62 @@ const COMMAND_ENUM_DESCEND = new Set([
*/
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
/**
* Compound statement node types whose bodies really run commands:
* `for`/`while`/`until` loops (tree-sitter-bash folds `until` into
* `while_statement`), `if`/`case` statements, `{ … }` brace groups
* (`compound_statement`), function definitions, and `!`-negated commands.
*
* These were once emitted whole without descending (#306 deferred that), which
* let a gated command hide inside a loop body — a `for …; do find …; done`
* matched only patterns starting with `for`. Each is now emitted whole AND
* enumerated child-by-child; a `negated_command` emits `! grep …` whole and
* then `grep …` so the `!` prefix cannot defeat a command pattern either.
*/
const CONTROL_FLOW_TYPES = new Set([
"for_statement",
"c_style_for_statement",
"while_statement",
"if_statement",
"case_statement",
"compound_statement",
"function_definition",
"negated_command",
]);
/**
* Control-flow nodes that are pure body containers rather than statements in
* their own right: `do …; done`, `elif`/`else` clauses, and `case` items.
* They are descended exactly like {@link CONTROL_FLOW_TYPES} members but are
* not emitted as whole units — nobody writes a command pattern against
* `do …; done` or `a) find . ;;`, so emitting them only adds noise to prompts
* and approval suggestions.
*/
const CONTROL_FLOW_CONTAINERS = new Set([
"do_group",
"elif_clause",
"else_clause",
"case_item",
]);
/**
* Named children of a control-flow node that are statements in their own
* right and get full command enumeration (whole-emit rules, wrapper
* classification, substitution descent). Any other named child — words,
* `variable_name`, a for-loop's value list, a case subject or pattern, a
* `test_command` `[[ … ]]`, a comment — contributes only the nested
* `$(…)`/`<(…)` executions it hosts.
*/
const CONTROL_FLOW_STATEMENT_TYPES = new Set([
"command",
"list",
"pipeline",
"redirected_statement",
"subshell",
...CONTROL_FLOW_TYPES,
...CONTROL_FLOW_CONTAINERS,
]);
/**
* Enumerate the command units of a bash program, in source order.
*
@@ -82,8 +139,19 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
* subshells (`( … )`) — emitting each inner command as its own unit *in
* addition to* the enclosing command, since those inner commands really execute
* (#306).
* Control-flow bodies and `{ … }` brace groups are emitted whole without
* descending (deferred).
* Control-flow statements (loops, `if`/`elif`/`else`, `case`, `{ … }` groups,
* function definitions, `!` negation) are likewise emitted whole and then
* descended child-by-child: statement children (including nested control flow
* and conditions like `if grep -q …; then`) are enumerated as commands, while
* non-statement children (the for-loop value list, case subject/patterns,
* `[[ … ]]` tests, names) contribute only the nested executions they host.
* Pure body containers (`do …; done`, `elif`/`else` clauses, `case` items)
* are descended without a whole-unit emit of their own.
* Body commands keep the enclosing execution context — a loop inside `$(…)`
* runs there, a top-level loop's body runs in the current shell.
* Remaining named statements (`variable_assignment`, `declaration_command`,
* `test_command`, …) emit whole and still report the nested executions they
* host, so `x=$(grep foo f)` and `export X=$(find .)` gate the inner command.
*
* The enclosing command/subshell is always still emitted whole, so adding the
* nested units can only ever produce a more-restrictive decision, never weaker.
@@ -135,9 +203,57 @@ function collectCommandsInto(
return;
}
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
// function_definition): emit whole, do not descend — deferred (#306).
if (CONTROL_FLOW_TYPES.has(node.type)) {
out.push(makeUnit(node.text, context)); // never-weaker whole emit
descendControlFlowChildren(node, context, out);
return;
}
if (CONTROL_FLOW_CONTAINERS.has(node.type)) {
// Pure body container — enumerate its contents, emit no unit of its own.
descendControlFlowChildren(node, context, out);
return;
}
// Any other named statement (variable_assignment, declaration_command,
// test_command, …) keeps the whole-emit fallback, but a substitution in its
// payload still really runs — `x=$(grep foo f)` or `export X=$(find .)` —
// so hosted executions are enumerated in addition to the whole text.
out.push(makeUnit(node.text, context));
collectHostedCommands(node, out);
}
/**
* Enumerate inside a control-flow statement, keeping the enclosing context.
*
* Statement children (`command`, `list`, `pipeline`, `redirected_statement`,
* `subshell`, or nested control flow such as a `do_group`)
* get full enumeration. Other named children are scanned only for nested
* executions: a `command_substitution` child directly (the `$(…)` in
* `for f in $(…)` or `case $(…) in`) is a context node in its own right — the
* strict-subtree {@link forEachNestedExecution} walk would not see it — so it
* is descended with its own context, while words, `variable_name`, test and
* expression subtrees go through the hosted-execution search.
*/
function descendControlFlowChildren(
node: TSNode,
context: BashCommandContext | undefined,
out: BashCommand[],
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child?.isNamed) continue;
if (CONTROL_FLOW_STATEMENT_TYPES.has(child.type)) {
collectCommandsInto(child, context, out);
continue;
}
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
if (nestedContext) {
descendCommandChildren(child, nestedContext, out);
} else {
collectHostedCommands(child, out);
}
}
}
function makeUnit(