fix(coding-agent): closed bash approval rule bypasses

Tested the shell-control guard against the raw command: whitespace
normalization collapsed newlines/CR before the guard ran, so
'git status\nrm file.txt' rode a 'git *' allow rule while bash executed
both lines. Honored tool-owned allow/prompt policies in yolo mode so
per-command prompt rules were no longer silently discarded under the
default approvalMode. Added precision regression tests through the real
matcher (separators, subshells, redirects, env prefixes, path/quoting
variants) that fail on the unfixed head.
This commit is contained in:
can1357
2026-07-23 17:13:23 +02:00
parent 794515fd1a
commit 09d02c641f
3 changed files with 64 additions and 1 deletions
@@ -130,6 +130,15 @@ export function resolveApproval(
}
if (mode === "yolo") {
if (decision.policy) {
return {
policy: decision.policy,
tier: decision.tier,
override: false,
source: "tool",
...(decision.reason ? { reason: decision.reason } : {}),
};
}
return {
policy: userPolicy ?? "allow",
tier: decision.tier,
+1 -1
View File
@@ -179,7 +179,7 @@ function findBashApprovalPatternRule(
rules: readonly BashApprovalPatternRule[],
): BashApprovalPatternRule | undefined {
return rules.find(rule => {
if (rule.approval === "allow" && BASH_APPROVAL_SHELL_CONTROL_RE.test(normalizeBashApprovalPattern(command))) {
if (rule.approval === "allow" && BASH_APPROVAL_SHELL_CONTROL_RE.test(command)) {
return false;
}
return commandMatchesBashApprovalPattern(command, rule.match);
@@ -273,6 +273,60 @@ describe("tool-owned dynamic approval declarations", () => {
reason: "Blocked by bash pattern: rm -rf *",
});
});
it("never auto-approves a command that only prefixes an allow pattern", () => {
const settingsOverrides = {
"bash.patterns": [{ match: "git *", approval: "allow" }],
};
// Shell control syntax after (or around) the allowed prefix must not ride the allow rule.
for (const command of [
"git status; rm file.txt",
"git status && rm file.txt",
"git status | sh",
"git status\nrm file.txt",
"git status\r\nrm file.txt",
"git $(rm file.txt)",
"git `rm file.txt` status",
"git status > /etc/passwd",
"git status < seed",
// Different binary resolution than the pattern names.
"FOO=1 git status",
"/usr/bin/git status",
'"git" status',
"gitx status",
"git",
"",
]) {
const decision = bashApproval(command, settingsOverrides);
expect(typeof decision === "object" ? decision.policy : undefined).not.toBe("allow");
}
for (const command of ["git status", "git status --short", "git status", "git\tstatus"]) {
expect(bashApproval(command, settingsOverrides)).toEqual({ tier: "write", policy: "allow" });
}
});
it("honors bash pattern rules in yolo mode", () => {
const tool = createBashTool({
"bash.patterns": [
{ match: "echo *", approval: "prompt" },
{ match: "git *", approval: "allow" },
],
});
expect(resolveApproval(tool, { command: "echo hello" }, "yolo", {})).toMatchObject({
policy: "prompt",
source: "tool",
});
expect(resolveApproval(tool, { command: "git status" }, "yolo", {})).toMatchObject({
policy: "allow",
source: "tool",
});
expect(resolveApproval(tool, { command: "true" }, "yolo", {})).toMatchObject({
policy: "allow",
source: "mode",
});
});
it("exports LSP and debug read-only action sets from their owning tools", () => {
expect(LSP_READONLY_ACTIONS.has("diagnostics")).toBe(true);