mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
dae1a01f23
commit
2b21163a18
@@ -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: ":" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<PermissionState, number> = {
|
||||
@@ -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<string, PermissionState | DenyWithReason>,
|
||||
): 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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user