Merge remote-tracking branch 'origin/main' into capy/gsta-91-flaky-batch

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
garrytan committed 2026-10-25 16:22:51 +00:00
commit fe11936f01
18 files changed
+553 -32

No files matched your search

+6 -1
View File
@@ -90,8 +90,13 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://cli.github.
# and "gnupg" was renamed to "gpg" on Ubuntu 24.04 anyway, so NodeSource's script
# fails before it can add its own repo. Direct tarball download is network-simpler
# (one host: nodejs.org) and doesn't touch apt at all.
ENV NODE_VERSION=22.20.0
# The hash comes from the release's signed SHASUMS256.txt, and the archive is
# checked before it is unpacked. scripts/ubicloud/setup-free-suite.sh pins the
# same version and hash (test/ci-image-cli-pin.test.ts keeps them equal).
ENV NODE_VERSION=22.23.4
ARG NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \
&& echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \
&& tar -xJ -C /usr/local --strip-components=1 --no-same-owner -f /tmp/node.tar.xz \
&& rm -f /tmp/node.tar.xz \
&& node --version \
+56
View File
@@ -22,6 +22,62 @@
- `scripts/harness-version.json` records `test/helpers/llm-judge.ts` as non-behavioral at HARNESS_VERSION 6: only a parse failure's message changes, before any score is parsed, so no case's verdict or pass-rate series changes.
- No threshold, trial count, case kind, rubric, judge request or prompt changed.
## [1.91.215.0] - 2026-10-25
**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr <n> --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way.
### Fixed
- **`ship/sections/handoff.md.tmpl`**: the receipt write carries `--out .gstack/tmp/ship-receipt.md`. The completion-message step drops `(\`pr: none\`)`, which was wrong (the block omits `pr`; only the `SHIP_RECEIPT:` line shows `pr=-`), and the "nothing precedes the identifiers" clause, which repeated "identifiers first". Ship's skeleton plus sections is 313,151 bytes, 13 below main and under its 313,281 cap; no cap or pin moved.
- **`bin/gstack-skill-start`**: an unattended `/ship` prints one status line, `UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md`. No other skill or session kind prints it, and no instruction block was added. The unattended block's `/ship` record sentence names the file for a run that did not push and the PR body only once a PR exists.
- **`docs/unattended.md`**: for `/ship`, read `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json` in the child's checkout when the run did not push, use `read --pr <n> --json` only once a PR exists, and in both cases compare the receipt's `head` to the branch SHA, because `read` does not check that the receipt is current.
### Tests
- `test/gstack-ship-receipt.test.ts` runs the handoff template's write command as written in a fixture repository, then reads the file back with `read --body`: `head` is HEAD, `session_kind` is `unattended` and there is no `pr`. It also checks that the template and the generated section carry `--out` and that `docs/unattended.md` names the same file with `read --body`.
- `test/gstack-skill-start.test.ts` checks that the `UNATTENDED_SHIP` line appears exactly once for an unattended `/ship`, alongside only the `unattended-session` block, and never for `/review`, `/qa`, `/autoplan` or for `/ship` in an interactive, spawned or headless session.
## [1.91.214.0] - 2026-10-25
**`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44).
### Fixed
- **`bin/gstack-brain-sync`**: `--publish-removals` writes the temp file paths into its EXIT/INT/TERM trap when it installs the trap (quoted with `printf %q`), so the cleanup and lock release work however the function ends. The drain's trap in `--once` also names locals, but every path in that function ends with `exit` while they are still in scope, so it's unchanged. The other trap sites (the drain's lock-only trap and `gstack-brain-restore`'s, which uses a global) don't have the bug.
### Tests
- `test/brain-sync-removals.test.ts`: a new case runs `--publish-removals`, then `--publish-removals --yes` twice (publishing, then nothing to publish) with a private `TMPDIR`. After each run, stderr has no `unbound variable`, the exit status is 0, `TMPDIR` is empty and the lock directory is gone. It failed before the fix.
## [1.91.213.0] - 2026-10-25
**The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run.
### Fixed
- **`.github/docker/Dockerfile.ci`**: `NODE_VERSION` is 22.23.4 (Michael Sumner's commit). The archive is checked against `NODE_SHA256`, the linux-x64 hash from the release's SHASUMS256.txt, whose signature verifies against the Node.js release keyring, before it is unpacked.
- **`scripts/ubicloud/setup-free-suite.sh`**: pins the same 22.23.4 and hash, and downloads the archive to a temporary file and verifies it instead of piping `curl` straight into `sudo tar`.
### Tests
- `test/ci-image-cli-pin.test.ts` gains four cases: Dockerfile.ci pins an exact Node version and a SHA-256, both files verify before they unpack, and `setup-free-suite.sh` pins the same version and hash as the image. On #3355's head alone, all four fail; on main, three fail.
- The Bun ordering check in `setup-free-suite.sh` now looks for `sha256sum -c` after the Bun download instead of anywhere in the file, because the Node check now comes first.
- The test names `Dockerfile.ci` as `join(__dirname, '..', '.github', …)`, the form pregate's literal scan resolves, so a change to the image now selects it (before, pregate refused a Dockerfile.ci-only change with `PREGATE_NO_LANE`).
### Gate integrity
- One accepted-input change to review: the Bun ordering check searches for its `sha256sum -c` from the Bun download onward. It now accepts one input it used to reject: a file with some other `sha256sum -c` before the Bun download and the Bun check after it, which is this file. The property it guards, Bun verified after download and before unpack, is unchanged. A file whose only `sha256sum -c` sits before the download still fails, and so does removing the Bun hash or moving it after `unzip` (negative controls run). No threshold, budget, timeout, trial count or case kind changed.
## [1.91.212.0] - 2026-10-25
**/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF
)"` form agents use most. Our commits on top of the contributor's scanner fix four problems found in review: the inline awk program made the whole hook a bash syntax error on bash 3.2 (macOS `/bin/bash`), so every Bash call failed the hook there; a newline after the message was dropped, so `-m x⏎truncate …` stopped asking; any `-m '…'` after a commit was masked, including another command's argument; and the heredoc form was never masked.
### Fixed
- **`careful/bin/mask-commit-message.awk`** (new, from the contributor's scanner): reads the command as one string and keeps its newlines. It splits at unquoted `;` `&` `|` `(` `)` and newlines (a redirection's `&` does not split) and masks `-m`, `-am`, `--message` and `--message=` only in a segment whose first word is `git` with a `commit` word after it. Single-quoted messages, double-quoted messages without `$`, backtick or backslash, and a double-quoted `$(cat <<'DELIM' … DELIM )` with a quoted delimiter are masked. Quotes, backslashes and comments are parsed as bash does. The command is left unmasked when it contains any other command, parameter or process substitution, a backtick, `$'…'`, a heredoc, an unterminated quote, or a pipe after a masked message (git prints the message on stdout).
- **`careful/bin/check-careful.sh`**: runs the program with `awk -f` from the hook's own directory, so bash 3.2 parses the hook again. A missing program or an awk error leaves the command unmasked. The HIGH deny tier, the PowerShell table and the obfuscation tripwire still read the unmasked command.
### Tests
- `test/hook-scripts.test.ts` keeps the contributor's 10 cases and adds 31. Masked (no prompt): every flag form, `git -C repo commit`, `--amend`, multi-line messages and four heredoc spellings (`'EOF'`, `"MSG"`, `<<-` with a tab, a quoted word in the body). Still asking: a command on the next line, a quote that spans a line break, another command's `-m`, `echo git commit -m '…'`, an escaped quote, a backslash-newline, a heredoc with an unquoted delimiter, a substitution that closes the message early, a backtick, `git commit -F - <<'EOF'`, `$'…'`, a pipe after the commit (direct, after `2>&1`, or from a subshell), a process substitution and a parameter expansion. A partial install without the awk file still asks.
- The same cases pass with mawk and the one-true-awk (`original-awk`, the awk macOS ships). In a `bash:3.2` container the hook as submitted in #3221 exits 2 with `unexpected EOF while looking for matching '"'` on every command; with this change it answers normally.
### Gate integrity
- One accepted-input change to review, in a safety hook: MEDIUM checks (and custom `careful-patterns.txt` rules) no longer see the masked text of git commit's own literal message. That is a weakening by design, limited to text that bash passes to git as a literal argument. The negative controls listed under Tests still ask. Commands that combine a commit with anything that could execute the message, or that the scanner cannot read with certainty, are checked unmasked, exactly as on main. No threshold, budget, timeout, trial count or case kind changed.
- Known limit, still a false positive as on main: `git commit -F - <<'EOF'` messages and commits after an environment assignment or `sudo` are not masked.
## [1.91.211.0] - 2026-10-25
**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed.
+1 -1
View File
@@ -1 +1 @@
1.91.211.0
1.91.215.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.211.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.215.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+1 -1
View File
@@ -1068,7 +1068,7 @@ subcmd_publish_removals() {
sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; }
gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; }
list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1
trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM
trap "rm -f $(printf '%q ' "$list" "$list.gone"); gstack_brain_lock_release" EXIT INT TERM
removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; }
n=$(grep -c . "$list" 2>/dev/null || true)
if [ "$n" -eq 0 ]; then
+2 -1
View File
@@ -322,6 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
# GSTACK_SESSION_KIND=spawned override must light this up too.
[ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true
[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true
# ---------------------------------------------------------------------------
# Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim
@@ -647,7 +648,7 @@ if [ "$_SESSION_KIND" = "unattended" ]; then
case "$SKILL_NAME" in
autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=<dir>\`." ;;
eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=<outdir>\`, where <outdir> is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in <outdir>." ;;
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr <n> --json\`)." ;;
ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr <n> --json\`) only once a PR exists." ;;
review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;;
qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;;
*) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;;
+27 -10
View File
@@ -224,6 +224,23 @@ esac
WARN=""
PATTERN=""
# Mask quoted -m/-am/--message argument content in git commit commands so that
# descriptive commit messages do not trigger false positives (#1060). The
# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a
# heredoc inside a command substitution, its quote characters are mis-parsed by
# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there.
# A missing program or an awk failure leaves the command unmasked.
CMD_MEDIUM="$CMD"
CMD_MEDIUM_LOWER="$CMD_LOWER"
_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk"
case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac
if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then
if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then
CMD_MEDIUM="$_cmd_masked"
CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]')
fi
fi
# --- PowerShell and cmd table (#3067) ---
# Runs on the PowerShell tool's command, and on the part of any command from a
# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
@@ -303,50 +320,50 @@ if [ -n "$_WIN_SCAN" ]; then
fi
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi
# DROP TABLE / DROP DATABASE
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
PATTERN="drop_table"
fi
# TRUNCATE
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
PATTERN="truncate"
fi
# git push --force / git push -f / plus-refspec force (git push origin +ref)
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
PATTERN="git_force_push"
fi
# git reset --hard
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git reset --hard discards all uncommitted changes."
PATTERN="git_reset_hard"
fi
# git checkout . / git restore .
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: discards all uncommitted changes in the working tree."
PATTERN="git_discard"
fi
# kubectl delete
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
PATTERN="kubectl_delete"
fi
# docker rm -f / docker system prune
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
PATTERN="docker_destructive"
fi
@@ -386,7 +403,7 @@ $_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
_PAT_RC=0
printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$?
[ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
if grep -qE -- "$_PAT" <<< "$CMD" 2>/dev/null; then
if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Project rule matched: $_PAT"
PATTERN="project_rule"
break
+125
View File
@@ -0,0 +1,125 @@
# mask-commit-message.awk — read by careful/bin/check-careful.sh (#1060).
# Prints the command with the quoted message argument of git commit
# (-m, -am, --message) replaced by an empty quoted string. The command is
# scanned as one string: newlines are kept, since bash runs each line as its
# own command, and quote state carries across lines.
#
# Only git commit's own arguments are masked: the scanner splits the command
# at unquoted ; & | ( ) and newlines, and masks a message flag only in a
# segment whose first word is git and that has a commit word after it. The &
# of a redirection (2>&1, &>) does not split. Any pipe after a masked message
# prints the command unchanged, because git prints the message on stdout.
# Quotes, backslashes and comments follow bash: a backslash escapes the next
# character outside single quotes, and nothing inside single quotes; an
# unquoted # starting a word runs to the end of its line. An unterminated
# quote or escape prints the command unchanged.
#
# A message written as "$(cat <<'EOF' ... EOF )" (a quoted delimiter, so the
# body is never expanded) is masked whole. Any other command, parameter or
# process substitution, backtick, $'...' string or heredoc changes how bash
# reads the quotes after it, so the command is printed unchanged.
# heredoc_msg S N I — S[I] opens a double-quoted "$(cat <<'DELIM' ... DELIM )"
# message: the index after its closing quote, else 0.
function heredoc_msg(s, n, i, p, rest, strip, delim, e, line) {
rest = substr(s, i + 1)
if (!match(rest, /^\$\(cat[ \t]+<<-?[ \t]*/)) return 0
strip = substr(rest, 1, RLENGTH) ~ /<<-/
p = i + 1 + RLENGTH
rest = substr(s, p)
if (match(rest, /^'[A-Za-z_][A-Za-z0-9_]*'/) || match(rest, /^"[A-Za-z_][A-Za-z0-9_]*"/)) delim = substr(rest, 2, RLENGTH - 2)
else if (match(rest, /^\\[A-Za-z_][A-Za-z0-9_]*/)) delim = substr(rest, 2, RLENGTH - 1)
else return 0
p += RLENGTH
while (p <= n && substr(s, p, 1) ~ /[ \t]/) p++
if (substr(s, p, 1) != "\n") return 0
p++
for (;;) {
if (p > n) return 0
e = index(substr(s, p), "\n")
if (!e) return 0
line = substr(s, p, e - 1)
if (strip) sub(/^\t+/, "", line)
p += e
if (line == delim) break
}
while (p <= n && substr(s, p, 1) ~ /[ \t\n]/) p++
if (substr(s, p, 2) != ")\"") return 0
return p + 2
}
function handle_msg(s, n, i, j, c, safe) {
if (i > n) return i
if (substr(s, i, 1) == "'") {
j = i + 1
while (j <= n && substr(s, j, 1) != "'") j++
if (j > n) return i
result = result "''"
return j + 1
}
if (substr(s, i, 1) == "\"") {
if ((j = heredoc_msg(s, n, i)) > 0) { result = result "\"\""; return j }
j = i + 1; safe = 1
while (j <= n && (c = substr(s, j, 1)) != "\"") {
if (c == "$" || c == "`" || c == "\\") safe = 0
j++
}
if (!safe || j > n) return i
result = result "\"\""
return j + 1
}
return i
}
# flag S I — length of the message flag (with its trailing blanks) at S[I], or 0
function flag(s, i, rest, m) {
rest = substr(s, i)
if (rest ~ /^--message=/) return 10
if (match(rest, /^(--message|-am|-m)[ \t]+/)) return RLENGTH
return 0
}
{ cmd = (NR > 1) ? cmd "\n" $0 : $0 }
END {
s = cmd
n = length(s); sq = 0; dq = 0; bs = 0; i = 1
atword = 1; words = 0; git = 0; commit = 0; masked = 0; piped = 0; unsure = 0
while (i <= n) {
c = substr(s, i, 1)
if (bs) { result = result c; bs = 0; i++; continue }
if (sq) { if (c == "'") sq = 0; result = result c; i++; continue }
two = substr(s, i, 2)
if (c == "`" || two == "$(" || two == "${") unsure = 1
if (dq) {
if (c == "\\") bs = 1
else if (c == "\"") dq = 0
result = result c; i++; continue
}
if (two == "<<" || two == "$'" || two == "<(" || two == ">(") unsure = 1
if (atword && c == "#") {
while (i <= n && substr(s, i, 1) != "\n") { result = result substr(s, i, 1); i++ }
continue
}
if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { atword = 0; result = result c; i++; continue }
if (c ~ /[;&|()\n]/) {
if (c == "|" && masked && substr(s, i + 1, 1) != "|" && substr(s, i - 1, 1) != "|") piped = 1
atword = 1; words = 0; git = 0; commit = 0
result = result c; i++; continue
}
if (c == " " || c == "\t") { atword = 1; result = result c; i++; continue }
if (atword) {
atword = 0; words++
rest = substr(s, i)
if (words == 1 && rest ~ /^git([ \t\n;&|()]|$)/) git = 1
else if (git && !commit && rest ~ /^commit([ \t\n;&|()]|$)/) commit = 1
else if (commit && (len = flag(s, i)) > 0) {
result = result substr(s, i, len); i += len
j = handle_msg(s, n, i)
if (j > i) masked = 1
atword = (j == i); i = j
continue
}
}
if (c == "\\") bs = 1
else if (c == "'") sq = 1
else if (c == "\"") dq = 1
result = result c; i++
}
printf "%s", (sq || dq || bs || piped || unsure) ? s : result
}
+8 -3
View File
@@ -88,9 +88,14 @@ Not every workflow writes this line or a run directory today:
helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate
list; it does not create one). `/cso` skips the shared startup, so these
session rules do not reach it at all. Read each skill's own record instead:
the ship receipt in the PR body (`gstack-ship-receipt read --pr <n> --json`)
for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, and the
`qa-report-*.md` file for `/qa`.
the review-log rows (`gstack-review-read`) for `/review`, the
`qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When
the run did not push (an unattended run must not), read the receipt file in
the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`;
use `gstack-ship-receipt read --pr <n> --json` only once a PR exists. In both
cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in
the child's checkout): `read` does not check that the receipt is current, so
an older PR's receipt reads as valid.
Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage,
`3` refused or needs a flag. Error lines end with a result code in parentheses,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.211",
"version": "1.91.215",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+8 -3
View File
@@ -5,7 +5,8 @@
set -euo pipefail
BUN_VERSION=1.4.2
NODE_VERSION=22.20.0
NODE_VERSION=22.23.4
NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce
export DEBIAN_FRONTEND=noninteractive
sudo -E apt-get update -qq
@@ -17,9 +18,13 @@ sudo -E apt-get install -y -qq --no-install-recommends \
# needs; GitHub-hosted runners ship with this relaxed.
sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0
# The same verified release archive as .github/docker/Dockerfile.ci.
if ! command -v node >/dev/null; then
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
| sudo tar -xJ -C /usr/local --strip-components=1
node_tmp=$(mktemp -d)
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"
echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c - >/dev/null
sudo tar -xJ -C /usr/local --strip-components=1 -f "$node_tmp/node.tar.xz"
rm -rf "$node_tmp"
fi
# The exact release archive, verified by SHA-256 before it is unpacked, as in
+3 -3
View File
@@ -10,16 +10,16 @@ automation lane, an unattended parent said so). Nothing here publishes.
section does, then (with its Issue links flags; ask nothing):
```bash
~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`),
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
cap; `before/after:` one row per claimed metric, measured on base and on this
SHA with the command used; `escalation:` the one condition that stops the merge
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
### Multi-lane runs
+3 -3
View File
@@ -8,16 +8,16 @@ automation lane, an unattended parent said so). Nothing here publishes.
section does, then (with its Issue links flags; ask nothing):
```bash
~/.claude/skills/gstack/bin/gstack-issue-links --base <base> --out .gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json
~/.claude/skills/gstack/bin/gstack-ship-receipt write --base <base> --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md
```
2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:`
line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`),
line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`),
pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend
cap; `before/after:` one row per claimed metric, measured on base and on this
SHA with the command used; `escalation:` the one condition that stops the merge
(a `requires-remote` lane, an open gate finding, an unverified claim) or `none`.
`issues:` its lines, unpromoted candidates and lookup failures.
Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers.
Full thread ids (`jam_…`) cross-thread.
3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied.
### Multi-lane runs
+20 -2
View File
@@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..');
let home: string;
let remote: string;
function run(argv: string[]) {
function run(argv: string[], extraEnv: Record<string, string> = {}) {
const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), {
env: { ...process.env, HOME: home, GSTACK_HOME: home },
env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv },
encoding: 'utf-8',
timeout: 60_000,
});
@@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => {
expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish');
});
test('--publish-removals cleans up its temp files and lock on every exit path', () => {
syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']);
fs.rmSync(path.join(home, 'projects/p/designs/a.md'));
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-'));
try {
for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) {
const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp });
expect(r.stderr).not.toContain('unbound variable');
expect(r.status).toBe(0);
expect(fs.readdirSync(tmp)).toEqual([]);
expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false);
}
expect(remoteFiles()).toEqual(['projects/p/designs/b.md']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('config rejects values other than on and off', () => {
const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']);
expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized");
+42 -2
View File
@@ -24,7 +24,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(__dirname, '..');
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
const DOCKERFILE = path.join(__dirname, '..', '.github', 'docker', 'Dockerfile.ci');
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
export function globalNpmInstallSpecs(source: string): string[] {
@@ -193,7 +193,7 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
test(`${rel} downloads the verified linux-x64 archive instead of piping an installer into a shell`, () => {
expect(curlPipedToShell(source)).toEqual([]);
const download = source.indexOf('/releases/download/bun-v$BUN_VERSION/bun-linux-x64.zip');
const verify = source.indexOf('sha256sum -c');
const verify = source.indexOf('sha256sum -c', download);
const unpack = source.indexOf('unzip -q');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
@@ -216,3 +216,43 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => {
});
}
});
describe('CI Node.js installs match the image and are verified (#3354)', () => {
const docker = fs.readFileSync(DOCKERFILE, 'utf-8');
const imageVersion = docker.match(/^ENV NODE_VERSION=(\S+)$/m)?.[1];
const imageSha = docker.match(/^ARG NODE_SHA256=(\S+)$/m)?.[1];
const nodeRun = logicalLines(docker).find((line) => line.includes('nodejs.org/dist/v${NODE_VERSION}/')) ?? '';
test('Dockerfile.ci pins an exact Node.js version and the SHA-256 of its linux-x64 archive', () => {
expect(imageVersion).toMatch(/^\d+\.\d+\.\d+$/);
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
});
test('Dockerfile.ci checks the Node.js archive before it is unpacked', () => {
expect(nodeRun, 'Node.js must come from the nodejs.org release archive').not.toBe('');
const download = nodeRun.indexOf('-o /tmp/node.tar.xz');
const verify = nodeRun.indexOf('echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -');
const unpack = nodeRun.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
const rel = 'scripts/ubicloud/setup-free-suite.sh';
const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
test(`${rel} pins the same Node.js version and SHA-256 as Dockerfile.ci`, () => {
expect(imageSha).toMatch(/^[0-9a-f]{64}$/);
expect(source.match(/^NODE_VERSION=(\S+)$/m)?.[1]).toBe(imageVersion!);
expect(source.match(/^NODE_SHA256=(\S+)$/m)?.[1]).toBe(imageSha!);
});
test(`${rel} checks the Node.js archive before it is unpacked`, () => {
const download = source.indexOf('/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"');
const verify = source.indexOf('echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c -');
const unpack = source.indexOf('tar -xJ');
expect(download).toBeGreaterThan(-1);
expect(verify).toBeGreaterThan(download);
expect(unpack).toBeGreaterThan(verify);
});
});
+26
View File
@@ -119,6 +119,32 @@ describe('gstack-ship-receipt read', () => {
expect(runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '9'], env).status).toBe(EXIT.fail);
expect(runBin(repo, 'gstack-ship-receipt', ['read']).status).toBe(EXIT.usage);
});
test('the unattended handoff write lands in the file docs/unattended.md tells the parent to read --body, at HEAD and with no pr (GSTA-88)', () => {
const root = path.resolve(import.meta.dir, '..');
const tmpl = fs.readFileSync(path.join(root, 'ship/sections/handoff.md.tmpl'), 'utf8');
const write = tmpl.match(/gstack-ship-receipt (write [^\n`]*)/)![1].trim();
const out = write.match(/--out (\S+)/)?.[1];
expect(out).toBe('.gstack/tmp/ship-receipt.md');
expect(fs.readFileSync(path.join(root, 'ship/sections/handoff.md'), 'utf8')).toContain(write);
const docs = fs.readFileSync(path.join(root, 'docs/unattended.md'), 'utf8').replace(/\s+/g, ' ');
expect(docs).toContain(`gstack-ship-receipt read --body ${out} --json`);
expect(docs).toMatch(/read --pr <n> --json` only once a PR exists/);
const repo = fixture();
const env = { GSTACK_SESSION_KIND: 'unattended' };
fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true });
fs.writeFileSync(path.join(repo.work, '.gstack/tmp/ship-gate.json'), '{"free":{"pass":1,"fail":0,"skip":0}}');
expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', '.gstack/tmp/issues.json'], env).status).toBe(EXIT.ok);
const w = runBin(repo, 'gstack-ship-receipt', write.replace('<base>', 'main').split(' '), env);
expect(w.status).toBe(EXIT.ok);
expect(fs.readFileSync(path.join(repo.work, out!), 'utf8')).toBe(w.out);
const r = runBin(repo, 'gstack-ship-receipt', ['read', '--body', out!, '--json'], env);
expect(r.status).toBe(EXIT.ok);
const receipt = JSON.parse(r.out);
expect(receipt).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), base: 'main', session_kind: 'unattended' });
expect(receipt).not.toHaveProperty('pr');
});
});
describe('gstack-ship-receipt census (P7)', () => {
+14
View File
@@ -418,6 +418,20 @@ describe('gstack-skill-start behavior', () => {
}
}, 60_000);
test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file (GSTA-88)', () => {
const line = /^UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md$/gm;
const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' });
expect(ship.match(line)).toHaveLength(1);
expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']);
expect(ship).toContain('.gstack/tmp/ship-receipt.md when nothing was pushed');
for (const skill of ['review', 'qa', 'autoplan']) {
expect(runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' })).not.toContain('UNATTENDED_SHIP');
}
for (const kind of ['interactive', 'spawned', 'headless']) {
expect(runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: kind })).not.toContain('UNATTENDED_SHIP');
}
}, 60_000);
test('unattended session makes no egress call even with telemetry and artifacts sync enabled and a queue pending (plan B1)', () => {
// Shadow bin: every helper is the real one except the egress sinks, which
// record their invocation. Bins resolve siblings through dirname "$0", so a
+209
View File
@@ -342,6 +342,215 @@ describe('check-careful.sh', () => {
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// --- Commit message false positives (#1060) ---
describe('commit message false positives (#1060)', () => {
// Quoted -m content describes destructive patterns but does not run them.
test.each([
'git commit -m "fix: prevent accidental drop table in migration"',
'git commit -m "docs: explain truncate vs delete semantics"',
'git commit -m "feat: add guard for recursive delete patterns"',
'git add . && git commit -m "fix: handle drop database edge case"',
])('does not warn on descriptive text in commit message: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Real destructive commands chained with a commit still warn.
test('warns when a real DROP TABLE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "release" && psql -c "DROP TABLE staging"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
});
test('warns when a real TRUNCATE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "cleanup" && psql -c "TRUNCATE audit_log"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL TRUNCATE');
});
// Quote-aware awk scanner: a single-quoted message whose content looks like
// a flag boundary must not swallow real commands that follow via &&.
test('warns when rm follows a single-quoted message that embeds a double-quote', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput(`git commit -m 'note -m "' && rm -rf build && echo '"'`),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A double-quoted message containing $(...) is not masked — the substitution
// content remains visible to the MEDIUM checks.
test('warns when a commit message contains a command substitution with rm', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "$(rm -rf build)"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A force-push chained after a commit must still warn even after the message is masked.
test('warns on force-push chained after git commit', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "x" && git push --force'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
});
// -am shorthand: the message is masked, so "drop table" in the message text
// does not trigger the SQL DROP warning.
test('does not warn on drop-table text in -am commit message', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -am "drop table notes"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// bash 3.2 (macOS /bin/bash) mis-parses quote characters in a heredoc
// inside $( ), so the awk program inline made the whole hook a syntax error
// there. It is a file beside the hook, read with awk -f.
test('the masking program is a file beside the hook, not a heredoc inside $( )', () => {
const source = fs.readFileSync(CAREFUL_SCRIPT, 'utf8');
expect(source).not.toMatch(/\$\(cat <</);
expect(source).toContain('awk -f "$_MASK_AWK"');
expect(fs.existsSync(path.join(path.dirname(CAREFUL_SCRIPT), 'mask-commit-message.awk'))).toBe(true);
});
// Newlines separate commands, and quote state carries across them. The
// per-line scanner joined lines (`-m xtruncate`) and restarted quote
// state on every line, so a quoted line break could hide a real command.
test.each([
['git commit -m x\ntruncate -s 0 f', 'SQL TRUNCATE'],
['git commit -m wip\nrm -rf build', 'recursive delete'],
["git commit -m \"a\n-m 'x\" && rm -rf build && echo '", 'recursive delete'],
])('a command on the next line still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test('a multi-line quoted message is masked as one message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git commit -m "fix: guard migrations\n\nNo more drop table in the seed script."'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Only git commit's own message is masked: a -m argument of another
// command in the chain, a commit whose output is piped into a shell, and
// an escaped quote inside the message all stay visible.
test.each([
["git commit -m a && ls | parallel -m 'rm -rf build'", 'recursive delete'],
["git commit -m 'rm -rf build' | sh", 'recursive delete'],
["echo git commit -m 'drop table users'", 'SQL DROP'],
['git commit -m "a\\"b" && rm -rf build', 'recursive delete'],
["git commit -m wip \\\n&& rm -rf build", 'recursive delete'],
])('text outside commit\'s own message still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test.each([
'git -C repo commit --message="docs: truncate the changelog intro"',
"git commit --message 'chore: drop database fixtures from the seed'",
"git commit --amend -m 'fix: git reset --hard in the docs'",
])('commit\'s own message is masked in every flag form: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// The form agents use most: a quoted-delimiter heredoc inside "$( )". Its
// body is never expanded, so it is masked whole. Any other substitution,
// backtick, $'...' or heredoc leaves the command unmasked.
const heredocMessage = (body: string, delimiter = "'EOF'", end = 'EOF') =>
`git commit -m "$(cat <<${delimiter}\n${body}\n${end}\n)"`;
test.each([
heredocMessage('fix: guard the migration that could drop table users\n\nCo-Authored-By: A <a@example.com>'),
heredocMessage('docs: why we never truncate "audit" rows (see #12)'),
heredocMessage('chore: git reset --hard is documented', '"MSG"', 'MSG'),
heredocMessage('chore: rm -rf build in the clean script', "-'EOF'", '\tEOF'),
])('a quoted-delimiter heredoc message is masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test.each([
[heredocMessage('docs: drop table notes') + ' && git push --force', 'force-push'],
[heredocMessage('drop table $(rm -rf build)', 'EOF'), 'recursive delete'],
[`git commit -m "$(echo " -m 'x" ; rm -rf build ; echo ')"`, 'recursive delete'],
['git commit -m "\`rm -rf build\`"', 'recursive delete'],
["git commit -F - <<'EOF'\ndocs: drop table notes\nEOF", 'SQL DROP'],
["git commit -m $'drop table notes\\n'", 'SQL DROP'],
])('other substitutions and heredocs stay unmasked: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// git prints the message on stdout, so any pipe after a masked message,
// a process substitution or a parameter expansion leaves it unmasked; a
// redirection's & is not a separator, and a comment's quote is not a quote.
test.each([
"git commit -m 'rm -rf build' 2>&1 | sh",
"(git commit -m 'rm -rf build') | sh",
"git commit -m 'rm -rf build' > >(sh)",
'git commit -m "${MSG:-rm -rf build}"',
])('a message that can reach a shell stays unmasked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
test.each([
"git commit -m 'drop table notes' 2>&1 && git log -1 --oneline",
"git commit -m 'drop table notes' || echo failed",
"git commit -m 'drop table notes' # it's the docs commit",
])('redirections, || and comments keep the message masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test('a partial install without the masking program still asks on the message text', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nomask-'));
try {
for (const file of ['check-careful.sh', 'hook-extract.sh']) {
fs.copyFileSync(path.join(path.dirname(CAREFUL_SCRIPT), file), path.join(dir, file));
}
const { exitCode, output } = runHook(path.join(dir, 'check-careful.sh'), carefulInput('git commit -m "drop table notes"'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// --- Shell obfuscation ---
describe('shell obfuscation', () => {