fix(issue-23): the remote audit record names the dispatch it vouches for; a record without identity fails closed
Review round 4 (planner-reviewer-claude-01 B-1, planner-reviewer-codex-01 P2, reached independently): dispatch_verified is a boolean. It says the subscriber's token check ran when the record was written, but names no dispatch, the token being masked by the audit redactor and attempt_id not written. events.ndjson is appended to across a job's attempts, so a started verified under attempt 1 satisfied every later attempt's wait - the very misattribution R1 forbids, alive on the remote path while the local path (data.dispatch_token in events.log) was already immune. codex reproduced it with the real signed producer, the real _Watcher, the real audit writer and the real operator re-dispatch; both waits answered 0 where 1 was due, even when attempt 2's subscriber had correctly dropped attempt 1's replayed packet. The subscriber now writes dispatch_fingerprint = SHA-256 of the token it verified, and attempt_id for readers. With a token demanded, the predicate accepts an audit record only if it is dispatch_verified AND its fingerprint equals SHA-256 of that token; a record with no fingerprint - written before the field existed - names nothing and is rejected on that path. Without a token, any received started counts, as before. Fingerprint over attempt_id as the identity, weighing the two proposals: the local path already compares the token, so hashing it keeps one identity axis for both sources; the token is a uuid4 nonce (registry.py:141), 128 bits, so its SHA-256 is not recoverable and it is a CAS fence rather than the HMAC secret in the first place; and attempt_id cannot tell apart a token re-minted within one attempt, where the token can. attempt_id is recorded because it is cheap and readable, not relied on. W7 pins codex's five cases end to end - current dispatch accepted, an old attempt's record rejected after a real re-dispatch, still rejected when the stale packet is replayed and dropped, the new dispatch's own started accepted, a bad HMAC never recorded - with create's wait and the wrapper's wait required to agree on each. W8 pins the fail-closed legacy record and the untokened compatibility path. W5 gains the no-fingerprint and other-dispatch cases; W6 checks the real _Watcher writes the fingerprint and never the token. S7e's status reset had no reason left and is gone. codex's own reproduction file, unmodified, passes 5/5 against this tree. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
087eacaf21
commit
f4ba205c9f
3 files changed
+148
-15
No files matched your search
@@ -24,6 +24,7 @@ Exit codes:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
@@ -279,13 +280,19 @@ class _Watcher:
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
# `dispatch_verified` says this record is attributable to the job's
|
||||
# current dispatch: the token check above ran (the registry record had
|
||||
# a dispatch_token and the event's data.dispatch_token matched it) and
|
||||
# HMAC verification passed before that. The token itself is never
|
||||
# written - the audit redactor masks token-named keys - so this flag is
|
||||
# what a local reader (delegate_started_recorded) can trust for a
|
||||
# remote agent's `started`, which has no line in events.log.
|
||||
# Which dispatch this record vouches for. `dispatch_verified` says the
|
||||
# token check above ran (the record had a dispatch_token and the
|
||||
# event's data.dispatch_token matched it) after HMAC verification; on
|
||||
# its own it names no dispatch, and this file is appended to across a
|
||||
# job's attempts, so a reader waiting for a later dispatch would take
|
||||
# an earlier attempt's record as its acknowledgement (review round 4).
|
||||
# `dispatch_fingerprint` is SHA-256 of the token that was verified -
|
||||
# the same identity the local path compares in events.log, minus the
|
||||
# token itself, which the audit redactor masks by key name and which a
|
||||
# 128-bit uuid4 nonce is not recoverable from. `attempt_id` is kept for
|
||||
# readers; it is not the identity, since a token can be re-minted
|
||||
# within an attempt. A reader that demands a token must match the
|
||||
# fingerprint; a record without one proves nothing about identity.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
@@ -294,6 +301,10 @@ class _Watcher:
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
"dispatch_verified": expected_disp_tok is not None,
|
||||
"dispatch_fingerprint": (hashlib.sha256(expected_disp_tok.encode("utf-8")).hexdigest()
|
||||
if expected_disp_tok else None),
|
||||
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
|
||||
else self.attempts.get(jid)),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
Reference in new issue
Block a user