Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/crosswalks/acta-decision-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ 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"
},
"signature": {
"alg": "EdDSA",
"kid": "sb:issuer:QUGuJV1P6e6c",
"sig": "d709be9e8d907c14bd2005df05cc960b070d388a665964074a19a355a2eabb3d..."
"sig": "cf4665db8613b3b8b398fae6c1bacf88e4963d300af63cf709a2be1e8a6bda69..."
}
}
```
Expand Down Expand Up @@ -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"
}
Expand Down
4 changes: 2 additions & 2 deletions examples/action-receipts/acta/01-valid-accepted.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"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"
},
"signature": {
"alg": "EdDSA",
"kid": "sb:issuer:QUGuJV1P6e6c",
"sig": "d709be9e8d907c14bd2005df05cc960b070d388a665964074a19a355a2eabb3de14cb5cad89b533dc40089d50bd6f108dd1c3f7a78af588466227901f9ac4d04"
"sig": "cf4665db8613b3b8b398fae6c1bacf88e4963d300af63cf709a2be1e8a6bda69d7897a6e3671aaa242cd9d41dc310698206fc905670c4c4f1f60304eb3a5590c"
}
}
6 changes: 3 additions & 3 deletions examples/action-receipts/acta/02-valid-denied.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"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"
},
"signature": {
"alg": "EdDSA",
"kid": "sb:issuer:QUGuJV1P6e6c",
"sig": "51d482ae65587731d3398cbf3739baf5a573a2092bfad525cc055bd59c1c8b771180bfc62504221b42cd078636c8ff9ba7cf06190e7ce8eaaf21d870d4409100"
"sig": "7b66b3f2402542cc0cfa86791e1d99737b0b1e303acda3bda6dbd36b401b7e82e55928a0ca2ee1525205ebbf7d7376a25960fef69ee24f61ec91d8baa55b2a07"
}
}
6 changes: 3 additions & 3 deletions examples/action-receipts/acta/04-broken-chain.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"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"
},
"signature": {
"alg": "EdDSA",
"kid": "sb:issuer:QUGuJV1P6e6c",
"sig": "c453e538d8bd408854cc86c105421e72633e15b835bfe4a6673e73d4880c516ea8b87159feda531e607c6c9e60bf534f90b03cec679d49e7fe521b2e47dac306"
"sig": "b30a61782874309f174f0923ce9313fc840d78ccb674defc537240b4a998c1017fbda96fffc7ae26cc9eb3a53fbd625bd51897316f0c6f12d87f115f04bc0604"
}
}
31 changes: 20 additions & 11 deletions examples/action-receipts/acta/gen.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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');
Expand Down
58 changes: 46 additions & 12 deletions tests/test_acta_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading