From 2b21163a18520b8c46c431089b61dc3ae251162f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Thu, 22 Oct 2026 00:14:49 -0700 Subject: [PATCH] =?UTF-8?q?fix(pi-permission-system):=20bash=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E6=9E=9A=E4=B8=BE=E4=B8=8B=E9=99=8D=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=B5=81=E4=B8=8E=E8=B5=8B=E5=80=BC=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- pi-permission-system/CHANGELOG.md | 1 + pi-permission-system/UPSTREAM.md | 4 + pi-permission-system/docs/configuration.md | 5 +- .../access-intent/bash/command-enumeration.ts | 124 +++++++++++- .../bash/command-enumeration.test.ts | 179 ++++++++++++++++++ .../gates/bash-command-metamorphic.test.ts | 105 +++++++++- 6 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 pi-permission-system/test/access-intent/bash/command-enumeration.test.ts diff --git a/pi-permission-system/CHANGELOG.md b/pi-permission-system/CHANGELOG.md index 49b2393..886fb51 100644 --- a/pi-permission-system/CHANGELOG.md +++ b/pi-permission-system/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Descend into bash control-flow bodies when enumerating command units.** `for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, function definitions, and `!`-negated commands were previously matched only as whole-statement text, so a denied command hidden in a loop body or behind `!` (e.g. `for d in */; do find "$d" …; done`, `! rm -rf x`) bypassed every bash pattern — as did a substitution inside a value (`x=$(grep foo f)`, `export X=$(find .)`). The enumerator still emits each enclosing statement as a unit and now also emits every condition, body, and negated command — plus substitutions hosted in value lists, case subjects, assignments, declarations, and other non-command positions — so the most restrictive decision wins; pure body containers (`do …; done`, `elif`/`else` clauses, `case` items) no longer produce their own units. Hard guards share the same enumeration and now reach those nested commands too. - **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped. - **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask. - **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused. diff --git a/pi-permission-system/UPSTREAM.md b/pi-permission-system/UPSTREAM.md index d51aaed..7e4b1d1 100644 --- a/pi-permission-system/UPSTREAM.md +++ b/pi-permission-system/UPSTREAM.md @@ -21,3 +21,7 @@ AutoReview 源码已整合进 `src/auto-review/`,不再保留或加载第二 ## 本地维护 本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。 + +## 本地改动 + +- Bash 命令枚举在保留整条控制流语句为独立 command unit 的基础上递归下降:`for`/`while`/`until`、`if`/`elif`/`else`、`case` 分支、`{ … }` 组合语句、函数定义与 `!` 取反命令体内的命令也按 bash pattern 逐条判定;赋值与声明值(`x=$(…)`、`export X=$(…)`)、值列表、case 词与 `[[ … ]]` 测试中的 `$(…)` 替换同样枚举,而 `do …; done`、`elif`/`else`、`case` 分支等纯容器不再单独产生 unit。藏在循环体、取反前缀或赋值载荷里的 `find`/`grep`/`rm` 等不再绕过 deny 或 hard guard。 diff --git a/pi-permission-system/docs/configuration.md b/pi-permission-system/docs/configuration.md index 6724e68..7bb2a85 100644 --- a/pi-permission-system/docs/configuration.md +++ b/pi-permission-system/docs/configuration.md @@ -370,7 +370,10 @@ This holds wherever the substitution appears, not only in argument position. A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat < out.txt`. -Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text. +Control-flow statements (`for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, and function definitions) are evaluated as a whole unit *and* descended into: every command in their conditions and bodies is matched against the bash patterns in addition to the enclosing statement's text. +So `for d in src docs; do find "$d" -name '*.ts'; done` evaluates both the whole loop and `find "$d" -name '*.ts'`; if `find *` is denied, the whole invocation is denied. +Non-command parts of a control-flow statement — the `for` value list, the `case` subject and patterns, `[[ … ]]` tests — are not matched as commands themselves, but substitutions nested inside them still are, so `for f in $(find …)` and `if [[ "$(rm e)" = x ]]` gate the inner command. +Negation and shell bookkeeping get the same treatment: `! grep -q x f` evaluates both `! grep -q x f` and the inner `grep -q x f`, and a substitution inside a value — `x=$(grep foo f)`, `export X=$(find .)`, `local y=$(rg z)` — is gated as a command substitution even though the assignment or declaration itself runs no command. A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix. So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`. diff --git a/pi-permission-system/src/access-intent/bash/command-enumeration.ts b/pi-permission-system/src/access-intent/bash/command-enumeration.ts index 8b9b716..6d9729e 100644 --- a/pi-permission-system/src/access-intent/bash/command-enumeration.ts +++ b/pi-permission-system/src/access-intent/bash/command-enumeration.ts @@ -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( diff --git a/pi-permission-system/test/access-intent/bash/command-enumeration.test.ts b/pi-permission-system/test/access-intent/bash/command-enumeration.test.ts new file mode 100644 index 0000000..e772ea0 --- /dev/null +++ b/pi-permission-system/test/access-intent/bash/command-enumeration.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + resetWarmBashParser, + warmBashParser, +} from "#src/access-intent/bash/parser"; +import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands"; + +describe("collectCommands — control-flow descent", () => { + beforeEach(async () => { + resetWarmBashParser(); + await warmBashParser(); + }); + afterEach(() => { + resetWarmBashParser(); + }); + + it("emits a for-loop's body commands in addition to the whole statement", () => { + expect( + parseBashCommandsSync('for d in a b; do find "$d" -name x; done'), + ).toEqual([ + { text: 'for d in a b; do find "$d" -name x; done' }, + { text: 'find "$d" -name x' }, + ]); + }); + + it("emits a command substitution in the for value list as a nested context", () => { + expect( + parseBashCommandsSync('for f in $(find . -name x); do echo "$f"; done'), + ).toEqual([ + { text: 'for f in $(find . -name x); do echo "$f"; done' }, + { text: "find . -name x", context: "command_substitution" }, + { text: 'echo "$f"' }, + ]); + }); + + it("emits the condition and body commands of a redirected while loop", () => { + expect( + parseBashCommandsSync('while read l; do grep x "$l"; done < list'), + ).toEqual([ + { text: 'while read l; do grep x "$l"; done' }, + { text: "read l" }, + { text: 'grep x "$l"' }, + ]); + }); + + it("emits an if's condition, branch, and else commands", () => { + expect( + parseBashCommandsSync("if grep -q x f; then echo y; else rm z; fi"), + ).toEqual([ + { text: "if grep -q x f; then echo y; else rm z; fi" }, + { text: "grep -q x f" }, + { text: "echo y" }, + { text: "rm z" }, + ]); + }); + + it("emits a case item's commands", () => { + expect(parseBashCommandsSync("case $x in a) find . ;; esac")).toEqual([ + { text: "case $x in a) find . ;; esac" }, + { text: "find ." }, + ]); + }); + + it("emits a command substitution in the case subject as a nested context", () => { + expect( + parseBashCommandsSync("case $(rm x) in a) find . ;; esac"), + ).toEqual([ + { text: "case $(rm x) in a) find . ;; esac" }, + { text: "rm x", context: "command_substitution" }, + { text: "find ." }, + ]); + }); + + it("descends a brace group", () => { + expect(parseBashCommandsSync("{ grep x f; }")).toEqual([ + { text: "{ grep x f; }" }, + { text: "grep x f" }, + ]); + }); + + it("descends a function definition's body", () => { + expect(parseBashCommandsSync("f() { grep x f; }")).toEqual([ + { text: "f() { grep x f; }" }, + { text: "{ grep x f; }" }, + { text: "grep x f" }, + ]); + }); + + it("descends nested loops", () => { + expect( + parseBashCommandsSync("for a in 1; do for b in 2; do find .; done; done"), + ).toEqual([ + { text: "for a in 1; do for b in 2; do find .; done; done" }, + { text: "for b in 2; do find .; done" }, + { text: "find ." }, + ]); + }); + + it("descends a c-style for loop", () => { + expect( + parseBashCommandsSync("for ((i=0;i<3;i++)); do echo i; done"), + ).toEqual([ + { text: "for ((i=0;i<3;i++)); do echo i; done" }, + { text: "echo i" }, + ]); + }); + + it("emits no container units for a clean loop body", () => { + expect(parseBashCommandsSync('for d in a b; do echo "$d"; done')).toEqual([ + { text: 'for d in a b; do echo "$d"; done' }, + { text: 'echo "$d"' }, + ]); + }); + + it("does not emit a [[ ]] test as a separate command unit", () => { + expect(parseBashCommandsSync("if [[ -f x ]]; then echo y; fi")).toEqual([ + { text: "if [[ -f x ]]; then echo y; fi" }, + { text: "echo y" }, + ]); + }); + + it("catches a substitution hosted inside a [[ ]] test", () => { + expect( + parseBashCommandsSync('if [[ "$(rm x)" = y ]]; then echo y; fi'), + ).toEqual([ + { text: 'if [[ "$(rm x)" = y ]]; then echo y; fi' }, + { text: "rm x", context: "command_substitution" }, + { text: "echo y" }, + ]); + }); +}); + +describe("collectCommands — assignment, declaration, and negation", () => { + beforeEach(async () => { + resetWarmBashParser(); + await warmBashParser(); + }); + afterEach(() => { + resetWarmBashParser(); + }); + + it("emits a substitution in a plain assignment value as a nested context", () => { + expect(parseBashCommandsSync("x=$(grep foo f)")).toEqual([ + { text: "x=$(grep foo f)" }, + { text: "grep foo f", context: "command_substitution" }, + ]); + }); + + it("emits a substitution in an export declaration value", () => { + expect(parseBashCommandsSync("export X=$(find .)")).toEqual([ + { text: "export X=$(find .)" }, + { text: "find .", context: "command_substitution" }, + ]); + }); + + it("emits a substitution in a local declaration inside a function body", () => { + expect(parseBashCommandsSync("f() { local y=$(rg z); }")).toEqual([ + { text: "f() { local y=$(rg z); }" }, + { text: "{ local y=$(rg z); }" }, + { text: "rg z", context: "command_substitution" }, + ]); + }); + + it("emits a negated command's inner command as a normal unit", () => { + expect(parseBashCommandsSync("! grep -q x f")).toEqual([ + { text: "! grep -q x f" }, + { text: "grep -q x f" }, + ]); + }); + + it("emits a negated condition's inner command inside an if", () => { + expect(parseBashCommandsSync("if ! grep -q x f; then :; fi")).toEqual([ + { text: "if ! grep -q x f; then :; fi" }, + { text: "! grep -q x f" }, + { text: "grep -q x f" }, + { text: ":" }, + ]); + }); +}); diff --git a/pi-permission-system/test/handlers/gates/bash-command-metamorphic.test.ts b/pi-permission-system/test/handlers/gates/bash-command-metamorphic.test.ts index 8e3cefb..0ac9766 100644 --- a/pi-permission-system/test/handlers/gates/bash-command-metamorphic.test.ts +++ b/pi-permission-system/test/handlers/gates/bash-command-metamorphic.test.ts @@ -14,10 +14,13 @@ import { BashProgram } from "#src/access-intent/bash/program"; import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command"; import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { PathNormalizer } from "#src/path-normalizer"; +import { PermissionResolver } from "#src/permission-resolver"; import type { ScopedPermissionResolver } from "#src/permission-resolver"; -import type { PermissionState } from "#src/types"; +import { SessionRules } from "#src/session-rules"; +import type { DenyWithReason, PermissionState } from "#src/types"; import { makeCheckResult } from "#test/helpers/handler-fixtures"; +import { createInMemoryManager } from "#test/helpers/manager-harness"; /** Decision strength ordering: deny (2) > ask (1) > allow (0). */ const STRENGTH: Record = { @@ -146,3 +149,103 @@ describe("bash command gate — nested execution hosts do not weaken", () => { expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow"); }); }); + +/** + * The same totality property for control-flow bodies. + * + * A `for`/`while`/`if`/`case`/function was once emitted whole without + * descending, so `for d in …; do find …; done` only ever matched patterns + * starting with `for` — a `find*: deny` rule was bypassed. The enumerator now + * emits the whole statement AND its body commands, so hosting a gated command + * inside control flow cannot weaken its decision. + */ +describe("bash command gate — control-flow bodies do not weaken", () => { + /** Real pattern matching over a bash map: `*` allow, the rest deny-with-reason. */ + function makePolicyResolver( + bash: Record, + ): ScopedPermissionResolver { + const manager = createInMemoryManager({ + global: { permission: { "*": "allow", bash } }, + }); + return new PermissionResolver(manager, new SessionRules()); + } + + async function checkWith( + command: string, + resolver: ScopedPermissionResolver, + ) { + const program = await BashProgram.parse( + command, + new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"), + ); + return resolveBashCommandCheck(command, program.commands(), undefined, resolver); + } + + it("denies the reported repro: find inside a for-loop command substitution", async () => { + const resolver = makePolicyResolver({ + "*": "allow", + "find*": { action: "deny", reason: "use the find tool" }, + "grep*": { action: "deny", reason: "use the grep tool" }, + }); + + const result = await checkWith( + 'cd /Users/sia/code/my-pi && ls -1 extensions/ && echo "=== LOC by ext ===" && for d in pi-*/; do n=$(find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\' | xargs wc -l 2>/dev/null | tail -1 | awk \'{print $1}\'); echo "$d $n"; done && echo "=== extensions ts ===" && wc -l extensions/*.ts | tail -3', + resolver, + ); + + expect(result.state).toBe("deny"); + expect(result.command).toBe( + 'find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\'', + ); + expect(result.matchedPattern).toBe("find*"); + expect(result.reason).toBe("use the find tool"); + expect(result.commandContext).toBe("command_substitution"); + }); + + it.each([ + 'for d in a b; do find "$d" -name x; done', + 'while read l; do grep x "$l"; done < list', + "if grep -q x f; then echo y; else echo n; fi", + "case $x in a) find . ;; esac", + "{ grep x f; }", + "f() { grep x f; }", + "for a in 1; do for b in 2; do find .; done; done", + "x=$(grep foo f)", + "export X=$(find .)", + "f() { local y=$(rg z); }", + "! grep -q x f", + "if ! grep -q x f; then :; fi", + ])("denies a gated command inside `%s`", async (command) => { + const resolver = makePolicyResolver({ + "*": "allow", + "find*": "deny", + "grep*": "deny", + "rg*": "deny", + }); + + expect((await checkWith(command, resolver)).state).toBe("deny"); + }); + + it("denies a hard-guard payload hidden in a loop body", async () => { + const resolver = makePolicyResolver({ + "*": "allow", + "rm -rf*": "deny", + }); + + const result = await checkWith("for f in a; do rm -rf /; done", resolver); + expect(result.state).toBe("deny"); + expect(result.matchedPattern).toBe("rm -rf*"); + }); + + it("allows a control-flow statement whose body commands are allowed", async () => { + const resolver = makePolicyResolver({ + "*": "allow", + "find*": "deny", + "grep*": "deny", + }); + + expect( + (await checkWith('for d in a b; do echo "$d"; done', resolver)).state, + ).toBe("allow"); + }); +});