Skip to content

daemon(pipeline): hash emitter input/output into receipts#333

Merged
ojongerius merged 4 commits intomainfrom
worktree-daemon-hash-io-332
May 7, 2026
Merged

daemon(pipeline): hash emitter input/output into receipts#333
ojongerius merged 4 commits intomainfrom
worktree-daemon-hash-io-332

Conversation

@ojongerius
Copy link
Copy Markdown
Contributor

@ojongerius ojongerius commented May 6, 2026

What

Lift the Phase 1 hard-reject of non-null input/output in emitter frames. In pipeline.buildAndSign, hash any non-null f.Input and f.Output symmetrically via a new canonicalSHA256 helper (receipt.Canonicalize + SHA256Hash); the digests land on Action.ParametersHash and Outcome.ResponseHash respectively. Both gate on hasJSONPayload so a literal JSON null (or omitted field) still produces no hash — hashing the literal "null" would falsely commit the daemon to a value the emitter never sent.

The implementation deliberately does not route Output through receipt.CreateInput.ResponseBody: that helper panics on JSON unmarshal/canonicalize failure, and emitter-controlled bytes can be syntactically valid (so they pass EmitterFrame parse) yet fail re-unmarshal — 1e400 overflows float64. Routing Output through it would have given any authenticated emitter a remote DoS. Both hash paths now surface unmarshal/canonicalize errors as buildAndSign returns, which the caller maps to alloc.Rollback() and a clean per-frame failure.

pipeline.Process is also hardened against panics from any source between Allocate and Commit: chain.Allocation.Commit/Rollback are idempotent via sync.Once, and Process does defer alloc.Rollback() immediately after Allocate. Any panic now releases the chain mutex instead of orphaning it (the orphan would have deadlocked the daemon for every subsequent emitter).

The receipt format is unchanged: this enables the existing action.parameters_hash and outcome.response_hash fields. No SDK hashing path was touched.

Why

Phase 1 of the daemon (#322) deliberately rejected input/output because the canonical-hash path was not yet wired through receipt.Create. Section 3 of the daemon-process-separation rollout (#236) refactors mcp-proxy and the three SDKs into thin emitters that send real tool I/O. Per OQ3 (ADR-0010 amendment 2026-05-06) those five modules ship in one release — none of them can ship until the daemon accepts what they need to send.

Closes #332.

Tests

Added in daemon/internal/pipeline/build_test.go:

  • TestProcess_HashesInput / TestProcess_HashesOutput — assert the resulting receipts' hashes match independent re-canonicalization of the original payload (no shortcut through the daemon's internals).
  • TestProcess_HashesAreCanonical — same logical payload sent two different ways (key order, whitespace) MUST produce identical hashes. Guards the cross-language determinism property.
  • TestProcess_AcceptsPrimitiveInputOutput — primitives (string) and arrays are accepted and hashed; not all tool I/O is a JSON object.
  • TestProcess_RejectsUnrepresentableNumbers — emitter-controlled 1e400 (syntactically valid JSON, fails re-unmarshal into float64) returns a per-frame error, no panic, no chain advance.
  • TestProcess_PanicReleasesChainAllocation — injects a KeySource that panics on Sign, expects Process to panic, then asserts the next state.Allocate returns within 2s rather than hanging. Guards future panic sources.
  • Strengthened TestProcess_AcceptsExplicitNullInputOutput to assert that null produces empty hash fields (regression-safety on the documented null-as-absent behavior).
  • Removed three cases from TestProcess_RejectsMalformedFrames that asserted the Phase 1 reject — the positive tests above replace that coverage.

Added in daemon/internal/chain/state_test.go:

  • TestRollbackAfterCommitIsNoOp / TestCommitAfterRollbackIsNoOp / TestDoubleRollbackIsNoOp — pin the sync.Once-guarded idempotency that pipeline's deferred Rollback relies on.

Checklist

  • Tests pass for all changed components — daemon/internal/pipeline and daemon/internal/chain green with -race; full unit suite passes; cross-sdk-tests integration suite passes
  • Linter passes (go vet ./...)
  • No real keys or secrets in the diff
  • Cross-language tests pass (cross-sdk-tests go test -tags=integration ✓; no SDK hashing or canonicalization logic changed, daemon uses sdk/go/receipt.Canonicalize/SHA256Hash unchanged)
  • AGENTS.md updated — N/A (no project structure change)
  • Spec changes have been reviewed by a maintainer — N/A (no spec change; this enables existing parameters_hash / response_hash fields documented in spec/)

Pre-existing flake noticed (out of scope)

Five integration tests in daemon/integration_test.go and one in daemon/internal/socket/listener_test.go fail on macOS hosts whose t.TempDir() produces socket paths longer than the 104-byte AF_UNIX sun_path limit (/var/folders/.../events.sock is typically ~119 chars). Reproduces on main without these changes; passes on GitHub's macOS runner (shorter $TMPDIR). Worth a short follow-up to use a shorter path or honor a test override; not appropriate to fix in this PR's scope.

Phase 1 of the daemon (#322) deliberately rejected emitter frames that
populated `input` or `output` because the canonical-hash path was not
yet wired through `receipt.Create`. Section 3 of the thin-emitter
refactor cannot ship until those fields are accepted and committed to
the receipt — every emitter is supposed to send real tool I/O.

Lift the reject in `validateFrame`; in `buildAndSign`, hash a non-null
`f.Input` into `Action.ParametersHash` via a new `canonicalSHA256`
helper, and pass `f.Output` through `CreateInput.ResponseBody` so
`receipt.Create` populates `Outcome.ResponseHash` along the same path.
Both paths gate on `hasJSONPayload` so a literal JSON `null` (or
omitted field) still means "no payload" — hashing the literal "null"
would falsely commit the daemon to a value the emitter never sent.

Tests cover (a) input/output hashing matches independent
re-canonicalisation, (b) whitespace and key-order variants produce
identical hashes (cross-language determinism), (c) primitive and
array payloads are accepted, and (d) null input/output produces empty
hash fields. Removed three cases from `TestProcess_RejectsMalformedFrames`
that asserted the Phase 1 reject; the positive tests above replace
that coverage.

Closes #332. Unblocks #236 Section 3.

Pre-existing flake noticed: several daemon integration tests fail on
macOS hosts whose `t.TempDir()` produces socket paths longer than the
104-byte `AF_UNIX` `sun_path` limit (`/var/folders/...` is typically
119 chars). Reproduces on `main` without these changes; out of scope
for this PR.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables Phase 2 prerequisite behavior in the daemon pipeline: accept emitter input/output payloads (including primitives/arrays) and commit them into receipts via canonical (RFC 8785) SHA-256 hashes (action.parameters_hash, outcome.response_hash) while treating JSON null/omitted as “no payload”.

Changes:

  • Removes the Phase 1 validation hard-reject of non-null input/output frames.
  • Hashes input into Action.ParametersHash and wires output hashing through the receipt creation path.
  • Updates daemon docs and adds/adjusts pipeline tests for hashing, canonicalization invariants, primitives, and explicit null.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
daemon/README.md Updates the documented emitter frame example and clarifies input/output hashing + null-as-absent behavior.
daemon/internal/pipeline/build.go Accepts input/output, computes/stores parameters_hash, and attempts to populate response_hash from output.
daemon/internal/pipeline/build_test.go Adds tests asserting correct hashing, canonical stability, primitive support, and null yields empty hashes.

Comment thread daemon/internal/pipeline/build.go Outdated
Comment on lines 265 to 292
@@ -256,6 +288,7 @@ func (p *Pipeline) buildAndSign(
PreviousReceiptHash: alloc.PrevHash,
ChainID: p.State.ChainID(),
},
ResponseBody: responseBody,
})
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — verified: 1e400 is a syntactically valid JSON token (so it survives json.RawMessage storage on the initial EmitterFrame unmarshal) but errors when re-unmarshaled into any (overflows float64). Through receipt.CreateInput.ResponseBody that becomes a panic, and worse, pipeline.Process does not defer alloc.Rollback() — the panic also orphans the chain.State mutex, so even after a hypothetical recover the daemon would deadlock on the next frame.

Fixed in 0242e24: Output is now hashed via canonicalSHA256 directly (symmetric with Input), so buildAndSign returns a clean error → Process calls alloc.Rollback() → daemon stays up.

Added TestProcess_RejectsUnrepresentableNumbers covering 1e400 in both input and output positions; asserts no panic, returns error, chain not advanced.

(The lock-orphan-on-panic property in pipeline.Process is a separate hardening — defer alloc.Rollback() plus an idempotent rollback would close the door on any future panic source. Holding it as a follow-up unless you'd prefer it in this PR.)

Comment thread daemon/internal/pipeline/build.go Outdated
Comment on lines +168 to +172
// raw is expected to already be valid JSON — json.Unmarshal into EmitterFrame
// would have failed earlier otherwise — so the unmarshal here cannot fail
// in practice. We still surface the error rather than panicking; a daemon
// crash on a malformed inbound frame would be the wrong failure mode even
// for an "impossible" path.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — the comment overclaimed. json.RawMessage only validates the JSON token shape; numeric range/precision errors don't surface until re-unmarshal into a typed value. Reworded in 0242e24 to acknowledge that errors are real and expected on syntactically valid frames (with 1e400 as the canonical example), and that returning an error rather than panicking is the intended behavior — not just a defensive overhang.

Copilot caught a remote-DoS in PR #333: Output was routed through
receipt.CreateInput.ResponseBody, which panics if json.Unmarshal or
RFC 8785 canonicalisation fails. f.Output is emitter-controlled and
can be syntactically valid JSON yet still fail re-unmarshal — `1e400`
parses as a token but overflows float64. The panic crashed the daemon
AND orphaned the chain.State mutex (Process did not defer Rollback),
so even after a hypothetical recover the daemon would deadlock on the
next frame.

Stop using ResponseBody. Hash Output directly via canonicalSHA256 the
same way Input is hashed; both paths surface unmarshal/canonicalise
errors as buildAndSign error returns, which Process maps to
alloc.Rollback() and a clean per-frame failure.

Also reword canonicalSHA256's doc comment, which previously claimed
the unmarshal "cannot fail in practice" — wrong on syntactically
valid frames carrying out-of-range numbers.

New test: TestProcess_RejectsUnrepresentableNumbers covers `1e400`
in both input and output, asserts no panic and no chain advance.

Addresses PR #333 review by copilot-pull-request-reviewer[bot].
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread daemon/internal/pipeline/build.go Outdated
Comment on lines +164 to +167
// canonicalSHA256 unmarshals raw, canonicalises per RFC 8785, and returns the
// "sha256:<hex>" digest. Callers must check hasJSONPayload first; passing a
// null/empty value canonicalises to "null" and produces a misleading hash.
//
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, you're correct on both counts:

  • Empty RawMessagejson.Unmarshal on []byte{} returns io.EOF, never reaches canonicalize. (And the rewritten helper passes raw straight to receipt.Canonicalize, which surfaces that as a canonicalize: ... error rather than a misleading "null" hash.)
  • Literal null → would canonicalise to "null" and produce a hashable digest if it ever reached this function. Both cases are filtered by hasJSONPayload upstream, so neither path actually fires today, but the doc shouldn't conflate them.

Reworded in 4952b58 to call out both shapes explicitly and to credit the upstream hasJSONPayload gate.

Comment thread daemon/internal/pipeline/build.go Outdated
Comment on lines +171 to +174
// `1e400` is valid JSON syntax but overflows float64. The daemon MUST
// surface that as a per-frame error and keep running; a panic here would
// let any authenticated emitter DoS every other emitter on the same socket
// (and the orphaned chain.State allocation would leave the lock held).
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — folding the hardening into this PR.

Done in 9ba38ed:

  • chain.Allocation.Commit and Rollback are now idempotent via a shared sync.Once. Whichever runs first wins; subsequent calls are no-ops rather than unlock of unlocked mutex panics. Tests cover Rollback-after-Commit, Commit-after-Rollback, and double-Rollback.
  • pipeline.Process does defer alloc.Rollback() immediately after Allocate. On the success path Commit runs first and the deferred Rollback no-ops. On any error or panic the deferred Rollback releases the mutex.

I considered adding recover() → error in Process but decided against it: silently swallowing panics would mask real bugs from the listener-side error log, and a panic is already a clear crash signal. The mutex no longer leaks, which was the actual DoS vector.

New regression test TestProcess_PanicReleasesChainAllocation injects a KeySource that panics on Sign, expects Process to panic, then asserts the next state.Allocate succeeds within 2s rather than hanging.

Comment on lines +175 to +184
func canonicalSHA256(raw json.RawMessage) (string, error) {
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return "", fmt.Errorf("unmarshal: %w", err)
}
canonical, err := receipt.Canonicalize(v)
if err != nil {
return "", fmt.Errorf("canonicalize: %w", err)
}
return receipt.SHA256Hash(canonical), nil
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the redundant parse. Verified: json.RawMessage implements json.Marshaler and returns its bytes verbatim, so receipt.Canonicalize's internal marshalNoHTMLEscapejson.Unmarshal round-trip handles parsing without us needing to do an extra Unmarshal first.

Simplified in 4952b58canonicalSHA256 now passes raw straight into receipt.Canonicalize. One fewer unmarshal per hashed event in the hot path; same RFC 8785 behavior; same error shape (out-of-range numbers like 1e400 still surface as a per-frame error rather than panicking).

ojongerius added 2 commits May 7, 2026 08:14
pipeline.Process held the chain.State mutex from Allocate until either
Commit or Rollback. Any panic between those points (signer crash, store
transient, future receipt-build refactor) would leak the lock and
deadlock the daemon for every subsequent emitter on the same socket.
Copilot called this out as the still-open class of DoS the prior
output-hashing fix didn't fully close.

Two pieces:

1. Make chain.Allocation.Commit and Rollback idempotent via a shared
   sync.Once on the allocation. Whichever runs first wins; subsequent
   calls are no-ops rather than "unlock of unlocked mutex" panics.
   Tests cover Rollback-after-Commit, Commit-after-Rollback, and
   double Rollback.

2. In pipeline.Process, `defer alloc.Rollback()` immediately after
   Allocate. On the success path Commit runs first and the deferred
   Rollback no-ops. On any error or panic the deferred Rollback
   releases the mutex.

New regression test TestProcess_PanicReleasesChainAllocation injects a
KeySource that panics on Sign, expects Process to panic, and asserts
the next state.Allocate succeeds within 2s rather than hanging.

Addresses PR #333 review by copilot-pull-request-reviewer[bot].
json.RawMessage implements json.Marshaler and returns its bytes verbatim,
so receipt.Canonicalize already handles the parse via its internal
marshal+unmarshal. The explicit json.Unmarshal here was a wasted parse
on every hashed event in the daemon's hot path.

Pass raw straight into Canonicalize. Same RFC 8785 behavior, same error
shape (an out-of-range number like 1e400 still surfaces as an error
instead of a panic), one fewer unmarshal per frame.

Also tighten the doc comment per Copilot's other note: distinguish empty
RawMessage (Canonicalize returns EOF, but hasJSONPayload filters it
before we get here) from literal JSON null (canonicalises to "null" —
also filtered upstream). The previous wording conflated the two.

Addresses two PR #333 review threads from copilot-pull-request-reviewer[bot]
on commit 0242e24.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment on lines +282 to +286
// Hash Output here rather than via receipt.CreateInput.ResponseBody
// (which panics on bad JSON). f.Output is emitter-controlled and may
// be syntactically valid JSON yet still fail re-unmarshal — see
// canonicalSHA256 doc — so the daemon MUST surface that as an error,
// not a crash.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the PR description was stale — when I rewrote Output away from CreateInput.ResponseBody in 0242e24 to fix the DoS, I never updated the body. The implementation is the intended end state; description was wrong.

Updated the PR description to describe the actual symmetric canonicalSHA256 path for both Input and Output, plus an explicit note that ResponseBody was deliberately avoided because of the panic-on-bad-JSON behavior. Also folded in the panic-safety hardening that landed in 9ba38ed and rebuilt the Tests section to list the new regression tests.

@ojongerius ojongerius merged commit 7e4e835 into main May 7, 2026
14 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.

Phase 2 prereq: daemon must hash emitter input/output into receipts

2 participants