Compare commits

..
Author SHA1 Message Date
jarvisandClaude Fable 5 89e26ff7c2 fix(framework): key §5 lookup on rendered bullets, not the token (mos-dt round-2)
ci/woodpecker/pr/ci Pipeline failed
§5 sent the agent to read direct|friendly|formal in USER.md, but the builder
renders prose bullets, not the token — the documented lookup could not key on
the shipped file. Table now keys on the leading bullet USER.md actually
contains. Also: 'concise, technical' -> 'concise, structured' (drop the round-1
residual value name from a rule-9 guide). Docs-only, no code, no scope growth.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-30 15:41:26 -05:00
jarvisandClaude Fable 5 caf40afc01 fix(framework): ride the existing communicationStyle enum, drop the no-op USER.md edit (mos-dt review #960)
F1: defaults/USER.md is never installed (generated from templates/USER.md.template
via buildCommunicationPrefs). Editing it was a no-op asserting a phantom setting —
exactly the false-green §2 warns against. Reverted.
F2: the framework already has communicationStyle (direct|friendly|formal). §5 now
maps THOSE values to output instead of inventing technical|prose|brief (rule 9).
Minor: §6 states no mechanical prose check exists today; rule 1 points at §3.4.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-30 15:24:17 -05:00
jarvisandClaude Fable 5 6cc093afdb feat(framework): MOS-STE writing standard + Google-style code + per-user comms choice
Adds the agent output standard to the framework SOT so it injects at launch and
is selectable per user (closes the gap: it lived only as a jarvis-brain lab doc + issue #960).

- guides/WRITING-STYLE.md: MOS-STE (adapted ASD-STE100) for docs, Google Style for code,
  verification-artifact emphasis, absolute user-voice carve-out. Written in MOS-STE.
- defaults/STANDARDS.md: Output-standards block (always injected via the prompting contract).
- defaults/AGENTS.md: routing row so writing/doc/comms work reaches the guide.
- defaults/USER.md: per-user 'Comms style' option (technical|prose|brief), default technical.

Refs mosaicstack/stack#960. Owner directive (Jason, 2026-07-30): docs->adapted ASD-STE100,
code->Google style, resumes/personal carved out, comms style a per-user choice.

Written-by: jarvis (dragon-lin)
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-30 14:19:26 -05:00
15 changed files with 154 additions and 2551 deletions
@@ -39,6 +39,7 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
| QA / test strategy | `guides/QA-TESTING.md` |
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
| Writing style (docs, comms, any prose) | `guides/WRITING-STYLE.md` |
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
@@ -27,6 +27,14 @@ Master/slave model:
- Do not perform destructive git/file actions without explicit instruction.
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
### Output standards (writing + code)
- Technical documentation follows **MOS-STE** (Mosaic Simplified Technical English — an adapted ASD-STE100 profile): short sentences, one instruction per sentence, active voice, one word per meaning, one term per concept. Full rules: `~/.config/mosaic/guides/WRITING-STYLE.md`.
- Apply MOS-STE **hardest to verification artifacts** (acceptance criteria, witness predicates, gate/alarm conditions). There an ambiguous term produces a false green, not just a confused reader.
- Source code follows the **Google Style Guide** for the language.
- User-facing comms follow the user's declared `communicationStyle` in `USER.md` "Communication Preferences" (`direct` | `friendly` | `formal`, default `direct`); `guides/WRITING-STYLE.md` §5 maps each value to output. The documentation standard does not change with user preference.
- **Carve-out:** MOS-STE does NOT apply to content that must carry a specific human voice (letters, personal or marketing prose, voice-matched output). A declared voice profile wins.
### Secrets handling (HARD RULE)
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
@@ -0,0 +1,134 @@
# Writing Style Standard — MOS-STE (MANDATORY)
This guide defines how agents write. It sets one style standard per output type.
It is written in the standard it defines, as a worked example.
**Adapted, not compliant.** MOS-STE (Mosaic Simplified Technical English) is an
adapted profile of ASD-STE100. Mosaic does not license or certify against
ASD-STE100. Mosaic uses the load-bearing rules and fits them to agent work. This
is the same stance Mosaic takes toward DO-178B/C: use the rigor, do not claim the
certification.
## Scope — which standard governs which output
| Output type | Standard |
|---|---|
| Technical documentation (READMEs, runbooks, PRDs, procedures, ADRs, guides, acceptance criteria, design docs) | **MOS-STE** (this guide) |
| Source code and code comments | **Google Style Guide** for the language (§4) |
| Inter-agent comms | MOS-STE by default (concise, structured) |
| User-facing comms | **Per-user style choice** — read `USER.md` "Communication Preferences" (§5) |
| End-user prose the user owns (marketing, letters, personal writing, voice-matched content) | The user's declared voice. MOS-STE does NOT apply. |
**The user-voice carve-out is absolute.** Do not apply MOS-STE to content that
must carry a specific human voice (for example a cover letter, a personal
message, or marketing copy). That content needs the user's voice. MOS-STE would
damage it. When a project declares a voice profile, that profile wins.
## 1. Why one standard
Agent documentation drifts across projects. Different agents use different terms,
sentence styles, and structures for the same concept. Readers lose time.
Assumptions hide in ambiguous prose. One standard gives agents a clear target. It
gives reviewers a clear test.
## 2. Where MOS-STE matters most — verification artifacts
Apply MOS-STE hardest to acceptance criteria, witness predicates, gate
definitions, and alarm conditions. In prose, an ambiguous term produces a
confused reader. In a verification artifact, an ambiguous term produces a false
green — a check that passes without testing the claim.
The one-term-one-concept rule (rule 9) is the guard. When one word names two
concepts in one predicate, the check can test the wrong concept and still pass.
**Worked failure.** A rename used a witness predicate with three clauses: ref A
present, ref B absent, tip committed from this host. Every clause tested the git
*ref* (the channel). The claim under test was about a *field inside the payload*.
The word "beacon" named two concepts in one sentence. Deleting ref B was the next
scheduled step. That step flips the last clause green and certifies a state in
which the payload still names the wrong host. The predicate was one planned action
away from a false green on its normal path. The payload field was never tested.
Rule: when N failure modes share one observable, the observable is not a
diagnostic. In a verification artifact, that ambiguity does not confuse a reader —
it certifies the defect.
## 3. MOS-STE rules
### 3.1 Sentence rules
1. Keep sentences short. Use 20 words or fewer for a procedure. Use 25 words or
fewer for a description. (Reasoning and doctrine prose relaxes this limit —
see §3.4. A future lint enforces §3.1, not §3.4.)
2. Write one instruction per sentence. In a procedure, give one command per step.
3. Use the active voice. Write "Run the script." Do not write "The script should
be run."
4. Use the imperative for instructions. Start the sentence with the verb.
5. Use simple verb tenses. Prefer the present tense. Avoid the perfect and
progressive tenses when a simple tense works.
6. Do not use an `-ing` form when it makes the meaning unclear.
7. Write positive statements. State what to do, not only what to avoid.
### 3.2 Word rules
8. Use one word for one meaning. Do not use the same word in two senses.
9. Use one term for one concept. Do not use synonyms for variety. Example: choose
`secret`, `credential`, or `key` for each concept, and keep it.
10. Use articles (`a`, `the`). Do not drop words to save space.
11. Keep an approved-terms glossary per project. Add each domain noun and each
chosen verb. Technical names (for example `Vault`, `cgroup`, `systemd`) are
always allowed.
12. Define an abbreviation at its first use. Then use it consistently.
### 3.3 Structure rules
13. Use a list for parallel items or sequential steps. Do not put them in one long
sentence.
14. Use a table for data with more than two dimensions.
15. Use parallel structure in headings and steps.
16. Repeat the noun. Do not use a pronoun when the reference is unclear.
### 3.4 Adaptation notes (where MOS-STE deviates from ASD-STE100, and why)
- **No licensed dictionary.** ASD-STE100 ships a controlled dictionary under
copyright. MOS-STE uses per-project glossaries instead (rule 11).
- **Domain terms are allowed.** MOS-STE keeps every term the work needs.
- **Reasoning prose gets structure, not amputation.** Apply the sentence and word
rules to design and doctrine writing. Allow the length a subtle argument needs.
Readable-first beats rule-strict when the two conflict.
## 4. Code — Google Style Guide
Write source code to the Google Style Guide for the language (Python, TypeScript,
Shell, Go, and so on). Match the existing file when a local convention already
exists. Keep code comments to the MOS-STE sentence and word rules.
## 5. User-facing comms — a per-user choice
Mosaic is multi-user. Different users want different comms styles. The framework
already carries the selectable setting: `communicationStyle` (`direct` |
`friendly` | `formal`, default `direct`). `mosaic init` writes it, and the
builder renders it into the generated `USER.md` "Communication Preferences"
section. This guide adds the OUTPUT meaning of each value; do not invent new
values.
The builder renders the style as prose bullets, not the token name, so match on
the leading bullet the generated `USER.md` actually contains:
| `USER.md` leading bullet | Style | User-facing output |
|---|---|---|
| "Direct and concise" | `direct` (default) | MOS-STE structure — short, active, defined terms, tables for overview. |
| "Warm and conversational" | `friendly` | Warmer register. Full sentences, explain reasoning, fewer tables. |
| "Professional and structured" | `formal` | Professional and structured. Thorough, with explicit recommendations. |
This setting governs **user-facing comms only**. It does not change the
documentation standard (§3), which is always MOS-STE regardless of the value.
## 6. Enforcement
- **Now:** human review only. **No mechanical prose check exists today.** The
pre-push gate runs typecheck, lint, build, and tests; it inspects no prose.
Reviewers check output against the scope table and the MOS-STE rules by hand.
- **Future:** an MOS-STE lint check (built from the §3.1 sentence rules) and a
Google-style linter in the pre-push gate. A future linter enforces §3.1, not
§3.4 — see the note at rule 1.
@@ -1,138 +0,0 @@
# push-guard
Mechanical closure of one defect: **a verification that passes when the thing it verifies never happened.**
Three incidents in one evening, three agents, no coordination, same shape:
| # | Incident | Why the check passed |
|---|---|---|
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a *failed* commit and ran on stale state |
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote *moved*, the content *parses*.
## Checks
| Sub-command | Asserts | Exit on failure |
|---|---|---|
| `check-staged` | index has no unmerged paths | `2` |
| | no conflict markers in staged content | `2` |
| | the conflict scan actually *completed* | `2` |
| | staged JSON under the nominated paths parses | `3` |
| | **something is actually staged** | `5` |
| | **an explicit JSON decision exists** | `6` |
| `push` | HEAD advanced past `--since-head` | `5` |
| | HEAD is a non-empty commit | `5` |
| | remote **moved**, and now equals local HEAD | `4` |
## Usage
```bash
push-guard.sh check-staged --json-path 'data/**/*.json'
BEFORE=$(git rev-parse HEAD)
git commit -m "..."
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
```
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref *before* pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
## Design decisions that measurement forced
These are the interesting part; each one was wrong in the first draft.
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, *all legitimately* — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and *silently skips* `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
## Found by independent review, after the needles were already green
An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17 self-written needles, five mutation runs and a repo-wide false-positive measurement had all missed. Both were reproduced before being fixed, and both now have needles.
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
There is a trap inside this one worth recording. With *only* the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass *and* a false count. Fixed with `-a`.
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
## The JSON decision is mandatory (`.push-guard.json`)
The first release announced `JSON check NOT REQUESTED` and continued. That was *visible* rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
The decision is now required, from one of exactly two places:
```jsonc
// nominate generated JSON for parse-checking
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
// or opt out — the reason is REQUIRED and is printed on every run
{"json_check": "none", "reason": "no generated JSON in this repo"}
```
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
**The asymmetry is deliberate.** Turning the check *on* is safe from anywhere, so a CLI flag suffices. Turning it *off* is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but *states nothing* (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase *is* the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
## Known limitations — stated, not hidden
- `check-staged` inspects the index. Content added to the working tree *after* it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
## Test harness
`test-push-guard.sh` — 32 cases, each check in **both polarities**:
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
- **CONTROL** — a clean fixture that must pass.
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's *output* (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
### Mutation results — the harness was itself tested by breaking the guard
| Mutant | Result |
|---|---|
| fail-open (every failure downgraded to exit 0) | 9/16 fail — exactly the needles |
| always-fail | 16/16 fail — controls catch it |
| revert `:(glob)` normalization | 3/16 fail — caught **only** by the `--out` assertions |
| drop the remote-did-not-move assertion | 1/16 fail — the incident-1 needle |
| restore blanket `\|\| true` on the scan | 1/17 fail — the fault-injection needle |
| revert `--no-renames` | 1/19 fail — the rename needle |
| revert `-a` to `-I` | 1/19 fail — the binary-attribute needle |
| delete the `--since-head` block | 2/19 fail — incl. the control that *was* vacuous |
| revert refuse-on-missing-config | 1/32 fail — the closed fail-open |
| malformed config degrades to "absent" | 4/32 fail — **all four by `--out` alone**, exit code was correct every time |
| drop the mandatory `reason` | 1/32 fail — the unexplained-opt-out needle |
| make the guard emit a stray warning | 1/32 fail — the stray-output control |
| unmodified | 32/32 pass |
A guard nobody has watched fail is not a guard. The same applies to the harness: mutant C would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts *substrings it was told to look for* — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to *basic* regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
## Proposed framework path
```
framework/tools/git/push-guard.sh
framework/tools/git/test-push-guard.sh
```
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
@@ -1,617 +0,0 @@
#!/usr/bin/env bash
# push-guard.sh - Mechanical guards against verifications that PASS when the
# thing they verify never happened.
#
# WHY THIS EXISTS
# ---------------
# Three independent incidents, one shape:
# 1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
# reported success. The commit had ABORTED, so local trivially equalled
# remote. The guard could not distinguish "pushed" from "committed nothing".
# 2. An agent chained a push after a FAILED commit, ran on stale state, and
# pushed an EMPTY commit carrying an unrelated commit's message.
# 3. An agent committed unresolved conflict markers into 70 generated files and
# pushed invalid JSON to a default branch.
#
# All three are the same defect: a check whose success condition is satisfied by
# the null case. This script asserts the POSITIVE fact (a new object exists, the
# remote MOVED, the content parses) rather than the absence of an error.
#
# DESIGN RULES (do not relax these)
# * Every check exits NON-ZERO and names what failed. There is deliberately no
# --warn-only / --soft flag: a guard that can degrade to a warning is the
# fail-open we are trying to remove.
# * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
# network error aborts non-zero rather than falling through to "OK".
# * Nothing is skipped silently. A check with no applicable inputs says so on
# stdout instead of contributing a quiet green.
#
# EXIT CODES
# 0 all requested checks passed
# 2 conflict markers present, or the index has unmerged paths
# 3 staged JSON does not parse
# 4 push verification failed (remote did not move, or moved elsewhere)
# 5 nothing staged / empty commit — the null case, refused
# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json
# 64 usage error
#
# Dependencies: bash 4.4+, git, python3.
set -euo pipefail
readonly EX_OK=0
readonly EX_CONFLICT=2
readonly EX_JSON=3
readonly EX_PUSH=4
readonly EX_NULL=5
readonly EX_CONFIG=6
readonly EX_USAGE=64
# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
# resolve_json_config() for why.
readonly CONFIG_FILE=".push-guard.json"
# Conflict-marker detection.
#
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
# column 0 and required to be followed by a space or end-of-line (real markers
# are exactly seven characters plus an optional ref label).
#
# We deliberately do NOT match a bare `=======` line. It is the one marker that
# collides with ordinary prose — reStructuredText section underlines and ASCII
# rules use it constantly — and a guard that fires on documentation gets switched
# off, which costs more than the miss. Every genuine conflict git writes contains
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
readonly MARKER_PATTERNS=(
-e '^<<<<<<<( |$)'
-e '^>>>>>>>( |$)'
-e '^[|]{7}( |$)'
)
log() { printf '%s\n' "$*"; }
fail() {
local code="$1"; shift
printf '\n' >&2
printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
shift
local line
for line in "$@"; do printf ' %s\n' "$line" >&2; done
printf '\n' >&2
exit "$code"
}
usage() {
cat <<'EOF'
Usage:
push-guard.sh check-staged [--json-path <pathspec>]...
[--allow-invalid-json <pathspec>]...
push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
[-- <extra git push args>...]
push-guard.sh --help
Subcommands:
check-staged Run pre-commit content guards against the STAGED index:
(a) no unmerged index paths, no conflict markers
(c) staged JSON under the nominated paths parses
Refuses to pass when nothing is staged (exit 5).
Check (c) requires an EXPLICIT decision — either --json-path,
or .push-guard.json. Saying nothing is refused (exit 6).
push Perform a push and prove it HAPPENED:
- HEAD is a non-empty commit (differs from its parent)
- with --since-head: HEAD advanced past that sha
- the remote ref MOVED, and now equals local HEAD
Capturing the remote ref BEFORE the push is what makes
"remote == local" meaningful; without it the assertion is
satisfied by having pushed nothing.
Options:
--json-path <pathspec> Git pathspec of generated/serialized JSON to
parse-check, e.g. 'data/**/*.json'. Repeatable.
Opt-in on purpose: many real .json files are
JSONC (tsconfig) or templates and do NOT parse
strictly. Nominate the generated ones. Satisfies
the decision requirement on its own; overrides a
config opt-out (loudly).
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check
(for deliberate malformed-input fixtures).
Exemptions are printed, never silent.
--since-head <sha> HEAD before your commit step. Asserts a new
commit object was actually created.
--remote <name> Remote to push to.
--branch <name> Branch to push.
Repo config (.push-guard.json, at the repository root):
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
— nominate generated JSON for parse-checking.
{"json_check": "none", "reason": "no generated JSON in this repo"}
— opt out. The reason is REQUIRED and is printed on every run.
An opt-out is only accepted from this file, never from a command-line flag: a
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
An invalid config is refused outright rather than treated as absent.
There is no flag to downgrade a failure to a warning. That is intentional.
EOF
}
require_repo() {
git rev-parse --show-toplevel >/dev/null 2>&1 \
|| fail "$EX_USAGE" "not inside a git repository"
cd "$(git rev-parse --show-toplevel)"
}
# ---------------------------------------------------------------- check-staged
check_unmerged() {
local unmerged
unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
if [[ -n "$unmerged" ]]; then
mapfile -t paths <<<"$unmerged"
fail "$EX_CONFLICT" \
"the index has UNMERGED paths — a merge/rebase is still in progress" \
"${paths[@]}" \
"" \
"Resolve the conflict and 'git add' the result. Do not commit past this."
fi
log " ok index has no unmerged paths"
}
check_conflict_markers() {
local -a files=("$@")
local hits
# git grep --cached searches the INDEX (what will actually be committed),
# not the working tree.
#
# -a (treat every blob as text), NOT -I (skip binary). -I honours git's
# binary classification, which includes an explicit `.gitattributes` `binary`
# attribute. A repo that marks a path binary would have its staged conflict
# markers silently skipped while the summary still counted the file as
# scanned — a clean pass AND a false count. Verified reproducible. The cost
# of -a is that a genuine binary blob could theoretically emit a noisy match
# line; a false alarm is recoverable, a silent miss is not.
# -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
# and '{7}' are literal characters. Without it these patterns match nothing
# and the check reports a clean pass over a file full of markers. That
# failure was caught by the needle, not by review.
#
# Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
# match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
# '|| true' would turn a broken scan into "ok, no conflict markers" — the
# same fail-open in the guard's own error handling.
local status=0
hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
if (( status > 1 )); then
fail "$EX_CONFLICT" \
"git grep FAILED (exit $status) — the conflict scan did not run" \
"Refusing to report a clean result from a scan that did not complete."
fi
if [[ -n "$hits" ]]; then
mapfile -t lines <<<"$hits"
fail "$EX_CONFLICT" \
"staged content contains unresolved CONFLICT MARKERS" \
"${lines[@]}" \
"" \
"These are staged and would be committed verbatim."
fi
log " ok no conflict markers in ${#files[@]} staged file(s)"
}
# JSON parse check.
#
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
# The first draft checked every staged *.json. Run against a real repository that
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
# them legitimate: every tsconfig*.json is JSONC (comments are legal there) and
# the CA templates are Go templates that merely carry a .json suffix. An
# on-by-default check would have fired on ~14% of this repo's JSON, and a guard
# that cries wolf gets routed around until the bypass is habitual — which is
# worse than no guard, because the bypass then also covers the true positives.
#
# So the caller nominates the generated/serialized paths this check is FOR, as
# git pathspecs (git, not bash, expands them — '**' works correctly).
#
# WHAT SAYING NOTHING NOW COSTS YOU
# ---------------------------------
# The first release announced "JSON check NOT REQUESTED" and continued. That was
# visible rather than silent, which is better — but it is still an ABSENCE, and
# an absence is not reviewable. Nobody reads a line that has printed correctly
# ten thousand times. A repo that needed the check and never wired it up stayed
# unprotected forever, and nothing ever failed.
#
# The decision is now MANDATORY and must come from one of two places:
# * turning the check ON — --json-path on the command line, or "json_paths"
# in .push-guard.json
# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
# which REQUIRES a non-empty "reason"
# Saying nothing at all is refused (exit 6).
#
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
# one OFF is confined to a committed file because that is the only form a human
# can review: you can read a reason in a diff, and you cannot review the fact
# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is
# just the old fail-open with extra steps.
# The config parser, held as a string rather than an inline heredoc.
#
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
# warning: command substitution: 1 unterminated here-document
# on every single run. It still executed, every needle stayed green and the
# static linter stayed clean — the warning goes to stderr and broke no
# assertion. It surfaced only when the guard was run against a real
# repository. A tool built to refuse quiet output was quietly polluting
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
#
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
# shape where it does not mask a real error, unlike the `git grep || true`
# this codebase already removed once.
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
import json, sys
path = sys.argv[1]
try:
with open(path) as fh:
cfg = json.load(fh)
except Exception as exc:
sys.stderr.write("does not parse: %s" % exc)
sys.exit(2)
if not isinstance(cfg, dict):
sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
sys.exit(2)
def clean_list(name, value):
if not isinstance(value, list):
sys.stderr.write('"%s" must be an array' % name)
sys.exit(2)
for item in value:
if not isinstance(item, str) or not item.strip():
sys.stderr.write('"%s" must contain only non-empty strings' % name)
sys.exit(2)
# Tabs/newlines would be mangled by the tab-delimited handoff below.
# Reject them explicitly rather than silently truncating a pathspec.
if "\t" in item or "\n" in item:
sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
sys.exit(2)
return value
mode = cfg.get("json_check")
paths = cfg.get("json_paths")
allow = cfg.get("allow_invalid_json", [])
if mode is not None and mode != "none":
sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
sys.exit(2)
if mode == "none":
if paths:
sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
sys.exit(2)
reason = cfg.get("reason")
if not isinstance(reason, str) or not reason.strip():
sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
sys.exit(2)
print("MODE\tnone")
print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
sys.exit(0)
if paths is None:
sys.stderr.write(
'states no decision — needs "json_paths", '
'or "json_check": "none" with a "reason"'
)
sys.exit(2)
clean_list("json_paths", paths)
if not paths:
sys.stderr.write('"json_paths" must not be empty')
sys.exit(2)
clean_list("allow_invalid_json", allow)
print("MODE\tcheck")
for item in paths:
print("JPATH\t%s" % item)
for item in allow:
print("ALLOW\t%s" % item)
PY
resolve_json_config() {
local cfg out status=0
cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"
if [[ ! -f "$cfg" ]]; then
CFG_MODE="absent"
return 0
fi
# A malformed config must REFUSE, never degrade to "treat as absent". That
# fallback would rebuild precisely the fail-open this gate closes: a typo in
# the config would silently disable the check it was written to enable.
out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?
if (( status != 0 )); then
fail "$EX_CONFIG" \
"$CONFIG_FILE is INVALID — $out" \
"" \
"Refusing to run. A config that does not parse is not the same as no" \
"config: treating it as absent would silently disable the very check" \
"this file was written to enable."
fi
local key val
while IFS=$'\t' read -r key val; do
case "$key" in
MODE) CFG_MODE="$val" ;;
REASON) CFG_REASON="$val" ;;
JPATH) CFG_PATHS+=("$val") ;;
ALLOW) CFG_ALLOW+=("$val") ;;
esac
done <<<"$out"
}
check_staged_json() {
local -a checked=() skipped=()
local f err
local -a pathspec=()
resolve_json_config
if (( ${#JSON_PATHS[@]} == 0 )); then
case "$CFG_MODE" in
absent)
fail "$EX_CONFIG" \
"NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
"" \
"This guard will not run without an explicit decision about" \
"staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
"" \
' {"json_paths": ["data/**/*.json"]}' \
"" \
"or, if this repo genuinely has no generated JSON:" \
"" \
' {"json_check": "none", "reason": "<why>"}' \
"" \
"The reason is mandatory so the opt-out is recorded and" \
"reviewable in the diff, instead of being an absence nobody sees."
;;
none)
log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
return 0
;;
check)
JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
;;
esac
else
# An explicit --json-path turns the check ON, so it satisfies the decision
# requirement by itself. If the config opted out, the nomination WINS and
# says so loudly — resolving a contradiction toward more checking is the
# only safe direction, but silently ignoring a committed opt-out would
# hide a real disagreement.
case "$CFG_MODE" in
none)
log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was"
log " !! given — honouring the nomination and checking anyway."
;;
check)
JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
;;
esac
fi
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
# makes 'data/**/*.json' require at least one intermediate directory, so it
# matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
# spelling would then deliver partial coverage with no warning — a
# quiet-underscan, which is the same family of defect as a quiet pass.
# Pathspecs that already carry explicit magic (leading ':') are left alone.
for f in "${JSON_PATHS[@]}"; do
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
done
for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
skipped+=("$f")
done
local -a files=()
mapfile -d '' -t files < <(
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
-- "${pathspec[@]}"
)
for f in ${files[@]+"${files[@]}"}; do
[[ "$f" == *.json ]] || continue
if ! err="$(git show ":$f" | python3 -c '
import json, sys
try:
json.load(sys.stdin)
except Exception as exc:
sys.stderr.write(str(exc))
sys.exit(1)
' 2>&1)"; then
fail "$EX_JSON" \
"staged JSON does not parse: $f" \
"$err" \
"" \
"A serialization error from a generator is a STOP, not something to commit past."
fi
checked+=("$f")
done
for f in ${skipped[@]+"${skipped[@]}"}; do
log " -- JSON check EXEMPTED by --allow-invalid-json: $f"
done
if (( ${#checked[@]} == 0 )); then
log " -- no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
else
log " ok ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
fi
}
cmd_check_staged() {
require_repo
log "push-guard: checking staged content"
check_unmerged
# --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
# (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
# as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
# becomes invisible to every content check: staged conflict markers sail
# through and the guard prints "staged content OK". Verified reproducible at
# 97% similarity with an ordinary co-staged file present. --no-renames
# decomposes each rename back into D + A so the new path is always seen.
# (Adding R to the filter also works, but --name-only -z emits two paths for
# a rename and is ambiguous to parse — this removes the category instead.)
mapfile -d '' -t files < <(
git diff --cached --name-only --diff-filter=ACM --no-renames -z
)
if (( ${#files[@]} == 0 )); then
fail "$EX_NULL" \
"NOTHING IS STAGED" \
"About to commit, but the index is identical to HEAD." \
"" \
"This is the null case: a commit here is empty, and any later" \
"'verified' step would be asserting against content that does not exist."
fi
check_conflict_markers "${files[@]}"
check_staged_json "${files[@]}"
log "push-guard: staged content OK"
}
# ------------------------------------------------------------------------ push
remote_sha() {
# Empty output means the branch does not exist on the remote yet.
# A network/auth failure makes ls-remote non-zero, which set -e turns into an
# abort — the guard never treats an unreachable remote as "unchanged".
git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
}
cmd_push() {
require_repo
local remote="" branch="" since_head=""
local -a extra=()
while (( $# )); do
case "$1" in
--remote) remote="${2:-}"; shift 2 ;;
--branch) branch="${2:-}"; shift 2 ;;
--since-head) since_head="${2:-}"; shift 2 ;;
--) shift; extra=("$@"); break ;;
*) fail "$EX_USAGE" "unknown argument to push: $1" ;;
esac
done
[[ -n "$remote" && -n "$branch" ]] \
|| fail "$EX_USAGE" "push requires --remote and --branch"
local head
head="$(git rev-parse HEAD)"
log "push-guard: verifying push of $head -> $remote/$branch"
# (1) Did a new commit actually get created?
if [[ -n "$since_head" ]]; then
if [[ "$head" == "$since_head" ]]; then
fail "$EX_NULL" \
"HEAD DID NOT ADVANCE — no commit was created" \
"HEAD is still $head" \
"" \
"Your commit step failed and execution continued on stale state." \
"Pushing now would publish something you did not just build."
fi
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9}"
fi
# (2) Is that commit non-empty?
local parents
parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
if (( parents == 2 )); then # sha + exactly one parent
if git diff --quiet HEAD^ HEAD; then
fail "$EX_NULL" \
"HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
"commit $head" \
"" \
"An empty commit carrying a real message is indistinguishable" \
"from real work in the log. Refusing to push it."
fi
log " ok HEAD is a non-empty commit"
elif (( parents > 2 )); then
log " -- HEAD is a merge commit — emptiness check not applicable"
else
log " -- HEAD is a root commit — emptiness check not applicable"
fi
# (3) Capture the remote BEFORE. This is the step whose absence made the
# original "PUSH VERIFIED" a false positive.
local before after
before="$(remote_sha "$remote" "$branch")"
log " .. remote before: ${before:-<branch does not exist>}"
git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}
after="$(remote_sha "$remote" "$branch")"
log " .. remote after: ${after:-<absent>}"
# (4) The remote must now equal local HEAD...
if [[ "$after" != "$head" ]]; then
fail "$EX_PUSH" \
"REMOTE DOES NOT MATCH LOCAL HEAD after push" \
"local HEAD: $head" \
"remote $branch: ${after:-<absent>}" \
"" \
"The push did not land what you are holding."
fi
# (5) ...AND it must have MOVED to get there. Without this, a push that
# transferred nothing passes step (4) trivially.
if [[ "$after" == "$before" ]]; then
fail "$EX_PUSH" \
"REMOTE DID NOT MOVE — nothing was actually pushed" \
"remote was already at $after before this push ran" \
"" \
"'remote == local' is satisfied by having pushed nothing. That is" \
"the false positive this guard exists to catch: the work you think" \
"you just published was already there, or was never committed."
fi
log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
}
# ------------------------------------------------------------------------ main
ALLOW_INVALID_JSON=()
JSON_PATHS=()
CFG_MODE="absent"
CFG_REASON=""
CFG_PATHS=()
CFG_ALLOW=()
main() {
(( $# )) || { usage; exit "$EX_USAGE"; }
local sub="$1"; shift
case "$sub" in
check-staged)
while (( $# )); do
case "$1" in
--json-path) JSON_PATHS+=("${2:-}"); shift 2 ;;
--allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
*) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
esac
done
cmd_check_staged
;;
push) cmd_push "$@" ;;
-h|--help) usage ;;
*) usage; exit "$EX_USAGE" ;;
esac
# Explicit: the only way to reach here is with every requested check passed.
exit "$EX_OK"
}
main "$@"
@@ -1,472 +0,0 @@
#!/usr/bin/env bash
# test-push-guard.sh - Needle harness for push-guard.sh.
#
# Every check gets BOTH polarities:
# NEEDLE a deliberately-broken fixture that MUST trip the guard.
# CONTROL a clean fixture that MUST pass.
#
# The controls are not decoration. A guard that failed unconditionally would
# satisfy every needle and look fully covered — which is the same class of defect
# (an assertion satisfied by the null case) that push-guard.sh exists to prevent.
# A needle set without controls cannot tell "working guard" from "broken guard".
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GUARD="$SCRIPT_DIR/push-guard.sh"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}"
PASS=0
FAIL=0
# Fresh repo + bare "remote" for each case, so no case can inherit another's state.
new_repo() {
local name="$1"
local dir="$WORK_DIR/$name"
rm -rf "$dir"
mkdir -p "$dir"
git init -q -b main "$dir/repo"
git init -q --bare "$dir/remote.git"
git -C "$dir/repo" remote add origin "$dir/remote.git"
git -C "$dir/repo" config user.name "Needle Harness"
git -C "$dir/repo" config user.email "[email protected]"
printf 'seed\n' > "$dir/repo/seed.txt"
git -C "$dir/repo" add seed.txt
git -C "$dir/repo" commit -q -m "seed"
printf '%s' "$dir/repo"
}
# write_config <repo> <json-text>
# The guard reads .push-guard.json from the WORKING TREE at the repo root, so it
# does not need to be staged — which also keeps the "N staged file(s)" counts in
# the older controls unchanged.
write_config() {
printf '%s\n' "$2" > "$1/.push-guard.json"
}
# expect <kind> <expected_exit> <description> [--out <substring>] -- <command...>
#
# --out asserts a substring of the guard's own output. It exists because exit 0
# alone cannot distinguish "checked the files and they were fine" from "matched
# no files and had nothing to check". Two controls in an earlier revision of this
# harness were passing vacuously for exactly that reason — the pathspec silently
# matched nothing. A control that cannot fail is not a control.
expect() {
local kind="$1" want="$2" desc="$3"; shift 3
local need_out=""
while (( $# )); do
case "$1" in
--out) need_out="$2"; shift 2 ;;
--) shift; break ;;
*) break ;;
esac
done
local got=0 out
out="$("$@" 2>&1)" || got=$?
local why=""
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
why="exit $got as expected, but output never said: $need_out"
fi
if [[ -z "$why" ]]; then
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
PASS=$((PASS + 1))
else
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
printf '%s\n' "$out" | sed 's/^/ | /'
FAIL=$((FAIL + 1))
fi
}
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
echo "=== (a) conflict markers / unmerged index ==="
# NEEDLE: staged content carrying real conflict markers.
R="$(new_repo a1)"
cat > "$R/conflicted.txt" <<'EOF'
intro line
<<<<<<< HEAD
ours
=======
theirs
>>>>>>> feature/x
tail line
EOF
git -C "$R" add conflicted.txt
expect NEEDLE 2 "staged conflict markers are refused" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: a genuine unmerged index (merge in progress).
R="$(new_repo a2)"
git -C "$R" checkout -q -b other
printf 'from-other\n' > "$R/clash.txt"
git -C "$R" add clash.txt && git -C "$R" commit -q -m other
git -C "$R" checkout -q main
printf 'from-main\n' > "$R/clash.txt"
git -C "$R" add clash.txt && git -C "$R" commit -q -m main
git -C "$R" merge other -q >/dev/null 2>&1 || true
expect NEEDLE 2 "unmerged index paths are refused" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: ordinary staged content passes.
# The config is REQUIRED as of the decision gate: this fixture has no generated
# JSON, so it records that fact with a reason. This is the migration cost of the
# hard cutover, paid here first — these two controls were the only pre-existing
# cases that reached the JSON stage without nominating a path, and they are
# exactly the "repo that never wired it up" the gate now refuses.
R="$(new_repo a3)"
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
printf 'just some code\n' > "$R/clean.txt"
git -C "$R" add clean.txt
expect CONTROL 0 "clean staged content passes (1 file actually scanned)" \
--out "no conflict markers in 1 staged file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL (false-positive guard): prose using ======= as an underline must NOT trip.
# This is why the marker pattern deliberately ignores a bare '======='.
R="$(new_repo a4)"
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
cat > "$R/README.rst" <<'EOF'
Section Title
=============
Body text.
Another Heading
=======
EOF
git -C "$R" add README.rst
expect CONTROL 0 "prose using ======= underlines does not trip the guard" \
--out "no conflict markers in 1 staged file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE (fault injection): if the scan itself ERRORS, the guard must refuse
# rather than report a clean result. git grep exits 1 for "no match" but >=2 for
# a real failure; a blanket '|| true' would collapse the two. We inject the
# failure with a PATH shim rather than trying to corrupt an index.
R="$(new_repo a5)"
SHIM="$WORK_DIR/a5/bin"
mkdir -p "$SHIM"
REAL_GIT="$(command -v git)"
cat > "$SHIM/git" <<SH
#!/usr/bin/env bash
if [[ "\$1" == "grep" ]]; then exit 2; fi
exec "$REAL_GIT" "\$@"
SH
chmod +x "$SHIM/git"
printf 'ordinary content\n' > "$R/thing.txt"
git -C "$R" add thing.txt
expect NEEDLE 2 "a conflict scan that ERRORS is refused, not reported clean" \
--out "did not run" -- \
bash -c "cd '$R' && export PATH=\"$SHIM:\$PATH\" && '$GUARD' check-staged"
# NEEDLE: RENAME + MODIFY. Git reports a `git mv` plus a small edit as a single
# 'R' entry, which --diff-filter=ACM does not match, making the file invisible to
# every content check. A clean file is co-staged deliberately: without it the
# file list is empty and the nothing-staged guard fires by ACCIDENT, which would
# make this needle pass for the wrong reason and hide the real defect.
R="$(new_repo a6)"
mkdir -p "$R/data"
seq 1 200 | sed 's/^/line /' > "$R/data/canon.txt"
printf 'original\n' > "$R/other.txt"
git -C "$R" add -A && git -C "$R" commit -q -m seed
git -C "$R" mv data/canon.txt data/canon2.txt
printf '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n' >> "$R/data/canon2.txt"
printf 'an ordinary innocent change\n' > "$R/other.txt"
git -C "$R" add -A
expect NEEDLE 2 "conflict markers in a RENAMED file are refused (rename+modify)" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: a path marked binary in .gitattributes. `git grep -I` honours that
# classification and skips the blob entirely while the summary still counts the
# file as scanned — a clean pass AND a false count. Hence -a, not -I.
R="$(new_repo a7)"
printf 'notes.txt binary\n' > "$R/.gitattributes"
printf 'x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\ny\n' > "$R/notes.txt"
git -C "$R" add -A
expect NEEDLE 2 "conflict markers in a .gitattributes-binary file are refused" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
echo "=== (c) staged JSON parses ==="
# NEEDLE: malformed JSON, the shape a broken generator emits.
R="$(new_repo c1)"
mkdir -p "$R/data"
printf '{"a": 1,\n' > "$R/data/broken.json"
git -C "$R" add data/broken.json
expect NEEDLE 3 "staged unparseable JSON under --json-path is refused" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
# NEEDLE: conflict markers inside a generated JSON file — the 70-file incident.
R="$(new_repo c2)"
mkdir -p "$R/data"
cat > "$R/data/canon.json" <<'EOF'
{
<<<<<<< HEAD
"v": 1
=======
"v": 2
>>>>>>> main
}
EOF
git -C "$R" add data/canon.json
expect NEEDLE 2 "conflict markers in generated JSON are refused" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: valid JSON passes.
R="$(new_repo c3)"
mkdir -p "$R/data"
printf '{"a": 1, "b": [2, 3]}\n' > "$R/data/good.json"
git -C "$R" add data/good.json
expect CONTROL 0 "valid staged JSON passes (and was actually parsed)" \
--out "1 staged .json file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
# CONTROL: an explicit exemption works (and is reported, never silent).
R="$(new_repo c4)"
mkdir -p "$R/fixtures"
printf 'not json at all' > "$R/fixtures/malformed.json"
git -C "$R" add fixtures/malformed.json
expect CONTROL 0 "deliberate malformed fixture can be exempted (exemption announced)" \
--out "EXEMPTED by --allow-invalid-json" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'fixtures/*' --allow-invalid-json 'fixtures/*'"
# CONTROL (false-positive regression): JSONC is the common real-world case.
# tsconfig.json legally carries comments and does NOT parse strictly. Measured on
# a real repo: 17 of 119 tracked .json files are like this. An on-by-default
# check would fire on all of them; scoping by --json-path is what prevents it.
R="$(new_repo c5)"
cat > "$R/tsconfig.json" <<'EOF'
{
// JSONC: comments are legal here and strict json.load() rejects them.
"compilerOptions": { "strict": true }
}
EOF
mkdir -p "$R/data"
printf '{"generated": true}\n' > "$R/data/view.json"
git -C "$R" add tsconfig.json data/view.json
expect CONTROL 0 "JSONC outside --json-path does not trip the JSON check" \
--out "1 staged .json file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
# NEEDLE: the same tsconfig DOES fail if someone wrongly nominates it, proving
# the check is genuinely running and the control above is not a vacuous pass.
expect NEEDLE 3 "the same JSONC file fails when explicitly nominated" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'tsconfig.json'"
echo "=== (d) the JSON decision is MANDATORY (.push-guard.json) ==="
# NEEDLE: the fail-open this gate closes. No --json-path, no config: the previous
# release printed "JSON check NOT REQUESTED" and exited 0, leaving the repo
# unprotected forever with nothing ever failing.
R="$(new_repo d1)"
printf '{"a": 1}\n' > "$R/thing.json"
git -C "$R" add thing.json
expect NEEDLE 6 "no --json-path and no config is REFUSED (the closed fail-open)" \
--out "NO JSON DECISION" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: config nominates paths, and the check genuinely runs off it.
R="$(new_repo d2)"
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
mkdir -p "$R/data"
printf '{"a": 1,\n' > "$R/data/broken.json"
git -C "$R" add data/broken.json
expect NEEDLE 3 "broken JSON is caught via config-supplied json_paths" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: opt-out WITHOUT a reason. An unexplained opt-out is the absence again,
# merely relocated into a file.
R="$(new_repo d3)"
write_config "$R" '{"json_check": "none"}'
printf 'code\n' > "$R/x.txt"
git -C "$R" add x.txt
expect NEEDLE 6 "json_check:none without a reason is refused" \
--out "REQUIRES a non-empty" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: malformed config must REFUSE, never fall back to "treat as absent".
# That fallback would mean a typo silently disables the check the file enables.
R="$(new_repo d4)"
write_config "$R" '{"json_paths": ["data/**/*.json",'
printf 'code\n' > "$R/x.txt"
git -C "$R" add x.txt
expect NEEDLE 6 "an unparseable config is refused, not treated as absent" \
--out "INVALID" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: a config that parses but states NO decision. Distinct from malformed —
# this one is valid JSON and still says nothing, which is the original defect
# wearing a config file as a disguise.
R="$(new_repo d5)"
write_config "$R" '{}'
printf 'code\n' > "$R/x.txt"
git -C "$R" add x.txt
expect NEEDLE 6 "a valid config that states no decision is refused" \
--out "states no decision" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# NEEDLE: a bogus json_check value must not be read as an opt-out.
R="$(new_repo d6)"
write_config "$R" '{"json_check": "off", "reason": "typo for none"}'
printf 'code\n' > "$R/x.txt"
git -C "$R" add x.txt
expect NEEDLE 6 'json_check with an unrecognised value is refused' \
--out 'must be "none"' -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: config nominates paths, JSON is clean.
# --out is REQUIRED. Exit 0 alone cannot distinguish "read the config, resolved
# the paths, parsed the files" from "matched nothing and had nothing to do" —
# the vacuous-control trap that already caught this harness once.
R="$(new_repo d7)"
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
mkdir -p "$R/data"
printf '{"generated": true}\n' > "$R/data/view.json"
git -C "$R" add data/view.json
expect CONTROL 0 "config-supplied json_paths pass and are reported as parsed" \
--out "1 staged .json file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: a recorded opt-out passes AND the reason is echoed. Asserting on the
# reason is the whole point — an opt-out nobody can see is what we just removed.
R="$(new_repo d8)"
write_config "$R" '{"json_check": "none", "reason": "no generated JSON in this repo"}'
printf 'code\n' > "$R/x.txt"
git -C "$R" add x.txt
expect CONTROL 0 "a recorded opt-out passes and PRINTS its reason" \
--out "recorded reason: no generated JSON in this repo" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: config-supplied exemptions still work through the config path.
R="$(new_repo d9)"
write_config "$R" '{"json_paths": ["fixtures/*"], "allow_invalid_json": ["fixtures/*"]}'
mkdir -p "$R/fixtures"
printf 'not json at all' > "$R/fixtures/malformed.json"
git -C "$R" add fixtures/malformed.json
expect CONTROL 0 "config-supplied allow_invalid_json exempts (and announces it)" \
--out "EXEMPTED by --allow-invalid-json" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
# CONTROL: --json-path alone still satisfies the decision, with no config at all.
# This is what keeps every pre-existing caller working: nominating a path IS an
# explicit decision. Only saying nothing is refused.
R="$(new_repo d10)"
mkdir -p "$R/data"
printf '{"a": 1}\n' > "$R/data/good.json"
git -C "$R" add data/good.json
expect CONTROL 0 "--json-path alone satisfies the decision with no config present" \
--out "1 staged .json file(s)" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
# NEEDLE: a CLI nomination must WIN over a config opt-out, loudly. Resolving a
# contradiction toward more checking is the only safe direction, but doing it
# silently would hide a genuine disagreement between caller and repo.
R="$(new_repo d11)"
write_config "$R" '{"json_check": "none", "reason": "claims no generated JSON"}'
mkdir -p "$R/data"
printf '{"a": 1,\n' > "$R/data/broken.json"
git -C "$R" add data/broken.json
expect NEEDLE 3 "--json-path overrides a config opt-out and still catches bad JSON" \
--out "honouring the nomination" -- \
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
echo "=== (z) the guard itself emits no stray output ==="
# CONTROL: the guard must not print interpreter warnings.
#
# This case exists because a real one shipped: the config parser was an inline
# `<<'PY'` heredoc inside a $( ) command substitution, so bash printed
# warning: command substitution: 1 unterminated here-document
# on EVERY run. Thirty needles and a clean shellcheck all missed it — the
# warning went to stderr, and no assertion in this harness looked at output it
# had not already been told to expect. It was found only by running the guard
# against a real repository.
#
# The lesson is narrow and worth encoding: asserting on what you EXPECT to see
# cannot detect what you never thought to look for. This asserts on the absence
# of a whole output class instead.
R="$(new_repo z1)"
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
mkdir -p "$R/data"
printf '{"a": 1}\n' > "$R/data/view.json"
git -C "$R" add data/view.json
z_out="$(cd "$R" && "$GUARD" check-staged 2>&1)"
if grep -qiE 'warning:|unterminated|command substitution' <<<"$z_out"; then
printf ' FAIL [%-7s] %s\n' "CONTROL" "guard emits interpreter warnings"
printf '%s\n' "$z_out" | sed 's/^/ | /'
FAIL=$((FAIL + 1))
else
printf ' PASS [%-7s] %s\n' "CONTROL" "guard runs without emitting any interpreter warning"
PASS=$((PASS + 1))
fi
# NEEDLE for the case above: prove the detector can actually fire, otherwise it
# is one more assertion that passes because it looked at nothing.
if grep -qiE 'warning:|unterminated|command substitution' \
<<<"bash: warning: command substitution: 1 unterminated here-document"; then
printf ' PASS [%-7s] %s\n' "NEEDLE" "the warning detector fires on a known warning string"
PASS=$((PASS + 1))
else
printf ' FAIL [%-7s] %s\n' "NEEDLE" "the warning detector is dead — it cannot fire"
FAIL=$((FAIL + 1))
fi
echo "=== null case: nothing staged ==="
# NEEDLE: the index equals HEAD. Committing here produces the empty commit.
R="$(new_repo n1)"
expect NEEDLE 5 "empty index is refused before a commit happens" -- \
bash -c "cd '$R' && '$GUARD' check-staged"
echo "=== (b) push actually happened ==="
# CONTROL: a real push that moves the remote.
R="$(new_repo b1)"
printf 'work\n' > "$R/work.txt"
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
expect CONTROL 0 "a push that moves the remote is confirmed" \
--out "PUSH CONFIRMED" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
# NEEDLE: THE ORIGINAL FALSE POSITIVE. Remote already equals local HEAD, so the
# naive 'remote == local' assertion succeeds while nothing is transferred.
R="$(new_repo b2)"
printf 'work\n' > "$R/work.txt"
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
git -C "$R" push -q origin HEAD:refs/heads/main
expect NEEDLE 4 "second push transferring nothing is refused (remote did not move)" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
# NEEDLE: the commit step aborted, so HEAD never advanced.
R="$(new_repo b3)"
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
expect NEEDLE 5 "push after an aborted commit is refused (HEAD did not advance)" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
# NEEDLE: an empty commit carrying a real message.
R="$(new_repo b4)"
git -C "$R" commit -q --allow-empty -m "feat: important-sounding message"
expect NEEDLE 5 "pushing an empty commit is refused" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
# CONTROL: --since-head is satisfied when a real commit was made.
R="$(new_repo b5)"
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
printf 'work\n' > "$R/work.txt"
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
# --out is required here. Without it this control is VACUOUS: deleting the whole
# --since-head validation block would still yield exit 0, because the empty-commit
# and remote-moved checks independently succeed. It would prove nothing about the
# code path it names.
expect CONTROL 0 "--since-head passes when a real commit was created" \
--out "HEAD advanced" -- \
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
echo
printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
(( FAIL == 0 )) || exit 1
@@ -414,22 +414,6 @@ cmd_poll_once() {
_wake_clean_stale_tmp "$STATE_DIR"
mkdir -p "$DET_DIR"
local failed=0
# #958 preimage provenance: check the OPERATOR-SIDE preimage definition (the
# source adapter file, the watch-list, operator-declared extras) BEFORE
# observing any source. A changed preimage re-baselines EVERY source at once;
# running the check first means its first-class cause line is enqueued at a
# LOWER observed_seq than the N per-source deltas it explains, so the digest
# shows the cause, not just the flood. An infrastructure failure of the check
# is LOUD and marks this pass failed (G2a discipline — never read as "no
# change"), but source observation still proceeds: provenance must not be
# able to starve wake delivery.
if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then
echo "detector.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); source observation continues but this pass exits non-zero." >&2
failed=1
fi
# Iterate the DECLARED source-coverage inventory (§4/G3): only sources listed
# in watches[].sources[] are polled. An omitted source is not observed (and so
# cannot make anything pass vacuously); a referenced-but-undefined source is a
@@ -443,7 +427,7 @@ cmd_poll_once() {
exit 2
fi
local kind id def class
local failed=0 kind id def class
while IFS=$'\t' read -r kind id; do
[ -n "$kind" ] || continue
# Resolve the source definition from its top-level collection by id.
@@ -488,19 +488,6 @@ _dlq_alarm_offhost() {
# A dead-letter write failure is itself alarmed (both legs) but never aborts the
# drain — the good entries MUST still deliver (availability is the whole point of
# #920).
#
# RETENTION IS LOAD-BEARING (#953) — read BEFORE adding rotation/pruning/caps:
# this ledger is append-only history AND the SOLE evidence base for
# store.sh quarantine-audit's conviction predicate (#946): a false
# consumed-hashes witness row is provable ONLY while its matching entry
# survives HERE. Any rotation, pruning, or size cap — however locally correct
# — silently converts provable rows into unprovable ones, and the audit's
# clean sweep reads IDENTICALLY before and after the evidence disappears (no
# signal at either end; the audit never guesses, by design). If retention
# limits ever become genuinely necessary, they MUST ship with (a) a loud
# signal at prune time naming what evidence is being given up, and (b) the
# audit's residual-class reporting updated IN THE SAME CHANGE. Until then:
# nothing prunes this file, and that is a recorded decision, not an omission.
_quarantine_entry() {
local line="$1" seq="$2" dlq="$STATE_DIR/dead-letter.jsonl"
if [ ! -f "$dlq" ] || ! grep -qxF "$line" "$dlq" 2>/dev/null; then
@@ -374,99 +374,8 @@
# Changed: store.sh, digest.sh, ack.sh
# (+ test-wake-store-ack.sh T13-T16,
# test-wake-digest-quarantine.sh Q12-Q16).
# 0.7.0 #958 A11 preimage.sh — durable provenance for the OPERATOR-SIDE
# preimage definition (wake-pilot finding: source-adapter.sh was
# unversioned, so the operator-owned bytes every observed_hash is
# computed FROM had thinner provenance than any hash they feed;
# attribution required an agent transcript). NEW tool + two
# pre-step integrations, ADDITIVE: (1) preimage.sh derives the
# preimage set from the RUNTIME env (the resolved
# WAKE_DETECTOR_SOURCE_CMD file, WAKE_WATCH_LIST, optional
# WAKE_PREIMAGE_EXTRA paths) and, per file, records
# {ts,path,sha256,size,mtime,prev} to an append-only ledger +
# captures the bytes CONTENT-ADDRESSED under
# $STATE_DIR/preimage/objects/<sha256> — prior bytes + change
# time are answerable from durable state alone, no transcript
# (acceptance a). A git-repo-in-operator-dir design was REJECTED:
# its who/why claim is false under shared-author fleets, and
# .gitignore is pattern-based where acceptance (b) demands
# fail-closed. (2) CREDENTIAL HARD GATE (acceptance b), FOUR
# LAYERS (#964 re-verdict: B2 cases C+D): (i) POLARITY — extras
# (WAKE_PREIMAGE_EXTRA) are RECORD-ONLY by default (ledger row +
# cause line, NO bytes); byte capture for an extra requires the
# path listed in WAKE_PREIMAGE_CAPTURE (colon-separated opt-in);
# only the core set (adapter, watch-list) is capture-eligible by
# default — a shape list can only refuse the secrets someone
# already enumerated, so safety may not rest on one (#964-D).
# (ii) PATH DENY evaluated on BOTH the raw and realpath-resolved
# candidate forms against BOTH unresolved and resolved
# mosaic-home anchors (credentials.json,
# tools/_lib/credentials.json, credentials/**, any basename
# credentials.json) — a deny list written in unresolved paths
# cannot match a path resolved before it arrived (#964-C), and a
# symlinked opt-in entry cannot smuggle a denied target (deny
# runs FIRST, independent of what the opt-in matched). (iii)
# CONTENT DENY on the opt-in path only, defense-in-depth NOT the
# safety mechanism: digest.sh's six scrub shapes + a
# named-assignment probe (key/token/secret/passw/hmac/credential/
# bearer = unbroken value >=16 chars); probe ERROR fails TOWARD
# refusal (an error exit is not a negative result); false
# positives are acceptable — a false match only withholds byte
# capture, never tracking. (iv) size cap WAKE_PREIMAGE_MAX_BYTES
# (default 1 MiB). Deny ALWAYS wins over the opt-in. A refused
# file still gets its hash/size/mtime row (captured:false +
# refused reason) so change TIME survives even when content must
# not. (3) FIRST-CLASS CAUSE LINE
# (acceptance c): on a change (or deletion — ABSENT is a state,
# not an error), one class=actionable entry is enqueued via the
# store allocator with locators {kind:preimage, path (§2.1 hard
# locator — never quarantined), observed_hash, prev_hash,
# preimage:true, reason:preimage-definition-changed}. Both
# detector.sh cmd_poll_once and reconcile.sh cmd_reconcile run
# the check as a PRE-step, so the cause line lands at a LOWER
# observed_seq than the N per-source deltas/enumerations it
# explains — "preimage definition changed" reads first, not N
# UNACCOUNTED lines. (4) FAIL-LOUD discipline (D2/#955 class):
# an unresolvable adapter, unreadable watch-list, corrupt ledger
# (REFUSES to compare or re-baseline over corrupt history), failed
# object/ledger write, or failed enqueue is a loud non-zero —
# never read as "no change"; in both integrations the pass exits
# non-zero but source observation still proceeds (no starvation).
# An ABSENT/EMPTY ledger with objects/ NON-empty is a loud
# non-zero REFUSAL to re-baseline (#964-B11: objects with no
# ledger cannot be a first install — history was deleted; the
# first-install path must not silently absorb it). First-seen on
# a genuinely clean state dir is a SILENT baseline (detector
# first-poll idiom).
# The installer is UNCHANGED — Gate A auto-enumerates the new
# file from the filesystem; the recording site is the runtime
# tick, which is where the env-derived preimage set exists.
# store.sh/digest.sh/beacon.sh UNCHANGED; watch-list schema
# UNTOUCHED ([1,1]). Changed: preimage.sh (new), detector.sh,
# reconcile.sh (+ test-wake-preimage.sh P1-P17; P13-P17 are the
# #964 re-verdict regression needles: symlinked store, live
# secret value, polarity, ledger deletion, allowlist-symlink).
# 0.7.1 #952 quarantine-audit clean-sweep message named only ONE of the
# two unprovable residual classes ("rows without surviving
# dead-letter evidence"), so an operator reading the OK concluded
# NO evidence exists when evidence can exist and be UNUSABLE: a
# surviving dead-letter row whose locator extracts an empty
# observed_hash (live specimen: mos-dt seq 13,
# bench/malformed-locator-test, "deliberately non-conformant")
# can never satisfy the four-field conviction match, because
# _record_last_consumed only writes rows with a NON-empty hash.
# WORDING-ONLY fix (measurement defect, #951 review finding 1):
# THREE surfaces — the clean-sweep message, the PROVABILITY
# BOUND comment, and (second commit, author-side self-catch)
# the usage() help text — now name both classes; the
# conviction predicate is UNCHANGED.
# Test T17 drives the real writer flow and plants the verbatim
# live specimen (nested .locators.*, NO observed_hash key) —
# never a hand-built flat dead-letter row, which would make the
# audit's correct non-conviction look exactly like the defect
# under hunt (the #951 review's false-defect near-miss).
component=wake
version=0.7.1
version=0.6.15
# Watch-list schema this component consumes, and the INCLUSIVE range of
# schema_version values it supports. A wake-watch-list.json whose schema_version
@@ -537,19 +446,6 @@ schema_max=1
# seed) instead of a bare source error, and LINKS the unit into
# the user systemd search path + post-install-validates it
# resolves (#913). (W7, #913)
# preimage.sh A11 — operator-side PREIMAGE-DEFINITION provenance: derives the
# preimage set from the runtime env (resolved adapter file,
# watch-list, WAKE_PREIMAGE_EXTRA), appends
# {ts,path,sha256,size,mtime,prev} rows to an append-only
# ledger, captures bytes content-addressed under
# preimage/objects/<sha256>, and enqueues a FIRST-CLASS
# "preimage-definition-changed" actionable (path = §2.1 hard
# locator) BEFORE the deltas it explains (detector +
# reconcile pre-step). Credential HARD GATE: capture is
# REFUSED (hash/mtime still recorded) for credential-store
# paths, secret-shaped content, and oversized files —
# refusal, never redaction. Fail-loud on any infra failure;
# a corrupt ledger refuses re-baseline. (#958)
# Companion (framework subtree, not under tools/wake/): systemd/user/mosaic-wake.service
# — the long-lived detector daemon unit (per-class SLO lives in
# the daemon, NOT a systemd interval). (W7)
@@ -1,571 +0,0 @@
#!/usr/bin/env bash
# preimage.sh — A11 of the wake canon (#958): durable PROVENANCE for the
# OPERATOR-SIDE preimage definition.
#
# THE GAP THIS CLOSES (#958): every observed_hash in the lane store is
# sha256(adapter stdout) — the operator's source adapter (plus the watch-list
# and any operator-declared preimage files) IS the preimage definition for
# every hash the pipeline ever records. Those files live OUTSIDE the versioned
# wake component, so a byte change to them was attributable only through agent
# transcripts: provenance thinner than any hash it feeds. A changed preimage
# also re-baselines EVERY source at once, which previously surfaced only as N
# per-source deltas / UNACCOUNTED lines with no first-class cause.
#
# WHAT THIS TOOL DOES:
# - Derives the PREIMAGE SET generically (no operator paths baked in):
# * the resolved file behind WAKE_DETECTOR_SOURCE_CMD (first word),
# * the WAKE_WATCH_LIST file,
# * operator-declared extras via WAKE_PREIMAGE_EXTRA (colon-separated —
# e.g. sink/feed scripts). Extras are RECORD-ONLY by default (see the
# credential hard gate below); never list key-material files.
# - Records each file's (sha256, size, mtime, ts) as an APPEND-ONLY ledger
# row in <state>/preimage/preimage-ledger.jsonl and stores the full bytes
# CONTENT-ADDRESSED at <state>/preimage/objects/<sha256> — so a change is
# attributable (prior bytes + change time) from durable state alone, with
# no transcript required. Acceptance (a) of #958.
# - On a CHANGE (not first-seen), `check --enqueue` enqueues ONE first-class
# class=actionable entry per changed file via the store's single allocator
# (#908), carrying a §2.1 hard locator (`path`), BEFORE the per-source
# deltas it explains are observed — so the digest shows the cause line,
# not just N re-baselined sources. Acceptance (c).
#
# CREDENTIAL HARD GATE — acceptance (b): the mechanism must be UNABLE to
# capture credential material even by operator error. Layered, every layer
# fail-closed toward NOT capturing bytes (the hash/size/mtime row is still
# written — change-time attribution survives, content capture does not;
# a sha256 discloses nothing about the bytes):
# 1. POLARITY (#964 review, case D): byte capture is DENY-BY-DEFAULT for
# operator extras. WAKE_PREIMAGE_EXTRA paths are RECORD-ONLY (rows +
# first-class change entries, no bytes) unless explicitly listed in
# WAKE_PREIMAGE_CAPTURE — a shape list can only refuse the secrets
# someone already enumerated, so arbitrary operator-pointed files must
# not default to capture. The CORE set (the resolved adapter file, the
# watch-list) is capture-eligible: it IS the preimage definition this
# tool exists to snapshot, and the deny layers below still apply to it.
# 2. PATH deny: the mosaic credential store locations
# (<mosaic-home>/credentials.json, <mosaic-home>/tools/_lib/
# credentials.json, anything under <mosaic-home>/credentials/, and any
# file literally named credentials.json) are refused. Evaluated on BOTH
# the operator-supplied form AND the symlink-resolved form, against BOTH
# the unresolved and resolved forms of the mosaic-home anchor — a deny
# list written only in unresolved paths cannot match a path that was
# resolved before it arrived (#964 review, case C).
# 3. CONTENT deny: a candidate whose bytes match the well-known secret-token
# shapes (same conservative set digest.sh redacts: PAT/Slack/AKIA/JWT/PEM)
# OR a named-assignment secret shape (key/token/secret/passw/hmac/
# credential/bearer = long unbroken value — catches prefix-less
# high-entropy keys, e.g. an HMAC key in an env file) is refused —
# refusal, not redaction: a redacted preimage would be a false witness,
# and secret bytes must never land in the object store. Deliberately
# conservative toward REFUSAL: a false match only withholds byte capture,
# never tracking.
# Plus a size cap (WAKE_PREIMAGE_MAX_BYTES, default 1 MiB) so a stray extra
# pointing at a large binary cannot turn provenance into data hoovering.
# Deny ALWAYS wins over the WAKE_PREIMAGE_CAPTURE opt-in.
#
# FAIL-LOUD DISCIPLINE (the D2/#955 class, both layers): an infrastructure
# failure of THIS tool (unresolvable adapter, unreadable/corrupt ledger,
# failed durable write, failed enqueue) exits NON-ZERO and is never read as
# "no change". A corrupt ledger REFUSES to compare (and to re-baseline):
# silently restarting history would erase the very attribution this exists
# to provide. Likewise (#964 review, B11) an ABSENT/EMPTY ledger while
# objects/ still holds prior captures is DELETED HISTORY, not a first
# install — it refuses loudly instead of re-baselining, because a silent
# restart would absorb the next real change as first-seen.
#
# Operator-agnostic (framework firewall): all state via XDG/env; the deny
# list names only framework-defined credential locations, no operator hosts/
# names/secrets. This tool never prints file CONTENT to any stream.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_wake-common.sh disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh"
STATE_DIR="$(wake_state_dir)"
STORE_SH="$SCRIPT_DIR/store.sh"
PRE_DIR="$STATE_DIR/preimage"
LEDGER="$PRE_DIR/preimage-ledger.jsonl"
OBJECTS="$PRE_DIR/objects"
_need_jq() {
command -v jq >/dev/null 2>&1 || {
echo "preimage.sh: jq is required" >&2
exit 3
}
}
# _hash_stdin — sha256 of stdin, first field only (portable, same fallback
# chain as detector.sh).
_hash_stdin() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then
openssl dgst -sha256 | awk '{print $NF}'
else
echo "preimage.sh: no sha256 tool (sha256sum/shasum/openssl) available" >&2
exit 3
fi
}
usage() {
cat >&2 <<'EOF'
Usage: preimage.sh <command>
Commands:
check [--enqueue] Compare every preimage-set file against the ledger head.
A changed file gets a new ledger row (+ captured bytes
when capture-eligible and not denied — see Environment);
with --enqueue each change (not first-seen) also enqueues
ONE first-class class=actionable store entry (hard
locator: path). First-seen files baseline SILENTLY (row,
no enqueue — same idiom as the detector's first-seen).
Exit 0 whether or not changes were found; non-zero ONLY
on infrastructure failure (never read as "no change").
record Alias of `check` without enqueue (baseline/refresh).
set Print the derived preimage set, one resolved path per
line (diagnostic).
status Print the ledger head row for every tracked path.
Environment:
WAKE_DETECTOR_SOURCE_CMD Adapter command; its resolved file joins the set.
WAKE_WATCH_LIST Watch-list path; joins the set when set.
WAKE_PREIMAGE_EXTRA Colon-separated additional operator preimage files
(e.g. sink/feed scripts). Extras are RECORD-ONLY
by default: changes get a hash/size/mtime row and
a first-class change entry, but their BYTES are
never captured unless the path is also listed in
WAKE_PREIMAGE_CAPTURE. A listed path that does not
exist is tracked as ABSENT (deletion of a preimage
file is a change, not an error).
WAKE_PREIMAGE_CAPTURE Colon-separated allowlist of extras whose bytes
MAY be captured. The credential deny rules ALWAYS
win over this list. NEVER list files that can hold
key material (env files with keys/HMACs, credential
stores): the deny gate is a backstop, not a
license.
WAKE_PREIMAGE_MAX_BYTES Byte-capture cap (default 1048576). Larger files:
hash/size/mtime recorded, bytes refused.
WAKE_STATE_HOME/WAKE_AGENT store namespace (see store.sh).
EOF
}
# --- preimage-set derivation (generic; no operator paths baked in) ----------
# _realpath_or_self PATH — resolved form when resolvable, the input otherwise.
_realpath_or_self() {
local p="$1"
if command -v realpath >/dev/null 2>&1; then
realpath -- "$p" 2>/dev/null || printf '%s' "$p"
else
printf '%s' "$p"
fi
}
# _resolve_cmd_file CMD — resolve the FIRST WORD of an adapter command string
# to a real file; prints `raw<TAB>resolved`. Non-zero (loud) if it cannot be
# resolved: an adapter the detector will invoke but provenance cannot see is
# an infrastructure failure, not a smaller set. BOTH forms are kept: the deny
# gate must see the pre-resolution form too (#964 review, case C).
_resolve_cmd_file() {
local cmd="$1" word path
# shellcheck disable=SC2086
set -- $cmd
word="${1:-}"
[ -n "$word" ] || return 1
if [ -f "$word" ]; then
path="$word"
else
path="$(command -v -- "$word" 2>/dev/null)" || return 1
[ -f "$path" ] || return 1
fi
# realpath so the ledger keys on the actual file, not a symlink alias.
printf '%s\t%s' "$path" "$(_realpath_or_self "$path")"
}
# _preimage_set — print the derived set, one member per line as
# `origin<TAB>raw<TAB>resolved` (origin: core|extra). BOTH path forms travel
# with every member so the deny gate and the capture allowlist are evaluated
# on the SAME candidate in BOTH its forms — resolving before denying is the
# #964 case-C ordering defect. Paths from WAKE_PREIMAGE_EXTRA are printed
# EVEN IF ABSENT (deletion is a tracked state); the adapter and watch-list
# must resolve (loud failure otherwise — see _resolve_cmd_file rationale).
_preimage_set() {
local failed=0
if [ -n "${WAKE_DETECTOR_SOURCE_CMD:-}" ]; then
local af
if af="$(_resolve_cmd_file "$WAKE_DETECTOR_SOURCE_CMD")"; then
printf 'core\t%s\n' "$af"
else
echo "preimage.sh: FAIL LOUD — WAKE_DETECTOR_SOURCE_CMD ('$WAKE_DETECTOR_SOURCE_CMD') does not resolve to a file; the preimage definition cannot be observed (NOT treated as 'no change')." >&2
failed=1
fi
fi
if [ -n "${WAKE_WATCH_LIST:-}" ]; then
if [ -f "$WAKE_WATCH_LIST" ]; then
printf 'core\t%s\t%s\n' "$WAKE_WATCH_LIST" "$(_realpath_or_self "$WAKE_WATCH_LIST")"
else
echo "preimage.sh: FAIL LOUD — WAKE_WATCH_LIST ('$WAKE_WATCH_LIST') is not a file; the preimage definition cannot be observed." >&2
failed=1
fi
fi
if [ -n "${WAKE_PREIMAGE_EXTRA:-}" ]; then
local IFS=':' p
for p in $WAKE_PREIMAGE_EXTRA; do
[ -n "$p" ] || continue
# Extras are tracked even when absent (ABSENT is a state). Resolve the
# realpath only when the file exists; otherwise track the literal path.
if [ -e "$p" ]; then
printf 'extra\t%s\t%s\n' "$p" "$(_realpath_or_self "$p")"
else
printf 'extra\t%s\t%s\n' "$p" "$p"
fi
done
fi
return "$failed"
}
# --- credential hard gate (acceptance (b)) ----------------------------------
# _deny_path RAW RESOLVED — 0 (deny) iff EITHER form of the candidate names a
# known credential-store location. Framework-defined locations only
# (firewall): the mosaic credential store, the tools/_lib credential file the
# framework-manifest itself carves out of tools/**, the credentials/ operator
# subtree, and any file literally named credentials.json.
#
# Evaluated on BOTH the operator-supplied (raw) form and the symlink-resolved
# form, against BOTH the unresolved and resolved forms of the mosaic-home
# anchor: a deny list written only in unresolved paths cannot match a path
# that was resolved before it arrived (#964 review, case C — a symlink whose
# target was renamed slipped every path rule because the candidate had
# already been realpath'd but the anchors never were).
_deny_path() {
local raw="$1" resolved="$2"
local home="${MOSAIC_HOME:-$HOME/.config/mosaic}" rhome p a
rhome="$(_realpath_or_self "$home")"
for p in "$raw" "$resolved"; do
[ -n "$p" ] || continue
for a in "$home" "$rhome"; do
case "$p" in
"$a/credentials.json") return 0 ;;
"$a/tools/_lib/credentials.json") return 0 ;;
"$a/credentials/"*) return 0 ;;
esac
done
case "$(basename -- "$p")" in
credentials.json) return 0 ;;
esac
done
return 1
}
# _deny_content FILE — 0 (deny) iff FILE's bytes look secret-shaped.
# Two probes: (a) the well-known vendor-token shapes digest.sh redacts
# (PAT / Slack / AKIA / JWT / PEM — conservative: a 40-hex git sha never
# matches); (b) a named-assignment shape (key/token/secret/passw/hmac/
# credential/bearer = long unbroken value) that catches prefix-less
# high-entropy keys, e.g. an HMAC key in an env file (#964 review, case D).
#
# (b) is defense-in-depth for the OPT-IN path only, NOT the thing that keeps
# case D safe — polarity does that (extras are record-only unless allowlisted;
# a shape list can only refuse the secrets someone already enumerated). It is
# deliberately conservative toward REFUSAL: a false match (a checksum in a
# config, a path that happens to be long and unbroken) only withholds byte
# capture — the hash/size/mtime row and change entry still land. A variable
# REFERENCE (token="$GITEA_TOKEN") never matches: `$` is not in the value
# class — only literal secret values do.
#
# FAILS TOWARD REFUSAL: if a probe itself errors (unreadable file, grep
# failure — rc >= 2), the answer is DENY, not capture. An error exit is not a
# negative result.
_deny_content() {
local rc
LC_ALL=C grep -Eq \
-e 'gh[pousr]_[A-Za-z0-9]{16,}' \
-e 'github_pat_[A-Za-z0-9_]{16,}' \
-e 'xox[baprs]-[A-Za-z0-9-]{10,}' \
-e 'AKIA[0-9A-Z]{16}' \
-e 'eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}' \
-e '-----BEGIN[A-Z ]*PRIVATE KEY-----' \
-- "$1" 2>/dev/null
rc=$?
[ "$rc" -eq 1 ] || return 0
LC_ALL=C grep -Eiq \
-e "[A-Za-z0-9_]*(key|token|secret|passw|hmac|credential|bearer)[A-Za-z0-9_]*[[:space:]]*[=:][[:space:]]*[\"']?[A-Za-z0-9+/=_-]{16,}" \
-- "$1" 2>/dev/null
rc=$?
[ "$rc" -eq 1 ] || return 0
return 1
}
# _capture_allowed ORIGIN RAW RESOLVED — 0 iff byte capture may even be
# ATTEMPTED for this member (the deny gate still runs after, and wins).
# POLARITY (#964 review, case D): core members (the adapter file, the
# watch-list) are capture-eligible — they ARE the preimage definition this
# tool exists to snapshot (acceptance (a)). Extras are RECORD-ONLY unless
# listed in WAKE_PREIMAGE_CAPTURE.
#
# The allowlist is matched with the SAME both-forms discipline as the deny
# list (entry raw/resolved vs candidate raw/resolved): two lists keyed on
# different strings would let a symlink alias slip between them. "Deny wins
# over opt-in" is a precedence rule, not a guarantee both lists see the same
# path — the guarantee comes from _check_one running _deny_path on both forms
# FIRST, independent of anything matched here.
_capture_allowed() {
local origin="$1" raw="$2" resolved="$3"
[ "$origin" = "core" ] && return 0
[ -n "${WAKE_PREIMAGE_CAPTURE:-}" ] || return 1
local IFS=':' e er
for e in $WAKE_PREIMAGE_CAPTURE; do
[ -n "$e" ] || continue
er="$e"
[ -e "$e" ] && er="$(_realpath_or_self "$e")"
if [ "$e" = "$raw" ] || [ "$e" = "$resolved" ] ||
[ "$er" = "$raw" ] || [ "$er" = "$resolved" ]; then
return 0
fi
done
return 1
}
# --- ledger primitives ------------------------------------------------------
# _ledger_head PATH — print the LAST ledger row for PATH (empty if none).
# Non-zero (loud) if the ledger exists but is unparseable: a corrupt ledger
# must REFUSE to compare, never silently re-baseline (that would erase the
# attribution history this tool exists to keep).
_ledger_head() {
local path="$1"
[ -f "$LEDGER" ] || return 0
if [ -s "$LEDGER" ] && ! jq -cn 'inputs' <"$LEDGER" >/dev/null 2>&1; then
echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is unparseable; REFUSING to compare or re-baseline over corrupt history. Investigate/restore the ledger." >&2
return 1
fi
jq -c --arg p "$path" 'select(.path == $p)' "$LEDGER" 2>/dev/null | tail -n 1
}
# _ledger_append ROW_JSON — append one row atomically (read + append + rename;
# rows are small and the writer is serialized by the preimage lock).
_ledger_append() {
local row="$1"
{
[ -f "$LEDGER" ] && cat "$LEDGER"
printf '%s\n' "$row"
} | _atomic_write "$LEDGER"
}
# --- the check itself -------------------------------------------------------
# _check_one ORIGIN RAW PATH ENQUEUE — compare PATH (the resolved form; the
# ledger keys on it) to its ledger head; on change record (+bytes only if
# capture-eligible and not denied) and optionally enqueue. Prints nothing on
# no-change. Returns: 0 ok (changed or not), 1 infrastructure failure.
_check_one() {
local origin="$1" raw="$2" path="$3" enqueue="$4"
local cur_sha size mtime denied="" refused=""
if [ -f "$path" ]; then
cur_sha="$(_hash_stdin <"$path")" || return 1
[ -n "$cur_sha" ] || {
echo "preimage.sh: FAIL LOUD — could not hash $path" >&2
return 1
}
size="$(wc -c <"$path" | tr -d '[:space:]')"
# Portable mtime (GNU stat -c / BSD stat -f).
mtime="$(stat -c %Y -- "$path" 2>/dev/null || stat -f %m -- "$path" 2>/dev/null || echo 0)"
local cap="${WAKE_PREIMAGE_MAX_BYTES:-1048576}"
case "$cap" in '' | *[!0-9]*) cap=1048576 ;; esac
# Gate order matters: path deny FIRST, on BOTH forms, INDEPENDENT of the
# capture allowlist — so an allowlisted symlink whose target is denied
# refuses no matter what string the allowlist matched (a known credential
# store is refused without a content probe; the hash read above is
# deliberate: a sha256 discloses nothing about the bytes). Then polarity
# (record-only extras never reach the content probe — case D must be safe
# WITHOUT the shape list), then size cap (never content-grep an oversized
# file), content shape last.
if _deny_path "$raw" "$path"; then
denied="credential-store path (deny list)"
elif ! _capture_allowed "$origin" "$raw" "$path"; then
denied="extra is record-only by default (byte capture requires WAKE_PREIMAGE_CAPTURE; deny rules still win)"
elif [ "$size" -gt "$cap" ]; then
denied="exceeds WAKE_PREIMAGE_MAX_BYTES ($size > $cap)"
elif _deny_content "$path"; then
denied="secret-shaped content"
fi
else
# ABSENT is a state (deletion of a preimage file is a change to record).
cur_sha="ABSENT"
size=0
mtime=0
fi
local head prev_sha=""
head="$(_ledger_head "$path")" || return 1
[ -n "$head" ] && prev_sha="$(jq -r '.sha256 // ""' <<<"$head")"
if [ "$cur_sha" = "$prev_sha" ]; then
return 0 # unchanged — no row, no enqueue (idempotent)
fi
# --- capture bytes (content-addressed) unless the hard gate refuses -------
local captured=true
if [ "$cur_sha" != "ABSENT" ]; then
if [ -n "$denied" ]; then
captured=false
refused="$denied"
echo "preimage.sh: byte capture WITHHELD for $path ($denied) — hash/size/mtime recorded, content NOT stored (#958)." >&2
else
mkdir -p "$OBJECTS" || return 1
if [ ! -f "$OBJECTS/$cur_sha" ]; then
if ! _atomic_write "$OBJECTS/$cur_sha" <"$path"; then
echo "preimage.sh: FAIL LOUD — durable object write failed for $path ($OBJECTS/$cur_sha); NO ledger row recorded (a row whose bytes were never durably kept would be a false witness)." >&2
return 1
fi
fi
fi
fi
local ts row
ts="$(date +%s)"
row="$(jq -cn \
--arg path "$path" \
--arg sha "$cur_sha" \
--arg prev "$prev_sha" \
--argjson size "$size" \
--argjson mtime "$mtime" \
--argjson ts "$ts" \
--argjson captured "$captured" \
--arg refused "$refused" \
'{ts:$ts, path:$path, sha256:$sha, size:$size, mtime:$mtime, captured:$captured}
+ (if $prev != "" then {prev:$prev} else {} end)
+ (if $refused != "" then {refused:$refused} else {} end)')" || return 1
if ! _ledger_append "$row"; then
echo "preimage.sh: FAIL LOUD — durable ledger append failed ($LEDGER)" >&2
return 1
fi
if [ -z "$head" ]; then
# First-seen: baseline SILENTLY (row only, no enqueue) — the same idiom as
# the detector's first-seen source baseline.
return 0
fi
echo "preimage.sh: preimage definition CHANGED — $path ($prev_sha -> $cur_sha); prior bytes: $OBJECTS/$prev_sha" >&2
if [ "$enqueue" = "1" ]; then
# First-class cause line (acceptance (c)): ONE class=actionable entry per
# changed preimage file, allocated by the store's single allocator (#908).
# `path` is a §2.1 hard locator, so the digest renders it — never
# quarantined. Callers run this BEFORE polling sources, so this seq is
# LOWER than the per-source deltas the change explains.
local locators
locators="$(jq -cn \
--arg path "$path" \
--arg sha "$cur_sha" \
--arg prev "$prev_sha" \
'{kind:"preimage", id:$path, path:$path, observed_hash:$sha, prev_hash:$prev,
preimage:true, reason:"preimage-definition-changed"}')"
if ! "$STORE_SH" enqueue --class actionable --locators "$locators" --emit-ts "$(date +%s)" >/dev/null; then
echo "preimage.sh: FAIL LOUD — store enqueue of the preimage-change entry FAILED for $path (the change IS recorded in the ledger; the first-class wake line is NOT — treat as infrastructure failure)." >&2
return 1
fi
fi
return 0
}
cmd_check() {
local enqueue=0
while [ $# -gt 0 ]; do
case "$1" in
--enqueue)
enqueue=1
shift
;;
*)
echo "preimage.sh check: unknown option '$1'" >&2
exit 2
;;
esac
done
_need_jq
local set_list
if ! set_list="$(_preimage_set)"; then
exit 1
fi
if [ -z "$set_list" ]; then
# Nothing declared (no adapter/watch-list/extras in env) — an empty set is
# a no-op, not an error: standalone invocations outside a wake runtime
# must not fail loud for lacking one.
return 0
fi
mkdir -p "$PRE_DIR" || exit 1
# Serialize concurrent checks (detector tick vs reconcile vs manual) — the
# ledger append is read+rewrite, so two writers must not interleave.
if ! _wake_lock_acquire "$PRE_DIR/preimage.lock"; then
echo "preimage.sh: FAIL LOUD — cannot acquire preimage lock" >&2
exit 1
fi
# #964 review (B11): an ABSENT/EMPTY ledger while objects/ still holds
# prior captures is DELETED HISTORY, not a first install — the asymmetry is
# locally detectable and is the whole detection. Re-baselining here would
# silently absorb the next real change as first-seen (the exact erasure
# this tool exists to prevent). ABSENT is not CORRUPT: it must not take the
# first-install path either.
if [ ! -s "$LEDGER" ] && [ -d "$OBJECTS" ] && [ -n "$(ls -A -- "$OBJECTS" 2>/dev/null)" ]; then
echo "preimage.sh: FAIL LOUD — preimage ledger $LEDGER is ABSENT/EMPTY but $OBJECTS still holds prior objects: history was deleted; REFUSING to re-baseline over it (a silent restart would absorb the next real change as first-seen). Restore the ledger, or move objects/ aside explicitly after investigation." >&2
_wake_lock_release
exit 1
fi
local failed=0 origin raw resolved
while IFS=$'\t' read -r origin raw resolved; do
[ -n "$resolved" ] || continue
_check_one "$origin" "$raw" "$resolved" "$enqueue" || failed=1
done <<EOF
$set_list
EOF
_wake_lock_release
[ "$failed" -eq 0 ] || exit 1
}
cmd_status() {
_need_jq
[ -f "$LEDGER" ] || {
echo "preimage.sh: no ledger yet ($LEDGER)" >&2
return 0
}
# Head row per path (last wins).
jq -cs 'group_by(.path) | map(last) | .[]' "$LEDGER"
}
cmd_set() {
local s
s="$(_preimage_set)" || exit 1
[ -n "$s" ] && printf '%s\n' "$s" | cut -f3
}
main() {
[ $# -ge 1 ] || {
usage
exit 2
}
local cmd="$1"
shift
case "$cmd" in
check) cmd_check "$@" ;;
record) cmd_check ;;
set) cmd_set "$@" ;;
status) cmd_status "$@" ;;
-h | --help | help) usage ;;
*)
echo "preimage.sh: unknown command '$cmd'" >&2
usage
exit 2
;;
esac
}
main "$@"
@@ -440,17 +440,6 @@ cmd_reconcile() {
: "$allow_enumerate"
local pairs kind id failed=0 unaccounted=0 enumerated=0
# #958 preimage provenance: same pre-step as the detector's poll tick, for
# the path where the preimage changed while the detector was down — without
# it, a changed adapter surfaces here only as N UNACCOUNTED enumerations
# with no first-class cause line. Run BEFORE observing any source so the
# cause entry's observed_seq precedes the enumerations it explains. Loud
# infrastructure failure marks the reconcile failed but does not stop it.
if ! "$SCRIPT_DIR/preimage.sh" check --enqueue; then
echo "reconcile.sh: FAIL LOUD — preimage provenance check failed (see preimage.sh above); reconcile continues but exits non-zero." >&2
failed=1
fi
pairs="$(printf '%s' "$json" | jq -r '[ .watches[].sources[] | "\(.kind)\t\(.id)" ] | unique | .[]')"
if [ -z "$pairs" ]; then
echo "reconcile.sh: watch-list declares no sources under watches[].sources[]" >&2
+8 -19
View File
@@ -110,13 +110,9 @@ Commands:
found); --repair removes exactly those
rows (atomic, loud). The dead-letter
ledger itself is NEVER modified (it is
history). TWO residual classes are
unprovable and never touched (#952):
rows whose dead-letter evidence was
pruned away, AND rows whose surviving
evidence extracts an empty
observed_hash (it can never satisfy
the four-field conviction match).
history). Rows whose dead-letter evidence
was pruned are NOT provable and are
never touched.
cursors Print observed_seq / consumed_seq / depth.
Environment:
@@ -567,17 +563,10 @@ cmd_quarantine_sync() {
# it on (kind, id, observed_seq, observed_hash) AND row.observed_seq <=
# consumed_seq: the per-key max_by merge means the surviving row's provenance IS
# that quarantined entry (a healed row differs in seq/hash and never matches).
# PROVABILITY BOUND — TWO residual classes, both unprovable (#952): (1) a row
# whose dead-letter evidence was pruned/rotated away — no evidence to convict
# on; (2) a row whose dead-letter evidence SURVIVES but extracts an empty
# observed_hash (e.g. a deliberately non-conformant locator: nested
# .locators.* with no observed_hash key) — evidence exists but can never
# satisfy the four-field match, because _record_last_consumed only ever writes
# rows with a NON-empty hash. Neither class is touched — this audit only ever
# removes what the ledger can convict, and the clean-sweep message names BOTH
# classes: "no evidence" and "evidence unusable" are different operator
# conclusions. The dead-letter ledger itself is history and is NEVER modified
# here.
# PROVABILITY BOUND: a row whose dead-letter evidence was pruned/rotated away is
# NOT provable and is never touched — this audit only ever removes what the
# ledger can convict. The dead-letter ledger itself is history and is NEVER
# modified here.
cmd_quarantine_audit() {
local repair=0
while [ $# -gt 0 ]; do
@@ -616,7 +605,7 @@ cmd_quarantine_audit() {
)) | length) > 0)
' "$rec" 2>/dev/null || true)"
if [ -z "$false_rows" ]; then
echo "store.sh quarantine-audit: OK — no provably-false consumed-hash rows. Two residual classes are unprovable and were NOT judged: rows whose dead-letter evidence was pruned/rotated away (no evidence to convict on), and rows whose surviving dead-letter evidence extracts an empty observed_hash (evidence exists but can never satisfy the four-field conviction match)."
echo "store.sh quarantine-audit: OK — no provably-false consumed-hash rows (rows without surviving dead-letter evidence are not provable and were not judged)"
return 0
fi
local n row
@@ -1,534 +0,0 @@
#!/usr/bin/env bash
# test-wake-preimage.sh — RED-FIRST invariant harness for A11 (#958): durable
# provenance for the OPERATOR-SIDE preimage definition (preimage.sh).
#
# Each test asserts ONE #958 invariant and is designed to go RED if it
# regresses:
# P1 first-seen -> SILENT baseline (ledger rows, NO enqueue) (detector idiom)
# P2 change -> attributable from durable state alone: new ledger row with
# prev, PRIOR BYTES retrievable content-addressed, first-class actionable
# enqueued with a §2.1 hard locator (path) (acceptance a+c)
# P3 detector ordering: the preimage cause line is enqueued at a LOWER
# observed_seq than the per-source delta it explains (acceptance c)
# P4 credential-store PATH is refused — bytes NEVER captured, even when the
# operator lists it by error (hash/mtime row still written) (acceptance b)
# P5 secret-SHAPED content is refused — refusal, not redaction (acceptance b)
# P6 deletion of a preimage file is a tracked CHANGE (ABSENT), not an error
# P7 no-change is idempotent (no row growth, no enqueue)
# P8 unresolvable adapter command -> FAIL LOUD, never "no change" (D2/#955 class)
# P9 corrupt ledger -> REFUSE to compare/re-baseline (loud, no append)
# P10 oversized file -> bytes refused, hash still recorded (no data hoovering)
# P11 reconcile pre-step: a preimage change surfaces as a first-class line
# BEFORE the UNACCOUNTED enumerations it explains (acceptance c)
# P12 detector: preimage infra failure is LOUD (pass exits non-zero) but does
# NOT starve source observation
#
# Regression needles from the #964 NOT-CLEAR review (decoy method: plant the
# bytes, grep the WHOLE state dir — the decoy does not know what the code
# believes):
# P13 case C: symlinked/renamed credential store (mosaic-home itself behind
# a symlink; a renamed target outside every path rule) — refused on BOTH
# path forms / by content, bytes grep-absent (acceptance b)
# P14 case D: prefix-less high-entropy key (detector.env class) — safe
# WITHOUT opt-in by POLARITY alone (must hold with the shape list
# deleted), and refused WITH opt-in by the named-assignment shape
# P15 polarity: extras are RECORD-ONLY by default (change row + enqueue, no
# bytes); byte capture requires explicit WAKE_PREIMAGE_CAPTURE opt-in
# P16 case B11: ledger deleted while objects/ survives — LOUD refusal to
# re-baseline; the v2->v3 change is NOT absorbed as first-seen
# P17 allowlist symmetry: an allowlisted symlink whose TARGET is denied
# refuses (deny runs first, on both forms — precedence is not path
# agreement); a symlink to an ALLOWED target still captures (the fix
# must not close case C by breaking every symlinked path)
#
# Uses FAKE/STUB sources only (no live network). Isolated per test.
# shellcheck disable=SC2030,SC2031
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PRE="$SCRIPT_DIR/preimage.sh"
STORE="$SCRIPT_DIR/store.sh"
DET="$SCRIPT_DIR/detector.sh"
RECON="$SCRIPT_DIR/reconcile.sh"
command -v jq >/dev/null 2>&1 || {
echo "SKIP: jq not available" >&2
exit 0
}
TMP_ROOT="$(mktemp -d)"
trap 'rm -rf "$TMP_ROOT"' EXIT
FAILFILE="$TMP_ROOT/failures"
: >"$FAILFILE"
pass=0
fail_msg() {
echo " FAIL: $*" >&2
echo "x" >>"$FAILFILE"
}
ok() { pass=$((pass + 1)); }
fresh_state() {
local d="$TMP_ROOT/$1"
rm -rf "$d"
mkdir -p "$d"
printf '%s' "$d"
}
depth() { "$STORE" cursors | sed -n 's/pending_depth=//p'; }
state_dir() { printf '%s/default' "$WAKE_STATE_HOME"; }
ledger_rows() {
local f
f="$(state_dir)/preimage/preimage-ledger.jsonl"
[ -f "$f" ] && wc -l <"$f" | tr -d '[:space:]' || echo 0
}
# make_stub DIR — a source adapter reading $DIR/<kind>_<id> (same shape as the
# reconcile harness stub).
make_stub() {
local dir="$1"
cat >"$dir/adapter.sh" <<EOF
#!/usr/bin/env bash
set -u
kind="\$1"; id="\$2"; base="$dir/\${kind}_\${id}"; rc=0
[ -f "\$base.rc" ] && rc="\$(cat "\$base.rc")"
[ -f "\$base" ] && cat "\$base"
exit "\$rc"
EOF
chmod +x "$dir/adapter.sh"
}
make_wl() { # make_wl FILE — one repo source r1
cat >"$1" <<'EOF'
{ "schema_version": 1,
"repos": [ { "id": "r1" } ],
"watches": [ { "lane": "L", "sources": [ { "kind": "repo", "id": "r1" } ] } ] }
EOF
}
echo "== P1: first-seen -> SILENT baseline (rows, no enqueue) =="
(
WAKE_STATE_HOME="$(fresh_state p1)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p1fx"; mkdir -p "$fx"
make_stub "$fx"; make_wl "$fx/wl.json"
printf 'extra v1\n' >"$fx/extra.env"
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json" WAKE_PREIMAGE_EXTRA="$fx/extra.env"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P1: baseline check must exit 0 (rc=$rc) [$out]"
[ "$(ledger_rows)" -eq 3 ] || fail_msg "P1: expected 3 baseline rows (adapter, watch-list, extra), got $(ledger_rows)"
[ "$(depth)" = "0" ] || fail_msg "P1: first-seen must NOT enqueue (depth=$(depth))"
) && ok
echo "== P2: adapter change -> prior bytes + first-class hard-locator enqueue =="
(
WAKE_STATE_HOME="$(fresh_state p2)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p2fx"; mkdir -p "$fx"
make_stub "$fx"; make_wl "$fx/wl.json"
# Acceptance (a) names the OPERATOR ADAPTER — a core member. Core members
# capture by default (they ARE the preimage definition); extras do not (P15).
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
unset WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P2: baseline failed"
cp "$fx/adapter.sh" "$fx/adapter.v1"
old_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')"
printf '# adapter v2 CHANGED\n' >>"$fx/adapter.sh"
new_sha="$(sha256sum "$fx/adapter.sh" | awk '{print $1}')"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P2: change check must exit 0 (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(jq -c --arg p "$(realpath "$fx/adapter.sh")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
[ "$(jq -r '.prev // ""' <<<"$row")" = "$old_sha" ] || fail_msg "P2: new row must carry prev=<old sha> [$row]"
[ "$(jq -r '.sha256' <<<"$row")" = "$new_sha" ] || fail_msg "P2: new row sha mismatch [$row]"
# Acceptance (a): the PRIOR BYTES are retrievable from durable state alone.
cmp -s "$sd/preimage/objects/$old_sha" "$fx/adapter.v1" || fail_msg "P2: prior bytes missing or not byte-exact"
cmp -s "$sd/preimage/objects/$new_sha" "$fx/adapter.sh" || fail_msg "P2: current bytes object missing or mismatched"
# Acceptance (c): ONE first-class actionable with a §2.1 hard locator (path).
[ "$(depth)" = "1" ] || fail_msg "P2: exactly one enqueue expected (depth=$(depth))"
ent="$(tail -n1 "$sd/pending.jsonl")"
[ "$(jq -r '.class' <<<"$ent")" = "actionable" ] || fail_msg "P2: entry class must be actionable [$ent]"
[ "$(jq -r '.locators.kind' <<<"$ent")" = "preimage" ] || fail_msg "P2: locator kind must be preimage [$ent]"
[ -n "$(jq -r '.locators.path // ""' <<<"$ent")" ] || fail_msg "P2: locator must carry path (hard locator) [$ent]"
[ "$(jq -r '.locators.observed_hash' <<<"$ent")" = "$new_sha" ] || fail_msg "P2: locator observed_hash mismatch [$ent]"
[ "$(jq -r '.locators.prev_hash' <<<"$ent")" = "$old_sha" ] || fail_msg "P2: locator prev_hash mismatch [$ent]"
) && ok
echo "== P3: detector orders the cause line BEFORE the delta it explains =="
(
WAKE_STATE_HOME="$(fresh_state p3)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p3fx"; mkdir -p "$fx"
make_stub "$fx"; make_wl "$fx/wl.json"
printf 'r1 state v1\n' >"$fx/repo_r1"
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
unset WAKE_PREIMAGE_EXTRA
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: baseline poll failed"
[ "$(depth)" = "0" ] || fail_msg "P3: baseline poll must enqueue nothing (depth=$(depth))"
# Change the ADAPTER (preimage) and the source state in the same window.
printf '# comment: adapter changed\n' >>"$fx/adapter.sh"
printf 'r1 state v2\n' >"$fx/repo_r1"
"$DET" poll-once >/dev/null 2>&1 || fail_msg "P3: second poll failed"
sd="$(state_dir)"
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
src_seq="$(jq -r 'select(.locators.kind == "repo") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
[ -n "$pre_seq" ] || fail_msg "P3: no preimage cause entry enqueued"
[ -n "$src_seq" ] || fail_msg "P3: no source delta entry enqueued"
if [ -n "$pre_seq" ] && [ -n "$src_seq" ]; then
[ "$pre_seq" -lt "$src_seq" ] || fail_msg "P3: cause line must precede the delta (preimage seq=$pre_seq, source seq=$src_seq)"
fi
) && ok
echo "== P4: credential-store PATH refused — bytes never captured (acceptance b) =="
(
WAKE_STATE_HOME="$(fresh_state p4)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p4fx"; mkdir -p "$fx"
export MOSAIC_HOME="$fx/mosaic-home"
mkdir -p "$MOSAIC_HOME"
secret='cred-value-P4-do-not-capture'
printf '{"svc":{"token":"%s"}}\n' "$secret" >"$MOSAIC_HOME/credentials.json"
# Operator error: the credential store listed as a preimage extra — AND
# explicitly opted in to capture. Deny must win over the opt-in (#964: a
# path both denied and allowlisted must refuse).
export WAKE_PREIMAGE_EXTRA="$MOSAIC_HOME/credentials.json"
export WAKE_PREIMAGE_CAPTURE="$MOSAIC_HOME/credentials.json"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P4: refusal is not an infra failure (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P4: row must record captured=false [$row]"
jq -r '.refused // ""' <<<"$row" | grep -qi 'path' || fail_msg "P4: refusal must name the path deny [$row]"
# THE invariant: the secret bytes exist NOWHERE under the wake state dir.
if grep -rq "$secret" "$sd" 2>/dev/null; then
fail_msg "P4: credential bytes leaked into the wake state dir"
fi
) && ok
echo "== P5: secret-SHAPED content refused (refusal, not redaction) =="
(
WAKE_STATE_HOME="$(fresh_state p5)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p5fx"; mkdir -p "$fx"
tok='ghp_abcdefghijklmnop0123456789ABCDEF'
printf 'export MY_TOKEN=%s\n' "$tok" >"$fx/leaky.env"
# Opted in, so the CONTENT gate (not record-only polarity) is what refuses.
export WAKE_PREIMAGE_EXTRA="$fx/leaky.env" WAKE_PREIMAGE_CAPTURE="$fx/leaky.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P5: refusal is not an infra failure (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P5: row must record captured=false [$row]"
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P5: refusal must name secret-shaped content [$row]"
if grep -rq "$tok" "$sd" 2>/dev/null; then
fail_msg "P5: secret-shaped bytes leaked into the wake state dir"
fi
) && ok
echo "== P6: deletion is a tracked CHANGE (ABSENT), not an error =="
(
WAKE_STATE_HOME="$(fresh_state p6)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p6fx"; mkdir -p "$fx"
printf 'to be deleted\n' >"$fx/gone.env"
export WAKE_PREIMAGE_EXTRA="$fx/gone.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P6: baseline failed"
rm -f "$fx/gone.env"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P6: deletion check must exit 0 (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.sha256' <<<"$row")" = "ABSENT" ] || fail_msg "P6: deletion must record sha256=ABSENT [$row]"
[ "$(depth)" = "1" ] || fail_msg "P6: deletion is a change and must enqueue (depth=$(depth))"
) && ok
echo "== P7: no-change is idempotent =="
(
WAKE_STATE_HOME="$(fresh_state p7)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p7fx"; mkdir -p "$fx"
printf 'stable\n' >"$fx/stable.env"
export WAKE_PREIMAGE_EXTRA="$fx/stable.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: baseline failed"
r1="$(ledger_rows)"
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P7: second check failed"
[ "$(ledger_rows)" = "$r1" ] || fail_msg "P7: no-change must not append rows ($r1 -> $(ledger_rows))"
[ "$(depth)" = "0" ] || fail_msg "P7: no-change must not enqueue (depth=$(depth))"
) && ok
echo "== P8: unresolvable adapter -> FAIL LOUD, never 'no change' (D2 class) =="
(
WAKE_STATE_HOME="$(fresh_state p8)"
export WAKE_STATE_HOME
unset WAKE_AGENT
export WAKE_DETECTOR_SOURCE_CMD="$TMP_ROOT/does-not-exist-adapter"
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA MOSAIC_HOME
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "P8: an unobservable preimage definition must FAIL LOUD (rc=0)"
echo "$out" | grep -qi 'does not resolve' || fail_msg "P8: the failure must name the unresolvable adapter [$out]"
) && ok
echo "== P9: corrupt ledger -> REFUSE to compare/re-baseline =="
(
WAKE_STATE_HOME="$(fresh_state p9)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p9fx"; mkdir -p "$fx"
printf 'v1\n' >"$fx/f.env"
export WAKE_PREIMAGE_EXTRA="$fx/f.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P9: baseline failed"
sd="$(state_dir)"
printf 'NOT-JSON-GARBAGE{{{\n' >>"$sd/preimage/preimage-ledger.jsonl"
r1="$(wc -l <"$sd/preimage/preimage-ledger.jsonl")"
printf 'v2 changed\n' >"$fx/f.env"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "P9: a corrupt ledger must FAIL LOUD (rc=0)"
echo "$out" | grep -qi 'unparseable' || fail_msg "P9: the failure must name the corrupt ledger [$out]"
[ "$(wc -l <"$sd/preimage/preimage-ledger.jsonl")" = "$r1" ] || fail_msg "P9: nothing may be appended over corrupt history"
) && ok
echo "== P10: oversized file -> bytes refused, hash still recorded =="
(
WAKE_STATE_HOME="$(fresh_state p10)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p10fx"; mkdir -p "$fx"
head -c 200 /dev/zero | tr '\0' 'A' >"$fx/big.bin"
# Opted in, so the SIZE gate (not record-only polarity) is what refuses.
export WAKE_PREIMAGE_EXTRA="$fx/big.bin" WAKE_PREIMAGE_MAX_BYTES=64
export WAKE_PREIMAGE_CAPTURE="$fx/big.bin"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P10: size refusal is not an infra failure (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P10: row must record captured=false [$row]"
jq -r '.refused // ""' <<<"$row" | grep -qi 'MAX_BYTES' || fail_msg "P10: refusal must name the size cap [$row]"
sha="$(jq -r '.sha256' <<<"$row")"
[ "$sha" = "$(sha256sum "$fx/big.bin" | awk '{print $1}')" ] || fail_msg "P10: hash must still be recorded [$row]"
[ ! -f "$sd/preimage/objects/$sha" ] || fail_msg "P10: oversized bytes must NOT be stored"
) && ok
echo "== P11: reconcile surfaces the cause line before its enumerations =="
(
WAKE_STATE_HOME="$(fresh_state p11)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p11fx"; mkdir -p "$fx"
make_stub "$fx"; make_wl "$fx/wl.json"
printf 'r1 state\n' >"$fx/repo_r1"
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME
# Baseline the preimage set, then change the adapter while "the detector is
# down" — the reconcile path must still produce the first-class cause line.
"$PRE" check >/dev/null 2>&1 || fail_msg "P11: preimage baseline failed"
printf '# adapter changed while detector down\n' >>"$fx/adapter.sh"
"$RECON" reconcile >/dev/null 2>&1 # rc 1 expected (unaccounted enumerated)
sd="$(state_dir)"
pre_seq="$(jq -r 'select(.locators.kind == "preimage") | .observed_seq' "$sd/pending.jsonl" | head -n1)"
enum_seq="$(jq -r 'select(.locators.reconciled == true) | .observed_seq' "$sd/pending.jsonl" | head -n1)"
[ -n "$pre_seq" ] || fail_msg "P11: reconcile must enqueue the preimage cause line"
[ -n "$enum_seq" ] || fail_msg "P11: reconcile must still enumerate the unaccounted source"
if [ -n "$pre_seq" ] && [ -n "$enum_seq" ]; then
[ "$pre_seq" -lt "$enum_seq" ] || fail_msg "P11: cause line must precede the enumeration (preimage seq=$pre_seq, enum seq=$enum_seq)"
fi
) && ok
echo "== P12: detector — preimage infra failure is LOUD but does not starve observation =="
(
WAKE_STATE_HOME="$(fresh_state p12)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p12fx"; mkdir -p "$fx"
make_stub "$fx"; make_wl "$fx/wl.json"
printf 'r1 state\n' >"$fx/repo_r1"
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh" WAKE_WATCH_LIST="$fx/wl.json"
unset WAKE_PREIMAGE_EXTRA MOSAIC_HOME
# Corrupt the ledger BEFORE the first poll: the preimage check fails loud,
# but the poll must still observe + baseline the source.
sd="$(state_dir)"
mkdir -p "$sd/preimage"
printf 'NOT-JSON-GARBAGE{{{\n' >"$sd/preimage/preimage-ledger.jsonl"
out="$("$DET" poll-once 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "P12: the pass must exit non-zero on preimage infra failure"
echo "$out" | grep -qi 'preimage' || fail_msg "P12: the failure must name the preimage check [$out]"
n="$(find "$sd/detector" -name 'watch-*.hash' 2>/dev/null | wc -l | tr -d '[:space:]')"
[ "$n" -ge 1 ] || fail_msg "P12: source observation must still proceed (no watch hash baselined)"
) && ok
echo "== P13: symlinked/renamed credential store refused on BOTH path forms (#964 C) =="
(
WAKE_STATE_HOME="$(fresh_state p13)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p13fx"; mkdir -p "$fx"
# The mosaic home is ITSELF behind a symlink: a deny list written in
# unresolved paths cannot match a candidate that was realpath'd before the
# deny saw it — unless the anchors are resolved too.
mkdir -p "$fx/real-store/credentials"
ln -s "$fx/real-store" "$fx/mh"
export MOSAIC_HOME="$fx/mh"
s1='decoy-P13a-renamed-target-under-store'
printf 'x_token=%s\n' "$s1" >"$fx/mh/credentials/brain-creds.json"
# And a renamed target OUTSIDE every known store location, reached via a
# benign-looking symlink: no path rule can name it — the content gate must
# hold on the resolved file.
s2='0123456789abcdef0123456789abcdefP13b'
printf 'service_hmac_key=%s\n' "$s2" >"$fx/renamed-anywhere.cfg"
ln -s "$fx/renamed-anywhere.cfg" "$fx/link-b.env"
export WAKE_PREIMAGE_EXTRA="$fx/mh/credentials/brain-creds.json:$fx/link-b.env"
# Explicit opt-in for BOTH: deny must win over the allowlist.
export WAKE_PREIMAGE_CAPTURE="$fx/mh/credentials/brain-creds.json:$fx/link-b.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P13: refusal is not an infra failure (rc=$rc) [$out]"
sd="$(state_dir)"
n="$(jq -r 'select(.captured == false) | .path' "$sd/preimage/preimage-ledger.jsonl" | wc -l | tr -d '[:space:]')"
[ "$n" = "2" ] || fail_msg "P13: both decoys must record captured=false (got $n)"
# THE invariant (decoy method): the planted bytes exist NOWHERE in state.
if grep -rq "$s1" "$sd" 2>/dev/null; then
fail_msg "P13: renamed-target store bytes leaked into the wake state dir"
fi
if grep -rq "$s2" "$sd" 2>/dev/null; then
fail_msg "P13: prefix-less key bytes leaked into the wake state dir"
fi
) && ok
echo "== P14: detector.env-class key — safe WITHOUT opt-in by polarity, refused WITH opt-in by shape (#964 D) =="
(
WAKE_STATE_HOME="$(fresh_state p14)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p14fx"; mkdir -p "$fx"
hm='9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c'
printf 'WAKE_HMAC_KEY=%s\n' "$hm" >"$fx/detector.env"
export WAKE_PREIMAGE_EXTRA="$fx/detector.env"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE
# (a) NO opt-in: polarity alone keeps the bytes out. This leg must hold even
# with the content shape list deleted — the shape is defense-in-depth for
# the opt-in path, never the thing that makes case D read safe.
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P14a: record-only tracking must succeed (rc=$rc) [$out]"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14a: extra must be record-only without opt-in [$row]"
if grep -rq "$hm" "$sd" 2>/dev/null; then
fail_msg "P14a: key bytes leaked without any opt-in"
fi
# (b) The operator opts the env file in (the exact error the old usage text
# invited): the named-assignment shape must still refuse the bytes.
export WAKE_PREIMAGE_CAPTURE="$fx/detector.env"
printf 'WAKE_HMAC_KEY=%s\nrotated=1\n' "$hm" >"$fx/detector.env"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P14b: refusal is not an infra failure (rc=$rc) [$out]"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P14b: opted-in key material must still refuse [$row]"
jq -r '.refused // ""' <<<"$row" | grep -qi 'secret' || fail_msg "P14b: refusal must name secret-shaped content [$row]"
if grep -rq "$hm" "$sd" 2>/dev/null; then
fail_msg "P14b: key bytes leaked despite refusal"
fi
) && ok
echo "== P15: extras are RECORD-ONLY by default; capture requires explicit opt-in =="
(
WAKE_STATE_HOME="$(fresh_state p15)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p15fx"; mkdir -p "$fx"
printf 'plain v1\n' >"$fx/notes.cfg"
export WAKE_PREIMAGE_EXTRA="$fx/notes.cfg"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST MOSAIC_HOME WAKE_PREIMAGE_CAPTURE
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: baseline failed"
printf 'plain v2\n' >"$fx/notes.cfg"
sha2="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')"
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: change check failed"
sd="$(state_dir)"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "false" ] || fail_msg "P15: default must be record-only [$row]"
[ ! -f "$sd/preimage/objects/$sha2" ] || fail_msg "P15: bytes captured without opt-in"
[ "$(depth)" = "1" ] || fail_msg "P15: record-only must still track the change (depth=$(depth))"
# Opt in -> the next change captures.
export WAKE_PREIMAGE_CAPTURE="$fx/notes.cfg"
printf 'plain v3\n' >"$fx/notes.cfg"
sha3="$(sha256sum "$fx/notes.cfg" | awk '{print $1}')"
"$PRE" check --enqueue >/dev/null 2>&1 || fail_msg "P15: opted-in change check failed"
row="$(tail -n1 "$sd/preimage/preimage-ledger.jsonl")"
[ "$(jq -r '.captured' <<<"$row")" = "true" ] || fail_msg "P15: opt-in must capture [$row]"
cmp -s "$sd/preimage/objects/$sha3" "$fx/notes.cfg" || fail_msg "P15: opted-in bytes missing or mismatched"
) && ok
echo "== P16: deleted ledger over surviving objects/ — REFUSE to re-baseline (#964 B11) =="
(
WAKE_STATE_HOME="$(fresh_state p16)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p16fx"; mkdir -p "$fx"
make_stub "$fx"
export WAKE_DETECTOR_SOURCE_CMD="$fx/adapter.sh"
unset WAKE_WATCH_LIST WAKE_PREIMAGE_EXTRA WAKE_PREIMAGE_CAPTURE MOSAIC_HOME
"$PRE" check >/dev/null 2>&1 || fail_msg "P16: v1 baseline failed"
printf '# v2\n' >>"$fx/adapter.sh"
"$PRE" check >/dev/null 2>&1 || fail_msg "P16: v2 change failed"
sd="$(state_dir)"
nobj="$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')"
# Delete the ledger; objects/ survives. ABSENT is not CORRUPT — and it is
# not a first install either: that asymmetry is locally detectable.
rm -f "$sd/preimage/preimage-ledger.jsonl"
printf '# v3\n' >>"$fx/adapter.sh"
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail_msg "P16: absent ledger over prior objects must FAIL LOUD (rc=0) [$out]"
echo "$out" | grep -qi 'REFUSING to re-baseline' || fail_msg "P16: the failure must name the refusal [$out]"
[ ! -e "$sd/preimage/preimage-ledger.jsonl" ] || fail_msg "P16: the ledger must NOT be silently recreated over deleted history"
[ "$(ls "$sd/preimage/objects" | wc -l | tr -d '[:space:]')" = "$nobj" ] || fail_msg "P16: prior objects must remain untouched"
[ "$(depth)" = "0" ] || fail_msg "P16: the v2->v3 change must NOT be absorbed or enqueued from an unverifiable baseline (depth=$(depth))"
) && ok
echo "== P17: allowlist symmetry — symlink to a DENIED target refuses; symlink to an allowed target captures =="
(
WAKE_STATE_HOME="$(fresh_state p17)"
export WAKE_STATE_HOME
unset WAKE_AGENT
fx="$TMP_ROOT/p17fx"; mkdir -p "$fx"
export MOSAIC_HOME="$fx/mh"
mkdir -p "$MOSAIC_HOME"
s='decoy-P17-cred-behind-benign-alias'
printf '{"svc":{"token":"%s"}}\n' "$s" >"$MOSAIC_HOME/credentials.json"
# A benign-looking alias whose TARGET is denied: the allowlist matches the
# alias string, but deny runs FIRST and on BOTH forms — "deny wins over
# opt-in" is a precedence rule, path agreement is what makes it hold.
ln -s "$MOSAIC_HOME/credentials.json" "$fx/settings.json"
# And a symlink to an ALLOWED target: the fix must not close case C by
# breaking every symlinked path outright.
printf 'benign payload v1\n' >"$fx/target.cfg"
ln -s "$fx/target.cfg" "$fx/alias.cfg"
export WAKE_PREIMAGE_EXTRA="$fx/settings.json:$fx/alias.cfg"
export WAKE_PREIMAGE_CAPTURE="$fx/settings.json:$fx/alias.cfg"
unset WAKE_DETECTOR_SOURCE_CMD WAKE_WATCH_LIST
out="$("$PRE" check --enqueue 2>&1)"; rc=$?
[ "$rc" -eq 0 ] || fail_msg "P17: check must exit 0 (rc=$rc) [$out]"
sd="$(state_dir)"
crow="$(jq -c --arg p "$(realpath "$fx/settings.json")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
[ "$(jq -r '.captured' <<<"$crow")" = "false" ] || fail_msg "P17: allowlisted symlink to a denied target must refuse [$crow]"
if grep -rq "$s" "$sd" 2>/dev/null; then
fail_msg "P17: credential bytes leaked via an allowlisted alias"
fi
brow="$(jq -c --arg p "$(realpath "$fx/alias.cfg")" 'select(.path == $p)' "$sd/preimage/preimage-ledger.jsonl" | tail -n1)"
[ "$(jq -r '.captured' <<<"$brow")" = "true" ] || fail_msg "P17: symlink to an allowed target must still capture [$brow]"
) && ok
echo
total=$((pass + $(wc -l <"$FAILFILE")))
echo "== test-wake-preimage: $pass/$total passed =="
[ -s "$FAILFILE" ] && exit 1
exit 0
@@ -28,15 +28,6 @@
# T16 #946 quarantine-audit: consumed-hashes rows provably false against the
# dead-letter ledger are reported (exit 1) and removed only under --repair;
# healed rows and the ledger itself are untouched
# T17 #952 quarantine-audit clean sweep names BOTH unprovable residual
# classes: evidence pruned (no evidence) AND evidence surviving with an
# empty observed_hash (evidence unusable). Fixture is the VERBATIM live
# specimen (mos-dt lane dead-letter seq 13, bench/malformed-locator-test)
# — a nested .locators.* row with NO observed_hash key. Do NOT hand-build
# a FLAT dead-letter fixture here: consumed-hashes rows are genuinely
# flat, dead-letter rows are genuinely nested, and a flat fixture makes
# the audit's CORRECT non-conviction look exactly like the defect under
# hunt (this false-defect near-miss actually happened in the #951 review)
#
# Isolated: every test runs against a fresh WAKE_STATE_HOME temp dir.
set -uo pipefail
@@ -689,50 +680,6 @@ echo "== T16: #946 — quarantine-audit: a consumed-hash row matching a dead-let
grep -qi 'OK' "$TMP_ROOT/t16.ok" || fail_msg "T16: a clean audit must say OK [$(cat "$TMP_ROOT/t16.ok")]"
) && ok
echo "== T17: #952 — the clean-sweep message names BOTH unprovable residual classes; a surviving-but-empty-hash dead-letter row is correctly NOT convicted =="
(
WAKE_STATE_HOME="$(fresh_state t17)"
export WAKE_STATE_HOME
unset WAKE_AGENT
STATE_DIR="$WAKE_STATE_HOME/default"
rec="$STATE_DIR/consumed-hashes.jsonl"
dl="$STATE_DIR/dead-letter.jsonl"
# Drive the REAL flow so the consumed-hashes row is produced by the actual
# writer (_record_last_consumed), not hand-built: 12 fillers, then the bench
# key lands at observed_seq 13 — the same seq as the live specimen below.
i=1
while [ "$i" -le 12 ]; do
"$STORE" enqueue --class actionable --locators "{\"kind\":\"filler\",\"id\":\"f$i\",\"observed_hash\":\"HF$i\"}" >/dev/null || fail_msg "T17: filler enqueue $i failed"
i=$((i + 1))
done
"$STORE" enqueue --class actionable --locators '{"kind":"bench","id":"malformed-locator-test","observed_hash":"H-REAL-CONSUMED"}' >/dev/null
"$STORE" consume --upto 13 >/dev/null 2>&1 || fail_msg "T17: baseline consume failed"
jq_any "$rec" '.kind=="bench" and .id=="malformed-locator-test" and .observed_seq==13 and .observed_hash=="H-REAL-CONSUMED"' ||
fail_msg "T17: fixture broken — the real writer did not record the bench@13 row"
# VERBATIM live specimen: mos-dt lane dead-letter.jsonl line 1 (2026-07-26).
# Copied byte-for-byte per the #952 fixture constraint — nested .locators.*
# with NO observed_hash key, so the audit's extraction yields "".
printf '%s\n' '{"observed_seq":13,"locators":{"kind":"bench","id":"malformed-locator-test","note":"deliberately non-conformant"},"class":"actionable","emit_ts":1785055610,"hmac":""}' >"$dl"
# Guard the near-miss trap: the specimen must be genuinely NESTED (no
# top-level kind) and must extract an EMPTY hash via the audit's own paths.
jq -e '(has("kind") | not) and .locators.kind=="bench" and ((.locators.observed_hash // "") == "")' "$dl" >/dev/null ||
fail_msg "T17: specimen fixture is not the genuine nested shape — rebuild it from the live lane, not the schema"
# Evidence SURVIVES (kind/id/seq all match the row) but extracts hash "";
# _record_last_consumed never writes an empty-hash row, so the four-field
# match can never fire: this is unprovable class 2, NOT a false witness.
rep="$TMP_ROOT/t17.rep"
"$STORE" quarantine-audit >"$rep" 2>&1 || fail_msg "T17: the audit must exit 0 — nothing here is provable [$(cat "$rep")]"
grep -q 'FALSE WITNESS' "$rep" && fail_msg "T17: the empty-hash evidence must NOT convict (the predicate is correct and must not change) [$(cat "$rep")]"
# The wording under test (#952): BOTH residual classes, named.
grep -qi 'pruned' "$rep" || fail_msg "T17: clean sweep must name residual class 1 — evidence pruned away [$(cat "$rep")]"
grep -qi 'empty observed_hash' "$rep" || fail_msg "T17: clean sweep must name residual class 2 — surviving evidence with an empty observed_hash [$(cat "$rep")]"
# --repair on a clean sweep removes nothing: the unprovable row survives.
"$STORE" quarantine-audit --repair >"$TMP_ROOT/t17.fix" 2>&1 || fail_msg "T17: --repair on a clean sweep must exit 0 [$(cat "$TMP_ROOT/t17.fix")]"
jq_any "$rec" '.kind=="bench" and .observed_seq==13 and .observed_hash=="H-REAL-CONSUMED"' ||
fail_msg "T17: --repair must NOT remove the unprovable bench@13 row — the audit only removes what the ledger can convict"
[ "$(grep -c . "$dl")" = "1" ] || fail_msg "T17: the dead-letter LEDGER must be untouched"
) && ok
echo
if [ -s "$FAILFILE" ]; then
echo "wake store/ack harness: FAILED ($(grep -c . "$FAILFILE") assertion(s))" >&2
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",