Skip to content

Privileged MCP Action Evidence Profile (v0)

  • Profile id: privileged-mcp-action/v0
  • Status: experimental open profile (not a standard; see "Versioning and maturity")
  • Normative URI: https://docs.getassay.dev/profiles/privileged-mcp-action/v0/
  • Conformance corpus: ../../../conformance/privileged-mcp-action-v0/ (14 vectors, corpus digest in its MANIFEST.json)
  • Independent reproduction wanted: #1840, which names the exact commit the current corpus digest describes.

In-repository links here are relative, so a reader who obtained this file at some commit follows them to the corpus at that same commit. The digest describes one set of bytes.

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.

1. What this profile is

This profile is a composition and verification contract over existing evidence records. It defines what a reviewer may conclude, by recomputation from carried bytes alone, about a single privileged MCP tools/call: what was decided, what the caller observably received, and what stays unproven. It introduces no new record envelope: the inputs are the shipped carriers below, byte-faithful inside a standard evidence bundle.

The output is a claim matrix, never a score. Each claim is confirmed, incomplete, or refuted on its own, and the profile's non-claims travel with every result. A verifier under this profile recomputes; it does not trust the producer's summary of its own records.

Single-call by design. The decision record carries no timestamp and no correlation id; records correlate to observations only through the (tool name, target digest) pair. Two calls with identical arguments are indistinguishable in the record stream, so this profile refuses to pair records across multiple calls: a bundle carrying more than one decision record is invalid, never heuristically paired. Multi-call composition is deferred until a concurrency-safe correlation id exists (a forward-looking design is in enforcement-decision-correlation-id.md).

2. Input records

All inputs arrive inside one evidence bundle (Section 4). Events are selected by the exact schema member of their payload; the event's type MUST equal that schema string. A mismatch in either direction within the profile's namespaces (an in-namespace payload under a different type, or an in-namespace type over a payload that declares no in-namespace schema) is invalid, fail closed.

Required, exactly one:

  • assay.enforcement_decision.v0: the pre-call policy decision, emitted before any forward, for both allow and deny. Validity constraints in Section 5.

Optional, at most one each:

  • assay.denied_call_observation.v0: the caller-visible denial observation. Only a record that satisfies the marker triple (Section 5, stage 3) participates; it binds to the decision through (call.tool_name, call.target_digest).
  • assay.manifest_establish.v0: diagnostic journey only. Its presence, absence, or content changes no claim cell. A journey that contradicts the verdict is reported as a finding; the verdict stays with the decision carrier.

Out of scope for v0 (deferred, not forbidden in the bundle): allow-response observations, provider-side audit records, gateway or kernel observations, retries and concurrency, whole-action verdicts. Events in the bundle outside the profile's namespace are ignored by this profile (their integrity is still covered by bundle verification). Events inside the profile's namespace with an unrecognized schema fail closed (stage 2).

3. Verification output

The verifier emits one report:

{
  "schema": "assay.privileged_mcp_action.verify.report.v0",
  "profile": "privileged-mcp-action/v0",
  "bundle_integrity": "pass",
  "verdict": "valid",
  "claims": {
    "policy_decision_recorded": { "status": "confirmed", "source_class": "producer_reported" },
    "caller_visible_denial":    { "status": "confirmed", "source_class": "producer_reported" },
    "upstream_delivery":        { "status": "incomplete" },
    "external_side_effect":     { "status": "incomplete" }
  },
  "findings": [],
  "non_claims": [
    "allow does not prove upstream delivery",
    "deny does not establish maliciousness",
    "caller-visible denial does not prove external side-effect absence",
    "bundle integrity does not upgrade source class"
  ]
}

Closed vocabularies, fail-closed on anything else:

  • bundle_integrity: pass | fail (stage 1; the same member name and values the conformance manifest uses). On fail, verdict and claims MUST be absent and nothing below stage 1 may be consumed. The report MAY carry a single bundle_integrity finding with the bundle verifier's error string, and the fixed non_claims are emitted in every report, failures included.
  • verdict: valid | invalid (stages 2-4). Present only when bundle_integrity is pass; on invalid, claims MUST be absent and the verifier SHOULD report one free-form finding per detected violation (all violations, not only the first).
  • claim status: confirmed | incomplete | refuted.
  • source_class: pinned to the gateway vocabulary (producer_reported | issuer_attested | receiver_receipt | boundary_observed | third_party_observed | unknown). Every claim this profile can confirm or refute in v0 carries producer_reported: the evidence is the producer's own record, and bundle integrity never upgrades source class. Claims at incomplete carry no source_class. (The tool-decision-truth vocabulary authoritative_boundary | reported_trace | inferred is a different record family and MUST NOT be used here.)
  • The four non_claims strings above are fixed and MUST all be present verbatim in every report.

confirmed + producer_reported means: the producer's record carries this claim within its own ceiling. It never means independent observation.

4. Bundle essentials (normative summary)

So that this profile is implementable without reading any Assay code, the bundle rules a verifier needs are restated here. The bundle is a .tar.gz with exactly two entries, manifest.json first, then events.ndjson.

  • Canonicalization is RFC 8785 (JCS). A content id is "sha256:" + hex(sha256(jcs(value))).
  • Each event line is a JSON object carrying at least specversion ("1.0"), type, source, id ("{run_id}:{seq}"), time, datacontenttype ("application/json"), assayrunid, assayseq, assaycontenthash, and the payload byte-faithful under data.
  • assaycontenthash MUST equal the content id of the object {specversion, type, datacontenttype, subject?, data} (with subject omitted when absent). The hash input deliberately excludes time, id, stream identity, and producer metadata, so a re-export at a different time keeps the same content hash.
  • run_root = "sha256:" + hex(sha256(concat(content_hash_i + "\n"))) over all content hashes in sequence order; the manifest's run_root and bundle_id MUST both equal it.
  • The manifest carries schema_version: 1, the algorithms block (canon: "jcs-rfc8785", hash: "sha256", root: "sha256(concat(content_hash + \"\n\"))"), an event_count, and a files map whose events.ndjson entry pins that file's sha256 and byte length.
  • Verification checks structure, the two-file allowlist, path safety, file hashes and sizes, per-event content-hash recomputation, the id contract, contiguous sequence from 0, run-id consistency, event count, the run-root recomputation, and finally the manifest's bundle_id against that recomputed root, in that order and before any profile semantics. The bundle_id comparison is last on purpose: placed earlier it would report a tampered run_root as a bundle_id disagreement, and an implementation that reports the wrong one of the two is harder to debug than one that rejects late. Both are required, so a bundle satisfying only one of them MUST be rejected -- including the case where the two manifest fields agree with each other but not with the recomputed chain.

5. Verification stages

A verifier proceeds in numbered stages. Stages 1 through 4 are byte-pure: each is a function of the carried bundle bytes alone, with no key material and no policy input. All four are consumption preconditions: a consumer that reads any claim cell MUST have evaluated them first, and on any failure the report is bundle_integrity: "fail" (stage 1) or verdict: "invalid" (stages 2-3) and no claim may be consumed. There is no trust-relative signature tier in v0: the profile carries no signatures, and a bundle-level attestation, if present, is outside this profile's scope.

Stage 1: bundle integrity. The checks of Section 4, in order. Any failure invalidates the attestation before profile semantics; a tampered byte anywhere in a covered file is an integrity failure, never a semantic one.

Stage 2: statement well-formedness. Over the profile-selected events:

  • Exactly one assay.enforcement_decision.v0 payload (zero or more than one is invalid).
  • At most one assay.denied_call_observation.v0; at most one assay.manifest_establish.v0.
  • Record-level constraints below are evaluated only when the record kind is at its allowed cardinality; a cardinality violation invalidates the bundle on its own.
  • Any payload whose schema starts with assay.enforcement_decision., assay.denied_call_observation., or assay.manifest_establish. but is not the exact .v0 string is an unrecognized profile schema: invalid, fail closed.
  • Decision record constraints, each fail-closed:
  • decision in the closed set allow | deny;
  • reason in the closed producer set unclassified_tool_call | classification_incomplete | no_declared_allowance | credential_scope_unknown | credential_scope_insufficient | manifest_baseline_missing | manifest_observation_ambiguous | manifest_current_observation_incomplete | manifest_drifted_since_approval | allow;
  • drift_state in satisfied | baseline_missing | current_observation_incomplete | observation_ambiguous | drifted | not_evaluated;
  • tool.name a non-empty string;
  • action.target_digest exactly sha256: followed by 64 lowercase hexadecimal characters (anything else, including an empty string, uppercase hex, or a wrong length, is invalid). A null or absent target digest is invalid under this profile: v0 covers explicitly classified privileged actions, and an unclassified decision (which the producer emits with a null digest) cannot be bound and falls outside the profile;
  • fail_closed MUST equal (decision == "deny"). The producer derives it; a record where the two diverge is malformed, not an alternative policy statement;
  • non_claims MUST be an array containing at least the five producer strings verbatim (the producer's fixed array; see the conformance corpus for the exact bytes).
  • Observation record constraints (when present): call.tool_name non-empty, caller_visible_error.code, caller_visible_error.origin, caller_visible_error.reason present and non-null (stage 2 checks presence; stage 3 compares typed values, so a string "-32042" is a present member that never becomes a marker), caller_visible_response_digest a sha256:-prefixed 64-hex string, non_claims containing the four producer strings.
  • Establish record constraints (when present): establish_path in no_establish_needed | established_then_allowed | established_then_denied | immediate_deny; establish_attempted MUST equal (run_outcome != "not_performed"). run_outcome MUST be a string (its value set is open in v0) and establish_attempted MUST be a boolean; a missing or wrongly typed member is invalid, since the equality must be checkable.

Stage 3: binding validity. An observation participates as a caller-visible denial marker only if all three hold: schema is assay.denied_call_observation.v0, caller_visible_error.code is -32042, and caller_visible_error.origin is "assay-proxy". A marker MUST bind to the decision: call.tool_name equals tool.name AND call.target_digest equals action.target_digest. A marker whose binding pair does not match the decision, or a marker present with no decision record at all, is invalid (a caller-visible enforcement marker that nothing backs is the exact condition lint rule ASSAY-W004 exists to catch; under this profile it is a validity failure, not a warning). A marker with a null or empty call.target_digest is unbindable and invalid for the same reason. An observation that is well-formed but fails the marker triple is valid and inert: it is not a marker, participates in no binding, and changes no claim cell.

Stage 4: claim recompute. The claim matrix is a deterministic function of the validated records:

Validated input policy_decision_recorded caller_visible_denial upstream_delivery external_side_effect
deny + bound marker confirmed confirmed incomplete incomplete
deny, no marker confirmed incomplete incomplete incomplete
allow, no marker confirmed incomplete incomplete incomplete
allow + bound marker confirmed refuted incomplete incomplete
  • The allow + bound marker row is the contradiction case: the producer recorded the decision to forward, and the same producer recorded a caller-visible denial for the same (tool, target). The caller-visible outcome is refuted; the contradiction is emitted as a finding. refuted carries source_class: producer_reported like every producer-record conclusion.
  • A deny without a marker is expected, not anomalous (a denied notification produces no caller-visible record), so the cell reads incomplete, never refuted.
  • upstream_delivery and external_side_effect are incomplete in every v0 row. This is the profile's load-bearing honesty: an allow is the decision to forward, and no record in this profile has the vantage to carry delivery or side-effect truth. A future profile version may add stronger members for these cells; under v0 they cannot be confirmed by any input.
  • The establish record changes no cell. The contradiction set is normative: decision: "allow" beside establish_path "immediate_deny" or "established_then_denied", and decision: "deny" beside "established_then_allowed"; "no_establish_needed" never contradicts. On a contradiction the verifier MUST emit a finding and MUST NOT alter the matrix.
  • The verifier does not recompute whether the policy decision was correct. It confirms which decision the producer recorded and whether sibling records support that decision within their ceilings.

6. Non-claims (always emitted)

  1. allow does not prove upstream delivery: a transport failure after an allow surfaces on the caller side as proxy_failed, never inside these records.
  2. deny does not establish maliciousness: deny is fail-closed caution.
  3. caller-visible denial does not prove external side-effect absence: the marker proves what the caller was shown, at the producer's vantage.
  4. bundle integrity does not upgrade source class: a cryptographically valid bundle of producer-reported records is still producer-reported.

Two further boundaries a consumer should know, stated here rather than in every report: the recorded decision is the final decision (the producer may re-evaluate around a manifest-establish attempt and records only the outcome), and the profile says nothing about calls that produced no records at all (coverage of the record stream itself is outside a single-bundle profile).

7. Reserved extension: approval retention

The decision record MAY carry approval_retained_view (vocabulary today: structured_meta_jcs readable; encrypted reserved). The shipped enforcing producer does not emit it; the member arrives via imported or future records. When present, lint rule ASSAY-W005 semantics apply: a readable structured view passes silently, an opaque or unknown retained view caps content-review claims at incomplete and never upgrades any cell in this profile's matrix. This profile does not read the member in stage 4; it is recorded here so that a future version can adopt it without a breaking change.

8. Conformance corpus

The corpus at conformance/privileged-mcp-action-v0/ carries 14 vectors (5 accept, 9 reject), generated by a single deterministic generator (gen_vectors.py, standard library only, byte-identical on re-run). MANIFEST.json declares for every vector the expected bundle_integrity, the expected profile verdict, and for accepts the full expected claim matrix. That triple is the normative comparison surface. Integrity-fail vectors declare no expected.verdict: a conforming report omits the member on bundle_integrity: "fail", and the comparison asserts its absence. The first_failure_informative codes are the generator's own vocabulary and are informative only, so an independent implementation is compared on outcomes, not on its reasons: it SHOULD report a free-form reason per reject in its own words, which makes wrong-reason agreement visible instead of silently scoring as parity.

  • The corpus digest (corpus_digest in the manifest) is sha256 over the concatenation of each vector's bundle digest plus a newline, in listed order, mirroring the bundle run_root construction. Any edit to any vector changes it.
  • The accept vectors and all shape rejects verify as well-formed bundles under the shipped bundle verifier; the tamper vector fails it. The ok-005 and bad-105/bad-108 vectors reproduce the shipped ASSAY-W004 contradiction and not-backed findings, so the profile's stage-3 semantics and the linter's are checkably the same.
  • Maturity language: this corpus digest is a candidate until an independent, non-author implementation reproduces the expected outcomes from this specification text alone. Reproduction is digest-scoped: a future corpus change re-enters candidate state until re-reproduced.

9. Versioning and maturity

  • The profile id is versioned (/v0). Any change to a closed vocabulary, a stage rule, or the claim matrix is a new profile version; v0 semantics are frozen once the corpus digest has an independent reproduction.
  • The profile is an open profile. The word "standard" is not used for it, per ADR-042, until an independent implementation exists and a neutral venue has actually taken the contract up.
  • The stop list of ADR-042 binds this profile: no aggregate score, no whole-action verdict, no detector semantics, no provider-side outcome claims.

10. Security considerations

  • All bundle content is hostile input. Stage 1 runs the full bundle verification, including decompression and size limits, before any profile parsing.
  • The profile fails closed everywhere: unknown schema in-namespace, out-of-vocabulary values, unbindable markers, and cardinality violations are invalid, never ignored or downgraded.
  • The (tool name, target digest) binding is exact byte equality on the digest string; a verifier MUST NOT case-fold or re-encode digests before comparison.
  • Producer strings (reasons, non-claims) are compared as exact bytes. Terminal-rendering safety of those strings is the consumer's concern (the shipped tooling sanitizes on render).

Changelog

  • v0 (2026-07-24): initial experimental open profile. Single-call, four byte-pure stages, claim matrix with pinned gateway source-class vocabulary, 13-vector candidate corpus.
  • v0 (2026-07-27): no normative change to the stages or the claim matrix. The Section 4 verification order now ends with the bundle_id comparison it always required, and the corpus grows to 14 vectors with bad-109-bundle-id-mismatch, the first case that can tell a verifier performing that last step from one that stops at the run-root recomputation. The corpus digest changes, so a pack released against the 13-vector digest is superseded.