# 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 \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.