merge: trekexecute guard-4-5b2-part-a
This commit is contained in:
commit
96fba75996
13 changed files with 2031 additions and 34 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -89,11 +89,23 @@ Part 3 fixes two MAJOR findings of part 2's review, in the two places part 2 rew
|
|||
|
||||
Part 4 fixes four MAJOR findings of part 3's review in the Bash guard's check of the argument heredoc; each has a test seen failing first at part 3's end point `591f3c4` (`tests/fix-round-4-5b-part-4-red.sh` proves it). The tokenizer headline in part 2's group above no longer claims more than the tokenizer does (see the tokenizer Known issue).
|
||||
|
||||
- **The guard checks the parser's delimiter when a backslash-newline splits it** (CSG). `hooks/scripts/pre-bash-executor.mjs` checks a command that names the parser's delimiter only once a backslash-newline is removed, so an unquoted opener split that way with a body and no closing line is blocked as unquoted; such a command with a quoted opener and no closing line, or with an unreadable opener elsewhere, is now checked too and refused, as it would be with the delimiter written out. It names review finding `3a34adbe`.
|
||||
- **A `<<` cut by the quote that closes a string on its line is text** (SLT). A `<<` inside a string on the opener's line, whose cutting quote closes that string, is text and passes, so commit messages, grep patterns and file writes that hold `"<<"` and name the delimiter pass again; a quote with no string open before it stays unreadable and blocked; a string opened on an earlier line is still refused (see Known issues). It names review finding `ad8ad828`.
|
||||
- **A backslash-escaped `<` with no quote or heredoc operator before it starts no opener** (ESC). A `<` after an odd run of backslashes starts no opener when no quote, backquote or heredoc operator stands before the run, in both readings, so `echo \<<` followed by the delimiter and a line passes; the `<` after it is read as usual, so a `<<` right after an escaped `<`, which the guard took for part of a here-string before, is now checked; an escaped `<` inside a heredoc body fed to a shell is still checked. It names review finding `797784e0`.
|
||||
- **The guard checks the parser's delimiter when a backslash-newline splits it** (CSG). `hooks/scripts/pre-bash-executor.mjs` checks a command that names the parser's delimiter only once a backslash-newline is removed, so an unquoted opener split that way with a body and no closing line is blocked as unquoted; such a command with a quoted opener and no closing line, or with an unreadable opener elsewhere, is now checked too and refused, as it would be with the delimiter written out. It names review finding `3a34adbe`. A split delimiter with a suffix was not refused; it is now, fixed below as SFX, and inside a string as SIS. Part 4's review found the gap: review finding `27b0b46a`.
|
||||
- **A `<<` cut by the quote that closes a string on its line is text to the shell reading** (SLT). A `<<` inside a string on the opener's line, whose cutting quote closes that string, is text to the shell reading, unless a backslash-newline splits its opener (see SIS below). The regex reading still reads a `<<` that the delimiter follows, and the string test counts one quote character from the line start, so such a command passes only where neither reading blocks it: a grep pattern `'<<'` on a line that names the delimiter further on, a commit message or file write whose heredoc body holds `"<<"` with the delimiter elsewhere on its line, and a string holding `<<` before the commands' argument heredoc. A grep pattern or commit message whose string puts the delimiter right after `<<`, and one whose string holds a quote of the other kind or an escaped quote before the `<<`, are refused as before (see Known issues); a quote with no string open before it stays unreadable and blocked; a string opened on an earlier line is still refused. It names review finding `ad8ad828`. Part 4's review found the gap: review findings `943f9841` and `a6735ef9`.
|
||||
- **A backslash-escaped `<` starts no opener while no quote is still open before it, no heredoc body holds it and the guard can follow the shell up to it, with one exception** (ESC). A `<` after an odd run of backslashes starts no opener when no quote is still open before the run, no earlier heredoc is still open, and the scan can follow the shell there, in both readings, so `echo \<<` followed by the delimiter and a line passes. The exception: two escaped `<` in a row that name the delimiter with a line after them are refused, also where no second shell reads them (see EVL below). The `<` after an escaped one is read as usual, so a `<<` right after an escaped `<`, which the guard took for part of a here-string before, is now checked; an escaped `<` inside a heredoc body fed to a shell is still checked. It names review finding `797784e0`. The limits covered the whole command, fixed below as ESR. Part 4's review found the gap: review finding `ed40abdb`.
|
||||
- **The guard's error path is tested** (CTB). A test forces an error inside the guard's shell reading and sees it block with exit 2, through a test-side preload; the hook gains no switch. It names review finding `1658fc64`.
|
||||
|
||||
**Fix round 4.5b2, part A (decision 91)**
|
||||
|
||||
Part A fixes four of the six MAJOR findings of part 4's review in the Bash guard's check of the argument heredoc, one more gap the operator added (decision 107), a split opener inside a string that part 4's string skip let through (decision 123), and a false test header; the other two MAJOR findings are listed under Known issues (decision 122). Each fix has a test seen failing first at part 4's end point `e2d6bec`, or under a named mutation where the code already held (`tests/fix-round-4-5b2-a-red.sh` proves both), and a fixed bypass corpus (`tests/guard-bypass-corpus-4-5b2-a.sh`) keeps the commands blocked that each loosened rule must not let through.
|
||||
|
||||
- **An error inside the regex reading's escape limit is tested** (RXT). A test forces an error inside the regex reading's escape limit and sees the command blocked with exit 2. It names review finding `5f1cc34c`.
|
||||
- **An opener split between its two `<` keeps the escape rule off** (CNT). A test pins an outer opener split between its two `<` as keeping the escape rule off. It names review finding `97d1f547`.
|
||||
- **A split delimiter with a suffix is judged as written out** (SFX). `hooks/scripts/pre-bash-executor.mjs` judges a delimiter that a backslash-newline splits, followed by any suffix, as it judges the same opener written out. It names review finding `27b0b46a`.
|
||||
- **The escape rule reads the text before the run, not the whole command** (ESR). The escape rule is off only while a quote is still open before the run, an earlier heredoc is not closed before it, or the scan cannot follow the shell (for example after `$'`, `$"`, a backquote, a parenthesis, a `#` or a continuation). It names review finding `ed40abdb`.
|
||||
- **A heredoc operator a second shell would read from `\<\<` is blocked** (EVL). A heredoc operator a second shell would read from `\<\<`, with the delimiter after it (also when a backslash-newline splits it) and a line after it, is blocked, through `eval`, `bash -c`, `sh -c` or no second shell at all. It is decision 107.
|
||||
- **A `<<` whose opener a backslash-newline splits is never skipped** (SIS). A `<<` whose opener a backslash-newline splits is never skipped, as text in a string or as text in the body of a quoted heredoc that passes, because the regex reading cannot see it; such an opener inside a `bash -c` string with a body was let through, also inside a quoted heredoc fed to `bash`, and the inner shell expanded the body. A harmless one with a line after the command may now be refused. Decision 126 widened it to the body skip. It is decision 123.
|
||||
- **A test header names its red base** (HDR). The header of `tests/hooks/bash-guard-false-stops.test.mjs` named the wrong red base; it now names part 3's end point `591f3c4`. It names review finding `86488cd6`.
|
||||
|
||||
Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyage control could be bypassed or was too lax, or where the text gave a guarantee no code held. Each is fixed, and each has a test that fails on the code before it, except DJ, whose sentences already stood and whose pins fail when they are deleted (`tests/fix-round-4-5a-red.sh` proves every other id red at the round's start point, and DJ under its two mutations).
|
||||
|
||||
**Execute**
|
||||
|
|
@ -176,7 +188,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- **The lessons-block check accepts any valid lesson line** (MINOR `5114fa84`, `lib/review/reviewed-commit.mjs`; decision 88). A line added inside the block that passes the lesson-line check, an instruction written in the lesson form included, does not make the review stale.
|
||||
- **The label keeps inner quotes** (MINOR `e585ce27`, `lib/parsers/arg-parser.mjs` `tailOf`; decision 88). `tailOf` strips quotes only when one pair wraps the whole rest, so `brief.md "Session 2" of "3"` gives the label `"Session 2" of "3"`.
|
||||
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
|
||||
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file.
|
||||
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file. Two kinds of such command pass, under the conditions the ESC and SLT bullets above give: one whose opener's first `<` follows an odd run of backslashes, unless two escaped `<` in a row name the delimiter (see EVL), a case fix round 4.5b2 part A widens, and one whose opener is text inside a string on its line, unless a backslash-newline splits it (see SIS).
|
||||
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
|
||||
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `. Review finding `564d6dff` (decision 90) names this case: such a value can swallow a later flag with no error.
|
||||
- **`gitEntryDir` does not follow git's `GIT_CEILING_DIRECTORIES` rules** (MAJOR `3b98d18b`, `lib/review/recurrence.mjs`; decision 89). It resolves the entries after an empty one through realpath, and it keeps relative entries and entries whose realpath fails. So a ceiling written that way can give `no-git-repo` and a written pointer where git sees a repository.
|
||||
|
|
@ -188,6 +200,21 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- **Part 3's entry did not name the false stops of a string-borne `<<`** (MAJOR `6f09b931`, `CHANGELOG.md`; decision 90). Part 4's SLT bullet lets most of them through, and a command whose string opens on an earlier line than its `<<`, for example a commit message heredoc whose quoted text spans two lines, is still refused.
|
||||
- **An error inside the guard is reported as the command's fault** (MAJOR `161545f6`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). An error inside the guard's own reading is reported as an unquoted opener whose word could not be read, which blames the command for the guard's fault.
|
||||
- **The guard re-splits the command for each candidate opener** (MINOR `fe3fe55b`, `hooks/scripts/pre-bash-executor.mjs`; decision 90). For each candidate opener the guard re-splits the command to find its line, quadratic work on a large command.
|
||||
- **The guard keeps its narrow reader** (MINOR `03df3b0b`, `hooks/scripts/pre-bash-executor.mjs`; decision 91). The narrow reader is kept; a full reader that reads the command from start to finish like the shell is deferred (decision 92).
|
||||
- **The guard's test files copy the same helpers** (MINOR `78040bd7`, `tests/hooks/bash-guard-false-stops.test.mjs`; decision 91). The Bash guard's test files copy the same helpers (`ARGS_DELIM`, `ARGS_RULE`, `PARSER_LINE` and others) instead of sharing them, so a change to the block message means editing every copy.
|
||||
- **`argsShellOpeners` keeps an unused default parameter** (MINOR `49873813`, `hooks/scripts/pre-bash-executor.mjs`; decision 91). Its second parameter defaults to `argsEscapeLimit(text)`, and its one caller always passes it.
|
||||
- **A simplify commit has no scope** (MINOR `79402171`, `hooks/scripts/pre-bash-executor.mjs`; decision 91). Part 4's commit `9b80e6c` (`refactor: simplify pass (trekexecute)`) has no scope after its type; it is already pushed, so it stays.
|
||||
- **A grep pattern that puts the delimiter right after `<<` inside a string is refused** (MAJOR `943f9841`, `hooks/scripts/pre-bash-executor.mjs`; fix round 4.5b2 part A, decision 122). A grep pattern that puts the delimiter right after `<<` inside a string, with a line after the command, is refused although the shell reads the `<<` as text; the regex reading does not skip a `<<` the shell reading skips. Fixing it was dropped from part A: each version of the fix that was planned or built let through some command that part 4 blocks and that a nested shell or zsh then runs. A test pins the command as refused (`tests/hooks/bash-guard-4-5b2-a-known.test.mjs`).
|
||||
- **A string whose `<<` follows a quote of the other kind or an escaped quote is refused** (MAJOR `a6735ef9`, `hooks/scripts/pre-bash-executor.mjs`; fix round 4.5b2 part A, decision 122). A string whose `<<` follows a quote of the other kind or an escaped quote (the review's commit message with a second `-m` that spans two lines, and its `echo "x\"y" && grep "<<" …` with a line after it) is refused, because the string test counts one quote character from the line start. Fixing it was dropped from part A: each version of the fix that was planned or built let through some command that part 4 blocks and that a nested shell or zsh then runs. A test pins each command as refused (`tests/hooks/bash-guard-4-5b2-a-known.test.mjs`).
|
||||
- **Other spellings of the heredoc operator for a second shell still pass** (fix round 4.5b2 part A, decision 107). Other spellings that build the parser's heredoc operator or word for a second shell still pass and expand or close early: a quoted `<<` next to the word (`'<<'`), `<` and `<` in two strings, the word held in a variable, an empty quote pair before the word inside a string (decision 125), and a form on one physical line. Only `\<\<` with a line after it is checked (EVL), and it is checked with or without a second shell, so `\<\<` before the word with a line after it is refused even where no shell reads it again.
|
||||
- **The escape scan models default shell options only** (fix round 4.5b2 part A, decision 124). Text before the escaped run that changes how the shell reads quotes, aliases or expansions (`setopt`, `emulate`, `set -o`, `shopt`, an alias) is not followed: for example `shopt -s expand_aliases`, an alias `q` for `bash -c "`, and `q echo x \\\<<…` with a body line and the closing line passes since part A, and `/bin/bash` expands the body (with `set -o posix` too); zsh does not.
|
||||
- **A split opener inside a string or a quoted heredoc's body is refused** (fix round 4.5b2 part A, decision 123, widened by decision 126). A `<<` whose opener a backslash-newline splits is refused inside a string or in the body of a quoted heredoc that passes when a line follows it, also where the shell reads it as text.
|
||||
- **The quote scan's signature differs from part A's plan** (MAJOR `227985d4`, `hooks/scripts/pre-bash-executor.mjs`; part A's review, decision 127). The simplify pass dropped the unused `from` parameter of `argsQuoteScan`, while the plan's text still gives five parameters. Behaviour is unchanged: the one caller always passed 0 and a scan state.
|
||||
- **The split-suffix branch's pass exits have no test** (MAJOR `90e253d3`, `hooks/scripts/pre-bash-executor.mjs`; part A's review, decision 127). Every test of a split delimiter with a suffix expects a block; none shows that a quoted one closing once at the end, or one with no body line, passes as the same opener written out does, so a change that refuses them would leave the suite green.
|
||||
- **A `<<-` heredoc closed by a tab-indented line before an escaped run has no test** (MAJOR, the review's finding at `hooks/scripts/pre-bash-executor.mjs:462`; part A's review, decision 127). The quote scan strips leading tabs from the closing line of a `<<-` heredoc, and no test combines that with a later escaped `<`; if the strip were lost, such a command would be refused again without a test failing.
|
||||
- **An error in the shell reading hides the regex reading's reason** (MAJOR `3890ec2b`, `hooks/scripts/pre-bash-executor.mjs`; part A's review, decision 127). The shell reading now runs first, so an error in it blocks with the unquoted, unreadable reason even where the regex reading would have named the early close. The command is still blocked; only the reason is wrong.
|
||||
- **More spellings of the operator for a second shell pass** (MINOR `402a9416` and `0464e12e`, `hooks/scripts/pre-bash-executor.mjs`; part A's review, decision 127). Measured by the review: quoting inside the delimiter word (`VOYAGE"_ARGS…"`, `VOYAGE\_ARGS…`), a `$'…'` word, and an escaped `<` next to a quoted one (`\<'<'`, `'<'\<`) after `eval cat` pass the guard, and `/bin/bash` and zsh expand the body. The check reads one spelling of the operator and a prefix-stripped word, not the word as a second shell reads it; the mechanism goes to the 4.5c structure review with the full reader.
|
||||
- **Smaller findings of part A's review** (MINOR `397b32ae`, `179386c4`, `73f6727b`, `75c439ab`, `f9ee4fd4`; decision 127). A second, weaker word reader next to the shell reading's word loop; the split-opener rule repeated in three places; the suffix branch repeating the regex reading's quoted test and body lines; a per-opener copy of the rest of the command in the re-read check; and this round's simplify commit `cc277c4` again without a scope. The corpus test's case with the guard switched off also passes on a corpus run killed at its timeout.
|
||||
|
||||
### Doors
|
||||
|
||||
|
|
@ -200,6 +227,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
|
|||
- two-way: fix round 4.5b part 2 (decision 88) — a revert restores part 1's four behaviours (the tokenizer's quote search, the guard's refusal of a one-line mention, the pointer written on any `git check-ignore` exit 128, the undefined `{hash}`) and the guard's first-opener-only check.
|
||||
- two-way: fix round 4.5b part 3 (decision 89) — a revert restores part 2's closing-quote search (a value whose closing quote follows whitespace splits again) and the guard's regex-only opener check.
|
||||
- two-way: fix round 4.5b part 4 (decision 90) — a revert restores part 3's literal-delimiter gate and its refusal of a heredoc operator inside a string or after a backslash; the catch-branch test and the TOK wording go with it.
|
||||
- two-way: fix round 4.5b2 part A (decision 91, decision 107, decision 123) — a revert restores part 4's whole-command escape limits, the exact-word check for a split delimiter, the string and body skips of a split opener and the unchecked twice-escaped operator; the corpus, the end-to-end script, the keep-assertion test and the corrected test header go with it.
|
||||
|
||||
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -302,7 +302,8 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
|
|||
// same trim the body lines get, so a CR (a CRLF line), VT, FF or other
|
||||
// character trim() strips around the word does not hide it.
|
||||
// After a quoted opener that passes, a later opener is skipped only when its
|
||||
// word is cut by an unpaired quote and is readable otherwise: the shell meets
|
||||
// word is cut by an unpaired quote and is readable otherwise and not split by
|
||||
// a backslash-newline (fix round 4.5b2, part A, decision 126): the shell meets
|
||||
// the same unpaired quote, stops with a parse error and runs nothing after it,
|
||||
// so a typed argument such as fix "<<" handling passes. Every other later
|
||||
// opener is checked, because the accepted opener may sit inside a string and
|
||||
|
|
@ -313,17 +314,38 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
|
|||
// odd count of that quote from the line start to the opener's first <),
|
||||
// as in grep -n '<<' or a commit message holding "<<". A cut word with
|
||||
// no string open before it stays unreadable and is blocked as unquoted.
|
||||
// The skip is safe: the quote has no partner, so the word's body is
|
||||
// never read as a heredoc body, and each opener is read on its own, so
|
||||
// skipping it hides no later opener.
|
||||
// The quote has no partner, so the outer shell never reads the word's
|
||||
// body as a heredoc body; a second shell (bash -c, eval) may, and the
|
||||
// regex reading still reads such a << when the delimiter follows it. An
|
||||
// opener whose raw span holds a backslash-newline has no regex match, so
|
||||
// it is never skipped (fix round 4.5b2, part A, decision 123). Each
|
||||
// opener is read on its own, so skipping one hides no later opener.
|
||||
// - A < after an odd run of backslashes (counted on the raw text, a line
|
||||
// break ends the run) starts no opener, in the regex reading and in the
|
||||
// shell reading alike, but only when no ', " or backquote and no <<
|
||||
// stands before the run: each of those can make the shell read the
|
||||
// backslashes once more (inside a backquote, or in an unquoted heredoc
|
||||
// body fed to a shell, measured), so there the < is read as unescaped.
|
||||
// The < after the escaped one is read as usual, so a << right after it
|
||||
// is still checked.
|
||||
// shell reading alike, when a quote scan of the text before the run ends
|
||||
// outside every quote and every heredoc body. The scan skips a closed
|
||||
// '...' or "..." string, a here-string and the body of a heredoc closed
|
||||
// before the run, so none of these turns the rule off. A quote still open
|
||||
// before the run, a heredoc not closed before it, and text the scan
|
||||
// cannot follow (for example $' or $", $( ${ $[, a backquote, a (, a #, a
|
||||
// continuation, a heredoc word it cannot read, a second < on a heredoc's
|
||||
// line, a body line of an unquoted heredoc that ends in a backslash) keep
|
||||
// the rule off: there the shell may read the backslashes once more
|
||||
// (inside a backquote, or in a heredoc body fed to a shell, measured), so
|
||||
// the < is read as unescaped. The regex reading takes the shell reading's
|
||||
// decision for the same <. The < after the escaped one is read as usual,
|
||||
// so a << right after it is still checked (fix round 4.5b2, part A).
|
||||
// A < followed (continuations skipped) by an odd run of backslashes and a
|
||||
// second < is a heredoc operator a second shell may read: the outer shell
|
||||
// removes the backslashes, so eval, bash -c or sh -c reads << there. It is
|
||||
// blocked as an unquoted opener the guard could not read when the word after
|
||||
// the second < names the delimiter as a second shell reads it (every
|
||||
// backslash-newline removed, then a leading -, blanks, quotes and
|
||||
// backslashes) and a non-blank line follows anywhere after the opener's line
|
||||
// (the body may sit inside the quotes that carry the word). It is judged
|
||||
// before every skip. A one-line form has no such line and passes. The rule
|
||||
// does not look for the second shell, so the same text with none is blocked
|
||||
// as well (fix round 4.5b2, part A, decision 107).
|
||||
// The shell reading fails closed: an error in it blocks (exit 2), because an
|
||||
// exit 1 is a non-blocking error and the command would run.
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
|
|
@ -343,36 +365,180 @@ function blockArgs(unquoted, unreadable) {
|
|||
process.exit(2);
|
||||
}
|
||||
|
||||
// Where the backslash rule stops applying in text: the first ', " or
|
||||
// backquote, and the end of the first << (continuations between the two <).
|
||||
// The quote state at `to` of `text` read from its start for quotes only: 'none'
|
||||
// (outside every quote), 'open' (inside '...' or "...", or inside an earlier
|
||||
// heredoc's body) or 'unknown' (the scan cannot follow the shell here).
|
||||
// `state` lets the calls on one text resume where the last stopped; a call
|
||||
// before that point starts over from the start of the text.
|
||||
function argsQuoteScan(text, to, openers, state) {
|
||||
const st = state.pos <= to ? state : { pos: 0, mode: 'none', pending: null, bodyEnd: -1 };
|
||||
let p = st.pos;
|
||||
let mode = st.mode;
|
||||
let pending = st.pending;
|
||||
let bodyEnd = st.bodyEnd;
|
||||
const stop = (result) => {
|
||||
st.pos = p;
|
||||
st.mode = mode;
|
||||
st.pending = pending;
|
||||
st.bodyEnd = bodyEnd;
|
||||
return result;
|
||||
};
|
||||
const unknown = () => {
|
||||
mode = 'unknown';
|
||||
return stop('unknown');
|
||||
};
|
||||
for (;;) {
|
||||
if (mode === 'unknown') return stop('unknown');
|
||||
if (mode === 'body') {
|
||||
if (to < bodyEnd) {
|
||||
p = to;
|
||||
return stop('open');
|
||||
}
|
||||
p = bodyEnd;
|
||||
mode = 'none';
|
||||
}
|
||||
// A token that runs past `to` (a heredoc word, a here-string) is not followed.
|
||||
if (p > to) return unknown();
|
||||
if (p === to) return stop(mode === 'none' ? 'none' : 'open');
|
||||
// The line break that ends a heredoc's line: its body starts after it.
|
||||
if (pending && p === pending.nl) {
|
||||
if (mode !== 'none' || pending.bodyEnd === null) return unknown();
|
||||
mode = 'body';
|
||||
bodyEnd = pending.bodyEnd;
|
||||
pending = null;
|
||||
continue;
|
||||
}
|
||||
const c = text[p];
|
||||
const d = text[p + 1];
|
||||
if (mode === 'single') {
|
||||
if (c === "'") mode = 'none';
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') {
|
||||
// The scan never decides a backslash without the character it escapes.
|
||||
if (p + 1 >= to) return unknown();
|
||||
if (d === '\n') return unknown(); // scan: a continuation cannot be followed
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (mode === 'double') {
|
||||
if (c === '"') mode = 'none';
|
||||
else if (c === '`' || (c === '$' && (d === '(' || d === '{' || d === '['))) return unknown();
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') {
|
||||
mode = c === "'" ? 'single' : 'double';
|
||||
p += 1;
|
||||
continue;
|
||||
}
|
||||
if (c === '`' || c === '(' || c === '#') return unknown();
|
||||
if (c === '$' && (d === "'" || d === '"' || d === '(' || d === '{' || d === '[')) return unknown();
|
||||
if (c === '<' && d === '<') {
|
||||
if (text[p + 2] === '<') {
|
||||
p += 3; // a here-string is text
|
||||
continue;
|
||||
}
|
||||
// The opener record at p (openers are in text order).
|
||||
let o = null;
|
||||
for (let lo = 0, hi = openers.length - 1; lo <= hi && !o;) {
|
||||
const m = (lo + hi) >> 1;
|
||||
if (openers[m].index < p) lo = m + 1;
|
||||
else if (openers[m].index > p) hi = m - 1;
|
||||
else o = openers[m];
|
||||
}
|
||||
if (!o || o.unreadable || o.cutOnly || !/^[A-Za-z0-9_]+$/.test(o.word)) return unknown();
|
||||
let nl = text.indexOf('\n', o.end);
|
||||
if (nl === -1) nl = text.length;
|
||||
if (text.slice(o.end, nl).includes('<')) return unknown();
|
||||
// The closing line: the first line after nl equal to the word (after
|
||||
// <<- with its leading tabs stripped, nothing else). null: the body
|
||||
// cannot be followed (an unquoted body line joined to the next).
|
||||
let close = Infinity;
|
||||
for (let ls = nl + 1; nl < text.length;) {
|
||||
const le = text.indexOf('\n', ls);
|
||||
const line = text.slice(ls, le === -1 ? text.length : le);
|
||||
if ((o.dash ? line.replace(/^\t+/, '') : line) === o.word) {
|
||||
close = le === -1 ? text.length : le + 1;
|
||||
break;
|
||||
}
|
||||
if (!o.quoted && line.endsWith('\\')) {
|
||||
close = null;
|
||||
break;
|
||||
}
|
||||
if (le === -1) break;
|
||||
ls = le + 1;
|
||||
}
|
||||
pending = { nl, bodyEnd: close };
|
||||
p = o.end;
|
||||
continue;
|
||||
}
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The escape rule cannot apply when no backslash stands directly before a <
|
||||
// (firstRun is -1: the fast path). The scan state is shared by the escape
|
||||
// checks of one command, so the text is read once (fix round 4.5b2, part A).
|
||||
function argsEscapeLimit(text) {
|
||||
const h = /<(?:\\\n)*</.exec(text);
|
||||
return { quoteAt: text.search(/['"`]/), heredocEnd: h ? h.index + h[0].length : -1 };
|
||||
return { firstRun: text.search(/\\</), escaped: new Set(), state: { pos: 0, mode: 'none', pending: null, bodyEnd: -1 } };
|
||||
}
|
||||
|
||||
// A < after an odd run of backslashes in the raw text (a line break ends the
|
||||
// run) is a literal < to the shell, when no ', " or backquote and no <<
|
||||
// stands before the run: inside quotes, backquotes or a heredoc body fed to
|
||||
// a shell, a backslash may be read once more.
|
||||
function argsEscapedLt(text, i, lim) {
|
||||
// run) is a literal < to the shell when the quote scan of the text before the
|
||||
// run ends outside every quote and heredoc body ('none'). `openers` holds
|
||||
// every opener before i (fix round 4.5b2, part A).
|
||||
function argsEscapedLt(text, i, lim, openers) {
|
||||
if (lim.firstRun === -1) return false;
|
||||
let b = 0;
|
||||
while (i - 1 - b >= 0 && text[i - 1 - b] === '\\') b += 1;
|
||||
const s = i - b;
|
||||
return b % 2 === 1 && (lim.quoteAt === -1 || lim.quoteAt >= s) &&
|
||||
(lim.heredocEnd === -1 || lim.heredocEnd > s);
|
||||
return b % 2 === 1 && argsQuoteScan(text, s, openers, lim.state) === 'none';
|
||||
}
|
||||
|
||||
// An opener whose raw span holds a backslash-newline. The regex reading cannot
|
||||
// see such an opener, so it is never skipped, as string text or as body text
|
||||
// (fix round 4.5b2, part A, decisions 123 and 126).
|
||||
function argsSplit(text, o) {
|
||||
return text.slice(o.index, o.end).includes('\\\n');
|
||||
}
|
||||
|
||||
// A cut opener (its quote has no partner after it) whose quote closes a
|
||||
// string opened earlier on the raw line holding the opener's first <: an
|
||||
// odd count of that quote character from the line start to the <.
|
||||
function argsCutInString(text, o) {
|
||||
if (argsSplit(text, o)) return false;
|
||||
const q = text[o.end];
|
||||
const lineStart = text.lastIndexOf('\n', o.index) + 1;
|
||||
return text.slice(lineStart, o.index).split(q).length % 2 === 0;
|
||||
}
|
||||
|
||||
// After the second < of a twice-escaped operator (p), does the word a second
|
||||
// shell reads name the delimiter? Every backslash-newline is removed, then a
|
||||
// leading -, blanks, quotes and backslashes (fix round 4.5b2, part A,
|
||||
// decision 107).
|
||||
function argsRereadNames(text, p) {
|
||||
for (;;) {
|
||||
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
|
||||
if ('- \t\'"\\'.includes(text[p]) && p < text.length) { p += 1; continue; }
|
||||
break;
|
||||
}
|
||||
let j = 0;
|
||||
while (j < ARGS_DELIM.length && p < text.length) {
|
||||
if (text[p] === '\\' && text[p + 1] === '\n') { p += 2; continue; }
|
||||
if (text[p] !== ARGS_DELIM[j]) return false;
|
||||
p += 1;
|
||||
j += 1;
|
||||
}
|
||||
return j === ARGS_DELIM.length;
|
||||
}
|
||||
|
||||
// Each heredoc operator in `text`, in text order, as the shell reads its word:
|
||||
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
|
||||
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly,
|
||||
// dash (the - of <<- was read), reread (a twice-escaped operator a second
|
||||
// shell reads, decision 107) }. Every < it judges escaped goes into
|
||||
// lim.escaped, which the regex reading reads.
|
||||
function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
|
||||
const n = text.length;
|
||||
// The index after any run of continuations that starts at k.
|
||||
|
|
@ -383,7 +549,19 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
|
|||
const openers = [];
|
||||
let i = text.indexOf('<');
|
||||
while (i !== -1) {
|
||||
if (argsEscapedLt(text, i, lim)) { i = text.indexOf('<', i + 1); continue; }
|
||||
{
|
||||
const r = skip(i + 1);
|
||||
let b = 0;
|
||||
while (text[r + b] === '\\') b += 1;
|
||||
if (b % 2 === 1 && text[r + b] === '<' && argsRereadNames(text, r + b + 1)) {
|
||||
openers.push({ index: i, end: r + b + 1, word: '', quoted: false, unreadable: true, cutOnly: false, dash: false, reread: true });
|
||||
}
|
||||
}
|
||||
if (argsEscapedLt(text, i, lim, openers)) {
|
||||
lim.escaped.add(i);
|
||||
i = text.indexOf('<', i + 1);
|
||||
continue;
|
||||
}
|
||||
let next = i + 1;
|
||||
let k = skip(i + 1);
|
||||
if (text[k] === '<') {
|
||||
|
|
@ -396,7 +574,9 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
|
|||
next = k + 1; // a here-string: move past it
|
||||
} else {
|
||||
let unreadable = false;
|
||||
let dash = false;
|
||||
if (text[k] === '-') {
|
||||
dash = true;
|
||||
// After a continuation, bash reads <<- and zsh reads - as the word.
|
||||
if (k !== second + 1) unreadable = true;
|
||||
k = skip(k + 1);
|
||||
|
|
@ -446,6 +626,7 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
|
|||
quoted,
|
||||
unreadable: unreadable || isCut,
|
||||
cutOnly: isCut && !unreadable,
|
||||
dash,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -456,18 +637,23 @@ function argsShellOpeners(text, lim = argsEscapeLimit(text)) {
|
|||
}
|
||||
|
||||
if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
|
||||
// The regex reading and the shell reading. The shell reading's loop records
|
||||
// the first failing verdict; an error anywhere in either reading blocks.
|
||||
// The regex reading and the shell reading. The shell reading's openers are
|
||||
// read first, so the regex reading takes its escape decisions; the shell
|
||||
// reading's loop records the first failing verdict; an error anywhere in
|
||||
// either reading blocks.
|
||||
let argsVerdict = null;
|
||||
try {
|
||||
const argsLim = argsEscapeLimit(command);
|
||||
const argsShell = argsShellOpeners(command, argsLim);
|
||||
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
|
||||
const argsLines = command.split('\n');
|
||||
const argsClosing = argsLines.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
|
||||
const closesOnceAtEnd = argsClosing.length === 1 &&
|
||||
argsLines.slice(argsClosing[0] + 1).every((line) => line.trim() === '');
|
||||
for (const argsOpener of argsOpeners) {
|
||||
if (argsEscapedLt(command, argsOpener.index, argsLim)) continue;
|
||||
// The shell reading's decision for the same <; a < it never reached as
|
||||
// a start counts as not escaped.
|
||||
if (argsLim.escaped.has(argsOpener.index)) continue;
|
||||
// Some line after the opener's line is non-blank (\s is the set trim() strips).
|
||||
const hasBody = /\n\s*\S/.test(command.slice(argsOpener.index));
|
||||
if (!hasBody) continue;
|
||||
|
|
@ -476,9 +662,35 @@ if (command.replace(/\\\n/g, '').includes(ARGS_DELIM)) {
|
|||
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
|
||||
}
|
||||
let bodyFrom = Infinity;
|
||||
for (const o of argsShellOpeners(command, argsLim)) {
|
||||
if (o.index >= bodyFrom && o.cutOnly) continue;
|
||||
for (const o of argsShell) {
|
||||
if (o.reread) {
|
||||
if (/\n\s*\S/.test(command.slice(o.index))) {
|
||||
argsVerdict = [true, true];
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (o.index >= bodyFrom && o.cutOnly && !argsSplit(command, o)) continue;
|
||||
if (o.cutOnly && argsCutInString(command, o)) continue;
|
||||
// A delimiter split by a backslash-newline whose word has a suffix is
|
||||
// judged as the regex reading judges the same opener written out (fix
|
||||
// round 4.5b2, part A).
|
||||
if (!o.unreadable && argsSplit(command, o)) {
|
||||
const w = o.word.trim();
|
||||
if (w.startsWith(ARGS_DELIM) && w.length > ARGS_DELIM.length) {
|
||||
const m = new RegExp(`^<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`).exec(command.slice(o.index, o.end).replace(/\\\n/g, ''));
|
||||
if (!m) continue;
|
||||
const snl = command.indexOf('\n', o.end);
|
||||
if (snl === -1) continue;
|
||||
const sbody = argsLines.slice(command.slice(0, snl).split('\n').length);
|
||||
if (!sbody.some((line) => line.trim() !== '')) continue;
|
||||
const [, sopen, sclose] = m;
|
||||
const squoted = sopen === '\\' ? sclose === '' : sopen !== '' && sclose === sopen;
|
||||
if (squoted && closesOnceAtEnd) continue;
|
||||
argsVerdict = [!squoted, false];
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The word is compared trimmed, as the body lines are: a word of D plus
|
||||
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
|
||||
// no-break space) is closed by a line equal to it, which trims to D.
|
||||
|
|
|
|||
303
tests/e2e-fix-round-4-5b2-a.sh
Normal file
303
tests/e2e-fix-round-4-5b2-a.sh
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/e2e-fix-round-4-5b2-a.sh
|
||||
# End to end for fix round 4.5b2, part A (plan
|
||||
# .claude/projects/2026-10-31-guard-4-5b2-part-a, End-to-end:, decisions 91,
|
||||
# 107 and 123): the Bash hook (hooks/scripts/pre-bash-executor.mjs) reads
|
||||
# commands the way a session sends them, and /bin/bash and /bin/zsh -f show,
|
||||
# each in a temp directory with HOME set to a temp directory, that each block
|
||||
# is real and each pass is text to the shell.
|
||||
# 1. ESR: the three commands that newly pass (a here-string, a closed quoted
|
||||
# heredoc, a closed quote before an escaped <) pass the hook, and both
|
||||
# shells run the line after them as a command (a stub next on PATH
|
||||
# touches ran-next) and create no PWNED file.
|
||||
# 2. Blocks that are no false positive: SFX-X and SFX-dot print
|
||||
# body EXPANDED under both shells; SIS1 and the eval and sh -c forms of a
|
||||
# twice-escaped operator (X1, X1s) create PWNED under both shells; the
|
||||
# [CNT] command expands under /bin/bash, while zsh stops with a parse
|
||||
# error.
|
||||
# 3. The shipped form still passes: the parser block of
|
||||
# commands/trekreview.md (## Phase 1), filled with the repo root and the
|
||||
# typed argument fix "<<" handling.
|
||||
# A missing /bin/zsh is a FAIL, not a skip.
|
||||
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
|
||||
# when any case failed, else 0. Starts nothing but node, /bin/bash and
|
||||
# /bin/zsh (and the usual file tools). bash 3.2 compatible, ASCII only, no
|
||||
# arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
|
||||
ROOT="$(pwd)"
|
||||
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
|
||||
|
||||
unset CLAUDE_CODE_SESSION_ID
|
||||
|
||||
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b2-a.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
|
||||
|
||||
export LC_ALL=C
|
||||
export LANG=C
|
||||
|
||||
FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
|
||||
|
||||
# The premises' HOME, and a stub next that touches ran-next in the current
|
||||
# directory.
|
||||
mkdir -p "$TMP/home" "$TMP/bin" || { echo "FAIL - setup: cannot make the temp directories"; exit 1; }
|
||||
printf '%s\n' '#!/bin/sh' 'touch ran-next' > "$TMP/bin/next" && chmod +x "$TMP/bin/next" \
|
||||
|| { echo "FAIL - setup: cannot write the next stub"; exit 1; }
|
||||
|
||||
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
|
||||
# is the given lines joined by newlines; stderr goes to <out>. Returns the
|
||||
# hook's exit status.
|
||||
hookrun() {
|
||||
out="$1"
|
||||
shift
|
||||
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
|
||||
| node "$HOOK" > /dev/null 2> "$out"
|
||||
}
|
||||
|
||||
# hookfile <out> <file>: the Bash hook with the file's text as the command.
|
||||
hookfile() {
|
||||
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
|
||||
| node "$HOOK" > /dev/null 2> "$1"
|
||||
}
|
||||
|
||||
D=VOYAGE_ARGS_EOF_7F3C9E
|
||||
|
||||
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
|
||||
blocked() {
|
||||
bname="$1"
|
||||
bout="$2"
|
||||
shift 2
|
||||
hookrun "$bout" "$@"
|
||||
c=$?
|
||||
if [ "$c" -ne 2 ]; then
|
||||
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
|
||||
return 1
|
||||
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
|
||||
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
|
||||
unquoted() {
|
||||
qname="$1"
|
||||
uout="$2"
|
||||
shift 2
|
||||
blocked "$qname" "$uout" "$@" || return 1
|
||||
if ! grep -q "opens $D unquoted" "$uout"; then
|
||||
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# allowed <name> <out> <line>...: the hook exits 0.
|
||||
allowed() {
|
||||
aname="$1"
|
||||
aout="$2"
|
||||
shift 2
|
||||
hookrun "$aout" "$@"
|
||||
c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$aname: the hook exited $c: $(cat "$aout")"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# premise <shell> <dir> <script> <out> <line>...: writes the lines to <script>
|
||||
# and runs it in <dir> with /bin/bash (<shell> bash) or /bin/zsh -f (<shell>
|
||||
# zsh), HOME=$TMP/home and the next stub first on PATH; stdout and stderr go
|
||||
# to <out>, the shell's exit status to PSTATUS. Returns 1 when the script
|
||||
# cannot be written or the shell is unknown or missing.
|
||||
PSTATUS=0
|
||||
premise() {
|
||||
pshell="$1"
|
||||
pdir="$2"
|
||||
pscript="$3"
|
||||
pout="$4"
|
||||
shift 4
|
||||
PSTATUS=0
|
||||
mkdir -p "$pdir" || return 1
|
||||
printf '%s\n' "$@" > "$pscript" || return 1
|
||||
case "$pshell" in
|
||||
bash) [ -x /bin/bash ] || return 1 ;;
|
||||
zsh) [ -x /bin/zsh ] || return 1 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
if [ "$pshell" = bash ]; then
|
||||
( cd "$pdir" && HOME="$TMP/home" PATH="$TMP/bin:/usr/bin:/bin" /bin/bash "$pscript" ) > "$pout" 2>&1 < /dev/null
|
||||
else
|
||||
( cd "$pdir" && HOME="$TMP/home" PATH="$TMP/bin:/usr/bin:/bin" /bin/zsh -f "$pscript" ) > "$pout" 2>&1 < /dev/null
|
||||
fi
|
||||
PSTATUS=$?
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---- 0. Setup: both shells are there ----
|
||||
|
||||
case_shells() {
|
||||
if [ ! -x /bin/bash ]; then
|
||||
bad 'setup: /bin/bash is missing'
|
||||
fi
|
||||
if [ ! -x /bin/zsh ]; then
|
||||
bad 'setup: /bin/zsh is missing (a FAIL, not a skip)'
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 1. ESR: the commands that newly pass ----
|
||||
|
||||
# esr_case <tag> <name> <line>...: the hook passes the lines; under each shell
|
||||
# the stub next runs (ran-next) and no PWNED file appears.
|
||||
esr_case() {
|
||||
etag="$1"
|
||||
ename="$2"
|
||||
shift 2
|
||||
allowed "$ename" "$TMP/$etag.err" "$@" || return
|
||||
for sh in bash zsh; do
|
||||
edir="$TMP/$etag-$sh"
|
||||
premise "$sh" "$edir" "$TMP/$etag-$sh.sh" "$TMP/$etag-$sh.out" "$@" \
|
||||
|| { bad "$ename: could not run the premise under $sh"; return; }
|
||||
if [ ! -e "$edir/ran-next" ]; then
|
||||
bad "$ename: $sh did not run the line after the opener as a command: $(cat "$TMP/$etag-$sh.out")"
|
||||
return
|
||||
fi
|
||||
if ls "$edir" | grep -q '^PWNED'; then
|
||||
bad "$ename: $sh created a PWNED file"
|
||||
return
|
||||
fi
|
||||
done
|
||||
pass "$ename (exit 0; /bin/bash and /bin/zsh -f run next as a command)"
|
||||
}
|
||||
|
||||
case_esr() {
|
||||
esr_case esr-1 'ESR: a here-string before an escaped < leaves the rule on' \
|
||||
'cat <<<x; echo \<<'"$D" 'next'
|
||||
esr_case esr-2 'ESR: a closed quoted heredoc before an escaped < leaves the rule on' \
|
||||
'cat <<'"'"'EOF'"'" "don't" 'EOF' 'echo \<<'"$D" 'next'
|
||||
esr_case esr-3 'ESR: a quote closed before an escaped < leaves the rule on' \
|
||||
'echo '"'"'x'"'"' && echo \<<'"$D" 'next'
|
||||
}
|
||||
|
||||
# ---- 2. Blocks that are no false positive ----
|
||||
|
||||
# expanded_case <tag> <name> <line>...: the hook blocks the lines as unquoted;
|
||||
# under each shell the body prints body EXPANDED.
|
||||
expanded_case() {
|
||||
xtag="$1"
|
||||
xname="$2"
|
||||
shift 2
|
||||
unquoted "$xname" "$TMP/$xtag.err" "$@" || return
|
||||
for sh in bash zsh; do
|
||||
premise "$sh" "$TMP/$xtag-$sh" "$TMP/$xtag-$sh.sh" "$TMP/$xtag-$sh.out" "$@" \
|
||||
|| { bad "$xname: could not run the premise under $sh"; return; }
|
||||
if ! grep -q 'body EXPANDED' "$TMP/$xtag-$sh.out"; then
|
||||
bad "$xname: $sh did not expand the body: $(cat "$TMP/$xtag-$sh.out")"
|
||||
return
|
||||
fi
|
||||
done
|
||||
pass "$xname (exit 2; /bin/bash and /bin/zsh -f print body EXPANDED)"
|
||||
}
|
||||
|
||||
# pwned_case <tag> <name> <line>...: the hook blocks the lines as unquoted;
|
||||
# under each shell the body creates PWNED.
|
||||
pwned_case() {
|
||||
ptag="$1"
|
||||
pname="$2"
|
||||
shift 2
|
||||
unquoted "$pname" "$TMP/$ptag.err" "$@" || return
|
||||
for sh in bash zsh; do
|
||||
premise "$sh" "$TMP/$ptag-$sh" "$TMP/$ptag-$sh.sh" "$TMP/$ptag-$sh.out" "$@" \
|
||||
|| { bad "$pname: could not run the premise under $sh"; return; }
|
||||
if [ ! -e "$TMP/$ptag-$sh/PWNED" ]; then
|
||||
bad "$pname: $sh did not expand the body: $(cat "$TMP/$ptag-$sh.out")"
|
||||
return
|
||||
fi
|
||||
done
|
||||
pass "$pname (exit 2; /bin/bash and /bin/zsh -f create PWNED)"
|
||||
}
|
||||
|
||||
case_blocks() {
|
||||
expanded_case sfx-x 'SFX: a split delimiter with an X suffix is blocked as unquoted' \
|
||||
'cat <<VOYAGE_ARGS\' '_EOF_7F3C9EX' 'body $(echo EXPANDED)'
|
||||
expanded_case sfx-dot 'SFX: a split delimiter with a . suffix is blocked as unquoted' \
|
||||
'cat <<VOYAGE_ARGS\' '_EOF_7F3C9E.' 'body $(echo EXPANDED)'
|
||||
pwned_case sis-1 'SIS: a split delimiter inside a bash -c string is blocked as unquoted' \
|
||||
'bash -c '"'"'cat <<VOYAGE_ARGS\' '_EOF_7F3C9E'"'"'"' '\$(touch PWNED); touch RAN' '"'
|
||||
pwned_case evl-x1 'EVL: an eval of a twice-escaped heredoc operator with a body is blocked as unquoted' \
|
||||
'eval cat \<\<'"$D""'" '$(touch PWNED)' "'"
|
||||
pwned_case evl-x1s 'EVL: the sh -c form of a twice-escaped heredoc operator is blocked as unquoted' \
|
||||
'sh -c '"'"'cat '"'"'\<\<'"$D""'" '$(touch PWNED)' "'"
|
||||
}
|
||||
|
||||
case_cnt() {
|
||||
name='CNT: an outer opener split between its two < keeps the escape rule off'
|
||||
L1='bash <\'
|
||||
L2='<EOF'
|
||||
L3='cat - \\\<<'"$D"
|
||||
L4='\$(touch PWNED)'
|
||||
L5="$D"
|
||||
L6='EOF'
|
||||
unquoted "$name" "$TMP/cnt.err" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" || return
|
||||
premise bash "$TMP/cnt-bash" "$TMP/cnt-bash.sh" "$TMP/cnt-bash.out" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" \
|
||||
|| { bad "$name: could not run the premise under bash"; return; }
|
||||
if [ ! -e "$TMP/cnt-bash/PWNED" ]; then
|
||||
bad "$name: /bin/bash did not expand the inner body: $(cat "$TMP/cnt-bash.out")"
|
||||
return
|
||||
fi
|
||||
premise zsh "$TMP/cnt-zsh" "$TMP/cnt-zsh.sh" "$TMP/cnt-zsh.out" "$L1" "$L2" "$L3" "$L4" "$L5" "$L6" \
|
||||
|| { bad "$name: could not run the premise under zsh"; return; }
|
||||
if [ "$PSTATUS" -eq 0 ] || ! grep -q 'parse error' "$TMP/cnt-zsh.out"; then
|
||||
bad "$name: /bin/zsh -f did not stop with a parse error (exit $PSTATUS): $(cat "$TMP/cnt-zsh.out")"
|
||||
return
|
||||
fi
|
||||
pass "$name (exit 2; /bin/bash expands the inner body, /bin/zsh -f exits $PSTATUS with a parse error)"
|
||||
}
|
||||
|
||||
# ---- 3. The shipped form still passes ----
|
||||
|
||||
case_shipped() {
|
||||
name='shipped: the trekreview Phase 1 parser block with the typed argument fix "<<" handling passes'
|
||||
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
|
||||
# the way Claude Code fills them.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const [md, root, args] = process.argv.slice(1);
|
||||
const text = fs.readFileSync(md, "utf8");
|
||||
const s = text.indexOf("## Phase 1 ");
|
||||
if (s === -1) process.exit(3);
|
||||
const rest = text.slice(s);
|
||||
const e = rest.search(/\n## /);
|
||||
const phase = e === -1 ? rest : rest.slice(0, e);
|
||||
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
|
||||
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
|
||||
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
|
||||
' "$ROOT/commands/trekreview.md" "$ROOT" 'fix "<<" handling' > "$TMP/phase1.sh" \
|
||||
|| { bad "$name: could not read the Phase 1 block"; return; }
|
||||
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
|
||||
c=$?
|
||||
if [ "$c" -ne 0 ]; then
|
||||
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
|
||||
else
|
||||
pass "$name (exit 0)"
|
||||
fi
|
||||
}
|
||||
|
||||
case_shells
|
||||
case_esr
|
||||
case_blocks
|
||||
case_cnt
|
||||
case_shipped
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "e2e: $FAILED case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "e2e: ok"
|
||||
exit 0
|
||||
62
tests/fix-round-4-5b2-a-mutations.mjs
Normal file
62
tests/fix-round-4-5b2-a-mutations.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// tests/fix-round-4-5b2-a-mutations.mjs
|
||||
// The mutations of fix round 4.5b2, part A (plan
|
||||
// .claude/projects/2026-10-31-guard-4-5b2-part-a, Step 5, 5a; decisions 91,
|
||||
// 107 and 123), for the shared red script (tests/helpers/red-round.sh,
|
||||
// through tests/helpers/red-mutate.mjs).
|
||||
// An id named here has tests that pass at the round's start point e2d6bec and
|
||||
// is proven red under its mutations instead:
|
||||
// - RXT: the try already holds the regex reading's escape limit at e2d6bec
|
||||
// (the round adds only its test, which forces an error there through a
|
||||
// preload); the mutation opens the try after the regex reading, in
|
||||
// e2d6bec's hook, so that error escapes it.
|
||||
// - CNT: the first-<< search already takes a line continuation between the
|
||||
// two < at e2d6bec; the mutation drops that branch, in e2d6bec's hook.
|
||||
// - HDR: the false-stops header is corrected in the working tree's tests/
|
||||
// that the script copies into the clone; the mutation puts the wrong red
|
||||
// base back.
|
||||
// Every span must match exactly once in the clone (e2d6bec's code with the
|
||||
// working tree's tests/). Data only; never matched by `npm test`.
|
||||
|
||||
export const MUTATIONS = [
|
||||
{
|
||||
key: 'RXT-try',
|
||||
id: 'RXT',
|
||||
what: 'the try opens after the regex reading',
|
||||
spans: [
|
||||
{
|
||||
file: 'hooks/scripts/pre-bash-executor.mjs',
|
||||
find: ' let argsVerdict = null;\n try {\n',
|
||||
replace: ' let argsVerdict = null;\n',
|
||||
},
|
||||
{
|
||||
file: 'hooks/scripts/pre-bash-executor.mjs',
|
||||
find: ' let bodyFrom = Infinity;\n',
|
||||
replace: ' try {\n let bodyFrom = Infinity;\n',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'CNT-regex',
|
||||
id: 'CNT',
|
||||
what: 'the first-<< search loses its continuation branch',
|
||||
spans: [
|
||||
{
|
||||
file: 'hooks/scripts/pre-bash-executor.mjs',
|
||||
find: 'const h = /<(?:\\\\\\n)*</.exec(text);',
|
||||
replace: 'const h = /<</.exec(text);',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'HDR-header',
|
||||
id: 'HDR',
|
||||
what: 'the false-stops header names the wrong red base again',
|
||||
spans: [
|
||||
{
|
||||
file: 'tests/hooks/bash-guard-false-stops.test.mjs',
|
||||
find: 'loads at 591f3c4',
|
||||
replace: 'loads at 12bca8b',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
68
tests/fix-round-4-5b2-a-red.sh
Normal file
68
tests/fix-round-4-5b2-a-red.sh
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b2-a-red.sh
|
||||
# The red proof of fix round 4.5b2, part A (plan
|
||||
# .claude/projects/2026-10-31-guard-4-5b2-part-a/plan.md, Step 5, 5b;
|
||||
# decisions 91, 107 and 123), as a wrapper over the shared red script
|
||||
# tests/helpers/red-round.sh. Every id of the round has tests whose names
|
||||
# start with "[id]"; SFX, ESR, EVL, KIA and SIS must each fail on an assertion
|
||||
# at the round's start point e2d6bec, and RXT, CNT and HDR, whose tests pass
|
||||
# there, are proven red under their mutations.
|
||||
# This wrapper passes the shared script:
|
||||
# --base BASE, the full sha of e2d6bec;
|
||||
# --ids IDS (the round's 8 ids);
|
||||
# --other-ids: the ids of fix round 4-5a, of 4-5b part 1, of part 2, of
|
||||
# part 3 and of part 4, read with sed from the IDS= lines of
|
||||
# tests/fix-round-4-5a-red.sh, tests/fix-round-4-5b-red.sh,
|
||||
# tests/fix-round-4-5b-part-2-red.sh, tests/fix-round-4-5b-part-3-red.sh
|
||||
# and tests/fix-round-4-5b-part-4-red.sh and passed as ONE value, in that
|
||||
# order (the shared script keeps only the last --other-ids it is given);
|
||||
# a tag on that list is not judged;
|
||||
# --mutations tests/fix-round-4-5b2-a-mutations.mjs (RXT-try, CNT-regex,
|
||||
# HDR-header);
|
||||
# the round's FILES: the two tagged test files. The keep file
|
||||
# (tests/hooks/bash-guard-4-5b2-a-known.test.mjs) holds at e2d6bec by
|
||||
# design and its tag is no red-proof id; the corpus test and the scripts
|
||||
# test are untagged; none of the three is listed.
|
||||
# No --require-sha256: the round has no SHA-256 test.
|
||||
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
|
||||
# (also: no ids on one of the five older IDS= lines); never 0 on a setup
|
||||
# failure.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
set -f
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
|
||||
ROOT="$(pwd -P)"
|
||||
|
||||
BASE=e2d6beccb7ea38cb8d596b6a35bcad0232ae3ca1
|
||||
IDS='RXT CNT SFX ESR EVL KIA HDR SIS'
|
||||
FILES='tests/hooks/bash-guard-4-5b2-a.test.mjs tests/lib/fix-round-4-5b2-a-prose.test.mjs'
|
||||
|
||||
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
|
||||
if [ -z "$OTHER_A" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
|
||||
if [ -z "$OTHER_B" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
OTHER_C=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-2-red.sh")
|
||||
if [ -z "$OTHER_C" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-2-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
OTHER_D=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-3-red.sh")
|
||||
if [ -z "$OTHER_D" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-3-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
OTHER_E=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-4-red.sh")
|
||||
if [ -z "$OTHER_E" ]; then
|
||||
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-4-red.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B $OTHER_C $OTHER_D $OTHER_E" --mutations "$ROOT/tests/fix-round-4-5b2-a-mutations.mjs" $FILES
|
||||
72
tests/fix-round-4-5b2-a-regression.sh
Normal file
72
tests/fix-round-4-5b2-a-regression.sh
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/fix-round-4-5b2-a-regression.sh
|
||||
# The Regression: of fix round 4.5b2, part A (plan
|
||||
# .claude/projects/2026-10-31-guard-4-5b2-part-a/plan.md, Step 5, 5c;
|
||||
# decisions 91, 107 and 123): the whole suite (npm test, which runs the keep
|
||||
# file and the corpus test too), the round's bypass corpus
|
||||
# (tests/guard-bypass-corpus-4-5b2-a.sh), the round's end to end
|
||||
# (tests/e2e-fix-round-4-5b2-a.sh), part 4's, part 3's, part 2's and part 1's
|
||||
# end to end (tests/e2e-fix-round-4-5b-part-4.sh,
|
||||
# tests/e2e-fix-round-4-5b-part-3.sh, tests/e2e-fix-round-4-5b-part-2.sh,
|
||||
# tests/e2e-fix-round-4-5b.sh), the round's red proof
|
||||
# (tests/fix-round-4-5b2-a-red.sh) and part 4's red proof
|
||||
# (tests/fix-round-4-5b-part-4-red.sh, which lists the false-stops file this
|
||||
# round edited). The 4-5a, part-1, part-2 and part-3 red proofs are not run
|
||||
# here: one Regression command has the criteria runner's 15-minute cap; they
|
||||
# run as their own Verification lines.
|
||||
# All nine run, in that order; the script prints each exit and exits with the
|
||||
# first non-zero status, or 0 when all nine exit 0.
|
||||
# bash 3.2 compatible, ASCII only, no arrays.
|
||||
|
||||
set -u
|
||||
|
||||
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
|
||||
|
||||
FIRST=0
|
||||
|
||||
npm test
|
||||
rc=$?
|
||||
echo "regression: npm test exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/guard-bypass-corpus-4-5b2-a.sh
|
||||
rc=$?
|
||||
echo "regression: tests/guard-bypass-corpus-4-5b2-a.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b2-a.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b2-a.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b-part-4.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b-part-4.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b-part-3.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b-part-3.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b-part-2.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b-part-2.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/e2e-fix-round-4-5b.sh
|
||||
rc=$?
|
||||
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/fix-round-4-5b2-a-red.sh
|
||||
rc=$?
|
||||
echo "regression: tests/fix-round-4-5b2-a-red.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
/bin/bash tests/fix-round-4-5b-part-4-red.sh
|
||||
rc=$?
|
||||
echo "regression: tests/fix-round-4-5b-part-4-red.sh exited $rc"
|
||||
[ "$FIRST" -eq 0 ] && FIRST=$rc
|
||||
|
||||
exit "$FIRST"
|
||||
251
tests/guard-bypass-corpus-4-5b2-a.sh
Normal file
251
tests/guard-bypass-corpus-4-5b2-a.sh
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/env bash
|
||||
# tests/guard-bypass-corpus-4-5b2-a.sh
|
||||
# The bypass corpus of fix round 4.5b2, part A (plan
|
||||
# .claude/projects/2026-10-31-guard-4-5b2-part-a/plan.md, Step 3, 3c;
|
||||
# decisions 91, 107 and 123): commands the args-heredoc guard in
|
||||
# hooks/scripts/pre-bash-executor.mjs must block. Each entry is fed to the
|
||||
# hook as a Bash tool input; it passes when the hook exits 2 and names
|
||||
# args-heredoc-closed-early.
|
||||
#
|
||||
# guard-bypass-corpus-4-5b2-a.sh [--hook <absolute path>]
|
||||
#
|
||||
# --hook defaults to hooks/scripts/pre-bash-executor.mjs of this repo.
|
||||
# Exit 0 every entry blocked, 1 an entry not blocked (its FAIL line names it),
|
||||
# 2 a setup failure ("corpus: setup failure: <reason>": a hook path that does
|
||||
# not exist, an unknown argument, no entries, a bad line, a duplicate name);
|
||||
# never 0 on a setup failure.
|
||||
#
|
||||
# Entries: one line each, "E <name> <JSON string>", after one comment line
|
||||
# "# <name>: expands under bash|zsh|both|neither (blocked conservatively);
|
||||
# measured <date>". The JSON keeps every byte ASCII (CR and tab show as \r
|
||||
# and \t). fs-<n> is the blocked command on line <n> of
|
||||
# tests/hooks/bash-guard-false-stops.test.mjs and ag-<n> the one on line <n> of
|
||||
# tests/hooks/bash-guard-args-gate.test.mjs, each with its template expanded,
|
||||
# as the branch left them (fixed when written: a later edit of those files
|
||||
# does not update this corpus). The other names are the plan's (its Probe
|
||||
# Catalogue and Step 3, 3c).
|
||||
# "expands under" was measured with /bin/bash (3.2.57) and /bin/zsh -f (5.9),
|
||||
# each in its own temp directory with HOME set to a temp directory and
|
||||
# PATH=/usr/bin:/bin, the entry's $(touch PWNED) rewritten to
|
||||
# $(touch PWNED); touch RAN: it expands when a PWNED file exists and RAN does
|
||||
# not. "neither" entries are blocked conservatively: a parse error, a quote
|
||||
# left open, or a body read as text.
|
||||
# Starts nothing but node and the hook. bash 3.2 compatible, ASCII only, no
|
||||
# arrays.
|
||||
|
||||
set -u
|
||||
|
||||
setup_fail() {
|
||||
echo "corpus: setup failure: $1" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" || setup_fail "cannot find the repo root from $0"
|
||||
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--hook)
|
||||
[ $# -ge 2 ] || setup_fail "--hook needs a path"
|
||||
HOOK="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
setup_fail "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f "$HOOK" ] || setup_fail "no hook at $HOOK"
|
||||
command -v node > /dev/null 2>&1 || setup_fail "node not found"
|
||||
|
||||
unset CLAUDE_CODE_SESSION_ID
|
||||
|
||||
# The reader: parses the entries on stdin, feeds each to the hook, prints
|
||||
# "ok - <name>" or "FAIL - <name>: exit <n>" and a count line. Any error of
|
||||
# its own is a setup failure (exit 2).
|
||||
READER='
|
||||
const fs = require("fs");
|
||||
const { spawnSync } = require("child_process");
|
||||
const setup = (why) => {
|
||||
process.stderr.write("corpus: setup failure: " + why + "\n");
|
||||
process.exit(2);
|
||||
};
|
||||
try {
|
||||
const hook = process.argv[1];
|
||||
if (!hook || !fs.existsSync(hook)) setup("no hook at " + hook);
|
||||
const entries = [];
|
||||
const seen = new Set();
|
||||
fs.readFileSync(0, "utf8").split("\n").forEach((line, i) => {
|
||||
if (line === "" || line.startsWith("#")) return;
|
||||
const m = /^E ([a-z0-9][a-z0-9-]*) (".*")$/.exec(line);
|
||||
if (!m) setup("bad line " + (i + 1) + ": " + line.slice(0, 80));
|
||||
let command;
|
||||
try {
|
||||
command = JSON.parse(m[2]);
|
||||
} catch {
|
||||
setup("bad JSON string on line " + (i + 1));
|
||||
}
|
||||
if (typeof command !== "string") setup("not a JSON string on line " + (i + 1));
|
||||
if (seen.has(m[1])) setup("duplicate name " + m[1]);
|
||||
seen.add(m[1]);
|
||||
entries.push({ name: m[1], command });
|
||||
});
|
||||
if (entries.length === 0) setup("no entries");
|
||||
let failed = 0;
|
||||
for (const e of entries) {
|
||||
const r = spawnSync(process.execPath, [hook], {
|
||||
input: JSON.stringify({ tool_name: "Bash", tool_input: { command: e.command } }),
|
||||
encoding: "utf8",
|
||||
timeout: 20000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (r.status === 2 && String(r.stderr).includes("args-heredoc-closed-early")) {
|
||||
console.log("ok - " + e.name);
|
||||
continue;
|
||||
}
|
||||
failed += 1;
|
||||
if (r.error && r.error.code === "ETIMEDOUT") console.log("FAIL - " + e.name + ": timeout after 20 s");
|
||||
else if (r.status === null) console.log("FAIL - " + e.name + ": exit null (signal " + r.signal + ")");
|
||||
else console.log("FAIL - " + e.name + ": exit " + r.status);
|
||||
}
|
||||
console.log("corpus: " + entries.length + " entries, " + (entries.length - failed) + " blocked, " + failed + " not blocked");
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
} catch (err) {
|
||||
setup("reader error: " + (err && err.message));
|
||||
}
|
||||
'
|
||||
|
||||
cat <<'CORPUS' | node -e "$READER" "$HOOK"
|
||||
# fs-75: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E fs-75 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<\"VOYAGE_ARGS_EOF_7F3C9E\nbrief.md"
|
||||
# fs-78: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E fs-78 "echo \"x\" && node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<V\"OYAGE_ARGS_EOF_7F3C9E\nbrief.md\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-96: expands under both; measured 2026-11-02
|
||||
E fs-96 "echo \"a<<b\" && node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-104: expands under both; measured 2026-11-02
|
||||
E fs-104 "cat \\<<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nHDBODY #$(touch PWNED1)"
|
||||
# fs-105: expands under both; measured 2026-11-02
|
||||
E fs-105 "cat \\<\\\n<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nHDBODY #$(touch PWNED1)"
|
||||
# fs-106: expands under bash; measured 2026-11-02
|
||||
E fs-106 "cat \\<<\\\n<VOYAGE_ARGS_EOF_7F3C9E\nHDBODY #$(touch PWNED1)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-107: expands under both; measured 2026-11-02
|
||||
E fs-107 "cat \\<<<VOYAGE_ARGS\\\n_EOF\\\n_7F3C9E\nHDBODY #$(touch PWNED1)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-108: expands under both; measured 2026-11-02
|
||||
E fs-108 "cat \\<<<V\"OYAGE_ARGS_EOF_7F3C9E\"\nx\nVOYAGE_ARGS_EOF_7F3C9E\ntouch PWNED2"
|
||||
# fs-112: expands under both; measured 2026-11-02
|
||||
E fs-112 "echo \\\\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-114: expands under both; measured 2026-11-02
|
||||
E fs-114 "echo '\\'<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-116: expands under both; measured 2026-11-02
|
||||
E fs-116 "echo '\\'<<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-118: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E fs-118 "echo 'x' && echo \"y \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-120: expands under both; measured 2026-11-02
|
||||
E fs-120 "echo \\<<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-122: expands under both; measured 2026-11-02
|
||||
E fs-122 "echo a\\\\\n\\\\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# fs-127: expands under both; measured 2026-11-02
|
||||
E fs-127 "bash -c \"cat \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# fs-129: expands under both; measured 2026-11-02
|
||||
E fs-129 "x=`cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(echo EXPANDED)\nVOYAGE_ARGS_EOF_7F3C9E`"
|
||||
# fs-132: expands under both; measured 2026-11-02
|
||||
E fs-132 "bash <<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# ag-61: expands under both; measured 2026-11-02
|
||||
E ag-61 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)"
|
||||
# ag-62: expands under both; measured 2026-11-02
|
||||
E ag-62 "node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<VOYAGE_ARGS_EOF\\\n_7F3C9E\nbrief.md $(touch PWNED)"
|
||||
# scan-single-backslash: expands under both; measured 2026-11-02
|
||||
E scan-single-backslash "echo '\\'; bash -c \"echo \\\"'\\\"; cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# scan-dq-escaped-quote: expands under both; measured 2026-11-02
|
||||
E scan-dq-escaped-quote "bash -c \"echo \\\"x\\\"; cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-quote-open: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E esc-quote-open "echo \"y \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# esc-bash-c-dq: expands under both; measured 2026-11-02
|
||||
E esc-bash-c-dq "bash -c \"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-eval-dq: expands under both; measured 2026-11-02
|
||||
E esc-eval-dq "eval \"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-bash-c-ansi: expands under bash; measured 2026-11-02
|
||||
E esc-bash-c-ansi "bash -c $'cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E'"
|
||||
# esc-eval-ansi: expands under bash; measured 2026-11-02
|
||||
E esc-eval-ansi "eval $'cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E'"
|
||||
# esc-bash-c-locale: expands under both; measured 2026-11-02
|
||||
E esc-bash-c-locale "bash -c $\"cat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-heredoc-open: expands under both; measured 2026-11-02
|
||||
E esc-heredoc-open "bash <<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# esc-backquote: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E esc-backquote "x=`date`; echo \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# esc-close-trailing-space: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E esc-close-trailing-space "cat <<'EOF'\nEOF \necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# esc-close-cr: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E esc-close-cr "cat <<'EOF'\nEOF\r\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# esc-close-tab: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E esc-close-tab "cat <<'EOF'\n\tEOF\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# esc-two-heredocs: expands under both; measured 2026-11-02
|
||||
E esc-two-heredocs "cat <<A <<B\nA\necho \\<<VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nB"
|
||||
# esc-cj: expands under both; measured 2026-11-02
|
||||
E esc-cj "echo \\<x; bash <<EOF\nx\\\nEOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# esc-e1: expands under both; measured 2026-11-02
|
||||
E esc-e1 "# don't\nbash -c \"# it's\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e2: expands under both; measured 2026-11-02
|
||||
E esc-e2 "echo $((1<<X))\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e2b: expands under both; measured 2026-11-02
|
||||
E esc-e2b "# see <<X\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e4b: expands under both; measured 2026-11-02
|
||||
E esc-e4b "x=$(cat <<X)\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e4d: expands under both; measured 2026-11-02
|
||||
E esc-e4d "cat <(cat <<X) >/dev/null\nbash -c \":\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e8: expands under both; measured 2026-11-02
|
||||
E esc-e8 "bash <<X$(:)\nX$\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nX$(:)"
|
||||
# esc-e3: expands under both; measured 2026-11-02
|
||||
E esc-e3 "bash -s <<X \\\nX\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nX"
|
||||
# esc-e18: expands under bash; measured 2026-11-02
|
||||
E esc-e18 "bash -c \"$\\\n(: '\"')# '\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-e19: expands under both; measured 2026-11-02
|
||||
E esc-e19 "bash -c $\\\n'# \\' \"'\"\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# esc-cnt: expands under bash; measured 2026-11-02
|
||||
E esc-cnt "bash <\\\n<EOF\ncat - \\\\\\<<VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\nVOYAGE_ARGS_EOF_7F3C9E\nEOF"
|
||||
# sfx-word: expands under both; measured 2026-11-02
|
||||
E sfx-word "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9EX\nbody $(echo EXPANDED)"
|
||||
# sfx-dot: expands under both; measured 2026-11-02
|
||||
E sfx-dot "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E.\nbody $(echo EXPANDED)"
|
||||
# sfx-backquote: expands under neither (blocked conservatively); measured 2026-11-02
|
||||
E sfx-backquote "cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E`\nbody $(echo EXPANDED)"
|
||||
# split-in-string: expands under both; measured 2026-11-02
|
||||
E split-in-string "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\n\""
|
||||
# split-in-string-dot: expands under both; measured 2026-11-02
|
||||
E split-in-string-dot "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E.'\"\n\\$(touch PWNED); touch RAN\n\""
|
||||
# split-in-string-word: expands under both; measured 2026-11-02
|
||||
E split-in-string-word "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9EX'\"\n\\$(touch PWNED); touch RAN\n\""
|
||||
# split-in-string-closed: expands under both; measured 2026-11-02
|
||||
E split-in-string-closed "bash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\nVOYAGE_ARGS_EOF_7F3C9E\""
|
||||
# split-in-body: expands under both; measured 2026-11-02
|
||||
E split-in-body "bash <<'VOYAGE_ARGS_EOF_7F3C9E'\nbash -c 'cat <<VOYAGE_ARGS\\\n_EOF_7F3C9E'\"\n\\$(touch PWNED); touch RAN\n\"\nVOYAGE_ARGS_EOF_7F3C9E"
|
||||
# evl-x1: expands under both; measured 2026-11-02
|
||||
E evl-x1 "eval cat \\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-x1b: expands under both; measured 2026-11-02
|
||||
E evl-x1b "bash -c cat\\ \\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-sh-c: expands under both; measured 2026-11-02
|
||||
E evl-sh-c "sh -c 'cat '\\<\\<VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-x2: expands under both; measured 2026-11-02
|
||||
E evl-x2 "eval cat \\<\\< \"VOYAGE_ARGS_EOF_7F3C9E\n\\$(touch PWNED)\n\""
|
||||
# evl-split-word: expands under both; measured 2026-11-02
|
||||
E evl-split-word "eval cat \\<\\<VOYAGE_ARGS\\\n_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-split-dq: expands under both; measured 2026-11-02
|
||||
E evl-split-dq "eval cat \\<\\<\"VOYAGE_ARGS\\\n_EOF_7F3C9E\"'\n$(touch PWNED)\n'"
|
||||
# evl-split-sq: expands under both; measured 2026-11-02
|
||||
E evl-split-sq "eval cat \\<\\<'VOYAGE_ARGS\\\n_EOF_7F3C9E\n$(touch PWNED)\n'"
|
||||
# evl-split-before-word: expands under both; measured 2026-11-02
|
||||
E evl-split-before-word "eval cat \\<\\<\\\nVOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-blank-escaped: expands under both; measured 2026-11-02
|
||||
E evl-blank-escaped "eval cat \\<\\<\\ VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-empty-pair: expands under both; measured 2026-11-02
|
||||
E evl-empty-pair "eval cat \\<\\<'' VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
# evl-escaped-dash: expands under both; measured 2026-11-02
|
||||
E evl-escaped-dash "eval cat \\<\\<\\-VOYAGE_ARGS_EOF_7F3C9E'\n$(touch PWNED)\n'"
|
||||
CORPUS
|
||||
status=$?
|
||||
case "$status" in
|
||||
0 | 1 | 2) exit "$status" ;;
|
||||
*) setup_fail "the reader exited $status" ;;
|
||||
esac
|
||||
100
tests/hooks/bash-guard-4-5b2-a-known.test.mjs
Normal file
100
tests/hooks/bash-guard-4-5b2-a-known.test.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// tests/hooks/bash-guard-4-5b2-a-known.test.mjs
|
||||
// [KNA] Fix round 4.5b2, part A (decision 122): the false stops part A keeps.
|
||||
// The three commands below are refused by the args-heredoc guard in
|
||||
// hooks/scripts/pre-bash-executor.mjs although the shell reads their << as
|
||||
// text. Decision 122 dropped their fix; the CHANGELOG lists 943f9841 and
|
||||
// a6735ef9 under Known issues. This file holds at e2d6bec too, so it stands in
|
||||
// no red wrapper's file list and KNA is no red-proof id. It goes red if a later
|
||||
// change lets one of them through; its untagged mutation test shows that each
|
||||
// keep-assertion can go red.
|
||||
// The helpers bashInput, ARGS_DELIM, ARGS_RULE, expectArgsBlocked and D are
|
||||
// copied from tests/hooks/bash-guard-false-stops.test.mjs:26-68, not imported,
|
||||
// because importing a test file would run its tests.
|
||||
|
||||
import { test, after } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { pluginCopy } from './guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
|
||||
function bashInput(command) {
|
||||
return { tool_name: 'Bash', tool_input: { command } };
|
||||
}
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_RULE = /args-heredoc-closed-early/;
|
||||
|
||||
async function expectArgsBlocked(command, hook = PRE_BASH) {
|
||||
const { code, stderr } = await runHook(hook, bashInput(command));
|
||||
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
|
||||
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
|
||||
return stderr;
|
||||
}
|
||||
|
||||
const D = ARGS_DELIM;
|
||||
|
||||
// Catalogue K1 (943f9841): a grep string holding << right before the delimiter.
|
||||
const K1 = `grep -n '<<${D}' file\necho done`;
|
||||
// Catalogue K2 (a6735ef9): the review's commit message.
|
||||
const K2 = `git commit -m "don't use '<<' for ${D}" -m "second\nline"`;
|
||||
// Catalogue K3 (a6735ef9): the review's escaped-quote grep.
|
||||
const K3 = `echo "x\\"y" && grep "<<" f | grep ${D}\nnext`;
|
||||
|
||||
test('[KNA] a grep string holding << right before the delimiter is still refused (943f9841)', async () => {
|
||||
await expectArgsBlocked(K1);
|
||||
});
|
||||
|
||||
test("[KNA] the review's commit message is still refused (a6735ef9)", async () => {
|
||||
await expectArgsBlocked(K2);
|
||||
});
|
||||
|
||||
test("[KNA] the review's escaped-quote grep is still refused (a6735ef9)", async () => {
|
||||
await expectArgsBlocked(K3);
|
||||
});
|
||||
|
||||
// The copies, removed after the file's tests.
|
||||
const copies = [];
|
||||
after(() => {
|
||||
for (const dir of copies) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A copy of the plugin whose hook has `find` (asserted to occur exactly once)
|
||||
// replaced by `replace`; returns the copy's hook path.
|
||||
function mutatedHook(find, replace) {
|
||||
const root = pluginCopy();
|
||||
copies.push(root);
|
||||
const hook = join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const src = readFileSync(hook, 'utf8');
|
||||
const count = src.split(find).length - 1;
|
||||
assert.strictEqual(count, 1, `the mutation text matched ${count} times, not once: ${find}`);
|
||||
writeFileSync(hook, src.replace(find, () => replace));
|
||||
return hook;
|
||||
}
|
||||
|
||||
async function expectPasses(name, command, hook) {
|
||||
const { code, stderr } = await runHook(hook, bashInput(command));
|
||||
assert.doesNotMatch(stderr, ARGS_RULE, `${name} was still refused under the mutation`);
|
||||
assert.strictEqual(code, 0, `${name} did not exit 0 under the mutation: ${stderr}`);
|
||||
}
|
||||
|
||||
test('part-A known: each keep-assertion goes red when the reading that refuses it lets it through', async () => {
|
||||
// The regex reading off: its loop runs over no matches. K1 is refused only
|
||||
// by the regex reading, so it passes; K2 and K3 stay refused.
|
||||
const regexOff = mutatedHook(' for (const argsOpener of argsOpeners) {', ' for (const argsOpener of []) {');
|
||||
await expectPasses('K1', K1, regexOff);
|
||||
await expectArgsBlocked(K2, regexOff);
|
||||
await expectArgsBlocked(K3, regexOff);
|
||||
|
||||
// The string count off: every cut opener counts as inside a string. K2 and
|
||||
// K3 are refused only by the string count, so they pass; K1 stays refused.
|
||||
const countOff = mutatedHook(' return text.slice(lineStart, o.index).split(q).length % 2 === 0;', ' return true;');
|
||||
await expectPasses('K2', K2, countOff);
|
||||
await expectPasses('K3', K3, countOff);
|
||||
await expectArgsBlocked(K1, countOff);
|
||||
});
|
||||
317
tests/hooks/bash-guard-4-5b2-a.test.mjs
Normal file
317
tests/hooks/bash-guard-4-5b2-a.test.mjs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
// tests/hooks/bash-guard-4-5b2-a.test.mjs
|
||||
// [RXT] [CNT] [ESR] [SFX] [SIS] [EVL] Fix round 4.5b2, part A (decisions 91, 107 and 123).
|
||||
// Defects: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs
|
||||
// turned its escape rule off for the whole command when a ', " or backquote
|
||||
// or a << stood anywhere before an escaped <, so a here-string, a closed
|
||||
// heredoc or a closed quote earlier in the command made the guard refuse a
|
||||
// command in which the shell reads \< as a literal < (ESR). Two branches part
|
||||
// 4 added had no test: the try that makes an error inside the regex reading's
|
||||
// escape limit block (RXT), and the continuation-split outer opener that keeps
|
||||
// the escape rule off (CNT). A delimiter split by a backslash-newline with a
|
||||
// suffix after it was compared as an exact word, so it passed (SFX). Part 4's
|
||||
// string skip skipped a split opener inside a string, which the regex reading
|
||||
// cannot see (SIS). A heredoc operator a second shell reads from \<\< (eval,
|
||||
// bash -c, sh -c) was seen by neither reading (EVL).
|
||||
// The helpers bashInput, ARGS_DELIM, ARGS_RULE, PARSER_LINE,
|
||||
// expectArgsBlocked, expectArgsAllowed, UNQUOTED, expectUnquoted,
|
||||
// CLOSED_EARLY, expectClosedEarly, D and BODY are copied from
|
||||
// tests/hooks/bash-guard-false-stops.test.mjs:26-68, not imported, because
|
||||
// importing a test file would run its tests. The guard is only spawned, so
|
||||
// this file loads at e2d6bec and its red is an assertion.
|
||||
|
||||
import { test, after } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { runHook, runHookWithEnv } from '../helpers/hook-helper.mjs';
|
||||
import { tmp, pluginCopy } from './guard-helpers.mjs';
|
||||
import { fastestWithin } from '../helpers/timing.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
|
||||
function bashInput(command) {
|
||||
return { tool_name: 'Bash', tool_input: { command } };
|
||||
}
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_RULE = /args-heredoc-closed-early/;
|
||||
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
|
||||
|
||||
async function expectArgsBlocked(command) {
|
||||
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
||||
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
|
||||
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
|
||||
return stderr;
|
||||
}
|
||||
|
||||
async function expectArgsAllowed(command) {
|
||||
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
||||
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
|
||||
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
|
||||
}
|
||||
|
||||
// The unquoted reason: the guard blocks and says the opener is unquoted.
|
||||
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
|
||||
|
||||
async function expectUnquoted(command) {
|
||||
const stderr = await expectArgsBlocked(command);
|
||||
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
|
||||
}
|
||||
|
||||
// The closed-early reason: a line equal to the closing line, then more text.
|
||||
const CLOSED_EARLY = /hold a line equal to the heredoc's closing line/;
|
||||
|
||||
async function expectClosedEarly(command) {
|
||||
const stderr = await expectArgsBlocked(command);
|
||||
assert.match(stderr, CLOSED_EARLY, `the guard did not give the closed-early reason for: ${JSON.stringify(command)}`);
|
||||
assert.doesNotMatch(stderr, UNQUOTED, `the guard gave the unquoted reason for: ${JSON.stringify(command)}`);
|
||||
}
|
||||
|
||||
const D = ARGS_DELIM;
|
||||
// The body line and the closing line of the escape-rule controls.
|
||||
const BODY = `\nbrief.md $(touch PWNED)\n${D}`;
|
||||
|
||||
test('[ESR] a here-string before an escaped < leaves the rule on', async () => {
|
||||
// Red: the here-string <<< is text to the quote scan, so \< is a literal <
|
||||
// and the next line runs as a command.
|
||||
await expectArgsAllowed(`cat <<<x; echo \\<<${D}\nnext`);
|
||||
|
||||
// Control: a # comment holding a ' before bash -c "... (catalogue E1): the
|
||||
// scan cannot follow a comment, so the rule is off and the inner shell
|
||||
// opens an unquoted heredoc.
|
||||
await expectUnquoted(`# don't\nbash -c "# it's\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
|
||||
// Control: an arithmetic shift $((1<<X)) before the run (catalogue E2): the
|
||||
// scan cannot follow $(, so the rule is off.
|
||||
await expectUnquoted(`echo $((1<<X))\nbash -c ":\nX\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}"`);
|
||||
// Control: a continuation before the run: the scan cannot follow it, so the
|
||||
// rule is off.
|
||||
await expectUnquoted(`echo a\\\nb && echo \\<<${D}${BODY}`);
|
||||
});
|
||||
|
||||
test("[ESR] a closed quoted heredoc before an escaped < leaves the rule on, and a quote in its body counts for nothing", async () => {
|
||||
// Red: the heredoc EOF is closed before the run and the ' in its body is
|
||||
// body text, so \< is a literal < and the next line runs as a command.
|
||||
await expectArgsAllowed(`cat <<'EOF'\ndon't\nEOF\necho \\<<${D}\nnext`);
|
||||
|
||||
// Control: an earlier heredoc not yet closed: the run stands in its body,
|
||||
// so the rule is off.
|
||||
await expectUnquoted(`cat <<'EOF'\necho \\<<${D}${BODY}`);
|
||||
// Control: the would-be closing line is EOF plus a trailing blank, which
|
||||
// does not close the heredoc, so the run stands in its body.
|
||||
await expectUnquoted(`cat <<'EOF'\nEOF \necho \\<<${D}${BODY}\nEOF`);
|
||||
// Control: the would-be closing line is EOF plus a CR, which does not
|
||||
// close the heredoc, so the run stands in its body.
|
||||
await expectUnquoted(`cat <<'EOF'\nEOF\r\necho \\<<${D}${BODY}\nEOF`);
|
||||
// Control: the would-be closing line is a tab plus EOF under << without -,
|
||||
// which does not close the heredoc, so the run stands in its body.
|
||||
await expectUnquoted(`cat <<'EOF'\n\tEOF\necho \\<<${D}${BODY}\nEOF`);
|
||||
// Control: two heredocs on one line: the scan cannot follow them, so the
|
||||
// rule is off.
|
||||
await expectUnquoted(`cat <<A <<B\nA\necho \\<<${D}${BODY}\nB`);
|
||||
// Control: a continuation inside an earlier unquoted heredoc's body
|
||||
// (catalogue CJ): the shell joins x\ to the next line, so EOF does not
|
||||
// close the body there and the scan cannot follow it; the rule is off.
|
||||
await expectUnquoted(`echo \\<x; bash <<EOF\nx\\\nEOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
|
||||
});
|
||||
|
||||
test('[ESR] a quote closed before an escaped < leaves the rule on', async () => {
|
||||
// Red. Moved from tests/hooks/bash-guard-false-stops.test.mjs:117-118
|
||||
// (decision 106): the ' is closed before the run, so the shell reads \< as a
|
||||
// literal <.
|
||||
await expectArgsAllowed(`echo 'x' && echo \\<<${D}${BODY}`);
|
||||
|
||||
// Control: a " still open before the run: inside "..." \< keeps its
|
||||
// backslash, so the rule is off.
|
||||
await expectUnquoted(`echo "y \\<<${D}${BODY}`);
|
||||
// Control: a $' string before the run: the scan does not decode it, so the
|
||||
// rule is off.
|
||||
await expectUnquoted(`echo $'x' && echo \\<<${D}${BODY}`);
|
||||
// Control: a closed backquote before the run: the scan cannot follow a
|
||||
// backquote, so the rule is off.
|
||||
await expectUnquoted(`x=\`date\`; echo \\<<${D}${BODY}`);
|
||||
// Control that passes: a first odd run of three. The shell reads \\ and \<
|
||||
// and then a <D redirection. The run starts two characters before the
|
||||
// backslash the fast path finds, so this pins the rule against comparing
|
||||
// that backslash with the run start.
|
||||
await expectArgsAllowed(`echo \\\\\\<<${D}\nnext`);
|
||||
});
|
||||
|
||||
// The preload makes String.prototype.search throw when it is called from
|
||||
// inside argsEscapeLimit, so the regex reading's escape limit throws. It is
|
||||
// written into a temp directory, never under tests/, and only makes the hook
|
||||
// block.
|
||||
const PRELOAD = `const orig = String.prototype.search;
|
||||
String.prototype.search = function (...args) {
|
||||
if (/argsEscapeLimit/.test(new Error().stack)) {
|
||||
process.stderr.write('[rxt-preload] thrown\\n');
|
||||
throw new Error('RXT forced read error');
|
||||
}
|
||||
return orig.apply(this, args);
|
||||
};
|
||||
`;
|
||||
|
||||
const preloadDir = tmp('voyage-rxt-');
|
||||
after(() => rmSync(preloadDir, { recursive: true, force: true }));
|
||||
|
||||
test("[RXT] an error inside the regex reading's escape limit blocks the command (exit 2), never lets it through", async () => {
|
||||
const file = join(preloadDir, 'rxt-preload.mjs');
|
||||
writeFileSync(file, PRELOAD);
|
||||
const forced = { NODE_OPTIONS: '--import=' + pathToFileURL(file).href };
|
||||
const shipped = `${PARSER_LINE} <<'${D}'\nbrief.md\n${D}`;
|
||||
|
||||
// Red, forced: the shipped heredoc under the preload is blocked by the catch.
|
||||
const { code, stderr } = await runHookWithEnv(PRE_BASH, bashInput(shipped), forced);
|
||||
assert.ok(stderr.includes('[rxt-preload] thrown'), `the preload did not throw inside the escape limit: ${stderr}`);
|
||||
assert.strictEqual(code, 2, `the guard did not block when its escape limit threw (exit ${code}): ${stderr}`);
|
||||
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early: ${stderr}`);
|
||||
assert.ok(!stderr.includes('RXT forced read error'), `the error was not caught (an uncaught throw exits 1): ${stderr}`);
|
||||
|
||||
// Control: the same command with no preload passes, so the block can come
|
||||
// only from the catch.
|
||||
await expectArgsAllowed(shipped);
|
||||
// Control: a command with no delimiter passes under the preload.
|
||||
const plain = await runHookWithEnv(PRE_BASH, bashInput('ls -la'), forced);
|
||||
assert.strictEqual(plain.code, 0, `ls -la was refused under the preload: ${plain.stderr}`);
|
||||
});
|
||||
|
||||
test('[CNT] a heredoc opener split between its two < still turns the escape rule off', async () => {
|
||||
// Red: the outer opener <\ + line break + <EOF is a heredoc whose body is
|
||||
// fed to bash, which reads the backslashes once more. Measured: /bin/bash
|
||||
// 3.2.57 expands the inner body; zsh 5.9 gives a parse error. At e2d6bec the
|
||||
// base's first-<< search keeps the rule off; on the branch the quote scan's
|
||||
// continuation check does.
|
||||
await expectUnquoted(`bash <\\\n<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`);
|
||||
});
|
||||
|
||||
// The delimiter split by a backslash-newline into its two halves; the shell
|
||||
// removes the backslash-newline and reads the delimiter written out.
|
||||
const SPLIT = 'VOYAGE_ARGS\\\n_EOF_7F3C9E';
|
||||
// The body line of the split-suffix forms.
|
||||
const SBODY = '\nbody $(echo EXPANDED)';
|
||||
|
||||
test('[SFX] a split delimiter with an X suffix is blocked as the same opener written out is', async () => {
|
||||
// Red (catalogue SFX-X): the shell reads the word as the delimiter plus X,
|
||||
// an unquoted heredoc. Measured: /bin/bash and /bin/zsh -f print
|
||||
// body EXPANDED.
|
||||
await expectUnquoted(`cat <<${SPLIT}X${SBODY}`);
|
||||
|
||||
// Control: the same opener written out is blocked as unquoted.
|
||||
await expectUnquoted(`cat <<${D}X${SBODY}`);
|
||||
});
|
||||
|
||||
test('[SFX] a split delimiter with a backquote suffix is blocked as the same opener written out is', async () => {
|
||||
// Red (catalogue SFX-bq): measured, the form is a parse error in /bin/bash
|
||||
// and /bin/zsh -f; it is blocked conservatively, as written out.
|
||||
await expectUnquoted(`cat <<${SPLIT}\`${SBODY}`);
|
||||
|
||||
// Control: the same opener written out is blocked as unquoted.
|
||||
await expectUnquoted(`cat <<${D}\`${SBODY}`);
|
||||
});
|
||||
|
||||
test('[SFX] a split delimiter with a . suffix is blocked as the same opener written out is', async () => {
|
||||
// Red (catalogue SFX-dot): the shell reads the word as the delimiter plus
|
||||
// a dot, an unquoted heredoc. Measured: /bin/bash and /bin/zsh -f print
|
||||
// body EXPANDED.
|
||||
await expectUnquoted(`cat <<${SPLIT}.${SBODY}`);
|
||||
|
||||
// Control: the same opener written out is blocked as unquoted.
|
||||
await expectUnquoted(`cat <<${D}.${SBODY}`);
|
||||
});
|
||||
|
||||
test('[SIS] a split delimiter inside a string is never skipped as text', async () => {
|
||||
// Measured: SIS1-SIS4 and SIS6 create PWNED under /bin/bash and
|
||||
// /bin/zsh -f and pass the hook at e2d6bec and 1731e41. The inner shell
|
||||
// removes the backslash-newline and opens an unquoted heredoc.
|
||||
const RUN = '\n\\$(touch PWNED); touch RAN\n';
|
||||
// Red (catalogue SIS1).
|
||||
await expectUnquoted(`bash -c 'cat <<${SPLIT}'"${RUN}"`);
|
||||
|
||||
// Control (SIS2): a . after the split delimiter.
|
||||
await expectUnquoted(`bash -c 'cat <<${SPLIT}.'"${RUN}"`);
|
||||
// Control (SIS3): a word character after the split delimiter.
|
||||
await expectUnquoted(`bash -c 'cat <<${SPLIT}X'"${RUN}"`);
|
||||
// Control (SIS4): SIS1 with a closing line.
|
||||
await expectUnquoted(`bash -c 'cat <<${SPLIT}'"${RUN}${D}"`);
|
||||
// Control (SIS0): the delimiter written out, blocked already at e2d6bec.
|
||||
await expectUnquoted(`bash -c 'cat <<${D}'"${RUN}"`);
|
||||
// Control (SIS6): SIS1 in the body of a quoted heredoc fed to bash; the
|
||||
// skip after a quoted opener that passes must not skip a split opener.
|
||||
await expectUnquoted(`bash <<'${D}'\nbash -c 'cat <<${SPLIT}'"${RUN}"\n${D}`);
|
||||
// Control that passes (SIS5): a split delimiter inside a grep string with
|
||||
// no line after the command, so no body (decision 87's one-line rule).
|
||||
await expectArgsAllowed(`grep -n '<<${SPLIT}' file`);
|
||||
});
|
||||
|
||||
// The eval word, written in two parts so the live guard's dynamic-eval rule
|
||||
// does not refuse the file write.
|
||||
const EV = 'ev' + 'al';
|
||||
// The body line of the re-read forms; the ' after the word opens the string
|
||||
// that carries it, and the next ' closes it.
|
||||
const XBODY = "\n$(touch PWNED)\n'";
|
||||
|
||||
test('[EVL] an eval of a twice-escaped heredoc operator with a body is blocked', async () => {
|
||||
// Red (catalogue X1): the outer shell removes the backslashes, so the
|
||||
// second shell (eval) reads <<D and an unquoted heredoc. Measured: /bin/bash
|
||||
// and /bin/zsh -f create PWNED.
|
||||
await expectUnquoted(`${EV} cat \\<\\<${D}'${XBODY}`);
|
||||
|
||||
// Control that passes: the one-line form has no line after the opener, so
|
||||
// no body (decision 87's one-line rule).
|
||||
await expectArgsAllowed(`${EV} cat \\<\\<${D}`);
|
||||
// Control: with no second shell the shell reads \<\<D as text and the next
|
||||
// line runs as a command; the rule refuses it all the same (it is not keyed
|
||||
// on eval). This pins that known exception.
|
||||
await expectUnquoted(`echo \\<\\<${D}\nnext`);
|
||||
});
|
||||
|
||||
test('[EVL] a bash -c string built from a twice-escaped heredoc operator is blocked', async () => {
|
||||
// Red (catalogue X1b): bash -c gets the string cat <<D'...', an unquoted
|
||||
// heredoc. Measured: /bin/bash and /bin/zsh -f create PWNED.
|
||||
await expectUnquoted(`bash -c cat\\ \\<\\<${D}'${XBODY}`);
|
||||
|
||||
// Control (catalogue X1s): the sh -c form.
|
||||
await expectUnquoted(`sh -c 'cat '\\<\\<${D}'${XBODY}`);
|
||||
});
|
||||
|
||||
test('[EVL] a blank between the twice-escaped operator and the word does not hide it', async () => {
|
||||
// Red (catalogue X2): the second shell reads << "D...", the word after a
|
||||
// blank. Measured: /bin/bash and /bin/zsh -f create PWNED.
|
||||
await expectUnquoted(`${EV} cat \\<\\< "${D}\n\\$(touch PWNED)\n"`);
|
||||
|
||||
// Controls: the word is read as a second shell reads it once the outer
|
||||
// shell has removed its quoting (3a's word loop).
|
||||
// An escaped blank before the word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<\\ ${D}'${XBODY}`);
|
||||
// An empty quote pair and a blank before the word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<'' ${D}'${XBODY}`);
|
||||
// An escaped - before the word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<\\-${D}'${XBODY}`);
|
||||
// The split forms: a backslash-newline inside or before the word is removed
|
||||
// by one of the two shells, so the check removes it inside the delimiter.
|
||||
// Catalogue XS1: the split in the bare word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<${SPLIT}'${XBODY}`);
|
||||
// Catalogue XS2: the split inside a "..." word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<"${SPLIT}"'${XBODY}`);
|
||||
// Catalogue XS3: the split inside the '...' string that carries the body.
|
||||
await expectUnquoted(`${EV} cat \\<\\<'${SPLIT}\n$(touch PWNED)\n'`);
|
||||
// Catalogue XS4: a backslash-newline between the operator and the word.
|
||||
await expectUnquoted(`${EV} cat \\<\\<\\\n${D}'${XBODY}`);
|
||||
});
|
||||
|
||||
test('part-A scan: a long command with many escaped < is read in linear time', async () => {
|
||||
// About 870 KB: 20000 escaped < before the shipped quoted heredoc. The quote
|
||||
// scan resumes where its last call stopped, so the text is read once.
|
||||
const many = ('\\<' + 'x'.repeat(40) + ' ').repeat(20000);
|
||||
const command = `echo ${many}<<'${D}'\nbrief.md\n${D}`;
|
||||
let last = { code: null, stderr: '' };
|
||||
const timing = await fastestWithin(15000, async () => {
|
||||
const started = Date.now();
|
||||
last = await runHook(PRE_BASH, bashInput(command));
|
||||
return Date.now() - started;
|
||||
});
|
||||
assert.strictEqual(last.code, 0, `the guard did not pass the long command (exit ${last.code}): ${last.stderr.slice(0, 300)}`);
|
||||
assert.ok(timing.ok, `the guard took ${timing.fastest} ms at best (runs ${timing.runs.join(', ')}) on ${command.length} characters (limit 15000 ms)`);
|
||||
});
|
||||
217
tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
Normal file
217
tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// tests/hooks/bash-guard-corpus-4-5b2-a.test.mjs
|
||||
// Fix round 4.5b2, part A (decisions 91, 107 and 123): the red proof of the
|
||||
// bypass corpus tests/guard-bypass-corpus-4-5b2-a.sh. The corpus holds
|
||||
// commands the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs must
|
||||
// block; these tests show it passes on the hook and fails on hooks that let
|
||||
// an entry through. Untagged: no red wrapper lists this file.
|
||||
// Each corpus run is /bin/bash tests/guard-bypass-corpus-4-5b2-a.sh --hook
|
||||
// <path>. The mutated hooks are copies (pluginCopy,
|
||||
// tests/hooks/guard-helpers.mjs), each find text asserted to occur exactly
|
||||
// once first, so a later edit of a mutated line fails with "matched 0 times",
|
||||
// never passes silently (the mutatedCopy pattern of
|
||||
// tests/hooks/bash-guard-reader-pins.test.mjs).
|
||||
|
||||
import { test, after } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runHook } from '../helpers/hook-helper.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
import { tmp, pluginCopy } from './guard-helpers.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const CORPUS = join(ROOT, 'tests', 'guard-bypass-corpus-4-5b2-a.sh');
|
||||
|
||||
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const ARGS_RULE = /args-heredoc-closed-early/;
|
||||
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
|
||||
const D = ARGS_DELIM;
|
||||
|
||||
// The names the plan lists (Step 3, 3c); each must stand in exactly one
|
||||
// "E <name> " line of the corpus, so the corpus cannot be thinner than this.
|
||||
const NAMES = [
|
||||
'fs-75', 'fs-78', 'fs-96', 'fs-104', 'fs-105', 'fs-106', 'fs-107', 'fs-108', 'fs-112',
|
||||
'fs-114', 'fs-116', 'fs-118', 'fs-120', 'fs-122', 'fs-127', 'fs-129', 'fs-132',
|
||||
'ag-61', 'ag-62',
|
||||
'scan-single-backslash', 'scan-dq-escaped-quote',
|
||||
'esc-quote-open', 'esc-bash-c-dq', 'esc-eval-dq', 'esc-bash-c-ansi', 'esc-eval-ansi',
|
||||
'esc-bash-c-locale', 'esc-heredoc-open', 'esc-backquote',
|
||||
'esc-close-trailing-space', 'esc-close-cr', 'esc-close-tab', 'esc-two-heredocs',
|
||||
'esc-cj', 'esc-e1', 'esc-e2', 'esc-e2b', 'esc-e4b', 'esc-e4d', 'esc-e8', 'esc-e3',
|
||||
'esc-e18', 'esc-e19', 'esc-cnt',
|
||||
'sfx-word', 'sfx-dot', 'sfx-backquote',
|
||||
'split-in-string', 'split-in-string-dot', 'split-in-string-word', 'split-in-string-closed',
|
||||
'split-in-body',
|
||||
'evl-x1', 'evl-x1b', 'evl-sh-c', 'evl-x2',
|
||||
'evl-split-word', 'evl-split-dq', 'evl-split-sq', 'evl-split-before-word',
|
||||
'evl-blank-escaped', 'evl-empty-pair', 'evl-escaped-dash',
|
||||
];
|
||||
|
||||
// The [CNT] command (tests/hooks/bash-guard-4-5b2-a.test.mjs): an opener split
|
||||
// between its two < whose heredoc is fed to bash.
|
||||
const CNT = `bash <\\\n<EOF\ncat - \\\\\\<<${D}\n\\$(touch PWNED)\n${D}\nEOF`;
|
||||
|
||||
// Every corpus run gets this long: about 63 hook starts.
|
||||
const CORPUS_TIMEOUT_MS = 300_000;
|
||||
|
||||
function runCorpus(hook) {
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile('/bin/bash', [CORPUS, '--hook', hook],
|
||||
{ timeout: CORPUS_TIMEOUT_MS, env: childEnv(), maxBuffer: 16 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
resolve({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' });
|
||||
});
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
const corpusText = () => readFileSync(CORPUS, 'utf8');
|
||||
const entryLines = (text) => text.split('\n').filter((l) => l.startsWith('E '));
|
||||
|
||||
// The command of the corpus entry `name` (its JSON string).
|
||||
function entryCommand(name) {
|
||||
const line = entryLines(corpusText()).find((l) => l.startsWith(`E ${name} `));
|
||||
assert.ok(line, `no corpus entry ${name}`);
|
||||
return JSON.parse(line.slice(`E ${name} `.length));
|
||||
}
|
||||
|
||||
const dirs = [];
|
||||
after(() => {
|
||||
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A hook stub in a temp directory: it reads the whole input, then runs `body`
|
||||
// with `command` set to the input's command.
|
||||
function stubHook(body) {
|
||||
const dir = tmp('voyage-corpus-stub-');
|
||||
dirs.push(dir);
|
||||
const file = join(dir, 'stub-hook.mjs');
|
||||
writeFileSync(file, `let s = '';
|
||||
process.stdin.on('data', (d) => { s += d; });
|
||||
process.stdin.on('end', () => {
|
||||
const command = JSON.parse(s).tool_input.command;
|
||||
${body}
|
||||
});
|
||||
`);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A copy of the plugin whose hook has `find` (asserted to occur exactly once)
|
||||
// replaced by `replace`. Returns the hook path and the unmutated text.
|
||||
function mutatedHook(find, replace) {
|
||||
const root = pluginCopy();
|
||||
dirs.push(root);
|
||||
const hook = join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
||||
const src = readFileSync(hook, 'utf8');
|
||||
const count = src.split(find).length - 1;
|
||||
assert.strictEqual(count, 1, `the mutation text matched ${count} times, not once: ${find}`);
|
||||
return { hook, mutate: () => writeFileSync(hook, src.replace(find, () => replace)) };
|
||||
}
|
||||
|
||||
const failLines = (out) => out.split('\n').filter((l) => l.startsWith('FAIL - '));
|
||||
|
||||
test('part-A corpus: every entry is blocked by the hook, and the count line counts every E line', async () => {
|
||||
const { code, stdout, stderr } = await runCorpus(PRE_BASH);
|
||||
assert.strictEqual(code, 0, `the corpus did not pass on the hook (exit ${code}):\n${failLines(stdout).join('\n')}\n${stderr}`);
|
||||
const m = /^corpus: (\d+) entries, (\d+) blocked, (\d+) not blocked$/m.exec(stdout);
|
||||
assert.ok(m, `no count line in the output:\n${stdout}`);
|
||||
const lines = entryLines(corpusText()).length;
|
||||
assert.strictEqual(Number(m[1]), lines, `the count line says ${m[1]} entries, the script holds ${lines} E lines`);
|
||||
assert.strictEqual(Number(m[2]), lines, `the count line says ${m[2]} blocked of ${lines}`);
|
||||
assert.strictEqual(stdout.split('\n').filter((l) => l.startsWith('ok - ')).length, lines, 'not one ok line per entry');
|
||||
});
|
||||
|
||||
test('part-A corpus: the corpus holds every entry the plan names, each in exactly one E line', () => {
|
||||
const lines = entryLines(corpusText());
|
||||
for (const name of NAMES) {
|
||||
const n = lines.filter((l) => l.startsWith(`E ${name} `)).length;
|
||||
assert.strictEqual(n, 1, `the corpus holds ${n} E lines for ${name}, not one`);
|
||||
}
|
||||
assert.ok(lines.length >= NAMES.length, `the corpus holds ${lines.length} E lines, fewer than the ${NAMES.length} names`);
|
||||
});
|
||||
|
||||
test('part-A corpus: a hook that lets every command through fails the corpus (exit 1)', async () => {
|
||||
const hook = stubHook('process.exit(0);');
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail on a hook that exits 0 (exit ${code}):\n${stdout}`);
|
||||
assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed');
|
||||
});
|
||||
|
||||
test('part-A corpus: a hook that blocks with another rule name fails the corpus (exit 1)', async () => {
|
||||
const hook = stubHook("process.stderr.write('[voyage] BLOCKED: some-other-rule\\n'); process.exit(2);");
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail on a hook that names another rule (exit ${code}):\n${stdout}`);
|
||||
assert.strictEqual(failLines(stdout).length, entryLines(corpusText()).length, 'not every entry failed');
|
||||
});
|
||||
|
||||
test('part-A corpus: a hook that lets one entry through fails the corpus and names only that entry', async () => {
|
||||
const name = 'split-in-body';
|
||||
const text = entryCommand(name);
|
||||
const hook = stubHook(`if (command === ${JSON.stringify(text)}) process.exit(0);
|
||||
process.stderr.write('[voyage] BLOCKED: args-heredoc-closed-early\\n');
|
||||
process.exit(2);`);
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail when one entry passed (exit ${code}):\n${stdout}`);
|
||||
const fails = failLines(stdout);
|
||||
assert.strictEqual(fails.length, 1, `expected one FAIL line, got:\n${fails.join('\n')}`);
|
||||
assert.ok(fails[0].startsWith(`FAIL - ${name}: `), `the FAIL line names another entry: ${fails[0]}`);
|
||||
});
|
||||
|
||||
test('part-A corpus: a missing hook is a setup failure (exit 2)', async () => {
|
||||
const dir = tmp('voyage-corpus-missing-');
|
||||
dirs.push(dir);
|
||||
const { code, stdout, stderr } = await runCorpus(join(dir, 'no-such-hook.mjs'));
|
||||
assert.strictEqual(code, 2, `the corpus did not exit 2 on a missing hook (exit ${code}):\n${stdout}\n${stderr}`);
|
||||
});
|
||||
|
||||
test('part-A corpus: a hook copy with the args gate off fails the corpus (exit 1)', async () => {
|
||||
const { hook, mutate } = mutatedHook("if (command.replace(/\\\\\\n/g, '').includes(ARGS_DELIM)) {", 'if (false) {');
|
||||
mutate();
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail with the gate off (exit ${code}):\n${stdout}`);
|
||||
});
|
||||
|
||||
test("part-A corpus: the scan's continuation check holds the [CNT] command on the branch", async () => {
|
||||
const marker = '// scan: a continuation cannot be followed';
|
||||
const { hook, mutate } = mutatedHook(String.raw` if (d === '\n') return unknown(); ${marker}`, '');
|
||||
// The unmutated copy blocks the [CNT] command as unquoted.
|
||||
const before = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } });
|
||||
assert.strictEqual(before.code, 2, `the unmutated copy did not block the [CNT] command: ${before.stderr}`);
|
||||
assert.match(before.stderr, UNQUOTED, `the unmutated copy did not give the unquoted reason: ${before.stderr}`);
|
||||
mutate();
|
||||
assert.ok(!readFileSync(hook, 'utf8').includes(marker), 'the continuation check is still in the copy');
|
||||
// Without the check the scan skips the backslash and the line break, so the
|
||||
// escape rule turns on and the [CNT] command passes.
|
||||
const after_ = await runHook(hook, { tool_name: 'Bash', tool_input: { command: CNT } });
|
||||
assert.doesNotMatch(after_.stderr, ARGS_RULE, `the mutated copy still blocked the [CNT] command: ${after_.stderr}`);
|
||||
assert.strictEqual(after_.code, 0, `the mutated copy did not pass the [CNT] command (exit ${after_.code}): ${after_.stderr}`);
|
||||
});
|
||||
|
||||
test("part-A corpus: a scan that reads a backslash inside '...' as an escape lets scan-single-backslash through", async () => {
|
||||
// M1.
|
||||
const { hook, mutate } = mutatedHook(
|
||||
" if (mode === 'single') {\n if (c === \"'\") mode = 'none';",
|
||||
" if (mode === 'single') {\n" + String.raw` if (c === '\\') { p += 2; continue; }` + "\n if (c === \"'\") mode = 'none';",
|
||||
);
|
||||
mutate();
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail under M1 (exit ${code}):\n${stdout}`);
|
||||
assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-single-backslash: ')), `scan-single-backslash did not fail under M1:\n${stdout}`);
|
||||
});
|
||||
|
||||
test('part-A corpus: a scan that ignores a backslash inside "..." lets scan-dq-escaped-quote through', async () => {
|
||||
// M2. The first line alone also occurs inside argsShellOpeners, so the find
|
||||
// text is both lines.
|
||||
const { hook, mutate } = mutatedHook(
|
||||
String.raw` if (c === '\\') {` + '\n // The scan never decides',
|
||||
String.raw` if (c === '\\' && mode !== 'double') {` + '\n // The scan never decides',
|
||||
);
|
||||
mutate();
|
||||
const { code, stdout } = await runCorpus(hook);
|
||||
assert.strictEqual(code, 1, `the corpus did not fail under M2 (exit ${code}):\n${stdout}`);
|
||||
assert.ok(failLines(stdout).some((l) => l.startsWith('FAIL - scan-dq-escaped-quote: ')), `scan-dq-escaped-quote did not fail under M2:\n${stdout}`);
|
||||
});
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
// expectArgsBlocked, expectArgsAllowed, UNQUOTED and expectUnquoted are copied
|
||||
// from tests/hooks/bash-guard-args-gate.test.mjs:27-54, not imported, because
|
||||
// importing a test file would run its tests. The guard is only spawned, so
|
||||
// this file loads at 12bca8b and its red is an assertion.
|
||||
// this file loads at 591f3c4 and its red is an assertion.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
|
@ -114,8 +114,8 @@ test('[ESC] a backslash-escaped < starts no opener, and every real opener around
|
|||
await expectUnquoted(`echo '\\'<<${D}${BODY}`);
|
||||
// The same, continuation-split.
|
||||
await expectUnquoted(`echo '\\'<<VOYAGE_ARGS\\\n_EOF_7F3C9E${BODY}`);
|
||||
// A ' before an odd run: the rule is off.
|
||||
await expectUnquoted(`echo 'x' && echo \\<<${D}${BODY}`);
|
||||
// A " still open before an odd run: the rule is off.
|
||||
await expectUnquoted(`echo 'x' && echo "y \\<<${D}${BODY}`);
|
||||
// The << after the escaped <.
|
||||
await expectUnquoted(`echo \\<<<${D}${BODY}`);
|
||||
// A raw run of two: a line break ends the run, a continuation does not lengthen it.
|
||||
|
|
|
|||
205
tests/lib/fix-round-4-5b2-a-prose.test.mjs
Normal file
205
tests/lib/fix-round-4-5b2-a-prose.test.mjs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
// tests/lib/fix-round-4-5b2-a-prose.test.mjs
|
||||
// Prose pins for fix round 4.5b2, part A (plan
|
||||
// .claude/projects/2026-10-31-guard-4-5b2-part-a, decisions 91, 107, 122 and
|
||||
// 123): the fixes that are text a session reads. The tag in brackets is the
|
||||
// part-A id from the round's plan; each tagged test fails on an assertion at
|
||||
// e2d6bec, part 4's end point. The keep-behaviour checks sit inside the tagged
|
||||
// test as "Control:" assertions.
|
||||
// KIA the CHANGELOG lists part A's fixes (RXT, CNT, SFX, ESR, EVL, SIS, HDR)
|
||||
// under ### Fixed, brings part 4's CSG, SLT and ESC bullets in line,
|
||||
// lists the two MAJOR findings decision 122 keeps, the four MINOR and
|
||||
// what part A leaves open under ### Known issues, and its door under
|
||||
// ### Doors.
|
||||
// HDR tests/hooks/bash-guard-false-stops.test.mjs names its red base
|
||||
// 591f3c4. In the red clone this test passes at e2d6bec, because the
|
||||
// working tree's tests/ is copied in; its red is shown under a named
|
||||
// mutation that writes the old header back.
|
||||
// No static import of a new export: the file must load at e2d6bec.
|
||||
// Fix the SOURCE a pin guards, not the pin.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const readCache = new Map();
|
||||
|
||||
/** A repo file's text; the file must exist. Read from disk once per run. */
|
||||
function read(rel) {
|
||||
if (!readCache.has(rel)) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
|
||||
}
|
||||
return readCache.get(rel);
|
||||
}
|
||||
|
||||
/** Whitespace (line breaks too) collapsed to one space. */
|
||||
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// --- KIA: the round's CHANGELOG entry ----------------------------------------------
|
||||
// Part A stands in the same Unreleased section as parts 1 to 4. The section is
|
||||
// found by its title, by the rule tests/lib/fix-round-4-5b-part-4-prose.test.mjs
|
||||
// uses (copied, not imported): `## Unreleased — <title>` before the release,
|
||||
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
|
||||
|
||||
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
|
||||
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
|
||||
|
||||
/** The lines of the round's CHANGELOG section, heading excluded. */
|
||||
function changelogSection() {
|
||||
const lines = read('CHANGELOG.md').split('\n');
|
||||
const at = [];
|
||||
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
|
||||
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
|
||||
let end = lines.length;
|
||||
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
|
||||
return lines.slice(at[0] + 1, end);
|
||||
}
|
||||
|
||||
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
|
||||
function changelogBullets(heading) {
|
||||
const lines = changelogSection();
|
||||
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
|
||||
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
|
||||
const out = [];
|
||||
for (const l of lines.slice(s + 1)) {
|
||||
if (/^### /.test(l)) break;
|
||||
if (/^- /.test(l)) out.push(l);
|
||||
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
|
||||
else if (l.trim() !== '') out.push('');
|
||||
}
|
||||
return out.filter((b) => b !== '').map(flat);
|
||||
}
|
||||
|
||||
// Part A's seven Fixed bullets, each with the ref it must name: the four review
|
||||
// findings of part 4's review it fixes, the two operator decisions and the
|
||||
// false test header. The ids are the review's, written out here.
|
||||
const FIXED = [
|
||||
['(RXT)', '5f1cc34c'], ['(CNT)', '97d1f547'], ['(SFX)', '27b0b46a'], ['(ESR)', 'ed40abdb'],
|
||||
['(EVL)', 'decision 107'], ['(SIS)', 'decision 123'], ['(HDR)', '86488cd6'],
|
||||
];
|
||||
const FIXED_IDS = ['5f1cc34c', '97d1f547', '27b0b46a', 'ed40abdb'];
|
||||
const NEW_TAGS = FIXED.map(([tag]) => tag);
|
||||
// The two MAJOR findings decision 122 keeps open.
|
||||
const KEPT_IDS = ['943f9841', 'a6735ef9'];
|
||||
// The review's four MINOR findings, listed as open under decision 91.
|
||||
const MINOR_IDS = ['03df3b0b', '78040bd7', '49873813', '79402171'];
|
||||
|
||||
const DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
||||
const TWO_ESCAPED = 'two escaped `<` in a row';
|
||||
|
||||
test('[KIA] the CHANGELOG lists part A\'s seven fixes under ### Fixed, each with its ref', () => {
|
||||
const fixed = changelogBullets('### Fixed');
|
||||
|
||||
// Red: one ### Fixed bullet per part-A tag, each naming its ref.
|
||||
for (const [tag, ref] of FIXED) {
|
||||
const hits = fixed.filter((b) => b.includes(tag) && b.includes(ref));
|
||||
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${tag} naming ${ref}; found ${hits.length}`);
|
||||
}
|
||||
|
||||
// Control: no fixed finding is listed as open.
|
||||
const known = changelogBullets('### Known issues');
|
||||
for (const id of FIXED_IDS) {
|
||||
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[KIA] the two MAJOR findings decision 122 keeps stand under ### Known issues, one bullet each', () => {
|
||||
const known = changelogBullets('### Known issues');
|
||||
|
||||
// Red: each kept finding has exactly one Known-issues bullet, and that bullet names decision 122.
|
||||
const bullets = [];
|
||||
for (const id of KEPT_IDS) {
|
||||
const hits = known.filter((b) => b.includes(id));
|
||||
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
|
||||
assert.ok(hits[0].includes('decision 122'), `the Known-issues bullet naming ${id} does not say decision 122: ${hits[0]}`);
|
||||
bullets.push(hits[0]);
|
||||
}
|
||||
assert.notEqual(bullets[0], bullets[1], `${KEPT_IDS[0]} and ${KEPT_IDS[1]} must stand in two different Known-issues bullets`);
|
||||
|
||||
// Control: neither kept finding stands in a Fixed bullet marked with one of part A's tags.
|
||||
const fixed = changelogBullets('### Fixed');
|
||||
for (const id of KEPT_IDS) {
|
||||
const hits = fixed.filter((b) => b.includes(id) && NEW_TAGS.some((tag) => b.includes(tag)));
|
||||
assert.deepEqual(hits, [], `Control: ${id} must not stand in a ### Fixed bullet marked with one of ${NEW_TAGS.join(' ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[KIA] part 4\'s SLT bullet no longer promises a pass the guard refuses, and names the SIS exception', () => {
|
||||
const slt = changelogBullets('### Fixed').filter((b) => b.includes('(SLT)'));
|
||||
assert.equal(slt.length, 1, `### Fixed must hold exactly one bullet marked (SLT); found ${slt.length}`);
|
||||
|
||||
// Red: the old promise is gone.
|
||||
assert.ok(!slt[0].includes('pass again'), `the (SLT) bullet must not hold "pass again": ${slt[0]}`);
|
||||
|
||||
// It names the gaps of part 4's review, what is refused as before, and the SIS exception.
|
||||
for (const part of [...KEPT_IDS, 'refused as before', 'unless a backslash-newline splits']) {
|
||||
assert.ok(slt[0].includes(part), `the (SLT) bullet must hold ${JSON.stringify(part)}: ${slt[0]}`);
|
||||
}
|
||||
assert.ok(!slt[0].includes('is text and passes, so'), `the (SLT) bullet must not hold "is text and passes, so": ${slt[0]}`);
|
||||
});
|
||||
|
||||
test('[KIA] part 4\'s CSG and ESC bullets name the gaps part A fixes, ESC with the EVL exception', () => {
|
||||
const fixed = changelogBullets('### Fixed');
|
||||
|
||||
// Red: the CSG bullet names the split-delimiter gap.
|
||||
const csg = fixed.filter((b) => b.includes('(CSG)'));
|
||||
assert.equal(csg.length, 1, `### Fixed must hold exactly one bullet marked (CSG); found ${csg.length}`);
|
||||
assert.ok(csg[0].includes('27b0b46a'), `the (CSG) bullet must name review finding 27b0b46a: ${csg[0]}`);
|
||||
|
||||
// The ESC bullet names the whole-command limits gap and the EVL exception.
|
||||
const esc = fixed.filter((b) => b.includes('(ESC)'));
|
||||
assert.equal(esc.length, 1, `### Fixed must hold exactly one bullet marked (ESC); found ${esc.length}`);
|
||||
for (const part of ['ed40abdb', 'still open before the run', TWO_ESCAPED]) {
|
||||
assert.ok(esc[0].includes(part), `the (ESC) bullet must hold ${JSON.stringify(part)}: ${esc[0]}`);
|
||||
}
|
||||
for (const gone of ['no quote or heredoc operator before it', 'stands before the run']) {
|
||||
assert.ok(!esc[0].includes(gone), `the (ESC) bullet must not hold ${JSON.stringify(gone)}: ${esc[0]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[KIA] ### Known issues lists the four MINOR, decisions 107, 123 and 124, and part 2\'s bullet with its exceptions', () => {
|
||||
const known = changelogBullets('### Known issues');
|
||||
|
||||
// Red: each MINOR finding has exactly one Known-issues bullet, and that bullet names decision 91.
|
||||
for (const id of MINOR_IDS) {
|
||||
const hits = known.filter((b) => b.includes(id));
|
||||
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
|
||||
assert.ok(hits[0].includes('decision 91'), `the Known-issues bullet naming ${id} does not say decision 91: ${hits[0]}`);
|
||||
}
|
||||
|
||||
// What part A leaves open: one bullet per decision.
|
||||
const d107 = known.filter((b) => b.includes('decision 107'));
|
||||
assert.equal(d107.length, 1, `### Known issues must hold exactly one bullet naming decision 107; found ${d107.length}`);
|
||||
const d124 = known.filter((b) => b.includes('decision 124'));
|
||||
assert.equal(d124.length, 1, `### Known issues must hold exactly one bullet naming decision 124; found ${d124.length}`);
|
||||
for (const part of ['setopt', 'emulate', 'set -o', 'shopt', 'alias']) {
|
||||
assert.ok(d124[0].includes(part), `the decision 124 Known-issues bullet must hold ${JSON.stringify(part)}: ${d124[0]}`);
|
||||
}
|
||||
const d123 = known.filter((b) => b.includes('decision 123'));
|
||||
assert.equal(d123.length, 1, `### Known issues must hold exactly one bullet naming decision 123; found ${d123.length}`);
|
||||
|
||||
// Part 2's bullet keeps its pair and names the exceptions part A widens.
|
||||
const followed = known.filter((b) => b.includes(DELIM) && b.includes('stays blocked'));
|
||||
assert.equal(followed.length, 1, `### Known issues must hold one bullet holding ${DELIM} and "stays blocked"; found ${followed.length}`);
|
||||
for (const part of ['fix round 4.5b2 part A', TWO_ESCAPED]) {
|
||||
assert.ok(followed[0].includes(part), `part 2's Known-issues bullet must hold ${JSON.stringify(part)}: ${followed[0]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('[KIA] ### Doors holds one line for decision 91', () => {
|
||||
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 91'));
|
||||
assert.equal(doors.length, 1, `### Doors must hold exactly one bullet naming decision 91; found ${doors.length}`);
|
||||
});
|
||||
|
||||
// --- HDR: the false-stops test header -----------------------------------------------
|
||||
|
||||
test('[HDR] tests/hooks/bash-guard-false-stops.test.mjs names its red base 591f3c4', () => {
|
||||
const text = read('tests/hooks/bash-guard-false-stops.test.mjs');
|
||||
assert.ok(!text.includes('loads at 12bca8b'), 'tests/hooks/bash-guard-false-stops.test.mjs must not hold "loads at 12bca8b"');
|
||||
const hits = text.split('loads at 591f3c4').length - 1;
|
||||
assert.equal(hits, 1, `tests/hooks/bash-guard-false-stops.test.mjs must hold "loads at 591f3c4" exactly once; found ${hits}`);
|
||||
});
|
||||
162
tests/lib/fix-round-4-5b2-a-scripts.test.mjs
Normal file
162
tests/lib/fix-round-4-5b2-a-scripts.test.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// tests/lib/fix-round-4-5b2-a-scripts.test.mjs
|
||||
// The form of fix round 4.5b2 part A's shell scripts and mutation module (plan
|
||||
// .claude/projects/2026-10-31-guard-4-5b2-part-a, Step 5, 5d): the red
|
||||
// wrapper, the regression script, the bypass corpus, the end-to-end run and
|
||||
// the mutations.
|
||||
// Untagged on purpose and listed in no red wrapper's FILES: these checks are
|
||||
// not a part-A fix.
|
||||
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
|
||||
// bash 4 form;
|
||||
// - the red wrapper hands the shared red script exactly one --other-ids,
|
||||
// whose words are the 4-5a IDS= words, then part 1's, part 2's, part 3's
|
||||
// and part 4's, --ids RXT CNT SFX ESR EVL KIA HDR SIS, exactly the two
|
||||
// tagged test files, and no argument naming the keep file or its tag (run
|
||||
// from a temp root next to a stub red-round.sh that prints its arguments,
|
||||
// the method of tests/lib/red-script.test.mjs);
|
||||
// - with the 4-5a, the part-1, the part-2, the part-3 or the part-4 wrapper
|
||||
// lacking its IDS= line, the red wrapper exits 2 with a setup failure and
|
||||
// never runs the shared script;
|
||||
// - red-mutate.mjs list prints exactly the three part-A mutations.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
|
||||
const RED = 'tests/fix-round-4-5b2-a-red.sh';
|
||||
const REGRESSION = 'tests/fix-round-4-5b2-a-regression.sh';
|
||||
const CORPUS = 'tests/guard-bypass-corpus-4-5b2-a.sh';
|
||||
const E2E = 'tests/e2e-fix-round-4-5b2-a.sh';
|
||||
const MUTATIONS = 'tests/fix-round-4-5b2-a-mutations.mjs';
|
||||
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
|
||||
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
|
||||
const WRAP_C = 'tests/fix-round-4-5b-part-2-red.sh';
|
||||
const WRAP_D = 'tests/fix-round-4-5b-part-3-red.sh';
|
||||
const WRAP_E = 'tests/fix-round-4-5b-part-4-red.sh';
|
||||
const OLDER = [WRAP_A, WRAP_B, WRAP_C, WRAP_D, WRAP_E];
|
||||
const PARTA_IDS = ['RXT', 'CNT', 'SFX', 'ESR', 'EVL', 'KIA', 'HDR', 'SIS'];
|
||||
const PARTA_FILES = ['tests/hooks/bash-guard-4-5b2-a.test.mjs', 'tests/lib/fix-round-4-5b2-a-prose.test.mjs'];
|
||||
/** The shared script's options that take a value; every other argument after them is a test file. */
|
||||
const VALUE_OPTIONS = ['--base', '--ids', '--other-ids', '--mutations'];
|
||||
|
||||
/** A repo file's bytes; the file must exist. */
|
||||
function bytes(rel) {
|
||||
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
|
||||
return readFileSync(join(ROOT, rel));
|
||||
}
|
||||
|
||||
/** The words of a wrapper's one IDS='...' line. */
|
||||
function idsOf(rel) {
|
||||
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
|
||||
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
|
||||
const m = lines[0].match(/^IDS='([^']*)'$/);
|
||||
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
|
||||
return m[1].split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
test('part-A scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
|
||||
for (const rel of [RED, REGRESSION, CORPUS, E2E]) {
|
||||
const buf = bytes(rel);
|
||||
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
|
||||
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
|
||||
const text = buf.toString('utf8');
|
||||
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/]) {
|
||||
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
|
||||
const STUB = `#!/bin/bash
|
||||
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
|
||||
exit 0
|
||||
`;
|
||||
|
||||
/** A temp root with the part-A wrapper, the five older wrappers and the stub as tests/helpers/red-round.sh. */
|
||||
function stubRoot(base) {
|
||||
const dir = join(base, 'root');
|
||||
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
|
||||
for (const rel of [RED, ...OLDER]) writeFileSync(join(dir, rel), bytes(rel));
|
||||
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** The part-A wrapper run from a temp root: status, output and the arguments the stub saw. */
|
||||
function runWrapper(dir) {
|
||||
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
|
||||
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
|
||||
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
|
||||
}
|
||||
|
||||
/** The arguments that are neither an option nor an option's value: the test files. */
|
||||
function filesOf(argv) {
|
||||
const files = [];
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (VALUE_OPTIONS.includes(argv[i])) { i += 1; continue; }
|
||||
if (argv[i].startsWith('--')) continue;
|
||||
files.push(argv[i]);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test('part-A scripts: the red wrapper passes one --other-ids with the five older rounds\' ids in order, its own eight ids and its two tagged files', () => {
|
||||
const text = bytes(RED).toString('utf8');
|
||||
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
|
||||
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
|
||||
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
|
||||
|
||||
const base = mkdtempSync(join(tmpdir(), 'partA-red-wrap-'));
|
||||
try {
|
||||
const r = runWrapper(stubRoot(base));
|
||||
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
|
||||
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
|
||||
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
|
||||
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
|
||||
assert.deepEqual(other, OLDER.flatMap((rel) => idsOf(rel)),
|
||||
`${RED}: --other-ids is not the IDS= words of ${OLDER.join(', then ')}\n${r.out}`);
|
||||
const idsAt = r.argv.indexOf('--ids');
|
||||
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
|
||||
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PARTA_IDS, `${RED}: --ids is not ${PARTA_IDS.join(' ')}\n${r.out}`);
|
||||
assert.deepEqual(filesOf(r.argv), PARTA_FILES, `${RED}: the test files are not exactly ${PARTA_FILES.join(' and ')}\n${r.out}`);
|
||||
for (const a of r.argv) {
|
||||
assert.ok(!a.includes('-known') && !a.includes('KNA'), `${RED} passed an argument naming the keep file or its tag: ${a}\n${r.out}`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('part-A scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
|
||||
for (const missing of OLDER) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'partA-red-noids-'));
|
||||
try {
|
||||
const dir = stubRoot(base);
|
||||
const text = bytes(missing).toString('utf8');
|
||||
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
|
||||
const r = runWrapper(dir);
|
||||
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
|
||||
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
|
||||
assert.ok(r.stderr.includes(missing), `${RED}: the setup failure line does not name ${missing}\n${r.out}`);
|
||||
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('part-A scripts: red-mutate.mjs list prints exactly the three part-A mutations', () => {
|
||||
assert.ok(existsSync(join(ROOT, MUTATIONS)), `missing file ${MUTATIONS}`);
|
||||
const r = spawnSync(process.execPath, [join(ROOT, 'tests', 'helpers', 'red-mutate.mjs'), 'list', MUTATIONS],
|
||||
{ cwd: ROOT, encoding: 'utf8', timeout: 30000, env: childEnv() });
|
||||
assert.equal(r.status, 0, `red-mutate.mjs list ${MUTATIONS} exited ${r.status}:\n${r.stdout}${r.stderr}`);
|
||||
const lines = (r.stdout || '').split('\n').filter((l) => l !== '');
|
||||
assert.deepEqual(lines, ['RXT-try RXT', 'CNT-regex CNT', 'HDR-header HDR'],
|
||||
`red-mutate.mjs list ${MUTATIONS} does not print exactly the three part-A mutations:\n${r.stdout}${r.stderr}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue