diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index ade53c2b20..30f9e1abcb 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -98,6 +98,531 @@ describe("getCommandDecision — integration with dangerous substitution checks" it("should ask user for dangerous parameter expansion even when command is allowed", () => { expect(getCommandDecision('echo "${var@P}"', allowedCommands)).toBe("ask_user") }) + + it("does not auto-approve a substitution embedded in a parameter expansion", () => { + // The shell runs $(rm -rf ~/work) when x is unset. The inner command must + // therefore match the lists on its own. Allowing `echo` must not approve + // the payload. + expect(getCommandDecision("echo ${x:-$(rm -rf ~/work)}", ["echo"])).toBe("ask_user") + }) + + it("does not auto-approve a process substitution embedded in a parameter expansion", () => { + // With x unset, bash expands the alternate word and runs `rm a`. bash + // substitutes a device path for it. The inner command must match the + // lists on its own. Allowing `echo` must not approve the payload. + expect(getCommandDecision("echo ${x:-<(rm a)}", ["echo"])).toBe("ask_user") + }) + + it("does not auto-approve a nested substitution riding outer allowlist entries", () => { + // `$(foo $(rm -rf /) bar)` must decompose so that the innermost + // `rm -rf /` is evaluated on its own. A cut at the first ")" would let + // the payload share the allowlist match of the `foo` and `bar` entries. + expect(getCommandDecision("ls $(foo $(rm -rf /) bar)", ["ls", "foo", "bar"])).toBe("ask_user") + }) + + it("asks user when a heredoc opened inside a parameter expansion carries a substitution", () => { + // With x unset, bash expands the alternate word. That runs the heredoc + // body's $(evil), even though the expansion itself stays one segment. + expect(getCommandDecision("cat ${x:-y < { + // bash drops the NUL and runs the glued substitution. The placeholder + // degradation must not leave `git status$(whoami)` looking like one + // allowed word. The parser rejects the input as malformed instead. + expect(getCommandDecision("git status\x00$(whoami)", ["git"])).toBe("malformed_command") + }) + + it("asks user for a substitution on the heredoc opener line after the delimiter", () => { + // The shell expands the opener line's remainder even when the delimiter + // is quoted. The $(whoami) there therefore matches the lists on its own. + expect(getCommandDecision("cat < { + // bash pipes the heredoc into `rm x`: the text after the delimiter word + // is live syntax, so `rm` must match the denylist on its own instead of + // riding the heredoc head's allowlist match. + expect(getCommandDecision("echo $(cat < { + // bash treats the `#` inside `f#g` as an ordinary character and still + // pipes into `rm x`. The remainder masking keeps the tail visible to + // the lists instead of letting a comment swallow it. + expect(getCommandDecision("echo $(cat < { + // bash ends the delimiter word at `|`, so the body never terminates and + // `rm x` runs live. The parser cannot account for the construct and + // reports it malformed, which costs a rejection, never an approval. + expect(getCommandDecision("echo $(cat < { + // A split the denylist never sees must not over-fire: an operator-free + // opener and an all-allowlisted right-hand command both stay approved. + expect(getCommandDecision("echo $(cat < { + // bash keeps `$(ls)#x` as one word, so the chained `rm y` executes. + // The closer-aware remainder keeps it visible to the denylist instead + // of swallowing the tail as comment text. + expect(getCommandDecision("echo $(cat < { + // `<(...)` continues the word exactly like `$(...)`. + expect(getCommandDecision("echo $(cat < { + // The operator needs no blanks: bash still runs `rm y`, so the denylist + // must see it. + expect(getCommandDecision("echo $(cat < { + // bash refuses `cat < { + // bash reads `|` as the operator token where the delimiter word + // belongs and rejects the command outright. The no-word mask must not + // turn that syntax error into an approved opaque word. The deny head + // never reaches the lists: the whole command is rejected. + expect(getCommandDecision("cat <<|rm x\nhi\nE", ["cat", "echo", "ls"], ["rm"])).toBe("malformed_command") + }) + + it("fails closed on a word-start paren with no delimiter word", () => { + // `(` is the same operator-token shape as the pipe: bash rejects the + // command before executing anything, so the mask must not approve it. + expect(getCommandDecision("cat <<(x)\nhi\nE", ["cat", "echo", "ls"], ["rm"])).toBe("malformed_command") + }) + + it("denies a command chained after a # riding a group-frame closer", () => { + // bash balances the `(ls)` group before the `$( … )` closer, so the + // glued `#` continues the word and `rm y` really executes. It must + // reach the denylist as its own segment instead of being swallowed as + // comment text by a frame that popped at the group's closer. + expect(getCommandDecision("cat < { + // `<<` followed only by blanks before end of line is the shape the + // no-word mask is the accepted policy for: bash never executes it, so + // the mask's approval is not a policy over-approximation gap. + expect(getCommandDecision("cat << \nEOF\nrm -rf /\n", ["cat", "echo", "ls"], ["rm"])).toBe("auto_approve") + }) + + it("auto-approves a word-initial comment after a spaced substitution closer", () => { + // A blank before the `#` is a real word start: bash starts a comment + // there and never runs the tail, so approval stays bash-faithful. + expect(getCommandDecision("echo $(cat < { + // The shell expands $(whoami) inside an unquoted-delimiter heredoc body. + // The inner command must therefore match the lists, even though the + // heredoc is one host segment. + const heredoc = "cat < { + // bash expands `$[ … ]` interiors before it evaluates them. `touch` must + // therefore match the lists on its own. It must not share the allowlist + // match of `echo`. + expect(getCommandDecision("echo $[1+$(touch /tmp/pwn; echo 1)]", ["echo"])).toBe("ask_user") + }) + + it("asks user for the line after a double-quoted-delimiter heredoc whose backslash bash keeps", () => { + // bash keeps `\` before `q`. The heredoc therefore ends at the `a\q` + // line, and `rm -rf /` is an ordinary following command the lists must + // see. + expect(getCommandDecision('cat <<"a\\q"\na\\q\nrm -rf /\naq\n', ["cat"])).toBe("ask_user") + }) + + it("asks user for the line after a heredoc whose terminator bash joins from continued lines", () => { + // `EO\` plus `F` joins into the `EOF` terminator before bash matches + // it. `rm -rf /` therefore runs as a following command. It is not + // masked body text. + expect(getCommandDecision("cat < { + // Tabs join before stripping, so `\tEO\` plus `\tF` stays `EO\tF`. The + // heredoc runs to the real `EOF` line, and `rm -rf /` follows it as a + // live command. An allowlisted `cat` must not carry that command. + expect(getCommandDecision("cat <<-EOF\n\tEO\\\n\tF\ncat < { + // The msys/Cygwin read layer is the reference shell on the Windows + // target. It strips the CR from every line it reads. The line therefore + // ends with the lone backslash and continues. `EO\` plus `F` joins into + // the terminator, and the commands after it are live. POSIX bash reads + // `\` as an escaped CR and joins nothing, so the two models close + // on different lines. A join that consumes a CRLF makes the boundary + // model-dependent, and the parser rejects the input as malformed. + expect(getCommandDecision("cat < { + // The msys read layer strips the bare CR. The body line then arrives as + // the terminator `EOF`, and `curl evil` would run live after it. POSIX + // bash keeps the CR and masks that line. A CR inside a scanned body + // line makes the mask boundary model-dependent. The parser therefore + // rejects the whole input as malformed. The cost is a prompt or a + // rejection, never an approval. + expect(getCommandDecision("cat < { + // The msys model closes the first heredoc at `EOF`. The scanner + // would then read the body line `cat < { + // The msys join of `x\` plus the CRLF keeps the heredoc open past the + // line where POSIX bash ends it. There the delimiter word is `EOF`, + // and bash executes the line that msys masks. The two models diverge, + // so the input must cost a rejection. It must not auto-approve one + // masked `cat` command. + expect(getCommandDecision("cat < { + // The opener line's CRLF leaves msys reading the delimiter as `EOF` + // while POSIX bash reads `EOF`. The CR-free `EOF` line then closes + // the heredoc for msys only. The following `cat < { + // With an LF opener, both models read the delimiter as `EOF`. The CR on + // the terminator candidate is ordinary content for POSIX bash. The msys + // read layer strips that CR and closes the body there. That re-exposes + // `cat < { + expect(getCommandDecision("diff <(sort a) <(sort b)", ["diff"])).toBe("ask_user") + }) + + it("auto-approves an unbalanced delimiter inside single quotes", () => { + // A shell never parses delimiter characters inside single quotes, so an + // unbalanced "$(" there is ordinary argument text, not a syntax error. + expect(getCommandDecision("echo 'a $(b'", ["echo"])).toBe("auto_approve") + }) + + it("fails closed on an unmatched closer even when the first word is allowed", () => { + // A "}" with no matching "{" leaves the parser unable to account for + // the structure. The decision stays a rejection. An allowlisted first + // word must not carry it straight to execution. + expect(getCommandDecision("echo }", ["echo"])).toBe("malformed_command") + // bash reads "<<" inside "${...}" as an arithmetic shift. It runs + // "evil" and "E" as command lines, and refuses the trailing "}". + // Treating the "<<" as a heredoc opener would mask those lines and + // auto-approve the whole command. The closed frame rejects it instead. + const shift = "echo ${x:1< { + // bash runs all three commands. `find -exec {} \;` is a placeholder, + // `src/{a,b}` is brace expansion, and a depth-0 ")" cannot hide a + // substitution. None is malformed, so the allowlisted first word + // decides. + expect(getCommandDecision("find . -exec rm {} \\;", ["find"], [])).toBe("auto_approve") + expect(getCommandDecision("mkdir -p src/{a,b}", ["mkdir"], [])).toBe("auto_approve") + expect(getCommandDecision("git show HEAD@{1}", ["git"], [])).toBe("auto_approve") + expect(getCommandDecision("echo )", ["echo"], [])).toBe("auto_approve") + }) + + it("fails closed on an unclosed arithmetic opener even when the first word is allowed", () => { + // bash refuses an unclosed "$((" . The masked remainder must not let + // the allowlisted "echo" carry a never-closed "$(" straight to + // execution. + expect(getCommandDecision("echo $(( $(evil)", ["echo"])).toBe("malformed_command") + }) + + it("fails closed on a CR-only heredoc opener even when the first word is allowed", () => { + // A lone CR does not terminate the opener line, so the heredoc is + // unterminated. The input must not look well-formed to the allowlists. + expect(getCommandDecision("cat < { + // A lone CR is not a line terminator for bash, so the opener line never + // ends. The parser reports the heredoc as unterminated. The allowlisted + // `cat` must not carry the masked remainder. + expect(getCommandDecision("cat <<\rEOF\nEOF\nrm -rf /\n", ["cat"], [])).toBe("malformed_command") + }) + + it("fails closed on a bare-<< heredoc whose opener line ends CRLF", () => { + // The CR inside the masked span leaves the msys and POSIX read layers + // reading different bytes. The mask cannot then be verified in a + // model-independent way, so it fails closed. The decision is a + // rejection, never the approval an opaque `cat` word would otherwise + // earn. + expect(getCommandDecision("cat << \r\nEOF\n\r\nrm -rf /\n", ["cat"], [])).not.toBe("auto_approve") + expect(getCommandDecision("cat << \r\nEOF\n\r\nrm -rf /\n", ["cat"], [])).toBe("malformed_command") + }) + + it("fails closed on a bare-<< heredoc whose CR rides against the opener", () => { + // This is the same decision-level rejection with the CR directly after + // `<<`. One CR byte inside the masked region is enough to fail the + // heredoc closed. + expect(getCommandDecision("cat <<\r\nEOF\nrm -rf /\n", ["cat"], [])).not.toBe("auto_approve") + expect(getCommandDecision("cat <<\r\nEOF\nrm -rf /\n", ["cat"], [])).toBe("malformed_command") + }) + + it("auto-approves a CR-free bare-<< heredoc (no over-fire)", () => { + // The masked span here is byte-identical under both read layers. The + // accepted masking policy for this never-runnable shape is therefore + // unchanged. A CR-free bare-<< opener still masks through end of input + // and stays approved under an allowlisted `cat`. + expect(getCommandDecision("cat <<\nEOF\nrm -rf /\n", ["cat"], [])).toBe("auto_approve") + }) + + it("asks user when a quoted-empty heredoc delimiter lets a later command escape", () => { + // bash's quote removal makes `<<''` terminate at the first blank line, + // so `rm -rf /` really executes as its own command. Now that the parser + // splits it out, the unallowed segment costs a prompt. It must not + // share the allowlist match of `cat`. + expect(getCommandDecision("cat <<''\nbody\n\nrm -rf /", ["cat"])).toBe("ask_user") + }) + + it("fails closed on an unclosed delimiter quote hiding an executable tail", () => { + // bash carries the open quote across the newline and runs `; rm -rf /` + // as a separate command. The parser rejects the input as malformed, so + // no partial decomposition can auto-approve it. + expect(getCommandDecision("cat <<'X\r\n'; rm -rf /\nX\r\n", ["cat"])).toBe("malformed_command") + }) + + it("does not approve a deny command joined onto the opener line by a backslash", () => { + // `'a'` closes before the trailing backslash, so bash joins the body's + // `; rm -rf /` line onto the opener line as live syntax the remainder + // cut never sees. The heredoc fails closed, and the deny payload never + // reaches tokenization: an allowlisted `cat` must not carry it. + expect(getCommandDecision("cat < { + // Two more crafted joins reach the same decision through different walk + // branches. `\'` keeps the `'` escaped, so the run there is unquoted; + // `"a\"b'` ends with an open single quote that the double-quote branch + // must step over. Both joins must fail closed before the deny payload + // tokenizes. `"a"b'` additionally pins the accept side: an open single + // quote at the run keeps the guard silent, and a walk that loses that + // state rejects this ordinary heredoc instead of approving it. + expect(getCommandDecision("cat < { + // Decision-layer mirror of the remainder-walk pins: a quote region + // only closes on its own character, so the cross-quoted remainders + // drop the `#c` tail inside the still-open region and leave a + // heredoc whose every command is allowlisted; the closer-boundary + // shapes keep `#c` as word content and stay approved; a `>(…)` + // chain makes its payloads match the lists on their own, so an + // unlisted payload costs a prompt; and a plain `>`/`<` redirect + // stays host text that never masks a command from the lists. + expect(getCommandDecision("cat <(a)>(b)#c\nbody\nE\n", ["cat"], [])).toBe("ask_user") + expect(getCommandDecision("cat <out\nbody\nE\n", ["cat"], ["rm"])).toBe("auto_approve") + expect(getCommandDecision("cat < { + // While an outer frame stays open, a `#` at an inner closer is frame + // content and bash comments the `; rm -rf /]d` tail, so an allowlisted + // head carries nothing the deny list names. Marking the closer at the + // inner pop instead keeps " #c" in the host and fabricates + // "rm -rf /]d" as its own segment, flipping the decision to a deny. + // The counterpart with the `#` at the pop that empties the frames + // keeps `rm -rf /` live, so it must cost the deny and the prompt. + expect(getCommandDecision("cat < { + // `$( (echo safe) )` is ordinary syntax that bash runs. A top-level + // bare `(` stays host grouping, as segmentation worked before this + // change. The extracted group starts with `(`, so it matches neither + // list and the decision is `ask_user`, never `malformed_command`. + expect(getCommandDecision("$( (echo safe) )", ["echo"])).toBe("ask_user") + }) + + it("does not auto-approve the group of a mis-nested $(( … ) … )", () => { + // bash runs the group inside `echo $((rm -rf /) )`, so the group must + // be checked on its own instead of riding the allowlisted `echo`. + // The extracted group starts with `(`, so neither list prefix matches + // it; the decision is `ask_user`, never `auto_approve`. + expect(getCommandDecision("echo $((rm -rf /) )", ["echo"], ["rm"])).toBe("ask_user") + expect(getCommandDecision("echo $((rm -rf /) )", ["echo"], [])).toBe("ask_user") + }) + + it("asks user for the payload of a >() process substitution", () => { + // bash runs the inner command unconditionally. Its output becomes the + // device file that the outer command writes to. `evil` is therefore + // evaluated against the lists on its own, even though the outer `cat` + // is allowed. + expect(getCommandDecision("cat >(evil)", ["cat"])).toBe("ask_user") + }) + + it("auto-approves a well-formed mid-word-quoted delimiter (no over-fire)", () => { + // `< { + // bash runs `curl evil` from this body line. An allowlist that covers + // the host and the quoted-quote group prefix must not carry the tail. + // The frame keeps its full payload, `curl evil` is its own segment, and + // no segment matches. The decision is a prompt, never an approval. + expect(getCommandDecision('cat < { + expect(getCommandDecision("cat <