Add AI Agent Action predicate type (v0.1) - #588
Conversation
astrogilda
left a comment
There was a problem hiding this comment.
The Trust boundary section does something most predicate submissions never do, which is state plainly what the attestation cannot prove. "This predicate provides tamper evidence against third parties who do not hold the signing key. It does not provide non-repudiation against the signing entity itself" is the sentence I want every predicate in this directory to be forced to write, and the principal caveat directly under it is the right instinct. The Subject semantics section is a real contribution too: a stable chain identifier in the subject digest is a good answer to the question of what a subject means when there is no build artifact, and it is worth keeping. Everything below is an attempt to get the rest of the document up to the standard those two sections set, because the Use Cases section promises more at v0.1 than the schema delivers, and the gap is checkable.
I read spec/predicates/ai-agent-action.md at 707bdabf rather than the PR body. Every case below is a payload you can run.
The chain does not detect truncation, but Use Cases says it does. The incident-investigation case states that "The hash-chained structure additionally proves no records were removed between the triggering event and the investigation." Build a seven-record chain, apply every check the v0.1 text names, then hand an investigator only the first four records:
Full chain, 7 records: list_repos, read_file, read_file, create_pull_request, delete_branch, exfil_secrets, wipe_audit_config
verify(full) -> (True, 'all checks pass')
Records 1..4 presented:
tools presented -> ['list_repos', 'read_file', 'read_file', 'create_pull_request']
tools SUPPRESSED -> ['delete_branch', 'exfil_secrets', 'wipe_audit_config']
verify(truncated) -> (True, 'all checks pass')
Every previousHash links, the sequence is monotonic from 1, the subject digest is identical across both sets, and the policy you describe, all attestations where subject digest equals X, collects the truncated set and cannot tell that it is incomplete. Nothing carries the chain length, the head, or a signed checkpoint, so a verifier has nothing to compare against. A backward hash chain proves prefix integrity; it cannot prove suffix completeness, and the three most interesting calls in that chain are exactly the ones at the end. Your Chain initialization section already knows this, since it says deployments SHOULD publish chain heads to an append-only transparency log or similar external witness. The problem is that Use Cases states as proven what Chain initialization downgrades to a recommendation, and a compliance reader will read the first section and never reach the third. I would either make the external witness a MUST for the incident-investigation claim, or add an optional chain.head or checkpoint field a verifier can demand, or soften the sentence to say the chain proves no records were removed from within the range presented. Any one of the three closes it. The current text is the one combination that does not.
Two of the three agent fields are supplied by the party under audit, and only one of them says so. Your abstract's load-bearing claim is that the entity writing the log must not be the entity whose behavior needs verification. The signature moves to the intermediary, which is right, but the field contents still originate upstream, and the document flags this for principal only. I checked what an intermediary observing tools/call can actually see, reading the MCP schema rather than going from memory. CallToolRequest.params is exactly name plus optional arguments (schema.ts:852-858), with no model, no session and no principal. The clientInfo object is name, optional title, and version (schema.ts:331-333), with no model. The only model field anywhere on the wire belongs to CreateMessageResult (schema.ts:1076), which is part of sampling/createMessage, a different method, and is the client's own self-report in any case. And Mcp-Session-Id is assigned by the server at initialization and exists only on the Streamable HTTP transport (transports.mdx:177).
Your own example sets "transport": "stdio" alongside a model of gpt-4o-2025-03-15 and a sessionId of conv_abc123. Under stdio there is no session header and no model field on the wire, so both of those values in the published example had to be supplied by the observed party. Two statements signed by the same honest intermediary, differing only in those two fields, both verify:
What actually ran : {'principal': 'user:alice@example.com', 'model': 'claude-opus-4-20260514', 'sessionId': 'conv_REAL_9f31'}
What is attested : {'principal': 'user:alice@example.com', 'model': 'gpt-4o-2025-03-15', 'sessionId': 'conv_abc123'}
Fields differing : ['model', 'sessionId']
An auditor who filters for every action taken by GPT-4o gets an answer the agent chose. The cheap fix is to extend the principal caveat to cover agent.model and agent.sessionId verbatim. The better fix, and I think the one that would make this predicate distinctive, is to mark provenance per field, with an enum such as observed or declared on each agent member, so that a verifier can mechanically separate what the intermediary saw from what it was told. That is a small schema change and it turns the document's central argument into something a policy engine can act on rather than something a reader has to remember. It would also give the transport field real work to do, since declared is the only honest value for a session identifier under stdio while observed is available under Streamable HTTP.
The word canonical appears once in the document and is never defined. Grep for it and the neighbouring concepts:
$ grep -n "canonical\|8785\|serializ\|I-JSON\|depth" spec/predicates/ai-agent-action.md
238:| `request` | DigestSet | No | Digest of the canonical tool call request |
One hit, no definition, and no serialization rule anywhere in the 303 lines, so contentDigest binds to nothing reproducible at v0.1. Here is one real tools/call response and four readings of it that the text all permits:
full JSON-RPC envelope sha256=2ce4050217360d2341204536853c8b70297d7526ea9aedaaeaff1a61206f4c15
result member only sha256=48b607521881fe2b3e44d5ca2ec2cd50421767c0f3662a300e8b8bca4a3a9132
result.content array sha256=3b7412ba2411396f9a6dba244a04a7f075447b36c80f3bae223437e0b0c7bdbc
content[0].text (raw) sha256=e7eb308e8e6fe50aa5646b4427c7a5b30b241336a33b2789181e220d2c44d728
And the same result object under two serializations, both of which an honest implementer picks without violating a word of the spec:
same 'result', compact sha256=48b607521881fe2b3e44d5ca2ec2cd50421767c0f3662a300e8b8bca4a3a9132
same 'result', indented sha256=80c7801f1f065e1a5db81d9c3f3e152265d2467d2b958d8821457819b78a5c44
DIVERGE: True
Two conforming intermediaries observing the identical byte stream produce different contentDigest.response values, and a consumer holding the actual response bytes cannot decide whether a mismatch means tampering or means the other implementer read the sentence differently. The same ambiguity is worse on previousHash, which the field table defines as the SHA-256 hex of the preceding attestation without saying whether that is the Statement, the DSSE envelope, the envelope's payload, or the base64 of the payload. That one is load-bearing, because it is the tamper-evidence mechanism itself, and two implementations that choose differently build chains neither can verify against the other. Naming RFC 8785 and stating exactly which bytes are hashed for previousHash, contentDigest.request and contentDigest.response fixes all three in about five sentences.
Unbounded integers also split the ordering primitive. The sequence number is typed as an integer with no range, and verifiers are told to use it for ordering, but JSON numbers above 2^53 do not survive a double-based reader:
wire bytes : {"chain":{"previousHash":"3fdb...","sequenceNumber":9007199254740993}}
Go/Python int64 reader: 9007199254740993
JS/double reader : 9007199254740992
DIVERGE: True
Two verifiers order the same chain differently, and durationMs and errorCode have the same shape. The RFC 7493 I-JSON safe-integer profile, rejecting magnitudes at or above 2^53, closes this in one line.
There is no depth bound either, and the extension allowance is the vector. Your parsing rules say producers MAY add extension fields using field names unlikely to collide with names other producers use, and no depth limit is stated anywhere. A consumer has to parse a field to ignore it, and an intermediary computing a chain hash has to serialize the whole statement:
depth= 1000 bytes= 2075 -> parsed OK 3.1 ms
depth= 50000 bytes= 100075 -> RecursionError (interpreter stack exhausted) 12.5 ms
depth= 200000 bytes= 400075 -> RecursionError (interpreter stack exhausted) 7.3 ms
400 KB of input takes down a recursive-descent parser, which is most of them. A stated bound matters for a second reason beyond resource exhaustion: with no bound in the text, implementations pick their own, and two conforming verifiers then disagree about whether identical bytes are evidence at all across the whole range between their choices. If you want a number and a counting rule to lift directly, #570 states depth 128 as normative, counting open arrays and objects with the outermost brace as depth 1, and spells out why the counting rule has to be written down, since implementations that increment per parsed value rather than per open container land one level apart from an identical constant. Take that or set your own, but set one.
The subject digest is also not independently computable, which is the justification the section rests on. Subject semantics argues that the DigestSet specification allows any immutable identifier that a verifier can independently compute, and that the genesis hash satisfies this. But the genesis record is never defined: no fields, no serialization, no procedure. Hand me the digest and I have no way to check it, so I can only accept it, which is not what a sha256 key promises a verifier. spec/v1/digest_set.md says each entry MUST be an immutable reference to an artifact, and a genesis record qualifies as an artifact, but only once its bytes are pinned. Either define the genesis record and its canonicalization, or use an extension key under the non-cryptographic immutable digest guidance that document already provides, which signals correctly that the value is an identifier rather than a hash the verifier is expected to recompute.
One small related thing, offered only because it points at the same gap: four of the five digests in the example are trivial placeholders.
subject.digest (chain genesis) b5bb9d80... preimage = 'foo\n'
contentDigest.request 7d865e95... preimage = 'bar\n'
contentDigest.response 2cf24dba... preimage = 'hello'
metadata.configHash 9f86d081... preimage = 'test'
That is completely normal in a v0.1 draft and I mention it only because a worked example with real values, where the genesis hash is actually the hash of a defined genesis record and the response digest is actually the digest of the response shown, would answer the canonicalization question and the computability question at once. I think it is the highest-leverage edit in the document.
There is one question a maintainer is going to ask, so here it is early. The README change adds this predicate under the Vetted Predicates heading, which that file describes as predicate specification types that have gone through our vetting process. A v0.1 with a single implementation has not been through it, and I do not think that is what you meant to claim; more likely the list is the only list, so there was nowhere else to put it. I have an open proposal at in-toto/ITE#63 that exists precisely because that gap has no defined entry rule, and applying its criterion to your submission is useful to you rather than a hurdle. Under it every submission lands in contrib by default and is listed there as a full-status home, so nothing about this PR is blocked. Vetted additionally requires a conformance corpus with negative controls, an implementation built from the specification text by someone other than its author, and published run results including at least one blind run. As written, this submission has none of the three yet, which places it in contrib cleanly and gives you a concrete path rather than an open-ended review.
Rather than asking you to do that work, here is what I will do. I have a 250-vector conformance corpus and a verifier for another predicate at https://github.com/astrogilda/aee-conformance, with 55 accept, 193 reject and 2 indeterminate vectors, and a manifest carrying the corpus digest and the specification commit. The reject vectors are where the value is, because a corpus of valid examples only shows that the author can write one valid document, and what a reviewer learns from a corpus is what the specification refuses. If it is useful I will write the same shape for this predicate and send it as a PR to your branch, or wherever you prefer: the truncation case, the declared-versus-observed pairs, the serialization-divergence pair, the safe-integer split, and the depth cases, each labelled MUST-ACCEPT or MUST-REJECT with the clause that rejects it. That happens to satisfy the third door in ITE#63, a corpus written by someone other than the specification's author and run against the author's own implementation, so it would move you toward vetted at the same time. No obligation either way, and I am happy to file the vectors as an issue instead if you would rather keep this PR narrow.
For what it is worth, #570 is my own predicate in this directory, and its canonicalization section, meaning the RFC 8785 binding, the I-JSON profile, the depth bound and the counting rule, is directly liftable. Take any of it that helps rather than deriving it again. Two predicates in this registry disagreeing about which bytes get hashed would be a bad outcome for both of us.
Sankalp Gilda
|
Really appreciate you putting this much time into it. Every case runs and I can't argue with any of them. This is going to make the predicate significantly better. The truncation finding caught me off guard. I wrote the use case thinking backward chaining covered completeness, and your seven-record chain makes it obvious it doesn't. Present records 1 through 4 and everything looks fine. Every previousHash links up, sequence numbers go up, subject matches. Meanwhile the three most interesting calls just sit beyond the horizon and nobody knows they're missing. The chain proves nothing was altered or reordered in the window you're shown. It just can't tell you the window is short. I'll soften the claim to "proves no records were removed or reordered within the presented range." What I'll do is add an optional The canonicalization examples were the ones that made me realize how underspecified this was. I'm going to lift your RFC 8785 binding from #570 directly rather than reinvent it. Two predicates in the same registry disagreeing about which bytes get hashed would be bad for both of us. I'll attribute it. If you'd rather co-author that section, happy to do it that way instead. One thing I want to settle now rather than discovering it in the commit. For The provenance suggestion is what I keep thinking about. Before I build it I want to ask about shape. I can see three ways to do this. Sibling fields like The published example needs fixing too. I hadn't thought about it until your wire-level analysis, but under stdio there's no session header and no model field on the wire. So both values in that example can only be Integer bounds will be I-JSON with the 2^53 ceiling stated explicitly. Depth 128, your counting rule from #570. Moving to contrib in the first commit. You're right it ended up under vetted because the list had no other home, not because I thought it passed vetting. On the conformance corpus, yes please. PR against this branch whenever you're ready. The truncation vectors, the serialization-divergence pairs, the declared-vs-observed cases. Those are exactly what I wouldn't think to write against my own spec, which is the whole point. If it's useful going the other way I'd be happy to write vectors for #570 too. Two predicates with cross-authored conformance suites feels like the right state for the registry. Plan is straightforward fixes first (contrib move, integers, depth, truncation softening, chain.head), canonicalization plus genesis as a second batch since one depends on the other, then provenance last once we've settled on shape. |
3ba4a12 to
639ec56
Compare
|
Appreciate the review. All findings addressed in the latest commit. The truncation gap was the one that kept nagging at me. I ended up promoting checkpoint and chain_break to first-class record types, each with their own action.type enum value and sub-schema. The use case section now says the quiet part out loud: you can detect drops between externalized checkpoints, but you cannot tell whether it was malice or a crash. Anything emitted after the last externalized checkpoint sits in an unprotected window until the next one lands. I wanted that stated plainly rather than left for someone to figure out the hard way. On provenance: Canonicalization took the longest to get right. The problem that forced the split is simple once you see it: MCP tool payloads are arbitrary JSON, and arbitrary JSON can carry floats. The signing canonical form rejects floats by design. So you literally cannot use the same canonical form for content digests. I split it: signing stays as tuple-array (M/L-tagged, integer-only, surrogate-rejecting) for chain integrity. Content digests use RFC 8785 JCS directly, which handles floats and aligns with what #570 proposes. Two forms, clearly scoped, can't be mixed. Happy to lift your #570 canonicalization section text verbatim if that's cleaner for registry consistency. Same forcing function either way. Safe integers and depth: I-JSON bound on all integer fields (< 2^53, RFC 7493 §2.2), ~29 million years before the checkpoint sequence overflows at 10/sec. Extensions depth capped at 128 levels with the counting rule from #570 (outermost brace = depth 1, each nested open container increments). Reject without parsing past the bound. Genesis and chain breaks: the implementation uses the literal string "genesis" as previousHash for the very first record only. After a chain_break, the successor carries the break record's chain hash as its previousHash, which binds the discontinuity evidence into the new chain. Discarding the break record breaks hash linkage. A second "genesis" appearing anywhere in the log is what's suspicious. This is stronger than "genesis-after-break" because an adversary can't remove the scar without detection. The worked example I actually feel good about now. Every digest has a shown preimage. You can feed them to sha256 and check. The subject digest comes from the underlying JSONL record (printed in full). Content digests show the exact JCS strings. configHash shows the policy JSON. The HMAC attestation is reproducible with the stated trivial key (32 zero bytes, so anyone can verify without needing a secret). The toy chain runs end-to-end: record -> checkpoint -> chain_break, each hash derivable from shown bytes, with the break record's successor linkage demonstrating the corrected chain-from-break rule. Moved the README listing to community contributions per ITE-63. Not vetted, shouldn't pretend to be. Both offers: yes. Send the negative-vector corpus PR against this branch whenever you're ready. Non-author test corpus against the implementation is the third door of ITE-63 for moving toward vetted status. I'll turn that review around fast. And yes to lifting the #570 canonicalization text for the content-digest section. Reference by citation or inline, either works. Your call. Your line-level comments from the original review are preserved against the first commit. |
|
The chain forks, and nothing the round-2 commit added catches it. I went back at 639ec56 to break what is there now, and this is the one that worked best. Nothing in the text makes the predecessor relation injective, so two records may carry the same previousHash. I built a four-record chain whose second and third records are read_secrets and exfiltrate_env. I built a second record that also chains from record one and carries create_pull_request. Then I presented a two-line log holding record one and that second record. Every previousHash resolves, and no hash is broken. The literal genesis appears exactly once, so the second-genesis rule sees nothing. The subject digest is the genesis record's hash, which is byte-identical on both branches. A policy targeting the chain by subject digest cannot tell the honest chain from the branch I show. No gap exists for a checkpoint to catch: the presented branch is internally complete. This is not a truncation, it is a different history that shares a prefix. The fix is two sentences. Say that exactly one record may carry any given previousHash. Say that a verifier reconstructs the chain as a strict walk from genesis, not by checking that each previousHash appears somewhere in the presented set. Set-membership verification is the natural thing to write, and it accepts the fork. The strict walk plus injectivity does not. Both files are in the suite as bad-106, with the honest chain and the presented chain side by side. The chain hash is the only hashed byte string in the predicate that is not canonicalized. You pinned the signing form as a tuple array with M and L tags and UTF-16 member sorting. You pinned content digests to RFC 8785. The chain hash is SHA-256(JSON.stringify(record)), and it is what makes this a chain and what the subject digest derives from. JSON.stringify is not a canonical form. ECMAScript hoists canonical numeric property names to the front of an object in ascending numeric order, whatever the insertion order. So an extensions object whose members are 10, 2, aa, zz serializes as 2, 10, zz, aa under node. A Python gateway preserving insertion order gives 10, 2, zz, aa. Go, whose encoding/json sorts map keys, gives 10, 2, aa, zz. I ran that through a real node process rather than emulating it, and got three different chain hashes for one observation. No implementation is doing anything wrong. String escaping splits the same way. Python's json.dumps escapes non-ASCII by default. Go's encoding/json escapes the angle brackets and ampersand by default. So a tool named with an accented character, or containing a bracket, produces four candidate byte strings across three languages. Those are bad-101 through bad-103. A smaller ambiguity in the same paragraph is the more dangerous one operationally. The text says both SHA-256(JSON.stringify(record)) and "the complete JSON-serialized audit record as written to the JSONL log". Those are different operations. One re-serializes a parsed object, the other is the bytes on disk, and they coincide only by accident. Any log shipper that reparses and re-emits JSON moves a verifier from one reading to the other. That is what fluentd, vector and logstash all do. Every field value stays identical, so a chain that verified before the pipeline fails after it, and nobody can tell whether that means tampering. Close it by making the producer write the line as the canonical bytes. Make the verifier recompute the canonical form from the parsed record and reject when the line's bytes differ. That makes the two readings the same reading by construction. It is bad-104. There is no statement-wide I-JSON rule, so a record can carry toolName twice. A first-wins reader shows an auditor read_file while the committed hash is over a record naming delete_repository. Both readers believe the chain is intact. Under the re-serialize reading of the preimage, those bytes hash identically to a clean record naming the destructive call. That is two logical records with one hash, not one record with two hashes. It needs its own rule and not just a canonical form, and it is bad-105. The checkpoint and chain_break record types are a real improvement. Promoting them to first-class action types with their own sub-schemas beats the optional chain.head field you floated last round. You say out loud that you can detect a drop between externalized checkpoints but cannot attribute it. You say that anything after the last externalized checkpoint sits in an unprotected window. Those two sentences make a predicate usable rather than finished-sounding. The round-2 checkpoint change is still one sentence short of working. The chain_break section says explicitly that the record after the break carries the break record's chain hash. Nothing says the equivalent for a checkpoint, and I checked that rather than assuming it. The phrase "carries the break record's chain hash" occurs twice in the document, and the corresponding phrase for a checkpoint occurs zero times. The checkpoint schema carries its head in predicate.checkpoint.previousHash, not in the predicate.chain.previousHash that the fields table defines as the chain link. Your worked example does chain through the checkpoint, so the implementation is doing the right thing. But a verifier written from the fields table walks predicate.chain.previousHash and steps straight past every checkpoint. A checkpoint can therefore be deleted without breaking any documented linkage. The checkpoint is the entire mechanism standing between this predicate and silent tail truncation. So one sentence is worth adding: every record type carries predicate.chain.previousHash, and the successor of a record of any type carries that record's chain hash. It is bad-107. Four smaller ones follow. The Constraints on both forms block says numbers must be safe integers in both forms. The content digest subsection says the second form exists because MCP payloads can contain floats that the first rejects. Those two sentences sit in one subsection and contradict, so one implementer rejects the payload another accepts. Scope the safe-integer profile to the signing form and the record form, and say floats are permitted in content payloads and only there. The response content digest names the JSON-RPC result member, but an error response has no result member. Every record with success false is an error response. So the digest has no defined preimage on exactly the half of the audit trail an investigator reaches for first. I counted four plausible readings: omit the field, digest null, digest an empty object, digest the error member. They give four different digests, so it needs a named preimage, and my suggestion is the error member. The spec types previousHash as string with no case, no length and no algorithm, so uppercase hex and a forty-character digest are both admissible. A verifier that folds hex case treats two distinct byte strings as one link, while their two successors hash differently. Lowercase 64-hex or the literal lowercase genesis, and nothing else, closes it. Second-genesis detection is a SHOULD, so a verifier that ignores a second genesis is conformant. A detection obligation a verifier may decline is not much of a defence against an adversary who chooses which verifier to present to. One more is not a payload but blocks the vectors. The signing form says the field order is fixed by the implementation and matches the type definition. The tagging rules, the sort order and the tags are all written down, and they are enough to reproduce the encoding. But the list of fields the tuple contains lives in a type definition the specification does not carry. A second implementer can therefore get the encoding exactly right and still reproduce no signature. No conformance vector can be written for that form either, because a vector needs a preimage the text determines. Enumerating the field list in the spec is the whole fix. Three things I tried and could not break, because they say what the design gets right. Deleting predicate.chain from a mid-chain record to detach it does not work. The chain hash covers the complete serialized record rather than a field subset. Removing the object changes that record's own hash, and the successor stops resolving. Hashing the whole record rather than a chosen subset is the design's strongest property, and I would not trade it away. An unpaired surrogate in a signed string is genuinely shut by your own text. The chain_break successor rule does what you said it does. The successor chains from the break record's hash rather than from genesis, so discarding the scar breaks linkage. That beats a rule that only looks for a second genesis. The narrower string gaps that #570 also closes are still open: noncharacters, overlong UTF-8, raw control characters and a permissive four-hex-digit escape parser. They are a profile away rather than a redesign. On lifting the #570 canonicalization text, take it, and I would rather you took it than co-authored it. Two predicates in one registry disagreeing about which bytes get hashed is the outcome neither of us wants. The fastest way to avoid it is one of us copying the other verbatim. I have written the adapted version out as spec prose, not as a description of what it should say. It is at docs/ai-agent-action-canonicalization.md in the suite below, sized to replace your Canonicalization and Genesis sections. Attribute it however you like, or not at all. What I changed in the adaptation: #570 canonicalizes a Statement, while yours canonicalizes an underlying gateway record that lives outside the Statement. So the identical-bytes requirement becomes a relationship between the log line and the recomputation, rather than a property of one document. The injective-predecessor, single-head and checkpoint-linkage rules are new here, because #570 has no chain. The depth bound, safe-integer profile, duplicate-member rule and well-formed-string rule carry over close to verbatim, and deliberately so. You already cite #570 for the depth counting rule, and the wording should not fork between two predicates in one registry. To your two direct questions. For previousHash I would hash the canonical form of the audit record including its attestation member, not the DSSE payload bytes. Your instinct there is right. Hashing the payload before base64 makes the chain depend on the envelope layer for no gain. Hashing the full envelope makes it depend on signature encoding, which changes when you rotate algorithms. For contentDigest I agree: the result member is the right scope for a success response, and the error member for a failure. Your reason holds, that the envelope is transport overhead and the inner text loses structure. On witness and asserter versus observed and declared I have no strong view either, and I would keep the strings your implementation already emits. The suite is 27 vectors, 12 accept and 15 reject, at github.com/astrogilda/aee-conformance under vectors-ai-agent-action. Every reject member has an accepting twin carrying the same condition id, and the self-check refuses to pass a corpus where one does not. A corpus of rejections alone gives full marks to a verifier that rejects everything. The harness that built them is in attacks/ with its artifacts. Every claim above is therefore a file you can run, not a paragraph you have to trust. The specification text at 639ec56 is vendored alongside, so the corpus cannot drift from what it certifies against. Regenerate with gen_vectors.py and self-check with check_vectors.py. If you would rather these lived in your repository than mine, say so and I will open a pull request against it instead. |
639ec56 to
773dcd0
Compare
New predicate type for recording AI agent tool invocations as observed by protocol intermediaries. Enables tamper-evident auditing of autonomous agent actions through hash-chained attestation records. Signed-off-by: Elankumaran Srinivasan <5340827+elang2@users.noreply.github.com> Signed-off-by: Elan Ansrinivasan <5340827+elang2@users.noreply.github.com>
Revise the AI Agent Action predicate based on detailed review feedback: - Add checkpoint and chain_break as action.type values with sub-schemas, addressing the tail truncation detection gap - Add parties array with witness/asserter roles for field provenance - Split canonicalization: signing tuple-array (M/L-tagged) for chain integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads) - Document genesis convention (previousHash: "genesis") and chain_break requirement for crash recovery - Add I-JSON safe integer bound on all integer fields (RFC 7493) - Add 128-level depth bound on extensions with counting rule per in-toto#570 - Replace placeholder digests with fully recomputable worked example (every digest has a shown preimage, verifiable against conformance vectors) - Add Security Considerations section - Move listing to community contributions per ITE-63 process Signed-off-by: Elan Ansrinivasan <5340827+elang2@users.noreply.github.com>
773dcd0 to
66de88f
Compare
…JSON Adopts the canonicalization and chain-shape replacement prose contributed in review by Sankalp Gilda (@astrogilda), adapted from the text of in-toto#570. - bad-101..bad-104: the chain hash preimage is now the record canonical form: RFC 8785 (JCS) over the complete audit record including its attestation member. Producers MUST write the JSONL line as exactly these bytes; verifiers MUST recompute and reject on byte mismatch, fail-closed. JSON.stringify is banned from deriving the record canonical form. - bad-105: strict I-JSON statement-wide. Duplicate members at any depth reject fail-closed; well-formed-string rules on raw bytes and \u escapes; the 128-level depth bound now covers the whole record. - bad-107: checkpoint linkage wired. Every record type carries predicate.chain.previousHash; checkpoint.previousHash restates the head and MUST equal it; deleting a checkpoint now breaks documented linkage. - Unnumbered findings: previousHash constrained to lowercase 64-hex or the literal genesis; second-genesis detection upgraded SHOULD -> MUST reject; content digest preimages pinned (params member for requests, result member for success responses, error member for error responses); floats permitted in content payloads only; the signing-form field list enumerated in the spec text for all three record types. - Widened the tool_call signing tuple to cover type, errorClass, the content digests, attestorVersion, and configHash (adopting the reviewer's proposed additions), and added the Underlying record shape section pinning record-vs-Statement membership and the three protection layers (signing tuple, chain hash, DSSE envelope). - Hardening from adversarial self-review: RFC 2119 requirements notation; attestation byte encoding pinned (lowercase 64-hex HMAC-SHA256, 128-hex Ed25519); malformed JSON-RPC responses recorded with success false and no response digest rather than suppressed; predicate.chain REQUIRED on tool_call and checkpoint records (null-priorHead chain_break is the single legitimate omission); chain_break prior* members always present, explicit null when unknown; id pinned as opaque, at most 128 bytes, unique per chain; JCS string rules applied universally in the signing form; protocol and upstream.transport declared Statement-only, DSSE-covered annotations with fixed v0.1 vocabularies. - Conformance corpus subsection pinning astrogilda/aee-conformance, with the conformance MUST binding at the pin regenerated against this text. - Security Considerations: planted-break residual risk named with three implementable mitigations; signature-scheme trust model documented (Ed25519 for attestor accountability, HMAC-SHA256 only inside a single trust boundary). - Extensions retention pinned: post-signing stripping is forbidden (it corrupts the chain preimage); a never-inlined workflow emits extensionsDigest with extensions absent at emission time. Scope semantics clarified: scopes describe witnessing, not signing; structural members are emitter-witnessed by construction; unscoped fields remain unknown provenance, fail-closed. - Markdownlint conformance against the repository config: signing-form field lists moved to fenced blocks, fence languages and list-marker spacing normalized. Checkpoint and chain_break Statement schemas now show the metadata block their worked examples carry, and decisionContextDigest is pinned to lowercase 64-hex with a deployment-defined preimage at v0.1. - Worked example recomputed under the record canonical form and widened tuple, with subject digests fixed to the genesis chain hash on every statement in the chain. Co-authored-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com> Signed-off-by: Elankumaran Srinivasan <5340827+elang2@users.noreply.github.com>
|
@astrogilda, thanks for the Aug 26 review. A lot of what you flagged changed how I ended up thinking about this predicate. I pushed the follow-up as Your There are a few places I went in a different direction from your text, four in total, all in a new "Deviations from the vendored prose" subsection of Acknowledgments. The one I'd call out here: I kept Really appreciate the depth here. |
|
Repinned the corpus to My unpaired-surrogate and safe-integer probes both reported false results. They searched for a clause by its exact wording, so your rewordings read as removals: the surrogate attack reported itself as newly succeeding when you had closed it harder, replacing one line with a treatment covering overlong forms, surrogates encoded directly in UTF-8, and With the control in place, the safe-integer contradiction is still live. The signing form requires safe integers and rejects floats by design; content payloads permit floats because MCP tool payloads are arbitrary JSON. The current text states these more explicitly, sharpening the contradiction: an implementer reading the safe-integer rule rejects a record carrying a float in a content payload, and one reading the second accepts it and digests it under JCS. The two readings produce different verdicts on the same statement, and nothing in the text says which is conformant. Regarding the null- There is a narrower gap underneath this. Every statement carries the genesis record's chain hash as its subject digest, and a segment with no genesis record has no such hash. The subject digest for a null-priorHead segment is undefined. The current text admits divergent readings with opposite security meanings: the segment carries the break record's hash, in which case a consumer never retrieves the pre-break history and the checkpoint mitigation cannot fire; or it carries the original genesis hash, in which case the mitigation works but the text does not say so. Of your three mitigations, co-attestation covers the break record's own fields and an adversary who restarts the attestor keeps them, and the checkpoint is conditional under the first reading and inert under the second. The third holds. Since Security Considerations says "by one of", a deployment relying on either of the first two remains vulnerable to a compromised attestor. To close it: define the subject digest for a null-priorHead segment, and require the third mitigation specifically. Tagged v0.8.0 so you have something stable to pin, with the vendored spec regenerated against 8783c6b and its digest in the release notes. The suite is unchanged at 53 vectors; what moved is the spec it certifies against, which now matches the text your MUST binds to. |
… digest, priorHead:null prohibition; bump conformance pin to aee-conformance v0.8.0 - Record canonical form + content digest form: reframed as a data-location rule. The record admits no non-integer numbers; payload floats stay lawful behind the content digest; extensions inlining payload numbers MUST string-encode non-integers. IEEE 754 float-serialization phrasing removed from the record canonical form. - Chain shape "Genesis and breaks", predicate.chainBreak fields row, chain break schema example, and tool_call Statement schema comment: the subject digest for a break-rooted segment is the break record's own chain hash. Statements in a break-rooted segment MUST NOT carry any pre-break chain identifier; a policy requiring complete history MUST NOT accept a break-rooted segment as satisfying it. - Chain break vs. planted state: forbidding priorHead:null is the required mitigation. Externalized checkpoint and out-of-band co-attestation remain as detection aids that do not substitute for the prohibition. - Conformance corpus: bumped to aee-conformance v0.8.0 (tag object dereferences to commit 0599e85cfc1b22f5cda5bf4b265de40cd54acb51), which vendors the specification text at the parent of this revision, 8783c6b. specDigest = 273eb3475d11610b5f868661abb7b5538546c482a1a7d589a07a635ef2a79c02, byte-exact against the spec bytes at 8783c6b. - Fixed four MD030 list-marker violations in the Deviations appendix. - Added a third-round changelog entry. Signed-off-by: Elankumaran Srinivasan <5340827+elang2@users.noreply.github.com>
|
@astrogilda, round 6 on top of 8783c6b. Your three:
Pin bumped to v0.8.0 in the conformance corpus subsection, naming 8783c6b as the parent commit that release vendors. specDigest byte-exact against 8783c6b. Also fixed four MD030 markers in the deviations appendix. Thanks for reworking the probes. |
Summary
New predicate type for recording AI agent tool invocations as observed by protocol intermediaries (gateways, proxies, audit sidecars).
AI agents executing tool calls through MCP and similar protocols produce side effects in external systems. Application logs are insufficient for auditing because the entity writing the log is the same entity whose behavior needs verification. This predicate enables independent attestation at the protocol layer, separate from both the agent and the tool server.
The subject digest uses the genesis record hash as a stable chain identifier, so verification policies can target entire audit chains. Trust boundaries and security limitations are explicitly documented. The hash-chain model provides completeness and ordering guarantees against parties who do not hold the signing key.
Test plan