diff --git a/src/permission/command.test.ts b/src/permission/command.test.ts index 8878a0565..343253358 100644 --- a/src/permission/command.test.ts +++ b/src/permission/command.test.ts @@ -40,11 +40,9 @@ describe("splitChainedCommand heredocs", () => { expect(splitChainedCommand(command)).toEqual([command]); }); - test("scopes a terminated heredoc and its following lines together", () => { - // Newlines are not chain separators, so the whole multi-line script stays a - // single approval subject; the point is that it terminates rather than hangs. + test("splits a command after a terminated heredoc", () => { const command = "cat < out.txt\nhi\nEOF\necho done"; - expect(splitChainedCommand(command)).toEqual([command]); + expect(splitChainedCommand(command)).toEqual(["cat < out.txt\nhi\nEOF", "echo done"]); }); test("still splits ordinary chained commands", () => { diff --git a/src/permission/command.ts b/src/permission/command.ts index 286faa3b2..c36591cf8 100644 --- a/src/permission/command.ts +++ b/src/permission/command.ts @@ -1,184 +1,18 @@ import type { ApprovalScope } from "./types.js"; import { escapeGlobLiteral } from "./matcher.js"; +import { projectShellSegments, scanShellStructure } from "./shell-tokenizer.js"; -// Split a shell command into the individual commands it chains together, so each -// can be classified for security. The operator still approves the full command -// as one block (see buildRequests / gate). Operators recognised: && || | ; and a -// newline. Splitting is quote-aware — operators inside '...', "..." or `...` are -// part of an argument, not a separator. Heredoc bodies (<< 'MARKER' ... MARKER) -// are treated as atomic — newlines inside them are not chain boundaries. -// Parentheses group: operators inside a subshell or command substitution never -// split, and a segment that is exactly one `( ... )` group is unwrapped and its -// inner chain split recursively — so `(cd a && b)` yields `cd a` and `b`, not -// the fragment `(cd a`. +// Authorization projects every top-level boundary from the shared structural +// scan. Bare subshell groups are then recursively unwrapped so their inner +// commands remain independent approval subjects. export function splitChainedCommand(command: string): string[] { - const segments: string[] = []; - let current = ""; - let quote: '"' | "'" | "`" | null = null; - let heredocMarker: string | null = null; - let parenDepth = 0; - - const push = (): void => { - const trimmed = current.trim(); - current = ""; - if (trimmed.length === 0) return; - const inner = unwrapGroup(trimmed); - if (inner !== null) { - segments.push(...splitChainedCommand(inner)); - return; - } - segments.push(trimmed); - }; - - for (let i = 0; i < command.length; i++) { - const ch = command[i] as string; - - // Inside a heredoc body: scan for the terminating marker on its own line. - if (heredocMarker !== null) { - current += ch; - if (ch === "\n") { - // Check whether the line just completed is the marker. - const lines = current.split("\n"); - const lastLine = lines[lines.length - 2] ?? ""; - if (lastLine.trim() === heredocMarker) { - heredocMarker = null; - } - } - continue; - } - - if (quote !== null) { - current += ch; - if (ch === quote) quote = null; - continue; - } - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - current += ch; - continue; - } - - // Shell line continuation: a backslash immediately before a newline is - // consumed by the shell (elides the newline for chaining purposes). Do not - // append the \ or split the segment; this prevents fragments like "\" from - // becoming approval subjects when agents emit continued commands. - if (ch === "\\") { - const after = command[i + 1]; - if (after === "\n" || after === "\r") { - i += 1; - if (after === "\r" && command[i + 1] === "\n") i += 1; - continue; - } - } - - // Detect heredoc redirect: << or <<- - if (ch === "<" && command[i + 1] === "<") { - const opener = parseHeredocOpener(command, i); - if (opener !== null) { - current += command.slice(i, opener.lineEnd); - i = opener.lineEnd - 1; - heredocMarker = opener.marker; - continue; - } - } - - if (ch === "(") { - parenDepth++; - current += ch; - continue; - } - if (ch === ")") { - if (parenDepth > 0) parenDepth--; - current += ch; - continue; - } - if (parenDepth > 0) { - current += ch; - continue; - } - - const next = command[i + 1]; - // A chain operator immediately following a dangling redirect operator - // (`>`, `<`, `>&`, `<&` with no target yet) does not start a new command — - // the target got separated from its redirect, most often by a stray - // separator a model inserted mid-redirect (e.g. "cmd 2>& ; 1" meaning - // "cmd 2>&1"). Treat the operator as whitespace so the target rejoins the - // command it belongs to, instead of surfacing as its own "Run shell - // command" approval. A well-formed chain ("sleep 5 ; -1 ; echo end") has - // no dangling redirect before the separator, so it is never affected. - if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) { - if (endsWithDanglingRedirect(current)) { - current = `${current.trimEnd()} `; - i++; - continue; - } - push(); - i++; - continue; - } - // `&` participates in a redirect when it opens a bash combined redirect - // (`&>file`) or duplicates a fd after `>`/`<` (`2>&1`, `<&-`). In those - // positions it is not a background operator and must not split the chain — - // otherwise `bun run build 2>&1` fragments into a real command and a stray - // `1`, and the operator gets a separate approval prompt for "1". - if (ch === "&" && isRedirectAmpersand(next)) { - current += ch; - continue; - } - // A lone "&" backgrounds the preceding command and starts a new one, so it - // is a chain boundary. Without this, "ls & rm -rf foo" is treated as a - // single segment and the approval scope is derived from the benign head. - if (ch === "|" || ch === ";" || ch === "\n" || ch === "&") { - if (endsWithDanglingRedirect(current)) { - current = `${current.trimEnd()} `; - continue; - } - push(); - continue; - } - current += ch; - } - push(); - return segments; -} - -// Parses a heredoc opener (`<<` or `<<-`) starting at `command[i]` (which must -// be the first "<"). Returns the terminating marker text and the exclusive end -// index of the line that opened the heredoc, so the caller can copy the -// opening line verbatim and resume scanning the heredoc body from there. -// Shared by splitChainedCommand and stripCommentLines so both stay in sync on -// what counts as heredoc syntax. -function parseHeredocOpener( - command: string, - i: number, -): { marker: string; lineEnd: number } | null { - if (command[i] !== "<" || command[i + 1] !== "<") return null; - let j = i + 2; - if (command[j] === "-") j++; // <<- strips leading tabs - // Skip whitespace between << and the marker word. - while (j < command.length && (command[j] === " " || command[j] === "\t")) j++; - // The marker may be quoted ('EOF', "EOF", or bare EOF). - let markerQuote: string | null = null; - if (command[j] === "'" || command[j] === '"') { - markerQuote = command[j] as string; - j++; - } - let marker = ""; - while ( - j < command.length && - command[j] !== "\n" && - command[j] !== markerQuote && - // A bare (unquoted) marker is a single word; stop at whitespace so a - // trailing redirect like `< out.txt` is not folded into the - // marker (which would leave the heredoc unterminated). - !(markerQuote === null && (command[j] === " " || command[j] === "\t")) - ) { - marker += command[j++]; - } - if (markerQuote !== null && command[j] === markerQuote) j++; - // Advance j to the end of the line that opened the heredoc. - while (j < command.length && command[j] !== "\n") j++; - return { marker, lineEnd: j }; + return projectShellSegments(command, { + splitPipes: true, + coalesceDanglingRedirects: true, + }).flatMap((segment) => { + const inner = unwrapGroup(segment); + return inner === null ? [segment] : splitChainedCommand(inner); + }); } // Remove genuine top-level full-line shell comments from command text before @@ -199,104 +33,21 @@ function parseHeredocOpener( // shells do not honor line continuation there), so it never extends the // comment past its own line. export function stripCommentLines(command: string): string { - let out = ""; - let line = ""; - // Whether the physical/logical line currently being scanned is a comment: - // "unknown" until its first non-whitespace, top-level character is seen. - let commentState: "unknown" | "yes" | "no" = "unknown"; - let quote: '"' | "'" | "`" | null = null; - let heredocMarker: string | null = null; - - const flushLine = (): void => { - if (commentState !== "yes") out += line; - line = ""; - commentState = "unknown"; - }; + const comments = scanShellStructure(command).spans.filter( + (span) => span.kind === "comment" && span.fullLine, + ); + if (comments.length === 0) return command; - for (let i = 0; i < command.length; i++) { - const ch = command[i] as string; - - if (heredocMarker !== null) { - line += ch; - if (ch === "\n") { - const lines = line.split("\n"); - const lastLine = lines[lines.length - 2] ?? ""; - if (lastLine.trim() === heredocMarker) heredocMarker = null; - out += line; - line = ""; - } - continue; - } - - if (quote !== null) { - line += ch; - if (ch === quote) quote = null; - continue; - } - - if (ch === '"' || ch === "'" || ch === "`") { - quote = ch; - if (commentState === "unknown") commentState = "no"; - line += ch; - continue; - } - - // Line continuation only applies outside an already-open comment — inside - // one, a backslash is just another comment character. - if ( - commentState !== "yes" && - ch === "\\" && - (command[i + 1] === "\n" || command[i + 1] === "\r") - ) { - const after = command[i + 1] as string; - line += ch + after; - i += 1; - if (after === "\r" && command[i + 1] === "\n") { - line += "\n"; - i += 1; - } - if (commentState === "unknown") commentState = "no"; - // Deliberately do not flush: the next physical line is glued to this - // one and must never independently qualify as a comment start. - continue; - } - - if (commentState !== "yes" && ch === "<" && command[i + 1] === "<") { - const opener = parseHeredocOpener(command, i); - if (opener !== null) { - if (commentState === "unknown") commentState = "no"; - line += command.slice(i, opener.lineEnd); - i = opener.lineEnd - 1; - heredocMarker = opener.marker; - continue; - } - } - - if (ch === "\n") { - line += ch; - flushLine(); - continue; - } - - if (ch === " " || ch === "\t") { - line += ch; - continue; - } - - if (commentState === "unknown") commentState = ch === "#" ? "yes" : "no"; - line += ch; + let out = ""; + let cursor = 0; + for (const comment of comments) { + let end = comment.end; + if (command[end] === "\r" && command[end + 1] === "\n") end += 2; + else if (command[end] === "\n") end++; + out += command.slice(cursor, comment.start).replace(/[ \t]+$/, ""); + cursor = end; } - flushLine(); - return out; -} - -// Whether `text` ends (ignoring trailing whitespace) in a redirect operator -// that has not yet received its target: a bare `>`/`<`, or a fd-duplication -// opener `>&`/`<&` awaiting the fd number. -const DANGLING_REDIRECT = /(?:>&|<&|>|<)$/; - -function endsWithDanglingRedirect(text: string): boolean { - return DANGLING_REDIRECT.test(text.trimEnd()); + return out + command.slice(cursor); } // The inner chain of a segment that is exactly one parenthesised group, or null @@ -451,13 +202,6 @@ export function tokenize(command: string): string[] { return tokens; } -// `&` is the background operator when it stands alone as a word — followed by -// whitespace or end of input. Anywhere else it is part of a redirect token: -// `2>&1`, `<&-`, `&>file`. -function isRedirectAmpersand(next: string | undefined): boolean { - return !(next === undefined || next === " " || next === "\t"); -} - const MAX_PREFIX_SCOPES = 3; // Commands that multiplex many subcommands of wildly different risk under one diff --git a/src/permission/gate.ts b/src/permission/gate.ts index ee5619002..455f5fa3e 100644 --- a/src/permission/gate.ts +++ b/src/permission/gate.ts @@ -336,11 +336,10 @@ export interface PermissionGate { unregisterMcpServer: (serverName: string) => void; } -// True when splitChainedCommand can be trusted to yield only real segments for -// grant minting. False for patterns that confuse the no-backslash-escape -// splitter into phantom segments — those mint as one exact whole-pattern grant. +// Backslashes mark exact-escaped patterns and must not be interpreted again as +// shell source. Inline comments likewise remain one exact whole-pattern grant. function canSafelyMintPerSegment(pattern: string): boolean { - if (/\\["`]/.test(pattern)) return false; + if (pattern.includes("\\")) return false; if (pattern.includes("#")) return false; return true; } @@ -391,12 +390,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission // tradeoff. onGrant's covers predicate consults the live approvals list so // a queued identical chain drains only once every segment has been minted. // - // splitChainedCommand / tokenize intentionally have no backslash-escape - // support (CL-6988 / #673 rely on that so misparsed wrappers stay opaque). - // When the pattern contains escapes or inline `#` comments, the naive - // splitter can invent phantom segments (`printf "…\" && evil"` → `evil`). - // Fall back to one exact grant for the whole normalized pattern instead of - // minting those phantoms. + // Exact patterns contain backslash escapes for matcher metacharacters and + // cannot be safely reinterpreted as shell source. Inline comments also stay + // whole so commented text can never become a separately minted grant. const normalizedPattern = tool === "run_shell" ? stripCommentLines(outcome.persist.pattern).trim() diff --git a/src/permission/permission.test.ts b/src/permission/permission.test.ts index d1722bbd4..905ce0ef1 100644 --- a/src/permission/permission.test.ts +++ b/src/permission/permission.test.ts @@ -2134,13 +2134,12 @@ describe("createPermissionGate", () => { }); test("escaped quotes do not mint grants for unexecuted text", async () => { - // splitChainedCommand has no backslash-escape support (CL-6988). Minting - // per segment would invent a phantom `touch PWNED` grant, so the gate - // falls back to one exact whole-pattern grant instead. const full = `printf "safe \\" && touch PWNED && \\""`; const persisted: Approval[] = []; const built = buildRequests(shellCall(full))[0]?.scopes.find((scope) => scope.id === "exact"); - if (built === undefined) throw new Error("expected exact command scope"); + if (built?.pattern === null || built?.pattern === undefined) { + throw new Error("expected exact command scope"); + } const gate = createPermissionGate({ approvals: [], requestApproval: async () => ({ allow: true, persist: { ...built, grant: "project" } }), @@ -2150,7 +2149,7 @@ describe("createPermissionGate", () => { }); expect((await gate.evaluate(shellCall(full))).allowed).toBe(true); - expect(persisted.map((a) => a.pattern)).toEqual([full]); + expect(persisted.map((a) => a.pattern)).toEqual([built.pattern]); expect(persisted.map((a) => a.pattern)).not.toContain("touch PWNED"); }); diff --git a/src/permission/shell-tokenizer-property.test.ts b/src/permission/shell-tokenizer-property.test.ts new file mode 100644 index 000000000..b8988abad --- /dev/null +++ b/src/permission/shell-tokenizer-property.test.ts @@ -0,0 +1,249 @@ +import { test, expect, describe } from "bun:test"; +import type { ToolCall } from "@intx/types/runtime"; +import { splitChainedCommand } from "./command.js"; +import { buildRequests } from "./classify.js"; +import { isRequestCoveredByGrant } from "./gate.js"; +import { groupChainSegmentsForDisplay } from "../tui/command-display.js"; + +// Corpus of chained commands exercising quotes, heredocs, subshells, and +// various operators. Each entry is [description, command, agreement]. +// +// `agreement` is true when both splitters are expected to produce the same +// segments (modulo pipe merging), and false for cases where they intentionally +// diverge (subshell unwrap and dangling-redirect coalescing). +const CORPUS: [string, string, boolean][] = [ + ["simple &&", "echo a && echo b", true], + ["simple ||", "echo a || echo b", true], + ["simple ;", "echo a ; echo b", true], + ["quoted operator", 'echo "a && b"', true], + ["single-quoted operator", "echo 'c || d'", true], + ["heredoc body", "cat < && echo done", false], +]; + +describe("shared tokenizer: display is a coarse merge of authz", () => { + for (const [desc, cmd, expectedAgreement] of CORPUS) { + test(desc, () => { + const authz = splitChainedCommand(cmd); + const display = groupChainSegmentsForDisplay(cmd); + + if (expectedAgreement) { + // For commands where we expect agreement, the display segments should + // be formable by merging consecutive authz segments joined by " | " + // (the only intentional difference: display keeps pipes inline). + let authzIdx = 0; + + for (const dseg of display) { + let found = false; + const current = authz[authzIdx]; + if (current !== undefined && current.trim() === dseg.trim()) { + authzIdx++; + found = true; + } else { + for (let end = authzIdx + 1; end < authz.length; end++) { + const candidate = authz + .slice(authzIdx, end + 1) + .map((s) => s.trim()) + .join(" | "); + if (candidate.trim() === dseg.trim()) { + authzIdx = end + 1; + found = true; + break; + } + } + } + expect(found).toBe(true); + } + + expect(authzIdx).toBe(authz.length); + } else { + // For known-divergent cases (subshell unwrap and dangling-redirect + // coalescing), both splitters should still + // produce non-empty, non-blank segments. We don't compare content + // because unwrapGroup strips parens/operators that display preserves. + expect(authz.length).toBeGreaterThanOrEqual(1); + expect(display.length).toBeGreaterThanOrEqual(1); + + for (const seg of authz) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + for (const seg of display) { + expect(seg.trim().length).toBeGreaterThanOrEqual(1); + } + } + }); + } +}); + +describe("heredoc boundaries", () => { + test("keeps a following command outside the heredoc approval scope", () => { + const command = "cat < scope.pattern)).toEqual([command]); + expect( + isRequestCoveredByGrant( + request, + { tool: "run_shell", pattern: "cat *" }, + undefined, + () => false, + { resolvedCwd: "/repo", roots: ["/repo"] }, + ), + ).toBe(false); + }); + + const heredocCases = [ + { + name: "CRLF terminator", + command: "cat <