Skip to content

Commit 832b157

Browse files
Harden the DYADT claim verifier against adversarial-review bypasses (#458)
## Context Follow-up to #457 (Wave 4, merged), which introduced DYADT — the post-action verifier that checks an agent's *claimed* outcomes actually happened. Before considering it done, I ran an **adversarial review** of the reference verifier (3 red-team lenses — bypass, verdict-logic, spec-gaps — each finding independently re-verified). It found **real ways to make a false claim pass**, or to make the verifier drop or mis-judge a claim. A claim-checker that can be fooled is worse than none. This PR closes every confirmed hole **fail-safe** (return `unverifiable`, never a confident wrong verdict) and locks each with a regression assertion **and** a conformance vector. ## Holes closed (`scripts/verify-claims.sh`) | Bypass found | Now | |---|---| | Unresolvable/empty base ref → confident-wrong `created`/`modified`/`deleted` | `unverifiable no-base-ref` | | `created` confirmed any existing file (untracked build output, etc.) | requires a git-**tracked** file new to the change | | Missing required field → false-confirm / silent drop | `unverifiable missing-field`; a block with no `id` still appears (no silent drop) | | Empty / `.*` `expect` → unconditional confirm | `unverifiable empty-pattern`; malformed regex → `bad-regex` | | `target` = absolute / `..` / symlink → evidence redirection | `unverifiable unsafe-path` | | `stdout-contains:` matched **stderr** too | matches stdout only (stderr captured separately) | | `contains:`/`sha256:` on a directory/unreadable file → `refuted` | `unverifiable` | | Licence claim phrased only in `statement` → auto-confirmed | licence detected in class/target/expect/**statement** → `manual-only` | | `claims-compose` infinite recursion (cycle / fork bomb) | depth-capped at 8 | | `not_before` (stale-evidence) unimplemented | present → `unverifiable` (reference collects no timestamps) | | Parser only accepted `key = "v"` | whitespace-tolerant (`key="v"` too) | ## Spec `VERIFICATION-PROTOCOL.adoc` gains two normative sections: **Fail-safe requirements** (the exhaustive list of "cannot collect trustworthy evidence → unverifiable") and **Command execution & sandboxing** (`command-transcript` executes `target`; untrusted claims MUST be sandboxed; the reference impl is trusted-input-only and says so). ## Verification - `scripts/tests/wave4-dyadt-test.sh`: **14/14** (7 new hardening assertions — each proves a specific bypass is now closed). - `spec/conformance/`: **9/9** vectors (added `missing-field`, `unsafe-path`, `licence-in-statement` so the production verifier must handle them too). - Dogfood `CLAIMS.a2ml` still all-confirmed; Waves 0/1/3 tests unaffected; registry + dashboard in sync. Method: 3-lens red-team fan-out → independent re-verification of each finding → fix only the confirmed ones fail-safe. Licence handling stays `manual-only` end-to-end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_0114ps6mY5jAH4SzbGxeuYjc)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9083f25 commit 832b157

10 files changed

Lines changed: 269 additions & 35 deletions

File tree

.machine_readable/REGISTRY.a2ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ name = "DYADT — Did-You-Actually-Do-That"
234234
stream = "governance"
235235
home = "did-you-actually-do-that/"
236236
canonical_doc = "did-you-actually-do-that/README.adoc"
237-
source_hash = "sha256:445359ddcc92b56dfc8e8a3bdc16062439f1236b5fd0f42099113e7afa86d2e0"
237+
source_hash = "sha256:2ae635b9ede51e76781cb7c171108f2a4505b0aae9ac97fb05c910915141eb2a"
238238
route = "post-action agent-claim verification (Tier 4 accountability)"
239239

240240
[[spec]]

did-you-actually-do-that/spec/VERIFICATION-PROTOCOL.adoc

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,45 @@ discharged by `command-transcript`.
9292
NOT fall back to reading the agent's cited evidence.
9393
. *Stale evidence is unverifiable.* If `not_before` is set and the primary
9494
evidence predates it, the verdict is `unverifiable` (reason `stale-evidence`).
95+
A verifier that does not collect evidence timestamps MUST return
96+
`unverifiable` for any claim carrying `not_before` (it cannot honour the
97+
guarantee) rather than verify possibly-stale evidence as fresh.
98+
99+
== Fail-safe requirements (normative)
100+
101+
Every place a verifier *cannot* collect trustworthy primary evidence is
102+
`unverifiable`, never a confident wrong verdict. A conforming verifier MUST
103+
return `unverifiable` (not `confirmed`/`refuted`) when:
104+
105+
* the diff base ref is missing or does not resolve (`no-base-ref`) for a
106+
`created`/`modified`/`deleted` claim;
107+
* a required field (`claim_class`, `target`, `expect`, `verifier`) is absent
108+
(`missing-field`), or a `[[claim]]` block carries no `id` (it MUST still
109+
appear in the output — no silent drop);
110+
* the `target` of a file verifier is an absolute path, contains `..`, or is a
111+
symlink (`unsafe-path` — evidence could be redirected);
112+
* an `expect` predicate is empty or otherwise always-matching
113+
(`empty-pattern`), or its regex is malformed (`bad-regex`);
114+
* a `contains:`/`sha256:` target is not a readable regular file
115+
(`not-a-regular-file` / `unreadable`).
116+
117+
And a licence/SPDX claim — detected in ANY of `claim_class`, `target`,
118+
`expect`, or `statement` — MUST be `unverifiable` with reason `manual-only`
119+
(the Manual-Only licence policy; never auto-`confirmed`).
120+
121+
== Command execution & sandboxing (normative)
122+
123+
The `command-transcript` verifier EXECUTES the claim's `target`. Consequences:
124+
125+
* `stdout-contains:` MUST match the command's *stdout only*; stderr MUST be
126+
captured separately so a marker printed to stderr cannot false-confirm.
127+
* Because the claims file is agent-authored, executing its commands is code
128+
execution. A verifier running over UNTRUSTED claims MUST sandbox the command
129+
(no network, constrained filesystem, resource limits). The reference verifier
130+
does NOT sandbox and is for TRUSTED (repo-authored, PR-reviewed) claims only;
131+
a conforming production verifier MUST document its execution boundary.
132+
* `claims-compose` recursion MUST be depth-bounded to defeat self- or
133+
mutually-referential claim files (the reference verifier caps depth at 8).
95134

96135
== Multi-verifier corroboration (SHOULD)
97136

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[claims]
2+
schema = "dyadt/claim@1"
3+
actor = "conformance"
4+
[[claim]]
5+
id = "C1"
6+
claim_class = "command-ran"
7+
statement = "added the SPDX-License-Identifier header"
8+
target = "true"
9+
expect = "exit==0"
10+
verifier = "command-transcript"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
C1 unverifiable
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[claims]
2+
schema = "dyadt/claim@1"
3+
actor = "conformance"
4+
[[claim]]
5+
id = "C1"
6+
claim_class = "command-ran"
7+
statement = "a claim missing its target field"
8+
expect = "exit==0"
9+
verifier = "command-transcript"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
C1 unverifiable
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[claims]
2+
schema = "dyadt/claim@1"
3+
actor = "conformance"
4+
[[claim]]
5+
id = "C1"
6+
claim_class = "file-changed"
7+
statement = "a path-traversal target"
8+
target = "../../etc/passwd"
9+
expect = "created"
10+
verifier = "git-diff"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
C1 unverifiable

scripts/tests/wave4-dyadt-test.sh

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,76 @@ verifier = "command-transcript"
107107
EOF
108108
( cd "$ROOT" && bash "$V" "$TMP/g.a2ml" >/dev/null 2>&1 ); [ $? -eq 0 ] && ok "all-confirmed file exits 0" || bad "all-confirmed file did not exit 0"
109109

110+
echo "== hardening (adversarial-review fixes) =="
111+
mk() { printf '%s\n' "$2" > "$TMP/$1"; }
112+
reason_of() { # file id
113+
cd "$ROOT" && DYADT_ALLOW_UNVERIFIABLE=1 bash "$V" "$1" 2>/dev/null \
114+
| grep -E "$2|<block" | grep -oE '(confirmed|REFUTED|unverifiable) *\[[^]]*\] *[a-z-]+' | head -1
115+
}
116+
# missing required field -> unverifiable, still counted
117+
mk mf.a2ml '[claims]
118+
[[claim]]
119+
id = "C1"
120+
claim_class = "command-ran"
121+
expect = "exit==0"
122+
verifier = "command-transcript"'
123+
[[ "$(reason_of "$TMP/mf.a2ml" C1)" == unverifiable*missing-field ]] && ok "missing field -> unverifiable" || bad "missing field not caught"
124+
# claim with no id is NOT silently dropped (appears as a block, unverifiable)
125+
mk noid.a2ml '[claims]
126+
[[claim]]
127+
claim_class = "command-ran"
128+
target = "true"
129+
expect = "exit==0"
130+
verifier = "command-transcript"'
131+
( cd "$ROOT" && DYADT_ALLOW_UNVERIFIABLE=1 bash "$V" "$TMP/noid.a2ml" 2>/dev/null | grep -q 'no-id' ) && ok "missing id not silently dropped" || bad "missing id was dropped"
132+
# empty pattern -> unverifiable (not an always-match confirm)
133+
mk ep.a2ml '[claims]
134+
[[claim]]
135+
id = "C1"
136+
claim_class = "file-changed"
137+
target = "README.adoc"
138+
expect = "contains:"
139+
verifier = "git-diff"'
140+
[[ "$(reason_of "$TMP/ep.a2ml" C1)" == unverifiable*empty-pattern ]] && ok "empty contains pattern -> unverifiable" || bad "empty pattern not caught"
141+
# path traversal -> unverifiable
142+
mk up.a2ml '[claims]
143+
[[claim]]
144+
id = "C1"
145+
claim_class = "file-changed"
146+
target = "../etc/passwd"
147+
expect = "created"
148+
verifier = "git-diff"'
149+
[[ "$(reason_of "$TMP/up.a2ml" C1)" == unverifiable*unsafe-path ]] && ok "path traversal -> unverifiable" || bad "unsafe path not caught"
150+
# unresolvable base -> unverifiable (not confident-wrong created)
151+
mk nb.a2ml '[claims]
152+
[[claim]]
153+
id = "C1"
154+
claim_class = "file-changed"
155+
target = "README.adoc"
156+
expect = "created"
157+
verifier = "git-diff"'
158+
r="$(cd "$ROOT" && DYADT_BASE=definitely-not-a-ref DYADT_ALLOW_UNVERIFIABLE=1 bash "$V" "$TMP/nb.a2ml" 2>/dev/null | grep -oE 'unverifiable *\[[^]]*\] *[a-z-]+' | head -1)"
159+
[[ "$r" == unverifiable*no-base-ref ]] && ok "unresolvable base -> unverifiable" || bad "unresolvable base not caught (got: $r)"
160+
# stderr marker must NOT confirm a stdout-contains claim
161+
mk se.a2ml '[claims]
162+
[[claim]]
163+
id = "C1"
164+
claim_class = "command-ran"
165+
target = "echo marker >&2; true"
166+
expect = "stdout-contains:marker"
167+
verifier = "command-transcript"'
168+
[[ "$(reason_of "$TMP/se.a2ml" C1)" == REFUTED* ]] && ok "stderr does not satisfy stdout-contains" || bad "stderr false-confirmed stdout claim"
169+
# licence claim phrased only in the statement is still manual-only
170+
mk lic.a2ml '[claims]
171+
[[claim]]
172+
id = "C1"
173+
claim_class = "command-ran"
174+
statement = "added the SPDX licence header"
175+
target = "true"
176+
expect = "exit==0"
177+
verifier = "command-transcript"'
178+
[[ "$(reason_of "$TMP/lic.a2ml" C1)" == unverifiable*manual-only ]] && ok "licence-in-statement -> manual-only" || bad "licence-in-statement auto-confirmed"
179+
110180
echo "== conformance suite =="
111181
bash "$ROOT/did-you-actually-do-that/spec/conformance/run-conformance.sh" >/dev/null 2>&1 && ok "conformance vectors pass" || bad "conformance vectors failed"
112182

0 commit comments

Comments
 (0)