diff --git a/docs/crosswalks/acta-decision-receipts.md b/docs/crosswalks/acta-decision-receipts.md index 55a54dd0..3d4f9f54 100644 --- a/docs/crosswalks/acta-decision-receipts.md +++ b/docs/crosswalks/acta-decision-receipts.md @@ -24,7 +24,7 @@ Per Acta s2.1, a receipt is a two-field envelope: a `payload` and a `signature` "type": "protectmcp:decision", "tool_name": "run_shell", "decision": "allow", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_8f31ab", "issued_at": "2026-07-08T09:00:01.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c" @@ -32,7 +32,7 @@ Per Acta s2.1, a receipt is a two-field envelope: a `payload` and a `signature` "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "d709be9e8d907c14bd2005df05cc960b070d388a665964074a19a355a2eabb3d..." + "sig": "cf4665db8613b3b8b398fae6c1bacf88e4963d300af63cf709a2be1e8a6bda69..." } } ``` @@ -68,7 +68,7 @@ Per the direction set on [trace-spec#97](https://github.com/agentrust-io/trace-s ```json { "evidence_type": "acta/decision-receipt-chain", - "chain_head": "99c36f17af17d48c2e6aab51ca21d9a08c53dcffbbb1f5b847c76fda9bf098bd", + "chain_head": "ddf7efb089f7504bbe880a623976b34be04b0bd4a991cdec12288841030229e4", "receipt_count": 2, "issuer_key_id": "sb:issuer:QUGuJV1P6e6c" } diff --git a/examples/action-receipts/acta/01-valid-accepted.json b/examples/action-receipts/acta/01-valid-accepted.json index cc43a221..0ca55ea8 100644 --- a/examples/action-receipts/acta/01-valid-accepted.json +++ b/examples/action-receipts/acta/01-valid-accepted.json @@ -3,7 +3,7 @@ "type": "protectmcp:decision", "tool_name": "run_shell", "decision": "allow", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_8f31ab", "issued_at": "2026-07-08T09:00:01.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c" @@ -11,6 +11,6 @@ "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "d709be9e8d907c14bd2005df05cc960b070d388a665964074a19a355a2eabb3de14cb5cad89b533dc40089d50bd6f108dd1c3f7a78af588466227901f9ac4d04" + "sig": "cf4665db8613b3b8b398fae6c1bacf88e4963d300af63cf709a2be1e8a6bda69d7897a6e3671aaa242cd9d41dc310698206fc905670c4c4f1f60304eb3a5590c" } } diff --git a/examples/action-receipts/acta/02-valid-denied.json b/examples/action-receipts/acta/02-valid-denied.json index f70a2e86..fd8262f4 100644 --- a/examples/action-receipts/acta/02-valid-denied.json +++ b/examples/action-receipts/acta/02-valid-denied.json @@ -3,16 +3,16 @@ "type": "protectmcp:decision", "tool_name": "delete_database", "decision": "deny", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_8f31ab", "issued_at": "2026-07-08T09:00:05.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c", "reason": "policy_block", - "previousReceiptHash": "8fea5801658968ceb360401caed3532f133e42b4b9fa726f4dcb9a030344f345" + "previousReceiptHash": "ba9b64ec5c13fc6f00ccb32c4a7a5da597a8cb49a1ca334d64ed0960d8bca639" }, "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "12f7f08171446124b74c7e0d3d434496db8551065ddc823903320753b2091691231f71b7d1499c5af4de07b3f84c3e055c1bb31b2a54a4815be91e98b2814602" + "sig": "e950d938d9dd2d8d810260b1770bb2ec04926c6745481b080330e55a635c666fbe5df0566796d5ba222398f54978100ca00843afb011f060aca6bcf13b8bd00a" } } diff --git a/examples/action-receipts/acta/03-signature-key-mismatch.json b/examples/action-receipts/acta/03-signature-key-mismatch.json index 6c11245d..eef27a0c 100644 --- a/examples/action-receipts/acta/03-signature-key-mismatch.json +++ b/examples/action-receipts/acta/03-signature-key-mismatch.json @@ -3,7 +3,7 @@ "type": "protectmcp:decision", "tool_name": "run_shell", "decision": "allow", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_8f31ab", "issued_at": "2026-07-08T09:00:01.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c" @@ -11,6 +11,6 @@ "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "51d482ae65587731d3398cbf3739baf5a573a2092bfad525cc055bd59c1c8b771180bfc62504221b42cd078636c8ff9ba7cf06190e7ce8eaaf21d870d4409100" + "sig": "7b66b3f2402542cc0cfa86791e1d99737b0b1e303acda3bda6dbd36b401b7e82e55928a0ca2ee1525205ebbf7d7376a25960fef69ee24f61ec91d8baa55b2a07" } } diff --git a/examples/action-receipts/acta/04-broken-chain.json b/examples/action-receipts/acta/04-broken-chain.json index 3af53701..c5731e03 100644 --- a/examples/action-receipts/acta/04-broken-chain.json +++ b/examples/action-receipts/acta/04-broken-chain.json @@ -3,15 +3,15 @@ "type": "protectmcp:decision", "tool_name": "read_file", "decision": "allow", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_8f31ab", "issued_at": "2026-07-08T09:00:09.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c", - "previousReceiptHash": "8fea5801658968ceb360401caed3532f133e42b4b9fa726f4dcb9a030344f345" + "previousReceiptHash": "ba9b64ec5c13fc6f00ccb32c4a7a5da597a8cb49a1ca334d64ed0960d8bca639" }, "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "f0df4f88a5bae5c1bf07b1fed55d78bf4e5b35e9edef1cb17619cf0a6a9a6627ffee7c4ba7fd61a8ea55599a9ca0b81ba6c8304dc926be070b0935be6202b105" + "sig": "8dc6e3859e141ea751e33508d205b1b7985356c27e2325248090c3a62045f6178d3f565ad3a294fbc9966977cdce8cbb82743a3b089e252e5554ed22227e7b0d" } } diff --git a/examples/action-receipts/acta/06-session-binding-mismatch.json b/examples/action-receipts/acta/06-session-binding-mismatch.json index c25e8b8f..c756076c 100644 --- a/examples/action-receipts/acta/06-session-binding-mismatch.json +++ b/examples/action-receipts/acta/06-session-binding-mismatch.json @@ -3,7 +3,7 @@ "type": "protectmcp:decision", "tool_name": "run_shell", "decision": "allow", - "policy_digest": "sha256:b5af974ae7e1c6e4a656c9637c68ec0755a6cffa7861e196c0aaa2d6a7874cb5", + "policy_digest": "sha256:bda8d8f60c90afe5c05fc7f3b1b53d4715a0f7ba72ec8d4b82ad7aba70d9609b", "session_id": "ses_2c91d4", "issued_at": "2026-07-07T14:30:02.000Z", "issuer_id": "sb:issuer:QUGuJV1P6e6c" @@ -11,6 +11,6 @@ "signature": { "alg": "EdDSA", "kid": "sb:issuer:QUGuJV1P6e6c", - "sig": "c453e538d8bd408854cc86c105421e72633e15b835bfe4a6673e73d4880c516ea8b87159feda531e607c6c9e60bf534f90b03cec679d49e7fe521b2e47dac306" + "sig": "b30a61782874309f174f0923ce9313fc840d78ccb674defc537240b4a998c1017fbda96fffc7ae26cc9eb3a53fbd625bd51897316f0c6f12d87f115f04bc0604" } } diff --git a/examples/action-receipts/acta/gen.mjs b/examples/action-receipts/acta/gen.mjs index 0bd59c3a..5ca7fac5 100644 --- a/examples/action-receipts/acta/gen.mjs +++ b/examples/action-receipts/acta/gen.mjs @@ -70,12 +70,12 @@ function envelope(payload, privHex) { // s5.7: chain hash covers the ENTIRE envelope, signature included. const envelopeHash = (env) => sha256hex(canonicalBytes(env)); -function decisionPayload({ tool, decision, reason, sessionId, issuedAt, prevHash }) { +function decisionPayload({ tool, decision, reason, sessionId, issuedAt, prevHash, policyDigest = POLICY_V2 }) { const p = { type: 'protectmcp:decision', tool_name: tool, decision, - policy_digest: POLICY_V1, + policy_digest: policyDigest, session_id: sessionId, issued_at: issuedAt, issuer_id: KID, @@ -120,6 +120,7 @@ const r04 = envelope(decisionPayload({ const r05 = envelope(decisionPayload({ tool: 'run_shell', decision: 'allow', sessionId: SESSION, issuedAt: '2026-07-08T09:00:12.000Z', + policyDigest: POLICY_V1, // the only fixture carrying the superseded policy }), SIGNER_PRIV); // 06: validly signed, but bound to a different session than the one the @@ -152,9 +153,7 @@ const expected = { '05-stale-policy-digest.json': { signature: 'pass', chain: 'n/a', policy_freshness: 'fail', session_binding: 'pass' }, '06-session-binding-mismatch.json':{ signature: 'pass', chain: 'n/a', policy_freshness: 'pass', session_binding: 'fail' }, }, - note: 'policy_freshness for 01/02/04/06 is pass relative to their v1-era chain context in the docs narrative; see README. Freshness is only asserted as the DISTINGUISHING failure for 05.', }; -// Simplify: freshness comparisons below only assert the manifest values. // --- Self-check every fixture against the manifest before writing --- const verifySig = (env, pubHex) => { @@ -181,19 +180,29 @@ for (const [name, env] of Object.entries(fixtures)) { console.log(`${name}: signature=${sig} chain=${chain} structural=${ok ? 'ok' : 'BAD'}`); if (!ok) failures++; } -// distinguishing checks for 05 / 06 -const fresh05 = fixtures['05-stale-policy-digest.json'].payload.policy_digest === expected.current_policy_digest; -const bind06 = fixtures['06-session-binding-mismatch.json'].payload.session_id === expected.expected_session_id; -console.log(`05 policy freshness (expect false): ${fresh05}`); -console.log(`06 session binding (expect false): ${bind06}`); -if (fresh05 || bind06) failures++; +// Freshness and binding are checked for EVERY fixture against the manifest, not +// only for the two that are meant to fail them. Asserting the failure alone +// passes whether one fixture is stale or all six are, which is how every fixture +// came to carry the same policy_digest while four were declared to pass. +for (const [name, env] of Object.entries(fixtures)) { + const exp = expected.results[name]; + for (const [axis, actual] of [ + ['policy_freshness', env.payload.policy_digest === expected.current_policy_digest ? 'pass' : 'fail'], + ['session_binding', env.payload.session_id === expected.expected_session_id ? 'pass' : 'fail'], + ]) { + if (exp[axis] === 'n/a') continue; // 03 fails signature; nothing downstream is asserted + if (actual !== exp[axis]) { + console.error(`${name}: ${axis} is ${actual}, manifest declares ${exp[axis]}`); + failures++; + } + } +} if (failures) { console.error('SELF-CHECK FAILED'); process.exit(1); } mkdirSync('out', { recursive: true }); for (const [name, env] of Object.entries(fixtures)) { writeFileSync(`out/${name}`, JSON.stringify(env, null, 2) + '\n'); } -delete expected.note; writeFileSync('out/expected.json', JSON.stringify(expected, null, 2) + '\n'); writeFileSync('out/signer-public-key.txt', SIGNER_PUB + '\n'); writeFileSync('out/mismatched-signer-public-key.txt', MISMATCH_PUB + '\n'); diff --git a/tests/test_acta_fixtures.py b/tests/test_acta_fixtures.py index b1ffbd14..011b9f1b 100644 --- a/tests/test_acta_fixtures.py +++ b/tests/test_acta_fixtures.py @@ -90,20 +90,54 @@ def test_chain_link_result_matches_expected(name): assert actual == EXPECTED["results"][name]["chain"] -def test_stale_policy_digest_is_valid_but_stale(): - env = _load("05-stale-policy-digest.json") - assert _signature_verifies(env), "05 must pass signature verification" - assert env["payload"]["policy_digest"] != EXPECTED["current_policy_digest"], ( - "05 must fail the policy-freshness comparison" - ) +@pytest.mark.parametrize("name", sorted(FIXTURES)) +def test_policy_freshness_result_matches_expected(name): + """Every fixture's declared freshness, not only the one meant to fail it. + + Asserting the failure alone passes whether one fixture is stale or all of them + are, which is how the set came to share a single ``policy_digest`` while four + fixtures were declared to pass a comparison they failed. + """ + declared = EXPECTED["results"][name]["policy_freshness"] + if declared == "n/a": + return + env = _load(name) + fresh = env["payload"]["policy_digest"] == EXPECTED["current_policy_digest"] + assert ("pass" if fresh else "fail") == declared -def test_session_binding_mismatch_is_valid_but_misbound(): - env = _load("06-session-binding-mismatch.json") - assert _signature_verifies(env), "06 must pass signature verification" - assert env["payload"]["session_id"] != EXPECTED["expected_session_id"], ( - "06 must fail the session-binding comparison" - ) +@pytest.mark.parametrize("name", sorted(FIXTURES)) +def test_session_binding_result_matches_expected(name): + declared = EXPECTED["results"][name]["session_binding"] + if declared == "n/a": + return + env = _load(name) + actual = "pass" if env["payload"]["session_id"] == EXPECTED["expected_session_id"] else "fail" + assert actual == declared + + +def test_the_two_distinguishing_fixtures_are_otherwise_valid(): + """05 and 06 must isolate their own axis: signature passes, one comparison fails.""" + for name in ("05-stale-policy-digest.json", "06-session-binding-mismatch.json"): + assert _signature_verifies(_load(name)), f"{name} must pass signature verification" + + +def test_crosswalk_document_quotes_the_live_fixture_values(): + """The crosswalk embeds real digests and says so; regenerating must not falsify that. + + ``docs/crosswalks/acta-decision-receipts.md`` quotes 01 in full and states that the + ``chain_head`` it shows "is real". Both go stale the moment the fixtures are + regenerated, and nothing else would notice. + """ + doc = (Path(__file__).resolve().parents[1] / "docs" / "crosswalks").joinpath( + "acta-decision-receipts.md" + ).read_text(encoding="utf-8") + r01 = _load("01-valid-accepted.json") + chain_head = hashlib.sha256(_jcs(_load("02-valid-denied.json"))).hexdigest() + + assert r01["payload"]["policy_digest"] in doc, "the quoted 01 payload is stale" + assert r01["signature"]["sig"][:64] in doc, "the quoted 01 signature is stale" + assert chain_head in doc, "the quoted chain head is not 02's current envelope hash" def test_key_mismatch_fixture_is_signed_by_the_committed_second_key():