diff --git a/CHANGELOG.md b/CHANGELOG.md index 7957cad..fd148a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,71 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.39.2] - 2026-05-27 + +**Theme: SEP-2787 envelope v2 shape, full wire round-trip, versioned +audit-event schema, and Qi-survey coverage mapping.** + +The four mechanical alignments Vaara committed to in +`modelcontextprotocol/modelcontextprotocol#2787` after the +trust-surface grouping was incorporated into the SEP draft on +soup-oss commit `dd030d5b` ship as the v2 envelope shape: + +1. `toolCalls` lives under `payloadDerived`, not `plannerDeclared`. + Tool bindings (name, server fingerprint, args commitment) are + facts derived from the request payload, not planner declarations. +2. `argsProjection` serialises with a JSON-stringified `projection` + field carrying the JCS-canonical encoding of the projection + object. The digest is taken over those bytes. +3. The v1 `kind`-discriminated union is dropped. `ArgsRef` (ref + + digest) and `ArgsProjection` (projection + projectionDigest) + self-discriminate by which fields are present. +4. Commitment-only audit composes on `ArgsProjection` as a + hash-only-identity projection of the form `{"digest": + "sha256:..."}`. No separate `ArgsDigest` type ships in the spec. + +### Added +- `parse_attestation(d)` (and `sep2787_parse_attestation` from the + package root): inverse of `Attestation.to_dict()`. Reconstructs the + Python dataclass tree from a wire JSON dict so third-party + consumers of the v0 test vectors can parse, verify, and re-emit + envelopes byte-identically. Strict field-presence validation and + alg allowlisting on the boundary. +- `docs/audit_event_schema.md`: AUDIT-EVENT-SCHEMA-1.0, versioned + wire/storage contract for the audit events Vaara emits. + Independent of code version so third-party consumers can pin + without coupling to a Python runtime version. +- `docs/qi_survey_mapping.md`: Vaara surface coverage against the + taxonomy in Qi et al., *Towards Trustworthy Agentic AI* + (arXiv:2605.23989, 2026-05-17). Direct, partial, and + out-of-scope rows by Perceive / Plan / Act / Reflect / Learn / + Multi-agent / Long-horizon stage under both top-level dimensions. +- `tests/test_attestation_sep2787_wire.py`: 13 wire round-trip tests + covering `emit -> JCS bytes -> parse -> verify` across HS256, + ES256, RS256 for both `ArgsRef` and `ArgsProjection`, plus parse + rejection on missing-field and unsupported-alg inputs and a + byte-identical re-emit check. + +### Changed +- `vaara.attestation.sep2787` emits the v2 envelope shape. +- `docs/sep2787-overt-mapping.md` field table updated to v2. +- `COMPLIANCE.md` "Position relative to open runtime-attestation + standards" gains a SEP-2787 v2 subsection alongside the OVERT 1.0 + positioning. +- `vaara.attestation` package docstring covers both OVERT 1.0 and + SEP-2787 v2 surfaces (previously OVERT-only by omission). + +### SEP-2787 reference implementation tag +- `sep2787-ref-v2`: v2 envelope shape with the four post-soup-oss + alignments and full wire round-trip. Pinned for cross-repo + provenance citation against + `modelcontextprotocol/modelcontextprotocol#2787` and the v0 test + vector PR (`#2789`). +- `sep2787-ref-v1` (preserved at commit `a61e87c`): camelCase + envelope, the prior proposed-shape artefact. +- `sep2787-ref-v0` (preserved at commit `3d7af54`): snake_case + envelope, the historical proposed-shape artefact. + ## [0.39.1] - 2026-05-27 **Theme: SEP-2787 reference impl follows the spec into camelCase.** diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 24414cf..86c2ecb 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -376,6 +376,32 @@ Operators who need AAL-4 should pair Vaara with an independent attestation provider. The Vaara-emitted evidence is the input to that provider, not a replacement for it. +### SEP-2787 v2 tool-call attestation (v0.39.2) + +`vaara.attestation.sep2787` ships a reference implementation of +SEP-2787, a per-tool-call JSON attestation envelope carried in MCP +`_meta`. The v2 envelope shape groups envelope fields under three +trust-surface blocks (`plannerDeclared`, `issuerAsserted`, +`payloadDerived`) with `toolCalls` as a payload-derived fact, not a +planner declaration. Signing modes are HS256 (HMAC-SHA256), ES256 +(ECDSA P-256 raw r||s), and RS256 (RSASSA-PKCS1-v1_5). The signature +is computed over the JCS-canonical encoding of the four envelope +blocks `{version, alg, plannerDeclared, issuerAsserted, +payloadDerived}` and is excluded from its own input. + +The two envelopes coexist. OVERT 1.0 is the operator-side attestation +kernel emitting per-action CBOR Base Envelopes. SEP-2787 is the +per-tool-call JSON envelope carried inside MCP transport. A +deployment can run both: the OVERT envelope binds the action chain +while the SEP-2787 envelope binds the specific tool-call payload. +Field-level mapping between the two lives in +[`docs/sep2787-overt-mapping.md`](docs/sep2787-overt-mapping.md). + +`parse_attestation(d)` provides full wire round-trip: a third-party +consumer of the published v0 test vectors can parse JSON bytes, +verify the signature, and re-emit byte-identically. The reference +implementation is pinned at tag `sep2787-ref-v2`. + ### Hardware TEE attestation hook (experimental, v0.18.0) Beyond the software-signed attestation chain described above, Vaara diff --git a/clients/ts/package.json b/clients/ts/package.json index 2e70fa7..4c02587 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.39.1", + "version": "0.39.2", "description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/docs/audit_event_schema.md b/docs/audit_event_schema.md new file mode 100644 index 0000000..fb069bb --- /dev/null +++ b/docs/audit_event_schema.md @@ -0,0 +1,189 @@ +# Vaara Audit Event Schema, v1.0 + +Versioned wire/storage contract for the audit events that flow through +the Vaara execution layer. This document is the schema; the +implementation in `src/vaara/audit/trail.py` is one conforming emitter. +The schema is versioned independently of the Vaara Python package so +downstream consumers (compliance combiners, regulatory exports, +third-party verifiers) can pin to a schema version without coupling to +a runtime version. + +## Status and scope + +- Schema version: **1.0** +- Status: stable +- Applies to: events appended to `trail.jsonl` and to the JSON shape + returned by the audit HTTP API. +- Out of scope: the combiner that assembles per-Article evidence + reports, the signer that wraps an exported trail, the verifier that + walks the hash chain. Those consume conforming events and have their + own contracts. + +A conforming emitter MUST produce events that satisfy the field +requirements below. A conforming consumer MUST tolerate optional +fields and unknown additive fields under the rules in +[§ Forward compatibility](#forward-compatibility). + +## Event envelope + +Every audit event is a JSON object with the following fields. + +| Field | Type | Required | Description | +|---|---|---|---| +| `record_id` | UUIDv4 string | yes | Identifier unique to this single event. | +| `action_id` | UUIDv4 string | yes | Groups every event that belongs to one action lifecycle (request, score, decision, execute/block, outcome). | +| `event_type` | string enum | yes | Lifecycle stage. Closed set in [§ Event types](#event-types). | +| `timestamp` | number | yes | Unix epoch seconds (UTC), IEEE-754 double. Finite (no NaN, no ±∞). | +| `agent_id` | string | yes | Identity of the agent that submitted the action. Free-form, ≤ 256 bytes. | +| `tool_name` | string | yes | Name of the tool or action under interception. ≤ 512 bytes. | +| `data` | object | no | Event-specific payload. Schema by `event_type`, see [§ Data payloads](#data-payloads). Default `{}`. | +| `regulatory_articles` | array of objects | no | Regulatory provenance of this event. See [§ Regulatory article objects](#regulatory-article-objects). Default `[]`. | +| `previous_hash` | hex string | yes | SHA-256 of the predecessor record's `record_hash`. Empty string for the first record. | +| `record_hash` | hex string | yes | SHA-256 over the canonical encoding of the hashed-fields subset of this record. See [§ Hash chain](#hash-chain). | +| `system_operation` | string | no | prEN ISO/IEC 12792 transparency axis: how the AI system operated at this event. Metadata, not hashed. | +| `data_usage` | string | no | prEN ISO/IEC 12792 transparency axis: what data was consumed. Metadata, not hashed. | +| `decision_making` | string | no | prEN ISO/IEC 12792 transparency axis: how the conclusion was reached. Metadata, not hashed. | +| `limitations` | string | no | prEN ISO/IEC 12792 transparency axis: known constraints. Usually carried out-of-band. Metadata, not hashed. | + +## Event types + +`event_type` is a closed enum at schema 1.0. Additive values may +appear in a minor version bump; see [§ Forward compatibility](#forward-compatibility). + +| Value | Lifecycle position | +|---|---| +| `action_requested` | Agent submitted an action; recorded before processing. | +| `risk_scored` | Scorer produced a risk assessment with conformal prediction interval. | +| `decision_made` | Allow / escalate / deny decided. | +| `action_executed` | Action was actually executed downstream. | +| `action_blocked` | Action was blocked before execution. | +| `escalation_sent` | Action routed to human reviewer. | +| `escalation_resolved` | Human reviewer responded. | +| `outcome_recorded` | Post-execution outcome observed and recorded. | +| `policy_override` | Manual override of a prior automated decision. | + +Each event for one action references a single shared `action_id`. The +canonical lifecycle is `action_requested` → `risk_scored` → +`decision_made` → (`action_executed` | `action_blocked` | +`escalation_sent` → `escalation_resolved`) → `outcome_recorded`. +`policy_override` may appear at any point after `decision_made`. + +## Hash chain + +The chain is SHA-256 over a canonical JSON encoding of a strict subset +of the record. Encoding: `json.dumps(content, sort_keys=True, +separators=(",", ":"), allow_nan=False)`. + +Fields included in `content`: `record_id`, `action_id`, `event_type` +(as string), `timestamp`, `agent_id`, `tool_name`, `data`, +`regulatory_articles`, `previous_hash`. + +Fields excluded: `system_operation`, `data_usage`, `decision_making`, +`limitations`. Rationale: the four prEN ISO/IEC 12792 transparency +annotations may evolve with the WG4 draft. Excluding them keeps +records hash-stable under re-emission and avoids coupling chain +integrity to a moving annotation schema. A future major schema may +introduce a separate signed-bundle mechanism for transparency tagging. + +`previous_hash` of the first record is the empty string `""`. +`record_hash` is computed at append time and stable across +re-serialization. + +## Regulatory article objects + +`regulatory_articles` is an array of objects. Each object has: + +| Field | Type | Required | Description | +|---|---|---|---| +| `domain` | string | yes | Regulatory regime: `EU_AI_ACT`, `DORA`, `NIS2`, `MiFID_II`, `GDPR`. | +| `article` | string | yes | Article reference, e.g. `Article 12(1)` or `Article 9(2)(a)`. | +| `requirement` | string | yes | What the article requires. | +| `how_satisfied` | string | yes | How this event satisfies the requirement. | + +The combiner uses `regulatory_articles` to assemble per-Article +evidence reports. Including the regulatory provenance in the +hash-covered fields means that tampering with article attribution +after the fact breaks chain verification. + +## Data payloads + +`data` carries event-specific structured fields. The schema by +`event_type` is non-exhaustive at 1.0 (consumers MUST accept unknown +keys). Reserved top-level keys: + +- `action_requested`: `action_request` (object), `context` (object, optional). +- `risk_scored`: `risk_score` (number in [0, 1]), `interval` (`[low, high]`), `classifier_version` (string), `contributing_signals` (array). +- `decision_made`: `decision` (`allow` | `escalate` | `deny`), `threshold_set` (string), `verdict_inputs` (array). +- `action_executed`: `executor` (string), `duration_ms` (number). +- `action_blocked`: `reason` (string), `blocking_policy` (string). +- `escalation_sent`: `reviewer_queue` (string), `priority` (string). +- `escalation_resolved`: `reviewer_id` (string), `decision` (enum), `reason` (string). +- `outcome_recorded`: `outcome` (`success` | `failure` | `partial` | `unknown`), `feedback` (object, optional). +- `policy_override`: `overrider_id` (string), `prior_decision` (enum), `new_decision` (enum), `reason` (string). + +Caller-controlled strings in `data` (`agent_id`, `reason`, +`override_reason`) MUST be treated as untrusted at narrative-rendering +time. The hash chain still covers original values; the renderer +sanitizes for display only. + +## Numeric and string discipline + +- `timestamp` is IEEE-754 double Unix epoch seconds, UTC. NaN, +∞, -∞ + are rejected at the canonical-JSON boundary. +- Strings are UTF-8. ≤ 256 bytes for `agent_id`, ≤ 512 bytes for + `tool_name`. `record_id` and `action_id` are UUIDv4 in canonical form. + +## Wire and storage encodings + +JSONL on disk: one event per line, sorted keys, no trailing +whitespace. The hash chain is computed and verified against this +encoding. JSON over HTTP: the audit API returns events as JSON +objects, optionally wrapped in a pagination envelope; the event +object itself is byte-identical to the JSONL line modulo whitespace. +Other encodings (CBOR, Protobuf) may be defined in sibling specs +without bumping this version, provided they round-trip to the +canonical JSON form. + +## Signing and export + +A trail is exported as a zip bundle: `trail.jsonl`, `manifest.json`, +`trail.sig`, `signer_pubkey`. Signed message: +`SHA-256(trail.jsonl || manifest.json)`. Reference signing algorithms: +Ed25519 (default), ML-DSA-65 (FIPS 204). This schema does not +constrain the signing algorithm beyond requiring that the export +bundle carry the public key in a form the verifier can consume. + +## Forward compatibility + +- **Minor (1.x).** Additive only: new `event_type` values, new + optional fields, new entries inside `data`. Consumers MUST tolerate + unknown fields. +- **Major (2.0+).** Breaking changes: field removal, renames, change + of hash-input set, change of canonical encoding. Major bumps ship + with a migration note and the prior schema remains a valid emission + target for one release cycle. + +Producers SHOULD include a schema version tag in the export manifest. +Events themselves do not carry a per-record schema field, since the +chain integrity guarantee covers re-emission only under the same +schema version. + +## Relation to OVERT 1.0 and SEP-2787 + +OVERT 1.0 Base Envelopes (`vaara.attestation.overt`) are per-action +attestations encoded as deterministic CBOR. SEP-2787 v2 envelopes +(`vaara.attestation.sep2787`) are per-tool-call JSON envelopes carried +in MCP `_meta`. This audit-event schema is the full per-event +lifecycle log. The three coexist: an OVERT or SEP-2787 envelope can +back-link to the audit event that recorded the same action via +`record_id`. See `docs/sep2787-overt-mapping.md` for the +OVERT ↔ SEP-2787 field mapping. + +## Reference implementation + +- `src/vaara/audit/trail.py`, `signer.py`, `verify.py`, `export.py`. +- `src/vaara/server/schemas.py` (pydantic HTTP wire models). + +The reference implementation pins this schema at version 1.0. A +conforming third-party emitter or consumer may target this document +without coupling to the Python implementation. diff --git a/docs/qi_survey_mapping.md b/docs/qi_survey_mapping.md new file mode 100644 index 0000000..910036f --- /dev/null +++ b/docs/qi_survey_mapping.md @@ -0,0 +1,188 @@ +# Vaara mapping to the Qi et al. agentic-AI trustworthiness survey + +This page maps Vaara's runtime governance surface against the taxonomy +in Qi et al., [*Towards Trustworthy Agentic AI: A Comprehensive Survey +of Safety, Robustness, Privacy, and System Security*](https://arxiv.org/abs/2605.23989) +(arXiv:2605.23989, 2026-05-17). The survey organises agentic-AI risks +along the agent workflow (Perceive, Plan, Act, Reflect, Learn, +Multi-agent, Long-horizon) under two top-level dimensions: Safety and +Robustness, and Privacy and System Security. It also defines a +consolidated evaluation framework covering outcome vs. process +signals, trajectory- and step-level metrics, judge reliability, and a +release-pipeline shape from offline regression through canary rollout +to production monitoring. + +The mapping is deliberately honest. Vaara is a runtime evidence layer +at the agent tool-call boundary. It addresses risks that materialise +at that boundary (Act-stage and trail-side Reflect-stage risks) and +does not address risks that live at perception, planning, learning, +or training-time. Naming the gap is the point. + +## Mapping convention + +Each row names one risk category from the survey, a Vaara mechanism +that produces evidence against it, and a coverage classification: + +- **Direct**: Vaara records or enforces against this risk at the + tool-call boundary. +- **Partial**: Vaara captures the trail-side evidence (what happened, + when, by which agent) but the detection or mitigation lives in an + upstream component that Vaara composes with. +- **Out of scope**: the risk lives at a layer Vaara is not designed + to address (training data, model internals, planner reasoning). + +## Safety and Robustness (Section 3.1) + +### Perceive stage + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Data poisoning | Out of scope (training-time). | Out of scope | +| Adversarial perturbations on inputs | Out of scope (model-side). | Out of scope | +| Indirect prompt injection | Adversarial classifier (`adversarial_classifier_v9.joblib`) scores `tools/call` actions for injection-shaped patterns; v9 retrain on BIPIA follows brings FPR to 1.2% [0.4, 3.6] across four backends under BIPIA pressure. | Direct | +| Sensor / observation spoofing | Out of scope (sensor layer). | Out of scope | +| Instruction–data boundary confusion | MCP proxy perimeter separates resource/prompt operator scope from tool-call client scope; per-action audit captures the boundary crossing. | Direct | + +### Plan stage + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| OOD generalisation failures | Cross-model held-out evaluation on Mixtral and Claude Sonnet 4.6; conformal prediction intervals on every risk score quantify model uncertainty rather than hiding it. | Partial | +| Specification gaming | Out of scope (planner-side). Trail records the executed actions and outcomes that a downstream specification-gaming detector can consume. | Partial | +| Goal misgeneralisation | Out of scope (planner-side). | Out of scope | +| Miscalibrated uncertainty in world models | Out of scope (planner-side). Vaara's own scorer is conformal-calibrated; planner uncertainty is not. | Out of scope | +| "Happy-path" brittle strategies | Adversarial robustness measured against three PAIR attacker families at n=300 each; v0.38 ships 88.4% recall under PAIR pressure. | Partial | + +### Act stage + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Dangerous tool use | Policy gate enforces allow/escalate/deny at the MCP boundary. Adversarial classifier scores `tools/call` for `tool_misuse`, `privilege_escalation`, `data_exfil`. | Direct | +| Cascading failures from upstream errors | Per-action audit captures the full action chain. Conformal interval widens when context diverges from calibration distribution. | Direct | +| Irreversibility in high-impact actions | ESCALATE routes high-score calls to Article 14 human-in-loop queue before execution. Reviewer decision recorded back into the chain. | Direct | +| Partial automation failures | `evidence_insufficient` status returned honestly when an article has no recorded events. | Direct | +| Tool chaining cascades | Multi-agent action attribution via `agent_id`; composable audit chains across a Vaara fleet. | Direct | + +### Reflect stage + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Unsafe self-assessment | Out of scope (model-side). | Out of scope | +| Deceptive rationalisation | Out of scope (model-side). | Out of scope | +| Evaluator spoofing | Hash-chained audit trail prevents post-hoc rewriting of the trail an evaluator reads. SEP-2787 v2 attestation binds tool name + server fingerprint + args commitment per call. | Direct | +| Over-confidence | Conformal interval is honest by construction (distribution-free coverage). | Direct | +| Incomplete trace evidence | `evidence_insufficient` status when an Article report has no recorded events; the trail does not rubber-stamp gaps. | Direct | + +### Learn stage + +All Learn-stage risks (reward hacking, safety regression, capability– +constraint imbalance, catastrophic forgetting, adversarial pattern +importation) are out of scope. Vaara is a runtime substrate, not a +training pipeline. + +### Multi-agent + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Collusion to bypass constraints | Per-agent action attribution via `agent_id`; cross-agent trail composition surfaces collusive patterns to downstream analysis. | Partial | +| Misinformation amplification | Out of scope (content-side). | Out of scope | +| Negative externalities via competitive equilibria | Out of scope (system-design-side). | Out of scope | +| Communication channel compromise | MCP proxy enforces perimeter; audit chain captures every channel crossing. | Direct | + +### Long-horizon + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Compounding error | Per-action conformal interval widens as context drifts; outcome feedback loop recalibrates. | Partial | +| Delayed side effects | `outcome_recorded` events accept post-execution feedback at arbitrary delay; chain preserves the original decision context. | Direct | +| Value drift, memory accumulation, stale goal contamination | Out of scope (planner / memory-side). | Out of scope | + +## Privacy and System Security (Section 3.2) + +### Perceive stage + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Direct / indirect prompt injection | Adversarial classifier (see Safety section above). | Direct | +| Multimodal inference attacks | Out of scope (modality-side). | Out of scope | +| Obfuscated input attacks | Classifier corpus includes obfuscation-class adversarial samples; cross-model held-out evaluation tests generalisation. | Partial | +| Retrieval layer poisoning | Out of scope (retrieval-side); trail captures the resulting tool calls. | Partial | +| Zero-click injection | Classifier scores every `tools/call` regardless of provenance. | Direct | + +### Plan and Act stages (privacy) + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Regurgitation / reconstruction of private content | Out of scope (content-side). | Out of scope | +| Memory poisoning (delayed triggers) | Out of scope (memory-side); trail records the action chain that delayed triggers eventually produce. | Partial | +| Tool-mediated leakage | OVERT 1.0 envelope keeps request content local; only HMAC-SHA256 commitment crosses the trust boundary. SEP-2787 v2 hash-only-identity projection achieves the same property for MCP. | Direct | +| Credential theft via tool access | MCP proxy enforces allow/deny on credential-bearing tools; per-call attestation binds server fingerprint. | Direct | +| Exfiltration via authorised channels | Classifier specifically targets `data_exfil` patterns; conformal score quantifies exfiltration risk per call. | Direct | +| Side-channel leakage (timing, error codes) | Out of scope (transport-side). | Out of scope | +| SQL injection | Out of scope at the tool-call layer; trail captures the tool call and its risk score. | Partial | + +### Reflect stage (privacy) + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Cross-component propagation | Per-action audit chain captures every component crossing. | Direct | +| Trace over-collection | OVERT envelope is commitment-only by default; raw content stays local unless explicitly attached. | Direct | +| Rationale leakage | Out of scope (rationale-side); trail records the action, not the model's chain of thought. | Out of scope | +| Protocol-level failures (tampering, impersonation) | Hash-chained trail, Sigstore signing on export, optional ML-DSA-65 post-quantum signer. SEP-2787 v2 binds signer-secret-version per envelope. | Direct | +| Replay / downgrade attacks | SEP-2787 v2 nonce + TTL is the per-envelope replay guard; the verifier exposes step 5 (argument commitment) for caller composition. | Direct | + +### Learn, Multi-agent, Credential-management + +| Risk | Vaara mechanism | Coverage | +|---|---|---| +| Privacy risk persistence across updates | Out of scope (training-side). | Out of scope | +| Insider threats | `policy_override` event captures every manual override with overrider identity and reason; the override is itself audited. | Direct | +| Compromised tool / API backdoors | MCP proxy server-fingerprint binding; allow-list enforces known fingerprints only. | Direct | +| Supply chain poisoning | SLSA build provenance on every PyPI release; Sigstore signatures on the published wheels. | Direct | +| Non-parametric update vulnerabilities | Out of scope (memory-side). | Out of scope | +| Shared context disclosure | Multi-tenancy isolation at the Vaara layer is v0.40 scope; current single-tenant deployments isolate by Vaara instance. | Partial | +| Privilege escalation | Policy gate; classifier scores `privilege_escalation` class explicitly. | Direct | +| Collusive exfiltration | Cross-agent trail composition (see Multi-agent above). | Partial | +| Long-lived token exposure, recovery gaps | Out of scope at the runtime layer (credential-management lives upstream); trail captures use of credentialed tools. | Partial | + +## Evaluation framework (Section 4) + +The survey's consolidated evaluation framework maps to Vaara's own +bench discipline as follows. + +| Survey concept | Vaara correspondence | +|---|---| +| Outcome vs. process evaluation (4.2.1) | Both. `outcome_recorded` events carry outcome; the full lifecycle log is the process record. | +| Trajectory- vs. step-level metrics (4.2.2) | Trajectory: per-action conformal interval. Step: per-event hash-chained record. | +| Long-horizon evaluation (4.2.3) | Outcome feedback loop; `outcome_recorded` accepts arbitrary delay. | +| Multi-agent evaluation (4.2.4) | `agent_id` attribution; cross-agent composition. | +| Judge reliability and adversarial robustness (4.2.5) | PAIR robustness at n=300 per attacker family; cross-model held-out evaluation on Mixtral and Claude Sonnet 4.6. | +| Offline regression (replay known failures) | `tests/adversarial/` corpus replay. | +| Sandboxed execution (ToolEmu-style) | MCP proxy enforces perimeter in sandbox deployments. | +| Red teaming | PAIR three-family adversarial sweep. | +| Shadow mode (read-only deployment) | Audit-only mode (no enforcement) ships with the policy gate; both shapes write the same hash-chained trail. | +| Canary rollout | Out of scope (deployment-shape, not runtime). v0.40 deployment scope includes hot-reload extensions that compose with canary patterns. | +| Production monitoring | Per-action conformal scoring is the runtime monitor. | + +## Summary + +Vaara provides direct evidence against Act-stage and Reflect-stage +trail-integrity risks across both top-level dimensions of the Qi +survey, with partial coverage of Perceive-stage injection and several +Multi-agent and Long-horizon risks. Plan-stage, Learn-stage, and +content-side privacy risks are out of scope by design and named as +such above. + +The survey's open challenges (Section 6) include runtime monitoring, +trustworthy personalisation, standardising explainability, and +closing the accountability gap. Vaara's runtime governance substrate +is one primitive that addresses the first; the audit chain plus +per-Article evidence reports addresses the fourth. The other two sit +at adjacent layers. + +## Citation + +> Qi, J., Li, M., Liu, J., Shu, Y., Yu, D., Ma, S., Cui, W., Zhao, +> Y., Chen, Y., Jiang, R., King, I., Xu, Z. (2026). "Towards +> Trustworthy Agentic AI: A Comprehensive Survey of Safety, +> Robustness, Privacy, and System Security." arXiv:2605.23989. diff --git a/docs/sep2787-overt-mapping.md b/docs/sep2787-overt-mapping.md index 9a99755..fab1f60 100644 --- a/docs/sep2787-overt-mapping.md +++ b/docs/sep2787-overt-mapping.md @@ -2,7 +2,7 @@ This document maps fields between the OVERT 1.0 Protocol Profile 1.0 Base Envelope (`vaara.attestation.overt`) and the SEP-2787 Tool Call -Attestation envelope, proposed shape (`vaara.attestation.sep2787`). +Attestation envelope, v2 shape (`vaara.attestation.sep2787`). The two envelopes coexist in Vaara. OVERT is the operator-side attestation kernel, emitted per in-scope action and encoded as @@ -14,31 +14,35 @@ reason. ## Trust-surface alignment -The SEP-2787 proposed shape groups envelope fields under three -trust-surface blocks (`plannerDeclared`, `issuerAsserted`, -`payloadDerived`). OVERT does not group fields explicitly but applies -the same separation through field semantics. The mapping below names -which block each OVERT field corresponds to. +The SEP-2787 v2 shape groups envelope fields under three trust-surface +blocks: `plannerDeclared` (intent and requested capability), +`issuerAsserted` (iss, sub, iat, exp, nonce, key version, alg, set by +the attestation issuer at signing time), and `payloadDerived` (the +`toolCalls` array, each binding a tool name, server fingerprint, and +args commitment derived from the request payload). OVERT does not +group fields explicitly but applies the same separation through field +semantics. The mapping below names which block each OVERT field +corresponds to. ## Field-by-field mapping | OVERT Base Envelope (CBOR) | SEP-2787 proposed shape (JSON) | Trust surface | Notes | |---|---|---|---| | `blinded_identifier` (32 bytes) | `issuerAsserted.nonce` (base64url) | issuer-asserted | Both serve replay-protection. OVERT uses 32 bytes per action; SEP-2787 uses 18 bytes base64url-encoded per attestation. | -| `request_commitment` (HMAC-SHA256, bytes) | `payloadDerived.argsDigest` (`sha256:`) | payload-derived | Direct semantic match. OVERT uses keyed HMAC over a SHA-256 digest of the request content; SEP-2787 ArgsDigest is a plain SHA-256 over JCS-canonical bytes. Both keep the raw payload local. | -| `encoder_binary_identity` (SHA-256, bytes) | (no direct equivalent) | n/a | OVERT pins the arbiter implementation + version + policy hash at signing time. SEP-2787 v1 has no equivalent. Vaara's reference implementation could attach an extension field for this. | -| `non_content_metadata` (CBOR map) | `plannerDeclared.requestedCapability` (string) | planner-declared | Partial match. OVERT carries structural classification fields (action class, severity, decision); SEP-2787 has only the capability string in the proposed shape. | +| `request_commitment` (HMAC-SHA256, bytes) | `payloadDerived.toolCalls[*].args.projectionDigest` (`sha256:`), with `projection` carrying the JCS encoding of `{"digest": "sha256:"}` | payload-derived | Direct semantic match for commitment-only audit. OVERT uses keyed HMAC over a SHA-256 digest of the request content; SEP-2787 v2 expresses the same intent as a hash-only-identity projection (no separate `argsDigest` field in the spec). Both keep the raw payload local. | +| `encoder_binary_identity` (SHA-256, bytes) | (no direct equivalent) | n/a | OVERT pins the arbiter implementation + version + policy hash at signing time. SEP-2787 v2 has no equivalent. Vaara's reference implementation could attach an extension field for this. | +| `non_content_metadata` (CBOR map) | `plannerDeclared.requestedCapability` (string) | planner-declared | Partial match. OVERT carries structural classification fields (action class, severity, decision); SEP-2787 has only the capability string in the v2 shape. | | `monotonic_counter` (uint64) | (no direct equivalent) | n/a | OVERT requires a strictly increasing per-arbiter sequence to detect gaps. SEP-2787 has no monotonic counter; replay protection is per-nonce within TTL. | | `nanosecond_timestamp` (uint64) | `issuerAsserted.iat` (ISO 8601 string) | issuer-asserted | Same semantics, different encoding. OVERT uses uint64 nanoseconds for cross-language stability; SEP-2787 uses ISO 8601 strings for JSON-native consumption. | | `key_identifier` (SHA-256 over public key, bytes) | `issuerAsserted.secretVersion` (string) | issuer-asserted | OVERT binds the verifying key cryptographically via SHA-256 fingerprint; SEP-2787 names it via opaque version string and leaves key lookup to the verifier. | | `arbiter_instance_identifier` (16 bytes, UUID) | (no direct equivalent) | n/a | OVERT identifies the specific arbiter instance that produced the envelope. SEP-2787 conflates this with `iss`. | | `signature` (Ed25519, 64 bytes) | `signature` (hex-encoded) | binding output | Different curve choices. OVERT mandates Ed25519. SEP-2787 supports HS256 (HMAC-SHA256), ES256 (ECDSA P-256), RS256 (RSASSA-PKCS1-v1_5). | | (no equivalent) | `plannerDeclared.intent` (string) | planner-declared | Human-readable justification required by EU AI Act Article 12 audit reconstruction. OVERT can carry this in `non_content_metadata` but does not require it as a top-level field. | -| (no equivalent) | `plannerDeclared.toolCalls[*].name` and `.serverFingerprint` | planner-declared | MCP-specific binding. OVERT is transport-agnostic and does not name a tool or server in the envelope. | +| (no equivalent) | `payloadDerived.toolCalls[*].name` and `.serverFingerprint` | payload-derived | MCP-specific binding. OVERT is transport-agnostic and does not name a tool or server in the envelope. | ## Canonicalization -| OVERT | SEP-2787 proposed shape | +| OVERT | SEP-2787 v2 shape | |---|---| | Deterministic CBOR per RFC 8949 Section 4.2 (sorted keys, smallest int encoding, definite lengths) | JSON Canonicalization Scheme per RFC 8785 (sorted keys, ECMAScript number serialization, Unicode escaping) | | IEEE-754 floats prohibited; rates and probabilities as decimal strings | IEEE-754 floats rejected at the boundary (same discipline applied to JSON) | @@ -46,16 +50,16 @@ which block each OVERT field corresponds to. ## Where the standards point at the same evidence Both standards bind the same logical evidence: identity, intent, -payload commitment, time, and signature. The proposed SEP-2787 shape -makes the trust-surface separation explicit through named blocks; -OVERT achieves the same separation through field semantics and the -Phase 1 / Phase 2 / Phase 3 architecture. A deployment running OVERT -Phase 2 Provisional Receipts can produce an equivalent SEP-2787 -envelope by projecting the Base Envelope fields into the three named -blocks and re-signing under the JSON-native algorithm of choice. The -reverse projection is also possible but loses OVERT's monotonic -counter and arbiter instance identifier, which have no SEP-2787 -equivalent in the proposed shape. +payload commitment, time, and signature. The SEP-2787 v2 shape makes +the trust-surface separation explicit through named blocks; OVERT +achieves the same separation through field semantics and the Phase 1 +/ Phase 2 / Phase 3 architecture. A deployment running OVERT Phase 2 +Provisional Receipts can produce an equivalent SEP-2787 envelope by +projecting the Base Envelope fields into the three named blocks and +re-signing under the JSON-native algorithm of choice. The reverse +projection is also possible but loses OVERT's monotonic counter and +arbiter instance identifier, which have no SEP-2787 equivalent in the +v2 shape. ## See also diff --git a/pyproject.toml b/pyproject.toml index 66861e2..bb399c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.39.1" +version = "0.39.2" description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 700e023..da5ae1b 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.39.1" +__version__ = "0.39.2" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/attestation/__init__.py b/src/vaara/attestation/__init__.py index 071e05d..cc93fa9 100644 --- a/src/vaara/attestation/__init__.py +++ b/src/vaara/attestation/__init__.py @@ -1,27 +1,40 @@ -"""OVERT 1.0 Protocol Profile 1.0 attestation envelope emission. +"""Runtime attestation envelopes: OVERT 1.0 and SEP-2787 v2. -Vaara's structural position relative to OVERT 1.0 (Glacis Technologies, -overt.is): Vaara is the third-party runtime kernel that intercepts agent -actions, scores risk, and writes the audit trail. In OVERT terms Vaara is -the **Arbiter** at AAL-3 (operator-controlled notary model). Phase 1 -(Enforcement) and Phase 2 (Provisional Receipt) are emitted by Vaara -directly. Phase 3 (Full Attestation) is provided by ``vaara.attestation.iap`` -since v0.13.0 as a reference Independent Attestation Provider that -notary-signs the Provisional Receipt and anchors it in a transparency -log. Production deployments can swap in sigstore Rekor or an equivalent -independently-operated log at the same call sites. As of v0.17.0, the -``vaara overt verify`` CLI validates any OVERT 1.0 Base Envelope produced -by a conformant emitter, Vaara or otherwise. +This package ships two coexisting attestation surfaces: -The `BaseEnvelope` produced here implements Protocol Profile 1.0 Annex B.6 -verbatim: a 9-field closed-schema CBOR-encoded structure signed with -Ed25519. Any OVERT-aware verifier (auditor, IAP, relying party) can -recompute the canonical encoding and verify the signature offline. +**OVERT 1.0 Protocol Profile 1.0** (``vaara.attestation.overt``). +Per-action CBOR Base Envelope, 9-field closed schema, Ed25519 +(optionally ML-DSA-65). Vaara's structural position relative to OVERT +1.0 (Glacis Technologies, overt.is): Vaara is the third-party runtime +kernel that intercepts agent actions, scores risk, and writes the +audit trail. In OVERT terms Vaara is the **Arbiter** at AAL-3 +(operator-controlled notary model). Phase 1 (Enforcement) and Phase 2 +(Provisional Receipt) are emitted by Vaara directly. Phase 3 (Full +Attestation) is provided by ``vaara.attestation.iap`` since v0.13.0 +as a reference Independent Attestation Provider that notary-signs the +Provisional Receipt and anchors it in a transparency log. Production +deployments can swap in sigstore Rekor or an equivalent +independently-operated log at the same call sites. As of v0.17.0, +the ``vaara overt verify`` CLI validates any OVERT 1.0 Base Envelope +produced by a conformant emitter, Vaara or otherwise. + +**SEP-2787 v2** (``vaara.attestation.sep2787``). Per-tool-call JSON +envelope carried inside MCP ``_meta``. Three trust-surface blocks +(``plannerDeclared``, ``issuerAsserted``, ``payloadDerived``) plus a +signature computed over the JCS-canonical encoding of those four +blocks. Signing modes: HS256, ES256, RS256. ``parse_attestation`` +provides full wire round-trip so third-party consumers of the v0 +test vectors can parse JSON bytes, verify the signature, and re-emit +byte-identically. Reference implementation pinned at tag +``sep2787-ref-v2``. + +The two envelopes coexist. Field-level mapping lives in +``docs/sep2787-overt-mapping.md``. Install: ``pip install 'vaara[attestation]'``. -See COMPLIANCE.md "Position relative to open runtime-attestation standards" -for the full architectural framing. +See COMPLIANCE.md "Position relative to open runtime-attestation +standards" for the full architectural framing. """ from vaara.attestation.overt import ( @@ -69,23 +82,23 @@ from vaara.attestation.sep2787 import ( Algorithm as SEP2787Algorithm, ArgsCommitment as SEP2787ArgsCommitment, - ArgsDigest, ArgsProjection, ArgsRef, Attestation as SEP2787Attestation, AttestationError as SEP2787AttestationError, IssuerAsserted, + PayloadDerived, PlannerDeclared, ToolCallBinding, canonical_json as sep2787_canonical_json, emit_attestation as sep2787_emit_attestation, make_args_digest, make_args_projection, + parse_attestation as sep2787_parse_attestation, verify_attestation as sep2787_verify_attestation, ) __all__ = [ - "ArgsDigest", "ArgsProjection", "ArgsRef", "BaseEnvelope", @@ -97,6 +110,7 @@ "IssuerAsserted", "LogEntry", "MockSEVSNPAttester", + "PayloadDerived", "Phase3Attestation", "PlannerDeclared", "S3PAttestation", @@ -125,6 +139,7 @@ "regularized_incomplete_beta", "sep2787_canonical_json", "sep2787_emit_attestation", + "sep2787_parse_attestation", "sep2787_verify_attestation", "verify_base_envelope", "verify_envelope_binding", diff --git a/src/vaara/attestation/_sep2787_canonical.py b/src/vaara/attestation/_sep2787_canonical.py index c51b7b8..7fa6df4 100644 --- a/src/vaara/attestation/_sep2787_canonical.py +++ b/src/vaara/attestation/_sep2787_canonical.py @@ -18,7 +18,6 @@ from typing import Any, Optional from vaara.attestation._sep2787_types import ( - ArgsDigest, ArgsProjection, AttestationError, ) @@ -77,23 +76,37 @@ def iso8601_to_epoch(iso: str) -> Optional[float]: return None -def make_args_digest(args_obj: Any) -> ArgsDigest: - """Build an ArgsDigest from a JSON-serialisable args object. +def make_args_digest(args_obj: Any) -> ArgsProjection: + """Build a commitment-only ArgsProjection from a JSON-serialisable args object. - Computes the JCS canonical encoding, then ``sha256:``. The - payload itself is discarded, only the digest leaves the function. + Computes the JCS-canonical encoding of ``args_obj``, takes its + sha256, and ships the hash inside a hash-only-identity projection + of the form ``{"digest": "sha256:"}``. The original payload + never leaves the function. The verifier reconstructs the same + digest from the runtime arguments and rejects on mismatch. + + Replaces the v1 ``ArgsDigest`` extension: per the v2 envelope + shape, commitment-only audit is expressed as a hash-only-identity + projection rather than a third commitment kind. """ payload = canonical_json(args_obj) - return ArgsDigest( - digest=f"sha256:{hashlib.sha256(payload).hexdigest()}", - canonicalization="jcs", + args_digest_hex = f"sha256:{hashlib.sha256(payload).hexdigest()}" + projection_obj = {"digest": args_digest_hex} + projection_bytes = canonical_json(projection_obj) + return ArgsProjection( + projection=projection_bytes.decode("utf-8"), + projection_digest=f"sha256:{hashlib.sha256(projection_bytes).hexdigest()}", ) def make_args_projection(projection_obj: dict[str, Any]) -> ArgsProjection: - """Build an ArgsProjection (reviewed redaction) with its own digest.""" + """Build an ArgsProjection (reviewed redaction) from a projection dict. + + The projection is JCS-canonicalised to bytes, decoded to UTF-8 to + produce the wire-format projection string, and digested. + """ payload = canonical_json(projection_obj) return ArgsProjection( - projection=projection_obj, + projection=payload.decode("utf-8"), projection_digest=f"sha256:{hashlib.sha256(payload).hexdigest()}", ) diff --git a/src/vaara/attestation/_sep2787_emit.py b/src/vaara/attestation/_sep2787_emit.py index a327ecb..5174398 100644 --- a/src/vaara/attestation/_sep2787_emit.py +++ b/src/vaara/attestation/_sep2787_emit.py @@ -25,13 +25,13 @@ from vaara.attestation._sep2787_types import ( VALID_ALGS, Algorithm, - ArgsCommitment, Attestation, AttestationError, IssuerAsserted, + PayloadDerived, PlannerDeclared, - args_to_dict, issuer_to_dict, + payload_to_dict, planner_to_dict, ) @@ -42,7 +42,7 @@ def _signing_payload( alg: Algorithm, planner_declared: PlannerDeclared, issuer_asserted: IssuerAsserted, - payload_derived: tuple[ArgsCommitment, ...], + payload_derived: PayloadDerived, ) -> bytes: """JCS-canonical encoding of the four envelope blocks. @@ -54,7 +54,7 @@ def _signing_payload( "alg": alg, "plannerDeclared": planner_to_dict(planner_declared), "issuerAsserted": issuer_to_dict(issuer_asserted), - "payloadDerived": [args_to_dict(a) for a in payload_derived], + "payloadDerived": payload_to_dict(payload_derived), } return canonical_json(body) @@ -62,6 +62,7 @@ def _signing_payload( def emit_attestation( *, planner_declared: PlannerDeclared, + payload_derived: PayloadDerived, iss: str, sub: str, secret_version: str, @@ -74,21 +75,21 @@ def emit_attestation( ) -> Attestation: """Build, JCS-canonicalize, and sign an Attestation envelope. + ``planner_declared`` carries intent and an optional requested + capability. ``payload_derived`` carries one or more tool-call + bindings, each pointing at an args commitment derived from the + request payload. + ``signing_material`` is either a bytes shared secret (HS256) or a private-key object from ``cryptography.hazmat`` (ES256 / RS256). - - The ``payload_derived`` block is materialised from - ``planner_declared.tool_calls[*].args`` in declaration order. The - duplication is intentional: the planner declared the binding, the - args commitment is the payload-derived projection of that binding. """ if alg not in VALID_ALGS: raise AttestationError(f"unsupported alg: {alg!r}") if not planner_declared.intent.strip(): raise AttestationError("plannerDeclared.intent MUST be non-empty") - if not planner_declared.tool_calls: + if not payload_derived.tool_calls: raise AttestationError( - "plannerDeclared.toolCalls MUST contain at least one entry" + "payloadDerived.toolCalls MUST contain at least one entry" ) issuer_asserted = IssuerAsserted( @@ -100,7 +101,6 @@ def emit_attestation( secret_version=secret_version, alg=alg, ) - payload_derived = tuple(tc.args for tc in planner_declared.tool_calls) payload = _signing_payload( version=version, diff --git a/src/vaara/attestation/_sep2787_types.py b/src/vaara/attestation/_sep2787_types.py index 5323a97..de24f0f 100644 --- a/src/vaara/attestation/_sep2787_types.py +++ b/src/vaara/attestation/_sep2787_types.py @@ -1,11 +1,29 @@ """Dataclasses and serialization helpers for SEP-2787 envelopes. Internal module. Public surface is in ``vaara.attestation.sep2787``. + +v2 envelope shape lands the four changes Vaara committed to in +``modelcontextprotocol/modelcontextprotocol#2787`` after the trust-surface +grouping was incorporated into the SEP draft on soup-oss commit +``dd030d5b``: + +1. ``toolCalls`` lives under ``payloadDerived``, not ``plannerDeclared``. + Tool bindings are facts derived from the request payload, not planner + declarations. +2. ``argsProjection`` serialises with a JSON-stringified ``projection`` + field carrying the JCS-canonical encoding of the projection object. + The digest is taken over those bytes. +3. The v1 ``kind``-discriminated union is dropped; the two commitment + shapes (``ArgsRef``, ``ArgsProjection``) self-discriminate by which + fields are present. +4. Commitment-only audit composes on ``ArgsProjection`` as a + hash-only-identity projection of the form ``{"digest": "sha256:..."}``; + no separate ``ArgsDigest`` type ships in the spec. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Literal, Optional, Union Algorithm = Literal["HS256", "ES256", "RS256"] @@ -16,53 +34,46 @@ class AttestationError(RuntimeError): """Raised when SEP-2787 envelope construction or verification fails.""" -@dataclass(frozen=True) -class ArgsDigest: - """Commitment-only args binding. Payload never crosses the verifier. - - Privacy-friendly default. The audit invariant is "this call was - bound to this exact commitment." The verifier sees ``digest`` but - not the underlying arguments. - """ - - digest: str - canonicalization: Literal["jcs", "cbor"] = "jcs" - kind: Literal["digest"] = field(default="digest", init=False) - - @dataclass(frozen=True) class ArgsRef: """Content-addressed reference. Verifier may fetch and check digest.""" ref: str digest: str - canonicalization: Literal["jcs", "cbor"] = "jcs" - kind: Literal["ref"] = field(default="ref", init=False) + canonicalization: Literal["jcs"] = "jcs" @dataclass(frozen=True) class ArgsProjection: - """Redacted or transformed projection of the args, with its own digest. - - The projection is what the issuer reviewed and bound; the original - arguments are NOT recoverable from the projection. Useful when the - arguments contain personal data, secrets, or trade-secret payloads - but the audit needs a reviewable summary. + """Reviewed projection of the args, with its own digest. + + ``projection`` is the JCS-canonical JSON encoding of the projection + object as a UTF-8 string. ``projection_digest`` is ``sha256:`` + over the UTF-8 bytes of ``projection``. + + Commitment-only audit (payload stays local) is expressed as a + hash-only-identity projection: ``projection`` carries the + JCS-canonical encoding of ``{"digest": "sha256:..."}`` and the + embedded digest binds the underlying arguments. The verifier + recomputes the same digest from the runtime arguments and rejects + on mismatch. See ``make_args_digest``. """ - projection: dict[str, Any] + projection: str projection_digest: str - kind: Literal["projection"] = field(default="projection", init=False) -ArgsCommitment = Union[ArgsDigest, ArgsRef, ArgsProjection] +ArgsCommitment = Union[ArgsRef, ArgsProjection] @dataclass(frozen=True) class ToolCallBinding: - """One tool-call entry. The planner declares ``name`` and - ``server_fingerprint``; ``args`` is the trust-surface-separated - args binding for this call.""" + """One tool-call entry: name + server fingerprint + args commitment. + + Lives under ``payloadDerived.toolCalls`` in the v2 envelope. Each + binding is a fact derived from the request payload, not a planner + declaration. + """ name: str server_fingerprint: str @@ -73,13 +84,13 @@ class ToolCallBinding: class PlannerDeclared: """Trust surface 1: what the client / agent planner claims. - Set by the planner upstream of the issuer. The issuer binds these - under its signing key but does not assert their truth, only that - the planner claimed them. + Holds intent and an optional requested-capability claim. Tool-call + bindings moved to ``payloadDerived.toolCalls`` in the v2 envelope. + The issuer binds these fields under its signing key but does not + assert their truth, only that the planner claimed them. """ intent: str - tool_calls: tuple[ToolCallBinding, ...] requested_capability: Optional[str] = None @@ -102,24 +113,32 @@ class IssuerAsserted: @dataclass(frozen=True) -class Attestation: - """SEP-2787 tool call attestation envelope, proposed shape. +class PayloadDerived: + """Trust surface 3: facts derived from the request payload. + + Holds the tool-call bindings (name, server fingerprint, args + commitment). The args commitment is computed deterministically from + the request payload, not declared by the planner. + """ + + tool_calls: tuple[ToolCallBinding, ...] + - Composed of three trust-surface blocks plus the signature. The - signature is computed over the JCS-canonical encoding of - ``{version, alg, plannerDeclared, issuerAsserted, - payloadDerived}`` and does not cover itself. +@dataclass(frozen=True) +class Attestation: + """SEP-2787 tool call attestation envelope, v2 shape. - The ``payload_derived`` tuple parallels - ``planner_declared.tool_calls`` in order: index N here is the args - commitment for the N-th tool call. + Three trust-surface blocks plus the signature. The signature is + computed over the JCS-canonical encoding of ``{version, alg, + plannerDeclared, issuerAsserted, payloadDerived}`` and does not + cover itself. """ version: int alg: Algorithm planner_declared: PlannerDeclared issuer_asserted: IssuerAsserted - payload_derived: tuple[ArgsCommitment, ...] + payload_derived: PayloadDerived signature: str def to_dict(self) -> dict[str, Any]: @@ -128,46 +147,36 @@ def to_dict(self) -> dict[str, Any]: "alg": self.alg, "plannerDeclared": planner_to_dict(self.planner_declared), "issuerAsserted": issuer_to_dict(self.issuer_asserted), - "payloadDerived": [args_to_dict(a) for a in self.payload_derived], + "payloadDerived": payload_to_dict(self.payload_derived), "signature": self.signature, } def args_to_dict(args: ArgsCommitment) -> dict[str, Any]: - if isinstance(args, ArgsDigest): - return { - "kind": "digest", - "digest": args.digest, - "canonicalization": args.canonicalization, - } if isinstance(args, ArgsRef): return { - "kind": "ref", "ref": args.ref, "digest": args.digest, "canonicalization": args.canonicalization, } if isinstance(args, ArgsProjection): return { - "kind": "projection", "projection": args.projection, "projectionDigest": args.projection_digest, } raise AttestationError(f"unknown args commitment kind: {type(args)!r}") -def planner_to_dict(planner: PlannerDeclared) -> dict[str, Any]: - out: dict[str, Any] = { - "intent": planner.intent, - "toolCalls": [ - { - "name": tc.name, - "serverFingerprint": tc.server_fingerprint, - "args": args_to_dict(tc.args), - } - for tc in planner.tool_calls - ], +def tool_call_to_dict(tc: ToolCallBinding) -> dict[str, Any]: + return { + "name": tc.name, + "serverFingerprint": tc.server_fingerprint, + "args": args_to_dict(tc.args), } + + +def planner_to_dict(planner: PlannerDeclared) -> dict[str, Any]: + out: dict[str, Any] = {"intent": planner.intent} if planner.requested_capability is not None: out["requestedCapability"] = planner.requested_capability return out @@ -183,3 +192,107 @@ def issuer_to_dict(issuer: IssuerAsserted) -> dict[str, Any]: "secretVersion": issuer.secret_version, "sub": issuer.sub, } + + +def payload_to_dict(payload: PayloadDerived) -> dict[str, Any]: + return { + "toolCalls": [tool_call_to_dict(tc) for tc in payload.tool_calls], + } + + +def args_from_dict(d: dict[str, Any]) -> ArgsCommitment: + if "ref" in d: + if "digest" not in d: + raise AttestationError("ArgsRef missing 'digest'") + return ArgsRef( + ref=d["ref"], + digest=d["digest"], + canonicalization=d.get("canonicalization", "jcs"), + ) + if "projection" in d: + if "projectionDigest" not in d: + raise AttestationError("ArgsProjection missing 'projectionDigest'") + return ArgsProjection( + projection=d["projection"], + projection_digest=d["projectionDigest"], + ) + raise AttestationError( + "args commitment missing both 'ref' and 'projection'; cannot discriminate" + ) + + +def tool_call_from_dict(d: dict[str, Any]) -> ToolCallBinding: + for required in ("name", "serverFingerprint", "args"): + if required not in d: + raise AttestationError(f"toolCall missing required field {required!r}") + return ToolCallBinding( + name=d["name"], + server_fingerprint=d["serverFingerprint"], + args=args_from_dict(d["args"]), + ) + + +def planner_from_dict(d: dict[str, Any]) -> PlannerDeclared: + if "intent" not in d: + raise AttestationError("plannerDeclared missing required field 'intent'") + return PlannerDeclared( + intent=d["intent"], + requested_capability=d.get("requestedCapability"), + ) + + +def issuer_from_dict(d: dict[str, Any]) -> IssuerAsserted: + for required in ( + "alg", "expSeconds", "iat", "iss", "nonce", "secretVersion", "sub" + ): + if required not in d: + raise AttestationError(f"issuerAsserted missing required field {required!r}") + if d["alg"] not in VALID_ALGS: + raise AttestationError(f"unsupported alg {d['alg']!r}") + return IssuerAsserted( + alg=d["alg"], + exp_seconds=d["expSeconds"], + iat=d["iat"], + iss=d["iss"], + nonce=d["nonce"], + secret_version=d["secretVersion"], + sub=d["sub"], + ) + + +def payload_from_dict(d: dict[str, Any]) -> PayloadDerived: + if "toolCalls" not in d: + raise AttestationError("payloadDerived missing required field 'toolCalls'") + calls = d["toolCalls"] + if not isinstance(calls, list): + raise AttestationError("payloadDerived.toolCalls must be an array") + return PayloadDerived( + tool_calls=tuple(tool_call_from_dict(c) for c in calls), + ) + + +def attestation_from_dict(d: dict[str, Any]) -> Attestation: + """Reconstruct an Attestation from its wire JSON dict. + + Inverse of ``Attestation.to_dict()``. Accepts a parsed JSON object + (camelCase keys on the boundary), reconstructs the Python dataclass + tree, and returns an Attestation ready for ``verify_attestation``. + Field-presence validation only; signature verification still + requires the caller's keying material. + """ + for required in ( + "version", "alg", "plannerDeclared", "issuerAsserted", + "payloadDerived", "signature", + ): + if required not in d: + raise AttestationError(f"attestation missing required field {required!r}") + if d["alg"] not in VALID_ALGS: + raise AttestationError(f"unsupported alg {d['alg']!r}") + return Attestation( + version=d["version"], + alg=d["alg"], + planner_declared=planner_from_dict(d["plannerDeclared"]), + issuer_asserted=issuer_from_dict(d["issuerAsserted"]), + payload_derived=payload_from_dict(d["payloadDerived"]), + signature=d["signature"], + ) diff --git a/src/vaara/attestation/_sep2787_verifier.py b/src/vaara/attestation/_sep2787_verifier.py index c529087..c7ba7d2 100644 --- a/src/vaara/attestation/_sep2787_verifier.py +++ b/src/vaara/attestation/_sep2787_verifier.py @@ -7,19 +7,16 @@ If the toolCalls entry uses argsRef, resolve the URI, compute SHA-256 over the fetched content, and compare against the stored digest. Confirm the resolved content corresponds to the arguments - being executed. If the entry uses argsProjection, compare it - against the canonicalized runtime arguments (RFC 8785). Identity - projections MUST match exactly; redacted projections are verified - only to be signed -- the verifier makes no claim about - completeness relative to the runtime arguments. If neither field - is present, or if the content cannot be resolved and matched, - reject with args_commitment_mismatch. - -Vaara's three-way args shape (ArgsDigest / ArgsRef / ArgsProjection) -extends the spec's two-way (argsRef / argsProjection) with a -commitment-only ArgsDigest where the payload never crosses the -verifier. For ArgsDigest the verifier recomputes the JCS-canonical -hash of the runtime arguments and compares against the bound digest. + being executed. If the entry uses argsProjection, recompute the + projection digest from the projection bytes and compare against + the stored projectionDigest. If the projection is an identity + projection (the canonical runtime arguments themselves, or a + hash-only-identity projection ``{"digest": "sha256:..."}`` whose + embedded digest matches sha256(JCS(runtime arguments))), confirm + the binding. Redacted projections are verified only to be signed; + the verifier makes no completeness claim. Reject hash-only-identity + projections whose embedded digest does not match the runtime args + with args_commitment_mismatch. Step 5 is composed by the caller after steps 1-4 (signature, nonce replay, TTL, tool call match). It does not perform network IO. @@ -29,13 +26,13 @@ from __future__ import annotations import hashlib +import json from dataclasses import dataclass from typing import Any, Callable, Literal, Optional from vaara.attestation._sep2787_canonical import canonical_json from vaara.attestation._sep2787_types import ( ArgsCommitment, - ArgsDigest, ArgsProjection, ArgsRef, ) @@ -56,9 +53,12 @@ class ArgsCommitmentResult: on failure -- matching the spec's error-reason enum. ``projection_match`` is meaningful only for ArgsProjection: True - when the projection equals the canonicalized runtime arguments - (identity projection per spec), False when it differs (redacted - projection -- verified only to be signed), None otherwise. + when the projection is an identity projection of the runtime + arguments (either the canonical arguments themselves, or a + hash-only-identity projection whose embedded digest matches); + False for redacted / transformed projections (signed-only, + verifier makes no completeness claim); None for non-projection + commitments. """ ok: bool @@ -70,6 +70,28 @@ def _sha256_hex(content: bytes) -> str: return f"sha256:{hashlib.sha256(content).hexdigest()}" +def _parse_hash_only_identity(projection_str: str) -> Optional[str]: + """If projection_str is a hash-only-identity carrier, return its digest. + + A hash-only-identity projection is the JCS-canonical encoding of + a single-key object ``{"digest": "sha256:"}``. The verifier + treats this as an identity projection of a hash-only object: it + binds the underlying arguments' digest without revealing the + payload. Returns the embedded ``sha256:`` digest string, or + None if the projection has any other shape. + """ + try: + obj = json.loads(projection_str) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(obj, dict) or set(obj) != {"digest"}: + return None + digest = obj["digest"] + if not isinstance(digest, str) or not digest.startswith("sha256:"): + return None + return digest + + def verify_args_commitment( args: ArgsCommitment, *, @@ -88,12 +110,6 @@ def verify_args_commitment( does not perform network IO. The deployment chooses resolver policy (allowed schemes, timeouts, caching, trust). """ - if isinstance(args, ArgsDigest): - observed = _sha256_hex(canonical_json(runtime_arguments)) - if observed != args.digest: - return ArgsCommitmentResult(ok=False, reason=ARGS_COMMITMENT_MISMATCH) - return ArgsCommitmentResult(ok=True) - if isinstance(args, ArgsRef): if ref_resolver is None: return ArgsCommitmentResult(ok=False, reason=ARGS_COMMITMENT_MISMATCH) @@ -111,12 +127,18 @@ def verify_args_commitment( return ArgsCommitmentResult(ok=True) if isinstance(args, ArgsProjection): - recomputed = _sha256_hex(canonical_json(args.projection)) - if recomputed != args.projection_digest: + projection_bytes = args.projection.encode("utf-8") + if _sha256_hex(projection_bytes) != args.projection_digest: return ArgsCommitmentResult(ok=False, reason=ARGS_COMMITMENT_MISMATCH) runtime_canonical = canonical_json(runtime_arguments) - projection_canonical = canonical_json(args.projection) - identity = runtime_canonical == projection_canonical + hash_only = _parse_hash_only_identity(args.projection) + if hash_only is not None: + if hash_only != _sha256_hex(runtime_canonical): + return ArgsCommitmentResult( + ok=False, reason=ARGS_COMMITMENT_MISMATCH, + ) + return ArgsCommitmentResult(ok=True, projection_match=True) + identity = projection_bytes == runtime_canonical return ArgsCommitmentResult(ok=True, projection_match=identity) return ArgsCommitmentResult(ok=False, reason=ARGS_COMMITMENT_MISMATCH) diff --git a/src/vaara/attestation/sep2787.py b/src/vaara/attestation/sep2787.py index 6dee256..2962d1b 100644 --- a/src/vaara/attestation/sep2787.py +++ b/src/vaara/attestation/sep2787.py @@ -1,31 +1,28 @@ -"""SEP-2787 Tool Call Attestation envelope, proposed-shape reference. +"""SEP-2787 Tool Call Attestation envelope, v2 reference shape. -Implements the proposed SEP-2787 envelope shape with the four schema -changes Vaara raised in the v1 draft thread -(modelcontextprotocol/modelcontextprotocol#2787), not the v1 draft as -written: +Implements the v2 envelope shape: trust-surface grouping incorporated +into the SEP draft via soup-oss commit ``dd030d5b``, plus the four +mechanical alignments Vaara committed to in +``modelcontextprotocol/modelcontextprotocol#2787`` +(``issuecomment-4557017068``): -1. **Fact-source labels.** Envelope fields are grouped under three - named blocks by trust surface: ``plannerDeclared`` (intent, tool - name and server bindings the agent planner claims), - ``issuerAsserted`` (iss, sub, iat, expSeconds, nonce, secretVersion, - alg, set by the attestation issuer at signing time), and - ``payloadDerived`` (argsDigest / argsRef / argsProjection, - deterministically derived from the request payload). The signature - is the binding output and lives at the envelope root. -2. **Three-way args shape.** The v1 draft overloads a single - ``args: string`` field with both inline JSON and a magic - ``"resource: "`` prefix. This module replaces that with an explicit - tagged union: ``ArgsDigest`` (commitment only, payload stays local), - ``ArgsRef`` (URL plus digest), ``ArgsProjection`` (redacted / - transformed projection plus its own digest). -3. **RFC 8785 (JCS) canonicalization.** The v1 "sorted keys, no - whitespace" rule is replaced with a normative reference to RFC - 8785, plus an IEEE-754 float reject at the boundary (matching OVERT - Protocol Profile 1.0 numeric discipline). -4. **Scope: request attestation only.** The v1 optional ``ack`` field - crosses the pre-exec / post-exec boundary and is removed here. - Execution receipts belong in a separate extension composed on top. +1. **toolCalls under payloadDerived.** Tool bindings (name, server + fingerprint, args commitment) are facts derived from the request + payload, not planner declarations. ``plannerDeclared`` keeps intent + and an optional requested-capability claim. +2. **argsProjection as JSON-stringified projection.** The ``projection`` + field is the JCS-canonical JSON encoding of the projection object, + carried as a UTF-8 string; ``projectionDigest`` is computed over + those bytes. +3. **No ``kind`` discriminator.** ``ArgsRef`` (ref + digest) and + ``ArgsProjection`` (projection + projectionDigest) self-discriminate + by which fields are present. +4. **Commitment-only audit via hash-only-identity projection.** When + the payload must stay local, callers use ``make_args_digest`` which + builds an ``ArgsProjection`` whose ``projection`` is the + JCS-canonical encoding of ``{"digest": "sha256:..."}``. The verifier + reconstructs the same digest from the runtime arguments and rejects + on mismatch. Signing modes follow the v1 draft: HS256 (HMAC-SHA256), ES256 (ECDSA P-256 raw r||s, not DER), RS256 (RSASSA-PKCS1-v1_5). The signature is @@ -65,14 +62,15 @@ from vaara.attestation._sep2787_types import ( Algorithm, ArgsCommitment, - ArgsDigest, ArgsProjection, ArgsRef, Attestation, AttestationError, IssuerAsserted, + PayloadDerived, PlannerDeclared, ToolCallBinding, + attestation_from_dict as parse_attestation, ) from vaara.attestation._sep2787_verifier import ( ArgsCommitmentResult, @@ -83,18 +81,19 @@ "Algorithm", "ArgsCommitment", "ArgsCommitmentResult", - "ArgsDigest", "ArgsProjection", "ArgsRef", "Attestation", "AttestationError", "IssuerAsserted", + "PayloadDerived", "PlannerDeclared", "ToolCallBinding", "canonical_json", "emit_attestation", "make_args_digest", "make_args_projection", + "parse_attestation", "verify_args_commitment", "verify_attestation", ] diff --git a/tests/test_attestation_sep2787.py b/tests/test_attestation_sep2787.py index 05d97bf..fd99039 100644 --- a/tests/test_attestation_sep2787.py +++ b/tests/test_attestation_sep2787.py @@ -1,4 +1,4 @@ -"""SEP-2787 proposed-shape envelope tests.""" +"""SEP-2787 v2 envelope round-trip, tampering, and signing tests.""" from __future__ import annotations @@ -21,38 +21,39 @@ Attestation, AttestationError, IssuerAsserted, + PayloadDerived, PlannerDeclared, ToolCallBinding, canonical_json, emit_attestation, make_args_digest, make_args_projection, - verify_args_commitment, verify_attestation, ) - HS_SECRET = b"\x42" * 32 -def _planner(args=None) -> PlannerDeclared: - args = args or make_args_digest({"path": "/archive/2024-Q3.md"}) +def _planner() -> PlannerDeclared: return PlannerDeclared( intent="archive obsolete report per Q4 retention policy", - tool_calls=( - ToolCallBinding( - name="delete_file", - server_fingerprint="sha256:1111111111111111111111111111111111111111111111111111111111111111", - args=args, - ), - ), requested_capability="filesystem.delete", ) -def _emit_attestation(**overrides) -> Attestation: +def _payload(args=None) -> PayloadDerived: + args = args or make_args_digest({"path": "/archive/2024-Q3.md"}) + return PayloadDerived(tool_calls=(ToolCallBinding( + name="delete_file", + server_fingerprint="sha256:" + "1" * 64, + args=args, + ),)) + + +def _emit(**overrides) -> Attestation: kwargs = dict( planner_declared=_planner(), + payload_derived=_payload(), iss="issuer://test", sub="agent:archiver", secret_version="v1", @@ -63,37 +64,35 @@ def _emit_attestation(**overrides) -> Attestation: return emit_attestation(**kwargs) -def test_hs256_emit_and_verify_round_trip(): - env = _emit_attestation() +def test_hs256_round_trip(): + env = _emit() assert env.alg == "HS256" assert env.version == 1 assert env.signature assert verify_attestation(env, verifying_material=HS_SECRET) is True -def test_es256_emit_and_verify_round_trip(): +def test_es256_round_trip(): priv = ec.generate_private_key(ec.SECP256R1()) - env = _emit_attestation(alg="ES256", signing_material=priv) + env = _emit(alg="ES256", signing_material=priv) assert len(env.signature) == 128 assert verify_attestation(env, verifying_material=priv.public_key()) is True -def test_rs256_emit_and_verify_round_trip(): +def test_rs256_round_trip(): priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) - env = _emit_attestation(alg="RS256", signing_material=priv) + env = _emit(alg="RS256", signing_material=priv) assert verify_attestation(env, verifying_material=priv.public_key()) is True -def test_tampered_planner_block_fails_verification(): - env = _emit_attestation() +def test_tampered_planner_fails(): + env = _emit() tampered_planner = PlannerDeclared( - intent="archive obsolete report per Q4 retention policy", - tool_calls=env.planner_declared.tool_calls, + intent=env.planner_declared.intent, requested_capability="filesystem.WRITE", ) tampered = Attestation( - version=env.version, - alg=env.alg, + version=env.version, alg=env.alg, planner_declared=tampered_planner, issuer_asserted=env.issuer_asserted, payload_derived=env.payload_derived, @@ -102,11 +101,10 @@ def test_tampered_planner_block_fails_verification(): assert verify_attestation(tampered, verifying_material=HS_SECRET) is False -def test_tampered_issuer_block_fails_verification(): - env = _emit_attestation() - tampered_issuer = IssuerAsserted( - iss=env.issuer_asserted.iss, - sub="agent:HIJACKED", +def test_tampered_issuer_fails(): + env = _emit() + bad = IssuerAsserted( + iss=env.issuer_asserted.iss, sub="agent:HIJACKED", iat=env.issuer_asserted.iat, exp_seconds=env.issuer_asserted.exp_seconds, nonce=env.issuer_asserted.nonce, @@ -114,24 +112,40 @@ def test_tampered_issuer_block_fails_verification(): alg=env.issuer_asserted.alg, ) tampered = Attestation( - version=env.version, - alg=env.alg, + version=env.version, alg=env.alg, planner_declared=env.planner_declared, - issuer_asserted=tampered_issuer, + issuer_asserted=bad, payload_derived=env.payload_derived, signature=env.signature, ) assert verify_attestation(tampered, verifying_material=HS_SECRET) is False -def test_args_digest_round_trip_three_shapes(): - d = make_args_digest({"a": 1, "b": [1, 2, 3]}) - r = ArgsRef(ref="ipfs://Qm...", digest="sha256:" + "0" * 64) - p = make_args_projection({"redacted_user_id": "u-001"}) - for args in (d, r, p): - env = _emit_attestation(planner_declared=_planner(args=args)) +def test_tampered_payload_fails(): + env = _emit() + other = make_args_digest({"path": "/etc/passwd"}) + bad = PayloadDerived(tool_calls=(ToolCallBinding( + name=env.payload_derived.tool_calls[0].name, + server_fingerprint=env.payload_derived.tool_calls[0].server_fingerprint, + args=other, + ),)) + tampered = Attestation( + version=env.version, alg=env.alg, + planner_declared=env.planner_declared, + issuer_asserted=env.issuer_asserted, + payload_derived=bad, + signature=env.signature, + ) + assert verify_attestation(tampered, verifying_material=HS_SECRET) is False + + +def test_args_round_trip_two_shapes(): + hash_only = make_args_digest({"a": 1, "b": [1, 2, 3]}) + ref = ArgsRef(ref="ipfs://Qm...", digest="sha256:" + "0" * 64) + projection = make_args_projection({"redacted_user_id": "u-001"}) + for args in (hash_only, ref, projection): + env = _emit(payload_derived=_payload(args=args)) assert verify_attestation(env, verifying_material=HS_SECRET) - assert env.payload_derived[0].kind == args.kind def test_canonical_json_rejects_floats(): @@ -142,197 +156,33 @@ def test_canonical_json_rejects_floats(): def test_canonical_json_sorts_keys(): out_a = canonical_json({"b": 1, "a": 2}) out_b = canonical_json({"a": 2, "b": 1}) - assert out_a == out_b - assert out_a == b'{"a":2,"b":1}' + assert out_a == out_b == b'{"a":2,"b":1}' def test_canonical_json_unicode_handling(): - out = canonical_json({"name": "Sirkkavaara"}) - assert b'"name":"Sirkkavaara"' in out - - -def test_make_args_digest_is_deterministic(): - one = make_args_digest({"x": 1, "y": [1, 2]}) - two = make_args_digest({"y": [1, 2], "x": 1}) - assert one.digest == two.digest + assert b'"name":"Sirkkavaara"' in canonical_json({"name": "Sirkkavaara"}) def test_ttl_expired_returns_false(): - env = _emit_attestation(exp_seconds=60) - far_future = 9_999_999_999.0 + env = _emit(exp_seconds=60) assert verify_attestation( - env, verifying_material=HS_SECRET, now=far_future, + env, verifying_material=HS_SECRET, now=9_999_999_999.0, ) is False -def test_ttl_clock_skew_tolerance_window(): +def test_ttl_clock_skew_tolerance(): iat = "2026-05-26T12:00:00Z" - iat_epoch = datetime( - 2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc, - ).timestamp() - env = _emit_attestation(exp_seconds=60, iat=iat) - # default clock_skew_seconds=30: iat + 60 + 15 still inside window + iat_epoch = datetime(2026, 5, 26, 12, 0, 0, tzinfo=timezone.utc).timestamp() + env = _emit(exp_seconds=60, iat=iat) assert verify_attestation( env, verifying_material=HS_SECRET, now=iat_epoch + 60 + 15, ) is True - # iat + 60 + 31 is past the default skew window assert verify_attestation( env, verifying_material=HS_SECRET, now=iat_epoch + 60 + 31, ) is False -def test_emit_rejects_empty_intent(): - bad = PlannerDeclared( - intent=" ", - tool_calls=_planner().tool_calls, - ) - with pytest.raises(AttestationError, match="intent"): - _emit_attestation(planner_declared=bad) - - -def test_emit_rejects_unsupported_alg(): - with pytest.raises(AttestationError, match="unsupported alg"): - _emit_attestation(alg="HS512") - - -def test_hs256_rejects_non_bytes_secret(): - with pytest.raises(AttestationError, match="bytes shared_secret"): - _emit_attestation(signing_material="not-bytes") - - -def test_to_dict_round_trip_shape(): - env = _emit_attestation() - d = env.to_dict() - assert set(d) == { - "version", "alg", "plannerDeclared", - "issuerAsserted", "payloadDerived", "signature", - } - assert d["plannerDeclared"]["toolCalls"][0]["args"]["kind"] == "digest" - assert d["plannerDeclared"]["toolCalls"][0]["serverFingerprint"].startswith("sha256:") - assert d["plannerDeclared"]["requestedCapability"] == "filesystem.delete" - assert d["issuerAsserted"]["expSeconds"] == 300 - assert d["issuerAsserted"]["secretVersion"] == "v1" - assert d["payloadDerived"][0]["kind"] == "digest" - - def test_cross_alg_verification_fails(): - env = _emit_attestation() + env = _emit() priv = ec.generate_private_key(ec.SECP256R1()) assert verify_attestation(env, verifying_material=priv.public_key()) is False - - -# --- Step 5: argument commitment verification --- - - -def test_args_commitment_digest_matching_runtime_args_ok(): - runtime = {"path": "/archive/2024-Q3.md"} - args = make_args_digest(runtime) - result = verify_args_commitment(args, runtime_arguments=runtime) - assert result.ok is True - assert result.reason is None - - -def test_args_commitment_digest_mismatch_rejects(): - args = make_args_digest({"path": "/archive/2024-Q3.md"}) - result = verify_args_commitment( - args, runtime_arguments={"path": "/etc/passwd"}, - ) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" - - -def test_args_commitment_digest_key_reorder_still_matches(): - args = make_args_digest({"a": 1, "b": [1, 2, 3]}) - result = verify_args_commitment( - args, runtime_arguments={"b": [1, 2, 3], "a": 1}, - ) - assert result.ok is True - - -def test_args_commitment_ref_no_resolver_rejects(): - args = ArgsRef(ref="ipfs://Qm...", digest="sha256:" + "0" * 64) - result = verify_args_commitment(args, runtime_arguments={"x": 1}) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" - - -def test_args_commitment_ref_resolver_content_matches(): - runtime = {"path": "/archive/2024-Q3.md"} - canonical = canonical_json(runtime) - import hashlib as _h - digest = "sha256:" + _h.sha256(canonical).hexdigest() - args = ArgsRef(ref="memory://q3", digest=digest) - result = verify_args_commitment( - args, - runtime_arguments=runtime, - ref_resolver=lambda _ref: canonical, - ) - assert result.ok is True - - -def test_args_commitment_ref_digest_mismatch_rejects(): - runtime = {"path": "/archive/2024-Q3.md"} - args = ArgsRef(ref="memory://q3", digest="sha256:" + "0" * 64) - result = verify_args_commitment( - args, - runtime_arguments=runtime, - ref_resolver=lambda _ref: canonical_json(runtime), - ) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" - - -def test_args_commitment_ref_content_does_not_match_runtime_rejects(): - referenced = {"path": "/archive/2024-Q3.md"} - runtime = {"path": "/etc/passwd"} - canonical = canonical_json(referenced) - import hashlib as _h - digest = "sha256:" + _h.sha256(canonical).hexdigest() - args = ArgsRef(ref="memory://other", digest=digest) - result = verify_args_commitment( - args, - runtime_arguments=runtime, - ref_resolver=lambda _ref: canonical, - ) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" - - -def test_args_commitment_ref_resolver_raising_rejects(): - args = ArgsRef(ref="memory://oops", digest="sha256:" + "0" * 64) - def _broken(_ref): - raise RuntimeError("offline") - result = verify_args_commitment( - args, runtime_arguments={"x": 1}, ref_resolver=_broken, - ) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" - - -def test_args_commitment_identity_projection_marked_match(): - runtime = {"path": "/archive/2024-Q3.md"} - args = make_args_projection(runtime) - result = verify_args_commitment(args, runtime_arguments=runtime) - assert result.ok is True - assert result.projection_match is True - - -def test_args_commitment_redacted_projection_ok_but_not_identity(): - redacted = {"redacted_user_id": "u-001"} - args = make_args_projection(redacted) - result = verify_args_commitment( - args, runtime_arguments={"path": "/archive/2024-Q3.md", "user_id": "u-001"}, - ) - assert result.ok is True - assert result.projection_match is False - - -def test_args_commitment_projection_with_tampered_digest_rejects(): - from vaara.attestation.sep2787 import ArgsProjection - args = ArgsProjection( - projection={"redacted_user_id": "u-001"}, - projection_digest="sha256:" + "0" * 64, - ) - result = verify_args_commitment(args, runtime_arguments={"x": 1}) - assert result.ok is False - assert result.reason == "args_commitment_mismatch" diff --git a/tests/test_attestation_sep2787_shape.py b/tests/test_attestation_sep2787_shape.py new file mode 100644 index 0000000..f032a86 --- /dev/null +++ b/tests/test_attestation_sep2787_shape.py @@ -0,0 +1,136 @@ +"""SEP-2787 v2 envelope shape and validation tests. + +Covers wire-format invariants (toolCalls under payloadDerived, no +``kind`` field, JSON-stringified projection) and emit-side input +validation (empty intent, empty tool calls, unsupported alg, non-bytes +HS256 secret). +""" + +from __future__ import annotations + +import importlib.util +import json + +import pytest + +for _mod in ("rfc8785", "cryptography"): + if importlib.util.find_spec(_mod) is None: + pytest.skip( + "attestation extra not installed (pip install 'vaara[attestation]')", + allow_module_level=True, + ) + +from vaara.attestation.sep2787 import ( # noqa: E402 + ArgsRef, + Attestation, + AttestationError, + PayloadDerived, + PlannerDeclared, + ToolCallBinding, + emit_attestation, + make_args_digest, + make_args_projection, +) + +HS_SECRET = b"\x42" * 32 + + +def _planner() -> PlannerDeclared: + return PlannerDeclared( + intent="archive obsolete report per Q4 retention policy", + requested_capability="filesystem.delete", + ) + + +def _payload(args=None) -> PayloadDerived: + args = args or make_args_digest({"path": "/archive/2024-Q3.md"}) + return PayloadDerived(tool_calls=(ToolCallBinding( + name="delete_file", + server_fingerprint="sha256:" + "1" * 64, + args=args, + ),)) + + +def _emit(**overrides) -> Attestation: + kwargs = dict( + planner_declared=_planner(), + payload_derived=_payload(), + iss="issuer://test", + sub="agent:archiver", + secret_version="v1", + alg="HS256", + signing_material=HS_SECRET, + ) + kwargs.update(overrides) + return emit_attestation(**kwargs) + + +def test_make_args_digest_is_deterministic(): + one = make_args_digest({"x": 1, "y": [1, 2]}) + two = make_args_digest({"y": [1, 2], "x": 1}) + assert one.projection == two.projection + assert one.projection_digest == two.projection_digest + + +def test_make_args_digest_is_hash_only_identity(): + parsed = json.loads(make_args_digest({"path": "/archive/2024-Q3.md"}).projection) + assert set(parsed) == {"digest"} + assert parsed["digest"].startswith("sha256:") + + +def test_emit_rejects_empty_intent(): + with pytest.raises(AttestationError, match="intent"): + _emit(planner_declared=PlannerDeclared(intent=" ")) + + +def test_emit_rejects_empty_tool_calls(): + with pytest.raises(AttestationError, match="toolCalls"): + _emit(payload_derived=PayloadDerived(tool_calls=())) + + +def test_emit_rejects_unsupported_alg(): + with pytest.raises(AttestationError, match="unsupported alg"): + _emit(alg="HS512") + + +def test_hs256_rejects_non_bytes_secret(): + with pytest.raises(AttestationError, match="bytes shared_secret"): + _emit(signing_material="not-bytes") + + +def test_to_dict_top_level_shape(): + d = _emit().to_dict() + assert set(d) == { + "version", "alg", "plannerDeclared", + "issuerAsserted", "payloadDerived", "signature", + } + assert d["plannerDeclared"]["intent"] + assert d["plannerDeclared"]["requestedCapability"] == "filesystem.delete" + assert "toolCalls" not in d["plannerDeclared"] + assert d["issuerAsserted"]["expSeconds"] == 300 + assert d["issuerAsserted"]["secretVersion"] == "v1" + + +def test_to_dict_tool_calls_under_payload_derived(): + d = _emit().to_dict() + tc = d["payloadDerived"]["toolCalls"][0] + assert tc["name"] == "delete_file" + assert tc["serverFingerprint"].startswith("sha256:") + assert set(tc["args"]) == {"projection", "projectionDigest"} + assert "kind" not in tc["args"] + + +def test_to_dict_ref_has_no_kind(): + args = ArgsRef(ref="ipfs://Qm...", digest="sha256:" + "0" * 64) + a = _emit(payload_derived=_payload(args=args)).to_dict() + arg_dict = a["payloadDerived"]["toolCalls"][0]["args"] + assert set(arg_dict) == {"ref", "digest", "canonicalization"} + assert "kind" not in arg_dict + + +def test_to_dict_projection_is_json_stringified(): + args = make_args_projection({"redacted_user_id": "u-001"}) + d = _emit(payload_derived=_payload(args=args)).to_dict() + field = d["payloadDerived"]["toolCalls"][0]["args"]["projection"] + assert isinstance(field, str) + assert json.loads(field) == {"redacted_user_id": "u-001"} diff --git a/tests/test_attestation_sep2787_step5.py b/tests/test_attestation_sep2787_step5.py new file mode 100644 index 0000000..de5737d --- /dev/null +++ b/tests/test_attestation_sep2787_step5.py @@ -0,0 +1,127 @@ +"""SEP-2787 verifier step 5 tests (argument commitment verification).""" + +from __future__ import annotations + +import hashlib +import importlib.util + +import pytest + +for _mod in ("rfc8785", "cryptography"): + if importlib.util.find_spec(_mod) is None: + pytest.skip( + "attestation extra not installed (pip install 'vaara[attestation]')", + allow_module_level=True, + ) + +from vaara.attestation.sep2787 import ( # noqa: E402 + ArgsProjection, + ArgsRef, + canonical_json, + make_args_digest, + make_args_projection, + verify_args_commitment, +) + + +def _sha256_hex(content: bytes) -> str: + return "sha256:" + hashlib.sha256(content).hexdigest() + + +def test_hash_only_matching_runtime_ok(): + runtime = {"path": "/archive/2024-Q3.md"} + result = verify_args_commitment(make_args_digest(runtime), runtime_arguments=runtime) + assert result.ok is True + assert result.reason is None + assert result.projection_match is True + + +def test_hash_only_mismatch_rejects(): + args = make_args_digest({"path": "/archive/2024-Q3.md"}) + result = verify_args_commitment(args, runtime_arguments={"path": "/etc/passwd"}) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" + + +def test_hash_only_key_reorder_still_matches(): + args = make_args_digest({"a": 1, "b": [1, 2, 3]}) + result = verify_args_commitment(args, runtime_arguments={"b": [1, 2, 3], "a": 1}) + assert result.ok is True + assert result.projection_match is True + + +def test_ref_no_resolver_rejects(): + args = ArgsRef(ref="ipfs://Qm...", digest="sha256:" + "0" * 64) + result = verify_args_commitment(args, runtime_arguments={"x": 1}) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" + + +def test_ref_resolver_content_matches(): + runtime = {"path": "/archive/2024-Q3.md"} + canonical = canonical_json(runtime) + args = ArgsRef(ref="memory://q3", digest=_sha256_hex(canonical)) + result = verify_args_commitment( + args, runtime_arguments=runtime, ref_resolver=lambda _ref: canonical, + ) + assert result.ok is True + + +def test_ref_digest_mismatch_rejects(): + runtime = {"path": "/archive/2024-Q3.md"} + args = ArgsRef(ref="memory://q3", digest="sha256:" + "0" * 64) + result = verify_args_commitment( + args, runtime_arguments=runtime, + ref_resolver=lambda _ref: canonical_json(runtime), + ) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" + + +def test_ref_content_does_not_match_runtime_rejects(): + referenced = {"path": "/archive/2024-Q3.md"} + runtime = {"path": "/etc/passwd"} + canonical = canonical_json(referenced) + args = ArgsRef(ref="memory://other", digest=_sha256_hex(canonical)) + result = verify_args_commitment( + args, runtime_arguments=runtime, ref_resolver=lambda _ref: canonical, + ) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" + + +def test_ref_resolver_raising_rejects(): + args = ArgsRef(ref="memory://oops", digest="sha256:" + "0" * 64) + def _broken(_ref): + raise RuntimeError("offline") + result = verify_args_commitment(args, runtime_arguments={"x": 1}, ref_resolver=_broken) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" + + +def test_identity_projection_marked_match(): + runtime = {"path": "/archive/2024-Q3.md"} + result = verify_args_commitment(make_args_projection(runtime), runtime_arguments=runtime) + assert result.ok is True + assert result.projection_match is True + + +def test_redacted_projection_ok_but_not_identity(): + args = make_args_projection({"redacted_user_id": "u-001"}) + result = verify_args_commitment( + args, + runtime_arguments={"path": "/archive/2024-Q3.md", "user_id": "u-001"}, + ) + assert result.ok is True + assert result.projection_match is False + + +def test_projection_with_tampered_digest_rejects(): + payload = canonical_json({"redacted_user_id": "u-001"}) + args = ArgsProjection( + projection=payload.decode("utf-8"), + projection_digest="sha256:" + "0" * 64, + ) + result = verify_args_commitment(args, runtime_arguments={"x": 1}) + assert result.ok is False + assert result.reason == "args_commitment_mismatch" diff --git a/tests/test_attestation_sep2787_wire.py b/tests/test_attestation_sep2787_wire.py new file mode 100644 index 0000000..87b80ee --- /dev/null +++ b/tests/test_attestation_sep2787_wire.py @@ -0,0 +1,198 @@ +"""SEP-2787 wire round-trip: emit, serialise to JSON bytes, parse back, verify. + +Covers the path a third-party consumer of the published v0 test +vectors actually takes: + + emit_attestation(...) -> Attestation + -> canonical_json(env.to_dict()) -> bytes + -> json.loads(bytes) -> dict + -> parse_attestation(dict) -> Attestation + -> verify_attestation(parsed, verifying_material=...) -> True + +The Python in-memory round-trip tests in test_attestation_sep2787.py +verify the emit/verify pair but do not exercise the JSON boundary. +If to_dict() ever produces a shape that, when JCS-canonicalised by a +wire consumer, does not byte-match what was signed, in-memory tests +still pass but the published vectors break. These tests close that gap. +""" + +from __future__ import annotations + +import importlib.util +import json + +import pytest + +for _mod in ("rfc8785", "cryptography"): + if importlib.util.find_spec(_mod) is None: + pytest.skip( + "attestation extra not installed (pip install 'vaara[attestation]')", + allow_module_level=True, + ) + +from cryptography.hazmat.primitives.asymmetric import ec, rsa # noqa: E402 + +from vaara.attestation.sep2787 import ( # noqa: E402 + ArgsRef, + AttestationError, + PayloadDerived, + PlannerDeclared, + ToolCallBinding, + canonical_json, + emit_attestation, + make_args_digest, + parse_attestation, + verify_attestation, +) + +HS_SECRET = b"\x42" * 32 + + +def _planner() -> PlannerDeclared: + return PlannerDeclared( + intent="archive obsolete report per Q4 retention policy", + requested_capability="filesystem.delete", + ) + + +def _payload(args=None) -> PayloadDerived: + args = args or make_args_digest({"path": "/archive/2024-Q3.md"}) + return PayloadDerived(tool_calls=(ToolCallBinding( + name="delete_file", + server_fingerprint="sha256:" + "1" * 64, + args=args, + ),)) + + +def _emit(**overrides): + kwargs = dict( + planner_declared=_planner(), + payload_derived=_payload(), + iss="issuer://test", + sub="agent:archiver", + secret_version="v1", + alg="HS256", + signing_material=HS_SECRET, + ) + kwargs.update(overrides) + return emit_attestation(**kwargs) + + +def _wire(env): + """Serialise the envelope to canonical JSON bytes, then parse back.""" + wire_bytes = canonical_json(env.to_dict()) + parsed_dict = json.loads(wire_bytes) + return parse_attestation(parsed_dict) + + +def test_wire_round_trip_hs256(): + env = _emit() + parsed = _wire(env) + assert verify_attestation(parsed, verifying_material=HS_SECRET) is True + + +def test_wire_round_trip_es256(): + priv = ec.generate_private_key(ec.SECP256R1()) + env = _emit(alg="ES256", signing_material=priv) + parsed = _wire(env) + assert verify_attestation(parsed, verifying_material=priv.public_key()) is True + + +def test_wire_round_trip_rs256(): + priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + env = _emit(alg="RS256", signing_material=priv) + parsed = _wire(env) + assert verify_attestation(parsed, verifying_material=priv.public_key()) is True + + +def test_wire_round_trip_args_ref(): + """ArgsRef goes through the wire intact.""" + ref_args = ArgsRef( + ref="cid://blob/sha256/" + "a" * 64, + digest="sha256:" + "a" * 64, + ) + env = _emit(payload_derived=_payload(args=ref_args)) + parsed = _wire(env) + assert verify_attestation(parsed, verifying_material=HS_SECRET) is True + binding = parsed.payload_derived.tool_calls[0] + assert isinstance(binding.args, ArgsRef) + assert binding.args.ref == ref_args.ref + assert binding.args.digest == ref_args.digest + + +def test_wire_round_trip_args_projection(): + """ArgsProjection (hash-only-identity shape) goes through the wire intact.""" + env = _emit() + parsed = _wire(env) + binding = parsed.payload_derived.tool_calls[0] + from vaara.attestation.sep2787 import ArgsProjection + assert isinstance(binding.args, ArgsProjection) + assert binding.args.projection_digest.startswith("sha256:") + + +def test_parse_rejects_missing_signature(): + env = _emit() + d = env.to_dict() + del d["signature"] + with pytest.raises(AttestationError, match="signature"): + parse_attestation(d) + + +def test_parse_rejects_missing_planner_declared(): + env = _emit() + d = env.to_dict() + del d["plannerDeclared"] + with pytest.raises(AttestationError, match="plannerDeclared"): + parse_attestation(d) + + +def test_parse_rejects_unsupported_alg(): + env = _emit() + d = env.to_dict() + d["alg"] = "HS512" + with pytest.raises(AttestationError, match="unsupported alg"): + parse_attestation(d) + + +def test_parse_rejects_missing_tool_calls(): + env = _emit() + d = env.to_dict() + del d["payloadDerived"]["toolCalls"] + with pytest.raises(AttestationError, match="toolCalls"): + parse_attestation(d) + + +def test_parse_rejects_args_without_discriminator(): + env = _emit() + d = env.to_dict() + d["payloadDerived"]["toolCalls"][0]["args"] = {"unknown": "field"} + with pytest.raises(AttestationError, match="missing both"): + parse_attestation(d) + + +def test_parse_rejects_args_projection_without_digest(): + env = _emit() + d = env.to_dict() + d["payloadDerived"]["toolCalls"][0]["args"] = {"projection": "{}"} + with pytest.raises(AttestationError, match="projectionDigest"): + parse_attestation(d) + + +def test_wire_round_trip_preserves_optional_requested_capability_absent(): + """If plannerDeclared has no requestedCapability, it stays absent through the wire.""" + planner = PlannerDeclared(intent="read-only audit query", requested_capability=None) + env = _emit(planner_declared=planner) + wire_bytes = canonical_json(env.to_dict()) + assert b"requestedCapability" not in wire_bytes + parsed = _wire(env) + assert parsed.planner_declared.requested_capability is None + assert verify_attestation(parsed, verifying_material=HS_SECRET) is True + + +def test_wire_bytes_byte_identical_round_trip(): + """Re-emitting the parsed envelope produces the same wire bytes.""" + env = _emit() + first = canonical_json(env.to_dict()) + parsed = parse_attestation(json.loads(first)) + second = canonical_json(parsed.to_dict()) + assert first == second