Compare commits

...
Author SHA1 Message Date
jason.woltje 794f4c4a24 docs: row 211 R5 records for the busy check fixes (#1709, #426)
- BUILD.md: an R5 section covering N1 to N3, the fixes, the tests, the
  RED and GREEN counts, the 57-mutant table, the 13-shape live probe,
  the false BUSY and miss checks, and the known limits after R5. N3
  corrects the R4 bind-mount note: a same-filesystem bind mount keeps
  st_dev, so a detector needs mount metadata such as
  /proc/self/mountinfo. No code changed for N3. Earlier sections are
  untouched.
- docs/TOOLS.md: the busy check's flat form, span rule, exit 2 on text
  it can't classify, the literal listing, and 153 cases.
- BUILD-LOG.md and docs/SESSIONS.md: one appended entry each.
2026-11-02 17:53:36 -06:00
jason.woltje afc176de02 fix(scripts): row 211 R5 busy check literal listing, flat form, sourced word walk (#1709)
From the GPT review of R4 (row211-r4-gpt.md, N1 and N2), for #1709 and #426:

- slot-busy.sh (N1): gone() lists the proc root with
  ls -f -1 --quoting-style=literal, so QUOTING_STYLE can't quote a pid
  out of the listing, and the script exports LC_ALL=C before any tool
  runs. A failed or denied listing is still never gone.
- slot-busy.sh (N2): a command line holding quotes, backslashes, shell
  operators or uneven whitespace gets a flat form, made in awk. The
  snapshot rule reads the flat form. The general rules read the flat
  form, then the raw text. A wrapper's text goes through an awk word
  walk: every word of a source or . span counts, quoted text is read
  again, and the wrapper's own snapshot is skipped. More than 32 walk
  levels, an awk failure or bad output is exit 2, "cannot classify".
- test-git-guard.sh: 48 new cases, 153 in all. The new cases cover
  QUOTING_STYLE, the locale, the rule shapes, depth, awk failure, and
  live wrappers for the review's 13 shapes plus a CLEAR control.
- mutants.sh: sites R5 moved are updated, and 27 new mutants bring the
  total to 57, all killed.

RED 119/34 on fd1aa8cc8's slot-busy.sh, GREEN 153/0. The hook and the
installer are unchanged.
2026-11-02 17:53:26 -06:00
jason.woltje fd1aa8cc81 fix(scripts): row 211 R4 busy check ancestor reads, denied lookups, sourced tests (#1709)
From the GPT review of R3 (row211-r3-gpt.md, R1 to R3 and Check 5):

- slot-busy.sh: every ancestor status read must give a numeric PPid. A
  failed ancestor or cmdline read counts as gone only when a listing of
  the proc root succeeds and lacks the pid; anything else is exit 2 with
  no CLEAR. The [ -d ] and [ -e ] tests, which can't tell a denied lookup
  from a missing one, are gone.
- slot-busy.sh: a shell-snapshot wrapper whose text sources (source or .)
  a gate, test or mutant script is BUSY; one that only runs children is
  still skipped.
- install-git-hooks.sh: the hook is staged in a mktemp file in the hooks
  directory, not a pid-named path.
- mutants.sh: exit 1 on a survivor, NO MATCH, COPY FAILED or a timeout;
  nine new mutants, 30 of 30 killed.

test-git-guard.sh: RED 90/14, GREEN 105/0. BUILD.md R4 has the details,
including a correction to the R3 bind-mount note.
2026-11-02 14:11:47 -06:00
jason.woltje 7d03f212db fix(scripts): row 211 R3 push guard tags, installer hooks dir, busy check errors (#1709) 2026-11-02 13:50:11 -06:00
jason.woltje 3a121885cf row 211 (#1709): BUILD.md records the r2 rebase onto 78b69e1e6 (sage-4362); same patch-id, no conflict; doc only
(cherry picked from commit 80924f7d5d)
2026-11-02 13:50:11 -06:00
jason.woltje bcd74ec33d row 211 (#1709, #426): a pre-push guard that refuses deletes and non-fast-forward pushes, its installer, and slot-busy.sh that exits 1 on BUSY
scripts/git-hooks/pre-push refuses a delete, a remote tip missing locally and
any update whose remote tip isn't an ancestor of the local tip; a new branch
and a fast-forward pass. scripts/install-git-hooks.sh puts it in the common
.git/hooks, refuses a set core.hooksPath or a different hook without
--force-install, and runs a canary through git hook run. scripts/slot-busy.sh
exits 0 CLEAR, 1 BUSY, 2 error. scripts/test-git-guard.sh (61 cases) and the
mutant runner in agents/darkwing/work/queue-211; TOOLS.md gets the usage.
Not run yet: written during Gander's slot (sage-2801).

(cherry picked from commit 20e885dc35)
2026-11-02 13:50:11 -06:00
9 changed files with 2256 additions and 0 deletions

No files matched your search

+62
View File
@@ -5816,3 +5816,65 @@ Before: agents can read the library; supporting-path and push-credential depende
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
## 2026-11-02T19:39:18Z: row 211 (#1709, #426) R3, push guard and busy check fixes from the GPT review
Before: row 211 as picked onto 09a847eda for the lead's landing batch (5734c1a80, c8f2cb70c on
`t3/row211-push-guard-r3-fix`). The GPT review (batch1-a-gpt.md) found five gaps: slot-busy.sh read a
failed cmdline read as an exited process and didn't check its own status read; its shell-snapshot
exclusion matched a substring; the pre-push hook let an existing tag be re-made or moved to a descendant;
the installer wrote through a symlinked hooks directory; the snapshot fixture didn't kill its mutant. The
hook also took hex object names of any length.
After: one R3 commit by the lead's implementation sub-agent. An unreadable live process makes slot-busy.sh
exit 2 and never print CLEAR, and an exited one is still skipped. The exclusion matches only the wrapper's
shape. Any change to an existing `refs/tags/*` ref is refused. The installer refuses a symlinked hooks
directory, or one that resolves elsewhere, before any write. Object names must be 40 or 64 hex
characters. test-git-guard.sh: RED 67 passed, 18 failed (19:27:01Z to 19:27:06Z), GREEN 85 passed, 0
failed (19:28:58Z to 19:29:04Z). The run before GREEN gave 80/5 because the new error text said "not
CLEAR"; it was reworded. Mutants 21 of 21 killed (19:30:11Z to 19:33:22Z, base green). No 64-hex test
pin names a changed path. Not pushed. Gaps: the installer's resolved-path refusal has no test of its own
(it needs a bind mount); details in agents/darkwing/work/queue-211/BUILD.md, section "R3".
## 2026-11-02T20:11:25Z: row 211 (#1709, #426) R4, busy check ancestor reads, denied lookups and sourced tests
Before: row 211 R3 as picked onto 09a847eda (bcd74ec33, 3a121885c, 7d03f212d on
`t3/row211-push-guard-r4-fix`). The GPT review of R3 (row211-r3-gpt.md) found three ways slot-busy.sh could
print CLEAR in doubt: a failed ancestor status read ended the walk without an error; `[ -e ]` read a denied
lookup as an exited process; and a shell-snapshot wrapper that sourced a test into its own shell, with no
child, was skipped. It also noted that mutants.sh exited 0 after a survivor, that the installer's temporary
file had a pid name, and that the R3 note on bind mounts was wrong.
After: one R4 commit by the lead's implementation sub-agent. Every ancestor read must give a numeric PPid;
a failed read, or a failed cmdline read, counts as gone only when a listing of the proc root succeeds and
lacks the pid, and anything else is exit 2 with no CLEAR. A wrapper whose text sources (`source` or `.`) a
gate, test or mutant script is BUSY; a wrapper that only runs children is still skipped. mutants.sh exits 1
on a survivor, NO MATCH, COPY FAILED or timeout (shown by hand in TMPDIR: survived exit 1, NO MATCH exit 1).
The installer stages the hook in a mktemp file. test-git-guard.sh: RED 90 passed, 14 failed (19:57:25Z to
19:57:37Z); the first GREEN gave 101/3 (20:01:11Z to 20:01:25Z), from a BASH_REMATCH reset in the new
sourced() that a GREEN-time case now pins; GREEN 105 passed, 0 failed (20:02:40Z to 20:02:49Z). Mutants 30
of 30 killed, runner exit 0 (20:02:56Z to 20:06:58Z, base green). No 64-hex test pin names a changed path.
Not pushed. Gaps: lexical false BUSY and missed runtime-built sourced paths, and an undetected bind-mounted
hooks directory; details in agents/darkwing/work/queue-211/BUILD.md, section "R4".
## 2026-11-02T23:53:07Z: row 211 (#1709, #426) R5, busy check listing, quoting and spacing
Before: row 211 R4 (fd1aa8cc8 on `t3/row211-push-guard-r4-fix`, over 09a847eda). The GPT review of R4
(row211-r4-gpt.md) found that gone() parsed `ls -f`, whose names follow `QUOTING_STYLE`, so under
`shell-always` or `c` a failed read of a listed pid became CLEAR (N1). It also found that seven of 13 live
shell-snapshot wrappers that source a test, with no child, gave CLEAR: a quoted path with spaces, a quoted
`source`, `;` with no blank, a quoted snapshot, a tab and two spaces (N2). And the R4 bind-mount note named
st_dev, which a same-filesystem bind mount keeps (N3).
After: two R5 commits by the lead's implementation sub-agent, code and tests, then records. gone() lists with
`ls -f -1 --quoting-style=literal`, the script exports `LC_ALL=C`, and a failed listing is still never gone.
A command line holding quotes, backslashes, shell operators or uneven whitespace gets a flat form (awk). The
snapshot rule reads the flat form, the general rules read it and then the raw text, and a wrapper's text goes
through an awk word walk. In the walk, every word of each `source` or `.` span counts, and quoted text is read
again. Text the walk can't classify (over 32 levels, an awk failure) is exit 2. test-git-guard.sh: RED 119
passed, 34 failed (23:32:37Z to 23:33:12Z); GREEN 153 passed, 0 failed (23:33:23Z to 23:33:35Z). Mutants 57
of 57 killed, runner exit 0 (23:33:53Z to 23:46:34Z, base green). Live probes of the 13 shapes
(23:46:56Z to 23:47:18Z): R4 CLEAR on 7 to 13, R5 BUSY on all 13. No 64-hex test pin names a changed path.
Not pushed. Gaps: new lexical false BUSY (span arguments, redirects, `find .`, parentheses), still-missed
runtime-built and ANSI-C sourced paths, and an undetected bind-mounted hooks directory; details in
agents/darkwing/work/queue-211/BUILD.md, section "R5".
+954
View File
@@ -0,0 +1,954 @@
# Row 211 (#1709, Vikunja P19 #426): a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-20_force-push-guard.md` (blob 3a8c2cf13). Moved from Rocko
to me by sage-2801 (Q220, mos-1301). Base: 78b69e1e6 (r2; r1 was aca16404c). Branch: `darkwing/row211-r2`
(r1 `darkwing/row211` 6db25b1f0 stays as pushed). Reviewer: Dewey. Gate owner: Sage.
## What ships
- `scripts/git-hooks/pre-push` (new, `/bin/sh`). For each ref line on stdin it refuses:
- a delete (local sha all zeros);
- a remote tip that isn't in this repository (`git cat-file -e <remote>^{commit}`), with "fetch first,
then push again";
- a remote tip that isn't an ancestor of the local tip (`git merge-base --is-ancestor`), naming the ref
and both shas;
- an ancestor check that fails for any other reason (exit above 1);
- a short or overlong ref line, or a non-hex object name.
A new ref (remote sha all zeros) passes, and so does an empty push. It fetches nothing. On any refusal
it prints one line per refused ref and a closing line, and exits 1, so git pushes no ref at all.
- `scripts/install-git-hooks.sh` (new). Installs the hook as `pre-push` in the common `.git/hooks`
(`git rev-parse --git-common-dir`), so a `--repo` naming a worktree lands in the shared directory.
It refuses (exit 2):
- a set `core.hooksPath` at any scope;
- a different installed `pre-push`, unless `--force-install` is given;
- a symlinked `pre-push`, even with `--force-install`;
- any of `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY` or
`GIT_ALTERNATE_OBJECT_DIRECTORIES` in the environment;
- a directory that isn't a git checkout.
It never writes the installed file in place. A new hook goes in by `link()` from a temporary copy, so
a hook that appeared meanwhile is kept and the install refuses. A replacement goes in by `rename()`.
Then it compares the bytes, checks the mode, and runs a canary through `git hook run` (git 2.55 here):
an empty push must pass and a delete line must be refused with the guard's message.
- `scripts/slot-busy.sh` (new). Exit 0 CLEAR, 1 BUSY, 2 its own error, one `BUSY <pid> <rule> cwd=<cwd>
<command>` line per match. Rules: `node ... --test`, `*gate*.sh` (after a space, `/`, `_` or `-`), a
`mutant` substring, `test-*.sh`, `docker compose` and `docker-compose`. It skips its own pid and its
ancestors, empty command lines (kernel threads) and shell-snapshot wrappers.
- `scripts/test-git-guard.sh` (new), 61 cases, and three Maintenance rows plus a short section in
`docs/TOOLS.md`.
## Choices
1. **A separate `scripts/install-git-hooks.sh`, not `queue-commit.sh --install-push-hook`.** The brief
left this open. `queue-commit.sh --install-hook` is the queue guard's installer. It is tied to the
canonical checkout and to the lead (jason/sage) and runs the queue guard's canary. The push guard has
neither tie: it must also be installable in a scratch clone for the tests, and every seat worktree
shares it. Putting it in `queue-commit.sh` would widen that script's reach for no gain. The cost is a
second installer with a similar shape. The install pattern (copy beside, link or rename, verify,
canary) follows `queue-commit.sh`.
2. **No privilege check in the installer.** The brief makes the canonical install a separate step with
Sage after landing, and the tests need to install into scratch clones as the suite's user. So the
script installs wherever it is told. The guard is advisory on the client in any case (`--no-verify`).
3. **The hook source is the working file next to the installer** (`$ROOT/scripts/git-hooks/pre-push`),
not a blob from a ref. That lets the mutant runner install a mutated copy from its own tree, and
matches how `queue-commit.sh` reads its guard.
4. **A new `scripts/test-git-guard.sh`, not more cases in `test-land-work.sh`.** The brief allowed either.
Land-work's suite is about `land-work.sh`, and the gate runs every `scripts/test-*.sh`, so a new file
gets gated with no wiring.
5. **`--proc-root DIR` is a test seam in `slot-busy.sh`.** On a live host the real `/proc` may hold
other seats' runs at any time, so the suite can't demand exit 0 there. Exit 0 and every rule are
tested against a fake `/proc` with pids above `pid_max` (4194304), so they can't collide with the
ancestor set, which always comes from the real `/proc`. The real `/proc` is tested with a stand-in:
a `/bin/sh` script named `node`, started as `node --test standin.test.mjs` in its own cwd. The suite
asserts its BUSY line with that cwd, exit 1, that `slot-busy > /dev/null && echo ran` prints nothing,
that the suite itself (an ancestor whose command line matches `test-*.sh`) has no BUSY line, and that
after the stand-in exits its pid is gone. The brief's "0 without one" is the fake-`/proc` case.
6. **Ancestors are skipped**, not only its own pid as the brief says. A gate script that runs the busy
check matches its own rules (`*gate*.sh`, `test-*.sh`, `mutant`), so checking only `$$` would make
every such caller see itself as BUSY.
7. **The gate rule is broader than `gate.sh`**: `[ /_-]gate[^ /]*\.sh `, so `sage-gate.sh` and
`gate-253.sh` match too. A false BUSY costs a wait; a false CLEAR costs a broken run.
8. **Shell-snapshot wrappers are skipped.** An agent harness runs each command as `bash -c 'source
.../shell-snapshots/snapshot-*.sh && eval <command text>'`, so the wrapper's command line holds the
text of whatever the agent typed, including a busy check that names `test-*.sh`. The real child
processes the wrapper starts are still matched.
## Tests (`scripts/test-git-guard.sh`)
Scratch bare remote, a scratch clone with the guard installed, and a worktree of that clone. Git runs
with `GIT_CONFIG_NOSYSTEM=1` and an empty `GIT_CONFIG_GLOBAL`, as in `test-land-work.sh`. Nothing touches
origin, `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
- Install: exit 0, the canary passed, bytes and mode, no temporary file left, and the worktree's git
resolves `hooks/pre-push` to the common file.
- From the worktree: a new branch and a fast-forward pass. A `--force` push of a rewritten branch, a
`+refspec` push and a `--force-with-lease` push are refused by the guard's message, naming the ref and
both shas, and the remote ref is unchanged. `:ref` and `--delete` are refused and the branch stays.
- A second clone moves the remote; a `--force` push from the worktree is then refused with "fetch
first", the remote stays where the other clone put it, and the guard fetched nothing.
- The hook fed by hand: a short line, a non-hex name and an unknown local object are refused; an empty
push passes.
- The installer: `core.hooksPath` set refuses with that message and writes no hook (in `.git/hooks` or
the hooks path). A different hook refuses and stays byte-identical. `--force-install` replaces it, and
a hard link to the old file keeps the old bytes, so the replacement was a rename. A second install of
the same bytes exits 0. A symlinked hook refuses even with `--force-install`. A `--repo` naming a
worktree lands in the common directory. `GIT_DIR` set, a plain directory and an unknown argument
refuse.
- `slot-busy.sh`, fake `/proc`: CLEAR with exit 0 when nothing matches (a server, a `grep gate`, a
snapshot wrapper whose text holds `node --test`, an empty command line). Exit 1 and the BUSY line for
`node --test`, with no CLEAR in the output, and `> /dev/null && echo ran` prints nothing. Each rule
once more (7 cases). Exit 2 for a missing root, an empty root and an unknown argument.
- `slot-busy.sh`, real `/proc`: the stand-in cases in choice 5.
## Mutants (`agents/darkwing/work/queue-211/mutants.sh`)
Each mutant copies the four scripts into its own tree, edits one site there (exactly one match), and runs
that tree's suite under `timeout -k 10 180`. The worktree's files are never edited. The unmutated suite
runs first and a red base stops the run. The brief's five come first:
| Mutant | Site |
|---|---|
| ancestor-dropped | pre-push: `rc=0` in place of the `merge-base --is-ancestor` call |
| unknown-object-passes | pre-push: the missing-remote-tip branch skips the ref without refusing |
| delete-passes | pre-push: the delete branch skips the ref without refusing |
| install-over-hookspath | installer: the `core.hooksPath` refusal is `true \|\| die` |
| busy-exits-0 | slot-busy: `exit 0` when BUSY |
| differing-hook-replaced | installer: a different hook is replaced without `--force-install` |
| replace-in-place | installer: the replacement writes into the installed file (`cat >`) instead of renaming |
| symlink-followed | installer: no symlink refusal |
| ancestor-error-passes | pre-push: an ancestor check exiting above 1 doesn't refuse |
| snapshot-matched | slot-busy: no shell-snapshot skip |
| ancestors-matched | slot-busy: no ancestor skip |
| no-node-rule | slot-busy: the `node --test` rule removed |
| no-compose-rule | slot-busy: the `docker compose` rule removed |
`install-over-hookspath` still exits 2, because its canary then fails (git looks for the hook in the hooks
path). The suite kills it on the message and on "no hook was written", not on the exit code.
## Rebase r2 (sage-4362, 2026-10-24)
r1 6db25b1f0 sat on aca16404c, 555 commits behind refactor, and overlapped it in `docs/TOOLS.md`. r2
cherry-picks it onto 78b69e1e6 as a new commit on a new branch; no force-push. The TOOLS.md hunk merged
without a conflict, `git patch-id --stable` is the same for both commits (db451e11), and the four scripts,
`mutants.sh` and this file's other text are byte-identical to r1, so no assertion or reviewed line
changed. On the new base none of the six paths existed before, `scripts/git-hooks/` holds only the queue
guard's `pre-commit` (untouched here), and no test reads or pins `docs/TOOLS.md`.
## Runs
Pending: nothing has run yet. Gander held the gate slot (row 253 run 5, from 16:32Z) while this was
written, so only `bash -n`, `sh -n` and a `DRY=1` mutant pass (every site matched once, no suite run)
happened. The suite, the mutants and every other `scripts/test-*.sh` run in a CLEAR Sage announces, with
`TMPDIR` under `/mnt/storage/scratch/tmp`. Results and the measured suite time go here before review.
## Known limits
- `git push --no-verify` skips the hook. Gitea branch protection (ops-01, through Mos) is the guard that
holds.
- The hook doesn't check tags differently from branches: a moved tag is refused like a rewritten branch.
- `slot-busy.sh` can call a process BUSY that isn't a run, for example an editor with a `test-*.sh` open.
It never reads files under a seat's directory, so it can't tell.
## R3, the GPT review (2026-11-02)
The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from
the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r3-fix.md` and the review
`/mnt/storage/scratch/mosaic-lead/reviews/batch1-a-gpt.md` (F1 to F5 and the edge-case table). Base
09a847eda, branch `t3/row211-push-guard-r3-fix`: dca5f10cc and 3e39689c1 picked clean as 5734c1a80 and
c8f2cb70c, then one R3 commit. The suite goes from 61 cases to 85 and the mutants from 13 to 21. The
sections above stay as written; where R3 changes them, it says so here.
### F1: a failed read counted as a vanished process
What: `slot-busy.sh` skipped any process whose cmdline read failed, so a live process it couldn't read (a
mode-000 cmdline of a `node --test`, say) could end in CLEAR. Its own status read was unchecked too: it put
`$$` in the skip set first, and a failed read only ended the ancestor walk.
Fix: after a failed read the scan checks the pid directory. Gone means the process exited, and it's
skipped. Still there is an error: the scan names that cmdline, counts it and goes on, so BUSY lines still
print, then exits 2 and never prints CLEAR. Its own status must read and give a numeric PPid, or it exits 2
before the scan. A failed read further up the walk still only ends the walk, which can give a false BUSY but
never a false CLEAR.
Tests, section "slot-busy, read errors" (fake `/proc`): "a pid directory with no cmdline: exit 2", "it
names that cmdline and never says CLEAR", "a cmdline that can't be read (a directory): exit 2, no CLEAR", "a
read error beside a BUSY process: exit 2, the BUSY line kept, no CLEAR", "a node --test process whose
cmdline is unreadable (mode 000) is never CLEAR", "a failing read (tr exits 1) with every pid directory
still there: exit 2, no CLEAR", "a process that vanishes during its read isn't an error: exit 0 and CLEAR"
(a `tr` shim on PATH removes the pid directory, then fails) and "its own process unreadable (awk exits 1):
exit 2, no CLEAR" (an `awk` shim).
Mutants: `unreadable-read-clear` (every failed read skipped), `vanished-is-error` (no directory check, so
an exited process is an error), `read-errors-ignored` (no final error exit) and `self-read-unchecked` (its
own failed read ends the walk, as before).
### F2: the snapshot exclusion matched a substring
What: any command line holding `/shell-snapshots/snapshot-` was skipped, so a real `node --test` of a file
under a `shell-snapshots` directory read as CLEAR.
Fix: `RE_SNAPSHOT` matches only the wrapper's shape: a shell (`sh`, `bash` or `zsh`, with or without a
path), options that include `-c`, then `source` or `.` of a `shell-snapshots/snapshot-*.sh` file as the
first command. Any other command line that names such a path goes through the rules.
Tests: "busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test)" and "busy
on: bash /seat/shell-snapshots/snapshot-gate.sh (gate)".
Mutant: `snapshot-substring` (the old substring test put back).
### F3: an existing tag could be replaced or moved
What: tags went through the ancestor check, and `git merge-base --is-ancestor` peels a tag. A new annotated
tag object on the same commit, or a lightweight tag moved to a descendant, passed as a fast-forward.
Fix: after the delete and new-ref checks and before the object and ancestor checks, a `refs/tags/*` ref
whose remote object name isn't zero and differs from the local one is refused ("an existing tag can't be
moved or replaced"). A new tag still passes, and a tag delete is still refused as a delete. This corrects
the tag line in "Known limits" above: a moved tag was refused only when it left its line, not when
it moved to a descendant or was re-made on the same commit.
Tests, section "tags": "a new annotated tag passes", "push --force of a new tag object on the same commit
is refused", "the remote tag is still the first tag object", "a new lightweight tag passes", "push --force
of a lightweight tag moved to a descendant is refused", "the remote lightweight tag hasn't moved" and "a
tag delete is refused, and the tag stays".
Mutant: `tag-replace-passes`.
### F4: a symlinked hooks directory
What: the installer wrote through `.git/hooks` even when it was a symlink, so an install could set the hook
of every repository sharing that directory, or write outside the repository.
Fix: `own_hooks` refuses (exit 2) when `<common>/hooks` is a symlink, or when it exists and its physical
path isn't the common directory's physical path plus `/hooks`. It runs before the pre-push symlink check
and again after `mkdir -p`, so both checks come before the temporary file and any install write. The
existing refusals (`core.hooksPath`, a different hook without `--force-install`, a symlinked `pre-push`)
are unchanged.
Tests: "the install refuses a symlinked hooks directory (exit 2)", "it refuses it with --force-install too"
and "nothing was written in the directory the symlink names".
Mutant: `hooks-dir-symlink-followed` (`own_hooks` returns at once).
### F5: the snapshot fixture proved nothing
What: the fixture wrapper ran `eval 'node --test x.test.mjs'`. There `node` follows a quote, and the node
rule needs a space or `/` before it, so the fixture wasn't BUSY even without the exclusion and the
`snapshot-matched` mutant survived.
Fix: two wrappers whose text matches a rule without the exclusion. Pid 4194403 runs `node --test
x.test.mjs` straight after the `source ... &&`. Pid 4194405 has the harness's real shape: `source ...
2>/dev/null || true && eval 'bash scripts/test-x.sh > x.log' && pwd -P >| ...`. The paired BUSY case is the
same node command under `/bin/bash -c` with no snapshot source. The F2 cases are above.
Tests: "an agent's shell-snapshot wrapper isn't a match" (now neither 4194403 nor 4194405) and "busy on:
/bin/bash -c node --test x.test.mjs (node --test)".
Mutant: `snapshot-matched`, whose site is now the `RE_SNAPSHOT` test. It is killed (3 FAIL). The suite
alone can't fail before this fix, because the old substring exclusion skips these wrappers too; the killed
mutant is the before-and-after evidence.
### Object names of the wrong length (the review's table)
What: the hook took any hex string as an object name, so a remote field of `0` read as a new ref.
Fix: after the non-hex check, each field must be exactly 40 or 64 characters, or the ref is refused.
Tests: "a remote object name of 0 is refused, not read as a new ref", "a 39-character local object name is
refused" and the control "a 64-character object name passes the length check (64 zeros refuse as a
delete)".
Mutant: `short-sha-accepted`.
### What didn't change
The earlier cases all still pass: an empty push, a new branch and a fast-forward pass; a delete, `--force`,
`+refspec` and `--force-with-lease` rewrites are refused; a remote tip missing locally is refused with
"fetch first", and nothing is fetched.
### R3 runs
`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was in the foreground, with
output to files there.
- RED, all 24 new cases in place and no fix: 67 passed, 18 failed, 2026-11-02 19:27:01Z to 19:27:06Z. The
six new cases that pass there are controls that must hold before and after: both new tags pass, the tag
delete is refused, the 64-character control, the bare `bash -c` node case and the vanished process.
- GREEN: 85 passed, 0 failed, 19:28:58Z to 19:29:04Z. The run before it gave 80 passed, 5 failed: the new
closing error said "not CLEAR", which the "no CLEAR" assertions caught. It now says "the host isn't
known to be idle".
- Mutants: 19:30:11Z to 19:33:22Z, base green (85/0), runner exit 0, 21 of 21 killed, none survived, no
NO MATCH. Every kill exited 1.
| Mutant | FAIL | First failing case |
|---|---|---|
| ancestor-dropped | 8 | push --force of a rewritten branch is refused |
| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first |
| delete-passes | 12 | the install into a clean clone exits 0 |
| install-over-hookspath | 2 | it says core.hooksPath is set |
| busy-exits-0 | 14 | a node --test process: exit 1 |
| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) |
| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes |
| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install |
| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused |
| snapshot-matched | 3 | no matching process: exit 0 and CLEAR |
| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line |
| no-node-rule | 9 | a node --test process: exit 1 |
| no-compose-rule | 2 | busy on: docker compose -p x up -d (docker compose) |
| unreadable-read-clear | 6 | a pid directory with no cmdline: exit 2 |
| vanished-is-error | 1 | a process that vanishes during its read isn't an error: exit 0 and CLEAR |
| read-errors-ignored | 6 | a pid directory with no cmdline: exit 2 |
| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR |
| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) |
| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused |
| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) |
| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref |
- Real host: `bash scripts/slot-busy.sh` on the real `/proc` exited 1 on other seats' gate and `node
--test` runs, with no error line. Both live shell-snapshot wrappers on the host matched `RE_SNAPSHOT`.
- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the
positive control). None names a path this round changes. The only test that names one is
`scripts/test-git-guard.sh` itself, and no test reads `docs/TOOLS.md`.
### Known limits after R3
- The tag line in "Known limits" above is superseded by F3. The other two lines still hold.
- The resolved-path refusal in `own_hooks` has no test of its own. A hooks directory that isn't a symlink
but resolves elsewhere needs a bind mount, so root, and the symlink check catches the symlink case first.
`hooks-dir-symlink-followed` turns off both checks together.
- A wrapper that sources a test script into its own shell, with no child process, is still skipped, as
before R3. That script's children are still matched.
- The installer's temporary file is named by pid (`.pre-push.install-git-hooks.$$`). R3 leaves it alone.
- The "Runs" section above was written before any run. The R3 runs here are the ones on record for this
round.
## R4, the lead's review of R3 (2026-11-02)
The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from
the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r4-fix.md` and the review
`/mnt/storage/scratch/mosaic-lead/reviews/row211-r3-gpt.md` (R1 to R3, Check 2 and Check 5). Base 09a847eda,
branch `t3/row211-push-guard-r4-fix`: 5734c1a80, c8f2cb70c and 4f36e3c2e picked clean as bcd74ec33,
3a121885c and 7d03f212d, then one R4 commit. The rule for the round: an error, a denied read or a doubt ends
in exit 2 with no CLEAR line. The suite goes from 85 cases to 105 and the mutants from 21 to 30. The
sections above stay as written; where R4 changes them, it says so here.
### The gone check (R1 and R2)
Both findings come down to one question: has this pid gone, or can the scan just not see it? `gone ROOT PID`
in `slot-busy.sh` runs `ls -f -- ROOT` and is true only when that listing succeeds and PID isn't one of its
names. It can't read a denied lookup as gone:
- When the lookup failed because ROOT lost read or search permission, `ls` can't open ROOT, exits 2, and
`gone` is false.
- When ROOT still lists but the pid's entry can't be read (a pid directory or cmdline at mode 000, or ROOT
readable but not searchable), the name is still in the listing, so `gone` is false. `ls -f` lists in
directory order without a stat of each name, so a name whose lookup is denied still appears.
- `gone` is true only when the kernel's own listing of ROOT lacks the name. The name is compared with bash
string matching on the captured listing, not through a pipe, so a pipe failure can't become "absent".
- `/proc` lists only thread group leaders. The scan's pids come from a listing of the same root, and an
ancestor's PPid is a thread group id, so the second listing compares like with like.
### R1: every ancestor read counts
Was: the walk read each ancestor's PPid with awk and ended on any failure (`|| p=""`). Only the scan's own
read was checked, so a failed read further up dropped the rest of the ancestors without an error.
Fix: every ancestor read must succeed and give a numeric PPid. On a failed read or a bad PPid the walk ends
only when `gone /proc PID` confirms the ancestor exited. Anything else prints "cannot read the status of
ancestor PID" and exits 2. The loop runs to PPid 0; `[ -n "$p" ]` went, since p is always numeric now. The
scan's own read is unchanged.
Tests: `awkn`, an awk shim that is the real awk on call 1 (the scan's own status) and then fails ("an
ancestor's status read that fails after its own read passed: exit 2, no CLEAR"), prints nothing ("an
ancestor's status with no PPid: exit 2, no CLEAR"), or on call 2 prints 4194999, above pid_max, which `/proc`
never lists ("an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR", the
control for the allowed path). Each checks the shim ran at least twice, three times for the exited case, so
the failing read is an ancestor's and not the scan's own.
Mutants: `ancestor-read-unchecked` (the error becomes `break`) and `exited-ancestor-error` (no gone check, so
an exited ancestor is an error). Both killed.
### R2: a denied lookup isn't a vanished process
Was: after a failed cmdline read, `[ -e "$d" ] || continue` counted the process as exited. `[ -e ]` is false
for a missing path and for a denied lookup alike. The loop also opened with `[ -d "$d" ] || continue`, the
same flaw before the read.
Fix: after a failed read, `gone "$PROC" "$pid" && continue`; anything else is an error and exit 2. The
`[ -d ]` line is gone. The glob's literal when nothing matches fails the numeric pid test, and every listed
pid now goes through the read and the gone check.
The read is now `{ tr '\0' ' ' < "$d/cmdline"; } 2>/dev/null`. bash prints its own "No such file or
directory" for a failed `<` before the `2>/dev/null` on the same command applies. Without the `[ -d ]` line
that message showed on the real `/proc` for processes that exited mid-scan. The braces send it to
`/dev/null`. The error count and the exit code never depended on it.
Tests: `denytr`, a tr shim that sets the fake proc root to mode 000 at the read of pid 4194450 (a
`node --test`) and fails: "a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR", with
the control "the denied pid directory and its node --test cmdline were still there". `locktr` sets the root to
000 while 4194451 is read and lets that read pass; 4194452, a `node --test`, then can't be opened: "a proc
root that turns unreadable after one read hides no later process: exit 2, no CLEAR". Each test gives the mode
back right after its run, and the suite's cleanup runs `chmod 0755` on every directory in `LOCKED` before
`rm -rf`.
Mutants: `denied-lookup-vanished` (back to `[ -e "$d" ] || continue`), `denied-listing-gone` (a failed
listing counts as gone) and `dir-check-skips` (the `[ -d ]` line back before the read). All killed.
Two R3 mutants had the removed `[ -e "$d" ] || continue` line as their site. They now name the gone line,
with the same intent: `unreadable-read-clear` skips every failed read, and `vanished-is-error` makes a
confirmed exit an error. `snapshot-matched` now deletes the new snapshot line, which removes the whole wrapper
exclusion as before. `snapshot-substring`'s site is unchanged.
### R3: a wrapper that sources a test script
Was: every command line in the snapshot-wrapper shape was skipped. A wrapper that sourced a test into its own
shell, with no child, gave CLEAR while the test ran (the review's live probe).
Fix: `match()` still recognizes the wrapper by `RE_SNAPSHOT`, then passes the rest of its text, after the
snapshot source, to `sourced()`. That prints "sourced gate", "sourced mutant" or "sourced test script" when
the text sources one, and fails otherwise. Children's command lines play no part. A wrapper that only runs
other commands, an external node test included, is still skipped, and its child is still matched (F2, F5).
What counts as sourced (`RE_SOURCED`): the word `source` or `.`, at the start of the text or after a blank,
one of `; & | ( ) { }`, a backtick or a quote; then blanks; an optional `--` and blanks; an optional opening
quote; then the path, up to the next blank, shell operator, `<`, `>` or quote. The path, padded with spaces,
goes through the gate rule, the `*mutant*` rule and the test-script rule, in that order. Every such word in
the text is checked, not only the first. That covers `source scripts/test-x.sh`, `. ./gate.sh 253`,
`source "scripts/test-x.sh" arg`, `eval '. ./gate.sh 253'`, `eval 'source .../mutants.sh M3'`,
`builtin source ...` and a test sourced after other files.
False BUSY it can add: the text is read as words, not parsed. Inside a wrapper, `grep x . scripts/test-x.sh`,
`git add . scripts/test-x.sh`, `ls . gate.sh`, `echo source scripts/test-x.sh` and `cat . notes-mutant.txt`
all read as sourcing, and so does the same text in a heredoc or a comment. A false BUSY only delays a gate.
Missed: a path from a variable or a substitution (`source "$f"`, `. "$(pwd)/scripts/test-x.sh"`), a quoted
path with a space in it, a file that sources a test from inside it, and a function or alias that runs one.
Those run in the wrapper's shell too, so they remain a gap (see the limits below).
Tests: rule cases on the wrapper shape: "sourced test script" four times (`source`, `.`, a quoted path with an
argument, and a test after a file that isn't one), "sourced gate" (`eval '. ./gate.sh 253'`) and "sourced
mutant" (`eval 'source agents/x/work/queue-1/mutants.sh M3'`). Fake pid 4194406, a wrapper that sources
`.venv/bin/activate` and `. ./env.sh` and then runs `./gate.sh 253` as a child: "a wrapper that sources other
files and runs a gate as a child isn't a match". The new section "slot-busy, real snapshot wrappers" starts a
live `bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && source $SANDBOX/scripts/test-x.sh"` whose
test-x.sh blocks in `read -t 60 -u 3` on a fifo, and confirms its children file reads and is empty: "a live
wrapper that sourced a test script, blocked in a builtin with no child: exit 1", "its BUSY line names the
wrapper's pid, the sourced rule and its cwd" and "the real /proc has a BUSY line for that wrapper too". Its
control is a live wrapper that runs a stand-in `node --test` as a child: "a live wrapper that runs node --test
as a child: exit 1 on the child's BUSY line" and "the wrapper itself has no BUSY line". The suite kills and
reaps both wrappers and the child, and clears `KIDS` only after a confirmed reap.
The first GREEN run found a defect the RED-time cases missed. In `sourced()`, the `RE_GATE` and `RE_TEST`
tests reset `BASH_REMATCH`. After a sourced file that wasn't a rule, the next step read an unset
`BASH_REMATCH[0]`, and the `$(match)` subshell died on `set -u`. 4194406 printed an error line, and a wrapper
that sourced a test after another file would have been skipped. The match is now saved before the rule tests.
The fourth "sourced test script" case was added after RED to pin this, with the `sourced-first-only` mutant.
Mutants: `sourced-test-hidden` (the snapshot branch returns 1 again), `sourced-first-only` (only the first
sourced word is checked) and `dot-source-missed` (`.` dropped from `RE_SOURCED`). All killed.
### Item 4: the mutant runner's exit status
Was: exit 0 after printing "survived" or "NO MATCH".
Fix: `run()` sets `bad=1` on COPY FAILED, NO MATCH, survived, and a suite exit of 124 or 137 (timeout's codes
after TERM and after KILL). The runner ends with `exit "$bad"`. A red base still stops it with exit 3. Every
output line reads as before; the killed line takes its exit code from a saved `rc`.
Hand check, in TMPDIR only: two copies of the runner with every `run` line taken out and one line put in
before `exit "$bad"`. A comment-only edit to `slot-busy.sh` printed "comment-only: survived" and the copy
exited 1 (20:07:55Z). A site that isn't in the file printed "no-such-site: NO MATCH" and the copy exited 1
(20:08:02Z). A first try cut the copies short and lost the `exit "$bad"` line, so they exited 0. That was a
fault in the copies, not the runner, and the faithful copies above replaced them. Nothing of the check is
left in the runner.
### Item 5: the installer's temporary file
Was: `tmp="$HOOKS/.pre-push.install-git-hooks.$$"`, a name known in advance. `cp` writes through a symlink
planted there.
Fix: `stage()`, called just before each copy (the `--force-install` replacement and the new install), makes the
file with `mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX"` (O_EXCL, a random name), then copies the hook
into it and sets 0755. It runs after both `own_hooks` checks and the hook-symlink refusal. A failed `mktemp`
is exit 1, "cannot create a temporary file in HOOKS". The refusals and their order are as before, and a
refusal now creates no file at all.
Test: a fresh clone with a symlink at `.git/hooks/.pre-push.install-git-hooks.<pid>` pointing to a victim
file, and the installer started by `exec` from a subshell whose `$BASHPID` is that pid: "the install writes
nothing through a link planted at a pid-named temporary path" and "the installed hook is a regular file with
the repository's bytes".
Mutant: `installer-fixed-tmp` (the pid name back). Killed.
### Item 6: a correction to known limit 2 after R3
That line says the resolved-path refusal in `own_hooks` would need a bind mount to test. That is wrong.
`cd -P` and `pwd -P` resolve symlinks only. A bind mount shows the same path, so the comparison passes a
bind-mounted hooks directory; it can't be tested with a bind mount because it doesn't catch one. Once
`[ ! -L "$HOOKS" ]` has passed, `cd -P "$HOOKS"` resolves the same parents as `cd -P "$common"` plus
`/hooks`, so the comparison fires only if the path changes between the two checks. The R3 text stays as it
was written; this entry supersedes it.
### What didn't change
`scripts/git-hooks/pre-push` is untouched. Empty stdin passes, a new branch and a fast-forward pass, deletes
and non-fast-forwards are refused, an existing tag can't change, and a remote tip missing locally is refused
with fetch advice and no fetch. Those cases pass unchanged in GREEN. `docs/TOOLS.md` now gives 105 cases,
the sourced-wrapper rule, the gone condition on exit 2 and the installer's `mktemp` file.
### R4 runs
`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was in the foreground under
`timeout -k 10 1800`, with output to files there.
- RED, all new tests in place and no fix, 19:57:25Z to 19:57:37Z: 90 passed, 14 failed. The 14: the two
installer temporary-file cases, five sourced rule cases, two ancestor cases (fail, empty), two denied-lookup
cases and the three live sourced-wrapper cases. The five new cases that pass there are controls that must
hold before and after: 4194406, the exited ancestor, the denied pid directory still there, and the external
node wrapper's two cases.
- GREEN, first run, 20:01:11Z to 20:01:25Z: 101 passed, 3 failed ("no matching process: exit 0 and CLEAR",
"a process that vanishes during its read isn't an error: exit 0 and CLEAR" and "an ancestor that has exited
(its pid gone from /proc) ends the walk: exit 0 and CLEAR"). All three share the fake root that holds
4194406, whose error line from the `BASH_REMATCH` defect above broke the exact CLEAR output. Fixed, and one
rule case added.
- GREEN, 20:02:40Z to 20:02:49Z: 105 passed, 0 failed.
- Mutants, 20:02:56Z to 20:06:58Z: base green (105/0), runner exit 0, 30 of 30 killed, none survived, no NO
MATCH, no timeout. Every kill exited 1.
| Mutant | FAIL | First failing case |
|---|---|---|
| ancestor-dropped | 8 | push --force of a rewritten branch is refused |
| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first |
| delete-passes | 13 | the install into a clean clone exits 0 |
| install-over-hookspath | 2 | it says core.hooksPath is set |
| busy-exits-0 | 23 | a node --test process: exit 1 |
| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) |
| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes |
| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install |
| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused |
| snapshot-matched | 14 | no matching process: exit 0 and CLEAR |
| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line |
| no-node-rule | 10 | a node --test process: exit 1 |
| no-compose-rule | 2 | busy on: docker compose -p x up -d (docker compose) |
| unreadable-read-clear | 8 | a pid directory with no cmdline: exit 2 |
| vanished-is-error | 3 | a process that vanishes during its read isn't an error: exit 0 and CLEAR |
| read-errors-ignored | 8 | a pid directory with no cmdline: exit 2 |
| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR |
| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) |
| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused |
| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) |
| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref |
| ancestor-read-unchecked | 2 | an ancestor's status read that fails after its own read passed: exit 2, no CLEAR |
| exited-ancestor-error | 1 | an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR |
| denied-lookup-vanished | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| denied-listing-gone | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| dir-check-skips | 1 | a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR |
| sourced-test-hidden | 9 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| sourced-first-only | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source .venv |
| dot-source-missed | 3 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && . /seat/scri |
| installer-fixed-tmp | 2 | the install writes nothing through a link planted at a pid-named temporary path |
The runner cuts each first failing case at 100 characters, which is why three of them end mid-word.
- Real host: `bash scripts/slot-busy.sh` on the real `/proc` exited 1 on other seats' runs, which the brief
expects. Before the brace fix it also printed bash's "No such file or directory" for processes that exited
mid-scan; after it, none.
- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the
positive control). None names a path this round changes, and none holds the old or new sha256 or blob id of
the four changed scripts. No test reads `docs/TOOLS.md` or this file.
### Known limits after R4
- `git push --no-verify` skips the hook. Gitea branch protection is the guard that holds (unchanged).
- The busy rules read command lines as text. They can call a process BUSY that isn't a run, the sourced rule
adds the false BUSY cases above, and a test sourced by a path built at run time, by a nested file or by a
function is still missed.
- A bind-mounted hooks directory isn't detected (item 6). A mount-point test, such as the hooks directory's
st_dev against its parent's, would catch one. Setting one up needs root. Not this round.
- The installer has a check-then-use window after its path checks, as the review's Check 5 says. An actor who
can replace repository directories in it already controls the hooks.
- A BUSY line holds the command line as it is, so a wrapper whose `-c` text has newlines prints over several
output lines. Chain on the exit code, never on the lines (unchanged advice).
- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and
can't make it BUSY. This host's `/proc` has no hidepid option.
## R5, the GPT review of R4 (2026-11-02)
The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from
the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r5-fix.md` and the review
`/mnt/storage/scratch/mosaic-lead/reviews/row211-r4-gpt.md` (N1 to N3, the 13 live shapes in Check 2, and
Check 6). Base 09a847eda, branch `t3/row211-push-guard-r5-fix` from fd1aa8cc8, then one R5 code commit and
one record commit. Only `slot-busy.sh`, the suite, the mutant runner and the record files change; the hook
and the installer are untouched. The suite goes from 105 cases to 153 and the mutants from 30 to 57. The
sections above stay as written; where R5 changes them, it says so here.
### N1: a listing that reads the same in any environment
Was: `gone()` ran `ls -f -- ROOT` and looked for the pid among the names. GNU ls reads `QUOTING_STYLE`. With
`shell-always` or `c` it prints a present pid as `'4194430'` or `"4194430"`, the name check misses it, and
the pid reads as gone. A failed cmdline read and a failed ancestor read then both ended in CLEAR.
Fix: `ls -f -1 --quoting-style=literal -- ROOT`. The flag overrides the variable, and `-1` asks for one name
per line. R2's rule is unchanged: a failed or denied listing returns 1, never gone. The script also exports
`LC_ALL=C` before it runs any tool, for the locale finding below.
I checked every other tool the script runs or parses, on this host (GNU ls 9.11, GNU Awk 5.4.1, bash
5.3.15):
- ls. `shell-always` and `shell-escape-always` print `'N'`, `c` prints `"N"`, and `locale` prints the
locale's quote marks around N. `escape`, `shell`, `shell-escape` and `literal` print a digits-only name
bare, so the brief's `escape` control passes on R4 too and stays as a control. `LS_COLORS`, `CLICOLOR`,
`CLICOLOR_FORCE`, `COLUMNS`, `TABSIZE` and `POSIXLY_CORRECT` don't change the output to a pipe. To a pipe,
`-1` changes nothing either, so no test can pin it; it states the format. BSD and BusyBox ls reject
`--quoting-style`, so on such a host every listing fails and every failed read is exit 2.
- awk. `AWKPATH`, `GAWK_READ_TIMEOUT=1`, `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` left the status read's output
as it was. `GAWK_PERSIST_FILE` naming a missing file makes gawk exit 2 with a fatal error. Every awk call
in the script checks its exit status, so that ends in exit 2, never CLEAR.
- tr. It maps bytes; `POSIXLY_CORRECT` and a UTF-8 `LC_ALL` gave the same bytes, 0xff included.
- readlink only feeds the cwd field of a BUSY line, and it prints the link as it is. `sed` runs only for
`--help`.
- bash itself. bash doesn't take `IFS` from the environment. `GLOBIGNORE` had no effect on the pid glob.
`SHELLOPTS=noglob` (exported) turns the glob into its literal, which fails the numeric test, so nothing is
seen and the script exits 2, "no processes". Exported functions, `BASH_ENV` and `PATH` let a caller put
its own code in place of `ls`, `awk` or `tr`; the suite's shims rely on exactly that. The check can't
defend against its own caller, and that stays a stated limit.
- Locale. Under a UTF-8 locale, bash's `[^ /]*` in `RE_TEST` stops at a byte that isn't valid UTF-8, so
`bash scripts/test-<0xff>x.sh` didn't match. `export LC_ALL=C` makes every regex and every tool read bytes.
This host has `C.utf8`, so that test isn't vacuous here.
Tests, each RED on R4 except the `escape` pair: a loop over `QUOTING_STYLE` = `shell-always`, `c`, `escape`,
`shell-escape-always` and `locale`, each with "QUOTING_STYLE=X: a failing cmdline read with the pid still
listed: exit 2, no CLEAR" (the `failtr` shim) and "QUOTING_STYLE=X: a failing ancestor status read with the
pid still listed: exit 2, no CLEAR" (`awkn fail`, which now passes extra environment through `env`). "under
LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" pins the locale line.
Mutants: `ls-env-quoting` (the `--quoting-style=literal` flag removed) and `locale-kept` (the `LC_ALL=C` line
removed). `denied-listing-gone` keeps its intent at the new line. All killed.
### N2: quoting, spacing and operators no longer hide a sourced test
Was: three regular expressions read the text with a fixed idea of spacing. `RE_SNAPSHOT` needed single
spaces and a space after `.sh`, `RE_SOURCED` cut a quoted path at its first blank and didn't allow a quote
around `source`, and the general rules needed a blank after `.sh`. Seven of the review's 13 live wrappers
gave CLEAR.
Fix, in three parts.
1. The flat form. When a command line holds a quote, a backslash, one of `; & | ( ) { } < >`, a backtick,
any whitespace other than a single space, or two spaces in a row, `flat()` rewrites it in awk: quotes
and backslashes removed, and each run of whitespace and those operators made one space. A command line
with none of them is its own flat form, so most of the host's command lines skip the awk call (550 of
661 on this host). `flat()` prints an `F` before the text; a failed awk or output without the `F` is
exit 2.
2. The rules on both forms. `RE_SNAPSHOT` now runs on the flat form only, so a tab, two spaces, a quoted
snapshot path or `;` with no blank all reduce to the shape it expects. For any other process the general
rules run on the flat form first, then on the raw form. The flat form finds `bash scripts/test-x.sh;echo
done`, `docker compose` and `docker<TAB>compose`. The raw form keeps every R4 match, such as a script
named `gate;1.sh` passed as one argument, which the flat form splits.
3. The walk over a wrapper's text. `sourced()` is now an awk program that splits the wrapper's raw text
into words the way the shell does. A blank, a newline or one of `; & | ( ) < >` and backtick ends a word.
`'...'` and `"..."` are parts of a word, with the quotes removed. A backslash keeps the next character,
and `$'...'` and `$"..."` read as quotes. A span starts after a word that is `source` or `.`, quoted or
not, and runs to the next `;`, `&`, `|` or newline outside parentheses and backticks. Every word in the
span is checked, not only the first, against the gate rule, the `mutant` rule and the test-script rule,
each anchored at the word's end. So a quoted path with spaces is one word that still ends in
`/scripts/test-x.sh`, and `source 2>/dev/null T`, `source $(cd scripts && pwd)/test-x.sh` and the
backtick form match. Quoted text that holds a blank, an operator, a quote or a backslash is walked again
as shell text, which covers `eval '...'`, nested `bash -c` and the harness's `'\''` form. A span still
open at the end of quoted text stays open after the word. The first `shell-snapshots/snapshot-*.sh` word
in a span is the harness's own snapshot and is skipped whatever its name, and so is a later word equal
to it.
What the walk can't classify:
- An unbalanced quote isn't an error. The quote runs to the end of the text, and that text is walked again,
so a `source` span after it still counts and gives BUSY. An error there would turn every wrapper with a
stray apostrophe (`don't` in a heredoc, a commit message) into exit 2 and stall every gate on the host,
while reading on keeps the one property that matters: a named test, gate or mutant file in a source
argument is never CLEAR. The cost is a false BUSY when the quoted text only mentions a test.
- Quoting deeper than 32 walk levels is exit 2, "cannot classify the command line of PID". Each level of
escaped double quotes costs the walk about two levels, so 16 levels still read and 17 don't. Each level
walks its quoted text again, so the limit also bounds the time on a text that is megabytes long.
- A failed awk, or output that is neither a rule nor "none", is exit 2 with the same message. The scan goes
on, so other BUSY lines still print, and the final line says the host isn't known to be idle.
The first version of the flat form was bash, `${s//x/y}` per character class. A fake proc root holding one
2 MB command line full of quotes then took 36 s to 135 s to scan, since each replacement costs time per
match. A busy check that takes minutes stalls every gate that waits on it. With the awk version the same
root takes 238 ms to 478 ms as a plain process and 320 ms to 1387 ms as a wrapper, over 12 fill patterns
(quotes, operators, blanks, backslashes, an unclosed quote, repeated `source` and `.` words). I checked the
awk output against the bash version's on 27 inputs. On the real `/proc` (975 pids) R5 took 1317 ms to
1349 ms against R4's 1168 ms to 1172 ms, both CLEAR at the time.
Tests (all RED on R4 except the controls named here):
- Rule cases on the wrapper shape, "sourced test script", RED on R4: `source 2>/dev/null T`, `source $(cd
scripts && pwd)/test-x.sh`, the backtick form, `source "/seat/a;b (c)/scripts/test-x.sh"`, `eval 'source
"/seat/a;b/..."'`, an `eval "..."` with escaped quotes around a path with spaces, the harness's
`eval 'source '\''scripts/test-x.sh'\'''` with `pwd -P >|`, and a heredoc holding `don't` before
`source "it's dir/scripts/test-x.sh"`. Two more pass on R4 and stay as controls:
`eval $'source scripts/test-x.sh'`, and an unclosed `"` before `source /seat/scripts/test-x.sh`.
- General rule cases: "test script" for `bash -c "bash scripts/test-x.sh;echo done"`, "docker compose" for
two spaces and for a tab between `docker` and `compose`, and "gate" for `bash "/seat/gate;1.sh"` (a
control for the raw form; it passes on R4).
- CLEAR cases: a wrapper whose own snapshot is named `snapshot-bash-3-gate.sh`, bare and quoted (controls
that pass on R4), and the same snapshot sourced twice (RED on R4, which read the second as a gate).
- Depth: "a sourced test under 16 escaped quoting levels (131094 bytes): exit 1, sourced test script" and
"quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR".
- awk failure: a shim that fails or prints nothing for one program only, keyed on the walk's `function
walk` and the flat form's `gsub`: "the wrapper walk's awk exits 1", "the wrapper walk's awk prints
nothing", "the flat form's awk exits 1" and "the flat form's awk prints nothing", each "exit 2, cannot
classify, no CLEAR".
- Live wrappers, one per shape in the review's table: a real `bash -c` in `$SANDBOX/wrap` whose sourced
file writes a ready marker and blocks in `read -t 60 -u 3` on a fifo. Each case checks the marker, an
empty children file, exit 1, no CLEAR and a BUSY line naming the wrapper's pid, the rule and its cwd,
through a proc root that links only that wrapper. Shapes 1 to 6 pass on R4 and must still pass; shapes 7
to 13 fail on R4. Live shape 14 is a plain wrapper doing the same blocking read inline, which must stay
CLEAR. The suite kills and reaps each wrapper before the next. R4's external `node --test` child control
(F2, F5) is unchanged and still passes: exit 1 on the child's line, no line for its wrapper.
Mutants, all killed: `quotes-kept`, `operators-kept`, `snapshot-single-space`, `flat-skipped`,
`flat-double-blank-missed`, `special-tab-missed`, `flat-failure-passes`, `flat-sentinel-unchecked`,
`raw-rules-dropped`, `flat-rules-dropped`, `source-arg-first-word`, `no-requote`, `quoted-span-reset`,
`span-ends-in-parens`, `backtick-ignored`, `sq-kept`, `dq-kept`, `dollar-quote-glued`, `backslash-kept`,
`snapshot-path-checked`, `snapshot-repeat-checked`, `depth-unbounded`, `awk-failure-none`,
`lexer-garbage-none` and `classify-error-passes`. Each edits one line once; the table below gives the site
by name.
Sites R5 moved, same intent: `snapshot-matched` deletes the new snapshot line, `no-node-rule` and
`no-compose-rule` drop the rule from `rules()`, `snapshot-substring` turns the flat-form match into a
substring test, `denied-listing-gone` edits the new `ls` line, `sourced-test-hidden` makes the snapshot
branch return 1, `sourced-first-only` ends the walk at the end of the first span, and `dot-source-missed`
drops `.` from the span start.
New false BUSY. Each one only delays a gate, and each was a choice in favor of never giving CLEAR while a
named file runs. I checked each example on a fake proc root holding that one process, with R4 and R5
(the claims table under "R5 runs").
New in R5 (CLEAR on R4, BUSY on R5):
- Every word in a span counts, not only the sourced file. So the file's arguments count
(`source env.sh scripts/test-x.sh`), and so do redirection targets (`source x 2>notes-mutant.log`)
and the words inside `$(...)` and backticks.
- A `.` word starts a span, so `find . -name 'test-*.sh'` is BUSY.
- A span doesn't end at `;`, `&` or `|` inside parentheses, `$(...)` or backticks, so
`( source x; cat scripts/test-x.sh )` is BUSY. That keeps `source $(cd scripts && pwd)/test-x.sh` one
span. An unbalanced `(` keeps the span open to the end.
- A span open at the end of quoted text stays open: `echo 'x; source' scripts/test-x.sh` is BUSY.
- The general rules now see the flat form in every process. So a shell's `-c` text that names a test,
gate or mutant file next to a quote or an operator is BUSY, where R4 needed a blank after `.sh`:
`bash -c 'cat scripts/test-x.sh|wc -l'`.
Kept from R4 (BUSY on both): `grep -rn x . scripts/test-x.sh`, `git add . gate.sh`,
`grep -n source scripts/test-x.sh`, `echo source scripts/test-x.sh`, a commit message
`git commit -m "fix; source scripts/test-x.sh"` (also with the path in single quotes), a `#` comment line
naming one, the text after an unbalanced quote, and a second, different snapshot path whose name matches the
gate rule. R5 still reads quoted text again as shell text and doesn't know comments, so these stay BUSY.
Two more, by reasoning, not tested:
- Waiters can block each other. Take two wrappers that each run the check and whose text also names a
test in a source span. Each sees the other as BUSY until one gives up. R4 had the same risk with fewer
shapes.
- Text quoted more than 32 levels deep is exit 2 (that part is tested), which also holds a gate.
### N3: a correction to the R4 limit on bind mounts
R4's known limits say a mount-point test, "such as the hooks directory's st_dev against its parent's",
would catch a bind-mounted hooks directory. That is wrong for the case it names. A different st_dev finds
a mount of a different filesystem. A bind mount of a directory from the same filesystem keeps the device
number, so the comparison passes it. A detector would have to read mount metadata, such as
`/proc/self/mountinfo`, and look for a mount point at the hooks directory or between it and the
repository. None is built; the limit stands. The R4 text stays as written; this entry supersedes it. No
code changed for N3.
### What didn't change
`scripts/git-hooks/pre-push` and `scripts/install-git-hooks.sh` are untouched, and their cases pass
unchanged in GREEN. The ancestor walk, the read loop, the gone rule (a successful literal listing that
lacks the pid) and the exit codes are as R4 left them, apart from the new "cannot classify" error.
`docs/TOOLS.md` now gives 153 cases, the flat form, the span rule, the exit 2 on text it can't classify, and
the literal listing.
### R5 runs
`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was under `timeout -k 10 1800`,
one at a time, with output to files there. The suite runs were in the foreground. The mutant runner took
12 minutes 41 seconds, longer than the tool's 10-minute foreground limit. So it ran as a background job of
the tool, under the same `timeout`, and nothing else ran until it ended.
- RED, all new tests in place and R4's busy check, 23:32:37Z to 23:33:12Z: 119 passed, 34 failed. For this
one run the checkout's `scripts/slot-busy.sh` was fd1aa8cc8's copy (`git diff --quiet fd1aa8cc8` on it
passed), then R5's was put back (sha256 `8571e27d...` before and after). The hook and the installer are
the same blobs in R4 and R5. All 105 R4 tests pass there. Of the 48 new tests, 34 fail and 14 pass. The 14
are the controls named above: `bash "/seat/gate;1.sh"`, `eval $'source scripts/test-x.sh'`, the unclosed
`"` before a source span, the two `snapshot-bash-3-gate.sh` wrappers, the `QUOTING_STYLE=escape` pair,
live shapes 1 to 6 and live shape 14. Two earlier RED runs at 148 cases gave 117/31 and 119/29. In the
first, a fake pid that one of my new tests left in a shared fake root made two other tests fail. I fixed
that in the test before the second run. The last five cases added after that all fail on R4.
- GREEN, 23:33:23Z to 23:33:35Z: 153 passed, 0 failed. The three code files haven't changed since.
- R4's own suite, in a scratch tree of fd1aa8cc8's four scripts, 23:47:46Z to 23:47:53Z: 105 passed,
0 failed. Its 105 labels all appear in GREEN, and GREEN's other 48 are the new tests above.
- Mutants, 23:33:53Z to 23:46:34Z: base green (153/0), runner exit 0, 57 of 57 killed, none survived, no
NO MATCH, no COPY FAILED, no timeout. Every kill exited 1. A DRY run before it matched all 57 sites
once.
| Mutant | FAIL | First failing case |
|---|---|---|
| ancestor-dropped | 8 | push --force of a rewritten branch is refused |
| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first |
| delete-passes | 13 | the install into a clean clone exits 0 |
| install-over-hookspath | 2 | it says core.hooksPath is set |
| busy-exits-0 | 52 | a node --test process: exit 1 |
| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) |
| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes |
| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install |
| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused |
| snapshot-matched | 44 | no matching process: exit 0 and CLEAR |
| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line |
| no-node-rule | 10 | a node --test process: exit 1 |
| no-compose-rule | 4 | busy on: docker compose -p x up -d (docker compose) |
| unreadable-read-clear | 13 | a pid directory with no cmdline: exit 2 |
| vanished-is-error | 3 | a process that vanishes during its read isn't an error: exit 0 and CLEAR |
| read-errors-ignored | 18 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR |
| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) |
| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused |
| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) |
| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref |
| ancestor-read-unchecked | 7 | an ancestor's status read that fails after its own read passed: exit 2, no CLEAR |
| exited-ancestor-error | 1 | an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR |
| denied-lookup-vanished | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| denied-listing-gone | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| dir-check-skips | 1 | a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR |
| sourced-test-hidden | 36 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| sourced-first-only | 34 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| dot-source-missed | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && . /seat/scri |
| installer-fixed-tmp | 2 | the install writes nothing through a link planted at a pid-named temporary path |
| ls-env-quoting | 8 | QUOTING_STYLE=shell-always: a failing cmdline read with the pid still listed: exit 2, no CLEAR |
| locale-kept | 1 | under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1 |
| quotes-kept | 1 | live shape 11, source "S" && source "T", no child: exit 1, sourced test script |
| operators-kept | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| snapshot-single-space | 2 | busy on: /bin/sh -c docker compose -p x up (docker compose) |
| flat-skipped | 10 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| flat-double-blank-missed | 1 | busy on: /bin/sh -c docker compose -p x up (docker compose) |
| special-tab-missed | 1 | busy on: `/bin/sh -c docker<TAB>compose -p x up` (docker compose) |
| flat-failure-passes | 2 | the flat form's awk exits 1: exit 2, cannot classify, no CLEAR |
| flat-sentinel-unchecked | 1 | the flat form's awk prints nothing: exit 2, cannot classify, no CLEAR |
| raw-rules-dropped | 1 | busy on: bash /seat/gate;1.sh (gate) |
| flat-rules-dropped | 3 | busy on: /bin/bash -c bash scripts/test-x.sh;echo done (test script) |
| source-arg-first-word | 6 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source 2>/de |
| no-requote | 10 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n |
| quoted-span-reset | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "/sea |
| span-ends-in-parens | 2 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source $(cd |
| backtick-ignored | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source `cd s |
| sq-kept | 6 | busy on: /usr/bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/n |
| dq-kept | 13 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source "scri |
| dollar-quote-glued | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval $'sourc |
| backslash-kept | 3 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval "source |
| snapshot-path-checked | 3 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo h |
| snapshot-repeat-checked | 1 | not busy: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && . /hom |
| depth-unbounded | 1 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| awk-failure-none | 1 | the wrapper walk's awk exits 1: exit 2, cannot classify, no CLEAR |
| lexer-garbage-none | 2 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
| classify-error-passes | 5 | quoting 17 escaped levels deep (262166 bytes): exit 2, cannot classify, no CLEAR |
The runner cuts each line at 100 characters, which is why some first failing cases end mid-word.
Live probes, 23:46:56Z to 23:47:18Z, outside the suite (`probe.sh` in TMPDIR, exit 0). Each shape is one
real `bash -c` started in `$P/wrap`, whose sourced file writes a ready marker and blocks in
`read -t 60 -u 3` on a fifo. With the marker present and the children file read, R4's and R5's
`slot-busy.sh` each scanned a proc root that links only that wrapper. Then R5 scanned the real `/proc`,
and the table counts its BUSY lines for that pid. Each wrapper was killed and reaped before the next, and no
"still alive" warning printed. `S` is an empty snapshot under `$P/shell-snapshots`, `SF` one that runs
`false`, and `T` is `$P/scripts/test-x.sh`. Shape 2's path is relative to `$P/wrap`.
| Shape | Wrapper text | Pid | Marker, children | R4 | R5 | BUSY lines on the real /proc (R5) |
|---|---|---|---|---|---|---|
| 1 | `source S && source T` | 732550 | ready, none | exit 1, BUSY 732550 sourced test script | exit 1, BUSY 732550 sourced test script | 1 |
| 2 | `source S && .<TAB><TAB>'scripts/test-x.sh'` | 737522 | ready, none | exit 1, BUSY 737522 sourced test script | exit 1, BUSY 737522 sourced test script | 1 |
| 3 | `source S ; source T ; :` | 742569 | ready, none | exit 1, BUSY 742569 sourced test script | exit 1, BUSY 742569 sourced test script | 1 |
| 4 | `source SF \|\| source T` | 747729 | ready, none | exit 1, BUSY 747729 sourced test script | exit 1, BUSY 747729 sourced test script | 1 |
| 5 | `source S && . "./gate.sh"` | 755860 | ready, none | exit 1, BUSY 755860 sourced gate | exit 1, BUSY 755860 sourced gate | 1 |
| 6 | `source S && source ./mutants.sh` | 760031 | ready, none | exit 1, BUSY 760031 sourced mutant | exit 1, BUSY 760031 sourced mutant | 1 |
| 7 | `source S && source ".../dir with spaces/scripts/test-x.sh"` | 763715 | ready, none | exit 0, CLEAR | exit 1, BUSY 763715 sourced test script | 1 |
| 8 | `source S && "source" T` | 767367 | ready, none | exit 0, CLEAR | exit 1, BUSY 767367 sourced test script | 1 |
| 9 | `source S;source T;:` | 771113 | ready, none | exit 0, CLEAR | exit 1, BUSY 771113 sourced test script | 1 |
| 10 | `source S;. T;:` | 774899 | ready, none | exit 0, CLEAR | exit 1, BUSY 774899 sourced test script | 1 |
| 11 | `source "S" && source "T"` | 778649 | ready, none | exit 0, CLEAR | exit 1, BUSY 778649 sourced test script | 1 |
| 12 | `source<TAB>S && source "T"` | 782425 | ready, none | exit 0, CLEAR | exit 1, BUSY 782425 sourced test script | 1 |
| 13 | `source<SPACE><SPACE>S && source "T"` | 786085 | ready, none | exit 0, CLEAR | exit 1, BUSY 786085 sourced test script | 1 |
Claims, 23:47:24Z to 23:48:44Z (`claims.sh` and `claims3.sh` in TMPDIR). Each row is one process in a fake
proc root, scanned by R4 and R5. Unless the row says otherwise, the argv is
`/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && TEXT"`.
| TEXT | R4 | R5 |
|---|---|---|
| `source env.sh scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script |
| `source x 2>notes-mutant.log` | exit 0, CLEAR | exit 1, sourced mutant |
| `find . -name 'test-*.sh'` | exit 0, CLEAR | exit 1, sourced test script |
| `( source x; cat scripts/test-x.sh )` | exit 0, CLEAR | exit 1, sourced test script |
| `echo 'x; source' scripts/test-x.sh` | exit 0, CLEAR | exit 1, sourced test script |
| argv `/bin/bash -c 'cat scripts/test-x.sh\|wc -l'`, no snapshot | exit 0, CLEAR | exit 1, test script |
| `grep -rn x . scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `git add . gate.sh` | exit 1, sourced gate | exit 1, sourced gate |
| `grep -n source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `echo source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `git commit -m "fix; source scripts/test-x.sh"` | exit 1, sourced test script | exit 1, sourced test script |
| `git commit -m "fix; source 'scripts/test-x.sh'"` | exit 1, sourced test script | exit 1, sourced test script |
| `echo hi`, then a line `# source scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `echo "it's && source /seat/scripts/test-x.sh` | exit 1, sourced test script | exit 1, sourced test script |
| `source /home/u/.claude/shell-snapshots/snapshot-bash-9-gate.sh && echo hi` | exit 1, sourced gate | exit 1, sourced gate |
| `source $'scripts/test\x2dx.sh'` | exit 0, CLEAR | exit 0, CLEAR |
| `f=scripts/test-x.sh; source "$f"` | exit 0, CLEAR | exit 0, CLEAR |
| argv `/bin/bash -c 'source "/home/a b/.claude/shell-snapshots/snapshot-bash-1.sh" && source scripts/test-x.sh'` | exit 1, test script | exit 1, test script |
| the same with `&& node --test x.test.mjs; :` in place of the second source | exit 1, node --test | exit 1, node --test |
| `echo hi && ls -la` | exit 0, CLEAR | exit 0, CLEAR |
The ANSI-C and variable rows are the misses under "Known limits after R5". The two rows with a blank in the
snapshot path show that such a wrapper isn't skipped and goes through the general rules. Run as
`env SHELLOPTS=noglob bash slot-busy.sh`, both versions exit 2 with "no processes", on a fake root holding
`sleep 1` and on the real `/proc` (23:47:40Z). A first try set `SHELLOPTS=noglob` as a prefix
assignment. bash refused it ("readonly variable") and ran the check without it, so that row is the `env`
run.
- Real host, 23:50:13Z to 23:50:17Z: `bash slot-busy.sh` on the real `/proc`, R4, R5, R4, R5. Each exited
0 with CLEAR. R4 took 1157 ms and 1183 ms, R5 1352 ms and 1275 ms, over about 965 pids. The earlier
timing runs above gave R5 145 ms to 181 ms more than R4.
- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the
positive control). None names a path this round changes, and none holds the old or new sha256 or blob id
of the three changed code files. `git grep` for any of those twelve ids over the whole tree finds nothing.
No test reads `docs/TOOLS.md`, `BUILD-LOG.md`, `docs/SESSIONS.md` or this file. `gate-lane-auth.test.mjs`
leaves `docs`, `agents` and `BUILD-LOG.md` out of its copy, and `stack-release.test.mjs` only names
TOOLS.md in an expected message.
### Known limits after R5
- `git push --no-verify` skips the hook. Gitea branch protection is the guard that holds (unchanged).
- The busy rules read command lines as text. The walk is a model of shell word splitting, not bash's
parser: it doesn't know comments, `case` patterns, arithmetic or here-strings. The false BUSY cases above
are the price of reading every quoted and spanned word.
- Still missed: a test sourced by a path built at run time (a variable, a glob, command substitution
output), by an ANSI-C escape (`$'scripts/test\x2dx.sh'`, which the walk reads as plain quotes), by a
nested file, a function or an alias, or by a shell reading its commands from stdin or a terminal. The
command line doesn't show any of those, and the check may not read another seat's files (the review's
Check 6, item 2).
- A snapshot path with a blank in it, from a home directory with a space, doesn't match `RE_SNAPSHOT`, so
that wrapper is never skipped. Its text goes through the general rules, which can only add BUSY lines.
- `SPECIAL` must list every character `flat()` changes, or a command line holding only that character keeps
its raw form. The tab and two spaces are pinned by tests and mutants; CR, VT and FF are listed but not
pinned.
- `--quoting-style` needs GNU ls. BSD or BusyBox ls fails every listing, which fails closed (exit 2 on any
failed read) but turns every exit race into an error.
- `-1` is a no-op for a pipe, so no test pins it.
- Exported functions, `BASH_ENV` and `PATH` are the caller's to set; with them a caller can replace the tools
the check runs.
- A scan costs 145 ms to 181 ms more than R4's on this host (975 pids), and a single 2 MB wrapper text took
up to 1.4 s.
- A bind-mounted hooks directory isn't detected (corrected by N3 above).
- The installer has a check-then-use window after its path checks (unchanged).
- A BUSY line holds the command line as it is, so a wrapper whose `-c` text has newlines prints over several
output lines. Chain on the exit code, never on the lines (unchanged advice).
- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and
can't make it BUSY. This host's `/proc` has no hidepid option (unchanged).
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Row 211 (#426) mutants. Run from the candidate worktree's root:
# TMPDIR=<scratch dir> LOG=<dir> bash agents/darkwing/work/queue-211/mutants.sh > mutants.txt
# Each mutant copies the four row-211 scripts into its own tree, edits one
# site there (exactly one match, else NO MATCH), and runs that tree's
# scripts/test-git-guard.sh under a 180 s timeout. The worktree's files are
# never edited. "killed" means the suite failed; "survived" means it passed.
# The unmutated suite runs first, and a red base stops the run (exit 3).
# Exit 0 only when every mutant is killed; 1 when any survives, has NO MATCH
# or COPY FAILED, or times out (suite exit 124 or 137, still printed as
# killed with that exit code).
set -u
LOG=${LOG:-.}
FILES="scripts/git-hooks/pre-push scripts/install-git-hooks.sh scripts/slot-busy.sh scripts/test-git-guard.sh"
WORK=$(mktemp -d "${TMPDIR:-/tmp}/r211-mutants.XXXXXX") || exit 2
trap 'rm -rf "$WORK"' EXIT
tree() { # $1: name. Prints the copy's root.
local t="$WORK/$1" f
for f in $FILES; do mkdir -p "$t/$(dirname "$f")" && cp -p -- "$f" "$t/$f" || return 1; done
echo "$t"
}
suite() { # $1: tree, $2: log
timeout -k 10 180 bash "$1/scripts/test-git-guard.sh" > "$2" 2>&1
}
edit() { # $1: file, $2: from, $3: to. Exit 3 unless $2 occurs exactly once.
python3 -B - "$1" "$2" "$3" <<'PY'
import sys
path, old, new = sys.argv[1:4]
text = open(path).read()
if text.count(old) != 1:
sys.exit(3)
open(path, "w").write(text.replace(old, new))
PY
}
base=$(tree base) || exit 2
if [ -n "${DRY:-}" ]; then
echo "base: not run (DRY)"
elif suite "$base" "$LOG/mut-base.log"; then
echo "base: green ($(tail -1 "$LOG/mut-base.log"))"
else
echo "base: RED, stopping ($(tail -1 "$LOG/mut-base.log"))"
exit 3
fi
bad=0
run() { # $1: name, $2: file, $3: from, $4: to
local name=$1 file=$2 t log rc
t=$(tree "$name") || { echo "$name: COPY FAILED"; bad=1; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; bad=1; return; fi
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
log="$LOG/mut-$name.log"
if suite "$t" "$log"; then
echo "$name: survived"
bad=1
else
rc=$?
echo "$name: killed (exit $rc, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
case $rc in 124|137) bad=1 ;; esac
fi
}
H=scripts/git-hooks/pre-push
I=scripts/install-git-hooks.sh
S=scripts/slot-busy.sh
# The brief's five.
run ancestor-dropped $H $' git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null\n rc=$?' ' rc=0'
run unknown-object-passes $H $' refuse "$rref: the remote tip $rsha isn\'t in this repository; fetch first, then push again"\n continue' ' continue'
run delete-passes $H $' refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"\n continue' ' continue'
run install-over-hookspath $I '[ -z "$out" ] || die 2 "refused: core.hooksPath' '[ -z "$out" ] || true || die 2 "refused: core.hooksPath'
run busy-exits-0 $S 'if [ "$busy" = 1 ]; then exit 1; fi' 'if [ "$busy" = 1 ]; then exit 0; fi'
# Further sites the suite pins.
run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different' 'true || die 2 "refused: a different'
run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||'
run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2'
run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed'
run snapshot-matched $S $' if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi\n' ''
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
run no-node-rule $S 'if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
# R3, the GPT review (F1 to F5 and the object-name length check).
run unreadable-read-clear $S ' gone "$PROC" "$pid" && continue' ' continue'
run vanished-is-error $S $' gone "$PROC" "$pid" && continue\n' ''
run read-errors-ignored $S '[ "$errors" = 0 ] || {' 'true || {'
run self-read-unchecked $S ' || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }' ' || p=""'
run snapshot-substring $S '[[ " $n " =~ $RE_SNAPSHOT ]]' '[[ " $n " == *"/shell-snapshots/snapshot-"* ]]'
run tag-replace-passes $H 'if [ "${rref#refs/tags/}" != "$rref" ] && [ "$lsha" != "$rsha" ]; then' 'if false; then'
run hooks-dir-symlink-followed $I 'own_hooks() {' 'own_hooks() { return 0;'
run short-sha-accepted $H ' if ! idlen "$lsha" || ! idlen "$rsha"; then' ' if false; then'
# R4 (the lead's review of R3: R1 to R3 and the installer's temporary file).
run ancestor-read-unchecked $S $' gone /proc "$p" && break\n echo "slot-busy: error: cannot read the status of ancestor $p" >&2; exit 2' ' break'
run exited-ancestor-error $S ' gone /proc "$p" && break' ' :'
run denied-lookup-vanished $S ' gone "$PROC" "$pid" && continue' ' [ -e "$d" ] || continue'
run denied-listing-gone $S 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1' 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 0'
run dir-check-skips $S $' pid=${d##*/}\n' $' [ -d "$d" ] || continue\n pid=${d##*/}\n'
run sourced-test-hidden $S 'then sourced "$1"; return; fi' 'then return 1; fi'
run sourced-first-only $S '{ if (!depth && !bq) span = 0 }' '{ if (!depth && !bq) { if (span) return 1 } }'
run dot-source-missed $S 'w == "source" || w == "."' 'w == "source"'
run installer-fixed-tmp $I ' tmp=$(mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX") || die 1 "cannot create a temporary file in $HOOKS"' ' tmp="$HOOKS/.pre-push.install-git-hooks.$$"'
# R5 (the GPT review of R4: N1 and N2). Sites R5 moved are edited above.
run ls-env-quoting $S '-1 --quoting-style=literal --' '-1 --'
run locale-kept $S $'export LC_ALL=C\n' ''
run quotes-kept $S 'gsub(/["\047\\]/, "", t); ' ''
run operators-kept $S 'gsub(/[[:space:];&|(){}<>`]+/, " ", t)' 'gsub(/[[:space:]]+/, " ", t)'
run snapshot-single-space $S '<>`]+/, " ", t)' '<>`]/, " ", t)'
run flat-skipped $S ' if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then' ' if false; then'
run flat-double-blank-missed $S ' || "$n" == *" "* ]]; then' ' ]]; then'
run special-tab-missed $S '<>`\t\n\r\v\f]'"'" '<>`\n\r\v\f]'"'"
run flat-failure-passes $S '[[ "$n" == F* ]] || return 2' '[[ "$n" == F* ]] || true'
run flat-sentinel-unchecked $S 'n=$(flat "$1") && [[ "$n" == F* ]] || return 2' 'n=$(flat "$1") || return 2'
run raw-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $n "'
run flat-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $1 "'
run source-arg-first-word $S 'span = span || w == "source" || w == "."' 'span = w == "source" || w == "."'
run no-requote $S 'if (q && w ~ AGAIN) {' 'if (0) {'
run quoted-span-reset $S 'if (open) span = 1' 'span = open'
run span-ends-in-parens $S '{ if (!depth && !bq) span = 0 }' '{ span = 0 }'
run backtick-ignored $S 'bq = !bq' 'bq = bq'
run sq-kept $S 'if (c == SQ) {' 'if (0) {'
run dq-kept $S '} else if (c == DQ) {' '} else if (0) {'
run dollar-quote-glued $S '} else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) {' '} else if (0) {'
run backslash-kept $S '} else if (c == BS) {' '} else if (0) {'
run snapshot-path-checked $S 'if (snap == "" && w ~ SNAP) snap = w' 'if (0) snap = w'
run snapshot-repeat-checked $S 'else if (w != snap) {' 'else if (1) {'
run depth-unbounded $S 'if (lvl > maxlvl) return 2' 'if (0) return 2'
run awk-failure-none $S "') || return 2" "') || return 1"
run lexer-garbage-none $S '*) return 2 ;; esac' '*) return 1 ;; esac'
run classify-error-passes $S '[ "$rc" != 1 ] || continue' '[ "$rc" = 0 ] || continue'
exit "$bad"
+4
View File
@@ -1879,3 +1879,7 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | mos1884/#662 scope after330/331landing | ReadQ222/Q261/Q263 and659/660/662; posted bounded human-only factory settings/triage/defaulttwo-roundmandate/trackedno-exemptionflake scope with10acceptance criteria, task662comment1338 exactwholebody/author13GETverified. No implementation/review/gate launched.660c1335notready(review/merge, noVM); gatefacts1329/1334 retained; target integration/fullrunner confirmation follows readiness, no localfallback. Landedproduct/evidence unchanged;657/658backlog.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R3 from the GPT review, on t3/row211-push-guard-r3-fix over 09a847eda (picks 5734c1a80, c8f2cb70c) | F1-F5 and the object-name length check fixed with tests; suite RED 67/18, GREEN 85/0; 21 of 21 mutants killed; one local commit, not pushed
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R4 from the GPT review of R3, on t3/row211-push-guard-r4-fix over 09a847eda (picks bcd74ec33, 3a121885c, 7d03f212d) | R1-R3, runner exit status and installer mktemp fixed with tests; suite RED 90/14, GREEN 105/0; 30 of 30 mutants killed, runner exit 0; one local commit, not pushed
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R5 from the GPT review of R4, on t3/row211-push-guard-r5-fix over 09a847eda (from fd1aa8cc8) | N1-N3 fixed (literal ls listing under LC_ALL=C, flat form and awk word walk for sourced tests, bind-mount note corrected) with tests; suite RED 119/34, GREEN 153/0; 57 of 57 mutants killed, runner exit 0; 13 of 13 live shapes BUSY; two local commits, not pushed
+47
View File
@@ -166,8 +166,55 @@ resolved through the CLI.
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 153 cases |
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath`, a symlinked hooks directory or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error, including a process or an ancestor it can't read that isn't confirmed gone; chain on the exit code |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
### Push guard and busy check (row 211, #426)
`scripts/git-hooks/pre-push` refuses a push that deletes a branch, or that
moves a branch to a commit that doesn't descend from the remote tip (a
`--force`, `--force-with-lease` or `+refspec` push of a rewritten branch). A
new branch and a fast-forward pass. It refuses any change to an existing tag,
including a new tag object on the same commit; a new tag passes. The hook
fetches nothing: when the remote tip isn't in the local repository it refuses
and says to fetch first.
`git push --no-verify` skips it, so Gitea's branch protection (ops-01) is the
guard that holds. `scripts/install-git-hooks.sh` copies it into the common
`.git/hooks`, which every worktree of the checkout shares, refusing a hooks
directory that is a symlink. It stages the bytes in a new file that `mktemp`
makes in that directory, then runs a canary
through `git hook run`. Installing it in the canonical checkout is a step for
the lead after the row lands.
Chain the busy check on its exit code, and set `pipefail` in any script that
pipes it:
```bash
bash scripts/slot-busy.sh > "$LOG" && gate
```
Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
and agent shell-snapshot wrappers (a shell whose `-c` text starts by sourcing
`.../shell-snapshots/snapshot-*.sh`). Each rule runs on the command line as it
is and on a flat form with the quotes and backslashes removed and every run of
blanks, newlines and the operators `; & | ( ) { } < >` and backtick turned into
one space, so quoting and spacing don't hide a match. A wrapper is BUSY when
any word after a `source` or `.`, up to the next `;`, `&`, `|` or newline
outside parentheses and backticks, names a gate, test or mutant script,
because that script runs in the wrapper's own shell. Quoted text, an `eval`
string for one, is read the same way. That rule can call a wrapper BUSY that
runs no test; row 211's BUILD.md (R5) lists the cases. A command line it can't
classify, quoting more than 32 levels deep for one, makes it exit 2. It reads
only `/proc/<pid>/cmdline`, the cwd link, its own and its ancestors' `status`
and, after a failed read, the proc root's listing, which it takes from GNU
`ls --quoting-style=literal` under `LC_ALL=C` so the caller's environment
can't change it. A process or an ancestor it can't read makes it exit 2, never
CLEAR, unless a listing of the proc root succeeds and no longer has that pid.
## Seat launch with registration (`scripts/mosaic`)
```bash
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Mosaic push guard (row 211, #426). Installed as pre-push in the common
# .git/hooks by `scripts/install-git-hooks.sh`; never edit the installed copy.
# For each ref git is about to push, it refuses a delete and any update whose
# remote tip isn't an ancestor of the local tip: a force-push or a +refspec.
# It refuses any change to an existing tag, and an object name that isn't 40
# or 64 hex characters. A new ref passes. It fetches nothing, so a remote tip
# that isn't in this repository refuses. Any failure of the check refuses too.
# `git push --no-verify` skips this hook; Gitea's branch protection is the
# guard that holds (ops-01). Canon: never force-push.
refused=0
refuse() {
echo "mosaic push guard: refused: $*" >&2
refused=1
}
zero() {
case "$1" in *[!0]*) return 1 ;; esac
return 0
}
idlen() { # $1: an object name. True at 40 (SHA-1) or 64 (SHA-256) characters.
[ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]
}
while read -r lref lsha rref rsha extra || [ -n "${lref:-}" ]; do
if [ -z "${rsha:-}" ] || [ -n "${extra:-}" ]; then
refuse "unreadable ref line from git: $lref ${lsha:-} ${rref:-} ${rsha:-} ${extra:-}"
continue
fi
case "$lsha$rsha" in
*[!0-9a-f]*) refuse "$rref: a ref line with a non-hex object name (local $lsha, remote $rsha)"; continue ;;
esac
if ! idlen "$lsha" || ! idlen "$rsha"; then
refuse "$rref: a ref line with an object name that isn't 40 or 64 hex characters (local $lsha, remote $rsha)"
continue
fi
if zero "$lsha"; then
refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"
continue
fi
if zero "$rsha"; then
continue
fi
# An existing tag never changes. A new tag object on the same commit, or
# a tag moved to a descendant, would pass the ancestor check below.
if [ "${rref#refs/tags/}" != "$rref" ] && [ "$lsha" != "$rsha" ]; then
refuse "$rref: an existing tag can't be moved or replaced (remote $rsha, local $lsha); push a new tag instead"
continue
fi
if ! git cat-file -e "$rsha^{commit}" 2>/dev/null; then
refuse "$rref: the remote tip $rsha isn't in this repository; fetch first, then push again"
continue
fi
git merge-base --is-ancestor "$rsha" "$lsha" 2>/dev/null
rc=$?
case $rc in
0) ;;
1) refuse "$rref: not a fast-forward; remote $rsha isn't an ancestor of local $lsha (a force-push or a rewritten branch)" ;;
*) refuse "$rref: the ancestor check failed (exit $rc) for remote $rsha and local $lsha" ;;
esac
done
if [ "$refused" != 0 ]; then
echo "mosaic push guard: nothing was pushed; a pushed branch only moves forward (canon: never force-push)" >&2
exit 1
fi
exit 0
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# install-git-hooks.sh: install the push guard (row 211, #426) as pre-push in
# a repository's common .git/hooks, which every linked worktree shares.
#
# Usage:
# scripts/install-git-hooks.sh [--repo DIR] [--force-install]
#
# DIR defaults to this script's own checkout. The hook's bytes are this
# checkout's scripts/git-hooks/pre-push. It refuses when core.hooksPath is set
# (git would not run the hook), when the hooks directory is a symlink or
# resolves anywhere but the common directory's hooks, and when a different
# pre-push is installed, unless --force-install is given. It never edits an
# installed hook in place:
# a new hook is linked in, a replacement is renamed over the old one. The
# bytes go first into a new file that mktemp makes in the hooks directory, so
# nothing planted there in advance is written through. A canary then runs the
# installed hook through git and needs a delete refused.
#
# Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused;
# 4 usage. Installing in the canonical checkout is its own step with the lead.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
SRC="$ROOT/scripts/git-hooks/pre-push"
TMPD=""
say() { printf 'install-git-hooks: %s\n' "$*" >&2; }
die() { local code=$1; shift; say "$*"; exit "$code"; }
cleanup() { if [ -n "$TMPD" ]; then rm -rf -- "$TMPD"; fi; }
trap cleanup EXIT
usage() {
sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2
exit 4
}
REPO=$ROOT
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || usage; REPO=$2; shift 2 ;;
--force-install) FORCE=1; shift ;;
-h|--help) usage ;;
*) say "unknown argument: $1"; usage ;;
esac
done
for v in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES; do
if [ -n "${!v+x}" ]; then die 2 "refused: $v is set; run with the normal git environment"; fi
done
[ -f "$SRC" ] && [ ! -L "$SRC" ] || die 2 "refused: $SRC is missing or not a regular file"
g() { git -C "$REPO" "$@"; }
top=$(g rev-parse --show-toplevel 2>/dev/null) || die 2 "refused: $REPO is not a git checkout"
common=$(g rev-parse --path-format=absolute --git-common-dir) || die 1 "cannot read $REPO's git common directory"
HOOKS="$common/hooks"
HOOK="$HOOKS/pre-push"
out=$(g config --show-scope --get-all core.hooksPath 2>/dev/null)
[ -z "$out" ] || die 2 "refused: core.hooksPath is set ($(printf '%s' "$out" | tr '\n\t' '; ')), so git would not run the push guard; unset it first"
# The hooks directory must be the common directory's own. Through a symlink
# to a shared directory, the install would set the hook of every repository
# that shares it. Checked before and after mkdir, before any file is written.
own_hooks() {
local real want
[ ! -L "$HOOKS" ] || die 2 "refused: $HOOKS is a symlink; install only into the repository's own hooks directory"
[ -e "$HOOKS" ] || return 0
real=$(cd -P -- "$HOOKS" && pwd -P) && want="$(cd -P -- "$common" && pwd -P)/hooks" || die 1 "cannot resolve $HOOKS"
[ "$real" = "$want" ] || die 2 "refused: $HOOKS resolves to $real, not $want"
}
own_hooks
if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi
mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS"
own_hooks
# The hook's bytes in a new file that mktemp creates (O_EXCL, a random name)
# in the checked hooks directory, made only once a copy is due.
stage() {
tmp=$(mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX") || die 1 "cannot create a temporary file in $HOOKS"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
}
if [ -e "$HOOK" ]; then
[ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file"
if cmp -s "$SRC" "$HOOK"; then
chmod 0755 "$HOOK" || die 1 "chmod failed"
note="the same bytes were already there"
else
[ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it"
stage
# rename() swaps the whole file; the old hook's bytes are never edited.
mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; }
note="replaced a different hook (--force-install)"
fi
else
stage
# link() never replaces, so a hook that appeared meanwhile is kept.
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi
rm -f -- "$tmp"
note="new"
fi
cmp -s "$SRC" "$HOOK" || die 1 "$HOOK differs from $SRC after install"
[ -x "$HOOK" ] || die 1 "$HOOK is not executable after install"
# The canary: git runs the hook it would run for a push. A delete line needs
# no objects; the guard must refuse it, and pass an empty push.
TMPD=$(mktemp -d "${TMPDIR:-/tmp}/install-git-hooks.XXXXXX") || die 1 "mktemp failed"
z=0000000000000000000000000000000000000000
printf '(delete) %s refs/heads/install-canary 1%s\n' "$z" "${z#0}" > "$TMPD/delete"
: > "$TMPD/empty"
if ! out=$(cd "$top" && git hook run --to-stdin="$TMPD/empty" pre-push -- canary canary 2>&1); then
die 2 "refused: the canary's empty push failed, so git is not running the push guard as installed: $out"
fi
out=$(cd "$top" && git hook run --to-stdin="$TMPD/delete" pre-push -- canary canary 2>&1); rc=$?
[ "$rc" -ne 0 ] && [[ "$out" == *"mosaic push guard: refused"* ]] \
|| die 2 "refused: the canary's delete was not refused by the push guard (exit $rc)"
printf 'ok installed the push guard at %s (%s, sha256 %s, mode 0755); canary passed\n' \
"$HOOK" "$note" "$(sha256sum < "$HOOK" | cut -d' ' -f1)"
exit 0
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# slot-busy.sh: the shared busy check (row 211, #426). Is a gate or a test run
# going on this host?
#
# Usage:
# scripts/slot-busy.sh [--proc-root DIR]
#
# It prints one BUSY line per matching process, or one CLEAR line. Exit codes:
# 0 CLEAR, 1 BUSY, 2 its own error, which includes a process or an ancestor
# it can't read that isn't confirmed gone, and a wrapper's text it can't
# classify (it still prints any BUSY lines, never CLEAR). Gone means a
# literal listing of the proc root that succeeds and lacks the pid. Chain it
# on the exit code, never on a filter of its output:
# bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped)
# never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY.
#
# A process matches when its command line holds `node ... --test`, a
# `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`.
# The rules read the command line as it is and in a flat form: quotes and
# backslashes removed, and each run of blanks and the characters ; & | ( ) { }
# < > ` made one space. It skips itself and its ancestors (a gate script may
# run the check), and an agent harness's shell-snapshot wrapper (`bash -c
# 'source .../shell-snapshots/snapshot-*.sh ...'`, matched in the flat form),
# whose command line is the agent's command text; that shell's real children
# are still matched, and so is any other command line that only names a
# shell-snapshots path. A wrapper that sources (`source` or `.`) a gate, test
# or mutant script runs it in its own shell, so that wrapper is BUSY. Every
# word from the `source` or `.` to the next ; & | or newline counts, and
# quoted text is read again as shell text. It reads only /proc/<pid>/cmdline,
# the cwd link, its own and its ancestors' /proc/<pid>/status and, after a
# failed read, the proc root's listing, never a file under a seat's directory.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
# Every tool reads bytes the same way whatever the caller's locale: under a
# UTF-8 locale a byte that isn't UTF-8 would end a [^ /]* run early.
export LC_ALL=C
PROC=/proc
while [ $# -gt 0 ]; do
case "$1" in
--proc-root) [ $# -ge 2 ] || { echo "slot-busy: error: --proc-root needs a directory" >&2; exit 2; }; PROC=$2; shift 2 ;;
-h|--help) sed -n '5,6p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2 ;;
*) echo "slot-busy: error: unknown argument: $1" >&2; exit 2 ;;
esac
done
[ -d "$PROC" ] || { echo "slot-busy: error: $PROC is not a directory" >&2; exit 2; }
# gone ROOT PID: true only when ROOT lists and PID isn't in the listing. A
# failed or denied listing is false: a lookup that fails can't tell an exited
# process from one it may not see. `ls -f` reads the names only, no stat. The
# flags fix the format: one name per line, never quoted, whatever
# QUOTING_STYLE holds (a quoted name would read as a missing pid).
gone() {
local names
names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1
[[ $'\n'"$names"$'\n' != *$'\n'"$2"$'\n'* ]]
}
# This process and its ancestors, from the real /proc. Every status read must
# give a numeric PPid. An ancestor that exited (confirmed gone) ends the walk;
# any other failed read is an error, never a shorter walk.
declare -A SKIP=()
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$$/status" 2>/dev/null) && [[ "$p" =~ ^[0-9]+$ ]] \
|| { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
SKIP[$$]=1
while [ "$p" != 0 ]; do
SKIP[$p]=1
if ! q=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || ! [[ "$q" =~ ^[0-9]+$ ]]; then
gone /proc "$p" && break
echo "slot-busy: error: cannot read the status of ancestor $p" >&2; exit 2
fi
p=$q
done
RE_NODE='[ /]node[^ ]* (.* )?--test '
RE_GATE='[ /_-]gate[^ /]*\.sh '
RE_TEST='[ /]test-[^ /]*\.sh '
RE_COMPOSE='[ /]docker( compose|-compose) '
# The wrapper's shape, in the flat form: a shell, its options with -c, then
# `source` (or `.`) of a shell-snapshots/snapshot-*.sh file as the first
# command.
RE_SNAPSHOT='^ (/[^ ]*/)?(ba|z)?sh( -[a-zA-Z]+)* -[a-zA-Z]*c[a-zA-Z]*( -[a-zA-Z]+)* (source|\.) [^ ]*/shell-snapshots/snapshot-[^ /]*\.sh '
# sourced TEXT: a wrapper's whole command line. Prints the rule when a
# `source` or `.` in it names a gate, test or mutant script; returns 1 when
# none does, 2 when the text can't be classified.
#
# The awk walk reads the text in words the way the shell splits them: a
# blank, newline or one of ; & | ( ) < > ` ends a word; '...' and "..." are
# parts of a word with the quotes removed (a backslash inside "..." keeps the
# next character in the word); a backslash keeps the next character; $'...'
# and $"..." are read as quotes. A quote that never closes runs to the end of
# the text. Quoted text that holds a blank, an operator, a quote or a
# backslash is walked again as shell text (eval, bash -c and the harness's
# '\'' forms), and a span left open at its end stays open after the word.
# A span starts after a `source` or `.` word and ends at ; & | or a newline
# outside parentheses and backticks; every word in it is checked, so
# redirections, $(...) and `...` parts and later arguments count. The first
# shell-snapshots/snapshot-*.sh word in a span is the harness's own snapshot,
# skipped whatever its name; a later word equal to it is skipped too. Text
# quoted more than 32 levels deep, a failed awk or output that is neither a
# rule nor "none" returns 2.
sourced() {
local out
out=$(printf '%s' "$1" | awk -v maxlvl=32 '
BEGIN {
RS = "\001"; SQ = "\047"; DQ = "\""; BS = "\\"
SEP = " \t\n;&|()<>`"; STOP = SEP SQ DQ BS "$"
AGAIN = "[[:space:];&|()<>`" SQ DQ "\\\\]"
GATE = "(^|[/_-])gate[^/]*\\.sh$"; TEST = "(^|/)test-[^/]*\\.sh$"
SNAP = "/shell-snapshots/snapshot-[^/]*\\.sh$"
}
{ text = (NR > 1) ? text RS $0 : $0 }
function walk(s, span, lvl, n, i, j, c, w, q, had, r, depth, bq) {
if (lvl > maxlvl) return 2
n = length(s); i = 1
for (;;) {
w = ""; q = had = 0
while ((c = substr(s, i, 1)) != "" && !index(SEP, c)) {
had = 1
if (c == SQ) {
for (j = i + 1; j <= n && substr(s, j, 1) != SQ; j++) ;
w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1
} else if (c == DQ) {
for (j = i + 1; j <= n && (c = substr(s, j, 1)) != DQ; j++) if (c == BS) j++
w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1
} else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) {
i++
} else if (c == BS) {
c = substr(s, i + 1, 1); if (c != "\n") w = w c; i += 2; q = 1
} else {
for (j = i + 1; j <= n && !index(STOP, substr(s, j, 1)); j++) ;
w = w substr(s, i, j - i); i = j
}
}
if (had) {
if (q && w ~ AGAIN) {
r = walk(w, span, lvl + 1)
if (r != 1) return r
if (open) span = 1
}
if (span) {
if (snap == "" && w ~ SNAP) snap = w
else if (w != snap) {
if (w ~ GATE) { rule = "sourced gate"; return 0 }
if (index(w, "mutant")) { rule = "sourced mutant"; return 0 }
if (w ~ TEST) { rule = "sourced test script"; return 0 }
}
}
span = span || w == "source" || w == "."
}
if (c == "") { open = span; return 1 }
i++
if (c == ";" || c == "&" || c == "|" || c == "\n") { if (!depth && !bq) span = 0 }
else if (c == "(") depth++
else if (c == ")") { if (depth) depth-- }
else if (c == "`") bq = !bq
}
}
END { r = walk(text, 0, 1); print (r == 0 ? rule : r == 1 ? "none" : "deep") }
') || return 2
case $out in none) return 1 ;; "sourced "*) echo "$out"; return 0 ;; *) return 2 ;; esac
}
rules() { # $1: a command line with a blank at each end. Prints the rule.
if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$1" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$1" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$1" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
}
# The characters the flat form changes, other than a single space. A command
# line with none of them and no two blanks in a row is its own flat form.
SPECIAL=$'[\"\'\\\\;&|(){}<>`\t\n\r\v\f]'
# flat TEXT: prints F, then TEXT in the flat form. awk, not bash: a bash
# ${s//x/y} costs time per match, and a 2 MB line full of quotes took minutes.
flat() {
printf '%s' "$1" | awk '
BEGIN { RS = "\001" }
{ t = (NR > 1) ? t RS $0 : $0 }
END { gsub(/["\047\\]/, "", t); gsub(/[[:space:];&|(){}<>`]+/, " ", t); printf "F%s", t }
'
}
match() { # $1: the command line, NULs as spaces. Prints the rule; 1 none, 2 unclassified.
local n=$1
if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then
n=$(flat "$1") && [[ "$n" == F* ]] || return 2
n=${n#F}
fi
if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi
rules " $n " || rules " $1 "
}
seen=0
busy=0
errors=0
# No directory test before the read: `[ -d ]` is false for a denied lookup
# too, so every listed pid goes through the read and the gone check.
for d in "$PROC"/[0-9]*; do
pid=${d##*/}
[[ "$pid" =~ ^[0-9]+$ ]] || continue
seen=$((seen + 1))
[ -z "${SKIP[$pid]:-}" ] || continue
# A process can exit between the listing and the read; that's not an error.
# A failed read is, unless a fresh listing confirms the pid is gone: the
# scan can't say what that process runs.
# The braces send the shell's own message for a failed `<` to /dev/null too.
if ! cmd=$( { tr '\0' ' ' < "$d/cmdline"; } 2>/dev/null); then
gone "$PROC" "$pid" && continue
echo "slot-busy: error: cannot read $d/cmdline" >&2
errors=$((errors + 1))
continue
fi
cmd=${cmd% }
[ -n "$cmd" ] || continue
rule=$(match "$cmd"); rc=$?
[ "$rc" != 1 ] || continue
if [ "$rc" != 0 ]; then
echo "slot-busy: error: cannot classify the command line of $d" >&2
errors=$((errors + 1))
continue
fi
cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?"
printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}"
busy=1
done
[ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; }
[ "$errors" = 0 ] || { echo "slot-busy: error: $errors process(es) could not be read or classified, so the host isn't known to be idle" >&2; exit 2; }
if [ "$busy" = 1 ]; then exit 1; fi
echo "CLEAR"
exit 0
+645
View File
@@ -0,0 +1,645 @@
#!/usr/bin/env bash
# Sandboxed selftests for the push guard, its installer and the busy check
# (row 211, #426): scripts/git-hooks/pre-push, scripts/install-git-hooks.sh
# and scripts/slot-busy.sh.
#
# Pushes go from a scratch clone and its worktree to a scratch bare remote,
# never to origin. Git sees no user or system config. The busy check runs
# against a fake /proc (pids above pid_max) for the exit codes and rules, and
# against the real /proc with a sleeping stand-in whose argv is `node --test`.
set -uo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
STANDIN=""
KIDS="" # wrapper shells and their children that the suite starts
LOCKED="" # directories a test makes unreadable; cleanup gives modes back
cleanup() {
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
for k in $KIDS; do kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; done
for l in $LOCKED; do chmod 0755 "$l" 2>/dev/null; done
rm -rf "$SANDBOX"
}
trap cleanup EXIT
PASS=0
FAIL=0
# Status colors: terminal-only, NO_COLOR-respecting; plain when piped.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_OK=$'\033[0;32m'; C_FAIL=$'\033[0;31m'; C_RESET=$'\033[0m'
else
C_OK=""; C_FAIL=""; C_RESET=""
fi
check() {
if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "${C_OK}OK${C_RESET} $1"; else FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} $1"; fi
}
has() { [[ "$1" == *"$2"* ]]; }
# Git sees no user or system config: no hooks path, no signing, a fixed identity.
: > "$SANDBOX/gitconfig"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig"
export GIT_AUTHOR_NAME=suite GIT_AUTHOR_EMAIL=suite@local GIT_COMMITTER_NAME=suite GIT_COMMITTER_EMAIL=suite@local
HOOK_SRC="$REPO/scripts/git-hooks/pre-push"
INSTALL="$REPO/scripts/install-git-hooks.sh"
BUSY="$REPO/scripts/slot-busy.sh"
B="$SANDBOX/remote.git"
C="$SANDBOX/clone"
W="$SANDBOX/wt"
O="$SANDBOX/other"
commit() { # $1: repo, $2: file and message
echo "$2" > "$1/$2" && git -C "$1" add "$2" && git -C "$1" commit -q -m "$2"
}
rtip() { git -C "$B" rev-parse -q --verify "refs/heads/$1"; }
# ---- the remote, a clone with the guard, a worktree ----
git init -q --bare -b main "$B"
git init -q -b main "$SANDBOX/seed"
commit "$SANDBOX/seed" base
git -C "$SANDBOX/seed" push -q "$B" main
git clone -q "$B" "$C"
echo "== install =="
out=$(bash "$INSTALL" --repo "$C" 2>&1); rc=$?
check "the install into a clean clone exits 0" "$rc"
check "it reports the install and a passed canary" "$(has "$out" "ok installed the push guard" && has "$out" "canary passed"; echo $?)"
check "the hook is in the common .git/hooks, with the repository's bytes, executable" \
"$(cmp -s "$HOOK_SRC" "$C/.git/hooks/pre-push" && [ -x "$C/.git/hooks/pre-push" ]; echo $?)"
check "no temporary file is left in .git/hooks" \
"$(ls -A "$C/.git/hooks" | grep -q '^\.pre-push\.install'; [ $? = 1 ]; echo $?)"
git -C "$C" worktree add -q -b seat/w "$W" origin/main
check "the worktree's git runs the common hook" \
"$([ "$(git -C "$W" rev-parse --path-format=absolute --git-path hooks/pre-push)" = "$C/.git/hooks/pre-push" ]; echo $?)"
echo "== pushes from the worktree =="
commit "$W" one
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a new branch passes" "$rc"
check "the remote branch is at the pushed commit" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
commit "$W" two
out=$(git -C "$W" push origin seat/w 2>&1); rc=$?
check "a fast-forward passes" "$rc"
check "the remote branch moved forward" "$([ "$(rtip seat/w)" = "$(git -C "$W" rev-parse HEAD)" ]; echo $?)"
before=$(rtip seat/w)
git -C "$W" reset -q --hard HEAD~1
commit "$W" two-rewritten
after=$(git -C "$W" rev-parse HEAD)
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "push --force of a rewritten branch is refused" "$([ "$rc" -ne 0 ]; echo $?)"
check "the refusal is the guard's, and names the ref and both shas" \
"$(has "$out" "mosaic push guard: refused: refs/heads/seat/w: not a fast-forward" && has "$out" "$before" && has "$out" "$after"; echo $?)"
check "the remote ref is unchanged after the refused --force" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push origin +seat/w:seat/w 2>&1); rc=$?
check "a +refspec push is refused" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
check "the remote ref is unchanged after the refused +refspec" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
out=$(git -C "$W" push --force-with-lease origin seat/w 2>&1); rc=$?
check "push --force-with-lease is refused too" "$([ "$rc" -ne 0 ] && has "$out" "not a fast-forward"; echo $?)"
out=$(git -C "$W" push origin :seat/w 2>&1); rc=$?
check "a delete (:ref) is refused" "$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/heads/seat/w: a delete"; echo $?)"
out=$(git -C "$W" push --delete origin seat/w 2>&1); rc=$?
check "a delete (--delete) is refused" "$([ "$rc" -ne 0 ] && has "$out" "a delete"; echo $?)"
check "the remote branch still exists after the refused deletes" "$([ "$(rtip seat/w)" = "$before" ]; echo $?)"
# Another clone moves the remote; this clone never fetches it.
git clone -q -b seat/w "$B" "$O"
commit "$O" from-other
git -C "$O" push -q origin seat/w
moved=$(rtip seat/w)
check "the other clone moved the remote, and this clone lacks that commit" \
"$([ "$moved" != "$before" ] && ! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
out=$(git -C "$W" push --force origin seat/w 2>&1); rc=$?
check "a push over a remote tip missing locally is refused, saying fetch first" \
"$([ "$rc" -ne 0 ] && has "$out" "isn't in this repository; fetch first"; echo $?)"
check "the remote ref is unchanged after that refusal" "$([ "$(rtip seat/w)" = "$moved" ]; echo $?)"
check "the guard fetched nothing" "$(! git -C "$C" cat-file -e "$moved^{commit}" 2>/dev/null; echo $?)"
echo "== tags =="
# An existing tag never changes. Both cases below pass the ancestor check: a
# new tag object on the same commit peels to that commit, and a moved
# lightweight tag's old commit is an ancestor of its new one.
tagtip() { git -C "$B" rev-parse -q --verify "refs/tags/$1"; }
git -C "$W" tag -a -m first t-ann HEAD
out=$(git -C "$W" push origin t-ann 2>&1); rc=$?
check "a new annotated tag passes" "$([ "$rc" = 0 ] && [ "$(tagtip t-ann)" = "$(git -C "$W" rev-parse t-ann)" ]; echo $?)"
first=$(tagtip t-ann)
git -C "$W" tag -f -a -m second t-ann HEAD >/dev/null
out=$(git -C "$W" push --force origin t-ann 2>&1); rc=$?
check "push --force of a new tag object on the same commit is refused" \
"$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/tags/t-ann: an existing tag" && has "$out" "$first"; echo $?)"
check "the remote tag is still the first tag object" "$([ "$(tagtip t-ann)" = "$first" ]; echo $?)"
git -C "$W" tag t-light HEAD~1
out=$(git -C "$W" push origin t-light 2>&1); rc=$?
check "a new lightweight tag passes" "$rc"
git -C "$W" tag -f t-light HEAD >/dev/null
out=$(git -C "$W" push --force origin t-light 2>&1); rc=$?
check "push --force of a lightweight tag moved to a descendant is refused" \
"$([ "$rc" -ne 0 ] && has "$out" "mosaic push guard: refused: refs/tags/t-light: an existing tag"; echo $?)"
check "the remote lightweight tag hasn't moved" "$([ "$(tagtip t-light)" = "$(git -C "$W" rev-parse HEAD~1)" ]; echo $?)"
out=$(git -C "$W" push origin :refs/tags/t-light 2>&1); rc=$?
check "a tag delete is refused, and the tag stays" \
"$([ "$rc" -ne 0 ] && has "$out" "refs/tags/t-light: a delete" && [ -n "$(tagtip t-light)" ]; echo $?)"
echo "== the hook, fed by hand =="
z=0000000000000000000000000000000000000000
hookrun() { (cd "$C" && printf '%s\n' "$1" | sh "$C/.git/hooks/pre-push" origin "$B" 2>&1); }
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD) refs/heads/x"); rc=$?
check "a short ref line is refused" "$([ "$rc" -ne 0 ] && has "$out" "unreadable ref line"; echo $?)"
out=$(hookrun "refs/heads/x HEAD refs/heads/x $z"); rc=$?
check "a non-hex object name is refused" "$([ "$rc" -ne 0 ] && has "$out" "non-hex"; echo $?)"
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD) refs/heads/x 0"); rc=$?
check "a remote object name of 0 is refused, not read as a new ref" \
"$([ "$rc" -ne 0 ] && has "$out" "isn't 40 or 64 hex characters"; echo $?)"
out=$(hookrun "refs/heads/x $(git -C "$C" rev-parse HEAD | cut -c1-39) refs/heads/x $z"); rc=$?
check "a 39-character local object name is refused" "$([ "$rc" -ne 0 ] && has "$out" "isn't 40 or 64 hex characters"; echo $?)"
out=$(hookrun "refs/heads/x ${z}${z:0:24} refs/heads/x $(git -C "$C" rev-parse HEAD)"); rc=$?
check "a 64-character object name passes the length check (64 zeros refuse as a delete)" \
"$([ "$rc" -ne 0 ] && has "$out" "refs/heads/x: a delete" && ! has "$out" "isn't 40 or 64"; echo $?)"
out=$(hookrun "refs/heads/x 1234567890123456789012345678901234567890 refs/heads/x $(git -C "$C" rev-parse HEAD)"); rc=$?
check "a failed ancestor check (an unknown local object) is refused" \
"$([ "$rc" -ne 0 ] && has "$out" "the ancestor check failed"; echo $?)"
out=$(printf '' | (cd "$C" && sh "$C/.git/hooks/pre-push" origin "$B" 2>&1)); rc=$?
check "an empty push passes" "$rc"
echo "== installer refusals =="
fresh() { rm -rf "$1"; git clone -q "$B" "$1"; }
fresh "$SANDBOX/hp"
git -C "$SANDBOX/hp" config core.hooksPath .husky
out=$(bash "$INSTALL" --repo "$SANDBOX/hp" 2>&1); rc=$?
check "the install refuses with core.hooksPath set (exit 2)" "$([ "$rc" = 2 ]; echo $?)"
check "it says core.hooksPath is set" "$(has "$out" "core.hooksPath is set"; echo $?)"
check "no hook was written under the hooksPath refusal" \
"$([ ! -e "$SANDBOX/hp/.git/hooks/pre-push" ] && [ ! -e "$SANDBOX/hp/.husky/pre-push" ]; echo $?)"
fresh "$SANDBOX/df"
printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/df/.git/hooks/pre-push"
chmod 0755 "$SANDBOX/df/.git/hooks/pre-push"
ln "$SANDBOX/df/.git/hooks/pre-push" "$SANDBOX/df-old"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "the install refuses a different installed pre-push (exit 2)" \
"$([ "$rc" = 2 ] && has "$out" "a different pre-push hook is installed"; echo $?)"
check "the different hook is untouched" \
"$([ "$(cat "$SANDBOX/df/.git/hooks/pre-push")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" --force-install 2>&1); rc=$?
check "--force-install replaces it" \
"$([ "$rc" = 0 ] && has "$out" "replaced a different hook" && cmp -s "$HOOK_SRC" "$SANDBOX/df/.git/hooks/pre-push"; echo $?)"
check "the replacement was a rename: the old file keeps its bytes" \
"$([ "$(cat "$SANDBOX/df-old")" = "$(printf '#!/bin/sh\nexit 0')" ]; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/df" 2>&1); rc=$?
check "a second install over the same bytes exits 0" "$([ "$rc" = 0 ] && has "$out" "the same bytes"; echo $?)"
fresh "$SANDBOX/ln"
ln -s /dev/null "$SANDBOX/ln/.git/hooks/pre-push"
out=$(bash "$INSTALL" --repo "$SANDBOX/ln" --force-install 2>&1); rc=$?
check "the install refuses a symlinked pre-push, even with --force-install" "$([ "$rc" = 2 ] && has "$out" "is a symlink"; echo $?)"
# A hooks directory that links to a shared one would put the hook outside
# the repository, in every repository that shares it.
fresh "$SANDBOX/sl"
mkdir -p "$SANDBOX/shared-hooks"
rm -rf "$SANDBOX/sl/.git/hooks"
ln -s "$SANDBOX/shared-hooks" "$SANDBOX/sl/.git/hooks"
out=$(bash "$INSTALL" --repo "$SANDBOX/sl" 2>&1); rc=$?
check "the install refuses a symlinked hooks directory (exit 2)" "$([ "$rc" = 2 ] && has "$out" "/.git/hooks is a symlink"; echo $?)"
out=$(bash "$INSTALL" --repo "$SANDBOX/sl" --force-install 2>&1); rc=$?
check "it refuses it with --force-install too" "$([ "$rc" = 2 ] && has "$out" "/.git/hooks is a symlink"; echo $?)"
check "nothing was written in the directory the symlink names" "$([ -z "$(ls -A "$SANDBOX/shared-hooks")" ]; echo $?)"
# The temporary copy's name isn't predictable from the installer's pid. The
# subshell plants a link at the pid-named path, then execs the installer, so
# the installer's $$ is that pid.
fresh "$SANDBOX/pt"
printf 'victim\n' > "$SANDBOX/pt-victim"
out=$(pid=$BASHPID; ln -s "$SANDBOX/pt-victim" "$SANDBOX/pt/.git/hooks/.pre-push.install-git-hooks.$pid" && exec bash "$INSTALL" --repo "$SANDBOX/pt" 2>&1); rc=$?
check "the install writes nothing through a link planted at a pid-named temporary path" \
"$([ "$rc" = 0 ] && [ "$(cat "$SANDBOX/pt-victim")" = victim ]; echo $?)"
check "the installed hook is a regular file with the repository's bytes" \
"$([ -f "$SANDBOX/pt/.git/hooks/pre-push" ] && [ ! -L "$SANDBOX/pt/.git/hooks/pre-push" ] && cmp -s "$HOOK_SRC" "$SANDBOX/pt/.git/hooks/pre-push"; echo $?)"
fresh "$SANDBOX/wd"
git -C "$SANDBOX/wd" worktree add -q -b seat/x "$SANDBOX/wd-wt" origin/main
out=$(bash "$INSTALL" --repo "$SANDBOX/wd-wt" 2>&1); rc=$?
check "an install named by a worktree path lands in the common .git/hooks" \
"$([ "$rc" = 0 ] && cmp -s "$HOOK_SRC" "$SANDBOX/wd/.git/hooks/pre-push"; echo $?)"
out=$(GIT_DIR="$SANDBOX/wd/.git" bash "$INSTALL" --repo "$SANDBOX/wd" 2>&1); rc=$?
check "the install refuses with GIT_DIR set" "$([ "$rc" = 2 ] && has "$out" "GIT_DIR is set"; echo $?)"
mkdir -p "$SANDBOX/plain"
out=$(bash "$INSTALL" --repo "$SANDBOX/plain" 2>&1); rc=$?
check "the install refuses a directory that isn't a checkout" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$INSTALL" --nope 2>&1); rc=$?
check "an unknown argument exits 4" "$([ "$rc" = 4 ]; echo $?)"
echo "== slot-busy, fake /proc =="
P="$SANDBOX/proc"
fakeproc() { # $1: pid, $2: cwd, rest: argv
local pid=$1 cwd=$2; shift 2
mkdir -p "$P/$pid"
printf '%s\0' "$@" > "$P/$pid/cmdline"
ln -sfn "$cwd" "$P/$pid/cwd"
}
busyrun() { bash "$BUSY" --proc-root "$P" 2>&1; }
fakeproc 4194401 /srv node server.mjs
fakeproc 4194402 /srv grep -r gate .
# Both wrappers' text would match a rule without the snapshot exclusion.
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && node --test x.test.mjs"
fakeproc 4194405 /srv /usr/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/null || true && eval 'bash scripts/test-x.sh > x.log' && pwd -P >| /home/u/cwd"
# A wrapper that sources files that aren't gates, tests or mutants, then
# runs a gate as a child: the child is matched, the wrapper isn't.
fakeproc 4194406 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source .venv/bin/activate && . ./env.sh && ./gate.sh 253"
mkdir -p "$P/4194404"; : > "$P/4194404/cmdline"
out=$(busyrun); rc=$?
check "no matching process: exit 0 and CLEAR" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
check "an agent's shell-snapshot wrapper isn't a match" "$(! has "$out" "4194403" && ! has "$out" "4194405"; echo $?)"
check "a wrapper that sources other files and runs a gate as a child isn't a match" "$(! has "$out" "4194406"; echo $?)"
fakeproc 4194410 /seat/a node --test packages/x/tests/a.test.mjs
out=$(busyrun); rc=$?
check "a node --test process: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "its BUSY line names pid, rule and cwd" "$(has "$out" "BUSY 4194410 node --test cwd=/seat/a node --test"; echo $?)"
check "BUSY output never says CLEAR" "$(! has "$out" "CLEAR"; echo $?)"
ran=$(bash "$BUSY" --proc-root "$P" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step while BUSY" "$([ -z "$ran" ]; echo $?)"
rm -rf "${P:?}/4194410"
rule() { # $1: expected rule, rest: argv
local want=$1; shift
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@"
out=$(busyrun); rc=$?
check "busy on: ${*//$'\n'/\\n} ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
}
rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts
rule "gate" bash /mnt/scratch/sage-gate.sh 253
rule "gate" bash ./gate.sh
rule "mutant" bash agents/x/work/queue-1/mutants.sh M3
rule "test script" bash scripts/test-land-work.sh
rule "docker compose" docker compose -p x up -d
rule "docker compose" /usr/bin/docker-compose up
# The snapshot exclusion is for the wrapper's shape only: the same command
# without the snapshot source, and real runs whose arguments hold the path.
rule "node --test" /bin/bash -c "node --test x.test.mjs"
rule "node --test" node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs
rule "gate" bash /seat/shell-snapshots/snapshot-gate.sh
# A wrapper that sources a gate, test or mutant script runs it in its own
# shell, with no child to match, so the wrapper itself is BUSY.
SNAP="source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && . /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \"scripts/test-x.sh\" arg"
# A sourced file that isn't one comes first; the one after it still counts.
rule "sourced test script" /bin/bash -c "$SNAP && source .venv/bin/activate && . scripts/test-x.sh"
rule "sourced gate" /usr/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/null || true && eval '. ./gate.sh 253' && pwd -P >| /home/u/cwd"
rule "sourced mutant" /bin/bash -c "$SNAP && cd /seat && eval 'source agents/x/work/queue-1/mutants.sh M3'"
# R5 (N2): every word up to the next operator counts, not only the first; and
# quoted, escaped and nested forms read the way the shell reads them.
rule "sourced test script" /bin/bash -c "$SNAP && source 2>/dev/null /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \$(cd scripts && pwd)/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \`cd scripts; pwd\`/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \"/seat/a;b (c)/scripts/test-x.sh\""
rule "sourced test script" /bin/bash -c "$SNAP && eval 'source \"/seat/a;b/scripts/test-x.sh\"'"
rule "sourced test script" /bin/bash -c "$SNAP && eval \"source \\\"/seat/dir with spaces/scripts/test-x.sh\\\"\""
rule "sourced test script" /usr/bin/bash -c "$SNAP 2>/dev/null || true && eval 'source '\\''scripts/test-x.sh'\\''' && pwd -P >| /home/u/cwd"
rule "sourced test script" /bin/bash -c "$SNAP && eval \$'source scripts/test-x.sh'"
rule "sourced test script" /bin/bash -c "$SNAP && cat <<EOF"$'\n'"don't"$'\n'"EOF"$'\n'"source \"it's dir/scripts/test-x.sh\""
# An unclosed quote runs to the end of the text and is read again there.
rule "sourced test script" /bin/bash -c "$SNAP && echo \"it's && source /seat/scripts/test-x.sh"
# The general rules read the flat form too, and still read the raw one.
rule "test script" /bin/bash -c "bash scripts/test-x.sh;echo done"
rule "gate" bash "/seat/gate;1.sh"
# Two blanks in a row make one in the flat form, with no other character to
# flatten.
rule "docker compose" /bin/sh -c "docker compose -p x up"
rule "docker compose" /bin/sh -c $'docker\tcompose -p x up'
rm -rf "${P:?}/4194420"
idle() { # argv: a process that must leave the check CLEAR
rm -rf "${P:?}/4194421"; fakeproc 4194421 /seat/c "$@"
out=$(busyrun); rc=$?
check "not busy: ${*//$'\n'/\\n}" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
rm -rf "${P:?}/4194421"
}
# The harness's own snapshot path is skipped, quoted or not, whatever its name.
idle /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo hi"
idle /bin/bash -c "source \"/home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh\" && echo hi"
idle /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && . /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo hi"
# Locale: a byte that isn't UTF-8 must not end a name under a UTF-8 locale.
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b bash $'scripts/test-\xffx.sh'
out=$(LC_ALL=C.UTF-8 bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" \
"$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 test script "; echo $?)"
# Quoting that nests deeper than the walk reads is an error, never CLEAR. Each
# level escapes backslashes and double quotes and wraps the text in quotes.
nest() { # $1: levels. Sets w.
w='source scripts/test-x.sh'
for _ in $(seq "$1"); do w=$(printf '%s' "$w" | sed 's/[\\"]/\\&/g'); w="\"$w\""; done
}
nest 16
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w"
out=$(busyrun); rc=$?
check "a sourced test under 16 escaped quoting levels (${#w} bytes): exit 1, sourced test script" \
"$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 sourced test script "; echo $?)"
nest 17
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w"
out=$(busyrun); rc=$?
check "quoting 17 escaped levels deep (${#w} bytes): exit 2, cannot classify, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194420" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194420"
echo "== slot-busy, read errors =="
# A pid directory that stays while its cmdline can't be read is an error,
# never a vanished process: exit 2 and no CLEAR.
mkdir -p "$P/4194430"
out=$(busyrun); rc=$?
check "a pid directory with no cmdline: exit 2" "$([ "$rc" = 2 ]; echo $?)"
check "it names that cmdline and never says CLEAR" "$(has "$out" "cannot read $P/4194430/cmdline" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194430"
mkdir -p "$P/4194431/cmdline"
out=$(busyrun); rc=$?
check "a cmdline that can't be read (a directory): exit 2, no CLEAR" "$([ "$rc" = 2 ] && ! has "$out" CLEAR; echo $?)"
fakeproc 4194410 /seat/a node --test a.test.mjs
out=$(busyrun); rc=$?
check "a read error beside a BUSY process: exit 2, the BUSY line kept, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "BUSY 4194410 node --test" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194431"
chmod 000 "$P/4194410/cmdline"
out=$(busyrun); rc=$?
check "a node --test process whose cmdline is unreadable (mode 000) is never CLEAR" "$([ "$rc" != 0 ] && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194410"
mkdir -p "$SANDBOX/failtr" "$SANDBOX/vanishtr" "$SANDBOX/failawk"
printf '#!/bin/sh\nexit 1\n' > "$SANDBOX/failtr/tr"
printf '#!/bin/sh\nexit 1\n' > "$SANDBOX/failawk/awk"
# This tr stands in for a process that exits during the read: for pid
# 4194440 it removes the pid directory and fails; otherwise it is tr.
cat > "$SANDBOX/vanishtr/tr" <<EOF
#!/bin/sh
f=\$(readlink "/proc/\$\$/fd/0")
case "\$f" in */4194440/cmdline) rm -rf "\${f%/cmdline}"; exit 1 ;; esac
exec $(command -v tr) "\$@"
EOF
chmod +x "$SANDBOX/failtr/tr" "$SANDBOX/failawk/awk" "$SANDBOX/vanishtr/tr"
out=$(PATH="$SANDBOX/failtr:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "a failing read (tr exits 1) with every pid directory still there: exit 2, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot read" && ! has "$out" CLEAR; echo $?)"
fakeproc 4194440 /seat/v node --test v.test.mjs
out=$(PATH="$SANDBOX/vanishtr:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "a process that vanishes during its read isn't an error: exit 0 and CLEAR" \
"$([ "$rc" = 0 ] && [ "$out" = CLEAR ] && [ ! -e "$P/4194440" ]; echo $?)"
rm -rf "${P:?}/4194440"
out=$(PATH="$SANDBOX/failawk:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "its own process unreadable (awk exits 1): exit 2, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot read its own process" && ! has "$out" CLEAR; echo $?)"
# Every ancestor's status read counts, not only the first. The awk below is
# the real awk for call 1 (the scan's own status) and then fails or prints
# nothing; a third one prints a pid that isn't in /proc on call 2, so the
# walk meets an ancestor that has exited.
mkdir -p "$SANDBOX/awkn"
cat > "$SANDBOX/awkn/awk" <<EOF
#!/bin/sh
n=\$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0); n=\$((n + 1)); echo "\$n" > "$SANDBOX/awkn/n"
if [ "\$n" -ge 2 ]; then
case \$(cat "$SANDBOX/awkn/mode") in
fail) exit 1 ;;
empty) exit 0 ;;
exited) if [ "\$n" = 2 ]; then echo 4194999; exit 0; fi ;;
esac
fi
exec $(command -v awk) "\$@"
EOF
chmod +x "$SANDBOX/awkn/awk"
awkn() { # $1: fail, empty or exited; rest: NAME=VALUE for the check's environment.
echo "$1" > "$SANDBOX/awkn/mode"; rm -f "$SANDBOX/awkn/n"; shift
out=$(env "$@" PATH="$SANDBOX/awkn:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
calls=$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0)
}
awkn fail
check "an ancestor's status read that fails after its own read passed: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
awkn empty
check "an ancestor's status with no PPid: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
awkn exited
check "an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR" \
"$([ "$calls" -ge 3 ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
# R5 (N1): the gone check reads the proc root's listing whatever the caller's
# QUOTING_STYLE is. A quoted name would read as a vanished pid, and both
# failed reads above would turn into CLEAR.
for qs in shell-always c escape shell-escape-always locale; do
out=$(QUOTING_STYLE=$qs PATH="$SANDBOX/failtr:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "QUOTING_STYLE=$qs: a failing cmdline read with the pid still listed: exit 2, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot read $P/" && ! has "$out" CLEAR; echo $?)"
awkn fail QUOTING_STYLE=$qs
check "QUOTING_STYLE=$qs: a failing ancestor status read with the pid still listed: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
done
# The flat form and the walk over a wrapper's text are awk programs. If
# either awk fails, or prints nothing, the command line can't be classified:
# exit 2, never CLEAR. This awk fails or goes silent for one program only
# (the walk has `function walk`, the flat form `gsub`).
mkdir -p "$SANDBOX/lexawk"
cat > "$SANDBOX/lexawk/awk" <<EOF
#!/bin/sh
m=\$(cat "$SANDBOX/lexawk/mode")
case "\$*" in
*"function walk"*) case \$m in walk-fail) cat > /dev/null; exit 1 ;; walk-empty) cat > /dev/null; exit 0 ;; esac ;;
*gsub*) case \$m in flat-fail) cat > /dev/null; exit 1 ;; flat-empty) cat > /dev/null; exit 0 ;; esac ;;
esac
exec $(command -v awk) "\$@"
EOF
chmod +x "$SANDBOX/lexawk/awk"
lexawk() { # $1: mode, $2: label, rest: argv of a process that is BUSY
local mode=$1 label=$2; shift 2
fakeproc 4194460 /seat/w "$@"
echo "$mode" > "$SANDBOX/lexawk/mode"
out=$(PATH="$SANDBOX/lexawk:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "$label: exit 2, cannot classify, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194460" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194460"
}
lexawk walk-fail "the wrapper walk's awk exits 1" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
lexawk walk-empty "the wrapper walk's awk prints nothing" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
# Only the flat form names this one (`.sh;` ends no rule's match as it is).
lexawk flat-fail "the flat form's awk exits 1" /bin/bash -c "bash scripts/test-x.sh;echo done"
lexawk flat-empty "the flat form's awk prints nothing" /bin/bash -c "bash scripts/test-x.sh;echo done"
# A lookup that is denied isn't a process that vanished. This tr makes the
# proc root unreadable once the pid directory is listed, then fails the read;
# the pid directory and its node --test cmdline stay where they are.
DP="$SANDBOX/dproc"
mkdir -p "$SANDBOX/denytr" "$DP/4194450"
printf '%s\0' node --test d.test.mjs > "$DP/4194450/cmdline"
cat > "$SANDBOX/denytr/tr" <<EOF
#!/bin/sh
f=\$(readlink "/proc/\$\$/fd/0")
case "\$f" in */4194450/cmdline) chmod 000 "$DP"; exit 1 ;; esac
exec $(command -v tr) "\$@"
EOF
chmod +x "$SANDBOX/denytr/tr"
LOCKED="$LOCKED $DP"
out=$(PATH="$SANDBOX/denytr:$PATH" bash "$BUSY" --proc-root "$DP" 2>&1); rc=$?
denied=$([ ! -r "$DP" ] && echo yes); chmod 0755 "$DP"
check "a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR" \
"$([ "$denied" = yes ] && [ "$rc" = 2 ] && has "$out" "cannot read $DP/4194450/cmdline" && ! has "$out" CLEAR; echo $?)"
check "the denied pid directory and its node --test cmdline were still there" \
"$([ "$(tr '\0' ' ' < "$DP/4194450/cmdline")" = "node --test d.test.mjs " ]; echo $?)"
# The root turns unreadable after the first pid's read went through: the next
# pid, a node --test, is a doubt, not a skip.
rm -rf "${DP:?}/4194450"
mkdir -p "$SANDBOX/locktr" "$DP/4194451" "$DP/4194452"
printf '%s\0' sleep 60 > "$DP/4194451/cmdline"
printf '%s\0' node --test l.test.mjs > "$DP/4194452/cmdline"
cat > "$SANDBOX/locktr/tr" <<EOF
#!/bin/sh
f=\$(readlink "/proc/\$\$/fd/0")
case "\$f" in */4194451/cmdline) chmod 000 "$DP" ;; esac
exec $(command -v tr) "\$@"
EOF
chmod +x "$SANDBOX/locktr/tr"
out=$(PATH="$SANDBOX/locktr:$PATH" bash "$BUSY" --proc-root "$DP" 2>&1); rc=$?
denied=$([ ! -r "$DP" ] && echo yes); chmod 0755 "$DP"
check "a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR" \
"$([ "$denied" = yes ] && [ "$rc" = 2 ] && has "$out" "cannot read $DP/4194452/cmdline" && ! has "$out" CLEAR; echo $?)"
out=$(bash "$BUSY" --proc-root "$SANDBOX/no-such-proc" 2>&1); rc=$?
check "a missing proc root: exit 2" "$([ "$rc" = 2 ]; echo $?)"
mkdir -p "$SANDBOX/empty-proc"
out=$(bash "$BUSY" --proc-root "$SANDBOX/empty-proc" 2>&1); rc=$?
check "a proc root with no processes: exit 2" "$([ "$rc" = 2 ]; echo $?)"
out=$(bash "$BUSY" --nope 2>&1); rc=$?
check "an unknown argument: exit 2" "$([ "$rc" = 2 ]; echo $?)"
echo "== slot-busy, real /proc =="
mkdir -p "$SANDBOX/bin" "$SANDBOX/standin"
cat > "$SANDBOX/bin/node" <<'EOF'
#!/bin/sh
trap 'exit 0' TERM
while :; do sleep 0.2; done
EOF
chmod +x "$SANDBOX/bin/node"
(cd "$SANDBOX/standin" && exec "$SANDBOX/bin/node" --test standin.test.mjs) &
STANDIN=$!
for _ in $(seq 50); do
has "$(tr '\0' ' ' < "/proc/$STANDIN/cmdline" 2>/dev/null)" "--test standin" && break
sleep 0.1
done
out=$(bash "$BUSY" 2>&1); rc=$?
check "with a stand-in node --test running: exit 1" "$([ "$rc" = 1 ]; echo $?)"
check "the stand-in has a BUSY line with its cwd" \
"$(printf '%s\n' "$out" | grep -q "^BUSY $STANDIN node --test cwd=$SANDBOX/standin "; echo $?)"
check "this suite, an ancestor, has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $$ "; echo $?)"
ran=$(bash "$BUSY" >/dev/null && echo ran)
check "\`slot-busy > LOG && step\` doesn't run the step with the stand-in up" "$([ -z "$ran" ]; echo $?)"
kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null
gone=$STANDIN; STANDIN=""
out=$(bash "$BUSY" 2>&1); rc=$?
check "after the stand-in exits, it has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $gone "; echo $?)"
check "the real-/proc run exits 0 or 1, never 2" "$([ "$rc" = 0 ] || [ "$rc" = 1 ]; echo $?)"
echo "== slot-busy, real snapshot wrappers =="
# Live wrappers, each shown to the check through a proc root that links only
# its own real /proc directories, so other jobs on the host don't count.
mkdir -p "$SANDBOX/shell-snapshots" "$SANDBOX/scripts" "$SANDBOX/wrap"
: > "$SANDBOX/shell-snapshots/snapshot-x.sh"
mkfifo "$SANDBOX/wrap/fifo"
# Sourced: it says it's ready, then blocks in the read builtin, no child.
cat > "$SANDBOX/scripts/test-x.sh" <<EOF
exec 3<>"$SANDBOX/wrap/fifo"
: > "$SANDBOX/wrap/ready"
read -t 60 -u 3 _
EOF
(cd "$SANDBOX/wrap" && exec bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && source $SANDBOX/scripts/test-x.sh") &
WRAP=$!; KIDS="$KIDS $WRAP"
for _ in $(seq 50); do [ -e "$SANDBOX/wrap/ready" ] && break; sleep 0.1; done
kids=$(cat "/proc/$WRAP/task/$WRAP/children") && kids="none$kids"
LP="$SANDBOX/lproc1"; mkdir -p "$LP"; ln -s "/proc/$WRAP" "$LP/$WRAP"
out=$(bash "$BUSY" --proc-root "$LP" 2>&1); rc=$?
check "a live wrapper that sourced a test script, blocked in a builtin with no child: exit 1" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 1 ] && ! has "$out" CLEAR; echo $?)"
check "its BUSY line names the wrapper's pid, the sourced rule and its cwd" \
"$(printf '%s\n' "$out" | grep -q "^BUSY $WRAP sourced test script cwd=$SANDBOX/wrap "; echo $?)"
out=$(bash "$BUSY" 2>&1); rc=$?
check "the real /proc has a BUSY line for that wrapper too" \
"$([ "$rc" != 0 ] && printf '%s\n' "$out" | grep -q "^BUSY $WRAP sourced test script "; echo $?)"
kill "$WRAP" 2>/dev/null; wait "$WRAP" 2>/dev/null; KIDS=""
# External: the wrapper runs node --test as a child (`; :` keeps bash from
# exec'ing it). The wrapper is skipped and its child is BUSY.
(cd "$SANDBOX/wrap" && exec bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && $SANDBOX/bin/node --test active.test.mjs; :") &
WRAP=$!; KIDS="$KIDS $WRAP"; kid=""
for _ in $(seq 50); do
kid=$(cat "/proc/$WRAP/task/$WRAP/children" 2>/dev/null); kid=${kid%% *}
[ -n "$kid" ] && has "$(tr '\0' ' ' < "/proc/$kid/cmdline" 2>/dev/null)" "--test active" && break
sleep 0.1
done
KIDS="$KIDS $kid"
LP="$SANDBOX/lproc2"; mkdir -p "$LP"; ln -s "/proc/$WRAP" "$LP/$WRAP"; [ -n "$kid" ] && ln -s "/proc/$kid" "$LP/$kid"
out=$(bash "$BUSY" --proc-root "$LP" 2>&1); rc=$?
check "a live wrapper that runs node --test as a child: exit 1 on the child's BUSY line" \
"$([ -n "$kid" ] && [ "$rc" = 1 ] && printf '%s\n' "$out" | grep -q "^BUSY $kid node --test "; echo $?)"
check "the wrapper itself has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $WRAP "; echo $?)"
# The wrapper exits by itself once its child has; only then are both gone.
[ -n "$kid" ] && kill "$kid" 2>/dev/null
for _ in $(seq 50); do kill -0 "$WRAP" 2>/dev/null || break; sleep 0.1; done
if ! kill -0 "$WRAP" 2>/dev/null; then wait "$WRAP" 2>/dev/null; KIDS=""; fi
# R5 (N2): the 13 shapes from the R4 review's Check 2, each a live wrapper
# that runs the sourced file in its own shell, with no child. S is the
# snapshot, SF a snapshot that holds `false` (so `||` runs the test), T the
# test. The sourced file blocks as test-x.sh does above.
S="$SANDBOX/shell-snapshots/snapshot-x.sh"
SF="$SANDBOX/shell-snapshots/snapshot-f.sh"
T="$SANDBOX/scripts/test-x.sh"
echo false > "$SF"
mkdir -p "$SANDBOX/wrap/scripts" "$SANDBOX/dir with spaces/scripts"
for f in wrap/scripts/test-x.sh wrap/gate.sh wrap/mutants.sh "dir with spaces/scripts/test-x.sh"; do
cp "$T" "$SANDBOX/$f"
done
live() { # $1: shape number, $2: expected rule or CLEAR, $3: label, $4: the wrapper's text
local n=$1 want=$2 label=$3 lp
rm -f "$SANDBOX/wrap/ready"
(cd "$SANDBOX/wrap" && exec bash -c "$4") &
WRAP=$!; KIDS="$KIDS $WRAP"
for _ in $(seq 50); do [ -e "$SANDBOX/wrap/ready" ] && break; sleep 0.1; done
kids=$(cat "/proc/$WRAP/task/$WRAP/children") && kids="none$kids"
lp="$SANDBOX/lproc-s$n"; mkdir -p "$lp"; ln -s "/proc/$WRAP" "$lp/$WRAP"
out=$(bash "$BUSY" --proc-root "$lp" 2>&1); rc=$?
if [ "$want" = CLEAR ]; then
check "live shape $n, $label, no child: exit 0 and CLEAR" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
else
check "live shape $n, $label, no child: exit 1, $want" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 1 ] && ! has "$out" CLEAR \
&& printf '%s\n' "$out" | grep -q "^BUSY $WRAP $want cwd=$SANDBOX/wrap "; echo $?)"
fi
kill "$WRAP" 2>/dev/null; wait "$WRAP" 2>/dev/null; KIDS=""
}
live 1 "sourced test script" 'source S && source T' "source $S && source $T"
live 2 "sourced test script" "source S && .<TAB><TAB>'scripts/test-x.sh'" "source $S && ."$'\t\t'"'scripts/test-x.sh'"
live 3 "sourced test script" 'source S ; source T ; :' "source $S ; source $T ; :"
live 4 "sourced test script" 'source SF || source T' "source $SF || source $T"
live 5 "sourced gate" 'source S && . "./gate.sh"' "source $S && . \"./gate.sh\""
live 6 "sourced mutant" 'source S && source ./mutants.sh' "source $S && source ./mutants.sh"
live 7 "sourced test script" 'source S && source ".../dir with spaces/scripts/test-x.sh"' \
"source $S && source \"$SANDBOX/dir with spaces/scripts/test-x.sh\""
live 8 "sourced test script" 'source S && "source" T' "source $S && \"source\" $T"
live 9 "sourced test script" 'source S;source T;:' "source $S;source $T;:"
live 10 "sourced test script" 'source S;. T;:' "source $S;. $T;:"
live 11 "sourced test script" 'source "S" && source "T"' "source \"$S\" && source \"$T\""
live 12 "sourced test script" 'source<TAB>S && source "T"' "source"$'\t'"$S && source \"$T\""
live 13 "sourced test script" 'source<SPACE><SPACE>S && source "T"' "source $S && source \"$T\""
# A plain wrapper that blocks the same way but sources no test stays CLEAR.
live 14 CLEAR 'a plain wrapper, the same blocking read inline' \
"source $S && exec 3<>\"$SANDBOX/wrap/fifo\" && : > \"$SANDBOX/wrap/ready\" && read -t 60 -u 3 _"
echo "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]