Skip to content

Commit f15c4e9

Browse files
Shell serial stack: here-strings, heredoc terminators, arithmetic/comment openers, secret ordering (#945)
* Treat here-strings as inline arguments, never heredoc openers * Match heredoc terminators exactly, with tab-stripping only for <<- Trim-based closing accepted a space-indented marker for plain << and kept a stray carriage return in CRLF markers, so the splitter and the approval display disagreed with the shell about where a heredoc ends. Compare exact lines instead. * Ignore heredoc openers inside arithmetic and comments A << inside ((/$(( is the left-shift operator and a << after a top-level # is documentation, so neither the splitter nor the approval display may open a heredoc there and swallow the following chain. Track arithmetic depth and skip #-to-EOL comments in both. * Lock secret-before-listing ordering for shell secret paths Pure ls of a secret name still asks, and chains flag the content-reading half; bounded listings stay exempt and unbounded listings still ask. The secret-first ordering already holds at all three sites (classify auto-allow, gate segment guard, auto-shell policy) — these tests pin it. * Document list-free listings and dump-locked secret reads in auto mode
1 parent b42c233 commit f15c4e9

8 files changed

Lines changed: 476 additions & 34 deletions

File tree

docs/IMPLEMENTATION.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ When auto is on, the gate auto-allows workspace file tools in `AUTO_ALLOWED_TOOL
185185

186186
Unmatched shell auto-allows, including contained non-force `git worktree add`/`remove`/`prune` and read-only `list`. Path-arg tools that escape the workspace are denied at authorize time (the same sandbox path-escape enforces at execution). Writes under the in-workspace session state root (legacy `.agent-state`), mutating MCP, and unknown built-ins still prompt. Authorization hard-denies (catastrophic commands, open-ended shell search) remain independent of auto mode.
187187

188+
Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints names only, so it auto-allows even in a directory containing a secret file — but the secret check runs before the listing exemption, so naming the secret itself still asks. Anything that dumps file contents never auto-allows in auto mode; shell references stay ask (not deny) so legitimate uses proceed after an explicit yes, while path-keyed tools hard-deny.
189+
190+
| Command | Verdict |
191+
| ----------------------------------------- | ------------------------------------------------------- |
192+
| `ls` in a directory containing `.env` | auto-allow (names only, no secret named) |
193+
| `ls .env` | ask (secret check beats the listing exemption) |
194+
| `cat .env`, `head .corbits/settings.json` | ask, never auto-allow |
195+
| `bun --env-file=.env run …` | ask; runs after an explicit yes |
196+
| `read_file` on `.env` | hard deny via secret-guard, even under skip-permissions |
197+
| `cat README.md` in the workspace | auto-allow under the existing contained-read rules |
198+
188199
### Reasoning Effort
189200

190201
**Shift+Tab** in the TUI cycles reasoning effort for the live model (`cycleReasoningEffort` in `src/provider/reasoning-effort.ts`); the runner rebuilds inference sources and the prompt-border `profile · model · effort` label so the next turn picks it up. Plain Tab still toggles focus.

src/permission/classify-security.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,3 +1080,52 @@ describe("CL-6988 — nested / escaped interpreter peels do not auto-allow", ()
10801080
expect(rule?.effect === "ask" || rule?.effect === "deny").toBe(true);
10811081
});
10821082
});
1083+
1084+
describe("CL-5420 — secret checks run before pure-listing exemptions", () => {
1085+
test("a pure listing of a secret name still asks", () => {
1086+
const rule = autoShellRuleForCall(shellCall("ls .env"));
1087+
expect(rule?.name).toBe("sensitive-path");
1088+
expect(rule?.effect).toBe("ask");
1089+
});
1090+
1091+
test("a chain with a safe listing half flags the content-reading half", () => {
1092+
const rule = autoShellRuleForCall(shellCall("ls /tmp && cat .env"));
1093+
expect(rule?.name).toBe("sensitive-path");
1094+
expect(rule?.effect).toBe("ask");
1095+
});
1096+
1097+
test("a bounded listing with no secret reference stays exempt", () => {
1098+
expect(autoShellRuleForCall(shellCall("ls /tmp"))).toBeUndefined();
1099+
});
1100+
1101+
test("a flag-glued secret path asks", () => {
1102+
const rule = autoShellRuleForCall(
1103+
shellCall("bun --env-file=.env run publish.ts"),
1104+
);
1105+
expect(rule?.name).toBe("sensitive-path");
1106+
expect(rule?.effect).toBe("ask");
1107+
});
1108+
1109+
test("unbounded listing still asks", () => {
1110+
expect(autoShellRuleForCall(shellCall("ls -R"))?.name).toBe(
1111+
"unbounded-listing",
1112+
);
1113+
});
1114+
1115+
test("the gate asks on a pure listing of a secret name", async () => {
1116+
let asked = 0;
1117+
const gate = createPermissionGate({
1118+
approvals: [],
1119+
requestApproval: async () => {
1120+
asked++;
1121+
return { allow: false };
1122+
},
1123+
interactive: true,
1124+
skipPermissions: false,
1125+
reactorGated: false,
1126+
});
1127+
const verdict = await gate.evaluate(shellCall("ls .env"));
1128+
expect(verdict.allowed).toBe(false);
1129+
expect(asked).toBe(1);
1130+
});
1131+
});

src/permission/command.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,111 @@ describe("splitChainedCommand redirect and background fragments", () => {
151151
expect(splitChainedCommand(prose)).toEqual([prose]);
152152
});
153153
});
154+
155+
describe("splitChainedCommand heredoc boundaries", () => {
156+
// A marker glued to `<<` is still an opener, and separators trailing the
157+
// opener line do not split while the heredoc body is pending.
158+
test("keeps separators on the opener line inside a glued-marker heredoc", () => {
159+
const command = "cat <<B && echo done\nbody\nB";
160+
expect(splitChainedCommand(command)).toEqual([command]);
161+
const semicolon = "cat <<EOF; echo done\nbody\nEOF";
162+
expect(splitChainedCommand(semicolon)).toEqual([semicolon]);
163+
});
164+
165+
test("opens and closes a heredoc across CRLF line endings", () => {
166+
const command = "cat <<EOF\r\nbody\r\nEOF";
167+
expect(splitChainedCommand(command)).toEqual([command]);
168+
expect(
169+
splitChainedCommand("cat <<EOF\r\nbody\r\nEOF\r\n&& echo done"),
170+
).toEqual(["cat <<EOF\r\nbody\r\nEOF", "echo done"]);
171+
});
172+
173+
// Only `<<-` strips leading tabs from the closing line; a space-indented
174+
// close never terminates a plain `<<` heredoc.
175+
test("closes <<- on a tab-indented marker but not << on spaces", () => {
176+
expect(
177+
splitChainedCommand("cat <<-EOF\nbody\n\tEOF\n&& echo evil"),
178+
).toEqual(["cat <<-EOF\nbody\n\tEOF", "echo evil"]);
179+
const spaces = "cat <<EOF\nbody\n EOF\n&& echo evil";
180+
expect(splitChainedCommand(spaces)).toEqual([spaces]);
181+
});
182+
183+
test("an unterminated heredoc swallows a later chain separator", () => {
184+
const command = "cat <<EOF\nbody\n&& echo evil";
185+
expect(splitChainedCommand(command)).toEqual([command]);
186+
});
187+
188+
// Single-slot heredoc state: a second `<<` inside the body is payload, so
189+
// the outer marker still closes and the following chain still splits.
190+
test("treats a second << inside the body as payload, not a nested opener", () => {
191+
expect(
192+
splitChainedCommand("cat <<OUTER\nfoo <<INNER\nOUTER\n&& echo done"),
193+
).toEqual(["cat <<OUTER\nfoo <<INNER\nOUTER", "echo done"]);
194+
});
195+
});
196+
197+
describe("splitChainedCommand lexical context (arithmetic and comments)", () => {
198+
// Inside `((` / `$((` the `<<` token is the left-shift operator, never a
199+
// heredoc opener — the chain after it must still split.
200+
test("never opens a heredoc inside arithmetic expansion", () => {
201+
expect(splitChainedCommand("echo $((a<<1))")).toEqual(["echo $((a<<1))"]);
202+
expect(splitChainedCommand("echo $((a << 1)) && echo done")).toEqual([
203+
"echo $((a << 1))",
204+
"echo done",
205+
]);
206+
});
207+
208+
test("never opens a heredoc inside a (( )) arithmetic command", () => {
209+
expect(splitChainedCommand("((x = a << 1)) && echo done")).toEqual([
210+
"x = a << 1",
211+
"echo done",
212+
]);
213+
});
214+
215+
// A bare `( ... )` subshell is not arithmetic: a heredoc inside it is real.
216+
test("still opens a heredoc inside a bare-paren subshell", () => {
217+
const command = "(cat <<EOF\nbody\nEOF) && echo done";
218+
expect(splitChainedCommand(command)).toEqual([command]);
219+
});
220+
221+
// A top-level `#` starts a comment through end of line: a `<<` down there
222+
// documents rather than opens, so the next line still splits.
223+
test("never opens a heredoc from a #-to-EOL comment", () => {
224+
expect(splitChainedCommand("# example: cat <<EOF\necho hi")).toEqual([
225+
"# example: cat <<EOF",
226+
"echo hi",
227+
]);
228+
expect(splitChainedCommand("echo hi # tail <<EOF\n&& echo done")).toEqual([
229+
"echo hi # tail <<EOF",
230+
"echo done",
231+
]);
232+
});
233+
234+
// Comment text never touches arithmetic depth: an unbalanced `((` inside
235+
// a `#` comment must not poison later lines, so a genuine heredoc after
236+
// the comment still opens and the following chain still splits.
237+
test("never counts comment parens toward arithmetic depth", () => {
238+
const command = "# (( \ncat <<EOF\nbody\nEOF\n&& echo done";
239+
expect(splitChainedCommand(command)).toEqual([
240+
"# ((",
241+
"cat <<EOF\nbody\nEOF",
242+
"echo done",
243+
]);
244+
});
245+
246+
// Chain operators after `#` still split, so a dangerous command hiding
247+
// behind a comment still surfaces as its own approval subject.
248+
test("still splits chain operators after a # comment", () => {
249+
expect(splitChainedCommand("# note && rm -rf /")).toEqual([
250+
"# note",
251+
"rm -rf /",
252+
]);
253+
});
254+
255+
// A `#` line inside a genuine heredoc body stays payload: the marker still
256+
// closes and the following chain still splits.
257+
test("keeps a # line inside a heredoc body as payload", () => {
258+
const command = "cat <<EOF\n# payload\nEOF\necho done";
259+
expect(splitChainedCommand(command)).toEqual([command]);
260+
});
261+
});

src/permission/command.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { ApprovalScope } from "./types.js";
22
import { escapeGlobLiteral } from "./matcher.js";
3-
import { parseHeredocOpener } from "../shell/command-segments.js";
3+
import {
4+
isHeredocTerminator,
5+
parseHeredocOpener,
6+
} from "../shell/command-segments.js";
47

58
export { splitChainedCommand } from "../shell/command-segments.js";
69

@@ -29,6 +32,7 @@ export function stripCommentLines(command: string): string {
2932
let commentState: "unknown" | "yes" | "no" = "unknown";
3033
let quote: '"' | "'" | "`" | null = null;
3134
let heredocMarker: string | null = null;
35+
let heredocStripTabs = false;
3236

3337
const flushLine = (): void => {
3438
if (commentState !== "yes") out += line;
@@ -44,7 +48,10 @@ export function stripCommentLines(command: string): string {
4448
if (ch === "\n") {
4549
const lines = line.split("\n");
4650
const lastLine = lines[lines.length - 2] ?? "";
47-
if (lastLine.trim() === heredocMarker) heredocMarker = null;
51+
if (isHeredocTerminator(lastLine, heredocMarker, heredocStripTabs)) {
52+
heredocMarker = null;
53+
heredocStripTabs = false;
54+
}
4855
out += line;
4956
line = "";
5057
}
@@ -91,6 +98,7 @@ export function stripCommentLines(command: string): string {
9198
line += command.slice(i, opener.lineEnd);
9299
i = opener.lineEnd - 1;
93100
heredocMarker = opener.marker;
101+
heredocStripTabs = opener.stripTabs;
94102
continue;
95103
}
96104
}

src/permission/permission.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,23 @@ describe("splitChainedCommand", () => {
176176
expect(splitChainedCommand(cmd)).toHaveLength(2);
177177
});
178178

179+
test("does not treat a here-string (<<<) as a heredoc opener", () => {
180+
expect(splitChainedCommand('cat <<< "word" && echo hi')).toEqual([
181+
'cat <<< "word"',
182+
"echo hi",
183+
]);
184+
expect(splitChainedCommand("cmd <<<EOF")).toEqual(["cmd <<<EOF"]);
185+
expect(splitChainedCommand("<<< EOF && echo done")).toEqual([
186+
"<<< EOF",
187+
"echo done",
188+
]);
189+
});
190+
191+
test("still treats <<- as a heredoc opener", () => {
192+
const cmd = "cat <<-EOF\nbody\nEOF";
193+
expect(splitChainedCommand(cmd)).toHaveLength(1);
194+
});
195+
179196
test("treats shell line continuation (backslash + newline) as glue, not a chain split", () => {
180197
// Common pattern from agents emitting readable multi-line shell calls.
181198
expect(splitChainedCommand("cd foo && \\\nbun test")).toEqual([
@@ -3962,6 +3979,11 @@ describe("stripCommentLines", () => {
39623979
expect(stripCommentLines(command)).toBe(command);
39633980
});
39643981

3982+
test("a here-string never swallows a later line into a heredoc body", () => {
3983+
const command = 'cat <<< "word"\n# a real comment';
3984+
expect(stripCommentLines(command)).toBe('cat <<< "word"\n');
3985+
});
3986+
39653987
test("leaves a real command with a trailing inline comment untouched", () => {
39663988
expect(stripCommentLines("ls -la # list files")).toBe(
39673989
"ls -la # list files",

0 commit comments

Comments
 (0)