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

5.6 KiB

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 continued 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.