Files
troupe-standup/wizard-work/null/artifact-seal/README.md
T

110 lines
5.6 KiB
Markdown

# artifact_seal — deterministic SHA-256 seal manifests over a file tree
Lane: Codeforge. Stdlib only. No third-party dependencies.
artifact_seal.py the engine + CLI (~14 KB)
selftest_artifact_seal.py 44 checks, 3 layers
verify_artifact_seal_oracle.py independent oracle (sha256sum, git blob)
## Why
AI-generated artifacts drift silently: an editor strips a trailing newline, a
manually edited file changes by one character, a `git checkout` restores an
older revision. A single "total hash of the whole directory" is useless here --
it changes whenever ANY file changes, so it cannot answer "which file moved?".
This answers that with a per-file digest plus a whole-set seal, and reports
missing / extra / digest-mismatch per path.
## Design decisions that are load-bearing
* **Deterministic.** Same bytes, same seal, on any platform. No timestamps, no
inode data, no absolute paths in the digest input. Verified: sealing from two
different CWDs yields an identical manifest.
* **Tamper-evident.** Flipping one bit in one file changes that file's digest
AND the whole-set seal.
* **Order-invariant.** The whole-set seal is computed over entries sorted by
(path, name). Reordering the `entries` array inside the manifest does not
change the seal and does not affect verification. This matters because the
manifest is a JSON file that editors and `jq` will reorder.
* **Length-prefixed canonical encoding.** The whole-set digest is NOT a
concatenation of the per-file digests. A plain join is ambiguous across field
boundaries (`["ab","c"]` and `["a","bc"]` hash the same joined string); each
field is emitted as `len:value`, which removes the ambiguity. A test asserts
two structurally different trees do not share a seal.
* **A trailing newline is CONTENT.** `hash_text` does not normalise line
endings. A file ending in `\n` and the same file without it get different
digests -- a stripped trailing newline is exactly the drift this exists to
catch. `str.splitlines()` is never used.
* **Size is evidence, not a verdict.** A size mismatch is reported, but the
digest is still computed and still compared. An earlier version `continue`d
after a size mismatch, which silently swallowed a same-length content change
(see "Bugs this suite caught" below).
* **Renames are visible.** The digest covers the path as written in the
manifest, so a rename surfaces as `missing: old` + `extra: new`, never as a
silent digest match.
* **Symlinks and non-regular files are skipped.** Hashing a FIFO is either
infinite or meaningless. Hidden files (dot-prefixed) ARE included: `.gitignore`
is content too.
* **`parse_manifest` raises; `verify_manifest` returns problems.** A structurally
broken manifest (bad JSON, wrong `format`, wrong `count`, non-hex digest) is
not a "verification result" -- there is nothing sensible to compare against,
so it raises `SealError`. Content problems come back as a problem list.
## CLI
python3 artifact_seal.py seal DIR [--out FILE] [--prefix P]
python3 artifact_seal.py seal FILE ... --out FILE
python3 artifact_seal.py verify --manifest FILE [--target DIR]
`verify` reads the tree from `--target` (default: CWD). The directory is
deliberately NOT accepted as a bare positional: that was ambiguous with the
manifest's own path and was a real source of silent wrong-tree verification.
Exit codes: `0` ok / `1` verification failed or unreadable manifest / `2` usage.
## Checks
Layer 1 -- 24 hand cases: trailing newline, empty file (`== sha256('')`), empty
tree, order invariance, rename detection, length-prefix ambiguity, manifest
forgery, count mismatch, text round-trip, size/digest interaction, five
malformed manifests.
Layer 2 -- 60 randomised trees. Each is sealed, verified (must pass), then
mutated in exactly one of six ways -- bitflip, append, delete, truncate, added,
renamed. Every mutation must be caught: 60/60 caught, and all six mutation
classes were hit at least 9 times.
Layer 3 -- 3 mutants of the verifier. Each must FAIL the property suite,
otherwise the suite proves nothing: a seal-blind verifier accepts an
internally-inconsistent manifest; an extras-blind verifier accepts an injected
file; an order-dependent verifier rejects a reordered manifest. The real
verifier rejects all three forgeries and accepts the reordering.
Plus 6 CLI subprocess checks.
## Bugs this suite caught (all real, all fixed)
1. **CLI positional filter was broken.** `rest.index(a)` matched the FIRST
occurrence, so `--manifest`'s value was never excluded and the directory was
parsed as an option value. `verify DIR --manifest FILE` verified CWD instead
of `DIR` -- a silent wrong-tree pass. Fixed by an explicit state-machine
`_positional()`, and the directory is no longer accepted positionally at all.
2. **Size check swallowed the digest check.** `continue` after a size mismatch
meant an appended edit to a 4-byte file produced only "size mismatch" and the
digest was never compared. Fixed: both are reported; a regression test
asserts a same-length content change is caught by the digest check alone.
Both were found by running the suite, not by reading the code.
## Oracle
`verify_artifact_seal_oracle.py` cross-checks against two independent
implementations:
* `sha256sum` -- 40 real repo files, 0 disagreements.
* `git`'s blob hasher (`sha1("blob <len>\0" + content)`) -- different hash, same
content, used to prove the bytes are what the digest claims.
* A one-byte tamper to `wizard-work/null/diffkit/diffkit.py` in the real repo is
caught, and the tree verifies again after restoring the original bytes.