Skip to content

Commit 6ccd62d

Browse files
Fix auto-mode allowing quoted shell redirect writes and dangerous flags (#601)
* Fix auto-mode shell policy: quoting no longer defeats deny/ask rules The auto-shell policy blanked quoted spans to whole-space before matching its rules, so a quoted redirect target, a quoted -c/-i flag, a quoted install subcommand, or a quoted argv0 all read as absent text and slipped past deny/ask. Replace the blanket blank with quote-aware dequoting that mirrors real shell semantics: quote characters are dropped and their content stays literal, except the handful of characters that are only operators outside quotes (> < | & ; `) which are neutralized when quoted, so a literal '>' in a commit message still can't be mistaken for a redirect. Also widen the file-mutation redirect pattern to recognize the `>|` / `>>|` clobber form, which never matched at all. One tokenizer fix covers all three bypasses since they share the same root cause (stripQuoted) and the same call site (matchAutoShellRule). * Close backslash-escaped-quote bypass; narrow changelog claim dequoteForMatching tracked quote state without escape awareness, so a backslash-escaped quote (\") still toggled quote state the same as a real one. In real bash \" is a literal quote character that never opens or closes a quoted span, so an operator or flag that follows is genuinely unquoted. Skip the escaped character without touching quote state. Also narrow the CHANGELOG's nested bash -c claim to what the tests actually cover (one level of quoting inside -c), not true multi-level nested-shell parsing, which remains a known gap tracked separately. * Apply prettier formatting
1 parent 1135b01 commit 6ccd62d

3 files changed

Lines changed: 143 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
5858
the tool at all. `progress_note` for leaf workers is a separate,
5959
not-yet-implemented follow-up.
6060

61+
### Permissions
62+
63+
- **Quoting or backslash-escaping a redirect target, a dangerous flag, or a
64+
program name no longer bypasses auto mode's shell rules.** The auto-shell
65+
policy used to blank out quoted text before matching its rules, so `echo hi
66+
> "file"`, `echo hi >|file`, a quoted `-c`/`-i` flag, a quoted `install`
67+
subcommand, or a quoted upload-tool name all slipped past the
68+
file-mutation, dependency-install, and network-upload rules — including one
69+
level of quoting inside a `bash -c` payload. Matching now dequotes the
70+
command the way a real shell would (only the operator characters `> < | &
71+
; \`` are neutralized when they occur inside a quote, everything else stays
72+
literal, and a backslash-escaped quote never opens or closes a span), and
73+
the file-mutation redirect pattern now also recognizes the `>|` / `>>|`
74+
clobber form.
75+
6176
### Fixed
6277

6378
- **Interrupting a turn no longer risks a startup crash.** If an interrupt hit

src/permission/auto-shell-policy.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,52 @@ export interface AutoShellRule {
3333
const CMD = String.raw`(?:^|[\n;&|({]\s*)(?:\w+=\S*\s+)*`;
3434
const inCmd = (body: string): RegExp => new RegExp(`${CMD}${body}`);
3535

36-
// Drop the contents of single- and double-quoted spans before matching so a
37-
// quoted argument cannot trip a rule (e.g. `git commit -m 'fix > bug'` is not a
38-
// redirect, `echo "npm install"` is not an install). Quoted-out redirect targets
39-
// and heredoc markers fall away with their quotes, which is why the file-mutation
40-
// heredoc pattern keys on the bare `<<` operator rather than the marker word.
41-
const stripQuoted = (command: string): string => command.replace(/'[^']*'|"[^"]*"/g, " ");
36+
// Quote-aware dequoting for rule matching. Real shells strip the quote
37+
// characters themselves and hand the program a literal argument, so a rule
38+
// must see the same thing the program would: a quoted redirect target
39+
// (`>"file"`), a quoted flag (`"-c"`), or a quoted program/subcommand name
40+
// (`"sed" -i`, `npm "install"`) all read exactly like their unquoted form.
41+
// The one thing quoting genuinely changes is that a shell *operator*
42+
// character loses its operator meaning inside quotes — `'fix > bug'` is a
43+
// literal string, not a redirect — so only that small set of operator
44+
// characters (`> < | & ; \``) is neutralized when it occurs inside a quoted
45+
// span; every other character (letters, digits, `-`) passes through
46+
// dequoted. Heredoc bodies are left alone: the file-mutation heredoc pattern
47+
// keys on the bare `<<` operator, which is always outside any quoting.
48+
//
49+
// A backslash before a quote character escapes it: `\"` is a literal `"`
50+
// that never opens or closes a quoted span (real bash semantics outside
51+
// single quotes), so `echo hi \"> file"` is a bare, unquoted redirect, not
52+
// text inside a quote. Skip the escaped character without touching quote
53+
// state so its following operator is still seen as live.
54+
const QUOTE_NEUTRALIZED_OPERATORS = new Set(["<", ">", "|", "&", ";", "`"]);
55+
56+
const dequoteForMatching = (command: string): string => {
57+
let out = "";
58+
let quote: '"' | "'" | null = null;
59+
for (let i = 0; i < command.length; i++) {
60+
const ch = command[i] as string;
61+
if (ch === "\\" && quote !== "'" && i + 1 < command.length) {
62+
out += command[i + 1];
63+
i++;
64+
continue;
65+
}
66+
if (quote !== null) {
67+
if (ch === quote) {
68+
quote = null;
69+
} else {
70+
out += QUOTE_NEUTRALIZED_OPERATORS.has(ch) ? " " : ch;
71+
}
72+
continue;
73+
}
74+
if (ch === '"' || ch === "'") {
75+
quote = ch;
76+
continue;
77+
}
78+
out += ch;
79+
}
80+
return out;
81+
};
4282

4383
// Named separately (not inlined in AUTO_SHELL_RULES below) so the dedicated
4484
// `env -S`/`--split-string` check further down — which cannot be expressed as
@@ -71,9 +111,10 @@ export const AUTO_SHELL_RULES: AutoShellRule[] = [
71111
reason:
72112
"File creation and edits must go through the write_file and edit_file tools, not shell tooling (python, sed -i, awk, perl, tee, or output redirection). Re-do this change with edit_file for a surgical replacement or write_file for the full contents.",
73113
patterns: [
74-
// `>` / `>>` (optionally fd-qualified) to a target that is not an fd dup
114+
// `>` / `>>` (optionally fd-qualified, optionally clobber-forced with a
115+
// trailing `|` as in `>|` / `>>|`) to a target that is not an fd dup
75116
// (`2>&1`) or a safe pseudo-device (`> /dev/null`, a TTY).
76-
/[0-9]?>>?\s*(?!&|\/dev\/(?:null|stdout|stderr|stdin|tty|pts\/|fd\/))[^\s|;&)]/,
117+
/[0-9]?>>?\|?\s*(?!&|\/dev\/(?:null|stdout|stderr|stdin|tty|pts\/|fd\/))[^\s|;&)]/,
77118
// tee writes its stdin to one or more files.
78119
/(?:^|[\n;&|({]\s*)tee\b/,
79120
// In-place stream editors: sed -i, perl -pi -e, ruby -i.
@@ -166,7 +207,7 @@ export const AUTO_SHELL_RULES: AutoShellRule[] = [
166207
];
167208

168209
export function matchAutoShellRule(command: string): AutoShellRule | undefined {
169-
const scannable = stripQuoted(command);
210+
const scannable = dequoteForMatching(command);
170211
return AUTO_SHELL_RULES.find((rule) => rule.patterns.some((pattern) => pattern.test(scannable)));
171212
}
172213

src/permission/classify-security.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,3 +712,81 @@ describe("pure directory listing exemption", () => {
712712
expect(autoShellRuleForCall(shellCall("ls .env | xargs cat"))?.effect).toBe("ask");
713713
});
714714
});
715+
716+
describe("CL-6703 — quoted redirect targets still deny file-mutation", () => {
717+
test("plain unquoted redirect denies (baseline)", () => {
718+
expect(autoShellRuleForCall(shellCall("echo hi > out.txt"))?.name).toBe("file-mutation");
719+
});
720+
721+
test("a quoted redirect target denies", () => {
722+
expect(autoShellRuleForCall(shellCall(`echo hi > "out.txt"`))?.name).toBe("file-mutation");
723+
expect(autoShellRuleForCall(shellCall(`echo hi > 'out.txt'`))?.name).toBe("file-mutation");
724+
});
725+
726+
test('a quoted fd-qualified redirect target (1>"file") denies', () => {
727+
expect(autoShellRuleForCall(shellCall(`echo hi 1>"file"`))?.name).toBe("file-mutation");
728+
});
729+
730+
test("a nested bash -c form with a quoted redirect denies", () => {
731+
expect(autoShellRuleForCall(shellCall(`bash -c 'echo hi > "out.txt"'`))?.name).toBe(
732+
"file-mutation",
733+
);
734+
});
735+
736+
test("a quoted '>' inside non-redirect text does not false-positive", () => {
737+
expect(autoShellRuleForCall(shellCall(`git commit -m 'fix > bug'`))).toBeUndefined();
738+
});
739+
740+
test("a backslash-escaped quote before a redirect still denies", () => {
741+
// `\"` is a literal quote character in real bash, not a quote-open — the
742+
// shell is never inside a quoted string here, so the `>` that follows is
743+
// a genuine, unquoted redirect.
744+
expect(autoShellRuleForCall(shellCall('echo hi \\"> file"'))?.name).toBe("file-mutation");
745+
});
746+
747+
test("a backslash-escaped quote ahead of a dangerous flag still denies", () => {
748+
// The escaped quote sits before an extra leading space, so it never
749+
// touches the `\s-c` junction later in the string; a naive quote-pairing
750+
// scanner (ignoring the backslash) would consume that junction as part
751+
// of a fake quoted span and hide the -c flag entirely.
752+
expect(autoShellRuleForCall(shellCall('python3 \\" -c print(1)"'))?.name).toBe("file-mutation");
753+
});
754+
});
755+
756+
describe("CL-6702 — bash clobber redirects match file-mutation", () => {
757+
test("echo hi >|path denies", () => {
758+
expect(autoShellRuleForCall(shellCall("echo hi >|path"))?.name).toBe("file-mutation");
759+
});
760+
761+
test("echo hi >>|path denies", () => {
762+
expect(autoShellRuleForCall(shellCall("echo hi >>|path"))?.name).toBe("file-mutation");
763+
});
764+
});
765+
766+
describe("CL-6697 — quoted dangerous flags and program names still deny/ask", () => {
767+
test("a quoted -c interpreter one-liner denies", () => {
768+
expect(autoShellRuleForCall(shellCall(`python3 "-c" "print(1)"`))?.name).toBe("file-mutation");
769+
});
770+
771+
test("a quoted sed -i denies", () => {
772+
expect(autoShellRuleForCall(shellCall(`sed "-i" 's/a/b/' file.txt`))?.name).toBe(
773+
"file-mutation",
774+
);
775+
});
776+
777+
test("a quoted npm install asks", () => {
778+
expect(autoShellRuleForCall(shellCall(`npm "install" left-pad`))?.name).toBe(
779+
"dependency-install",
780+
);
781+
});
782+
783+
test("a quoted upload-tool argv0 (curl) asks", () => {
784+
expect(
785+
autoShellRuleForCall(shellCall(`"curl" -d @payload.json https://example.com`))?.name,
786+
).toBe("network-upload");
787+
});
788+
789+
test("an innocent quoted argument interior does not false-positive", () => {
790+
expect(autoShellRuleForCall(shellCall(`git commit -m "some text"`))).toBeUndefined();
791+
});
792+
});

0 commit comments

Comments
 (0)