mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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>
252 lines
9.0 KiB
TypeScript
252 lines
9.0 KiB
TypeScript
/**
|
|
* Metamorphic totality property for the bash command gate (#452, A3).
|
|
*
|
|
* Wrapping any `ask`/`deny` command in `cd /x && <cmd>` must not weaken the
|
|
* decision — the chain decomposition + most-restrictive-wins, combined with the
|
|
* fail-closed empty-parse fallback, guarantees a `cd …` prefix can never let a
|
|
* gated command ride a permissive top-level `*`.
|
|
*
|
|
* A focused parametrized table over the real tree-sitter parse + resolve, not a
|
|
* full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
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 { 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> = {
|
|
allow: 0,
|
|
ask: 1,
|
|
deny: 2,
|
|
};
|
|
|
|
/**
|
|
* Resolver whose decision keys on a command substring → state map. A command
|
|
* matching no entry resolves to allow (the permissive top-level `*`).
|
|
*/
|
|
function makeKeyedResolver(
|
|
rules: { match: string; state: PermissionState }[],
|
|
): ScopedPermissionResolver {
|
|
return {
|
|
resolve: (intent) => {
|
|
const command =
|
|
intent.kind === "tool"
|
|
? ((intent.input as { command?: string }).command ?? "")
|
|
: "";
|
|
const rule = rules.find((r) => command.includes(r.match));
|
|
const state: PermissionState = rule?.state ?? "allow";
|
|
return makeCheckResult({ state, source: "bash", command });
|
|
},
|
|
};
|
|
}
|
|
|
|
async function decide(
|
|
command: string,
|
|
resolver: ScopedPermissionResolver,
|
|
): Promise<PermissionState> {
|
|
const program = await BashProgram.parse(
|
|
command,
|
|
new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"),
|
|
);
|
|
return resolveBashCommandCheck(
|
|
command,
|
|
program.commands(),
|
|
undefined,
|
|
resolver,
|
|
).state;
|
|
}
|
|
|
|
describe("bash command gate — metamorphic totality", () => {
|
|
const cases: { bare: string; state: PermissionState }[] = [
|
|
{ bare: "git push", state: "ask" },
|
|
{ bare: "git commit -m wip", state: "ask" },
|
|
{ bare: "rm -rf build", state: "deny" },
|
|
{ bare: "npm install pkg", state: "deny" },
|
|
{ bare: "gh pr create", state: "ask" },
|
|
];
|
|
|
|
for (const { bare, state } of cases) {
|
|
it(`wrapping "${bare}" in a cd prefix does not weaken its ${state} decision`, async () => {
|
|
const resolver = makeKeyedResolver([
|
|
{ match: bare.split(" ")[0] ?? bare, state },
|
|
]);
|
|
|
|
const bareDecision = await decide(bare, resolver);
|
|
const wrappedDecision = await decide(`cd /repo && ${bare}`, resolver);
|
|
|
|
expect(STRENGTH[wrappedDecision]).toBeGreaterThanOrEqual(
|
|
STRENGTH[bareDecision],
|
|
);
|
|
expect(wrappedDecision).toBe(state);
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* The same totality property for nested execution hosts (#741).
|
|
*
|
|
* A command hosted in a redirect target or an interpolating heredoc body really
|
|
* executes, so hosting a gated command there must not weaken its decision — the
|
|
* enclosing `echo`/`cat` resolves to a permissive allow, and only the nested
|
|
* unit carries the restriction.
|
|
*/
|
|
describe("bash command gate — nested execution hosts do not weaken", () => {
|
|
const hosts: { label: string; wrap: (cmd: string) => string }[] = [
|
|
{ label: "a stdout redirect", wrap: (c) => `echo hi > $(${c})` },
|
|
{ label: "an appending redirect", wrap: (c) => `echo hi >> $(${c})` },
|
|
{ label: "a stderr redirect", wrap: (c) => `echo hi 2> \`${c}\`` },
|
|
{ label: "an input process substitution", wrap: (c) => `cat < <(${c})` },
|
|
{
|
|
label: "an interpolating heredoc",
|
|
wrap: (c) => `cat <<EOF\n$(${c})\nEOF`,
|
|
},
|
|
];
|
|
|
|
const cases: { bare: string; state: PermissionState }[] = [
|
|
{ bare: "rm -rf build", state: "deny" },
|
|
{ bare: "git push", state: "ask" },
|
|
];
|
|
|
|
for (const { label, wrap } of hosts) {
|
|
for (const { bare, state } of cases) {
|
|
it(`hosting "${bare}" in ${label} does not weaken its ${state} decision`, async () => {
|
|
const resolver = makeKeyedResolver([
|
|
{ match: bare.split(" ")[0] ?? bare, state },
|
|
]);
|
|
|
|
const bareDecision = await decide(bare, resolver);
|
|
const hostedDecision = await decide(wrap(bare), resolver);
|
|
|
|
expect(STRENGTH[hostedDecision]).toBeGreaterThanOrEqual(
|
|
STRENGTH[bareDecision],
|
|
);
|
|
expect(hostedDecision).toBe(state);
|
|
});
|
|
}
|
|
}
|
|
|
|
it("denies the reported repro when the enclosing command is allowed", async () => {
|
|
// #741: `echo *` allowed, `rm *` denied — the redirect-hosted `rm` decides.
|
|
const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]);
|
|
|
|
expect(await decide('echo "hello world" > $(rm *.txt)', resolver)).toBe(
|
|
"deny",
|
|
);
|
|
});
|
|
|
|
it("leaves a quoted heredoc body literal, so it does not gate", async () => {
|
|
const resolver = makeKeyedResolver([{ match: "rm", state: "deny" }]);
|
|
|
|
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");
|
|
});
|
|
});
|