Skip to content

v0.2 hardening: bind verdicts to sealed evidence, fail closed on publication - #1

Merged
nelsonwerd merged 8 commits into
mainfrom
v0.2-hardening
Jul 30, 2026
Merged

v0.2 hardening: bind verdicts to sealed evidence, fail closed on publication#1
nelsonwerd merged 8 commits into
mainfrom
v0.2-hardening

Conversation

@nelsonwerd

Copy link
Copy Markdown
Owner

Closes the evidence-integrity gaps found while dogfooding didrun on a 15-day autonomous build.

  • verify: bind the verdict to sealed evidence; stop losing interrupted flights
  • compat: freeze the v1 format; make seal publication fail closed
  • redact: score runs rather than paths, and redact every field that blocks
  • claims: supersede and conjoin so a fix-verify loop can converge
  • claim: bind the command that just ran; cite out-of-band authority

Local install is intentionally NOT upgraded yet — an autonomous build is mid-run against didrun 0.1.0.

P0 of the v0.2 hardening pack -- the foundation every later phase needs, so
that adding fields and grades later cannot invalidate evidence already sealed.

seal: publication and the watermark are now one atomic pair. _attach_note
checks git's exit code instead of discarding it (a seal could report success
when publication had failed), the body travels on stdin via -F - rather than
-m (a real note body hit 1.6 MB, past ARG_MAX), and everything after
publication runs inside a rollback that restores the commit's prior note.

ledger: freeze the v1 chain preimage behind an explicit per-version field
list, so future Event fields cannot silently change the hash of records
already written. from_dict now filters unknown keys instead of cls(**d), so a
newer ledger no longer crashes an older reader. Verified by recomputing a v1
entry hash under pre-change code: byte-identical.

manifest: refuse unknown manifest versions rather than parsing them
optimistically; carry the fingerprint version in band.

tests: add the preimage golden, the seal-publication suite, and a
content-blind compat replay harness that reads ledger structure and git
metadata only, gated on DIDRUN_COMPAT_CORPUS so it skips cleanly when absent.

53 -> 96 passing, 4 env-gated skips; harness.recall still PASS at 100%.
…rrupted flights

P1 and P2 of the v0.2 hardening pack.

P1 -- the reason v0.2 exists. verify --strict was not bound to the evidence it
sealed: it regraded claims against whatever ledger was on disk now, keyed by
integer index, never called verify_chain, and sealed no event hash, tree or
fingerprint into the note. Measured on v0.1: seal three claims, delete the
ledger, run three unrelated commands (one /usr/bin/true) into a fresh ledger on
the same tree, and it reported 3/3 recorded-exact, exit 0. That scenario now
reports witness-unavailable on every claim and exits 1.

Claims now carry an evidence block naming the entry that backed them, verify
checks that binding, a broken chain dominates the verdict, and tree_delta
returns None rather than [] when the delta cannot be computed -- an archived
ledger used to produce the confident false sentence "evidence tree equals
sealed tree". Manifest version 2; v1 notes still regrade exactly as before,
down to the reason string.

P2 -- operability. The ledger root is 0700 and read-only verify no longer
creates one as a side effect. Output can stream (--tee/--heartbeat) instead of
appearing only in a summary line after the command ends. Appends take an
flock so concurrent writers cannot corrupt the chain.

Interrupt safety needed two fixes, both found by reproducing the loss rather
than reasoning about it. Signals are now held across the digest-and-append
window, so a second Ctrl-C cannot unwind the recorder between the drain and
the record (measured 12/30 flights lost before, 0/28 after). And the
after-digest never costs the event on any path: a terminal Ctrl-C reaches the
whole process group and kills the git the digest shells out to, even when the
child exited cleanly, which lost a completed command's event 11/20 times.
tree_after is None there and coverage says observed-text-only rather than
claiming complete.

COMPAT.md said a v0.1 reader parses a v2 note without crashing. Measured, it
exits 2 -- correctly, by the refuse-never-coerce rule. Documented as the real
forward incompatibility it is: upgrade the verifier before the sealer.

96 -> 193 passing, 4 env-gated skips; harness.recall still PASS at 100%.
This lands the v0.2 secrets and evidence work, plus the two defects an
independent verification pass found in it.

The detector tiering stands as designed: the six structured patterns block,
the entropy sweep only notices. Measured over a real archive, the structured
detectors had never fired once and 65 of 66 seals carried an override, so a
gate that only ever produced false positives is a gate that gets switched
off. A refusal is now scoped to the bytes that actually leave the machine,
and findings carry a location instead of a bare count.

The first defect was in the fix for that gate's false positives. Removing `/`
from the entropy sweep's token class stopped a filesystem path being scored as
one long token, and that part was measured. What was not measured is that `/`
is also 1 of the 64 characters of base64: a credential whose slash-free runs
are all shorter than the 24-character floor stopped being a candidate at all,
and since replacement is driven off the findings, it stopped being redacted
too. An AWS-shaped secret in a recorded argv went into refs/notes/didrun
verbatim under a seal line reading "0 findings". Reproduced end to end before
touching anything.

So the separator is no longer decided in the character class. A run is taken
over the class including `/`, and a per-run test decides whether that `/` is a
separator or an alphabet member: a path is a sequence of names, base64 is not,
and a name is mostly letters that are mostly lower case. One name is enough to
call a run a path, which is the conservative direction — it can only return a
run to segment scoring, so it cannot add a false positive the previous
behaviour did not already have. Measured at exactly that: 0 new false
positives over 46,403 real paths, with recall on slash-bearing base64 back
from 74-87% to 97-99%. Both directions are now in MEASUREMENTS.md, and the
sentence there that presented a one-directional measurement as two-directional
is corrected rather than removed.

The second defect was that blocking and redacting were different sets. Widening
the scan to the export domain caught secrets in claim labels and delta paths
and refused the seal over them — but nothing scrubbed those fields, so the
override published them raw, under a refusal message that had already promised
"export a redacted artifact anyway". Every exported claim string is now
redacted by the same pass that reports it to the gate, so the two cannot drift
apart again; the projection is declared per field in `redaction.fields`, and
`scan_domain` says the fields were scanned. The seal still blocks exactly as
before, which has its own test, because a fix that quietly opened the gate
would be worse than the leak.

Both fixes carry regression tests that were falsified: reverting each one turns
the relevant tests red (8 and 4 respectively) and restoring it turns them green.

Also here, from the same phase: the v2 environment fingerprint and its
advisory-by-default drift comparison, claims bound to the recorded entry by
chain hash so an archived or substituted ledger is visible rather than silent,
interrupted flights recorded instead of lost, and the exported redaction
projection made exactly reconstructible.

Known and deliberately not closed, recorded in COMPAT.md rather than dropped:
an HTML report recomputes a stale claim's file list against the working tree,
so that list is not the note's redacted copy; the entropy sweep still misses
roughly 1-3% of slash-bearing base64 by design and resists no attacker who
knows the rule; and `reason`/`coverage` are exported unredacted because they
carry only generated text today, which is a property of the generators and not
an enforced invariant.

Gates: 320 passed, 4 skipped. harness.recall VERDICT PASS, defect-class recall
100%, freeze INTACT. All changed files parse under Python 3.11. No runtime
dependencies added. No private paths or internal names in shipped files.
Gate runs are UNRECEIPTED - this repo keeps no didrun ledger.
Two claim-layer additions and one remediation of the reader they exposed.

Supersession. A repair re-declares the same gate, and a later claim carrying
the same (ctype, label) now retires every earlier one in the same seal window.
That is what lets a fix clear a failed or stale claim with no intervening
commit; before this the only escapes were seal-early, which needs a commit, and
re-seal, which truncated the record, so discarding the ledger was the rational
third. Supersession subtracts from the verdict and never from the record: every
claim in the window is still graded and published, the retired entry keeps its
grade and reason verbatim, and both surfaces mark it and count it. Retrying a
gate until it passes is therefore visible rather than prevented, which is the
honest bound and strictly better than the invisible truncation it replaces.
The mark is validated on read, not trusted, and an all-superseded window grades
unknown rather than falling through all([]) to a vacuous green.

Conjunction. A new claim type that grades no evidence of its own: it names
other claims in the same window by label and grades as the worst of them, so it
can never come out better than any one of its members. It binds no event, and
verify resolves it in a second pass against the results in that same note --
never against the live claims.jsonl, which would let a claim declared after the
seal change an old commit's verdict. This substitutes for nothing. Every
conjunct keeps its own witnessed exit code and its own tree comparison, and the
design review's refusal to accept a transition marker in place of a recorded
execution still stands untouched.

The vocabulary change bumps MANIFEST_VERSION to 3, which is the first bump that
is not additive in either direction, and docs/COMPAT.md states the break at full
strength: an older reader meeting a v3 note raises out of the command rather
than refusing gracefully. Because the bump makes the version guard the thing
that whole story rests on, the guard was measured -- and it did not hold. Over
22 malformed note shapes the reader left an uncaught traceback on eleven and
exited 1, the same code --strict uses for a note that graded badly, so a corrupt
note could not be told from an honest failure by exit status. Four shapes were
accepted outright: a note whose version was the JSON value true verified green
at exit 0, because bool is an int subclass and True > 3 is False, and 2.5, 0 and
-1 did the same.

So an unreadable note is now a graded refusal with a reason naming what is
wrong, for every shape rather than the remembered ones. version is read with
the same rule the supersession mark gets and must be an integer of at least 1;
the required fields are checked; and a claim type this binary cannot parse is a
refusal instead of a ClaimError escaping verify, which closes from this end the
hole COMPAT.md documents from the other. The two refusals stay distinguishable
because the tree-fallback scan depends on it: a malformed body is skipped and
counted so one foreign note cannot hide every good one, while a version above
this reader's maximum propagates, since scanning past it would report an older
note's verdict as if it were current. That split used to happen by accident,
the scan catching bare Exception. This is fail-closed reading and not a forgery
barrier; whoever can rewrite a note can rewrite a grade directly.

Nothing legitimately published is newly refused -- every note in the world
carries an integer version of 1, 2 or 3 and the required fields, and the compat
corpus replays all three.

Also redacts reason for every claim rather than exporting it as generated text.
A conjunction's reason names a conjunct, which is operator-authored, so the
field stopped being generated-only; covering it for all claim types means a
later version that interpolates something into it is covered without anyone
remembering. And a narrowing re-seal is refused unless --reseal is passed:
git notes add -f force-replaces, and a seal scoped to all_claims[watermark:]
was silently publishing a narrow note over a wide one and destroying a record
that exists nowhere else.

Three known-not-closed items are recorded in docs/COMPAT.md rather than
dropped: a duplicate conjunct label is counted twice in the reason, the
no-events refusal for a conjunction borrows a message about binding events, and
a conjunction's redacted reason is not guaranteed to contain its redacted
conjunct label as a substring since the two are scanned as separate fields.
None can produce a grade the evidence does not back.

Suite 435 passed 4 skipped, up from 405 with no regressions; harness.recall
freeze INTACT, defect-class recall 100.0%, VERDICT PASS; the changed files parse
at feature_version 3.11 and the refusal paths were exercised under a real
3.11.14; runtime dependencies remain empty.
Three changes to what a claim can honestly say, and one remediation pass over
the surface the third one added.

`didrun claim` with no `--event` used to bind to event 0 whatever event 0 was.
In a session that ran a passing command and then a failing one, that produced a
fully green sealed receipt over a suite which had just exited non-zero. It now
binds the command that actually just ran, refuses when that command failed, and
names the index, the exit code and `--event N` in the refusal, so the operator
who really did mean an earlier event has a way to say so.

A claim that lands `scope-exact` now says which pathspec would have made it
tree-exact, naming the directories and files that fell outside what was
declared. The hint is absent when the scope is already exact, so the reason line
for an exact claim is unchanged.

`didrun authorize` records that a file with a given SHA-256 was cited as the
authority for one exceptional transition — `allow-secrets` or `reseal`, with no
scope for an ordinary seal, because an authorisation on the happy path becomes a
rubber stamp. It expires after a stated number of seals, and
`--require-authority` refuses a bare override that no live citation covers. It
grades `cited` and it buys exactly one property: retroactive fabrication becomes
detectable, because the digest sits in a note sealed earlier. It is not a
signature, it is not approval, and an agent can author its own authorization
artifact in one line — the limitation sentence is printed beside every citation
from a source constant so a note cannot edit it.

The remediation. Publishing a new block of operator text re-opened a defect this
repo had already closed once for claim labels: `principal` is `--principal` or
`$USER`, it was published verbatim, and the pass that redacts authority fields
never touched it. A token there refused the seal via the whole-manifest backstop
under a message promising a redacted export, and then `--allow-secrets` wrote it
into the git note unchanged with `redaction.fields` empty. Worse in the quiet
case: one high-entropy value passed as both label and principal came out
scrubbed in the first field and raw in the second, in the same JSON object, with
nothing overridden. `principal` now goes through the same `redact_field` pass as
the label and the cited path; the enumeration of which fields carry outside text
is checked for completeness by a test instead of trusted, and a field neither
table classifies is dropped from the note rather than published unscanned. The
whole-manifest backstop that had been carrying this alone is now exercised too —
removing the authority block from the scanned bytes used to break no test.

Two of the six fields in the terminal's authority block were interpolated
without `_sanitize`, because every use site spelled the call out by hand.
`artifact_bytes` and `expires_after_seals` are numbers in a note didrun wrote
and arbitrary strings in a note someone else edited, and a newline in either one
fabricated whole verdict rows above the real table — rows the renderer never
computed, carrying the words this vocabulary forbids, plus raw escape bytes into
the operator's terminal. A note crosses machines over `git fetch`, so every
field of it is outside data; the block now reads all of them through one
sanitizing accessor, the way the HTML path already funnelled everything through
one `esc`. The `seal` and `authorize` echoes had the same hole from the ledger
side and are sanitized too — redaction is not sanitization, and a scrubbed label
still carries whatever control characters surrounded the secret.

`principal_basis` joins `grade` and `limitation` as a field pinned to its source
constant at both bind and render. It qualifies the principal, so a note
supplying "verified-ssh-signature" printed a claim of verified identity two
lines above the sentence saying didrun cannot tell who wrote it.

Left open and written down in docs/COMPAT.md rather than fixed quietly: a note's
authority `scope` is still rendered as written and not re-validated against the
scope vocabulary. It grants nothing — `--require-authority` gates against the
local ledger, never against a fetched note — but a hand-edited note can misname
the transition it claims to cover, and choosing what to render for an
unrecognised scope deserves the same deliberate treatment the grade vocabulary
got.
…osed

The real-corpus legs did NOT run: DIDRUN_COMPAT_CORPUS was unset, so all four
are recorded as BET (UNEXERCISED) rather than simulated. What did run: the full
suite (496 passed, 4 skipped), the capture kill-gate (PASS, 100% defect-class
recall), a 3.11 grammar parse over the tree, the stdlib-only and private-path
audits, and an end-to-end walk of the real CLI in throwaway repos covering 30
properties — including every earlier phase's headline property, re-checked
rather than assumed after P3-P5 landed on top of them.

COMPAT_REPLAY.md carries the VERIFIED / BET / KNOWN-NOT-CLOSED split and the
four gates. V02_STATUS.md is the plain-English release statement, including the
five defects this pass found and deliberately did not fix (the integration pass
measures; a fix landing after the last gate is unverified code in a release).

COMPAT.md's forward-incompatibility warning was stale in two places: the
Version 3 section described the unknown-claim-type traceback in the present
tense after a later unit turned it into a graded refusal in this reader, and
"the compat corpus replays all three versions" was true only of the synthetic
fixtures. Both corrected.
COMPAT.md's whole forward-compatibility story is phrased as "a v0.1 reader
versus a v0.2 reader" and tells operators to upgrade the verifier before the
sealer. `--version` is the only way anyone can tell which one they have, so
shipping this work while it still reported 0.1.0 would have undercut the
document it exists to serve.

Closes K3 in docs/V02_STATUS.md, which was outstanding only because the
integration pass deliberately fenced off src/didrun/**.

496 passed, 4 skipped; harness.recall PASS at 100%.
The README described a tool that no longer existed and made several claims
nothing supported.

Corrected: the prose said Python 3.14+ while the badge, pyproject and CI all
said 3.11+; there were six commands documented as five; the grade table listed
five of seven grades, missing witness-unavailable and chain-broken entirely.
Every command, flag, grade token and output snippet in the file has now been
reproduced by running the CLI.

Added: what v0.2 changed, led by the evidence binding -- verify used to regrade
against whatever ledger was on disk, so deleting one and running unrelated
commands on the same tree still reported every claim recorded-exact. The
upgrade note now warns that older readers refuse a v0.2 note by design, which
is the thing most likely to bite a mixed-version setup. Local ledger exposure
is documented where a user needs it: raw output lives as blobs under .didrun
with no retention, so a credential scrubbed from git history survives there.

Removed or qualified: "FAILED means a caught lie" (an overclaim that also
contradicted this file's own closing paragraph, and in v0.2 claim refuses to
bind a bare claim to a failing event at all -- nothing in the grade speaks to
intent); an unsourced market assertion about 2026 review throughput; a census
of competing tools that was never taken; an absolute about an unshipped tier;
and the entropy sweep presented without its known ~1-3% miss on slash-bearing
base64.

What v0.2 did NOT establish is now its own section: compatibility with already
sealed evidence is not a finding, only synthetic fixtures are green and the
harness prints "corpus: SKIPPED (unverified)" itself; CI has never run on this
branch; and a session.log record this binary cannot parse still crashes with a
traceback at exit 1, which is indistinguishable by exit status from an honest
failure.

The closing paragraph now states the weakest thing that is true: across the
largest run to date exactly one claim graded failed, the claim counts disagree
with each other, every figure is self-reported by the system under study, and a
run whose only actor was cooperating cannot tell an effective gate from an
inert one.
@nelsonwerd
nelsonwerd merged commit 3de1736 into main Jul 30, 2026
0 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant