Compare commits

..
18 changed files with 1481 additions and 728 deletions

No files matched your search

-142
View File
@@ -1,142 +0,0 @@
# 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: aca16404c. Branch: `darkwing/row211`. 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.
## 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.
-74
View File
@@ -1,74 +0,0 @@
#!/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).
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
run() { # $1: name, $2: file, $3: from, $4: to
local name=$1 file=$2 t log
t=$(tree "$name") || { echo "$name: COPY FAILED"; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; return; fi
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
log="$LOG/mut-$name.log"
if suite "$t" "$log"; then
echo "$name: survived"
else
echo "$name: killed (exit $?, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
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 [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi\n' ''
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
+135
View File
@@ -0,0 +1,135 @@
# Row 258, E01: mail PRD addendum draft and schemas
Ludwig, 2026-10-22. Issue #1763. Assigned by sage-2802 under Q220. Reviewer: Della.
Base: aca16404c. Branch: ludwig/row258.
Sources: brief `docs/plans/2026-10-21_post-v1-row-briefs.md` (blob d7e5eaeea), "Brief: E01" and
"Gate: email rows". Email plan row E01 at origin/della/email-plan 179391055 (approved ludwig-0037).
Q2/Q222 terms (sage-2802): `mailboxScope` defaults to `[]`, changes are human-only, and the default-none
and refusal tests apply.
## Deliverable
| Path | What |
| --- | --- |
| `docs/prd/mail-addendum-draft.md` | REQ-MAIL-1 to 16, a draft. Each requirement names its rows. Two questions for Mos. |
| `packages/mail/schemas/*.schema.json` | source, data, access, service, content-key |
| `packages/mail/src/validate.mjs` | Closed-subset validator, binding, location, file and key-lifecycle checks |
| `packages/mail/tests/schema.test.mjs` | T1-T14, with T10b |
| `packages/mail/{package.json,README.md}` | Package manifest and notes |
Not changed: `docs/prd/mosaic-stack.md`, its requirements JSON and tests, `packages/plugins/src/kinds.mjs`,
`packages/business/src/vocabulary.mjs`, roles, `scripts/stack-copy.sh` COPY_PATHS. No new dependency.
## Choices and tradeoffs
1. **Validator.** A copy of the `packages/project/src/validate.mjs` pattern: a closed keyword set
that throws on an unknown keyword. Added `integer`, `minimum`, `maximum`, `uniqueItems` and `default`.
No JSON Schema library, so there is no dependency review. Tradeoff: two small validators to keep apart.
A shared one is a later refactor, not this row.
2. **Defaults live in the schema** (`default` keyword), read by `defaults(kind)`. One definition per
default and range, as Q222 asks.
3. **Ranges (implementer choice, for the reviewer to challenge).** Defaults come from the plan.
| Setting | Default | Min | Max |
| --- | --- | --- | --- |
| pollSeconds | 60 | 15 | 3600 |
| sliceObservations | 50 | 1 | 500 |
| sliceBytes | 10 MiB | 64 KiB | 64 MiB |
| pendingDeliveries | 1,000 | 1 | 100,000 |
| retainedBytes | 100 MiB | 1 MiB | 10 GiB |
| parserInputBytes | 30 MiB | 1 MiB | 64 MiB |
| mimeDepth | 10 | 1 | 32 |
| mimeParts | 100 | 1 | 1,000 |
| normalizedTextBytes | 256 KiB | 4 KiB | 4 MiB |
| attachmentPartBytes | 10 MiB | 0 | 50 MiB |
| attachmentMessageBytes | 10 MiB | 0 | 100 MiB |
| staleScanIntervals | 3 | 2 | 20 |
| pageSize | 20 | 1 | 100 |
| bodyDraftDays | 30 | 1 | 3,650 |
| attachmentDays | 7 | 1 | 3,650 |
| metadataDays | 365 | 1 | 3,650 |
Cross-checks: part bytes ≤ message bytes; metadataDays ≥ both content durations (tombstones outlive
content, D4).
4. **Fixed false until later rows.** `persistContent` and `automaticPrune` are `const false`: the plan
says metadata only until retention authority exists (E13), and settings never grant `data.delete`.
`sourceMutations` has `maxItems: 0`: no mutation capability exists (Q3). E13 or a later reviewed row
widens these.
5. **Human-only.** Settings records require `changedBy.kind: "human"`. The schema can't tell a real human
from an agent proxy. The E02 writer proves that, as the plan's E02 row says.
6. **Incomplete scopes.** An enabled source needs an adapter. Unattended wake needs at least one exact
sender: wake on with an empty allowlist would be on in name only. Grants need at least one right and
one mailbox. Service scopes are `["mail.read"]` only.
7. **Secret values.** Unknown keys are refused. All string values are also checked against secret
shapes (private key header, JWT, common token prefixes, URL userinfo password, `password=` style).
Messages name the path, never the value. This is a backstop, not a scanner.
8. **Content-key record location.** The plan names the key path. The lifecycle record's own file is left
to E03. `checkKeyLocation` and `checkProtectedFile` apply to both.
9. **Key lifecycle.** active → retired → expired, or active → expired. `keyTransitionAllowed` is a pure
check on one state edge. It doesn't persist state, stop a restored backup rolling a record back, or
destroy key copies; E03 owns that proof (della-0023 N1). `keyUse` is none outside
[createdAt, expiresAt), whatever the state says.
10. **v1 compatibility.** T13 asserts that kinds.mjs names no mail, vocabulary.mjs has no `mail.access`,
and the approved PRD has no REQ-MAIL. E05 and the approved addendum update T13 when they land.
11. **Use helpers fail closed (della-0023 F1).** `effectiveMailboxes` and `keyUse` validate their
records first and read the clock only from a valid `Date`. An invalid record or clock, or a time
outside [issuedAt, expiresAt) or [createdAt, expiresAt), gives no read and no key use. Tradeoff: a
caller with a slightly malformed but otherwise usable grant gets nothing, not a partial read. E02
still owns principal, right, manual-mode and revoke enforcement.
12. **Placement compares resolved paths too (della-0023 F2).** `checkSlotLocation` and
`checkKeyLocation` check the paths as written first, so planned paths that don't exist yet still
work. Then they resolve the nearest existing ancestor of the secret path and of each forbidden root,
and refuse containment. That catches a configDir linked into dataRoot and a root named through an
alias. `checkProtectedFile` now needs the file lexically under configDir, refuses a symbolic link
on configDir itself or any directory below it, and takes the forbidden roots for the same resolved
comparison. Symbolic links above configDir (a linked `/home`, say) stay allowed: they don't move
the secret relative to the roots, and the resolved comparison still covers them. Tradeoff: one
`realpath` per root on each check.
13. **Draft wording (mos-1313).** W1: rulings are cited as "DECISIONS Q…", plan questions as "email
plan Q…", and "Q4" is replaced by what it stands for. W2: retention says expiry stops serving an
item and removal waits for `data.delete`. W3: an invalid mail setting is refused when written and
holds its source with an alert when found at load; no fallback to the default. W4: the questions
section is gone; Mos's two answers are recorded in the status lines.
## Test plan (not run: Gander holds the slot, sage-2802)
`cd packages/mail && node --test tests/schema.test.mjs`. It writes only under `os.tmpdir()`, which the gate
sets to a `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX` directory. No network and no `~/.mosaic-dev`.
Static checks done: schemas written by an uncommitted scratch script (the JSON files are the source),
JSON parses, every keyword is in the validator's closed set, every required key has a property, T13/T14 assertions checked against
the repository text with python3 -B, secret and wildcard patterns checked against every fixture and
sentinel. Node was not run.
Existing suites to run whole, because they scan every package's `src` or copy tracked package files:
- `packages/bus/tests/credential-store.test.mjs` (credential use sites)
- `packages/bus/tests/host-run.test.mjs` (T24 kind lists and use sites)
- `packages/plugins/tests/harness.test.mjs` (harness names outside plugins)
- `packages/cli/tests/stack-copy.test.mjs`, `packages/cli/tests/stack-release.test.mjs` (fixture copies)
Mutants, named by Della (della-0023). Each must die on a semantic assertion in the whole
`schema.test.mjs`, run on a CLEAR slot. The wildcard tests stay.
| Mutant | Change | Must fail |
| --- | --- | --- |
| M1 | `mailboxScope` default nonempty | T1/T2 |
| M2 | allow an agent settings author | T3 |
| M3 | remove the source/business binding comparison | T8 |
| M4 | disable secret-shape rejection | T6 |
| M5 | omit the grant intersection | T2 |
| M6 | omit the forbidden-root exclusion | T10 (config under dataRoot) |
| M7 | allow mode 0640 | T11 |
| M8 | allow retired → active | T12 |
| M9 | bypass an enforced numeric lower or upper comparison in `visit` | T9 |
| M10 | omit the malformed-state/time guard | T2/T12 lifetime and malformed cases |
| M11 | omit the canonical or root-symlink guard | T10b/T11 |
## Round 2 (della-0023, mos-1313)
Round 1 was a8f0cf924, CHANGES (della-0023). Round 2 adds F1, F2 and N1 above, and Mos's W1-W4 on the
draft. New cases: T2 lifetime edges and malformed grant or clock, T12 lifetime edges and malformed key
or clock, T10b (configDir linked into dataRoot with a real 0600 key, a planned slot through the link,
a dataRoot alias), T11 (file outside configDir, unnormalized path, linked configDir, root alias).
Static checks only; Node not run (Gander holds the slot).
-29
View File
@@ -166,37 +166,8 @@ 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`) | 61 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` 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; 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. 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, 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, and reads only `/proc/<pid>/cmdline` and
the cwd link.
## Seat launch with registration (`scripts/mosaic`)
```bash
+164
View File
@@ -0,0 +1,164 @@
# PRD addendum draft: mail intake and access
- Status: draft, not approved. Ludwig drafted it for email plan row E01
(Vikunja 460, issue #1763). Its wording goes to Mos first, then Jason.
It does not change `docs/prd/mosaic-stack.md`. Once Jason approves it,
it lands there as a new PRD version, and only then does any requirement
here bind.
- Source: `docs/plans/2026-10-21_email-plan.md`, Della's revision
179391055, approved by Ludwig (ludwig-0037). DECISIONS Q217 settles
the mailbox (one agent mailbox on woltje.com at InMotion, which Jason
creates). DECISIONS Q222 settles personal mail (none for now) and the
settings rule: things are tunable, not hardcoded.
- Shape (mos-1313): once approved, this lands as one new PRD version with
a Mail section, in its own reviewed row. `mail.access` stays a hard
wait in that version.
- Scope: post-v1. None of it is needed for 2026-10-27.
- Q numbers: "DECISIONS Q…" is a ruling. "Email plan Q…" is a question
still open in the email plan.
## How to read this draft
Every limit, scope and time below is a setting with a default, unless
Jason fixed it himself. The default is the starting value, not a rule.
The schemas in `packages/mail/schemas/` hold each default and the range a
setting may take. Implementers and reviewers set that range. Widening it
needs a reviewed schema and test change. Only an authenticated human
changes a setting, through the audited CLI and later the Console. An
agent can't change one, not even to narrow it. For mail, an invalid
setting doesn't fall back to its default. A write with an invalid value
is refused. An invalid value found when the service loads holds that
source and raises an alert.
Each requirement lists the build rows that deliver it. The rows are in
the email plan's "Ordered build rows".
## Requirements
### Mail
- **REQ-MAIL-1.** The stack may run one mail service per instance. It is
off by default and opens no connection while off. A disabled source
makes no probe and no read. Each configured source has one leased
owner. Accounts stay separate by instance, business and source. One
service never means one shared credential or mailbox.
Rows: E01, E03, E14.
- **REQ-MAIL-2.** Each source has a mailbox scope, `mailboxScope`. It
defaults to none, which reads nothing, agent ingress included. A human
sets exact mailbox IDs. No wildcard is accepted. The scope is
rechecked before each scan and fetch. A mailbox removed from scope is
fenced before in-flight content returns. A read is the intersection of
the source scope, the provider's rights and the principal's current
grant. Widening a scope grants no agent anything. No personal mailbox
is enrolled until a later consent and grant (DECISIONS Q222).
Rows: E01, E02, E13.
- **REQ-MAIL-3.** A new plug-in kind, `mail-source`, carries providers
(REQ-PLUG-1). Its modules export `descriptor`, `scan`, `fetch` and
`health`. A module only reads: it never sends, deletes, labels, moves
or marks mail read. Microsoft 365 (Graph) and IMAP are the first
modules. POP3, Gmail API and JMAP wait for a named need. The kind
enters `packages/plugins/src/kinds.mjs` with its first production
module. That waits for the approved PRD version, and for the source,
grant and route approvals in REQ-MAIL-4, 7 and 8.
Rows: E05, E06.
- **REQ-MAIL-4.** A source connects through its own service principal,
`mail-service`. This is a new approved extension, not an exception to
REQ-CRED-1. Its record names the business, source, provider, mailbox,
scopes (read only), slot path, rotation date, approval and revision.
The slot sits under
`<configDir>/secrets/mosaic-stack/mail/<business>/<source>/`, never in
the repository or dataRoot. Agents never see the credential. A missing,
expired or revoked slot fails closed. Authentication may retry once,
then stops and alerts.
Rows: E01, E02.
- **REQ-MAIL-5.** Intake keeps a durable record of what it saw. The
provider cursor and the new observations commit together. A message's
identity is its provider ID within business, source and account, never
Message-ID alone. Delivery uses a stable key and a receipt at the
recipient, so a crash causes no second task. First activation starts
from now and acts on no old mail. Importing history is a separate
approved window. Per-source quotas default to 1,000 pending deliveries
and 100 MiB of content. A full quota, a full disk or missing audit
storage holds intake and raises an alert.
Rows: E03.
- **REQ-MAIL-6.** Parsing is bounded. Defaults: 30 MiB input, depth 10,
100 parts, 256 KiB of normalized text. Bad or truncated mail is
quarantined, never dropped. Active content never runs. Attachments
start as metadata. An authorized fetch quarantines the bytes, by
default up to 10 MiB per part and per message. Extracting attachment
content stays Jason's choice (email plan Q8). If he chooses it, extraction runs
isolated with no network and a scanner.
Rows: E04, E16.
- **REQ-MAIL-7.** A route table maps trusted delivery evidence to a Stack
user or role instance. It lives in the organization file and changes
only through the human-approved write path. A route grants no read
right. No agent reroutes mail or widens its own route. Bcc, ambiguous
alias or forwarded mail goes to restricted review. Unknown addresses
create no agent.
Rows: E07, E08.
- **REQ-MAIL-8.** Agent access to mail needs a `mail.access` grant. It is
separate from `credential.mint`. The chief raises it, and the human
approves the exact principal, source, mailboxes, rights and expiry.
Metadata, body, attachment and draft are separate rights. Each read
rechecks the current grant. A revoke fences new reads and in-flight
reads before content returns. Manual mode asks the human for each use.
`mail.access` is a new vocabulary action, so it is a hard wait
(REQ-WAIT-3) until a PRD version Jason approves says otherwise
(REQ-WAIT-2).
Rows: E02, E09.
- **REQ-MAIL-9.** Every harness gets the same mail tools through the
broker: list, get, thread, attachment, acknowledge, create a local
draft and health. A list page defaults to 20 items. Acknowledge
changes Stack state only. A draft is a local unsent record. There is
no send tool and no reroute tool.
Rows: E09.
- **REQ-MAIL-10.** Mail content is untrusted. Tool results carry
`untrusted: true`. A sender can't approve a decision, change policy,
mint a grant or pose as the human. Mail never wakes an agent by
default (`unattendedWake` is off, and its sender allowlist is empty).
A human may turn it on for a route. Then a wake also needs aligned
DMARC, a current grant and admission by the system manager
(DECISIONS Q198).
The woken work runs in a fresh restricted profile, and anything it
starts keeps `source: mail`.
Rows: E10.
- **REQ-MAIL-11.** A mail-triggered decision goes through the stack's one
decision module (DECISIONS Q207). Its input is metadata only, inside a closed
schema. The mail service never calls a model itself. Without a model,
deterministic intake keeps working and model work is held.
Rows: E11.
- **REQ-MAIL-12.** Mail health reports real progress: last scan, last
cursor commit, lag, backlog and credential state. A live process alone
is not healthy. Outages pause only the affected source. Recovery
follows the system manager's protected mode and its recovery latch
(DECISIONS Q198). A health check sends no mail and calls no model.
Rows: E12.
- **REQ-MAIL-13.** Mail data lives under `<dataRoot>/mail/<business>/`
and never enters git or an image. By default the stack keeps metadata
only. Content keys sit under
`<configDir>/secrets/mosaic-stack/mail-content/<business>/`, outside
dataRoot and its backups, mode 600, with no symbolic link. Retention
defaults: bodies and drafts 30 days, attachments 7 days, metadata 365
days. When an item's time runs out, the stack stops serving it. A
retention setting never authorizes deletion: removing the item waits
for `data.delete` or a retention authority Jason approves later. A
restored backup must not bring back expired content or redeliver old
work.
Rows: E01, E03, E13.
- **REQ-MAIL-14.** The mail service never sends. No SMTP, Graph send,
forward, auto-reply or emailed digest. The email specialist reads and
drafts, never sends (R21). A future sender needs its own plan and
`message.external` approval.
Rows: E05, E06, E09, E14.
- **REQ-MAIL-15.** Every mail limit, scope and retention time is a
setting. The schema holds its default and range. Only a human changes
it. A change is versioned and audited with who made it. An invalid
value is refused when written, and holds its source with an alert when
found at load. Settings never
override grants, hard waits or the system manager's contract.
Rows: E01, E04, E13.
- **REQ-MAIL-16.** A live pilot is separate. It needs Jason's mailbox,
the approved PRD version, the source, grant and route approvals in
REQ-MAIL-4, 7 and 8, and a reviewed re-pin. It sends no test mail, and it
is judged on evidence for each message, not counts.
Rows: E15.
+59
View File
@@ -0,0 +1,59 @@
# Mail records — schemas only (email plan row E01)
Post-v1. This package holds the record schemas for mail intake and access,
and a validator. Nothing here opens a connection, reads a secret or
registers a plug-in kind. Core `mail-source` registration waits for the
approved PRD version and the source, grant and route approvals. The addendum draft is
`docs/prd/mail-addendum-draft.md`; it binds nothing until Jason approves it.
| Schema | Record |
| --- | --- |
| `schemas/source.schema.json` | Per-source settings: enabled, adapter, `mailboxScope`, wake, limits |
| `schemas/data.schema.json` | Per-business content and retention settings |
| `schemas/access.schema.json` | A `mail.access` grant to one agent or user |
| `schemas/service.schema.json` | A source's service principal and its slot path |
| `schemas/content-key.schema.json` | A content key's location and lifecycle |
## Settings (DECISIONS Q222)
Every limit, scope and retention time is a setting. Its default and its
range sit in the schema, so they have one definition. `defaults(kind)`
reads them. Implementers and reviewers set the range; widening it is a
reviewed schema and test change. A settings record carries
`changedBy.kind: "human"`, and the validator refuses any other author.
Defaults are off and empty: `sourceEnabled: false`, `mailboxScope: []`,
`sourceMutations: []`, `accessMode: "manual"`, `unattendedWake: false`,
`wakeSenderAllowlist: []`, `persistContent: false` and
`automaticPrune: false`. `persistContent` and `automaticPrune` are fixed
false until retention authority exists (E13).
## Checks
- `validateRecord(kind, value)` returns a list of problems. It refuses
unknown keys, wildcards, secret-shaped values, values outside the
schema range and incomplete scopes. A refusal never echoes the value.
- `checkBinding({source, service, access})` refuses records that name a
different business, source or provider, and a grant past the source's
mailbox scope.
- `effectiveMailboxes(...)` intersects the source scope, the provider's
mailboxes and the grant, from `issuedAt` up to `expiresAt`. The
defaults read nothing. An invalid record or clock reads nothing.
Principal, right, manual-mode and revoke checks belong to E02.
- `checkSlotLocation` and `checkKeyLocation` keep provider slots and
content keys under `<configDir>/secrets/mosaic-stack/`, outside
dataRoot, the repository and any backup root. They compare the paths
as written and again after resolving symbolic links, so a linked
configDir or an aliased root can't hide a secret inside a forbidden root.
- `checkProtectedFile` wants a regular file under configDir, owned, mode
600, with no symbolic link from configDir itself down to the file. Given
the forbidden roots, it compares resolved paths against them too.
- `keyUse` gives active keys encrypt and decrypt, retired keys decrypt
only, and expired keys nothing. An invalid record or clock, or a time
outside `createdAt` to `expiresAt`, gives nothing.
- `keyTransitionAllowed` checks one state edge: active to retired or
expired, retired to expired. It is a pure predicate. It doesn't persist
state, stop a restored backup from rolling a record back, or destroy
key copies. E03 owns that proof.
Tests: `node --test tests/*.test.mjs` from this directory.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@mosaic/mail",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "UNLICENSED",
"exports": "./src/validate.mjs",
"scripts": {
"test": "node --test tests/*.test.mjs"
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:access",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"principal",
"rights",
"mailboxes",
"mode",
"approvalId",
"issuedAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-access"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"principal": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"enum": [
"agent",
"user"
]
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"rights": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"uniqueItems": true,
"items": {
"enum": [
"metadata",
"body",
"attachment",
"draft"
]
}
},
"mailboxes": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"mode": {
"enum": [
"manual",
"grant"
]
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"issuedAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
@@ -0,0 +1,57 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:content-key",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"keyId",
"path",
"state",
"createdAt",
"expiresAt",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-content-key"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"keyId": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"path": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"state": {
"enum": [
"active",
"retired",
"expired"
]
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"expiresAt": {
"type": "string",
"format": "date-time"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:data",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"persistContent",
"bodyDraftDays",
"attachmentDays",
"metadataDays",
"automaticPrune",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-data-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"persistContent": {
"const": false,
"default": false
},
"bodyDraftDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 30
},
"attachmentDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 7
},
"metadataDays": {
"type": "integer",
"minimum": 1,
"maximum": 3650,
"default": 365
},
"automaticPrune": {
"const": false,
"default": false
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:service",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"mailbox",
"scopes",
"slotPath",
"rotateBy",
"approvalId",
"revision"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-service"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"mailbox": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
},
"scopes": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"uniqueItems": true,
"items": {
"enum": [
"mail.read"
]
}
},
"slotPath": {
"type": "string",
"pattern": "^/[^\\u0000]{0,4095}$"
},
"rotateBy": {
"type": "string",
"format": "date-time"
},
"approvalId": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
}
}
}
+222
View File
@@ -0,0 +1,222 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "mosaic:mail:1:source",
"type": "object",
"additionalProperties": false,
"required": [
"recordVersion",
"kind",
"business",
"source",
"provider",
"sourceEnabled",
"sourceAdapter",
"mailboxScope",
"sourceMutations",
"accessMode",
"unattendedWake",
"wakeSenderAllowlist",
"limits",
"revision",
"changedBy",
"changedAt"
],
"properties": {
"recordVersion": {
"const": 1
},
"kind": {
"const": "mail-source-settings"
},
"business": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"source": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
},
"provider": {
"enum": [
"m365",
"imap"
]
},
"sourceEnabled": {
"type": "boolean",
"default": false
},
"sourceAdapter": {
"type": [
"string",
"null"
],
"pattern": "^[a-z][a-z0-9-]{0,63}$",
"default": null
},
"mailboxScope": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]{0,253}$"
}
},
"sourceMutations": {
"type": "array",
"maxItems": 0,
"default": [],
"items": {
"type": "string"
}
},
"accessMode": {
"enum": [
"manual",
"grant"
],
"default": "manual"
},
"unattendedWake": {
"type": "boolean",
"default": false
},
"wakeSenderAllowlist": {
"type": "array",
"maxItems": 100,
"uniqueItems": true,
"default": [],
"items": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._+-]{0,63}@[a-z0-9][a-z0-9.-]{0,252}$"
}
},
"limits": {
"type": "object",
"additionalProperties": false,
"required": [
"pollSeconds",
"sliceObservations",
"sliceBytes",
"pendingDeliveries",
"retainedBytes",
"parserInputBytes",
"mimeDepth",
"mimeParts",
"normalizedTextBytes",
"attachmentPartBytes",
"attachmentMessageBytes",
"staleScanIntervals",
"pageSize"
],
"properties": {
"pollSeconds": {
"type": "integer",
"minimum": 15,
"maximum": 3600,
"default": 60
},
"sliceObservations": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 50
},
"sliceBytes": {
"type": "integer",
"minimum": 65536,
"maximum": 67108864,
"default": 10485760
},
"pendingDeliveries": {
"type": "integer",
"minimum": 1,
"maximum": 100000,
"default": 1000
},
"retainedBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 10737418240,
"default": 104857600
},
"parserInputBytes": {
"type": "integer",
"minimum": 1048576,
"maximum": 67108864,
"default": 31457280
},
"mimeDepth": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"default": 10
},
"mimeParts": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"default": 100
},
"normalizedTextBytes": {
"type": "integer",
"minimum": 4096,
"maximum": 4194304,
"default": 262144
},
"attachmentPartBytes": {
"type": "integer",
"minimum": 0,
"maximum": 52428800,
"default": 10485760
},
"attachmentMessageBytes": {
"type": "integer",
"minimum": 0,
"maximum": 104857600,
"default": 10485760
},
"staleScanIntervals": {
"type": "integer",
"minimum": 2,
"maximum": 20,
"default": 3
},
"pageSize": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
}
}
},
"revision": {
"type": "integer",
"minimum": 1,
"maximum": 2147483647
},
"changedBy": {
"type": "object",
"additionalProperties": false,
"required": [
"kind",
"id"
],
"properties": {
"kind": {
"const": "human"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9._-]{0,63}$"
}
}
},
"changedAt": {
"type": "string",
"format": "date-time"
}
}
}
+237
View File
@@ -0,0 +1,237 @@
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
// Mail record schemas (email plan row E01). Nothing here reads a secret, opens a connection or
// registers a plug-in kind: core mail-source registration waits for the approved PRD version and the
// source, grant and route approvals.
export const KINDS = Object.freeze(['source', 'data', 'access', 'service', 'content-key']);
const KEYWORDS = new Set(['$schema', '$id', 'type', 'const', 'enum', 'properties', 'required',
'additionalProperties', 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength',
'minimum', 'maximum', 'pattern', 'format', 'default']);
const schemas = Object.fromEntries(KINDS.map(kind => [kind,
JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8')),
]));
// Package-owned schemas use this deliberately small closed subset. Never silently ignore a new constraint.
function known(schema) {
for (const key of Object.keys(schema)) if (!KEYWORDS.has(key)) throw Error(`unsupported schema keyword: ${key}`);
for (const child of Object.values(schema.properties ?? {})) known(child);
if (schema.items) known(schema.items);
}
for (const schema of Object.values(schemas)) known(schema);
const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
function visit(schema, value, path, problems) {
const bad = text => problems.push(`${path}: ${text}`);
if (Object.hasOwn(schema, 'const') && value !== schema.const) bad(`must equal ${JSON.stringify(schema.const)}`);
if (schema.enum && !schema.enum.includes(value)) bad(`must be one of ${schema.enum.join(', ')}`);
const types = schema.type ? [schema.type].flat() : [];
const matches = type => type === 'null' ? value === null : type === 'object' ? isObject(value)
: type === 'array' ? Array.isArray(value) : type === 'integer' ? Number.isSafeInteger(value)
: typeof value === type;
if (types.length && !types.some(matches)) { bad(`must be ${types.join(' or ')}`); return; }
if (typeof value === 'number') {
if (schema.minimum !== undefined && value < schema.minimum) bad(`below the schema bound ${schema.minimum}`);
if (schema.maximum !== undefined && value > schema.maximum) bad(`above the schema bound ${schema.maximum}`);
}
if (typeof value === 'string') {
if (schema.minLength !== undefined && value.length < schema.minLength) bad('too short');
if (schema.maxLength !== undefined && value.length > schema.maxLength) bad('too long');
if (schema.pattern && !new RegExp(schema.pattern).test(value)) bad('invalid format');
if (schema.format === 'date-time' && (!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value || value.length !== 24)) bad('invalid canonical UTC date-time');
if (value.includes('\0')) bad('NUL is forbidden');
}
if (Array.isArray(value)) {
if (schema.minItems !== undefined && value.length < schema.minItems) bad('too few items');
if (schema.maxItems !== undefined && value.length > schema.maxItems) bad('too many items');
if (schema.uniqueItems && new Set(value.map(item => JSON.stringify(item))).size !== value.length) bad('duplicate items');
if (schema.items) value.forEach((item, index) => visit(schema.items, item, `${path}[${index}]`, problems));
}
if (isObject(value)) {
for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) bad(`missing ${key}`);
for (const [key, child] of Object.entries(value)) {
if (Object.hasOwn(schema.properties ?? {}, key)) visit(schema.properties[key], child, `${path}.${key}`, problems);
else if (schema.additionalProperties === false) bad(`unknown key ${key}`);
}
}
}
// Wildcards are refused by every pattern; this names the reason plainly. Scopes are exact IDs only.
const WILDCARD = /[*?%]/;
// Shapes of secret values. A record names a protected slot path, never a credential (invariant 3).
const SECRET_SHAPES = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
/\b(?:gh[pousr]_|glpat-|xox[abprs]-|sk-)[A-Za-z0-9_-]{8,}/,
/:\/\/[^/\s:@]+:[^/\s@]+@/,
/\b(?:password|passwd|secret|token|apikey|api_key|client_secret)\s*[=:]/i,
];
export const looksSecret = text => SECRET_SHAPES.some(shape => shape.test(text));
function strings(value, path, out) {
if (typeof value === 'string') out.push([path, value]);
else if (Array.isArray(value)) value.forEach((item, index) => strings(item, `${path}[${index}]`, out));
else if (isObject(value)) for (const [key, child] of Object.entries(value)) strings(child, `${path}.${key}`, out);
return out;
}
export function validateRecord(kind, value) {
if (!Object.hasOwn(schemas, kind)) return [`unknown record kind: ${kind}`];
const problems = [];
visit(schemas[kind], value, kind, problems);
for (const [path, text] of strings(value, kind, [])) {
if (looksSecret(text)) problems.push(`${path}: looks like a secret value; name a protected slot instead`);
else if (WILDCARD.test(text)) problems.push(`${path}: wildcard not allowed; scopes name exact IDs`);
}
if (problems.length) return problems;
if (kind === 'source') {
if (value.sourceEnabled && value.sourceAdapter === null)
problems.push('source.sourceAdapter: an enabled source needs an enrolled adapter');
if (value.unattendedWake && value.wakeSenderAllowlist.length === 0)
problems.push('source.wakeSenderAllowlist: unattended wake needs at least one exact sender');
if (value.limits.attachmentPartBytes > value.limits.attachmentMessageBytes)
problems.push('source.limits: attachmentPartBytes exceeds attachmentMessageBytes');
}
if (kind === 'data' && value.metadataDays < Math.max(value.bodyDraftDays, value.attachmentDays))
problems.push('data.metadataDays: metadata and tombstones must outlive content');
if (kind === 'access' && Date.parse(value.expiresAt) <= Date.parse(value.issuedAt))
problems.push('access.expiresAt: must be after issuedAt');
if (kind === 'content-key' && Date.parse(value.expiresAt) <= Date.parse(value.createdAt))
problems.push('content-key.expiresAt: must be after createdAt');
return problems;
}
// Defaults come from the schema, so a default and its bounds have one definition (Q222).
function defaultsOf(schema) {
if (Object.hasOwn(schema, 'default')) return structuredClone(schema.default);
if (schema.type !== 'object') return undefined;
const out = {};
for (const [key, child] of Object.entries(schema.properties ?? {})) {
const value = defaultsOf(child);
if (value !== undefined) out[key] = value;
}
return Object.keys(out).length ? out : undefined;
}
export function defaults(kind) {
if (kind !== 'source' && kind !== 'data') throw Error(`no settings defaults for ${kind}`);
return defaultsOf(schemas[kind]);
}
export const schemaBounds = (kind, key) => {
const node = key.split('.').reduce((schema, part) => schema?.properties?.[part], schemas[kind]);
if (!node || node.minimum === undefined) throw Error(`no bounds for ${kind}.${key}`);
return { minimum: node.minimum, maximum: node.maximum, default: node.default };
};
// Records that name one another must agree, and a grant never reaches past the source's mailbox scope.
// (The access schema already limits a grant's principal to an agent or a user, never the service.)
export function checkBinding({ source, service, access }) {
const problems = [];
const same = (label, a, b) => { for (const key of ['business', 'source']) if (a[key] !== b[key]) problems.push(`${label}: ${key} mismatch`); };
if (service) {
same('service', service, source);
if (service.provider !== source.provider) problems.push('service: provider mismatch');
}
if (access) {
same('access', access, source);
const scope = new Set(source.mailboxScope);
for (const mailbox of access.mailboxes) if (!scope.has(mailbox)) problems.push(`access: mailbox ${mailbox} outside the source scope`);
}
return problems;
}
const instant = now => (now instanceof Date && Number.isFinite(now.getTime()) ? now.getTime() : NaN);
// What a principal may read: the source scope, the provider's mailboxes and the grant, intersected.
// A disabled source, the default empty scope, a missing, invalid, not-yet-issued or expired grant, or an
// invalid clock reads nothing. Authenticated principal, right, manual-mode and revoke checks belong to E02.
export function effectiveMailboxes({ source, providerMailboxes = [], access = null, now = new Date() }) {
if (!access || validateRecord('source', source).length || validateRecord('access', access).length) return [];
if (!source.sourceEnabled || !Array.isArray(providerMailboxes)) return [];
const at = instant(now);
if (!(at >= Date.parse(access.issuedAt) && at < Date.parse(access.expiresAt))) return [];
if (access.business !== source.business || access.source !== source.source) return [];
const provider = new Set(providerMailboxes);
const granted = new Set(access.mailboxes);
return source.mailboxScope.filter(mailbox => provider.has(mailbox) && granted.has(mailbox));
}
function inside(root, path) {
const rel = relative(root, path);
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
}
// The path with every existing part resolved, so a symbolic link or alias can't hide where it lands.
// Parts that don't exist yet (a planned slot) are kept as written under their nearest existing ancestor.
function canonical(path) {
const rest = [];
for (let at = path; ; at = dirname(at)) {
try { return join(realpathSync(at), ...rest.reverse()); } catch (error) {
if (error.code !== 'ENOENT' || at === dirname(at)) return null;
rest.push(basename(at));
}
}
}
// Lexical checks first, for planned paths. Then the same forbidden roots compared after resolution, so
// a configDir that links into dataRoot, the repository or a backup root is refused too.
function forbidden(label, path, { dataRoot, repoRoot, backupRoots = [] }) {
const problems = [];
const roots = [['dataRoot', dataRoot], ['the repository', repoRoot], ...backupRoots.map(root => ['a backup root', root])]
.filter(([, root]) => root);
for (const [name, root] of roots) if (inside(root, path)) problems.push(`${label}: inside ${name}; mail secrets live outside it`);
if (problems.length) return problems;
const real = canonical(path);
if (real === null) return [`${label}: path can't be resolved`];
for (const [name, root] of roots) {
const realRoot = canonical(root);
if (realRoot === null || inside(realRoot, real)) problems.push(`${label}: resolves inside ${name}; mail secrets live outside it`);
}
return problems;
}
function placed(label, path, expectedDir, roots) {
if (!isAbsolute(path) || normalize(path) !== path) return [`${label}: must be a normalized absolute path`];
const problems = !inside(expectedDir, path) || path === expectedDir ? [`${label}: must sit under ${expectedDir}`] : [];
return [...problems, ...forbidden(label, path, roots)];
}
// A source service's provider credential slot (plan D9).
export const slotDir = ({ configDir, business, source }) => join(configDir, 'secrets', 'mosaic-stack', 'mail', business, source);
export const checkSlotLocation = (service, roots) =>
placed('service.slotPath', service.slotPath, slotDir({ ...roots, ...service }), roots);
// A content key, outside dataRoot and its backup set, apart from provider slots (plan D7).
export const keyPath = ({ configDir, business, keyId }) => join(configDir, 'secrets', 'mosaic-stack', 'mail-content', business, keyId);
export function checkKeyLocation(record, roots) {
const problems = placed('content-key.path', record.path, join(roots.configDir, 'secrets', 'mosaic-stack', 'mail-content', record.business), roots);
if (!problems.length && record.path !== keyPath({ ...roots, ...record })) problems.push(`content-key.path: must be ${keyPath({ ...roots, ...record })}`);
return problems;
}
// A protected file: inside configDir, owned, mode 600, regular, with no symbolic link from configDir
// itself down to the file, and not resolving into a forbidden root.
export function checkProtectedFile(path, { configDir, uid = process.getuid(), ...roots }) {
if (!isAbsolute(path) || normalize(path) !== path || !isAbsolute(configDir) || normalize(configDir) !== configDir)
return [`${path}: must be a normalized absolute path`];
if (!inside(configDir, path) || path === configDir) return [`${path}: must sit under ${configDir}`];
let stat;
try { stat = lstatSync(path); } catch { return [`${path}: not found`]; }
if (stat.isSymbolicLink() || !stat.isFile()) return [`${path}: must be a regular file, not a symbolic link`];
for (let at = dirname(path); ; at = dirname(at)) {
let dir;
try { dir = lstatSync(at); } catch { return [`${path}: path can't be resolved`]; }
if (dir.isSymbolicLink()) return [`${path}: a symbolic link sits on the path at ${at}`];
if (at === configDir) break;
}
const problems = [];
if (stat.uid !== uid) problems.push(`${path}: owned by uid ${stat.uid}, not ${uid}`);
if ((stat.mode & 0o777) !== 0o600) problems.push(`${path}: mode ${(stat.mode & 0o777).toString(8)}; use 600`);
return [...problems, ...forbidden(path, path, roots)];
}
// Key lifecycle states: active keys encrypt and decrypt, retired keys only decrypt, expired keys do
// neither. keyTransitionAllowed is a pure check on one state edge. It doesn't persist state, stop a
// restored backup from rolling a record back, or destroy key copies; E03 owns that proof.
const KEY_STEPS = { active: ['retired', 'expired'], retired: ['expired'], expired: [] };
export const keyTransitionAllowed = (from, to) => Object.hasOwn(KEY_STEPS, from) && KEY_STEPS[from].includes(to);
// The use a record allows now. An invalid record or clock, or a time outside [createdAt, expiresAt), is none.
export function keyUse(record, now = new Date()) {
if (validateRecord('content-key', record).length) return 'none';
const at = instant(now);
if (!(at >= Date.parse(record.createdAt) && at < Date.parse(record.expiresAt))) return 'none';
return record.state === 'active' ? 'encrypt-decrypt' : record.state === 'retired' ? 'decrypt' : 'none';
}
+337
View File
@@ -0,0 +1,337 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
checkBinding, checkKeyLocation, checkProtectedFile, checkSlotLocation, defaults, effectiveMailboxes, keyPath,
keyTransitionAllowed, keyUse, schemaBounds, slotDir, validateRecord,
} from '../src/validate.mjs';
// Synthetic records only. No credential, mailbox or provider is real, and nothing here opens a connection.
const HUMAN = { kind: 'human', id: 'jason' };
const AT = '2026-10-22T16:00:00.000Z';
const source = (over = {}) => ({
recordVersion: 1, kind: 'mail-source-settings', business: 'acme', source: 'ingress', provider: 'imap',
...defaults('source'), revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const data = (over = {}) => ({
recordVersion: 1, kind: 'mail-data-settings', business: 'acme', ...defaults('data'),
revision: 1, changedBy: HUMAN, changedAt: AT, ...over,
});
const access = (over = {}) => ({
recordVersion: 1, kind: 'mail-access', business: 'acme', source: 'ingress',
principal: { kind: 'agent', id: 'mail-specialist' }, rights: ['metadata', 'body'],
mailboxes: ['[email protected]'], mode: 'manual', approvalId: 'decision-1',
issuedAt: AT, expiresAt: '2026-10-29T16:00:00.000Z', revision: 1, ...over,
});
const CONFIG = '/home/op/.config/mosaic-acme';
const ROOTS = { configDir: CONFIG, dataRoot: '/home/op/.mosaic-acme', repoRoot: '/src/stack', backupRoots: ['/backup/mosaic'] };
const service = (over = {}) => ({
recordVersion: 1, kind: 'mail-service', business: 'acme', source: 'ingress', provider: 'imap',
mailbox: '[email protected]', scopes: ['mail.read'],
slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/imap`, rotateBy: '2027-01-01T00:00:00.000Z',
approvalId: 'decision-2', revision: 1, ...over,
});
const contentKey = (over = {}) => ({
recordVersion: 1, kind: 'mail-content-key', business: 'acme', keyId: 'k1',
path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k1`, state: 'active',
createdAt: AT, expiresAt: '2027-10-22T16:00:00.000Z', revision: 1, ...over,
});
const refused = (kind, value, text) => {
const problems = validateRecord(kind, value);
assert.ok(problems.some(problem => problem.includes(text)), `${kind} should be refused with "${text}": ${JSON.stringify(problems)}`);
};
test('T1 the defaults are off, empty and valid, and every record kind validates', () => {
const settings = defaults('source');
assert.equal(settings.sourceEnabled, false);
assert.deepEqual(settings.mailboxScope, []);
assert.deepEqual(settings.sourceMutations, []);
assert.equal(settings.sourceAdapter, null);
assert.equal(settings.accessMode, 'manual');
assert.equal(settings.unattendedWake, false);
assert.deepEqual(settings.wakeSenderAllowlist, []);
assert.deepEqual(settings.limits, {
pollSeconds: 60, sliceObservations: 50, sliceBytes: 10485760, pendingDeliveries: 1000, retainedBytes: 104857600,
parserInputBytes: 31457280, mimeDepth: 10, mimeParts: 100, normalizedTextBytes: 262144,
attachmentPartBytes: 10485760, attachmentMessageBytes: 10485760, staleScanIntervals: 3, pageSize: 20,
});
assert.deepEqual(defaults('data'), { persistContent: false, bodyDraftDays: 30, attachmentDays: 7, metadataDays: 365, automaticPrune: false });
for (const [kind, value] of [['source', source()], ['data', data()], ['access', access()], ['service', service()], ['content-key', contentKey()]])
assert.deepEqual(validateRecord(kind, value), [], kind);
assert.throws(() => defaults('access'), /no settings defaults/);
assert.deepEqual(validateRecord('mailbox', {}), ['unknown record kind: mailbox']);
});
test('T2 the default scope reads nothing, and a read is the intersection of scope, provider and grant', () => {
const grant = access({ mailboxes: ['[email protected]'] });
const providerMailboxes = ['[email protected]', '[email protected]'];
assert.deepEqual(effectiveMailboxes({ source: source(), providerMailboxes, access: grant }), []);
const enabled = source({ sourceEnabled: true, sourceAdapter: 'imap' });
assert.deepEqual(effectiveMailboxes({ source: enabled, providerMailboxes, access: grant }), [], 'enabled with the default empty scope');
const scoped = source({ sourceEnabled: true, sourceAdapter: 'imap', mailboxScope: ['[email protected]', '[email protected]'] });
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT) }), ['[email protected]']);
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: null }), [], 'no grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes: [], access: grant, now: new Date(AT) }), [], 'provider denies');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date('2026-10-29T16:00:00.000Z') }), [], 'expired grant');
assert.deepEqual(effectiveMailboxes({ source: scoped, providerMailboxes, access: access({ source: 'other' }), now: new Date(AT) }), [], 'grant for another source');
assert.deepEqual(effectiveMailboxes({ source: { ...scoped, sourceEnabled: false }, providerMailboxes, access: grant, now: new Date(AT) }), [], 'disabled');
// Lifetime edges: the grant reads from issuedAt up to, not including, expiresAt.
const read = (over = {}) => effectiveMailboxes({ source: scoped, providerMailboxes, access: grant, now: new Date(AT), ...over });
assert.deepEqual(read({ now: new Date('2026-10-22T15:59:59.999Z') }), [], 'before issuedAt');
assert.deepEqual(read({ now: new Date('2026-10-29T15:59:59.999Z') }), ['[email protected]'], 'the last instant before expiresAt');
// Malformed records or clock fail closed.
assert.deepEqual(read({ now: new Date(Number.NaN) }), [], 'invalid clock');
assert.deepEqual(read({ now: AT }), [], 'a clock that is not a Date');
assert.deepEqual(read({ access: access({ expiresAt: 'not-a-date' }) }), [], 'invalid expiresAt');
assert.deepEqual(read({ access: access({ issuedAt: 'not-a-date' }) }), [], 'invalid issuedAt');
assert.deepEqual(read({ access: access({ issuedAt: '2026-10-23T16:00:00.000Z' }) }), [], 'issued in the future');
const { mailboxes, ...noMailboxes } = grant;
assert.deepEqual(read({ access: noMailboxes }), [], 'grant without mailboxes');
assert.deepEqual(read({ source: { ...scoped, mailboxScope: ['*'] } }), [], 'invalid source');
assert.deepEqual(read({ providerMailboxes: '[email protected]' }), [], 'provider list that is not an array');
});
test('T3 only a human changes settings', () => {
for (const kind of ['source', 'data']) {
const make = kind === 'source' ? source : data;
refused(kind, make({ changedBy: { kind: 'agent', id: 'mail-specialist' } }), 'changedBy.kind: must equal "human"');
refused(kind, make({ changedBy: { kind: 'system', id: 'mail' } }), 'changedBy.kind: must equal "human"');
const { changedBy, ...unsigned } = make();
refused(kind, unsigned, 'missing changedBy');
}
});
test('T4 unknown fields are refused at every level', () => {
refused('source', source({ secretRef: 'x' }), 'unknown key secretRef');
refused('source', source({ limits: { ...defaults('source').limits, unlimited: 1 } }), 'unknown key unlimited');
refused('data', data({ purgeNow: true }), 'unknown key purgeNow');
refused('access', access({ principal: { kind: 'agent', id: 'a', asHuman: true } }), 'unknown key asHuman');
refused('service', service({ password: 'x' }), 'unknown key password');
refused('content-key', contentKey({ material: 'x' }), 'unknown key material');
});
test('T5 wildcards are refused in scopes, grants, allowlists and principals', () => {
refused('source', source({ mailboxScope: ['*'] }), 'wildcard not allowed');
refused('source', source({ mailboxScope: ['%'] }), 'wildcard not allowed');
refused('source', source({ wakeSenderAllowlist: ['*@example.test'] }), 'wildcard not allowed');
refused('access', access({ mailboxes: ['[email protected]'] }), 'wildcard not allowed');
refused('access', access({ principal: { kind: 'agent', id: '*' } }), 'wildcard not allowed');
refused('service', service({ mailbox: '*' }), 'wildcard not allowed');
});
test('T6 secret values are refused wherever they appear', () => {
const sentinels = [
'-----BEGIN OPENSSH PRIVATE KEY-----', 'eyJhbGciOiJub25lIn0.eyJzdWIiOiJzZW50aW5lbCJ9.x',
'ghp_SENTINELSENTINEL', 'imaps://user:[email protected]', 'password=SENTINEL', 'client_secret: SENTINEL',
];
for (const sentinel of sentinels) {
refused('service', service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress/${sentinel}` }), 'looks like a secret value');
refused('service', service({ approvalId: sentinel }), 'looks like a secret value');
refused('access', access({ approvalId: sentinel }), 'looks like a secret value');
}
const problems = validateRecord('service', service({ approvalId: 'password=SENTINEL' }));
assert.ok(!problems.join('\n').includes('SENTINEL'), 'a refusal never echoes the value');
});
test('T7 incomplete scopes are refused', () => {
refused('access', access({ rights: [] }), 'access.rights: too few items');
refused('access', access({ mailboxes: [] }), 'access.mailboxes: too few items');
refused('access', access({ rights: ['metadata', 'send'] }), 'must be one of');
refused('access', access({ rights: ['body', 'body'] }), 'duplicate items');
refused('access', access({ expiresAt: AT }), 'must be after issuedAt');
refused('service', service({ scopes: [] }), 'service.scopes: too few items');
refused('service', service({ scopes: ['mail.send'] }), 'must be one of');
refused('source', source({ sourceEnabled: true }), 'an enabled source needs an enrolled adapter');
refused('source', source({ unattendedWake: true }), 'unattended wake needs at least one exact sender');
refused('source', source({ sourceMutations: ['mark-read'] }), 'too many items');
refused('source', source({ mailboxScope: ['[email protected]', '[email protected]'] }), 'duplicate items');
assert.deepEqual(validateRecord('source', source({ unattendedWake: true, wakeSenderAllowlist: ['[email protected]'] })), []);
});
test('T8 mismatched principal and source are refused', () => {
const scoped = source({ mailboxScope: ['[email protected]'] });
assert.deepEqual(checkBinding({ source: scoped, service: service(), access: access() }), []);
assert.deepEqual(checkBinding({ source: scoped, service: service({ source: 'other' }) }), ['service: source mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ business: 'beta' }) }), ['service: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, service: service({ provider: 'm365' }) }), ['service: provider mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ business: 'beta' }) }), ['access: business mismatch']);
assert.deepEqual(checkBinding({ source: scoped, access: access({ mailboxes: ['[email protected]'] }) }),
['access: mailbox [email protected] outside the source scope']);
assert.deepEqual(checkBinding({ source: source(), access: access() }), ['access: mailbox [email protected] outside the source scope'],
'the default empty scope admits no grant');
refused('access', access({ principal: { kind: 'service', id: 'mail' } }), 'must be one of agent, user');
assert.deepEqual(checkSlotLocation(service({ slotPath: `${CONFIG}/secrets/mosaic-stack/mail/acme/other/imap` }), ROOTS),
[`service.slotPath: must sit under ${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`]);
});
test('T9 settings take changed values inside the schema bounds and refuse values outside them', () => {
for (const key of ['pollSeconds', 'parserInputBytes', 'mimeDepth', 'mimeParts', 'normalizedTextBytes', 'pageSize']) {
const { minimum, maximum } = schemaBounds('source', `limits.${key}`);
const at = value => source({ limits: { ...defaults('source').limits, [key]: value } });
assert.deepEqual(validateRecord('source', at(minimum)), [], `${key} at its minimum`);
assert.deepEqual(validateRecord('source', at(maximum)), [], `${key} at its maximum`);
refused('source', at(minimum - 1), 'below the schema bound');
refused('source', at(maximum + 1), 'above the schema bound');
refused('source', at(minimum + 0.5), 'must be integer');
}
assert.equal(schemaBounds('source', 'limits.pageSize').maximum, 100);
refused('source', source({ limits: { ...defaults('source').limits, attachmentPartBytes: 20971520 } }), 'attachmentPartBytes exceeds attachmentMessageBytes');
assert.deepEqual(validateRecord('data', data({ bodyDraftDays: 90, metadataDays: 400 })), []);
refused('data', data({ metadataDays: 10 }), 'metadata and tombstones must outlive content');
refused('data', data({ persistContent: true }), 'persistContent: must equal false');
refused('data', data({ automaticPrune: true }), 'automaticPrune: must equal false');
});
test('T10 slot and key locations stay outside dataRoot, the repository and backups', () => {
assert.equal(slotDir({ configDir: CONFIG, business: 'acme', source: 'ingress' }), `${CONFIG}/secrets/mosaic-stack/mail/acme/ingress`);
assert.deepEqual(checkSlotLocation(service(), ROOTS), []);
assert.deepEqual(checkKeyLocation(contentKey(), ROOTS), []);
const nested = { ...ROOTS, dataRoot: CONFIG };
assert.ok(checkSlotLocation(service(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), nested).some(problem => problem.includes('inside dataRoot')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, backupRoots: ['/home/op/.config'] }).some(problem => problem.includes('inside a backup root')));
assert.ok(checkKeyLocation(contentKey(), { ...ROOTS, repoRoot: CONFIG }).some(problem => problem.includes('inside the repository')));
assert.deepEqual(checkKeyLocation(contentKey({ path: '/home/op/.mosaic-acme/mail/acme/k1' }), ROOTS),
[`content-key.path: must sit under ${CONFIG}/secrets/mosaic-stack/mail-content/acme`, 'content-key.path: inside dataRoot; mail secrets live outside it']);
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/k2` }), ROOTS),
[`content-key.path: must be ${keyPath({ configDir: CONFIG, business: 'acme', keyId: 'k1' })}`]);
assert.ok(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail/acme/k1` }), ROOTS)[0].includes('must sit under'), 'apart from provider slots');
assert.deepEqual(checkKeyLocation(contentKey({ path: `${CONFIG}/secrets/mosaic-stack/mail-content/acme/../acme/k1` }), ROOTS),
['content-key.path: must be a normalized absolute path']);
});
test('T10b a symbolic link or alias can\'t move a slot or key into a forbidden root', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-place-'));
try {
const dataRoot = join(root, 'data');
const roots = { dataRoot, repoRoot: join(root, 'repo'), backupRoots: [join(root, 'backup')] };
// configDir is a symbolic link into dataRoot, with a real 0600 key behind it.
mkdirSync(join(dataRoot, 'cfg'), { recursive: true, mode: 0o700 });
const configDir = join(root, 'config');
symlinkSync(join(dataRoot, 'cfg'), configDir);
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
const key = contentKey({ path: file });
assert.deepEqual(checkKeyLocation(key, { ...roots, configDir }), ['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// A planned slot that doesn't exist yet resolves through its nearest existing ancestor.
const slot = service({ slotPath: join(configDir, 'secrets', 'mosaic-stack', 'mail', 'acme', 'ingress', 'imap') });
assert.deepEqual(checkSlotLocation(slot, { ...roots, configDir }), ['service.slotPath: resolves inside dataRoot; mail secrets live outside it']);
// A forbidden root named through an alias still covers the real configDir.
const realConfig = join(dataRoot, 'cfg');
const alias = join(root, 'alias');
symlinkSync(dataRoot, alias);
const realKey = contentKey({ path: join(realConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(realKey, { ...roots, dataRoot: alias, configDir: realConfig }),
['content-key.path: resolves inside dataRoot; mail secrets live outside it']);
// The same layout outside every forbidden root passes.
const safeConfig = join(root, 'safe');
mkdirSync(safeConfig, { mode: 0o700 });
const safeKey = contentKey({ path: join(safeConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1') });
assert.deepEqual(checkKeyLocation(safeKey, { ...roots, configDir: safeConfig }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T11 a protected file must be a regular owned 600 file with no symbolic link on its path', () => {
const root = mkdtempSync(join(tmpdir(), 'mail-schema-'));
try {
const configDir = join(root, 'config');
const dir = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'acme');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = join(dir, 'k1');
writeFileSync(file, 'synthetic', { mode: 0o600 });
assert.deepEqual(checkProtectedFile(file, { configDir }), []);
chmodSync(file, 0o640);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 640')));
chmodSync(file, 0o400);
assert.ok(checkProtectedFile(file, { configDir }).some(problem => problem.includes('mode 400')));
chmodSync(file, 0o600);
assert.ok(checkProtectedFile(file, { configDir, uid: process.getuid() + 1 }).some(problem => problem.includes('owned by uid')));
const link = join(dir, 'k2');
symlinkSync(file, link);
assert.deepEqual(checkProtectedFile(link, { configDir }), [`${link}: must be a regular file, not a symbolic link`]);
const realDir = join(root, 'elsewhere');
mkdirSync(realDir, { mode: 0o700 });
writeFileSync(join(realDir, 'k3'), 'synthetic', { mode: 0o600 });
symlinkSync(realDir, join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta'));
const viaLink = join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta', 'k3');
assert.deepEqual(checkProtectedFile(viaLink, { configDir }),
[`${viaLink}: a symbolic link sits on the path at ${join(configDir, 'secrets', 'mosaic-stack', 'mail-content', 'beta')}`]);
assert.deepEqual(checkProtectedFile(join(dir, 'missing'), { configDir }), [`${join(dir, 'missing')}: not found`]);
mkdirSync(join(dir, 'k4'));
assert.deepEqual(checkProtectedFile(join(dir, 'k4'), { configDir }), [`${join(dir, 'k4')}: must be a regular file, not a symbolic link`]);
// The file must sit under configDir, lexically.
const outside = join(realDir, 'k3');
assert.deepEqual(checkProtectedFile(outside, { configDir }), [`${outside}: must sit under ${configDir}`]);
const dotted = `${configDir}/../elsewhere/k3`;
assert.deepEqual(checkProtectedFile(dotted, { configDir }), [`${dotted}: must be a normalized absolute path`]);
// A symbolic-link configDir is refused, even with a real 0600 file behind it.
const linkedConfig = join(root, 'linked-config');
symlinkSync(configDir, linkedConfig);
const viaConfig = join(linkedConfig, 'secrets', 'mosaic-stack', 'mail-content', 'acme', 'k1');
assert.deepEqual(checkProtectedFile(viaConfig, { configDir: linkedConfig }), [`${viaConfig}: a symbolic link sits on the path at ${linkedConfig}`]);
// A forbidden root named through an alias still covers the file.
const alias = join(root, 'alias');
symlinkSync(root, alias);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: alias }), [`${file}: resolves inside dataRoot; mail secrets live outside it`]);
assert.deepEqual(checkProtectedFile(file, { configDir, dataRoot: join(root, 'data') }), []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('T12 the key lifecycle moves forward only, and an expired key gives no use', () => {
assert.ok(keyTransitionAllowed('active', 'retired'));
assert.ok(keyTransitionAllowed('active', 'expired'));
assert.ok(keyTransitionAllowed('retired', 'expired'));
for (const [from, to] of [['retired', 'active'], ['expired', 'active'], ['expired', 'retired'], ['active', 'active'], ['lost', 'active'], ['constructor', 'active']])
assert.equal(keyTransitionAllowed(from, to), false, `${from} -> ${to}`);
const now = new Date('2027-01-01T00:00:00.000Z');
assert.equal(keyUse(contentKey(), now), 'encrypt-decrypt');
assert.equal(keyUse(contentKey({ state: 'retired' }), now), 'decrypt');
assert.equal(keyUse(contentKey({ state: 'expired' }), now), 'none');
assert.equal(keyUse(contentKey(), new Date('2027-10-22T16:00:00.000Z')), 'none', 'past expiresAt, whatever the state says');
assert.equal(keyUse(contentKey({ state: 'retired' }), new Date('2027-10-22T15:59:59.999Z')), 'decrypt', 'the last instant before expiresAt');
assert.equal(keyUse(contentKey(), new Date(AT)), 'encrypt-decrypt', 'from createdAt');
assert.equal(keyUse(contentKey(), new Date('2026-10-22T15:59:59.999Z')), 'none', 'before createdAt');
// Malformed records or clock fail closed.
assert.equal(keyUse(contentKey({ state: 'escrowed' }), now), 'none', 'unknown state');
assert.equal(keyUse(contentKey({ state: 'constructor' }), now), 'none', 'prototype key as state');
assert.equal(keyUse(contentKey({ createdAt: 'not-a-date' }), now), 'none', 'invalid createdAt');
assert.equal(keyUse(contentKey({ expiresAt: 'not-a-date' }), now), 'none', 'invalid expiresAt');
assert.equal(keyUse(contentKey(), new Date(Number.NaN)), 'none', 'invalid clock');
assert.equal(keyUse(contentKey(), '2027-01-01T00:00:00.000Z'), 'none', 'a clock that is not a Date');
refused('content-key', contentKey({ state: 'escrowed' }), 'must be one of');
refused('content-key', contentKey({ expiresAt: AT }), 'must be after createdAt');
});
test('T13 the disabled schema leaves v1 unchanged: no mail kind, no mail action', () => {
const kinds = readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8');
const vocabulary = readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8');
assert.ok(!/mail/i.test(kinds), 'core mail-source registration waits for the approved PRD version and the source, grant and route approvals');
assert.ok(!/mail\.access/.test(vocabulary), 'mail.access waits for a Jason-approved PRD version');
const prd = readFileSync(new URL('../../../docs/prd/mosaic-stack.md', import.meta.url), 'utf8');
assert.ok(!/REQ-MAIL-/.test(prd), 'the addendum is a draft until Jason approves it');
});
test('T14 every addendum requirement maps to build rows, and every row E01-E16 is covered', () => {
const draft = readFileSync(new URL('../../../docs/prd/mail-addendum-draft.md', import.meta.url), 'utf8');
assert.match(draft, /^- Status: draft, not approved\./m);
const blocks = draft.split(/^- \*\*(?=REQ-MAIL-)/m).slice(1);
const ids = blocks.map(block => block.match(/^(REQ-MAIL-\d+)\.\*\*/)[1]);
assert.deepEqual(ids, Array.from({ length: ids.length }, (_, index) => `REQ-MAIL-${index + 1}`));
assert.equal((draft.match(/\*\*REQ-MAIL-/g) ?? []).length, ids.length);
const covered = new Set();
for (const [index, block] of blocks.entries()) {
const rows = block.match(/^ {2}Rows: (E\d\d(?:, E\d\d)*)\.$/m);
assert.ok(rows, `${ids[index]} names its rows`);
for (const row of rows[1].split(', ')) covered.add(row);
}
const all = Array.from({ length: 16 }, (_, index) => `E${String(index + 1).padStart(2, '0')}`);
assert.deepEqual([...covered].sort(), all);
});
-50
View File
@@ -1,50 +0,0 @@
#!/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.
# 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
}
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 zero "$lsha"; then
refuse "$rref: a delete (remote $rsha); seat branches are never deleted by push"
continue
fi
if zero "$rsha"; then
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
-99
View File
@@ -1,99 +0,0 @@
#!/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), 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. 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"
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"
tmp="$HOOKS/.pre-push.install-git-hooks.$$"
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"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# 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
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
# 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
-77
View File
@@ -1,77 +0,0 @@
#!/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. 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`.
# It skips itself and its ancestors (a gate script may run the check), and an
# agent harness's shell-snapshot wrapper, whose command line is the agent's
# command text; that shell's real children are still matched. It reads only
# /proc/<pid>/cmdline and the cwd link, never a file under a seat's directory.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
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; }
# This process and its ancestors, from the real /proc.
declare -A SKIP=()
p=$$
while [ -n "$p" ] && [ "$p" != 0 ]; do
SKIP[$p]=1
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || p=""
done
[ "${#SKIP[@]}" -ge 1 ] && [ -n "${SKIP[$$]:-}" ] || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
RE_NODE='[ /]node[^ ]* (.* )?--test '
RE_GATE='[ /_-]gate[^ /]*\.sh '
RE_TEST='[ /]test-[^ /]*\.sh '
RE_COMPOSE='[ /]docker( compose|-compose) '
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
local c=" $1 "
if [[ "$c" == *"/shell-snapshots/snapshot-"* ]]; then return 1; fi
if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
}
seen=0
busy=0
for d in "$PROC"/[0-9]*; do
[ -d "$d" ] || continue
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.
cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null) || continue
cmd=${cmd% }
[ -n "$cmd" ] || continue
rule=$(match "$cmd") || continue
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; }
if [ "$busy" = 1 ]; then exit 1; fi
echo "CLEAR"
exit 0
-257
View File
@@ -1,257 +0,0 @@
#!/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=""
cleanup() {
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
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 "== 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 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 $?)"
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 .
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && eval 'node --test x.test.mjs'"
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"; 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: $* ($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
rm -rf "${P:?}/4194420"
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 "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]