diff --git a/README.md b/README.md index d5a87c72..3380e847 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ proxy. It checks mission, resource, budget, and delegation constraints before that integration dispatches the call, then emits an issuer-signed, hash-linked receipt for the decision. +For issuer-selected dangerous tools, an optional signed `risk_budget` claim +binds authenticated tool schemas to typed per-action impact caps and atomic +session, agent, and lineage ceilings. The executor must explicitly close every +permitted reservation as committed once execution may have started, or as +released only when execution never started. This does not infer semantic risk +or hidden side effects; see the +[typed risk-budget reference](docs/reference/risk-budgets.md). + [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-pre--release-blue)](STATUS.md) [![Discussions](https://img.shields.io/badge/GitHub-Discussions-181717?logo=github)](https://github.com/ArdurAI/ardur/discussions) @@ -216,6 +224,7 @@ Concretely — these are the design principles the repo is being built to meet, - **Composable with what already exists.** Designed around SPIFFE for workload identity, Biscuit for first-party-attenuation credentials, Cedar for policy, the individual AAT Internet-Draft for delegation-token semantics, and EAT (RFC 9711) for attestation-token semantics. We didn't reinvent the substrate. - **Cryptographically bound by design.** Mission credentials are designed to be signed by an issuer key and produce signed receipts chain-hashed to the previous one. The Python Biscuit path reports SPIFFE holder binding only when the proxy has a server-owned Biscuit issuer key, JWT-SVID trust bundle, and audience and the presented credentials verify against them; request payloads cannot choose those verifier inputs. JWT-SVID itself remains a replayable bearer credential, so this is bounded holder evidence rather than universal replay prevention. The design is documented in the [ADRs](docs/decisions/README.md); the public code that implements it is being curated in phases. - **Delegation that narrows, never widens.** Child sessions get strictly narrower authority than their parent — fewer tools, smaller resource scope, smaller budget. The narrowing discipline is formalised in [ADR-017](docs/decisions/ADR-017-biscuit-attenuation-narrowing-semantics.md). +- **Impact caps before dangerous actions.** Opted-in Mission Passports bind trusted tool contracts to typed action caps and atomically conserved session/agent/lineage ceilings. Crash reservations quarantine instead of silently refunding authority; the design is recorded in [ADR-026](docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md). - **No authority by omission.** An absent or empty `resource_scope` grants no resource authority. Operators who intentionally permit every resource must sign the sole explicit wildcard `resource_scope: ["**"]`; issuance and governed-run surfaces warn when they do. The decision and format-specific attenuation rules are documented in [ADR-023](docs/decisions/ADR-023-explicit-resource-scope-authority.md). - **Explicit about what it doesn't do.** Scope-level governance can't catch semantic misuse — if an allowed tool is used on an allowed resource for the wrong reason, that's a different layer's job. - **MIT licensed.** The research foundation (the Silence Theorem, the protocol formalism, the benchmark methodology) will be linked from this repo when the paper's public identifier is assigned. Articles in this repo paraphrase the research in original prose; they do not reproduce paper content. @@ -229,6 +238,7 @@ This repo currently includes: - current status and what is still being resolved - public v0.1 specs for mission declarations, execution receipts, verifier contracts, conformance profiles, and related protocol surfaces, plus a draft-10-pinned DRP mapping and executable profile with RFC 8785/P-256 emit, external-trust full-chain and critical-bound verification, and a portable seven-scenario implementation self-test bundle/report (not an IETF or independent interoperability claim), the v0.2 Execution Receipt hardening profile with versioned RFC 8785 payloads and legacy verification, a transparency-anchor sidecar profile with offline-verifiable Rekor v1 and separately keyed self-hosted proofs, a receiver-attestation profile with a two-key offline verifier and MCP shim fixture, a full offline-verification bundle/profile with redacted CLI/JSON/static HTML explorer reports, and a verified-receipt governance telemetry profile with redacted JSONL plus OTLP/HTTP trace/log export - Python governance runtime under `python/`; Go eBPF/K8s packages and version-dispatched JWT AAT credential attenuation under `go/`: the existing draft-00 DG v0.1 contract plus the explicit `ardur.dg.aat-draft-01.v0.2` profile with chain-position roles, audience-bound PoP, fresh per-hop holder keys, approval gates, and a deterministic self-test fixture (CWT and independent interoperability are not claimed) +- optional Python typed dangerous-action risk budgets with authenticated schema/extractor digests, signed attenuation, fsync-backed multi-scope reservations, explicit executor outcomes, and privacy-bounded signed receipts; the existing DRP profile does not project this extension - a Linux governance-overhead harness with a closed report schema, PR smoke workflow, manual stress profile, owner-only artifacts, and an opt-in shell-free paired-sensor mode - the Ardur Personal Hub service and CLI under `python/vibap/` (`ardur hub`, `ardur setup`, `ardur status`, `ardur protect claude-code`, `ardur profile init`, `ardur doctor-claude-code`, full offline evidence verification, verified redacted receipt telemetry export, receiver-envelope verification, detached normalized/Tetragon/Falco runtime-evidence correlation, static non-executing MCP/tool-server preflight, and no-key DRP/receiver/offline-verification fixtures), plus deterministic `ardur-drp-fixtures` and `ardur-policy-conformance` runners - the Claude Code plugin under `plugins/claude-code/` with `PreToolUse`, `PostToolUse`, `SubagentStart`, and `SubagentStop` hooks emitting signed receipts diff --git a/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md b/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md new file mode 100644 index 00000000..db36d450 --- /dev/null +++ b/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md @@ -0,0 +1,103 @@ +# ADR-026: Typed dangerous-action risk budgets + +**Status:** Accepted + +**Date:** 2026-07-14 + +## Context + +Tool allowlists and total call counts limit which operations an agent may +invoke and how often, but not the impact of one permitted invocation. One +allowed deletion could address one object or one million; one allowed send +could remain private or disclose regulated data publicly. Prompt-based risk +labels and MCP annotations are caller/server assertions, not a trustworthy +pre-action enforcement input. + +Impact caps must also survive concurrent agents and delegated sessions. A +read-check-write counter per process lets siblings simultaneously observe the +same remaining authority. Charging only after execution allows irreversible +actions to oversubscribe before the runtime records them. Automatically +returning a timed-out charge can race an executor that is still running. + +Primary inputs to the decision were RFC 8785, JSON Schema 2020-12, the current +MCP tools specification, OAuth Attenuating Agent Tokens draft-01, Agent +Delegation Receipt Protocol draft-10, and Python's `flock`/`os.replace` +contracts. See +the [risk-budget reference](../reference/risk-budgets.md#protocol-boundary-and-primary-sources). + +## Decision + +1. Mission Passports may carry an optional versioned `risk_budget` claim. + Absence preserves existing runtime behavior. +2. Each governed tool is bound to a trusted `ToolRiskContract` digest over the + authenticated tool name, JSON Schema, and a closed declarative extractor + program. The registry freezes at proxy startup. +3. Contracts derive mandatory typed facts locally. Numeric facts are additive; + categorical facts use closed ordered vocabularies. Missing, unknown, + malformed, negative, non-integral, oversized, or schema-invalid input fails + closed. +4. Signed policy contains per-action fact caps plus numeric session, agent, and + lineage ceilings. Delegation preserves lineage and contract/fact identity + while allowing only tool subsets and lower/equal caps. The first governed + call freezes a normalized session snapshot; reservations retain the exact + accounting ceilings used at authorization. Tools removed during delegation + also remove numeric ceilings that no retained tool references. +5. `evaluate_tool_call` atomically reserves all numeric facts across all three + scopes before ordinary policy can return `PERMIT`. A unique request ID is + mandatory for governed actions; active or terminal replay cannot re-permit. +6. The executor explicitly records `committed` once execution may have started + or `released` only when it did not start. Session finalization refuses active + or quarantined reservations and resolved lifecycle events whose receipts are + not yet durable. Exceptions never imply release, and quarantined reservations + may only reconcile as committed. +7. Stale active reservations quarantine while retaining authority. Explicit + reconciliation is preferred. After expiry and a bounded quarantine window, + pruning conservatively archives uncertainty as spent. Terminal compaction + preserves request/fingerprint tombstones for a bounded replay window and + never refunds committed authority. +8. Action and lifecycle receipts contain only a canonical fact digest, bounded + remaining counters, and stable denial classes. Raw facts, request IDs, + targets, paths, URLs, and secrets are excluded. Ledger lifecycle state and + persisted session receipt material form a retry-safe outbox; compaction does + not discard a terminal record before receipt delivery. +9. The existing DRP profile does not project `risk_budget`. Emitters must fail + closed instead of silently dropping the extension. + +## Consequences + +- Configured dangerous tools can enforce per-action and cumulative impact caps + before dispatch, including across processes and delegated agents. +- The proxy adds JSON Schema validation and an fsync-backed reservation plus + outcome transaction for each governed action. All lineages serialize on one + global ledger lock so agent ceilings remain atomic across lineage boundaries; + this favors safety over high-throughput authorization. +- An executor crash conservatively consumes/quarantines authority until an + operator or recovery controller explicitly reconciles it, or bounded + post-expiry maintenance archives the uncertainty as spent. +- Contract authors become part of the trusted computing base: a schema or + extractor that understates impact cannot be repaired by the ledger. +- The feature does not discover hidden side effects, validate the truth of + tool arguments, classify semantic intent, or govern calls that bypass the + configured adapter. +- The runtime adds no network dependency or cloud-service cost, but durable + receipt/ledger/tombstone storage and high-throughput lineage contention are + operating costs. Receipt-sink failure intentionally retains outbox records + and can exhaust the bounded ledger rather than lose audit evidence. + +## Alternatives considered + +- **Trust a caller-provided risk object.** Rejected because the actor seeking + authorization could choose its own impact label. +- **Use MCP annotations directly.** Rejected because the MCP specification + treats annotations as untrusted unless the server is trusted; annotations + also do not provide cumulative atomic accounting. +- **Charge after tool completion.** Rejected because concurrent irreversible + actions could all pass before any charge was recorded. +- **Return stale reservations automatically.** Rejected because timeout does + not prove the executor stopped. +- **Use the existing delegation-call ledger.** Rejected because its single + call-count dimension cannot atomically conserve multiple typed facts across + session, agent, and lineage scopes. +- **Store raw facts and identifiers for easier debugging.** Rejected because + targets, destinations, and secret classifications are sensitive audit data; + digests and bounded counters are sufficient for enforcement evidence. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 48a1bb7f..8c941588 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,9 +20,14 @@ ADRs are migrated from the private research repo with the two-pass cleanup appli | 022 | [SPIFFE mTLS identity for operator telemetry](./ADR-022-operator-telemetry-spiffe-mtls.md) | Accepted | 2026-07-11 | | 023 | [Explicit resource-scope authority](./ADR-023-explicit-resource-scope-authority.md) | Accepted | 2026-07-12 | | 024 | [Self-asserted owner identity assurance](./ADR-024-self-asserted-owner-identity-assurance.md) | Accepted | 2026-07-12 | +| 026 | [Typed dangerous-action risk budgets](./ADR-026-typed-dangerous-action-risk-budgets.md) | Accepted | 2026-07-14 | + +ADR-025 is reserved by a concurrently reviewed spend-gate decision. Parallel +issue branches may therefore show ADR-026 before ADR-025 lands in `dev`; the +reservation avoids a guaranteed rename conflict between focused changes. ## Conventions - **Status**: `Proposed`, `Accepted`, `Superseded by ADR-NNN`, `Deprecated`. A `Proposed` status means the design is documented but not yet landed in code; it can still change. -- **Numbering**: sequential, no gaps. The formal ADR-file practice began at ADR-015 in the private research repo; earlier design decisions were captured in running decision logs rather than individual ADR files. Public numbering preserves the original sequence so cross-references stay stable. +- **Numbering**: sequential with no gaps on `dev`. Concurrent branches may reserve the next number when the reservation is documented; an abandoned reservation must be reclaimed or later ADRs renumbered before merge. The formal ADR-file practice began at ADR-015 in the private research repo; earlier design decisions were captured in running decision logs rather than individual ADR files. Public numbering preserves the original sequence so cross-references stay stable. - **Scope**: ADRs record decisions about the protocol (MCEP), the runtime (Ardur), and deployment shapes. They do not duplicate spec content — the v0.1 specs live in [`docs/specs/`](../specs/). diff --git a/docs/reference/README.md b/docs/reference/README.md index c5777e0a..524878c5 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -22,6 +22,9 @@ walkthroughs see [`../guides/`](../guides/); for protocol semantics see - [Advisory AI Controls](advisory-ai-controls.md) — semantic-judge and behavioral-fingerprint defaults, non-authoritative status, failure policy, cost, and integration requirements +- [Typed Dangerous-Action Risk Budgets](risk-budgets.md) — authenticated tool + contracts, signed impact caps, atomic session/agent/lineage accounting, + executor outcomes, receipts, and crash recovery - [Agent Recognition Evaluation](agent-recognition-evaluation.md) — versioned maintained corpus, deterministic metrics, Wilson intervals, CI thresholds, and claim boundaries @@ -37,5 +40,6 @@ These pages mirror the public source. When the underlying surface changes `go/pkg/kernelcapture/testdata/agent_recognition_corpus.json`, `go/pkg/kernelcapture/testdata/agent_recognition_thresholds.json`, `python/vibap/semantic_judge.py`, `python/vibap/behavioral_fingerprint.py`, +`python/vibap/risk_budget.py`, `Dockerfile.proxy`, or its release workflow), update the matching page in the same change. They are deliberately mechanical so the diff is easy to review. diff --git a/docs/reference/risk-budgets.md b/docs/reference/risk-budgets.md new file mode 100644 index 00000000..409eb616 --- /dev/null +++ b/docs/reference/risk-budgets.md @@ -0,0 +1,282 @@ +# Typed Dangerous-Action Risk Budgets + +Ardur's optional `risk_budget` claim reserves signed impact ceilings before a configured dangerous tool may run. + +A trusted tool contract derives typed facts from schema-validated arguments, +compares them with signed per-action caps, and atomically reserves additive +session, agent, and lineage ceilings. Passports without `risk_budget` keep the +existing behavior. + +This is an enforcement boundary for configured proxy/adaptor calls. It is not +automatic discovery of every side effect, a semantic-risk classifier, or +proof that the tool reported truthful arguments. + +## Decision flow + +```mermaid +flowchart LR + A["Authenticated tool schema and risk contract"] --> B["Validate arguments and derive typed facts"] + B --> C["Check signed per-action caps"] + C --> D["Atomically reserve session, agent, and lineage ceilings"] + D --> E["Run ordinary Ardur policy and approval checks"] + E --> F["PERMIT: adapter may dispatch"] + F --> G["Executor records committed or released"] + G --> H["Signed lifecycle receipt"] +``` + +The reservation happens before ordinary policy returns `PERMIT`. If ordinary +policy denies, Ardur releases the reservation because the external executor +has not started. If policy evaluation raises, Ardur retains the reservation: +an exception is not evidence that execution never began. Proxy-internal memory +tools execute during policy evaluation and therefore cannot be registered for +typed risk governance. + +## Trusted tool contract + +`ToolRiskContract.from_schema(tool_name, input_schema, risk_contract)` binds +the following RFC 8785-canonical object to a `sha256:` digest: + +```json +{ + "tool_name": "storage.delete_objects", + "input_schema": { + "type": "object", + "properties": { + "targets": {"type": "array", "items": {"type": "string"}}, + "bytes": {"type": "integer", "minimum": 0}, + "irreversibility": { + "type": "string", + "enum": ["reversible", "compensatable", "irreversible"] + } + }, + "required": ["targets", "bytes", "irreversibility"], + "additionalProperties": false + }, + "risk_contract": { + "version": 1, + "mandatory_facts": [ + "objects_affected", + "bytes_affected", + "irreversibility" + ], + "extractors": { + "objects_affected": {"kind": "array_length", "pointer": "/targets"}, + "bytes_affected": {"kind": "integer", "pointer": "/bytes"}, + "irreversibility": {"kind": "enum", "pointer": "/irreversibility"} + } + } +} +``` + +The extractor vocabulary is closed: + +| Kind | Source | Result | +|---|---|---| +| `integer` | RFC 6901 pointer | Exact non-negative integer; booleans and floats deny | +| `array_length` | RFC 6901 pointer | Non-negative array length | +| `enum` | RFC 6901 pointer | A value in the fact's versioned categorical order | +| `constant` | Contract value | A trusted numeric or categorical constant | + +Input schemas use JSON Schema 2020-12. Invalid schemas, external references, +oversized/deep schemas, malformed pointers, oversized arguments, missing fact +sources, and schema-invalid arguments fail closed. The registry rejects +replacement and freezes at proxy startup. Contracts retain canonical byte +snapshots internally; returned schema and extractor objects are detached views +that cannot mutate registered authority or its digest. + +MCP `inputSchema` is suitable contract input only after the server and tool +definition have been authenticated. MCP tool annotations are untrusted hints +unless the server itself is trusted, so Ardur does not use them as risk facts. +Prompt text, model-generated risk labels, network lookups, scanners, and tool +execution are also outside extraction. + +## Fact vocabulary + +Version 1 supports additive numeric facts: + +- `destructive_targets` +- `objects_affected` +- `bytes_affected` + +It also supports ordered categorical facts: + +- `secret_sensitivity`: `none`, `public`, `internal`, `confidential`, + `restricted`, `regulated`, `unknown` +- `destination_risk`: `local`, `private_network`, `trusted_service`, + `public_internet`, `untrusted`, `unknown` +- `filesystem_scope`: `none`, `declared`, `workspace`, `external`, `system`, + `unknown` +- `irreversibility`: `reversible`, `compensatable`, `irreversible`, `unknown` + +`unknown` facts deny a governed action. Numeric values are bounded to signed +64-bit non-negative integers. + +## Mission Passport claim + +```json +{ + "risk_budget": { + "version": 1, + "lineage_id": "", + "tools": { + "storage.delete_objects": { + "contract_digest": "sha256:<64 lowercase hex>", + "max_facts": { + "objects_affected": 10, + "bytes_affected": 1048576, + "irreversibility": "compensatable" + } + } + }, + "ceilings": { + "objects_affected": {"session": 20, "agent": 50, "lineage": 100}, + "bytes_affected": { + "session": 2097152, + "agent": 5242880, + "lineage": 10485760 + } + } + } +} +``` + +Every numeric fact used by a tool policy requires all three ceilings. Tool +entries must be a subset of `allowed_tools`. A root issuer fills an omitted +`lineage_id` with the new passport JTI. Child passports inherit the policy or +provide an explicit policy with the same lineage, a subset of tools, the same +contract digests and fact sets for retained tools, and caps/ceilings no greater +than their parent. Removing a tool also removes numeric ceilings referenced +only by that tool. A parent without `risk_budget` cannot introduce it in a child. + +The first governed call freezes a normalized risk-policy snapshot in the +persisted session. Later mission-policy refreshes may continue to affect other +authorization rules, but a changed or removed risk policy fails closed. Outcome +accounting uses the ceilings captured in the reservation, not a later policy +view. + +## Runtime API + +Create and freeze contracts before constructing the proxy: + +```python +registry = ToolRiskRegistry() +registry.register(contract) +proxy = GovernanceProxy(risk_registry=registry, ...) +``` + +Each governed invocation requires a unique executor-generated request ID: + +```python +decision, reason = proxy.evaluate_tool_call( + session, + "storage.delete_objects", + arguments, + risk_request_id=request_id, +) +``` + +The HTTP equivalent supplies `risk_request_id` to `POST /evaluate`. After a +`PERMIT`, report exactly one explicit outcome: + +```python +proxy.record_risk_outcome( + session, + risk_request_id=request_id, + outcome="committed", # or "released" +) +``` + +The HTTP equivalent is `POST /risk/outcome` with `session_id`, +`risk_request_id`, and `outcome`. `released` is valid only when the executor +did not start. If execution may have started, record `committed` even when the +tool later reports an error. A repeated active, committed, or released request +ID cannot receive another `PERMIT`. + +## Crash and lifecycle behavior + +The global file ledger stores only hashes of lineage, session, agent, request, +and fingerprint identities. One `flock`-protected, fsync-backed replacement +transaction updates every fact and scope across all lineages, so agent ceilings +cannot be spent independently in separate lineages. Ledger invariants require account +`reserved` totals to equal active plus quarantined reservations and account +`spent` totals to equal retained plus archived committed reservations. + +`quarantine_stale_risk_reservations(session, stale_after_s=...)` converts stale +active reservations for that session to `quarantined` without returning +authority. Quarantine and later explicit reconciliation produce separate +signed lifecycle receipts. A session cannot end or issue its final attestation +while active or quarantined reservations remain, or while a resolved lifecycle +receipt is still pending delivery. + +Quarantined reservations cannot be released; explicit reconciliation may only +commit them. After passport expiry and at least 24 hours in quarantine, pruning +conservatively archives them as spent. Expired committed/released records are +pruned only after their lifecycle receipt is delivered. Committed amounts move +to `archived_spent`, so pruning never restores spent authority. Every pruned +record leaves a request/fingerprint hash tombstone for at least one additional +hour, preventing compaction from re-permitting the same request during its +authorization lifetime. Both reservation and tombstone stores are bounded; +capacity exhaustion fails closed until maintenance advances retention. + +Outcome and quarantine transitions use a durable receipt outbox. The ledger +records a deterministic lifecycle ID, the session atomically persists the +signed receipt material, the receipt journal appends that receipt ID at most +once with `fsync`, and only then does the ledger mark delivery complete. A retry +after any intermediate crash resumes the same receipt rather than minting a +second chain entry. Ledger files and locks reject symlink substitution and use +private `0700`/`0600` modes. + +## Receipts, metrics, and privacy + +Action and lifecycle receipts include: + +- `measurements.risk_facts`: a SHA-256 digest of canonical typed facts; +- bounded `budget_remaining` keys such as `objects_affected.lineage`; and +- stable internal denial codes, with risk exhaustion mapped to the public + `budget_exhausted` class. + +They do not include raw targets, paths, URLs, secrets, facts, request IDs, or +ledger identity hashes. Lifecycle events are excluded from ordinary action +permit/denial counts and tool-scope checks. Prometheus metrics use only fixed +operation, outcome, fact, and reason labels. + +## Failure behavior + +| Condition | Decision | +|---|---| +| Missing/invalid request ID, policy, contract, fact, or ledger state | `INSUFFICIENT_EVIDENCE` | +| Per-action cap exceeded | `DENY` | +| Session/agent/lineage ceiling exhausted | `DENY` | +| Active or terminal request replay | `DENY` | +| Ordinary policy denies after reservation | Ordinary denial; reservation released | +| Policy evaluation raises after reservation | Exception propagated; reservation retained | +| Attempt to release a quarantined reservation | Reconciliation denied; authority retained | +| Unresolved action at session end | Session finalization denied | + +## Operability and cost + +The runtime performs no risk-classification network calls and adds no cloud +service charge by itself. Each governed action adds a local canonicalization, +JSON Schema validation, and fsync-backed reservation; each outcome adds another +ledger transaction. Mutations within one lineage serialize on one lock, so a +single very high-throughput lineage may need sharding at issuance. Metrics stay +bounded; receipt and ledger retention still consume local storage and should be +included in operational capacity planning. A prolonged receipt-sink failure +retains terminal outbox records and can deliberately stop new reservations at +the bounded capacity limit. + +## Protocol boundary and primary sources + +`risk_budget` is currently an Ardur Mission Passport/runtime extension. The +repository's existing DRP profile does not project or verify it; a DRP emitter +must fail closed rather than drop it. This change does not claim DRP, MCP, or +AAT interoperability for the extension. + +Primary references: + +- [RFC 8785: JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) +- [MCP tools specification, 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +- [OAuth Attenuating Agent Tokens, draft-01](https://datatracker.ietf.org/doc/draft-niyikiza-oauth-attenuating-agent-tokens/) +- [Delegation Receipt Protocol, draft-10](https://datatracker.ietf.org/doc/html/draft-nelson-agent-delegation-receipts-10) +- [Python `fcntl` locking](https://docs.python.org/3/library/fcntl.html) +- [Python atomic `os.replace`](https://docs.python.org/3/library/os.html#os.replace) diff --git a/docs/specs/ardur-drp-implementation-interop-v0.1.md b/docs/specs/ardur-drp-implementation-interop-v0.1.md index 5e087d57..7d020a70 100644 --- a/docs/specs/ardur-drp-implementation-interop-v0.1.md +++ b/docs/specs/ardur-drp-implementation-interop-v0.1.md @@ -48,6 +48,7 @@ conformance designation. | Strict scope attenuation | `supported` | Every child effective allowed-action set must be a strict proper subset; signed widening denies. | | No and bounded re-delegation | `extension` | `metadata.x-ardur.redelegation` carries mode, depth, and maximum depth because draft-10 does not serialize all of them. | | Resource, argument, cwd, and budget attenuation | `extension` | Critical Ardur fields are verified transitively and against the concrete requested action. Unknown critical fields deny. | +| Typed dangerous-action risk budgets | `not-yet` | The Python Mission Passport/runtime supports `risk_budget`, but this DRP emitter/verifier does not project its contract digest, typed facts, or multi-scope ledger. Input carrying the claim must fail closed; no DRP compatibility is claimed. | | Operator instruction commitment | `supported` | Signed text/hash are compared with current externally supplied instructions. | | Tool schema commitment | `partial` | A finite tool-universe digest is verified. Broader model/provider state attestation is not implemented here. | | Delegation-log policy | `partial` | Signed backend/subject policy and preverified inclusion facts are required. Raw RFC 3161 response parsing and trust validation are not implemented by this module. | diff --git a/docs/specs/ardur-drp-mapping-v0.1.json b/docs/specs/ardur-drp-mapping-v0.1.json index c4e12e06..1c37f570 100644 --- a/docs/specs/ardur-drp-mapping-v0.1.json +++ b/docs/specs/ardur-drp-mapping-v0.1.json @@ -25,7 +25,7 @@ }, "legacy_python_passport": { "contract": "python/vibap/passport.py", - "scope": "JWT mission-passport claims emitted by issue_passport plus derive_child_passport lineage extensions." + "scope": "JWT mission-passport claims emitted by issue_passport plus derive_child_passport lineage extensions, including explicit inventory of runtime-only claims that the current DRP profile must reject." }, "execution_receipt_v0.2": { "contract": "docs/specs/execution-receipt-v0.2.schema.json", @@ -96,6 +96,7 @@ "child_time_window_contained": "A child notBefore MUST be no earlier than its parent and child notAfter MUST be no later.", "widening_rejected": "Tools, resources, argument constraints, budgets, time, and delegation depth MUST never widen.", "unknown_critical_extension_rejected": "A verifier that does not understand every path listed in metadata.x-ardur.critical MUST DENY and MUST NOT downgrade to base DRP authorization.", + "unprojected_risk_budget_rejected": "A source credential carrying risk_budget MUST NOT be emitted or verified by this profile because typed contract and multi-scope ledger semantics are not implemented; dropping the claim is forbidden.", "tri_state_extension": "Ardur insufficient_evidence MUST project to DRP DENY while remaining distinguishable in the signed extension.", "no_redelegation": "redelegation.mode=none MUST prohibit creation of any sub-receipt.", "bounded_redelegation": "redelegation.mode=bounded MUST require depth below maxDepth and child maxDepth no greater than the parent.", @@ -521,6 +522,13 @@ "drp_path": "metadata.x-ardur.policy.additional", "rationale": "Policy engine references and digests remain critical extensions." }, + { + "source_surface": "legacy_python_passport", + "source_path": "risk_budget", + "classification": "out_of_scope", + "drp_path": null, + "rationale": "The Python runtime enforces typed contract digests and atomic session, agent, and lineage risk accounting, but the current DRP emitter/verifier does not. Input carrying this claim must fail closed rather than drop authority-narrowing semantics." + }, { "source_surface": "legacy_python_passport", "source_path": "cnf", diff --git a/docs/specs/ardur-drp-mapping-v0.1.md b/docs/specs/ardur-drp-mapping-v0.1.md index 1410fe50..d62d1aa0 100644 --- a/docs/specs/ardur-drp-mapping-v0.1.md +++ b/docs/specs/ardur-drp-mapping-v0.1.md @@ -29,7 +29,8 @@ members: `authorization_details`, argument-constraint members, `mission_ref`, `reserved_budget_share`, and `lineage_budget_share`; 2. the JWT mission passport emitted by `python/vibap/passport.py`, including - child-lineage claims added by `derive_child_passport`; and + child-lineage claims added by `derive_child_passport` and the optional + runtime-only `risk_budget` extension; and 3. every top-level property in `docs/specs/execution-receipt-v0.2.schema.json`. @@ -76,6 +77,7 @@ transformations are: | `cnf.jwk` | `metadata.x-ardur.capabilityTokenRef.holderConfirmation.jwk` | The holder key is not the DRP receipt-signing key. | | depth and delegation policy | `metadata.x-ardur.redelegation` | DRP describes depth behavior but has no Authorization Object fields for mode, depth, or maximum depth. | | budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. | +| Python `risk_budget` | No projection in the current profile | The current emitter/verifier does not implement typed fact contracts or atomic session/agent/lineage risk accounting. An emitter presented with this claim MUST deny/fail closed instead of dropping it. A future profile may define a critical `metadata.x-ardur.riskBudget` extension. | | `mission_ref` | `metadata.x-ardur.missionRef` | DRP instruction commitment does not replace the governing Mission Declaration reference. | ### 3.1. Critical Extension Rule diff --git a/python/README.md b/python/README.md index 84f87b8c..c9d5f510 100644 --- a/python/README.md +++ b/python/README.md @@ -194,6 +194,7 @@ python/ │ ├── policy_backend.py # PolicyBackend protocol │ ├── proxy.py # Governance proxy + session lifecycle │ ├── receipt.py # Execution Receipt issuance + verify +│ ├── risk_budget.py # Typed impact contracts + atomic risk ledger │ ├── runtime_evidence.py # Offline normalized/Tetragon/Falco correlation │ └── ... └── tests/ # Curated runtime, adapter, security, and release tests @@ -201,6 +202,19 @@ python/ A couple of pinned dependencies worth flagging: `biscuit-python==0.4.0` (the Biscuit token format we use for delegated capabilities) and `spiffe>=0.2,<0.4` (workload identity). These pins are deliberate — both libraries have had breaking minor releases, so we hold them until we explicitly retest. +## Typed dangerous-action budgets + +Library callers can register authenticated `ToolRiskContract` definitions +before constructing `GovernanceProxy`. An optional signed `risk_budget` +Mission Passport claim then enforces typed per-action caps and atomic +session/agent/lineage ceilings before dispatch. Each governed call requires a +unique `risk_request_id`; after `PERMIT`, the executor must call +`record_risk_outcome(..., outcome="committed")` once execution may have +started, or use `outcome="released"` only when it never started. Unresolved or +quarantined reservations block session finalization. See the full +[risk-budget reference](../docs/reference/risk-budgets.md) for schemas, +failure behavior, privacy, and cost boundaries. + Library deployments that enable Biscuit JWT-SVID holder binding configure a server-owned Biscuit issuer key, `TrustBundle`, and expected audience on `GovernanceProxy`; clients present only `peer_jwt_svid`. Once configured, the diff --git a/python/tests/conftest.py b/python/tests/conftest.py index cd75b81c..6ad7102e 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -7,8 +7,6 @@ from __future__ import annotations -collect_ignore = ["run_cloud_model_test.py", "run_all_models.py", "run_adversarial_suite.py", "run_advanced_adversarial.py", "test_ardur_overhead_ab.py"] - import socket from pathlib import Path from typing import Any, Callable @@ -19,6 +17,14 @@ from vibap.passport import MissionPassport, generate_keypair, issue_passport from vibap.proxy import GovernanceProxy +collect_ignore = [ + "run_cloud_model_test.py", + "run_all_models.py", + "run_adversarial_suite.py", + "run_advanced_adversarial.py", + "test_ardur_overhead_ab.py", +] + # v0.1 spec required-members helper (FIX-3 from S2 audit, 2026-04-28). # @@ -46,6 +52,7 @@ def v01_default_status_url(mission_id: str) -> str: """ # mission_id is typically an opaque URN; hash to keep URL paths sane. import hashlib + digest = hashlib.sha256(mission_id.encode("utf-8")).hexdigest()[:16] return f"https://issuer.example/status/v01-default-{digest}.jwt" @@ -64,9 +71,7 @@ def v01_default_status_list_token(private_key, mission_id: str) -> str: import jwt raw = bytes([0]) # 1 byte covers idx=0; bit at idx=0 is 0 → not revoked. - encoded = ( - base64.urlsafe_b64encode(zlib.compress(raw)).rstrip(b"=").decode("ascii") - ) + encoded = base64.urlsafe_b64encode(zlib.compress(raw)).rstrip(b"=").decode("ascii") now = int(time.time()) claims = { "iss": "test-status-authority", @@ -97,13 +102,11 @@ def v01_required_md_extras( (``test_approval_governance``) pass it explicitly. """ extras: dict[str, Any] = { - "mission_id": mission_id, "receipt_policy": {"level": receipt_level}, "conformance_profile": conformance_profile, "tool_manifest_digest": "sha-256:" + ("a" * 64), "revocation_ref": ( - revocation_ref - or f"{v01_default_status_url(mission_id)}#idx=0" + revocation_ref or f"{v01_default_status_url(mission_id)}#idx=0" ), "governed_memory_stores": [], "probing_rate_limit": probing_rate_limit, @@ -123,7 +126,9 @@ def session_keys_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture(scope="session") -def keypair(session_keys_dir: Path) -> tuple[ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]: +def keypair( + session_keys_dir: Path, +) -> tuple[ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]: return generate_keypair(keys_dir=session_keys_dir) @@ -176,12 +181,16 @@ def delegating_mission() -> MissionPassport: @pytest.fixture def issued_passport(example_mission, private_key) -> str: - return issue_passport(example_mission, private_key, ttl_s=example_mission.max_duration_s) + return issue_passport( + example_mission, private_key, ttl_s=example_mission.max_duration_s + ) @pytest.fixture def issued_delegating_passport(delegating_mission, private_key) -> str: - return issue_passport(delegating_mission, private_key, ttl_s=delegating_mission.max_duration_s) + return issue_passport( + delegating_mission, private_key, ttl_s=delegating_mission.max_duration_s + ) @pytest.fixture @@ -196,7 +205,9 @@ def proxy(tmp_path: Path, public_key, session_keys_dir: Path) -> GovernanceProxy @pytest.fixture -def proxy_factory(tmp_path: Path, public_key, session_keys_dir: Path) -> Callable[[], GovernanceProxy]: +def proxy_factory( + tmp_path: Path, public_key, session_keys_dir: Path +) -> Callable[[], GovernanceProxy]: """Create independent proxy instances sharing the session keypair.""" counter = {"n": 0} diff --git a/python/tests/test_aat_adapter.py b/python/tests/test_aat_adapter.py index 1e1caee3..f331aa3a 100644 --- a/python/tests/test_aat_adapter.py +++ b/python/tests/test_aat_adapter.py @@ -54,10 +54,13 @@ def test_aat_explicit_unrestricted_mission_can_grant_bounded_scope() -> None: def test_aat_bounded_mission_can_grant_empty_scope() -> None: - assert aat_adapter_module._extract_resource_scope( - {"resource_scope": []}, - ["/workspace/*"], - ) == [] + assert ( + aat_adapter_module._extract_resource_scope( + {"resource_scope": []}, + ["/workspace/*"], + ) + == [] + ) def _install_fetch_map( @@ -100,6 +103,7 @@ def _issue_md( ) -> str: mission = MissionPassport( agent_id="md-authority", + mission_id=mission_id, mission="authoritative AAT-backed mission", allowed_tools=allowed_tools or ["read"], forbidden_tools=[], diff --git a/python/tests/test_claude_code_hook.py b/python/tests/test_claude_code_hook.py index 54c4be06..797cba59 100644 --- a/python/tests/test_claude_code_hook.py +++ b/python/tests/test_claude_code_hook.py @@ -11,6 +11,8 @@ import pytest +import jwt + from cryptography.hazmat.primitives.asymmetric import ec from vibap.claude_code_hook import ( @@ -54,7 +56,9 @@ def _deny_reason(output: dict) -> str: return hook_output["permissionDecisionReason"] -def _pre_hook_input(*, tool_name: str, tool_input: dict[str, Any], suffix: str) -> dict[str, Any]: +def _pre_hook_input( + *, tool_name: str, tool_input: dict[str, Any], suffix: str +) -> dict[str, Any]: return { "session_id": "personal-firewall-test", "hook_event_name": "PreToolUse", @@ -161,9 +165,7 @@ def test_direct_hook_denies_workspace_symlink_escape_and_signs_violation( ] -def test_direct_hook_denies_resource_when_signed_scope_is_empty( - tmp_path, monkeypatch -): +def test_direct_hook_denies_resource_when_signed_scope_is_empty(tmp_path, monkeypatch): from vibap.claude_code_hook import handle_pre_tool_use from vibap.claude_code_report import build_claude_code_report @@ -288,9 +290,7 @@ def test_direct_hook_fails_closed_for_malformed_additional_policies_claim( keys_dir=tmp_path, ) - assert "unknown policy backend: invalid_additional_policies" in _deny_reason( - output - ) + assert "unknown policy backend: invalid_additional_policies" in _deny_reason(output) def test_direct_hook_fails_closed_for_oversized_budget_chain(tmp_path, monkeypatch): @@ -396,7 +396,19 @@ def _issue_wildcard_test_passport( max_tool_calls=20, max_duration_s=600, ) - return issue_passport(mission, private_key, ttl_s=3600, extra_claims=extra_claims) + claims = dict(extra_claims or {}) + jti_override = claims.pop("jti", None) + token = issue_passport( + mission, + private_key, + ttl_s=3600, + extra_claims=claims, + ) + if not isinstance(jti_override, str): + return token + payload = jwt.decode(token, options={"verify_signature": False}) + payload["jti"] = jti_override + return jwt.encode(payload, private_key, algorithm="ES256") def _exercise_receipt_lock_and_subagent_sinks( @@ -409,7 +421,11 @@ def _exercise_receipt_lock_and_subagent_sinks( monkeypatch.setenv("VIBAP_HOME", str(tmp_path)) monkeypatch.setenv("ARDUR_CC_HOOK_DIR", str(chain_dir)) - from vibap.claude_code_hook import handle_post_tool_use, handle_pre_tool_use, handle_subagent_start + from vibap.claude_code_hook import ( + handle_post_tool_use, + handle_pre_tool_use, + handle_subagent_start, + ) pre_output = handle_pre_tool_use( { @@ -516,7 +532,10 @@ def test_returns_error_on_signature_mismatch(tmp_path, monkeypatch): with pytest.raises(MissionLoadError) as exc_info: load_active_passport(keys_dir=other_keys) - assert "signature" in str(exc_info.value).lower() or "verify" in str(exc_info.value).lower() + assert ( + "signature" in str(exc_info.value).lower() + or "verify" in str(exc_info.value).lower() + ) def test_empty_vibap_home_falls_back_to_default_home(tmp_path, monkeypatch): @@ -538,7 +557,9 @@ def test_empty_vibap_home_falls_back_to_default_home(tmp_path, monkeypatch): assert "no active mission passport" in str(exc_info.value).lower() -def test_jwt_heuristic_does_not_misclassify_path_starting_with_ey(tmp_path, monkeypatch): +def test_jwt_heuristic_does_not_misclassify_path_starting_with_ey( + tmp_path, monkeypatch +): # A path-like value starting with "ey" but not "eyJ" must be treated # as a path, not a literal JWT. Without keys we expect either a # missing-keys MissionLoadError or a no-passport MissionLoadError — @@ -583,8 +604,14 @@ def test_chain_per_trace_does_not_collide(tmp_path): state_b = ChainState(chain_dir=tmp_path, trace_id="trace-b") append_receipt(state_a, "a-only.jwt") append_receipt(state_b, "b-only.jwt") - assert previous_receipt_hash(state_a) == "sha-256:" + hashlib.sha256("a-only.jwt".encode()).hexdigest() - assert previous_receipt_hash(state_b) == "sha-256:" + hashlib.sha256("b-only.jwt".encode()).hexdigest() + assert ( + previous_receipt_hash(state_a) + == "sha-256:" + hashlib.sha256("a-only.jwt".encode()).hexdigest() + ) + assert ( + previous_receipt_hash(state_b) + == "sha-256:" + hashlib.sha256("b-only.jwt".encode()).hexdigest() + ) def test_child_receipt_summary_streams_chain_file(tmp_path, monkeypatch): @@ -594,7 +621,9 @@ def test_child_receipt_summary_streams_chain_file(tmp_path, monkeypatch): def unsigned_jwt(claims: dict[str, Any]) -> str: def encode(segment: dict[str, Any]) -> str: encoded = base64.urlsafe_b64encode( - json.dumps(segment, sort_keys=True, separators=(",", ":")).encode("utf-8") + json.dumps(segment, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) ) return encoded.rstrip(b"=").decode("ascii") @@ -661,7 +690,9 @@ def test_unsafe_env_trace_ids_do_not_escape_or_collapse_chain_paths_across_hook_ assert "\\" not in trace_dir.name -def test_unsafe_passport_jti_fallback_material_is_contained_and_single_segment(tmp_path, monkeypatch): +def test_unsafe_passport_jti_fallback_material_is_contained_and_single_segment( + tmp_path, monkeypatch +): cases = { "dotdot": "../passport-out", "slash": "bad/trace", @@ -675,7 +706,9 @@ def test_unsafe_passport_jti_fallback_material_is_contained_and_single_segment(t token = _issue_wildcard_test_passport(case_dir, extra_claims={"jti": bad_jti}) monkeypatch.delenv("ARDUR_TRACE_ID", raising=False) - chain_dir = _exercise_receipt_lock_and_subagent_sinks(case_dir, monkeypatch, token) + chain_dir = _exercise_receipt_lock_and_subagent_sinks( + case_dir, monkeypatch, token + ) assert not (case_dir / "receipts.jsonl").exists() assert not (case_dir / ".lock").exists() @@ -684,10 +717,19 @@ def test_unsafe_passport_jti_fallback_material_is_contained_and_single_segment(t assert trace_dir.name.startswith("trace-") assert "/" not in trace_dir.name assert "\\" not in trace_dir.name - assert trace_dir.name not in {".", "..", "bad", "trace", "passport-out", "absolute-out"} + assert trace_dir.name not in { + ".", + "..", + "bad", + "trace", + "passport-out", + "absolute-out", + } -def test_safe_dot_containing_env_trace_id_is_preserved_as_single_segment(tmp_path, monkeypatch): +def test_safe_dot_containing_env_trace_id_is_preserved_as_single_segment( + tmp_path, monkeypatch +): token = _issue_wildcard_test_passport(tmp_path) monkeypatch.setenv("ARDUR_TRACE_ID", "trace.v1-alpha_2") @@ -697,7 +739,9 @@ def test_safe_dot_containing_env_trace_id_is_preserved_as_single_segment(tmp_pat assert trace_dir.name == "trace.v1-alpha_2" -def test_resolve_chain_state_rejects_path_material_before_artifact_creation(tmp_path, monkeypatch): +def test_resolve_chain_state_rejects_path_material_before_artifact_creation( + tmp_path, monkeypatch +): monkeypatch.setenv("ARDUR_CC_HOOK_DIR", str(tmp_path / "chain")) from vibap.claude_code_hook import resolve_chain_state @@ -749,6 +793,7 @@ def test_allow_path_returns_continue_true_and_chains_receipt(tmp_path, monkeypat # the signature — the test isn't asserting receipt validity here, just # the chain semantics. import jwt as pyjwt + claims = pyjwt.decode(lines[0].strip(), options={"verify_signature": False}) assert claims.get("parent_receipt_hash") is None @@ -770,7 +815,9 @@ def test_allow_path_returns_continue_true_and_chains_receipt(tmp_path, monkeypat ] -def test_wildcard_allowed_tools_permits_agent_dispatch_and_reports_it(tmp_path, monkeypatch): +def test_wildcard_allowed_tools_permits_agent_dispatch_and_reports_it( + tmp_path, monkeypatch +): private_key, _public_key = generate_keypair(keys_dir=tmp_path) mission = MissionPassport( agent_id="alice", @@ -881,7 +928,9 @@ def test_empty_claude_code_report_human_output_prints_next_steps(tmp_path, capsy assert str(tmp_path) not in next_steps_output -def test_subagent_lifecycle_receipts_and_report_derived_tool_attribution(tmp_path, monkeypatch): +def test_subagent_lifecycle_receipts_and_report_derived_tool_attribution( + tmp_path, monkeypatch +): private_key, _public_key = generate_keypair(keys_dir=tmp_path) mission = MissionPassport( agent_id="alice", @@ -967,11 +1016,21 @@ def test_subagent_lifecycle_receipts_and_report_derived_tool_attribution(tmp_pat receipts = list((tmp_path / "chain").rglob("receipts.jsonl")) assert len(receipts) == 1 - lines = [line.strip() for line in receipts[0].read_text(encoding="utf-8").splitlines() if line.strip()] + lines = [ + line.strip() + for line in receipts[0].read_text(encoding="utf-8").splitlines() + if line.strip() + ] assert len(lines) == 4 import jwt as pyjwt + claims = [pyjwt.decode(line, options={"verify_signature": False}) for line in lines] - assert [claim["tool"] for claim in claims] == ["SubagentStart", "Read", "Read", "SubagentStop"] + assert [claim["tool"] for claim in claims] == [ + "SubagentStart", + "Read", + "Read", + "SubagentStop", + ] start_meta = claims[0]["measurements"]["claude_code"] assert start_meta["claude_agent_id"] == "agent-child-1" assert start_meta["actor_kind"] == "subagent" @@ -1027,7 +1086,11 @@ def test_report_keeps_unmatched_child_tools_trace_only(tmp_path, monkeypatch): parent_transcript = tmp_path / "parent.jsonl" parent_transcript.write_text("{}\n", encoding="utf-8") - from vibap.claude_code_hook import handle_pre_tool_use, handle_subagent_start, handle_subagent_stop + from vibap.claude_code_hook import ( + handle_pre_tool_use, + handle_subagent_start, + handle_subagent_stop, + ) from vibap.claude_code_report import build_claude_code_report handle_subagent_start( @@ -1074,10 +1137,15 @@ def test_report_keeps_unmatched_child_tools_trace_only(tmp_path, monkeypatch): ) assert report["coverage"]["per_child_attribution"] == "trace_only" assert report["totals"]["unattributed_tool_receipt_count"] == 1 - assert report["chains"][0]["unattributed_tool_receipts"][0]["tool_use_id"] == "toolu_unmatched" + assert ( + report["chains"][0]["unattributed_tool_receipts"][0]["tool_use_id"] + == "toolu_unmatched" + ) -def test_long_scoped_bash_command_is_not_denied_by_truncated_target(tmp_path, monkeypatch): +def test_long_scoped_bash_command_is_not_denied_by_truncated_target( + tmp_path, monkeypatch +): private_key, _public_key = generate_keypair(keys_dir=tmp_path) scope = tmp_path / "scope" nested = scope / "a" / "b" / "c" / "d" @@ -1180,7 +1248,11 @@ def test_parallel_pre_tool_use_processes_serialize_receipt_chain(tmp_path): receipts = list((tmp_path / "chain").rglob("receipts.jsonl")) assert len(receipts) == 1 - lines = [line.strip() for line in receipts[0].read_text(encoding="utf-8").splitlines() if line.strip()] + lines = [ + line.strip() + for line in receipts[0].read_text(encoding="utf-8").splitlines() + if line.strip() + ] assert len(lines) == 5 from vibap.receipt import verify_chain @@ -1196,6 +1268,7 @@ def test_deny_path_returns_continue_false_with_stop_reason(tmp_path, monkeypatch monkeypatch.setenv("ARDUR_CC_HOOK_DIR", str(tmp_path / "chain")) from vibap.claude_code_hook import handle_pre_tool_use + output = handle_pre_tool_use( { "session_id": "sess-1", @@ -1215,6 +1288,7 @@ def test_deny_path_returns_continue_false_with_stop_reason(tmp_path, monkeypatch # Audit trail: the appended receipt MUST carry a non-compliant verdict. # Inspect without verifying signature — we only care about chain semantics. import jwt as pyjwt + claims = pyjwt.decode(lines[0].strip(), options={"verify_signature": False}) assert claims.get("verdict") == "violation" assert claims.get("policy_decisions") == [ @@ -1234,6 +1308,7 @@ def test_post_tool_use_chains_to_pre_and_records_result_hash(tmp_path, monkeypat # First, run PreToolUse to seed the chain. from vibap.claude_code_hook import handle_pre_tool_use, handle_post_tool_use + handle_pre_tool_use( { "tool_name": "Read", @@ -1264,6 +1339,7 @@ def test_post_tool_use_chains_to_pre_and_records_result_hash(tmp_path, monkeypat # so that verify_chain can compare it directly against its own computed hash. import hashlib as _hashlib import jwt as pyjwt + pre_jwt = lines[0].strip() post_jwt = lines[1].strip() expected_parent = _hashlib.sha256(pre_jwt.encode("utf-8")).hexdigest() @@ -1301,14 +1377,22 @@ def test_main_pre_reads_stdin_writes_stdout(tmp_path, monkeypatch): env["VIBAP_HOME"] = str(tmp_path) env["ARDUR_CC_HOOK_DIR"] = str(tmp_path / "chain") - hook_input = json.dumps({ - "tool_name": "Read", - "tool_input": {"file_path": "/tmp/x.txt"}, - }) + hook_input = json.dumps( + { + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/x.txt"}, + } + ) repo_root = Path(__file__).resolve().parents[2] result = subprocess.run( - [sys.executable, "-m", "vibap.claude_code_hook", "pre", - "--keys-dir", str(tmp_path)], + [ + sys.executable, + "-m", + "vibap.claude_code_hook", + "pre", + "--keys-dir", + str(tmp_path), + ], input=hook_input, capture_output=True, text=True, @@ -1382,8 +1466,14 @@ def test_claude_code_hook_cli_returns_structured_input_error_next_steps( "configure_claude_code_protection", "rerun_with_hook_event_json_file", ] - assert "ardur protect claude-code --scope --home " in output_text - assert "ardur claude-code-hook pre --keys-dir < " in output_text + assert ( + "ardur protect claude-code --scope --home " + in output_text + ) + assert ( + "ardur claude-code-hook pre --keys-dir < " + in output_text + ) assert "Traceback" not in output_text assert stdin_payload not in output_text assert str(tmp_path) not in output_text @@ -1418,7 +1508,9 @@ def test_pre_daemon_first_uses_daemon_output(tmp_path, monkeypatch): ) def _local_should_not_run(*_args, **_kwargs): - raise AssertionError("local pre handler should not run when daemon returns output") + raise AssertionError( + "local pre handler should not run when daemon returns output" + ) monkeypatch.setattr(hook_module, "handle_pre_tool_use", _local_should_not_run) output = hook_module._handle_pre_tool_use_daemon_first( @@ -1455,14 +1547,19 @@ def _local_fallback(hook_input, *, keys_dir=None): assert observed == {"tool_name": "Read", "keys_dir": tmp_path} -def test_pre_daemon_first_falls_back_when_daemon_output_is_malformed(tmp_path, monkeypatch): +def test_pre_daemon_first_falls_back_when_daemon_output_is_malformed( + tmp_path, monkeypatch +): from vibap import claude_code_daemon_client as daemon_client_module from vibap import claude_code_hook as hook_module monkeypatch.setattr( daemon_client_module, "dispatch_pre_tool_use", - lambda hook_input, *, keys_dir=None: {"ok": True, "output": {"not": "hook-output"}}, + lambda hook_input, *, keys_dir=None: { + "ok": True, + "output": {"not": "hook-output"}, + }, ) observed: dict[str, Any] = {} @@ -1470,7 +1567,10 @@ def test_pre_daemon_first_falls_back_when_daemon_output_is_malformed(tmp_path, m def _local_fallback(hook_input, *, keys_dir=None): observed["tool_name"] = hook_input["tool_name"] observed["keys_dir"] = keys_dir - return {"continue": True, "systemMessage": "ardur: local fallback from malformed daemon output"} + return { + "continue": True, + "systemMessage": "ardur: local fallback from malformed daemon output", + } monkeypatch.setattr(hook_module, "handle_pre_tool_use", _local_fallback) output = hook_module._handle_pre_tool_use_daemon_first( @@ -1494,7 +1594,9 @@ def test_claude_daemon_hook_import_topology_is_acyclic(): edges: set[tuple[str, str]] = set() for module_name in modules: - tree = ast.parse((package_root / f"{module_name}.py").read_text(encoding="utf-8")) + tree = ast.parse( + (package_root / f"{module_name}.py").read_text(encoding="utf-8") + ) for node in ast.walk(tree): if not isinstance(node, ast.ImportFrom) or node.level != 1: continue @@ -1566,7 +1668,9 @@ def test_dispatch_pre_tool_use_rejects_malformed_ok_envelope(tmp_path, monkeypat from vibap import claude_code_daemon as daemon_module token, _ = _issue_test_passport(tmp_path) - socket_parent = Path(f"/tmp/ardur-daemon-malformed-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-daemon-malformed-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -1707,7 +1811,9 @@ def test_daemon_ignores_response_write_failures(tmp_path, monkeypatch): from vibap import claude_code_daemon as daemon_module token, _ = _issue_test_passport(tmp_path) - socket_parent = Path(f"/tmp/ardur-daemon-broken-pipe-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-daemon-broken-pipe-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" monkeypatch.setenv("ARDUR_MISSION_PASSPORT", token) @@ -1789,7 +1895,9 @@ def test_daemon_unlinks_stale_unix_socket_path(tmp_path): # AF_UNIX paths are short on macOS, so use /tmp rather than pytest's deep # tmp_path for this socket-specific regression. - stale_path = Path(f"/tmp/ardur-stale-socket-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock") + stale_path = Path( + f"/tmp/ardur-stale-socket-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + ) server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: server.bind(str(stale_path)) @@ -1812,7 +1920,9 @@ def test_daemon_cleanup_unlinks_stale_unix_socket_path(tmp_path): from vibap import claude_code_daemon as daemon_module - stale_path = Path(f"/tmp/ardur-stale-cleanup-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock") + stale_path = Path( + f"/tmp/ardur-stale-cleanup-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + ) server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: server.bind(str(stale_path)) @@ -1837,7 +1947,9 @@ def test_daemon_socket_probe_treats_starting_listener_as_active(tmp_path): from vibap import claude_code_daemon as daemon_module - socket_path = Path(f"/tmp/ardur-starting-socket-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock") + socket_path = Path( + f"/tmp/ardur-starting-socket-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + ) server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) observed = {"accepted": False} failures: list[Exception] = [] @@ -1879,7 +1991,9 @@ def test_daemon_creates_private_socket_parent_when_missing(tmp_path, monkeypatch from vibap import claude_code_daemon as daemon_module token, _ = _issue_test_passport(tmp_path) - socket_parent = Path(f"/tmp/ardur-daemon-private-created-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-daemon-private-created-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_path = socket_parent / "hook.sock" assert not socket_parent.exists() @@ -1955,7 +2069,9 @@ def test_daemon_refuses_preexisting_shared_socket_parent_without_chmod(tmp_path) from vibap import claude_code_daemon as daemon_module - socket_path = Path(f"/tmp/ardur-daemon-shared-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock") + socket_path = Path( + f"/tmp/ardur-daemon-shared-{os.getpid()}-{uuid.uuid4().hex[:8]}.sock" + ) tmp_dir = Path("/tmp") original_mode = stat_module.S_IMODE(tmp_dir.stat().st_mode) @@ -1992,7 +2108,9 @@ def test_daemon_refuses_to_replace_active_socket(tmp_path, monkeypatch): from vibap import claude_code_daemon as daemon_module token, _ = _issue_test_passport(tmp_path) - socket_parent = Path(f"/tmp/ardur-daemon-active-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-daemon-active-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2065,14 +2183,16 @@ def test_wrapper_accepts_native_client_env_alias_before_python_fallback(tmp_path repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - socket_parent = Path(f"/tmp/ardur-wrapper-native-alias-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-native-alias-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" capture_path = tmp_path / "native-stdin.txt" native_client = tmp_path / "fake-native-client" native_client.write_text( "#!/usr/bin/env sh\n" - "cat > \"$ARDUR_NATIVE_ALIAS_CAPTURE\"\n" + 'cat > "$ARDUR_NATIVE_ALIAS_CAPTURE"\n' "printf '{\"continue\":true}\\n'\n", encoding="utf-8", ) @@ -2122,7 +2242,6 @@ def test_wrapper_accepts_native_client_env_alias_before_python_fallback(tmp_path socket_parent.rmdir() - def test_wrapper_accepts_pretty_printed_hook_json_when_daemon_disabled(tmp_path): import os import subprocess @@ -2166,7 +2285,6 @@ def test_wrapper_accepts_pretty_printed_hook_json_when_daemon_disabled(tmp_path) assert output["hookSpecificOutput"]["hookEventName"] == "PreToolUse" - def test_wrapper_falls_back_when_daemon_returns_error_payload(tmp_path): import os import socket @@ -2181,11 +2299,15 @@ def test_wrapper_falls_back_when_daemon_returns_error_payload(tmp_path): repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - socket_parent = Path(f"/tmp/ardur-wrapper-error-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-error-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2210,7 +2332,9 @@ def _serve_bad_response() -> None: with conn: _ = conn.recv(8192) observed["requests"] += 1 - conn.sendall(b'{"ok":false,"error":"simulated daemon failure"}\\n') + conn.sendall( + b'{"ok":false,"error":"simulated daemon failure"}\\n' + ) except Exception as exc: # pragma: no cover - surfaced via assertion failures.append(exc) @@ -2292,11 +2416,15 @@ def test_wrapper_and_python_fallback_rejects_malformed_pretooluse_shape( repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - socket_parent = Path(f"/tmp/ardur-wrapper-invalid-output-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-invalid-output-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2388,20 +2516,29 @@ def test_native_pre_tool_use_client_rejects_truncated_ok_envelope(tmp_path): from vibap import claude_code_daemon as daemon_module - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") # Exercise the installed non-force path: legacy installs can predate current # source and miss provenance metadata. Removing the stamp here ensures this # test covers reinstalling a stale/unstamped command before probe execution. - command_stamp = native_pre_tool_use_command.parent / f"{native_pre_tool_use_command.name}.sha256" + command_stamp = ( + native_pre_tool_use_command.parent + / f"{native_pre_tool_use_command.name}.sha256" + ) if command_stamp.exists(): command_stamp.unlink() - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=False) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=False + ) assert native_pre_tool_use_command is not None - socket_parent = Path(f"/tmp/ardur-native-malformed-envelope-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-native-malformed-envelope-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2463,7 +2600,9 @@ def _serve_truncated_envelope() -> None: socket_parent.rmdir() -def test_native_pre_tool_use_client_rejects_spaced_false_ok_envelope_with_hook_output(tmp_path): +def test_native_pre_tool_use_client_rejects_spaced_false_ok_envelope_with_hook_output( + tmp_path, +): import os import socket import subprocess @@ -2472,11 +2611,15 @@ def test_native_pre_tool_use_client_rejects_spaced_false_ok_envelope_with_hook_o from vibap import claude_code_daemon as daemon_module - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - socket_parent = Path(f"/tmp/ardur-native-spaced-ok-false-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-native-spaced-ok-false-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2538,14 +2681,21 @@ def _serve_spaced_false_ok_envelope() -> None: socket_parent.rmdir() -def test_install_native_pre_tool_use_command_rebuilds_tampered_executable_with_intact_stamp(tmp_path): +def test_install_native_pre_tool_use_command_rebuilds_tampered_executable_with_intact_stamp( + tmp_path, +): from vibap import claude_code_daemon as daemon_module - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - command_stamp = native_pre_tool_use_command.parent / f"{native_pre_tool_use_command.name}.sha256" + command_stamp = ( + native_pre_tool_use_command.parent + / f"{native_pre_tool_use_command.name}.sha256" + ) assert command_stamp.exists() tampered = b"#!/bin/sh\necho tampered\n" @@ -2553,14 +2703,17 @@ def test_install_native_pre_tool_use_command_rebuilds_tampered_executable_with_i native_pre_tool_use_command.chmod(0o700) assert native_pre_tool_use_command.read_bytes() == tampered - rebuilt = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=False) + rebuilt = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=False + ) assert rebuilt is not None assert rebuilt == native_pre_tool_use_command assert native_pre_tool_use_command.read_bytes() != tampered - -def test_wrapper_local_fallback_denies_forbidden_tool_after_truncated_ok_envelope(tmp_path): +def test_wrapper_local_fallback_denies_forbidden_tool_after_truncated_ok_envelope( + tmp_path, +): import os import socket import subprocess @@ -2574,18 +2727,27 @@ def test_wrapper_local_fallback_denies_forbidden_tool_after_truncated_ok_envelop repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") # Ensure wrapper coverage uses the installed command refresh path as well. - command_stamp = native_pre_tool_use_command.parent / f"{native_pre_tool_use_command.name}.sha256" + command_stamp = ( + native_pre_tool_use_command.parent + / f"{native_pre_tool_use_command.name}.sha256" + ) if command_stamp.exists(): command_stamp.unlink() - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=False) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=False + ) assert native_pre_tool_use_command is not None - socket_parent = Path(f"/tmp/ardur-wrapper-truncated-envelope-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-truncated-envelope-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2661,7 +2823,9 @@ def _serve_invalid_hook_output() -> None: output = json.loads(result.stdout) assert output["hookSpecificOutput"]["permissionDecision"] == "deny" - assert "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + assert ( + "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + ) finally: if socket_path.exists(): socket_path.unlink() @@ -2669,7 +2833,9 @@ def _serve_invalid_hook_output() -> None: socket_parent.rmdir() -def test_wrapper_local_fallback_denies_forbidden_tool_after_spaced_false_ok_envelope(tmp_path): +def test_wrapper_local_fallback_denies_forbidden_tool_after_spaced_false_ok_envelope( + tmp_path, +): import os import socket import subprocess @@ -2683,11 +2849,15 @@ def test_wrapper_local_fallback_denies_forbidden_tool_after_spaced_false_ok_enve repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - socket_parent = Path(f"/tmp/ardur-wrapper-spaced-ok-false-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-spaced-ok-false-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2763,7 +2933,9 @@ def _serve_spaced_false_ok_envelope() -> None: output = json.loads(result.stdout) assert output["hookSpecificOutput"]["permissionDecision"] == "deny" - assert "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + assert ( + "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + ) finally: if socket_path.exists(): socket_path.unlink() @@ -2771,7 +2943,9 @@ def _serve_spaced_false_ok_envelope() -> None: socket_parent.rmdir() -def test_wrapper_local_fallback_still_denies_forbidden_tool_after_malformed_daemon_output(tmp_path): +def test_wrapper_local_fallback_still_denies_forbidden_tool_after_malformed_daemon_output( + tmp_path, +): import os import socket import subprocess @@ -2785,11 +2959,15 @@ def test_wrapper_local_fallback_still_denies_forbidden_tool_after_malformed_daem repo_root = Path(__file__).resolve().parents[2] wrapper = repo_root / "plugins" / "claude-code" / "hooks" / "pre_tool_use" - native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command(home=tmp_path, force=True) + native_pre_tool_use_command = daemon_module.install_native_pre_tool_use_command( + home=tmp_path, force=True + ) if native_pre_tool_use_command is None: pytest.xfail("native PreToolUse daemon client could not be built on this host") - socket_parent = Path(f"/tmp/ardur-wrapper-malformed-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}") + socket_parent = Path( + f"/tmp/ardur-wrapper-malformed-deny-{os.getpid()}-{uuid.uuid4().hex[:8]}" + ) socket_parent.mkdir(mode=0o700) socket_path = socket_parent / "hook.sock" @@ -2864,7 +3042,9 @@ def _serve_invalid_hook_output() -> None: output = json.loads(result.stdout) assert output["hookSpecificOutput"]["permissionDecision"] == "deny" - assert "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + assert ( + "ardur:" in output["hookSpecificOutput"]["permissionDecisionReason"].lower() + ) finally: if socket_path.exists(): socket_path.unlink() @@ -2965,22 +3145,49 @@ def test_three_call_session_chain_verifies(tmp_path, monkeypatch): from vibap.claude_code_hook import handle_pre_tool_use, handle_post_tool_use # Call 1: Read (allowed) — pre + post. - handle_pre_tool_use({"tool_name": "Read", "tool_input": {"file_path": "/tmp/a.txt"}}, keys_dir=tmp_path) - handle_post_tool_use({"tool_name": "Read", "tool_input": {"file_path": "/tmp/a.txt"}, "tool_response": {"content": "a", "exit_code": 0}}, keys_dir=tmp_path) + handle_pre_tool_use( + {"tool_name": "Read", "tool_input": {"file_path": "/tmp/a.txt"}}, + keys_dir=tmp_path, + ) + handle_post_tool_use( + { + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/a.txt"}, + "tool_response": {"content": "a", "exit_code": 0}, + }, + keys_dir=tmp_path, + ) # Call 2: Bash (denied) — pre only (post never fires when blocked). - out = handle_pre_tool_use({"tool_name": "Bash", "tool_input": {"command": "echo hi"}}, keys_dir=tmp_path) + out = handle_pre_tool_use( + {"tool_name": "Bash", "tool_input": {"command": "echo hi"}}, keys_dir=tmp_path + ) assert "ardur:" in _deny_reason(out).lower() # Call 3: Read (allowed) — pre + post. - handle_pre_tool_use({"tool_name": "Read", "tool_input": {"file_path": "/tmp/b.txt"}}, keys_dir=tmp_path) - handle_post_tool_use({"tool_name": "Read", "tool_input": {"file_path": "/tmp/b.txt"}, "tool_response": {"content": "b", "exit_code": 0}}, keys_dir=tmp_path) + handle_pre_tool_use( + {"tool_name": "Read", "tool_input": {"file_path": "/tmp/b.txt"}}, + keys_dir=tmp_path, + ) + handle_post_tool_use( + { + "tool_name": "Read", + "tool_input": {"file_path": "/tmp/b.txt"}, + "tool_response": {"content": "b", "exit_code": 0}, + }, + keys_dir=tmp_path, + ) receipts = list((tmp_path / "chain").rglob("receipts.jsonl")) assert len(receipts) == 1 - lines = [l.strip() for l in receipts[0].read_text(encoding="utf-8").splitlines() if l.strip()] + lines = [ + line.strip() + for line in receipts[0].read_text(encoding="utf-8").splitlines() + if line.strip() + ] # 5 entries: Pre1 + Post1 + Deny2 + Pre3 + Post3 assert len(lines) == 5 from vibap.receipt import verify_chain + verify_chain(lines, public_key) # raises ReceiptChainError if chain is broken diff --git a/python/tests/test_delegation.py b/python/tests/test_delegation.py index cd639213..ef5a3d62 100644 --- a/python/tests/test_delegation.py +++ b/python/tests/test_delegation.py @@ -49,7 +49,9 @@ def test_child_scope_is_subset_of_parent( ) claims = verify_passport(child, public_key, parent_token=parent_token) parent_claims = verify_passport(parent_token, public_key) - assert set(claims["allowed_tools"]).issubset(set(parent_claims["allowed_tools"])) + assert set(claims["allowed_tools"]).issubset( + set(parent_claims["allowed_tools"]) + ) def test_child_scope_cannot_equal_but_exceed_parent( self, parent_token, private_key, public_key @@ -134,22 +136,22 @@ def test_chain_splice_detected_via_token_hash( # The parent_jti check catches this first (child_b has a different jti # than child_a_prime). The chain[0].token_hash check is defense-in-depth # for cases where jti values could collide in multi-key deployments. - with pytest.raises(PermissionError, match="parent_jti does not match|chain splice"): + with pytest.raises( + PermissionError, match="parent_jti does not match|chain splice" + ): verify_passport(grand_c, public_key, parent_token=child_b) # Legitimate: verify grand_c with correct parent → should pass claims = verify_passport(grand_c, public_key, parent_token=child_a_prime) assert claims["sub"] == "grand-c" - def test_grandchild_cannot_re_escalate( - self, parent_token, private_key, public_key - ): + def test_grandchild_cannot_re_escalate(self, parent_token, private_key, public_key): child = derive_child_passport( parent_token=parent_token, public_key=public_key, private_key=private_key, child_agent_id="child", - child_allowed_tools=["read"], # child is already narrowed + child_allowed_tools=["read"], # child is already narrowed child_mission="child", child_ttl_s=300, ) @@ -357,7 +359,9 @@ def test_child_scope_default_inherits_parent(self, private_key, public_key): claims = verify_passport(child_token, public_key, parent_token=parent_token) assert claims["resource_scope"] == ["/data/*", "/logs/*"] - def test_unrestricted_parent_can_delegate_narrowed_child_scope(self, private_key, public_key): + def test_unrestricted_parent_can_delegate_narrowed_child_scope( + self, private_key, public_key + ): parent_token = issue_passport( MissionPassport( agent_id="p", @@ -408,7 +412,9 @@ def test_absent_parent_scope_cannot_delegate_resource_authority( child_resource_scope=["/tmp/*"], ) - def test_restricted_parent_can_delegate_empty_child_scope(self, private_key, public_key): + def test_restricted_parent_can_delegate_empty_child_scope( + self, private_key, public_key + ): parent_token = issue_passport( MissionPassport( agent_id="p", @@ -500,7 +506,8 @@ def _signed_child( } if extra_claims: claims.update(extra_claims) - return issue_passport( + jti_override = claims.pop("jti", None) + token = issue_passport( MissionPassport( agent_id="child", mission="forged child", @@ -514,6 +521,11 @@ def _signed_child( ttl_s=60, extra_claims=claims, ) + if not isinstance(jti_override, str): + return token + payload = jwt.decode(token, options={"verify_signature": False}) + payload["jti"] = jti_override + return jwt.encode(payload, private_key, algorithm="ES256") def test_orphan_child_without_chain_rejected_even_without_parent_token( self, private_key, public_key @@ -548,9 +560,7 @@ def test_unordered_chain_rejected_even_without_parent_token( with pytest.raises(PermissionError, match="inconsistent delegation_chain"): verify_passport(forged, public_key) - def test_cycle_rejected_even_without_parent_token( - self, private_key, public_key - ): + def test_cycle_rejected_even_without_parent_token(self, private_key, public_key): parent_token = self._parent(private_key) parent_claims = verify_passport(parent_token, public_key) forged = self._signed_child( @@ -643,22 +653,22 @@ def test_third_sibling_rejected_when_reservations_exhaust_ceiling( child_agent_id="c3", child_allowed_tools=["read"], child_mission="third sibling", - child_max_tool_calls=15, # asks for 15 - parent_reserved_for_descendants=20, # 2 prior sibs * 10 + child_max_tool_calls=15, # asks for 15 + parent_reserved_for_descendants=20, # 2 prior sibs * 10 ) claims = verify_passport(third, public_key, parent_token=root_token) # Clamped to escrow_remaining = ceiling(30) - reserved(20) = 10 assert claims["max_tool_calls"] == 10 assert claims["reserved_budget_share"] == 10 - def test_ceiling_exhausted_rejects_delegation( - self, private_key, public_key - ): + def test_ceiling_exhausted_rejects_delegation(self, private_key, public_key): """If parent_reserved_for_descendants == ceiling, no further delegation may occur even if parent_calls_remaining is silent about the exhaustion.""" root_token = self._root(private_key, ceiling=30) - with pytest.raises(PermissionError, match="descendant-reservation pool exhausted"): + with pytest.raises( + PermissionError, match="descendant-reservation pool exhausted" + ): derive_child_passport( parent_token=root_token, public_key=public_key, @@ -670,9 +680,7 @@ def test_ceiling_exhausted_rejects_delegation( parent_reserved_for_descendants=30, # already at ceiling ) - def test_over_allocated_reservation_rejected( - self, private_key, public_key - ): + def test_over_allocated_reservation_rejected(self, private_key, public_key): """Defensive: a caller that reports more reserved than the ceiling is mathematically impossible — refuse rather than accept and compute a negative escrow_remaining.""" @@ -689,9 +697,7 @@ def test_over_allocated_reservation_rejected( parent_reserved_for_descendants=31, # > ceiling ) - def test_negative_reservation_rejected( - self, private_key, public_key - ): + def test_negative_reservation_rejected(self, private_key, public_key): root_token = self._root(private_key, ceiling=30) with pytest.raises(PermissionError, match="must be non-negative"): derive_child_passport( @@ -723,9 +729,7 @@ def test_default_zero_reservation_preserves_back_compat( child_max_tool_calls=10, # parent_reserved_for_descendants omitted — defaults to 0 ) - claims = verify_passport( - child_token, public_key, parent_token=root_token - ) + claims = verify_passport(child_token, public_key, parent_token=root_token) assert claims["max_tool_calls"] == 10 assert claims["reserved_budget_share"] == 10 @@ -934,9 +938,7 @@ def test_legit_child_passes_when_parent_token_supplied( child_mission="subtask", child_ttl_s=120, ) - claims = verify_passport( - child_token, public_key, parent_token=parent_token - ) + claims = verify_passport(child_token, public_key, parent_token=parent_token) assert claims["sub"] == "child" assert claims["parent_jti"] # chain intact diff --git a/python/tests/test_http.py b/python/tests/test_http.py index 53e130b4..5b706d4b 100644 --- a/python/tests/test_http.py +++ b/python/tests/test_http.py @@ -31,6 +31,7 @@ from vibap.passport import ALGORITHM, MissionPassport, issue_passport from vibap.proxy import GovernanceProxy, serve_proxy from vibap.receipt import verify_chain +from vibap.risk_budget import ToolRiskContract, ToolRiskRegistry from tests.conftest import ( v01_default_status_list_token, @@ -106,7 +107,9 @@ def _post(url: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: return status, body -def _post_with_headers(url: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any], dict[str, str]]: +def _post_with_headers( + url: str, payload: dict[str, Any] +) -> tuple[int, dict[str, Any], dict[str, str]]: data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( url, @@ -116,7 +119,11 @@ def _post_with_headers(url: str, payload: dict[str, Any]) -> tuple[int, dict[str ) try: with urllib.request.urlopen(req, timeout=5) as resp: - return resp.status, json.loads(resp.read().decode("utf-8")), dict(resp.headers.items()) + return ( + resp.status, + json.loads(resp.read().decode("utf-8")), + dict(resp.headers.items()), + ) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8") try: @@ -168,7 +175,9 @@ def test_get_health_returns_200(self, http_proxy): class TestHTTPRequestParsing: - def test_transfer_encoding_chunked_is_rejected_before_json_dispatch(self, http_proxy): + def test_transfer_encoding_chunked_is_rejected_before_json_dispatch( + self, http_proxy + ): base, _ = http_proxy port = int(base.rsplit(":", 1)[1]) response = _raw_http_request( @@ -185,7 +194,9 @@ def test_transfer_encoding_chunked_is_rejected_before_json_dispatch(self, http_p headers, _, body = response.partition(b"\r\n\r\n") assert headers.startswith(b"HTTP/1.0 400 ") - assert json.loads(body.decode("utf-8")) == {"error": "unsupported Transfer-Encoding"} + assert json.loads(body.decode("utf-8")) == { + "error": "unsupported Transfer-Encoding" + } class TestHTTPEvaluate: @@ -198,7 +209,11 @@ def test_permit_decision(self, http_proxy, example_mission, private_key): status, body = _post( base + "/evaluate", - {"session_id": session_id, "tool_name": "read_file", "arguments": {"path": "/x"}}, + { + "session_id": session_id, + "tool_name": "read_file", + "arguments": {"path": "/x"}, + }, ) assert status == 200 assert body["decision"] == "PERMIT" @@ -217,7 +232,9 @@ def test_deny_decision(self, http_proxy, example_mission, private_key): assert body["decision"] == "DENY" assert "reason" in body - def test_revoked_active_session_returns_403(self, http_proxy, example_mission, private_key): + def test_revoked_active_session_returns_403( + self, http_proxy, example_mission, private_key + ): base, proxy = http_proxy token = issue_passport(example_mission, private_key, ttl_s=60) _, start = _post(base + "/session/start", {"token": token}) @@ -225,7 +242,11 @@ def test_revoked_active_session_returns_403(self, http_proxy, example_mission, p status, body = _post( base + "/evaluate", - {"session_id": session_id, "tool_name": "read_file", "arguments": {"path": "/x"}}, + { + "session_id": session_id, + "tool_name": "read_file", + "arguments": {"path": "/x"}, + }, ) assert status == 200 assert body["decision"] == "PERMIT" @@ -234,11 +255,115 @@ def test_revoked_active_session_returns_403(self, http_proxy, example_mission, p status, body = _post( base + "/evaluate", - {"session_id": session_id, "tool_name": "read_file", "arguments": {"path": "/x"}}, + { + "session_id": session_id, + "tool_name": "read_file", + "arguments": {"path": "/x"}, + }, ) assert status == 403 assert body == {"error": "passport_revoked"} + def test_governed_action_and_explicit_risk_outcome_roundtrip( + self, + tmp_path, + private_key, + session_keys_dir, + unused_tcp_port, + ): + contract = ToolRiskContract.from_schema( + "delete_objects", + { + "type": "object", + "properties": { + "targets": {"type": "array", "items": {"type": "string"}} + }, + "required": ["targets"], + "additionalProperties": False, + }, + { + "version": 1, + "mandatory_facts": ["objects_affected"], + "extractors": { + "objects_affected": { + "kind": "array_length", + "pointer": "/targets", + } + }, + }, + ) + registry = ToolRiskRegistry() + registry.register(contract) + governed_proxy = GovernanceProxy( + log_path=tmp_path / "governance.jsonl", + state_dir=tmp_path / "state", + keys_dir=session_keys_dir, + public_key=private_key.public_key(), + private_key=private_key, + risk_registry=registry, + ) + _, base, shutdown = _build_server_thread( + governed_proxy, + private_key, + unused_tcp_port, + ) + try: + token = issue_passport( + MissionPassport( + agent_id="risk-http-agent", + mission="bounded deletion", + allowed_tools=["delete_objects"], + resource_scope=["**"], + risk_budget={ + "version": 1, + "lineage_id": "http-lineage", + "tools": { + "delete_objects": { + "contract_digest": contract.digest, + "max_facts": {"objects_affected": 2}, + } + }, + "ceilings": { + "objects_affected": { + "session": 2, + "agent": 2, + "lineage": 2, + } + }, + }, + ), + private_key, + ttl_s=60, + ) + status, start = _post(base + "/session/start", {"token": token}) + assert status == 200 + + status, evaluation = _post( + base + "/evaluate", + { + "session_id": start["session_id"], + "tool_name": "delete_objects", + "arguments": {"targets": ["a", "b"]}, + "risk_request_id": "http-request-1", + }, + ) + assert status == 200 + assert evaluation["decision"] == "PERMIT" + + status, outcome = _post( + base + "/risk/outcome", + { + "session_id": start["session_id"], + "risk_request_id": "http-request-1", + "outcome": "committed", + }, + ) + assert status == 200 + assert outcome["status"] == "committed" + assert outcome["receipt_id"] + finally: + shutdown() + class TestHTTPDelegate: def test_valid_delegation_returns_200(self, http_proxy, private_key): @@ -580,9 +705,10 @@ def delegate(i: int) -> tuple[int, dict[str, Any]]: assert sum(accepted) == 5 assert all(status == 403 for status in rejected) - assert p1.lineage_budget_ledger.snapshot(start["session_id"])[ - "reserved_total" - ] == 5 + assert ( + p1.lineage_budget_ledger.snapshot(start["session_id"])["reserved_total"] + == 5 + ) finally: shutdown2() shutdown1() @@ -652,9 +778,7 @@ def test_issue_with_lineage_budgets_fails_phase1_deferred(self, http_proxy): "allowed_tools": ["read"], "delegation_allowed": True, "max_delegation_depth": 1, - "lineage_budgets": [ - {"type": "max_child_tool_calls", "limit": 3} - ], + "lineage_budgets": [{"type": "max_child_tool_calls", "limit": 3}], } }, ) @@ -726,7 +850,9 @@ def test_delegate_rejects_string_child_resource_scope_before_char_splitting( class TestHTTPSessionEnd: - def test_session_end_includes_attestation(self, http_proxy, example_mission, private_key): + def test_session_end_includes_attestation( + self, http_proxy, example_mission, private_key + ): base, _ = http_proxy token = issue_passport(example_mission, private_key, ttl_s=60) _, start = _post(base + "/session/start", {"token": token}) @@ -742,7 +868,9 @@ def test_session_end_includes_attestation(self, http_proxy, example_mission, pri assert "summary" in body assert body["summary"]["permits"] >= 1 - def test_session_start_returns_503_when_replay_cache_deleted(self, http_proxy, example_mission, private_key): + def test_session_start_returns_503_when_replay_cache_deleted( + self, http_proxy, example_mission, private_key + ): base, proxy = http_proxy token = issue_passport(example_mission, private_key, ttl_s=60) _post(base + "/session/start", {"token": token}) @@ -753,7 +881,9 @@ def test_session_start_returns_503_when_replay_cache_deleted(self, http_proxy, e assert status == 503 assert body == {"error": "replay_cache_unavailable"} - def test_attest_is_idempotent_after_end(self, http_proxy, example_mission, private_key): + def test_attest_is_idempotent_after_end( + self, http_proxy, example_mission, private_key + ): base, _ = http_proxy token = issue_passport(example_mission, private_key, ttl_s=60) _, start = _post(base + "/session/start", {"token": token}) @@ -781,9 +911,7 @@ class TestDelegateRequiresActiveParentSession: parent's ceiling if the parent session wasn't in the in-memory dict. Now it must refuse unless there's a persisted session for the parent jti.""" - def test_delegate_without_started_parent_returns_403( - self, http_proxy, private_key - ): + def test_delegate_without_started_parent_returns_403(self, http_proxy, private_key): base, _ = http_proxy parent_mission = MissionPassport( agent_id="parent", @@ -809,9 +937,7 @@ def test_delegate_without_started_parent_returns_403( assert status == 403 assert "parent session" in body.get("error", "").lower() - def test_delegate_with_ended_parent_returns_403( - self, http_proxy, private_key - ): + def test_delegate_with_ended_parent_returns_403(self, http_proxy, private_key): """A parent session that's been ended should not be able to spawn children.""" base, _ = http_proxy parent_mission = MissionPassport( @@ -859,11 +985,14 @@ def test_delegate_with_active_parent_caps_child_at_remaining( # Burn 7 calls of the 10 budget for _ in range(7): - _post(base + "/evaluate", { - "session_id": session_id, - "tool_name": "read", - "arguments": {}, - }) + _post( + base + "/evaluate", + { + "session_id": session_id, + "tool_name": "read", + "arguments": {}, + }, + ) # Delegate — parent has 3 remaining; child should get at most 3 status, body = _post( @@ -926,6 +1055,7 @@ def fake_urlopen(request, timeout=0, context=None): # noqa: ANN001, ARG001 def _issue_aat_md(private_key, *, mission_id: str) -> str: mission = MissionPassport( agent_id="md-authority", + mission_id=mission_id, mission="authoritative AAT HTTP mission", allowed_tools=["read"], forbidden_tools=[], @@ -1055,6 +1185,7 @@ def test_aat_session_evaluate_delegate_receipt_chain( # regressions pin the new HTTP-edge guards so future refactors can't # silently drop them. + class TestHTTPAATPoP: def test_cnf_aat_without_pop_inputs_fails_closed_at_http_edge( self, http_proxy, private_key, public_key, monkeypatch @@ -1130,9 +1261,7 @@ def test_kb_jwt_size_cap_rejects_oversize_payload( assert status == 400, f"expected 400, got {status}: {body}" assert "MAX_KB_JWT_BYTES" in body.get("error", "") - def test_delegate_reserves_budget_across_siblings( - self, http_proxy, private_key - ): + def test_delegate_reserves_budget_across_siblings(self, http_proxy, private_key): """Round-3 H1: sibling delegations must not reuse the same remainder snapshot.""" base, _ = http_proxy parent_mission = MissionPassport( @@ -1148,11 +1277,14 @@ def test_delegate_reserves_budget_across_siblings( session_id = start["session_id"] for _ in range(7): - _post(base + "/evaluate", { - "session_id": session_id, - "tool_name": "read", - "arguments": {}, - }) + _post( + base + "/evaluate", + { + "session_id": session_id, + "tool_name": "read", + "arguments": {}, + }, + ) status1, body1 = _post( base + "/delegate", @@ -1212,11 +1344,14 @@ def test_http_e2e_receipts_chain_budget_reservations( session_id = start["session_id"] for _ in range(2): - status, body = _post(base + "/evaluate", { - "session_id": session_id, - "tool_name": "read", - "arguments": {}, - }) + status, body = _post( + base + "/evaluate", + { + "session_id": session_id, + "tool_name": "read", + "arguments": {}, + }, + ) assert status == 200 assert body["decision"] == "PERMIT" @@ -1267,7 +1402,10 @@ def test_http_e2e_receipts_chain_budget_reservations( claims = verify_chain([entry["jwt"] for entry in entries], public_key) assert {claim["trace_id"] for claim in claims} == {session_id} assert len({claim["run_nonce"] for claim in claims}) == 1 - assert all(claim["invocation_digest"]["scope"] == "normalized_input" for claim in claims) + assert all( + claim["invocation_digest"]["scope"] == "normalized_input" + for claim in claims + ) assert [claim["tool"] for claim in claims] == [ "read", "read", @@ -1308,11 +1446,14 @@ def test_failed_delegate_does_not_consume_reserved_budget( session_id = start["session_id"] for _ in range(7): - _post(base + "/evaluate", { - "session_id": session_id, - "tool_name": "read", - "arguments": {}, - }) + _post( + base + "/evaluate", + { + "session_id": session_id, + "tool_name": "read", + "arguments": {}, + }, + ) failed_status, failed_body = _post( base + "/delegate", @@ -1351,12 +1492,18 @@ def test_failed_delegate_does_not_consume_reserved_budget( # Go regressions: missing-header → 401, wrong-token → 401, correct- # token → not-401, public paths remain unauthenticated. + def _build_authenticated_server_thread( - proxy: GovernanceProxy, private_key, port: int, *, api_token: str, + proxy: GovernanceProxy, + private_key, + port: int, + *, + api_token: str, ): """Variant of ``_build_server_thread`` that runs with require_auth=True and a fixed token, so tests can exercise the bearer-auth path.""" import signal as _signal + original = _signal.signal _signal.signal = lambda *_a, **_kw: None # type: ignore[assignment] @@ -1393,7 +1540,10 @@ def run() -> None: def authed_http_proxy(proxy, private_key, unused_tcp_port): token = "auth-test-token-32-bytes-A-B-C-D-E" thread, base, shutdown = _build_authenticated_server_thread( - proxy, private_key, unused_tcp_port, api_token=token, + proxy, + private_key, + unused_tcp_port, + api_token=token, ) yield base, proxy, token shutdown() @@ -1426,7 +1576,9 @@ def test_missing_authorization_header_rejected(self, authed_http_proxy): def test_wrong_token_rejected(self, authed_http_proxy): base, _, _ = authed_http_proxy status, body = _post_with_auth( - base + "/issue", {}, token="attacker-supplied-wrong-token-32", + base + "/issue", + {}, + token="attacker-supplied-wrong-token-32", ) assert status == 401 assert "invalid bearer token" in body.get("error", "") @@ -1497,6 +1649,7 @@ def test_lowercase_bearer_scheme_accepted(self, authed_http_proxy): # anti-pattern (raw ``compare_digest(provided, api_token_bytes)``) # that round-8 audit identified as the regression vector. + class TestPythonProxyBearerAuthSourceShape: """Source-shape regressions that pin fixed-length bearer comparison closure (round-8 FIX-R8-1) at the code-text level. These tests @@ -1563,6 +1716,7 @@ def test_check_auth_uses_compare_digest_on_fixed_length_material(self): # client-presented bearer can match — the operator-confusion failure # mode R9-1 closed. + class TestPythonProxyCliTokenStrip: """FIX-R11-1 (round-11, 2026-04-29): close round-10 audit's LOW-R10-A finding — the R10-4 ``--api-token`` CLI strip shipped @@ -1584,12 +1738,16 @@ def test_whitespace_padded_cli_token_authenticates_after_trim( monkeypatch.delenv("VIBAP_API_TOKEN", raising=False) canonical_token = "cli-test-token-32-bytes-DEFGHIJ" thread, base, shutdown = _build_authenticated_server_thread( - proxy, private_key, unused_tcp_port, + proxy, + private_key, + unused_tcp_port, api_token=f" {canonical_token} ", ) try: status, body = _post_with_auth( - base + "/issue", {}, token=canonical_token, + base + "/issue", + {}, + token=canonical_token, ) assert status != 401, ( f"R10-4 regression: trimmed CLI token authentication " @@ -1616,13 +1774,17 @@ def test_whitespace_padded_env_token_authenticates_after_trim( monkeypatch.setenv("VIBAP_API_TOKEN", f" {canonical_token} ") thread, base, shutdown = _build_authenticated_server_thread( - proxy, private_key, unused_tcp_port, + proxy, + private_key, + unused_tcp_port, api_token="ignored-arg-because-env-takes-precedence", ) try: # Client presents the canonical (trimmed) token — must succeed. status, body = _post_with_auth( - base + "/issue", {}, token=canonical_token, + base + "/issue", + {}, + token=canonical_token, ) assert status != 401, ( f"R9-1 regression: trimmed env token authentication " @@ -1677,8 +1839,7 @@ def test_non_ascii_bearer_token_rejected_with_explicit_message( ) body = json.loads(body_bytes.decode("utf-8")) assert "ASCII" in body.get("error", ""), ( - f"R9-5 regression: error must explicitly name ASCII; " - f"got: {body}" + f"R9-5 regression: error must explicitly name ASCII; got: {body}" ) except http.client.HTTPException: # If the underlying http.client refuses to send the header diff --git a/python/tests/test_mic_conformance.py b/python/tests/test_mic_conformance.py index 8ae25bd1..dfa82e19 100644 --- a/python/tests/test_mic_conformance.py +++ b/python/tests/test_mic_conformance.py @@ -47,6 +47,7 @@ def _issue_passport( pass ``""`` to remove it from claims entirely.""" mission = MissionPassport( agent_id="mic-test-agent", + mission_id=mission_id or FAKE_MISSION_ID, mission="MIC conformance test", allowed_tools=allowed_tools or ["read_file", "write_file"], forbidden_tools=["delete_file"], @@ -318,7 +319,9 @@ def test_independent_sessions_tracked(self, proxy, private_key): with proxy._last_seen_receipts_lock: assert s1.jti in proxy._last_seen_receipts assert s2.jti in proxy._last_seen_receipts - assert proxy._last_seen_receipts[s1.jti] != proxy._last_seen_receipts[s2.jti] + assert ( + proxy._last_seen_receipts[s1.jti] != proxy._last_seen_receipts[s2.jti] + ) def test_parent_receipt_required_for_child(self, proxy, private_key): # Inject parent_jti + delegation_chain into claims after session @@ -455,6 +458,7 @@ def test_mic_evidence_applies_all_checks(self, proxy, private_key): def test_missing_profile_defaults_to_delegation_core(self, proxy, private_key): mission = MissionPassport( agent_id="no-profile-agent", + mission_id="urn:ardur:mission:mic:no-profile", mission="No conformance profile set", allowed_tools=["read_file"], forbidden_tools=[], @@ -493,7 +497,9 @@ def test_receipt_records_manifest_drift(self, proxy, private_key): receipts = _read_receipts(proxy.receipts_log_path) assert len(receipts) >= 1 assert receipts[0]["verdict"] == "violation" - assert receipts[0].get("internal_denial_code") == DenialReason.MANIFEST_DRIFT.value + assert ( + receipts[0].get("internal_denial_code") == DenialReason.MANIFEST_DRIFT.value + ) def test_receipt_records_envelope_tampered(self, proxy, private_key): token = _issue_passport( @@ -506,7 +512,10 @@ def test_receipt_records_envelope_tampered(self, proxy, private_key): receipts = _read_receipts(proxy.receipts_log_path) assert len(receipts) >= 1 - assert receipts[0].get("internal_denial_code") == DenialReason.ENVELOPE_TAMPERED.value + assert ( + receipts[0].get("internal_denial_code") + == DenialReason.ENVELOPE_TAMPERED.value + ) # --------------------------------------------------------------------------- @@ -517,4 +526,8 @@ def test_receipt_records_envelope_tampered(self, proxy, private_key): def _read_receipts(path: Path) -> list[dict[str, Any]]: if not path.exists(): return [] - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] diff --git a/python/tests/test_mission_binding.py b/python/tests/test_mission_binding.py index a2328110..fa05d39e 100644 --- a/python/tests/test_mission_binding.py +++ b/python/tests/test_mission_binding.py @@ -58,6 +58,7 @@ def _status_list_token(private_key, *, idx: int, revoked: bool) -> str: def _issue_md(private_key, *, mission_id: str, revocation_ref: str) -> str: mission = MissionPassport( agent_id="md-authority", + mission_id=mission_id, mission="authoritative report mission", allowed_tools=["read_file"], forbidden_tools=["delete_file"], @@ -90,7 +91,9 @@ def _issue_dg(private_key, *, mission_ref: dict[str, str] | str) -> str: delegation_allowed=False, max_delegation_depth=0, ) - return issue_passport(dg, private_key, ttl_s=120, extra_claims={"mission_ref": mission_ref}) + return issue_passport( + dg, private_key, ttl_s=120, extra_claims={"mission_ref": mission_ref} + ) class _Response: @@ -135,15 +138,23 @@ def fake_urlopen(request, timeout=0, context=None): # noqa: ANN001, ARG001 def _receipt_entries(path: Path) -> list[dict[str, object]]: if not path.exists(): return [] - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + return [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] -def test_proxy_verifies_md_and_emits_receipt(proxy, private_key, public_key, monkeypatch): +def test_proxy_verifies_md_and_emits_receipt( + proxy, private_key, public_key, monkeypatch +): mission_id = "urn:ardur:mission:test:permit" md_url = "https://issuer.example/md/permit.jwt" status_url = "https://issuer.example/status/permit.jwt" revocation_ref = status_url + "#idx=4" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=revocation_ref) + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=revocation_ref + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, @@ -176,15 +187,23 @@ def test_proxy_verifies_md_and_emits_receipt(proxy, private_key, public_key, mon assert receipts[0]["verdict"] == "compliant" -def test_md_policy_is_authoritative_over_dg_scope(proxy, private_key, public_key, monkeypatch): +def test_md_policy_is_authoritative_over_dg_scope( + proxy, private_key, public_key, monkeypatch +): mission_id = "urn:ardur:mission:test:scope" md_url = "https://issuer.example/md/scope.jwt" status_url = "https://issuer.example/status/scope.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=1") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=1" + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) _install_fetch_map( monkeypatch, @@ -195,7 +214,9 @@ def test_md_policy_is_authoritative_over_dg_scope(proxy, private_key, public_key ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/wide/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/wide/report.txt"} + ) assert decision == Decision.DENY assert "outside resource_scope" in reason @@ -205,11 +226,17 @@ def test_revoked_md_returns_violation(proxy, private_key, public_key, monkeypatc mission_id = "urn:ardur:mission:test:revoked" md_url = "https://issuer.example/md/revoked.jwt" status_url = "https://issuer.example/status/revoked.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=7") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=7" + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) _install_fetch_map( monkeypatch, @@ -220,13 +247,19 @@ def test_revoked_md_returns_violation(proxy, private_key, public_key, monkeypatc ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/report.txt"} + ) assert decision == Decision.VIOLATION assert reason == "revoked" -def test_tampered_md_returns_chain_invalid(tmp_path, private_key, public_key, session_keys_dir, monkeypatch): + +def test_tampered_md_returns_chain_invalid( + tmp_path, private_key, public_key, session_keys_dir, monkeypatch +): from vibap.proxy import GovernanceProxy + proxy = GovernanceProxy( log_path=tmp_path / "tampered_log.jsonl", state_dir=tmp_path / "tampered_state", @@ -236,12 +269,18 @@ def test_tampered_md_returns_chain_invalid(tmp_path, private_key, public_key, se mission_id = "urn:ardur:mission:test:tampered" md_url = "https://issuer.example/md/tampered.jwt" status_url = "https://issuer.example/status/tampered.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=2") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=2" + ) md = mission_module.load_mission_declaration(md_token, public_key) tampered = _tamper_jwt_payload(md_token, {"mission": "tampered mission"}) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) _install_fetch_map( monkeypatch, @@ -252,21 +291,31 @@ def test_tampered_md_returns_chain_invalid(tmp_path, private_key, public_key, se ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/report.txt"} + ) assert decision == Decision.VIOLATION assert reason == "chain_invalid" -def test_status_list_network_error_fails_closed(proxy, private_key, public_key, monkeypatch): +def test_status_list_network_error_fails_closed( + proxy, private_key, public_key, monkeypatch +): mission_id = "urn:ardur:mission:test:network" md_url = "https://issuer.example/md/network.jwt" status_url = "https://issuer.example/status/network.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=5") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=5" + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) _install_fetch_map( monkeypatch, @@ -277,7 +326,9 @@ def test_status_list_network_error_fails_closed(proxy, private_key, public_key, ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/report.txt"} + ) assert decision == Decision.INSUFFICIENT_EVIDENCE assert reason == "revocation_unavailable" @@ -287,11 +338,17 @@ def test_oversized_status_list_rejected(proxy, private_key, public_key, monkeypa mission_id = "urn:ardur:mission:test:oversized-status-list" md_url = "https://issuer.example/md/oversized.jwt" status_url = "https://issuer.example/status/oversized.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=0") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=0" + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) oversized_body = b"x" * ((2 << 20) + 1) @@ -304,11 +361,15 @@ def test_oversized_status_list_rejected(proxy, private_key, public_key, monkeypa ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/report.txt"} + ) assert decision == Decision.INSUFFICIENT_EVIDENCE assert reason == "status_list_too_large" - with pytest.raises(mission_module.MissionStatusUnavailableError, match="size limit"): + with pytest.raises( + mission_module.MissionStatusUnavailableError, match="size limit" + ): mission_module._fetch_text(status_url) @@ -317,11 +378,17 @@ def test_zip_bomb_rejected(proxy, private_key, public_key, monkeypatch): md_url = "https://issuer.example/md/zip-bomb.jwt" status_url = "https://issuer.example/status/zip-bomb.jwt" revocation_ref = status_url + "#idx=0" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=revocation_ref) + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=revocation_ref + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) compressed = zlib.compress(b"\x00" * (mission_module.MAX_DECOMPRESSED_BYTES + 1024)) @@ -348,23 +415,35 @@ def test_zip_bomb_rejected(proxy, private_key, public_key, monkeypatch): ) session = proxy.start_session(dg_token) - decision, reason = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/report.txt"}) + decision, reason = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/report.txt"} + ) assert decision == Decision.INSUFFICIENT_EVIDENCE assert reason == "status_list_too_large" - with pytest.raises(mission_module.MissionStatusUnavailableError, match="decompression limit"): + with pytest.raises( + mission_module.MissionStatusUnavailableError, match="decompression limit" + ): mission_module.mission_is_revoked(md, public_key) -def test_mission_cache_avoids_refetching_md(proxy, private_key, public_key, monkeypatch): +def test_mission_cache_avoids_refetching_md( + proxy, private_key, public_key, monkeypatch +): mission_id = "urn:ardur:mission:test:cache" md_url = "https://issuer.example/md/cache.jwt" status_url = "https://issuer.example/status/cache.jwt" - md_token = _issue_md(private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=3") + md_token = _issue_md( + private_key, mission_id=mission_id, revocation_ref=status_url + "#idx=3" + ) md = mission_module.load_mission_declaration(md_token, public_key) dg_token = _issue_dg( private_key, - mission_ref={"uri": md_url, "mission_id": mission_id, "mission_digest": md.payload_digest}, + mission_ref={ + "uri": md_url, + "mission_id": mission_id, + "mission_digest": md.payload_digest, + }, ) calls = _install_fetch_map( monkeypatch, @@ -376,7 +455,9 @@ def test_mission_cache_avoids_refetching_md(proxy, private_key, public_key, monk session = proxy.start_session(dg_token) first = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/one.txt"}) - second = proxy.evaluate_tool_call(session, "read_file", {"path": "/allowed/two.txt"}) + second = proxy.evaluate_tool_call( + session, "read_file", {"path": "/allowed/two.txt"} + ) assert first[0] == Decision.PERMIT assert second[0] == Decision.PERMIT @@ -415,6 +496,7 @@ def test_fetch_accepts_public_ip_literal(): # project guards against. These tests lock in that the always-on guard # now rejects MDs missing any of the six audit-flagged spec members. + class TestMissionDeclarationSchemaGuard: @pytest.mark.parametrize( "missing_field", @@ -437,6 +519,7 @@ def test_load_fails_closed_on_missing_required_member( mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:guard", mission="schema guard test", allowed_tools=["read"], forbidden_tools=[], @@ -464,6 +547,7 @@ def test_load_fails_closed_on_invalid_conformance_profile( mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:bad-profile", mission="bad profile test", allowed_tools=["read"], forbidden_tools=[], @@ -492,6 +576,7 @@ def test_load_fails_closed_on_invalid_tool_manifest_digest( mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:bad-digest", mission="bad digest test", allowed_tools=["read"], forbidden_tools=[], @@ -519,6 +604,7 @@ def test_load_fails_closed_on_mic_evidence_with_minimal_receipts( mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:mic-vs-minimal", mission="mic-evidence test", allowed_tools=["read"], forbidden_tools=[], @@ -541,9 +627,7 @@ def test_load_fails_closed_on_mic_evidence_with_minimal_receipts( ): mission_module.load_mission_declaration(md_token, public_key) - def test_strict_schema_rejects_legacy_field_mixing( - self, private_key, public_key - ): + def test_strict_schema_rejects_legacy_field_mixing(self, private_key, public_key): """Opt-in strict_schema=True applies the full v0.1 schema, which has ``additionalProperties: false`` at the root. Existing MDs from :func:`issue_passport` carry legacy fields like ``allowed_tools`` @@ -552,6 +636,7 @@ def test_strict_schema_rejects_legacy_field_mixing( mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:strict", mission="strict schema test", allowed_tools=["read"], forbidden_tools=[], @@ -568,7 +653,9 @@ def test_strict_schema_rejects_legacy_field_mixing( mission_module.MissionBindingError, match="violates v0.1 schema", ) as excinfo: - mission_module.load_mission_declaration(md_token, public_key, strict_schema=True) + mission_module.load_mission_declaration( + md_token, public_key, strict_schema=True + ) assert excinfo.value.reason == "schema_invalid" @@ -581,13 +668,13 @@ def test_strict_schema_rejects_legacy_field_mixing( # _PinnedIPHTTPSConnection: resolve once, validate once, connect to the # exact IP that passed validation. + class TestPinnedIPSSRFDefense: - def test_resolve_to_pinned_public_ip_rejects_all_private_dns( - self, monkeypatch - ): + def test_resolve_to_pinned_public_ip_rejects_all_private_dns(self, monkeypatch): """If every IP a hostname resolves to is private, the pinned-IP helper must raise — never silently return a private IP for the connection to walk into.""" + def fake_getaddrinfo(host, port, *args, **kwargs): # All-private resolution (IMDS + RFC1918) return [ @@ -602,15 +689,14 @@ def fake_getaddrinfo(host, port, *args, **kwargs): ): mission_module._resolve_to_pinned_public_ip("evil.example", 443) - def test_resolve_to_pinned_public_ip_picks_first_public_ip( - self, monkeypatch - ): + def test_resolve_to_pinned_public_ip_picks_first_public_ip(self, monkeypatch): """Mixed resolution → return the first public IP, skipping any leading private entries that would have been rejected.""" + def fake_getaddrinfo(host, port, *args, **kwargs): return [ - (None, None, None, None, ("10.0.0.1", port)), # private, skip - (None, None, None, None, ("8.8.8.8", port)), # public, take + (None, None, None, None, ("10.0.0.1", port)), # private, skip + (None, None, None, None, ("8.8.8.8", port)), # public, take (None, None, None, None, ("169.254.169.254", port)), ] @@ -618,9 +704,7 @@ def fake_getaddrinfo(host, port, *args, **kwargs): ip = mission_module._resolve_to_pinned_public_ip("mixed.example", 443) assert ip == "8.8.8.8" - def test_pinned_urlopen_uses_resolved_ip_not_dns_at_connect( - self, monkeypatch - ): + def test_pinned_urlopen_uses_resolved_ip_not_dns_at_connect(self, monkeypatch): """Production path: _pinned_urlopen resolves once, validates, then constructs a _PinnedIPHTTPSConnection with that IP. Any re-resolution happening at connect time would defeat FIX-7's @@ -649,9 +733,7 @@ def fake_create_connection(addr, timeout=None): ) with pytest.raises(mission_module.URLError): - mission_module._pinned_urlopen( - "https://attacker.example/path", timeout=5.0 - ) + mission_module._pinned_urlopen("https://attacker.example/path", timeout=5.0) # Confirm the TCP layer used the pinned IP, not the hostname. assert captured["addr"] == ("203.0.113.7", 443) @@ -700,9 +782,7 @@ def getresponse(self): def close(self): pass - monkeypatch.setattr( - mission_module, "_PinnedIPHTTPSConnection", _StubConn - ) + monkeypatch.setattr(mission_module, "_PinnedIPHTTPSConnection", _StubConn) # And short-circuit IP resolution so the test does not touch DNS. monkeypatch.setattr( mission_module, @@ -716,34 +796,18 @@ def test_redirect_3xx_rejected_with_clear_message(self, monkeypatch): status=302, headers={"Location": "https://elsewhere.example/new"}, ) - with pytest.raises( - mission_module.URLError, match="refused redirect" - ): - mission_module._pinned_urlopen( - "https://example.test/md.jwt", timeout=5.0 - ) + with pytest.raises(mission_module.URLError, match="refused redirect"): + mission_module._pinned_urlopen("https://example.test/md.jwt", timeout=5.0) def test_status_4xx_raises_httperror(self, monkeypatch): - self._stub_pinned_connection( - monkeypatch, status=404, body=b"not found" - ) - with pytest.raises( - urllib.error.HTTPError, match="HTTP 404" - ): - mission_module._pinned_urlopen( - "https://example.test/md.jwt", timeout=5.0 - ) + self._stub_pinned_connection(monkeypatch, status=404, body=b"not found") + with pytest.raises(urllib.error.HTTPError, match="HTTP 404"): + mission_module._pinned_urlopen("https://example.test/md.jwt", timeout=5.0) def test_status_5xx_raises_httperror(self, monkeypatch): - self._stub_pinned_connection( - monkeypatch, status=503, body=b"upstream sad" - ) - with pytest.raises( - urllib.error.HTTPError, match="HTTP 503" - ): - mission_module._pinned_urlopen( - "https://example.test/md.jwt", timeout=5.0 - ) + self._stub_pinned_connection(monkeypatch, status=503, body=b"upstream sad") + with pytest.raises(urllib.error.HTTPError, match="HTTP 503"): + mission_module._pinned_urlopen("https://example.test/md.jwt", timeout=5.0) def test_status_200_passes_through(self, monkeypatch): body = b"hello" @@ -762,6 +826,7 @@ def test_status_200_passes_through(self, monkeypatch): # that the verifier accepted forever. These tests pin the # generalization so the gap doesn't reopen silently. + class _ShimResponse: """Minimal urlopen-like response shim for status-list mock tests.""" @@ -788,6 +853,7 @@ def test_status_list_with_iat_in_far_future_fails_closed( ): mission = MissionPassport( agent_id="md-authority", + mission_id="urn:test:status-list-iat", mission="status-list iat-skew test", allowed_tools=["read"], forbidden_tools=[], @@ -838,9 +904,7 @@ def test_status_list_with_iat_in_far_future_fails_closed( class TestMissionDeclarationIatSkewGuard: - def test_md_with_iat_in_far_future_fails_closed( - self, private_key, public_key - ): + def test_md_with_iat_in_far_future_fails_closed(self, private_key, public_key): from tests.conftest import v01_required_md_extras mission = MissionPassport( @@ -863,6 +927,7 @@ def test_md_with_iat_in_far_future_fails_closed( "iat": far_future, "exp": far_future + 600, "jti": "md-far-future", + "mission_id": "urn:test:far-future", **v01_required_md_extras(mission_id="urn:test:far-future"), "allowed_tools": list(mission.allowed_tools), "forbidden_tools": list(mission.forbidden_tools), diff --git a/python/tests/test_risk_budget.py b/python/tests/test_risk_budget.py new file mode 100644 index 00000000..ff5013cb --- /dev/null +++ b/python/tests/test_risk_budget.py @@ -0,0 +1,1806 @@ +from __future__ import annotations + +import hashlib +import json +import multiprocessing +import os +import stat +import threading +from pathlib import Path + +import pytest +import jwt +from cryptography.hazmat.primitives.asymmetric import ec + +from vibap.canonical_json import canonical_json_bytes +from vibap.denial import DenialReason +from vibap.mission import MissionDeclaration, load_mission_declaration +from vibap.passport import ( + MissionPassport, + derive_child_passport, + issue_passport, + verify_passport, +) +from vibap.proxy import Decision, GovernanceProxy +from vibap.risk_budget import ( + FileRiskBudgetLedger, + RiskBudgetConflictError, + RiskBudgetError, + RiskBudgetReplayError, + RiskFactError, + ToolRiskContract, + ToolRiskRegistry, + attenuate_risk_budget, + normalize_risk_budget, + validate_action_risk, +) + + +@pytest.fixture +def delete_contract() -> ToolRiskContract: + return ToolRiskContract.from_schema( + "delete_objects", + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "targets": { + "type": "array", + "items": {"type": "string"}, + "maxItems": 10, + }, + "bytes": {"type": "integer", "minimum": 0}, + "irreversibility": { + "type": "string", + "enum": ["reversible", "compensatable", "irreversible"], + }, + }, + "required": ["targets", "bytes", "irreversibility"], + "additionalProperties": False, + }, + { + "version": 1, + "mandatory_facts": [ + "objects_affected", + "bytes_affected", + "irreversibility", + "destination_risk", + ], + "extractors": { + "objects_affected": {"kind": "array_length", "pointer": "/targets"}, + "bytes_affected": {"kind": "integer", "pointer": "/bytes"}, + "irreversibility": {"kind": "enum", "pointer": "/irreversibility"}, + "destination_risk": {"kind": "constant", "value": "trusted_service"}, + }, + }, + ) + + +def _policy(contract: ToolRiskContract, *, ceiling: int = 20) -> dict: + return { + "version": 1, + "lineage_id": "lineage-1", + "tools": { + contract.tool_name: { + "contract_digest": contract.digest, + "max_facts": { + "objects_affected": 5, + "bytes_affected": 100, + "irreversibility": "compensatable", + "destination_risk": "trusted_service", + }, + } + }, + "ceilings": { + "objects_affected": { + "session": ceiling, + "agent": ceiling, + "lineage": ceiling, + }, + "bytes_affected": { + "session": ceiling * 100, + "agent": ceiling * 100, + "lineage": ceiling * 100, + }, + }, + } + + +def _reserve( + ledger: FileRiskBudgetLedger, + *, + request_id: str, + objects: int, + ceiling: int, + fingerprint: str | None = None, + lineage_id: str = "lineage-1", + session_id: str = "session-1", + agent_id: str = "agent-1", + expires_at: int = 2_000_000_000, + now: float | None = None, +): + ceilings = { + "objects_affected": { + "session": ceiling, + "agent": ceiling, + "lineage": ceiling, + } + } + return ledger.reserve( + lineage_id=lineage_id, + session_id=session_id, + agent_id=agent_id, + request_id=request_id, + fingerprint=fingerprint or f"fingerprint-{request_id}", + numeric_facts={"objects_affected": objects}, + ceilings=ceilings, + policy_digest="sha256:" + "1" * 64, + contract_digest="sha256:" + "2" * 64, + fact_digest="sha256:" + "3" * 64, + expires_at=expires_at, + now=now, + ) + + +def _append_receipt_once_worker(log_path: str, start_event: object) -> None: + proxy = object.__new__(GovernanceProxy) + proxy.receipts_log_path = Path(log_path) + proxy._receipts_log_lock = threading.Lock() + proxy._last_seen_receipts_lock = threading.Lock() + proxy._last_seen_receipts = {} + start_event.wait(timeout=5) + proxy._log_receipt_once({"receipt_id": "shared-lifecycle-receipt"}) + + +def test_contract_digest_binds_tool_schema_and_extractors( + delete_contract: ToolRiskContract, +) -> None: + same = ToolRiskContract.from_schema( + delete_contract.tool_name, + delete_contract.input_schema, + delete_contract.risk_contract, + ) + changed_schema = dict(delete_contract.input_schema) + changed_schema["title"] = "different authenticated definition" + changed = ToolRiskContract.from_schema( + delete_contract.tool_name, + changed_schema, + delete_contract.risk_contract, + ) + + assert same.digest == delete_contract.digest + assert changed.digest != delete_contract.digest + assert delete_contract.digest.startswith("sha256:") + + +def test_contract_extracts_typed_facts_without_trusting_caller_labels( + delete_contract: ToolRiskContract, +) -> None: + facts = delete_contract.extract( + { + "targets": ["a", "b"], + "bytes": 80, + "irreversibility": "compensatable", + } + ) + + assert facts == { + "objects_affected": 2, + "bytes_affected": 80, + "irreversibility": "compensatable", + "destination_risk": "trusted_service", + } + + +@pytest.mark.parametrize("bad_bytes", [True, 1.5, -1]) +def test_contract_rejects_non_exact_or_negative_integers( + delete_contract: ToolRiskContract, + bad_bytes: object, +) -> None: + with pytest.raises(RiskFactError): + delete_contract.extract( + { + "targets": ["a"], + "bytes": bad_bytes, + "irreversibility": "reversible", + } + ) + + +def test_contract_rejects_external_schema_reference() -> None: + with pytest.raises(RiskBudgetError, match="external JSON Schema references"): + ToolRiskContract.from_schema( + "dangerous", + {"$ref": "https://attacker.example/schema.json"}, + { + "version": 1, + "mandatory_facts": ["objects_affected"], + "extractors": {"objects_affected": {"kind": "constant", "value": 1}}, + }, + ) + + +def test_contract_rejects_ambiguous_json_pointer_escape() -> None: + with pytest.raises(RiskBudgetError, match="pointer is invalid"): + ToolRiskContract.from_schema( + "dangerous", + {"type": "object"}, + { + "version": 1, + "mandatory_facts": ["objects_affected"], + "extractors": { + "objects_affected": { + "kind": "integer", + "pointer": "/count~2shadow", + } + }, + }, + ) + + +def test_contract_rejects_oversized_risk_contract_before_processing() -> None: + with pytest.raises(RiskBudgetError, match="risk_contract exceeds"): + ToolRiskContract.from_schema( + "dangerous", + {"type": "object"}, + { + "version": 1, + "mandatory_facts": ["destination_risk"], + "extractors": { + "destination_risk": { + "kind": "constant", + "value": "x" * (64 * 1024), + } + }, + }, + ) + + +def test_registry_cannot_replace_or_mutate_contracts_after_freeze( + delete_contract: ToolRiskContract, +) -> None: + registry = ToolRiskRegistry() + registry.register(delete_contract) + with pytest.raises(RiskBudgetConflictError): + registry.register(delete_contract) + registry.freeze() + with pytest.raises(RuntimeError, match="frozen"): + registry.register( + ToolRiskContract.from_schema( + "other", + {"type": "object"}, + { + "version": 1, + "mandatory_facts": ["objects_affected"], + "extractors": { + "objects_affected": {"kind": "constant", "value": 1} + }, + }, + ) + ) + + +def test_contract_nested_views_cannot_mutate_registered_authority( + delete_contract: ToolRiskContract, +) -> None: + original_digest = delete_contract.digest + schema_view = delete_contract.input_schema + contract_view = delete_contract.risk_contract + schema_view["properties"]["bytes"]["minimum"] = -100 + contract_view["extractors"]["bytes_affected"]["pointer"] = "/shadow" + + assert delete_contract.digest == original_digest + assert delete_contract.input_schema["properties"]["bytes"]["minimum"] == 0 + assert ( + delete_contract.risk_contract["extractors"]["bytes_affected"]["pointer"] + == "/bytes" + ) + assert ( + delete_contract.extract( + { + "targets": ["a"], + "bytes": 1, + "irreversibility": "reversible", + } + )["bytes_affected"] + == 1 + ) + + +def test_policy_normalization_is_closed_and_requires_all_numeric_scopes( + delete_contract: ToolRiskContract, +) -> None: + policy = _policy(delete_contract) + assert normalize_risk_budget(policy) == policy + + policy["ceilings"]["objects_affected"].pop("lineage") + with pytest.raises(RiskBudgetError, match="session, agent, and lineage"): + normalize_risk_budget(policy) + + +def test_policy_rejects_noncanonical_uppercase_contract_digest( + delete_contract: ToolRiskContract, +) -> None: + policy = _policy(delete_contract) + policy["tools"][delete_contract.tool_name]["contract_digest"] = ( + delete_contract.digest.upper().replace("SHA256:", "sha256:") + ) + + with pytest.raises(RiskBudgetError, match="lowercase hex"): + normalize_risk_budget(policy) + + +def test_policy_rejects_colliding_normalized_tool_names( + delete_contract: ToolRiskContract, +) -> None: + policy = _policy(delete_contract) + policy["tools"][" delete_objects "] = copy = json.loads( + json.dumps(policy["tools"]["delete_objects"]) + ) + assert copy + + with pytest.raises(RiskBudgetError, match="duplicate normalized tool name"): + normalize_risk_budget(policy) + + +def test_child_policy_can_only_reduce_authority( + delete_contract: ToolRiskContract, +) -> None: + parent = _policy(delete_contract) + child = json.loads(json.dumps(parent)) + child["tools"][delete_contract.tool_name]["max_facts"]["objects_affected"] = 2 + child["tools"][delete_contract.tool_name]["max_facts"]["irreversibility"] = ( + "reversible" + ) + child["ceilings"]["objects_affected"]["session"] = 10 + assert attenuate_risk_budget(parent, child) == child + + escalated = json.loads(json.dumps(child)) + escalated["tools"][delete_contract.tool_name]["max_facts"]["objects_affected"] = 6 + with pytest.raises(PermissionError, match="cap escalation"): + attenuate_risk_budget(parent, escalated) + + parent["tools"]["purge_records"] = { + "contract_digest": "sha256:" + ("4" * 64), + "max_facts": {"destructive_targets": 3}, + } + parent["ceilings"]["destructive_targets"] = { + "session": 3, + "agent": 3, + "lineage": 3, + } + child_without_purge = json.loads(json.dumps(parent)) + child_without_purge["tools"].pop("purge_records") + child_without_purge["ceilings"].pop("destructive_targets") + + assert attenuate_risk_budget(parent, child_without_purge) == child_without_purge + + +def test_child_policy_cannot_switch_lineage(delete_contract: ToolRiskContract) -> None: + parent = _policy(delete_contract) + child = json.loads(json.dumps(parent)) + child["lineage_id"] = "attacker-lineage" + + with pytest.raises(PermissionError, match="lineage_id escalation"): + attenuate_risk_budget(parent, child) + + +def test_action_check_enforces_contract_digest_and_typed_caps( + delete_contract: ToolRiskContract, +) -> None: + facts = delete_contract.extract( + { + "targets": ["a", "b"], + "bytes": 80, + "irreversibility": "compensatable", + } + ) + assert validate_action_risk(_policy(delete_contract), delete_contract, facts) == { + "objects_affected": 2, + "bytes_affected": 80, + } + + facts["irreversibility"] = "irreversible" + with pytest.raises(RiskBudgetError, match="risk_action_cap_exceeded"): + validate_action_risk(_policy(delete_contract), delete_contract, facts) + + +def test_reserve_is_atomic_across_scopes_and_keeps_identifiers_private( + tmp_path: Path, +) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + result = _reserve(ledger, request_id="request-secret", objects=2, ceiling=3) + + assert result.accepted + assert result.remaining == { + "objects_affected.session": 1, + "objects_affected.agent": 1, + "objects_affected.lineage": 1, + } + snapshot = ledger.snapshot("lineage-1") + serialized = canonical_json_bytes(snapshot) + assert b"request-secret" not in serialized + assert b"session-1" not in serialized + assert b"agent-1" not in serialized + assert b"lineage-1" not in serialized + assert stat.S_IMODE(os.stat(ledger.ledger_dir).st_mode) == 0o700 + ledger_file = next(ledger.ledger_dir.glob("*.json")) + assert stat.S_IMODE(os.stat(ledger_file).st_mode) == 0o600 + + +def test_same_request_never_reauthorizes_and_conflicting_retry_is_detected( + tmp_path: Path, +) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="request-1", objects=1, ceiling=5) + + with pytest.raises(RiskBudgetReplayError, match="already recorded as active"): + _reserve(ledger, request_id="request-1", objects=1, ceiling=5) + with pytest.raises(RiskBudgetConflictError, match="different semantics"): + _reserve( + ledger, + request_id="request-1", + objects=1, + ceiling=5, + fingerprint="changed", + ) + + +def test_sibling_session_cannot_close_or_quarantine_reservation(tmp_path: Path) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="owned-request", objects=1, ceiling=5) + + with pytest.raises(RiskBudgetConflictError, match="different session"): + ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-2", + request_id="owned-request", + outcome="released", + ) + assert ( + ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-2", + stale_before=2_000_000_000, + ) + == [] + ) + assert ( + ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="owned-request", + outcome="released", + ).status + == "released" + ) + + +def test_concurrent_workers_cannot_oversubscribe_lineage_ceiling( + tmp_path: Path, +) -> None: + ledgers = [FileRiskBudgetLedger(tmp_path), FileRiskBudgetLedger(tmp_path)] + barrier = threading.Barrier(2) + results: list[bool] = [] + failures: list[Exception] = [] + + def worker(index: int) -> None: + try: + barrier.wait() + result = _reserve( + ledgers[index], + request_id=f"request-{index}", + objects=1, + ceiling=1, + ) + results.append(result.accepted) + except Exception as exc: # pragma: no cover - asserted below + failures.append(exc) + + threads = [ + threading.Thread(target=worker, args=(index,), daemon=True) + for index in range(2) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert not failures + assert sorted(results) == [False, True] + + +def test_concurrent_lineages_share_one_agent_ceiling(tmp_path: Path) -> None: + ledgers = [FileRiskBudgetLedger(tmp_path), FileRiskBudgetLedger(tmp_path)] + barrier = threading.Barrier(2) + results: list[bool] = [] + failures: list[Exception] = [] + + def worker(index: int) -> None: + try: + barrier.wait() + result = _reserve( + ledgers[index], + request_id="shared-cross-lineage-request", + objects=1, + ceiling=1, + lineage_id=f"lineage-{index}", + session_id=f"session-{index}", + agent_id="shared-agent", + ) + results.append(result.accepted) + except Exception as exc: # pragma: no cover - asserted below + failures.append(exc) + + threads = [ + threading.Thread(target=worker, args=(index,), daemon=True) + for index in range(2) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert not failures + assert sorted(results) == [False, True] + + +def test_reserve_persists_pruning_before_budget_rejection(tmp_path: Path) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve( + ledger, + request_id="expired-release", + objects=1, + ceiling=1, + expires_at=100, + now=10, + ) + released = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="expired-release", + outcome="released", + now=20, + ) + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=released.request_hash, + lifecycle_id=released.lifecycle_id, + receipt_id="released-receipt", + ) + + rejected = _reserve( + ledger, + request_id="too-large", + objects=2, + ceiling=1, + now=101, + ) + + assert rejected.accepted is False + reopened = FileRiskBudgetLedger(tmp_path).snapshot("lineage-1") + assert released.request_hash in reopened["tombstones"] + assert released.request_hash not in reopened["reservations"] + + +def test_commit_spends_authority_and_release_returns_it(tmp_path: Path) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="commit", objects=2, ceiling=3) + committed = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="commit", + outcome="committed", + ) + assert committed.remaining["objects_affected.lineage"] == 1 + assert ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="commit", + outcome="committed", + ).idempotent + + _reserve(ledger, request_id="release", objects=1, ceiling=3) + released = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="release", + outcome="released", + ) + assert released.remaining["objects_affected.lineage"] == 1 + with pytest.raises(RiskBudgetConflictError, match="not reconcilable"): + ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="release", + outcome="committed", + ) + + +def test_stale_reservation_is_quarantined_without_returning_authority( + tmp_path: Path, +) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="stale", objects=2, ceiling=2) + + quarantined = ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-1", + stale_before=2_000_000_000, + ) + assert len(quarantined) == 1 + assert ledger.unresolved_for_session( + lineage_id="lineage-1", session_id="session-1" + ) == [result.request_hash for result in quarantined] + blocked = _reserve(ledger, request_id="next", objects=1, ceiling=2) + assert not blocked.accepted + assert blocked.blocking_scope == "session" + + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=quarantined[0].request_hash, + lifecycle_id=quarantined[0].lifecycle_id, + receipt_id="receipt-quarantine", + ) + reconciled = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="stale", + outcome="committed", + ) + assert reconciled.status == "committed" + + +def test_pruning_terminal_records_preserves_committed_authority(tmp_path: Path) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="committed", objects=2, ceiling=3) + committed = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="committed", + outcome="committed", + ) + _reserve(ledger, request_id="released", objects=1, ceiling=3) + released = ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="released", + outcome="released", + ) + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=committed.request_hash, + lifecycle_id=committed.lifecycle_id, + receipt_id="receipt-committed", + ) + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=released.request_hash, + lifecycle_id=released.lifecycle_id, + receipt_id="receipt-released", + ) + + result = ledger.prune_expired(lineage_id="lineage-1", now=2_000_000_001) + snapshot = ledger.snapshot("lineage-1") + assert result == {"pruned": 2, "quarantined": 0} + assert snapshot["reservations"] == {} + assert len(snapshot["tombstones"]) == 2 + assert all(account["spent"] == 2 for account in snapshot["accounts"].values()) + assert all( + account["archived_spent"] == 2 for account in snapshot["accounts"].values() + ) + blocked = _reserve(ledger, request_id="too-large", objects=2, ceiling=3) + assert not blocked.accepted + + with pytest.raises(RiskBudgetReplayError, match="already archived"): + _reserve(ledger, request_id="committed", objects=2, ceiling=3) + + +def test_quarantined_reservation_can_only_reconcile_as_committed( + tmp_path: Path, +) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="uncertain", objects=1, ceiling=2) + quarantined = ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-1", + stale_before=2_000_000_000, + ) + + with pytest.raises(RiskBudgetConflictError, match="cannot be released"): + ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="uncertain", + outcome="released", + ) + + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=quarantined[0].request_hash, + lifecycle_id=quarantined[0].lifecycle_id, + receipt_id="receipt-quarantine", + ) + assert ( + ledger.record_outcome( + lineage_id="lineage-1", + session_id="session-1", + request_id="uncertain", + outcome="committed", + ).status + == "committed" + ) + + +def test_bounded_quarantine_archives_spend_but_retains_pending_outbox( + tmp_path: Path, +) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="abandoned", objects=1, ceiling=2) + quarantined = ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-1", + stale_before=2_000_000_000, + now=1_900_000_000, + ) + + ledger.prune_expired(lineage_id="lineage-1", now=2_000_000_001) + snapshot = ledger.snapshot("lineage-1") + assert snapshot["reservations"] == {} + tombstone = snapshot["tombstones"][quarantined[0].request_hash] + assert tombstone["status"] == "quarantined_committed" + assert tombstone["lifecycle"]["state"] == "pending" + assert all(account["reserved"] == 0 for account in snapshot["accounts"].values()) + assert all(account["spent"] == 1 for account in snapshot["accounts"].values()) + assert ledger.unresolved_for_session( + lineage_id="lineage-1", session_id="session-1" + ) == [quarantined[0].request_hash] + + recovered = ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-1", + stale_before=2_000_000_000, + ) + assert len(recovered) == 1 + assert recovered[0].idempotent is True + ledger.mark_lifecycle_delivered( + lineage_id="lineage-1", + session_id="session-1", + request_hash=recovered[0].request_hash, + lifecycle_id=recovered[0].lifecycle_id, + receipt_id="receipt-recovered-quarantine", + ) + ledger.prune_expired( + lineage_id="lineage-1", + now=tombstone["replay_until"] + 1, + ) + assert ledger.snapshot("lineage-1")["tombstones"] == {} + + +def test_ledger_rejects_symlink_substitution(tmp_path: Path) -> None: + state_dir = tmp_path / "state" + state_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (state_dir / "risk_budgets").symlink_to(outside, target_is_directory=True) + + with pytest.raises(RiskBudgetError, match="must not be a symlink"): + FileRiskBudgetLedger(state_dir) + + +def test_corrupt_ledger_fails_closed(tmp_path: Path) -> None: + ledger = FileRiskBudgetLedger(tmp_path) + _reserve(ledger, request_id="request-1", objects=1, ceiling=5) + ledger_file = next(ledger.ledger_dir.glob("*.json")) + payload = json.loads(ledger_file.read_text(encoding="utf-8")) + account = next(iter(payload["accounts"].values())) + account["reserved"] = 0 + ledger_file.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(RiskBudgetError, match="reserved invariant"): + ledger.snapshot("lineage-1") + + +def test_root_passport_binds_omitted_risk_lineage_to_signed_jti( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + policy = _policy(delete_contract) + policy.pop("lineage_id") + mission = MissionPassport( + agent_id="agent-1", + mission="delete bounded objects", + allowed_tools=["delete_objects"], + risk_budget=policy, + ) + + token = issue_passport(mission, private_key, ttl_s=60) + claims = verify_passport(token, private_key.public_key()) + assert claims["risk_budget"]["lineage_id"] == claims["jti"] + assert claims["risk_budget"]["tools"]["delete_objects"]["contract_digest"] == ( + delete_contract.digest + ) + + +def test_passport_risk_policy_cannot_escape_tool_allowlist( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + mission = MissionPassport( + agent_id="agent-1", + mission="read only", + allowed_tools=["read_file"], + risk_budget=_policy(delete_contract), + ) + + with pytest.raises(ValueError, match="subset of allowed_tools"): + issue_passport(mission, private_key, ttl_s=60) + + +def test_extra_claims_cannot_replace_validated_risk_policy( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + policy = _policy(delete_contract) + policy.pop("lineage_id") + mission = MissionPassport( + agent_id="agent-1", + mission="delete bounded objects", + allowed_tools=["delete_objects"], + risk_budget=policy, + ) + + claims = verify_passport( + issue_passport(mission, private_key, ttl_s=60), + private_key.public_key(), + ) + assert claims["risk_budget"]["lineage_id"] == claims["jti"] + + for protected_claims in ( + {"risk_budget": {}}, + {"jti": "attacker-jti"}, + {"sub": "attacker-agent"}, + {"allowed_tools": ["unbounded_tool"]}, + ): + with pytest.raises(ValueError, match="cannot override"): + issue_passport( + mission, + private_key, + ttl_s=60, + extra_claims=protected_claims, + ) + + with pytest.raises(ValueError, match="canonical UUID"): + issue_passport( + mission, + private_key, + ttl_s=60, + jti_override="not-a-session-uuid", + ) + + +def test_mission_declaration_loader_preserves_signed_risk_policy( + delete_contract: ToolRiskContract, +) -> None: + from tests.conftest import v01_required_md_extras + + private_key = ec.generate_private_key(ec.SECP256R1()) + mission = MissionPassport( + agent_id="risk-md-authority", + mission_id="urn:ardur:mission:risk-budget", + mission="authoritative bounded deletion", + allowed_tools=["delete_objects"], + risk_budget=_policy(delete_contract), + ) + token = issue_passport( + mission, + private_key, + ttl_s=60, + extra_claims=v01_required_md_extras(mission_id="urn:ardur:mission:risk-budget"), + ) + + declaration = load_mission_declaration(token, private_key.public_key()) + + assert ( + declaration.passport.risk_budget + == verify_passport( + token, + private_key.public_key(), + )["risk_budget"] + ) + + +def test_delegated_passport_attenuates_risk_policy( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + parent_policy = _policy(delete_contract) + parent = issue_passport( + MissionPassport( + agent_id="parent", + mission="coordinate bounded deletion", + allowed_tools=["delete_objects"], + delegation_allowed=True, + max_delegation_depth=1, + risk_budget=parent_policy, + ), + private_key, + ttl_s=60, + ) + child_policy = json.loads(json.dumps(parent_policy)) + child_policy["tools"]["delete_objects"]["max_facts"]["objects_affected"] = 2 + child_policy["ceilings"]["objects_affected"]["session"] = 2 + + child = derive_child_passport( + parent, + private_key.public_key(), + private_key, + "child", + ["delete_objects"], + "delete two objects", + child_risk_budget=child_policy, + ) + claims = verify_passport(child, private_key.public_key(), parent_token=parent) + assert claims["risk_budget"] == child_policy + + +def test_delegated_passport_projects_policy_to_retained_tools( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + parent_policy = _policy(delete_contract) + parent_policy["tools"]["purge_records"] = { + "contract_digest": "sha256:" + ("4" * 64), + "max_facts": {"destructive_targets": 3}, + } + parent_policy["ceilings"]["destructive_targets"] = { + "session": 3, + "agent": 3, + "lineage": 3, + } + parent = issue_passport( + MissionPassport( + agent_id="parent", + mission="coordinate bounded cleanup", + allowed_tools=["delete_objects", "purge_records"], + delegation_allowed=True, + max_delegation_depth=1, + risk_budget=parent_policy, + ), + private_key, + ttl_s=60, + ) + + child = derive_child_passport( + parent, + private_key.public_key(), + private_key, + "child", + ["delete_objects"], + "delete bounded objects only", + ) + claims = verify_passport(child, private_key.public_key(), parent_token=parent) + + assert set(claims["risk_budget"]["tools"]) == {"delete_objects"} + assert "destructive_targets" not in claims["risk_budget"]["ceilings"] + + +def test_ungoverned_parent_cannot_introduce_child_risk_policy( + delete_contract: ToolRiskContract, +) -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + parent = issue_passport( + MissionPassport( + agent_id="parent", + mission="legacy parent", + allowed_tools=["delete_objects"], + delegation_allowed=True, + max_delegation_depth=1, + ), + private_key, + ttl_s=60, + ) + + with pytest.raises(PermissionError, match="parent has none"): + derive_child_passport( + parent, + private_key.public_key(), + private_key, + "child", + ["delete_objects"], + "attempt to add policy", + child_risk_budget=_policy(delete_contract), + ) + + +def test_absent_risk_policy_preserves_legacy_mission_dict_shape() -> None: + mission = MissionPassport( + agent_id="legacy", + mission="read", + allowed_tools=["read_file"], + ) + + assert "risk_budget" not in mission.to_dict() + with pytest.raises(ValueError, match="must be a JSON object"): + MissionPassport.from_dict( + { + "agent_id": "invalid", + "mission": "read", + "allowed_tools": ["read_file"], + "risk_budget": None, + } + ) + + +def _governed_proxy( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + keys_dir: Path, + contract: ToolRiskContract, +) -> GovernanceProxy: + registry = ToolRiskRegistry() + registry.register(contract) + return GovernanceProxy( + log_path=tmp_path / "governance.jsonl", + receipts_log_path=tmp_path / "receipts.jsonl", + state_dir=tmp_path / "state", + keys_dir=keys_dir, + public_key=private_key.public_key(), + private_key=private_key, + risk_registry=registry, + ) + + +def _start_governed_session( + proxy: GovernanceProxy, + private_key: ec.EllipticCurvePrivateKey, + contract: ToolRiskContract, + *, + forbidden: bool = False, + ceiling: int = 20, +): + mission = MissionPassport( + agent_id="risk-agent", + mission="bounded object deletion", + allowed_tools=["delete_objects"], + forbidden_tools=["delete_objects"] if forbidden else [], + resource_scope=["**"], + max_tool_calls=10, + risk_budget=_policy(contract, ceiling=ceiling), + ) + return proxy.start_session(issue_passport(mission, private_key, ttl_s=60)) + + +def _safe_delete_arguments(*, count: int = 2, byte_count: int = 80) -> dict: + return { + "targets": [f"object-{index}" for index in range(count)], + "bytes": byte_count, + "irreversibility": "compensatable", + } + + +def test_proxy_requires_request_id_before_governed_action( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + + decision, reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + ) + + assert decision == Decision.INSUFFICIENT_EVIDENCE + assert reason == "risk_request_id_invalid" + assert session.events[-1].denial_reason == DenialReason.RISK_REQUEST_ID_INVALID + assert session.tool_call_count == 0 + + +def test_proxy_permit_requires_explicit_outcome_and_lifecycle_is_not_tool_counted( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + + decision, reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="private-request-1", + ) + assert (decision, reason) == (Decision.PERMIT, "within scope") + with pytest.raises(PermissionError, match="risk_budget_outcome_unresolved"): + proxy.end_session(session) + + outcome = proxy.record_risk_outcome( + session, + risk_request_id="private-request-1", + outcome="committed", + ) + assert outcome["status"] == "committed" + assert outcome["receipt_id"] + proxy.record_tool_result(session, "executor response", 12.5) + summary = proxy.end_session(session) + assert summary["total_events"] == 1 + assert summary["permits"] == 1 + assert len(session.events) == 2 + assert session.events[0].response == "executor response" + assert session.events[0].duration_ms == 12.5 + assert session.events[-1].tool_name == "risk_budget_lifecycle" + assert session.events[-1].response is None + + receipt_text = (tmp_path / "receipts.jsonl").read_text(encoding="utf-8") + assert "private-request-1" not in receipt_text + assert "object-0" not in receipt_text + receipt_claims = [ + jwt.decode( + json.loads(line)["jwt"], + private_key.public_key(), + algorithms=["ES256"], + options={"verify_aud": False}, + ) + for line in receipt_text.splitlines() + ] + assert all("risk_facts" in claims["measurements"] for claims in receipt_claims) + assert ( + len( + {claims["measurements"]["risk_facts"]["value"] for claims in receipt_claims} + ) + == 1 + ) + + +def test_proxy_outcome_retry_recovers_same_persisted_receipt_after_log_failure( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + assert ( + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="outbox-retry", + )[0] + == Decision.PERMIT + ) + + original_log_once = proxy._log_receipt_once + + def fail_before_append(_entry: dict) -> None: + raise OSError("simulated receipt log failure") + + monkeypatch.setattr(proxy, "_log_receipt_once", fail_before_append) + with pytest.raises(OSError, match="simulated receipt log failure"): + proxy.record_risk_outcome( + session, + risk_request_id="outbox-retry", + outcome="committed", + ) + snapshot = proxy.risk_budget_ledger.snapshot("lineage-1") + reservation = next(iter(snapshot["reservations"].values())) + assert reservation["lifecycle"]["state"] == "pending" + + monkeypatch.setattr(proxy, "_log_receipt_once", original_log_once) + recovered = proxy.record_risk_outcome( + session, + risk_request_id="outbox-retry", + outcome="committed", + ) + assert recovered["idempotent"] is True + assert recovered["receipt_id"] + lifecycle_events = [ + event for event in session.events if event.reason == "risk_outcome_committed" + ] + assert len(lifecycle_events) == 1 + assert ( + lifecycle_events[0].risk_receipt_entry["receipt_id"] == recovered["receipt_id"] + ) + receipt_lines = [ + json.loads(line) + for line in (tmp_path / "receipts.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + assert ( + sum(line["receipt_id"] == recovered["receipt_id"] for line in receipt_lines) + == 1 + ) + delivered = proxy.risk_budget_ledger.snapshot("lineage-1") + assert next(iter(delivered["reservations"].values()))["lifecycle"]["state"] == ( + "delivered" + ) + + +def test_proxy_rejects_mid_session_risk_policy_rotation( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + original_resolve = proxy._resolve_authoritative_policy_claims + assert ( + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(count=1), + risk_request_id="snapshot-first", + )[0] + == Decision.PERMIT + ) + proxy.record_risk_outcome( + session, + risk_request_id="snapshot-first", + outcome="committed", + ) + frozen_snapshot = json.loads(json.dumps(session.risk_policy_snapshot)) + + def rotated_policy(claims: dict) -> dict: + rotated = json.loads(json.dumps(original_resolve(claims))) + rotated["risk_budget"]["ceilings"]["objects_affected"]["session"] += 1 + return rotated + + monkeypatch.setattr(proxy, "_resolve_authoritative_policy_claims", rotated_policy) + decision, reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(count=1), + risk_request_id="snapshot-second", + ) + + assert (decision, reason) == ( + Decision.INSUFFICIENT_EVIDENCE, + "risk_policy_invalid", + ) + assert session.risk_policy_snapshot == frozen_snapshot + + session.risk_policy_snapshot["ceilings"]["objects_affected"]["session"] = 999 + proxy.summarize_session(session) + assert session.risk_policy_snapshot == frozen_snapshot + + +def test_mission_reference_preserves_and_attenuates_signed_risk_policy( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + mission = MissionPassport( + agent_id="risk-agent", + mission_id="mission-risk-budget", + mission="bounded object deletion", + allowed_tools=["delete_objects"], + risk_budget=_policy(delete_contract), + ) + token = issue_passport( + mission, + private_key, + ttl_s=60, + extra_claims={ + "mission_ref": { + "uri": "https://registry.example/missions/risk-budget", + "mission_id": "mission-risk-budget", + } + }, + ) + presented = verify_passport(token, private_key.public_key()) + authoritative_policy = _policy(delete_contract, ceiling=10) + authoritative_policy["tools"]["delete_objects"]["max_facts"]["objects_affected"] = 2 + declaration = MissionDeclaration( + mission_id="mission-risk-budget", + issuer=str(presented["iss"]), + subject=str(presented["sub"]), + audience=presented["aud"], + issued_at=int(presented["iat"]), + expires_at=int(presented["exp"]), + jwt_id=str(presented["jti"]), + passport=MissionPassport( + agent_id=mission.agent_id, + mission_id=mission.mission_id, + mission=mission.mission, + allowed_tools=mission.allowed_tools, + risk_budget=authoritative_policy, + ), + payload_digest="sha256:" + ("5" * 64), + ) + monkeypatch.setattr(proxy.mission_cache, "resolve", lambda *_args: declaration) + monkeypatch.setattr("vibap.proxy.mission_is_revoked", lambda *_args: False) + + resolved = proxy._resolve_authoritative_policy_claims(presented) + + assert resolved["risk_budget"] == authoritative_policy + + ungoverned_declaration = MissionDeclaration( + mission_id=declaration.mission_id, + issuer=declaration.issuer, + subject=declaration.subject, + audience=declaration.audience, + issued_at=declaration.issued_at, + expires_at=declaration.expires_at, + jwt_id=declaration.jwt_id, + passport=MissionPassport( + agent_id=mission.agent_id, + mission_id=mission.mission_id, + mission=mission.mission, + allowed_tools=mission.allowed_tools, + ), + payload_digest=declaration.payload_digest, + ) + monkeypatch.setattr( + proxy.mission_cache, + "resolve", + lambda *_args: ungoverned_declaration, + ) + with pytest.raises(RuntimeError, match="risk_policy_invalid"): + proxy._resolve_authoritative_policy_claims(presented) + + +def test_proxy_replay_cannot_repermit_after_terminal_outcome( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + arguments = _safe_delete_arguments() + assert ( + proxy.evaluate_tool_call( + session, + "delete_objects", + arguments, + risk_request_id="request-1", + )[0] + == Decision.PERMIT + ) + proxy.record_risk_outcome( + session, + risk_request_id="request-1", + outcome="released", + ) + + decision, reason = proxy.evaluate_tool_call( + session, + "delete_objects", + arguments, + risk_request_id="request-1", + ) + assert (decision, reason) == (Decision.DENY, "risk_request_replay") + assert session.tool_call_count == 1 + + +def test_proxy_quarantine_keeps_authority_until_explicit_reconciliation( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session(proxy, private_key, delete_contract) + assert ( + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="crashed-request", + )[0] + == Decision.PERMIT + ) + + assert ( + proxy.quarantine_stale_risk_reservations( + session, + stale_after_s=0, + ) + == 1 + ) + assert session.events[-1].reason == "risk_outcome_quarantined" + with pytest.raises(PermissionError, match="risk_budget_outcome_unresolved"): + proxy.end_session(session) + + proxy.record_risk_outcome( + session, + risk_request_id="crashed-request", + outcome="committed", + ) + summary = proxy.end_session(session) + assert summary["total_events"] == 1 + assert summary["permits"] == 1 + + +def test_proxy_ordinary_policy_denial_releases_preflight_reservation( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session( + proxy, + private_key, + delete_contract, + forbidden=True, + ) + + decision, _ = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="denied-request", + ) + assert decision == Decision.DENY + snapshot = proxy.risk_budget_ledger.snapshot("lineage-1") + reservation = next(iter(snapshot["reservations"].values())) + assert reservation["status"] == "released" + with pytest.raises(ValueError, match="prior permitted tool event"): + proxy.record_tool_result(session, "must not attach", 1.0) + assert proxy.end_session(session)["denials"] == 1 + + +def test_receipt_outbox_deduplicates_across_proxy_processes(tmp_path: Path) -> None: + receipt_path = tmp_path / "receipts.jsonl" + context = multiprocessing.get_context("spawn") + start_event = context.Event() + processes = [ + context.Process( + target=_append_receipt_once_worker, + args=(str(receipt_path), start_event), + ) + for _ in range(4) + ] + for process in processes: + process.start() + start_event.set() + for process in processes: + process.join(timeout=10) + + assert [process.exitcode for process in processes] == [0, 0, 0, 0] + entries = [ + json.loads(line) + for line in receipt_path.read_text(encoding="utf-8").splitlines() + ] + assert entries == [{"receipt_id": "shared-lifecycle-receipt"}] + assert stat.S_IMODE( + os.stat(receipt_path.with_name("receipts.jsonl.lock")).st_mode + ) == (0o600) + + +def test_receipt_append_is_ordered_with_cross_proxy_session_advancement( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first_proxy = _governed_proxy( + tmp_path, + private_key, + session_keys_dir, + delete_contract, + ) + session = _start_governed_session(first_proxy, private_key, delete_contract) + second_proxy = _governed_proxy( + tmp_path, + private_key, + session_keys_dir, + delete_contract, + ) + first_append_entered = threading.Event() + allow_first_append = threading.Event() + second_append_entered = threading.Event() + original_first_append = first_proxy._log_receipt + original_second_append = second_proxy._log_receipt + decisions: list[Decision] = [] + failures: list[Exception] = [] + + def block_first_append(entry: dict) -> None: + first_append_entered.set() + allow_first_append.wait(timeout=5) + original_first_append(entry) + + def observe_second_append(entry: dict) -> None: + second_append_entered.set() + original_second_append(entry) + + def evaluate(proxy: GovernanceProxy, request_id: str) -> None: + try: + decision, _ = proxy.evaluate_tool_call( + session.jti, + "delete_objects", + _safe_delete_arguments(count=1), + risk_request_id=request_id, + ) + decisions.append(decision) + except Exception as exc: # pragma: no cover - asserted below + failures.append(exc) + + monkeypatch.setattr(first_proxy, "_log_receipt", block_first_append) + monkeypatch.setattr(second_proxy, "_log_receipt", observe_second_append) + first_thread = threading.Thread( + target=evaluate, + args=(first_proxy, "ordered-first"), + daemon=True, + ) + second_thread = threading.Thread( + target=evaluate, + args=(second_proxy, "ordered-second"), + daemon=True, + ) + first_thread.start() + assert first_append_entered.wait(timeout=5) + second_thread.start() + assert not second_append_entered.wait(timeout=0.25) + allow_first_append.set() + first_thread.join(timeout=5) + second_thread.join(timeout=5) + + assert not failures + assert decisions == [Decision.PERMIT, Decision.PERMIT] + entries = [ + json.loads(line) + for line in (tmp_path / "receipts.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + assert len(entries) == 2 + assert ( + entries[1]["parent_receipt_hash"] + == hashlib.sha256(entries[0]["jwt"].encode("ascii")).hexdigest() + ) + + +def test_session_finalization_flushes_failed_policy_denial_outbox( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session( + proxy, + private_key, + delete_contract, + forbidden=True, + ) + original_log_once = proxy._log_receipt_once + + def fail_before_append(_entry: dict) -> None: + raise OSError("simulated denial receipt log failure") + + monkeypatch.setattr(proxy, "_log_receipt_once", fail_before_append) + with pytest.raises(OSError, match="simulated denial receipt log failure"): + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="denial-outbox", + ) + pending = proxy.risk_budget_ledger.pending_lifecycles_for_session( + lineage_id="lineage-1", + session_id=session.jti, + ) + assert len(pending) == 1 + assert pending[0].status == "released" + + monkeypatch.setattr(proxy, "_log_receipt_once", original_log_once) + summary = proxy.end_session(session) + assert summary["denials"] == 1 + assert ( + proxy.risk_budget_ledger.pending_lifecycles_for_session( + lineage_id="lineage-1", + session_id=session.jti, + ) + == [] + ) + receipt_id = next( + event.risk_receipt_entry["receipt_id"] + for event in session.events + if event.risk_lifecycle_id == pending[0].lifecycle_id + ) + receipt_lines = [ + json.loads(line) + for line in (tmp_path / "receipts.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + assert sum(line["receipt_id"] == receipt_id for line in receipt_lines) == 1 + + +def test_attestation_refuses_pending_lifecycle_until_outbox_is_durable( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, + monkeypatch: pytest.MonkeyPatch, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session( + proxy, + private_key, + delete_contract, + forbidden=True, + ) + original_log_once = proxy._log_receipt_once + + def fail_before_append(_entry: dict) -> None: + raise OSError("simulated lifecycle sink outage") + + monkeypatch.setattr(proxy, "_log_receipt_once", fail_before_append) + with pytest.raises(OSError, match="simulated lifecycle sink outage"): + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="attestation-outbox", + ) + with pytest.raises(OSError, match="simulated lifecycle sink outage"): + proxy.issue_attestation_for_session(session.jti, private_key) + assert session.attestation_token is None + + monkeypatch.setattr(proxy, "_log_receipt_once", original_log_once) + token, claims = proxy.issue_attestation_for_session(session.jti, private_key) + + assert token + assert claims["passport_jti"] == session.jti + assert ( + proxy.risk_budget_ledger.pending_lifecycles_for_session( + lineage_id="lineage-1", + session_id=session.jti, + ) + == [] + ) + + +def test_proxy_enforces_action_and_cumulative_caps_before_native_permit( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + session = _start_governed_session( + proxy, + private_key, + delete_contract, + ceiling=2, + ) + + action_cap_decision, action_cap_reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(count=6), + risk_request_id="over-action-cap", + ) + assert (action_cap_decision, action_cap_reason) == ( + Decision.DENY, + "risk_action_cap_exceeded", + ) + assert ( + proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(count=2), + risk_request_id="fills-budget", + )[0] + == Decision.PERMIT + ) + + cumulative_decision, cumulative_reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(count=1), + risk_request_id="over-cumulative-cap", + ) + assert (cumulative_decision, cumulative_reason) == ( + Decision.DENY, + "risk_budget_exhausted", + ) + assert session.tool_call_count == 1 + + +def test_proxy_registered_dangerous_tool_cannot_be_omitted_from_opted_in_policy( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + policy = _policy(delete_contract) + policy["tools"] = { + "unrelated_tool": { + "contract_digest": delete_contract.digest, + "max_facts": policy["tools"]["delete_objects"]["max_facts"], + } + } + mission = MissionPassport( + agent_id="risk-agent", + mission="malformed risk coverage", + allowed_tools=["delete_objects", "unrelated_tool"], + resource_scope=["**"], + risk_budget=policy, + ) + session = proxy.start_session(issue_passport(mission, private_key, ttl_s=60)) + + decision, reason = proxy.evaluate_tool_call( + session, + "delete_objects", + _safe_delete_arguments(), + risk_request_id="request-1", + ) + assert (decision, reason) == ( + Decision.INSUFFICIENT_EVIDENCE, + "risk_contract_invalid", + ) + + +def test_proxy_delegation_persists_attenuated_child_risk_policy( + tmp_path: Path, + private_key: ec.EllipticCurvePrivateKey, + session_keys_dir: Path, + delete_contract: ToolRiskContract, +) -> None: + proxy = _governed_proxy(tmp_path, private_key, session_keys_dir, delete_contract) + parent_policy = _policy(delete_contract) + parent_token = issue_passport( + MissionPassport( + agent_id="parent", + mission="coordinate bounded deletion", + allowed_tools=["delete_objects"], + resource_scope=["**"], + max_tool_calls=5, + delegation_allowed=True, + max_delegation_depth=1, + risk_budget=parent_policy, + ), + private_key, + ttl_s=60, + ) + parent_session = proxy.start_session(parent_token) + child_policy = json.loads(json.dumps(parent_policy)) + child_policy["tools"]["delete_objects"]["max_facts"]["objects_affected"] = 1 + child_policy["ceilings"]["objects_affected"]["session"] = 1 + + child_token, child_claims, _ = proxy.delegate_passport( + parent_token, + private_key, + "child", + ["delete_objects"], + "delete one object", + child_max_tool_calls=1, + child_risk_budget=child_policy, + delegation_request_id="delegation-1", + ) + assert child_claims["risk_budget"] == child_policy + assert ( + verify_passport( + child_token, + private_key.public_key(), + parent_token=parent_token, + )["risk_budget"] + == child_policy + ) + child_record = parent_session.delegated_children[0] + assert child_record["child_risk_budget"] == child_policy + assert child_record["delegation_request"]["child_risk_budget"] == child_policy diff --git a/python/vibap/__init__.py b/python/vibap/__init__.py index 38ef04df..77bb33a1 100644 --- a/python/vibap/__init__.py +++ b/python/vibap/__init__.py @@ -29,6 +29,14 @@ ) from .proxy import Decision, GovernanceProxy, GovernanceSession, PolicyEvent from .receipt import ExecutionReceipt, build_receipt, sign_receipt, verify_receipt +from .risk_budget import ( + FileRiskBudgetLedger, + RiskBudgetError, + ToolRiskContract, + ToolRiskRegistry, + attenuate_risk_budget, + normalize_risk_budget, +) __all__ = [ "ALGORITHM", @@ -44,12 +52,17 @@ "DRPVerifiedReceiptChainEvidence", "DRPVerifiedRevocationEvidence", "ExecutionReceipt", + "FileRiskBudgetLedger", "GovernanceProxy", "GovernanceSession", "MissionPassport", "MissionCache", "MissionDeclaration", "PolicyEvent", + "RiskBudgetError", + "ToolRiskContract", + "ToolRiskRegistry", + "attenuate_risk_budget", "build_receipt", "compute_log_digest", "derive_child_passport", @@ -59,6 +72,7 @@ "issue_passport", "load_mission_declaration", "load_mission_file", + "normalize_risk_budget", "sign_receipt", "tool_universe_digest", "validate_drp_receipt", diff --git a/python/vibap/aat_adapter.py b/python/vibap/aat_adapter.py index 60b88a84..3269c853 100644 --- a/python/vibap/aat_adapter.py +++ b/python/vibap/aat_adapter.py @@ -170,7 +170,9 @@ def material_from_aat_grant( raise PermissionError("AAT child grant requires the exact parent token") _assert_child_parent_binding(claims, parent_token) elif parent_token is not None: - raise PermissionError("AAT parent token was supplied without verified parent claims") + raise PermissionError( + "AAT parent token was supplied without verified parent claims" + ) try: mission_ref = parse_mission_ref(claims["mission_ref"]) @@ -221,7 +223,6 @@ def material_from_aat_grant( mission_id=declaration.mission_id, ) extra_claims = { - "jti": str(claims["jti"]), "credential_format": AAT_CREDENTIAL_FORMAT, "aat_grant_id": str(claims["jti"]), "aat_issuer": str(claims["iss"]), diff --git a/python/vibap/denial.py b/python/vibap/denial.py index 9cbf7cc5..b155ce85 100644 --- a/python/vibap/denial.py +++ b/python/vibap/denial.py @@ -17,3 +17,11 @@ class DenialReason(str, Enum): REVOCATION_UNAVAILABLE = "revocation_unavailable" MEMORY_COMPROMISE_BOUNDARY = "memory_compromise_boundary" APPROVAL_OPERATOR_UNAVAILABLE = "approval_operator_unavailable" + RISK_ACTION_CAP_EXCEEDED = "risk_action_cap_exceeded" + RISK_BUDGET_EXHAUSTED = "risk_budget_exhausted" + RISK_CONTRACT_INVALID = "risk_contract_invalid" + RISK_FACT_INVALID = "risk_fact_invalid" + RISK_POLICY_INVALID = "risk_policy_invalid" + RISK_REQUEST_ID_INVALID = "risk_request_id_invalid" + RISK_REPLAY = "risk_replay" + RISK_STATE_UNAVAILABLE = "risk_state_unavailable" diff --git a/python/vibap/metrics.py b/python/vibap/metrics.py index dbb97c8a..97e786ef 100644 --- a/python/vibap/metrics.py +++ b/python/vibap/metrics.py @@ -60,10 +60,24 @@ def render(self) -> str: class _Histogram: - def __init__(self, name: str, help_text: str, buckets: tuple[float, ...] | None = None): + def __init__( + self, name: str, help_text: str, buckets: tuple[float, ...] | None = None + ): self.name = name self.help = help_text - self.buckets = buckets or (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0) + self.buckets = buckets or ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1.0, + 2.5, + 5.0, + 10.0, + ) self._sum = 0.0 self._count = 0 self._bucket_counts: dict[float, int] = defaultdict(int) @@ -92,13 +106,34 @@ def render(self) -> str: class ArdurMetrics: def __init__(self): - self.requests_total = _Counter("ardur_requests_total", "Total HTTP requests", ("method", "path", "status")) - self.evaluations_total = _Counter("ardur_evaluations_total", "Tool-call evaluations by decision", ("decision",)) - self.errors_total = _Counter("ardur_errors_total", "Errors by type", ("error_type",)) - self.active_sessions = _Gauge("ardur_active_sessions", "Currently active governed sessions") - self.kill_switch_active = _Gauge("ardur_kill_switch_active", "1 if kill switch is active") - self.request_duration_seconds = _Histogram("ardur_request_duration_seconds", "Request duration in seconds") - self.evaluation_duration_seconds = _Histogram("ardur_evaluation_duration_seconds", "Evaluation duration in seconds") + self.requests_total = _Counter( + "ardur_requests_total", "Total HTTP requests", ("method", "path", "status") + ) + self.evaluations_total = _Counter( + "ardur_evaluations_total", + "Tool-call evaluations by decision", + ("decision",), + ) + self.errors_total = _Counter( + "ardur_errors_total", "Errors by type", ("error_type",) + ) + self.risk_budget_operations_total = _Counter( + "ardur_risk_budget_operations_total", + "Typed risk-budget operations by bounded outcome", + ("operation", "outcome", "fact", "reason"), + ) + self.active_sessions = _Gauge( + "ardur_active_sessions", "Currently active governed sessions" + ) + self.kill_switch_active = _Gauge( + "ardur_kill_switch_active", "1 if kill switch is active" + ) + self.request_duration_seconds = _Histogram( + "ardur_request_duration_seconds", "Request duration in seconds" + ) + self.evaluation_duration_seconds = _Histogram( + "ardur_evaluation_duration_seconds", "Evaluation duration in seconds" + ) self._startup_time = time.time() def render(self) -> str: @@ -106,13 +141,16 @@ def render(self) -> str: self.requests_total.render(), self.evaluations_total.render(), self.errors_total.render(), + self.risk_budget_operations_total.render(), self.active_sessions.render(), self.kill_switch_active.render(), self.request_duration_seconds.render(), self.evaluation_duration_seconds.render(), ] uptime = time.time() - self._startup_time - parts.append(f"# HELP ardur_uptime_seconds Proxy uptime in seconds\n# TYPE ardur_uptime_seconds gauge\nardur_uptime_seconds {uptime:.3f}\n") + parts.append( + f"# HELP ardur_uptime_seconds Proxy uptime in seconds\n# TYPE ardur_uptime_seconds gauge\nardur_uptime_seconds {uptime:.3f}\n" + ) return "\n".join(parts) diff --git a/python/vibap/mission.py b/python/vibap/mission.py index 530fb637..3ce3f726 100644 --- a/python/vibap/mission.py +++ b/python/vibap/mission.py @@ -12,21 +12,14 @@ import ssl import threading import urllib.parse +import urllib.request import zlib from collections import OrderedDict from dataclasses import dataclass, field from typing import Any, Callable from urllib.error import HTTPError, URLError -import urllib.request from urllib.request import Request -# NOTE: urlopen is accessed as the module-level binding so that -# monkeypatching `vibap.mission.urlopen` in tests works regardless of -# test ordering. We assign the production binding (``_pinned_urlopen``, -# defined below) at the bottom of this module — the variable is -# re-bound, not just initialized. -urlopen = urllib.request.urlopen # placeholder; real binding set at end of module. - import jwt from cryptography.hazmat.primitives.asymmetric import ec @@ -44,6 +37,13 @@ assert_iat_in_window, ) +# NOTE: urlopen is accessed as the module-level binding so that +# monkeypatching `vibap.mission.urlopen` in tests works regardless of +# test ordering. We assign the production binding (``_pinned_urlopen``, +# defined below) at the bottom of this module — the variable is +# re-bound, not just initialized. +urlopen = urllib.request.urlopen # placeholder; real binding set at end of module. + _FETCH_TIMEOUT_S = 5.0 MAX_STATUS_LIST_BYTES = 1 << 20 MAX_DECOMPRESSED_BYTES = 16 << 20 @@ -122,6 +122,8 @@ def policy_claims(self) -> dict[str, Any]: claims["parent_jti"] = self.passport.parent_jti if self.passport.cwd is not None: claims["cwd"] = self.passport.cwd + if self.passport.risk_budget is not None: + claims["risk_budget"] = copy.deepcopy(self.passport.risk_budget) if self.approval_policy: claims["approval_policy"] = copy.deepcopy(self.approval_policy) for name in ( @@ -178,7 +180,9 @@ def get_by_ref(self, ref: MissionReference) -> MissionDeclaration | None: self._ref_to_id.move_to_end(ref_key) return mission - def put(self, mission: MissionDeclaration, *, ref: MissionReference | None = None) -> MissionDeclaration: + def put( + self, mission: MissionDeclaration, *, ref: MissionReference | None = None + ) -> MissionDeclaration: with self._lock: self._by_id[mission.mission_id] = mission self._by_id.move_to_end(mission.mission_id) @@ -187,14 +191,18 @@ def put(self, mission: MissionDeclaration, *, ref: MissionReference | None = Non self._ref_to_id.move_to_end(ref.cache_key()) while len(self._by_id) > self.max_entries: evicted_id, _ = self._by_id.popitem(last=False) - stale = [key for key, value in self._ref_to_id.items() if value == evicted_id] + stale = [ + key for key, value in self._ref_to_id.items() if value == evicted_id + ] for key in stale: self._ref_to_id.pop(key, None) while len(self._ref_to_id) > self.max_entries * 2: self._ref_to_id.popitem(last=False) return mission - def resolve(self, ref: MissionReference, loader: Callable[[], MissionDeclaration]) -> MissionDeclaration: + def resolve( + self, ref: MissionReference, loader: Callable[[], MissionDeclaration] + ) -> MissionDeclaration: cached = self.get_by_ref(ref) if cached is not None: return cached @@ -259,9 +267,7 @@ def resolve(self, ref: MissionReference, loader: Callable[[], MissionDeclaration _VALID_CONFORMANCE_PROFILES = frozenset( {"Delegation-Core", "MIC-State", "MIC-Evidence"} ) -_VALID_RECEIPT_LEVELS = frozenset( - {"minimal", "counter_signed", "transparency_logged"} -) +_VALID_RECEIPT_LEVELS = frozenset({"minimal", "counter_signed", "transparency_logged"}) def _validate_required_v01_members(claims: dict[str, Any]) -> None: @@ -378,18 +384,26 @@ def parse_mission_ref(value: Any) -> MissionReference: raise MissionBindingError("chain_invalid", "mission_ref is empty") return MissionReference(uri=value.strip()) if not isinstance(value, dict): - raise MissionBindingError("chain_invalid", "mission_ref must be a string or object") + raise MissionBindingError( + "chain_invalid", "mission_ref must be a string or object" + ) uri = value.get("uri", value.get("url")) if not isinstance(uri, str) or not uri.strip(): raise MissionBindingError("chain_invalid", "mission_ref.uri is required") mission_id = value.get("mission_id") - if mission_id is not None and (not isinstance(mission_id, str) or not mission_id.strip()): - raise MissionBindingError("chain_invalid", "mission_ref.mission_id must be a non-empty string") + if mission_id is not None and ( + not isinstance(mission_id, str) or not mission_id.strip() + ): + raise MissionBindingError( + "chain_invalid", "mission_ref.mission_id must be a non-empty string" + ) mission_digest = value.get("mission_digest") if mission_digest is not None and ( not isinstance(mission_digest, str) or not mission_digest.startswith("sha-256:") ): - raise MissionBindingError("chain_invalid", "mission_ref.mission_digest must use sha-256:") + raise MissionBindingError( + "chain_invalid", "mission_ref.mission_digest must use sha-256:" + ) return MissionReference( uri=uri.strip(), mission_id=mission_id.strip() if isinstance(mission_id, str) else None, @@ -438,7 +452,9 @@ def load_mission_declaration( }, ) except jwt.PyJWTError as exc: - raise MissionBindingError("chain_invalid", f"mission declaration verification failed: {exc}") from exc + raise MissionBindingError( + "chain_invalid", f"mission declaration verification failed: {exc}" + ) from exc # Bounded-iat gate (round 3, 2026-04-28). Mirrors the receipt-side # FIX-6 generalization to mission declarations so a forged MD with # iat=year_3000, exp=year_3001 cannot survive verification just @@ -446,7 +462,9 @@ def load_mission_declaration( try: assert_iat_in_window(claims.get("iat"), field_name="MD iat") except jwt.InvalidTokenError as exc: - raise MissionBindingError("chain_invalid", f"mission declaration {exc}") from exc + raise MissionBindingError( + "chain_invalid", f"mission declaration {exc}" + ) from exc # Always run the required-members guard. The strict full-schema check # is opt-in for clean producers (see docstring). _validate_required_v01_members(claims) @@ -456,30 +474,55 @@ def load_mission_declaration( passport = MissionPassport.from_dict( { "agent_id": str(claims["sub"]), - "mission": str(claims.get("mission", claims.get("mission_id", claims["jti"]))), - "allowed_tools": list(claims.get("allowed_tools", claims.get("allowed_tool_classes", []))), + "mission": str( + claims.get("mission", claims.get("mission_id", claims["jti"])) + ), + "allowed_tools": list( + claims.get("allowed_tools", claims.get("allowed_tool_classes", [])) + ), "forbidden_tools": list(claims.get("forbidden_tools", [])), "resource_scope": _legacy_resource_scope( claims.get("resource_scope"), claims.get("resource_policies"), ), "max_tool_calls": int(claims.get("max_tool_calls", 50)), - "max_duration_s": int(claims.get("max_duration_s", max(1, int(claims["exp"]) - int(claims["iat"])))), + "max_duration_s": int( + claims.get( + "max_duration_s", + max(1, int(claims["exp"]) - int(claims["iat"])), + ) + ), "delegation_allowed": bool( claims.get( "delegation_allowed", - bool(((claims.get("delegation_policy") or {}).get("max_depth", 0))), + bool( + ( + (claims.get("delegation_policy") or {}).get( + "max_depth", 0 + ) + ) + ), ) ), "max_delegation_depth": int( - claims.get("max_delegation_depth", ((claims.get("delegation_policy") or {}).get("max_depth", 0))) + claims.get( + "max_delegation_depth", + ((claims.get("delegation_policy") or {}).get("max_depth", 0)), + ) ), "parent_jti": claims.get("parent_jti"), "cwd": claims.get("cwd"), + **( + {"risk_budget": copy.deepcopy(claims["risk_budget"])} + if "risk_budget" in claims + else {} + ), } ) except (KeyError, TypeError, ValueError) as exc: - raise MissionBindingError("chain_invalid", f"mission declaration schema invalid: {exc}") from exc + raise MissionBindingError( + "chain_invalid", f"mission declaration schema invalid: {exc}" + ) from exc return MissionDeclaration( mission_id=str(claims["mission_id"]), @@ -532,13 +575,19 @@ def fetch_mission_declaration( strict_schema=strict_schema, ) if ref.mission_id and ref.mission_id != mission.mission_id: - raise MissionBindingError("chain_invalid", "mission_ref mission_id does not match loaded mission") + raise MissionBindingError( + "chain_invalid", "mission_ref mission_id does not match loaded mission" + ) if ref.mission_digest and ref.mission_digest != mission.payload_digest: - raise MissionBindingError("chain_invalid", "mission_ref mission_digest does not match loaded mission") + raise MissionBindingError( + "chain_invalid", "mission_ref mission_digest does not match loaded mission" + ) return mission -def mission_is_revoked(mission: MissionDeclaration, public_key: ec.EllipticCurvePublicKey) -> bool: +def mission_is_revoked( + mission: MissionDeclaration, public_key: ec.EllipticCurvePublicKey +) -> bool: if mission.revocation_ref is None: return False uri, idx = _parse_revocation_ref(mission.revocation_ref) @@ -556,20 +605,28 @@ def mission_is_revoked(mission: MissionDeclaration, public_key: ec.EllipticCurve }, ) except jwt.PyJWTError as exc: - raise MissionBindingError("chain_invalid", f"status list verification failed: {exc}") from exc + raise MissionBindingError( + "chain_invalid", f"status list verification failed: {exc}" + ) from exc try: assert_iat_in_window(claims.get("iat"), field_name="status list iat") except jwt.InvalidTokenError as exc: raise MissionBindingError("chain_invalid", f"status list {exc}") from exc container = claims.get("status_list", claims.get("status")) if not isinstance(container, dict): - raise MissionBindingError("chain_invalid", "status list token missing status_list claim") + raise MissionBindingError( + "chain_invalid", "status list token missing status_list claim" + ) try: bits = int(container.get("bits", 1)) except (TypeError, ValueError) as exc: - raise MissionBindingError("chain_invalid", "status list bits must be an integer") from exc + raise MissionBindingError( + "chain_invalid", "status list bits must be an integer" + ) from exc if bits not in (1, 2, 4, 8): - raise MissionBindingError("chain_invalid", f"unsupported status list bits={bits}") + raise MissionBindingError( + "chain_invalid", f"unsupported status list bits={bits}" + ) lst = container.get("lst") if not isinstance(lst, str) or not lst: raise MissionBindingError("chain_invalid", "status list token missing lst") @@ -577,9 +634,13 @@ def mission_is_revoked(mission: MissionDeclaration, public_key: ec.EllipticCurve decompressor = zlib.decompressobj() raw = decompressor.decompress(_b64url_decode(lst), MAX_DECOMPRESSED_BYTES) except (ValueError, zlib.error) as exc: - raise MissionBindingError("chain_invalid", "status list decompression failed") from exc + raise MissionBindingError( + "chain_invalid", "status list decompression failed" + ) from exc if decompressor.unconsumed_tail or decompressor.unused_data: - raise MissionStatusUnavailableError("status_list_too_large", "status list exceeded decompression limit") + raise MissionStatusUnavailableError( + "status_list_too_large", "status list exceeded decompression limit" + ) return _status_value(raw, idx=idx, bits=bits) != 0 @@ -664,9 +725,7 @@ def connect(self) -> None: # noqa: D401 — interface override self._tunnel() # TLS handshake still uses ``self.host`` for SNI + cert validation. if isinstance(self._context, ssl.SSLContext): - self.sock = self._context.wrap_socket( - self.sock, server_hostname=self.host - ) + self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host) def _resolve_to_pinned_public_ip(host: str, port: int) -> str: @@ -710,7 +769,9 @@ class _PinnedIPResponse: minimal urllib-style context-manager interface :func:`_fetch_text` expects (``read(size)`` returning bytes, plus ``__enter__``/``__exit__``).""" - def __init__(self, conn: http.client.HTTPSConnection, resp: http.client.HTTPResponse) -> None: + def __init__( + self, conn: http.client.HTTPSConnection, resp: http.client.HTTPResponse + ) -> None: self._conn = conn self._resp = resp @@ -816,8 +877,7 @@ def _pinned_urlopen( raise HTTPError( url, resp.status, - f"pinned-IP fetch saw HTTP {resp.status}; " - f"body preview: {body_preview!r}", + f"pinned-IP fetch saw HTTP {resp.status}; body preview: {body_preview!r}", dict(resp.getheaders()) if hasattr(resp, "getheaders") else {}, None, ) @@ -828,16 +888,24 @@ def _fetch_text(url: str) -> str: _assert_public_target(url) request = Request( url, - headers={"Accept": "application/jwt, application/statuslist+jwt, application/json"}, + headers={ + "Accept": "application/jwt, application/statuslist+jwt, application/json" + }, ) try: - with urlopen(request, timeout=_FETCH_TIMEOUT_S, context=ssl.create_default_context()) as response: + with urlopen( + request, timeout=_FETCH_TIMEOUT_S, context=ssl.create_default_context() + ) as response: body = response.read(MAX_STATUS_LIST_BYTES + 1) if len(body) > MAX_STATUS_LIST_BYTES: - raise MissionStatusUnavailableError("status_list_too_large", "status list response exceeded size limit") + raise MissionStatusUnavailableError( + "status_list_too_large", "status list response exceeded size limit" + ) return body.decode("utf-8").strip() except (HTTPError, URLError, OSError, TimeoutError) as exc: - raise MissionStatusUnavailableError("revocation_unavailable", f"fetch failed for {url}") from exc + raise MissionStatusUnavailableError( + "revocation_unavailable", f"fetch failed for {url}" + ) from exc def _parse_revocation_ref(revocation_ref: str) -> tuple[str, int]: @@ -845,20 +913,30 @@ def _parse_revocation_ref(revocation_ref: str) -> tuple[str, int]: if parsed.scheme.lower() != "https": raise MissionBindingError("chain_invalid", "revocation_ref must use https") if not parsed.fragment: - raise MissionBindingError("chain_invalid", "revocation_ref must include #idx=") + raise MissionBindingError( + "chain_invalid", "revocation_ref must include #idx=" + ) try: fragment = urllib.parse.parse_qs(parsed.fragment, strict_parsing=True) except ValueError as exc: - raise MissionBindingError("chain_invalid", "revocation_ref fragment is malformed") from exc + raise MissionBindingError( + "chain_invalid", "revocation_ref fragment is malformed" + ) from exc idx_values = fragment.get("idx") if idx_values is None or len(idx_values) != 1: - raise MissionBindingError("chain_invalid", "revocation_ref must include exactly one idx") + raise MissionBindingError( + "chain_invalid", "revocation_ref must include exactly one idx" + ) try: idx = int(idx_values[0]) except ValueError as exc: - raise MissionBindingError("chain_invalid", "revocation_ref idx must be an integer") from exc + raise MissionBindingError( + "chain_invalid", "revocation_ref idx must be an integer" + ) from exc if idx < 0: - raise MissionBindingError("chain_invalid", "revocation_ref idx must be non-negative") + raise MissionBindingError( + "chain_invalid", "revocation_ref idx must be non-negative" + ) return urllib.parse.urlunparse(parsed._replace(fragment="")), idx @@ -875,9 +953,9 @@ def _legacy_resource_scope(resource_scope: Any, resource_policies: Any) -> list[ if not isinstance(pattern, str) or not pattern: raise ValueError("resource_policies[].pattern must be a non-empty string") if pattern.startswith("glob:"): - patterns.append(pattern[len("glob:"):]) + patterns.append(pattern[len("glob:") :]) elif pattern.startswith("exact:"): - patterns.append(pattern[len("exact:"):]) + patterns.append(pattern[len("exact:") :]) else: patterns.append(pattern) return patterns @@ -901,7 +979,9 @@ def _tuple_of_dicts(value: Any) -> tuple[dict[str, Any], ...]: items: list[dict[str, Any]] = [] for entry in value: if not isinstance(entry, dict): - raise MissionBindingError("chain_invalid", "mission array field must contain objects") + raise MissionBindingError( + "chain_invalid", "mission array field must contain objects" + ) items.append(copy.deepcopy(entry)) return tuple(items) @@ -914,7 +994,9 @@ def _tuple_of_strs(value: Any) -> tuple[str, ...]: items: list[str] = [] for entry in value: if not isinstance(entry, str) or not entry: - raise MissionBindingError("chain_invalid", "required_telemetry must contain strings") + raise MissionBindingError( + "chain_invalid", "required_telemetry must contain strings" + ) items.append(entry) return tuple(items) @@ -923,7 +1005,9 @@ def _dict_or_empty(value: Any) -> dict[str, Any]: if value is None: return {} if not isinstance(value, dict): - raise MissionBindingError("chain_invalid", "mission object field must be an object") + raise MissionBindingError( + "chain_invalid", "mission object field must be an object" + ) return copy.deepcopy(value) @@ -931,7 +1015,9 @@ def _optional_str(value: Any) -> str | None: if value is None: return None if not isinstance(value, str) or not value: - raise MissionBindingError("chain_invalid", "mission string field must be a non-empty string") + raise MissionBindingError( + "chain_invalid", "mission string field must be a non-empty string" + ) return value @@ -941,7 +1027,9 @@ def _optional_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError) as exc: - raise MissionBindingError("chain_invalid", "mission integer field must be an integer") from exc + raise MissionBindingError( + "chain_invalid", "mission integer field must be an integer" + ) from exc def _b64url_decode(value: str) -> bytes: diff --git a/python/vibap/passport.py b/python/vibap/passport.py index 9ec20c08..3d7fa34a 100644 --- a/python/vibap/passport.py +++ b/python/vibap/passport.py @@ -31,6 +31,7 @@ def resource_scope_is_explicitly_unrestricted(scope: list[str]) -> bool: return scope == [UNRESTRICTED_RESOURCE_SCOPE_PATTERN] + # Bounded-iat skew window applied to every JWT we verify (passport, AAT, # Mission Declaration, status list, and receipt). # @@ -42,11 +43,11 @@ def resource_scope_is_explicitly_unrestricted(scope: list[str]) -> bool: # legitimate clock drift across nodes. This helper provides a single # explicit bound; each verifier disables PyJWT's verify_iat and calls # this instead so the security choice is visible at every JWT decode. -DEFAULT_IAT_FUTURE_SKEW_S = 300 # 5 min — clock-drift tolerance +DEFAULT_IAT_FUTURE_SKEW_S = 300 # 5 min — clock-drift tolerance DEFAULT_IAT_PAST_SKEW_S = 30 * 86400 # 30 days — long-lived caches OK, - # archival replay handled by jti - # replay caches at the verifier - # boundary, not iat alone. +# archival replay handled by jti +# replay caches at the verifier +# boundary, not iat alone. def assert_iat_in_window( @@ -107,6 +108,7 @@ def _try_chmod_0700_warn(target: Path) -> None: os.chmod(target, stat.S_IRWXU) except OSError as exc: import sys + print( f"warning: could not chmod 0o700 on VIBAP home {target}: " f"{exc}. Private-key material may be world-readable. " @@ -179,7 +181,9 @@ def _is_under_default_home(path: Path) -> bool: DEFAULT_HOME = _default_home_dir() -DEFAULT_KEYS_DIR = Path(os.environ.get("VIBAP_KEYS_DIR", DEFAULT_HOME / "keys")).expanduser() +DEFAULT_KEYS_DIR = Path( + os.environ.get("VIBAP_KEYS_DIR", DEFAULT_HOME / "keys") +).expanduser() class KeyDirectoryError(ValueError): @@ -223,7 +227,9 @@ def _normalize_cwd(value: str | None) -> str | None: if not stripped: return None if not stripped.startswith("/"): - raise ValueError(f"cwd must be an absolute path (start with '/'), got {value!r}") + raise ValueError( + f"cwd must be an absolute path (start with '/'), got {value!r}" + ) # Phase-3.1a C-3 (external-review-X F3 + SF-P3-04): reject any ``..`` segment # BEFORE calling posixpath.normpath. normpath silently collapses # ``/workspace/../etc`` → ``/etc``, which would let a passport claim @@ -302,6 +308,11 @@ class MissionPassport: # DENY-wins across native + additional; formally verified in # verification/composition_smt.py (properties P1-P4). additional_policies: list[dict[str, Any]] = field(default_factory=list) + # Optional typed dangerous-action policy. When present, trusted proxy-side + # tool contracts derive risk facts before execution and reserve the signed + # session/agent/lineage ceilings atomically. Absence preserves the legacy + # behavior for missions that have not opted into impact governance. + risk_budget: dict[str, Any] | None = None def __post_init__(self) -> None: # Validate/normalize cwd at construction time so an invalid passport @@ -325,22 +336,34 @@ def __post_init__(self) -> None: # `_ttl_from_payload` understands (`ttl_s`, `issued_at`, `expires_at`) # and the legacy `budget` dict shape that exposes nested # max_tool_calls / max_duration_s. - _KNOWN_FIELDS: ClassVar[frozenset[str]] = frozenset({ - # MissionPassport dataclass fields - "agent_id", "mission", - "allowed_tools", "forbidden_tools", "resource_scope", - "max_tool_calls", "max_duration_s", - "delegation_allowed", "max_delegation_depth", - "parent_jti", "cwd", - "allowed_side_effect_classes", # side-effect-class enforcement - "max_tool_calls_per_class", # cumulative per-class budget - "holder_key_thumbprint", # K2 PoP - "holder_spiffe_id", - "additional_policies", # pluggable policy backends - "mission_id", # H1: stable mission identifier for PolicyStore lookup - # Mission-file metadata handled by load_mission_file / issue_passport - "budget", "ttl_s", "issued_at", "expires_at", - }) + _KNOWN_FIELDS: ClassVar[frozenset[str]] = frozenset( + { + # MissionPassport dataclass fields + "agent_id", + "mission", + "allowed_tools", + "forbidden_tools", + "resource_scope", + "max_tool_calls", + "max_duration_s", + "delegation_allowed", + "max_delegation_depth", + "parent_jti", + "cwd", + "allowed_side_effect_classes", # side-effect-class enforcement + "max_tool_calls_per_class", # cumulative per-class budget + "holder_key_thumbprint", # K2 PoP + "holder_spiffe_id", + "additional_policies", # pluggable policy backends + "risk_budget", # typed dangerous-action blast-radius caps + "mission_id", # H1: stable mission identifier for PolicyStore lookup + # Mission-file metadata handled by load_mission_file / issue_passport + "budget", + "ttl_s", + "issued_at", + "expires_at", + } + ) @classmethod def from_dict(cls, data: dict[str, Any]) -> "MissionPassport": @@ -364,28 +387,40 @@ def from_dict(cls, data: dict[str, Any]) -> "MissionPassport": f"(known: {known_fields})" ) raise ValueError( - f"unknown fields in mission: {unknown_fields} " - f"(known: {known_fields})" + f"unknown fields in mission: {unknown_fields} (known: {known_fields})" ) budget = data.get("budget") or {} + if "risk_budget" in data and not isinstance(data["risk_budget"], dict): + raise ValueError("risk_budget must be a JSON object when present") return cls( agent_id=data["agent_id"], mission=data["mission"], allowed_tools=list(data.get("allowed_tools", [])), forbidden_tools=list(data.get("forbidden_tools", [])), resource_scope=list(data.get("resource_scope", [])), - max_tool_calls=int(data.get("max_tool_calls", budget.get("max_tool_calls", 50))), - max_duration_s=int(data.get("max_duration_s", budget.get("max_duration_s", 600))), + max_tool_calls=int( + data.get("max_tool_calls", budget.get("max_tool_calls", 50)) + ), + max_duration_s=int( + data.get("max_duration_s", budget.get("max_duration_s", 600)) + ), delegation_allowed=bool(data.get("delegation_allowed", False)), max_delegation_depth=int(data.get("max_delegation_depth", 0)), parent_jti=data.get("parent_jti"), cwd=data.get("cwd"), - allowed_side_effect_classes=list(data.get("allowed_side_effect_classes", [])), + allowed_side_effect_classes=list( + data.get("allowed_side_effect_classes", []) + ), max_tool_calls_per_class=dict(data.get("max_tool_calls_per_class", {})), holder_key_thumbprint=data.get("holder_key_thumbprint"), holder_spiffe_id=data.get("holder_spiffe_id"), additional_policies=list(data.get("additional_policies", [])), mission_id=data.get("mission_id"), + risk_budget=( + dict(data["risk_budget"]) + if isinstance(data.get("risk_budget"), dict) + else None + ), ) def to_dict(self) -> dict[str, Any]: @@ -394,6 +429,8 @@ def to_dict(self) -> dict[str, Any]: data = asdict(self) if data.get("cwd") is None: data.pop("cwd", None) + if data.get("risk_budget") is None: + data.pop("risk_budget", None) return data @@ -429,6 +466,7 @@ def _write_private_bytes(path: Path, data: bytes) -> None: actual_mode = path.stat().st_mode & 0o777 if actual_mode != 0o600: import sys + print( f"WARNING: {path} permissions are {actual_mode:o}, expected 600; " f"private key may be readable by other users on this filesystem", @@ -450,7 +488,9 @@ def generate_keypair( pub_path = target_dir / "passport_public.pem" if priv_path.exists() and pub_path.exists() and not force: - priv_key = serialization.load_pem_private_key(priv_path.read_bytes(), password=None) + priv_key = serialization.load_pem_private_key( + priv_path.read_bytes(), password=None + ) pub_key = serialization.load_pem_public_key(pub_path.read_bytes()) return priv_key, pub_key @@ -483,9 +523,13 @@ def load_private_key(keys_dir: str | Path | None = None) -> ec.EllipticCurvePriv return serialization.load_pem_private_key(priv_path.read_bytes(), password=None) -def load_existing_private_key(keys_dir: str | Path | None = None) -> ec.EllipticCurvePrivateKey: +def load_existing_private_key( + keys_dir: str | Path | None = None, +) -> ec.EllipticCurvePrivateKey: """Load ``passport_private.pem`` without creating directories or key material.""" - target_dir = Path(keys_dir).expanduser() if keys_dir is not None else DEFAULT_KEYS_DIR + target_dir = ( + Path(keys_dir).expanduser() if keys_dir is not None else DEFAULT_KEYS_DIR + ) try: if target_dir.exists() and not target_dir.is_dir(): raise KeyDirectoryError() @@ -507,7 +551,9 @@ def load_existing_private_key(keys_dir: str | Path | None = None) -> ec.Elliptic except NotADirectoryError as exc: raise KeyDirectoryError() from exc except OSError as exc: - raise ValueError("passport_private.pem is not a readable EC private key") from exc + raise ValueError( + "passport_private.pem is not a readable EC private key" + ) from exc private_key = serialization.load_pem_private_key(private_bytes, password=None) if not isinstance(private_key, ec.EllipticCurvePrivateKey): raise ValueError("passport_private.pem must contain an EC private key") @@ -522,9 +568,13 @@ def load_public_key(keys_dir: str | Path | None = None) -> ec.EllipticCurvePubli return serialization.load_pem_public_key(pub_path.read_bytes()) -def load_existing_public_key(keys_dir: str | Path | None = None) -> ec.EllipticCurvePublicKey: +def load_existing_public_key( + keys_dir: str | Path | None = None, +) -> ec.EllipticCurvePublicKey: """Load ``passport_public.pem`` without creating key directories or key material.""" - target_dir = Path(keys_dir).expanduser() if keys_dir is not None else DEFAULT_KEYS_DIR + target_dir = ( + Path(keys_dir).expanduser() if keys_dir is not None else DEFAULT_KEYS_DIR + ) try: if target_dir.exists() and not target_dir.is_dir(): raise KeyDirectoryError() @@ -573,13 +623,30 @@ def issue_passport( audience: str = DEFAULT_AUDIENCE, ttl_s: int | None = None, extra_claims: dict[str, Any] | None = None, + *, + jti_override: str | None = None, ) -> str: now = int(time.time()) ttl = int(ttl_s if ttl_s is not None else mission.max_duration_s) if ttl <= 0: raise ValueError("ttl_s must be positive") - jti = str(uuid.uuid4()) + if jti_override is not None and ( + not isinstance(jti_override, str) + or not jti_override + or len(jti_override.encode("utf-8")) > 1024 + ): + raise ValueError( + "jti_override must be a non-empty string of at most 1024 bytes" + ) + if jti_override is not None: + try: + parsed_jti = uuid.UUID(jti_override) + except ValueError as exc: + raise ValueError("jti_override must use canonical UUID format") from exc + if str(parsed_jti).lower() != jti_override.lower(): + raise ValueError("jti_override must use canonical UUID format") + jti = jti_override or str(uuid.uuid4()) # H1 (2026-04-19): ``mission_id`` is DISTINCT from ``jti``. # Previously this field was set to ``jti`` which re-randomized every # issuance — the PolicyStore's key would rotate with every re-issued @@ -621,6 +688,17 @@ def issue_passport( claims["max_tool_calls_per_class"] = mission.max_tool_calls_per_class if mission.additional_policies: claims["additional_policies"] = mission.additional_policies + if mission.risk_budget is not None: + from .risk_budget import normalize_risk_budget + + risk_budget = dict(mission.risk_budget) + if risk_budget.get("lineage_id") is None: + risk_budget = normalize_risk_budget(risk_budget, lineage_id=jti) + else: + risk_budget = normalize_risk_budget(risk_budget) + if not set(risk_budget["tools"]).issubset(mission.allowed_tools): + raise ValueError("risk_budget tools must be a subset of allowed_tools") + claims["risk_budget"] = risk_budget # K2 (I6): Proof of Possession via cnf claim. When the mission declares # a holder_key_thumbprint, the passport is bound to that key. Presenters # must prove possession by signing a KB-JWT with the matching private key. @@ -629,6 +707,13 @@ def issue_passport( if mission.holder_key_thumbprint: claims["cnf"] = {"jkt": mission.holder_key_thumbprint} if extra_claims: + protected_claims = set(claims) + collisions = set(extra_claims).intersection(protected_claims) + if collisions: + raise ValueError( + "extra_claims cannot override protected passport claims: " + f"{sorted(collisions)}" + ) claims.update(extra_claims) return jwt.encode(claims, private_key, algorithm=ALGORITHM) @@ -647,9 +732,11 @@ def compute_jwk_thumbprint(public_key: ec.EllipticCurvePublicKey) -> str: x = base64.urlsafe_b64encode(nums.x.to_bytes(32, "big")).rstrip(b"=").decode() y = base64.urlsafe_b64encode(nums.y.to_bytes(32, "big")).rstrip(b"=").decode() canonical = f'{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}' - return base64.urlsafe_b64encode( - hashlib.sha256(canonical.encode("ascii")).digest() - ).rstrip(b"=").decode() + return ( + base64.urlsafe_b64encode(hashlib.sha256(canonical.encode("ascii")).digest()) + .rstrip(b"=") + .decode() + ) def create_kb_jwt( @@ -862,7 +949,9 @@ def delegation_chain_entries(claims: dict[str, Any]) -> list[dict[str, str]]: field=f"{DELEGATION_CHAIN_CLAIM}[{index}].jti", ) if link_jti != expected_jti: - raise PermissionError("delegated passport has inconsistent delegation_chain") + raise PermissionError( + "delegated passport has inconsistent delegation_chain" + ) if link_jti in seen: raise PermissionError(f"passport lineage cycle detected at '{link_jti}'") seen.add(link_jti) @@ -999,6 +1088,7 @@ def derive_child_passport( parent_reserved_for_descendants: int = 0, child_resource_scope: list[str] | None = None, child_cwd: str | None = None, + child_risk_budget: Mapping[str, Any] | None = None, ) -> str: """Derive a child passport with strictly narrowed scope. @@ -1027,6 +1117,8 @@ def derive_child_passport( — ``/workspace/a`` narrows ``/workspace``; ``/workspaceabc`` does not). Anything else raises ``PermissionError`` with a ``cwd escalation`` reason. + - risk_budget: a governed parent policy is inherited or explicitly + attenuated; an ungoverned parent cannot introduce one. """ # Signature-and-claims decode only. The full chain-anchor verification # (``verify_passport`` with ``parent_token=grandparent_token``) is the @@ -1055,7 +1147,9 @@ def derive_child_passport( max_ttl = parent_exp - int(time.time()) if max_ttl <= 0: raise PermissionError("parent passport expired") - requested_ttl = min(child_ttl_s, max_ttl) if child_ttl_s is not None else min(300, max_ttl) + requested_ttl = ( + min(child_ttl_s, max_ttl) if child_ttl_s is not None else min(300, max_ttl) + ) if requested_ttl <= 0: raise PermissionError("insufficient TTL for child passport") @@ -1108,7 +1202,9 @@ def derive_child_passport( parent_scope_set = set(parent_scope) new_patterns = child_scope_set - parent_scope_set if new_patterns: - raise PermissionError(f"scope escalation (resources): {sorted(new_patterns)}") + raise PermissionError( + f"scope escalation (resources): {sorted(new_patterns)}" + ) final_scope = requested_scope else: final_scope = parent_scope @@ -1148,11 +1244,40 @@ def derive_child_passport( f"cwd escalation: {final_cwd!r} is not a subpath of parent's {parent_cwd!r}" ) + parent_risk_budget = parent.get("risk_budget") + if parent_risk_budget is None: + if child_risk_budget is not None: + raise PermissionError("cannot introduce risk_budget: parent has none") + final_risk_budget = None + else: + from .risk_budget import attenuate_risk_budget, project_risk_budget + + if not isinstance(parent_risk_budget, dict): + raise PermissionError("parent risk_budget is invalid") + projected_parent = project_risk_budget(parent_risk_budget, child_tools) + if projected_parent is None: + if child_risk_budget is not None: + raise PermissionError( + "cannot retain risk_budget after removing all governed tools" + ) + final_risk_budget = None + else: + final_risk_budget = attenuate_risk_budget( + projected_parent, + child_risk_budget, + ) + if set(final_risk_budget["tools"]) != set(projected_parent["tools"]): + raise PermissionError( + "risk_budget tool removal must match child allowed_tools" + ) + child = MissionPassport( agent_id=child_agent_id, mission=child_mission, allowed_tools=sorted(child_tools), - forbidden_tools=sorted(set(parent.get("forbidden_tools", [])) | (parent_tools - child_tools)), + forbidden_tools=sorted( + set(parent.get("forbidden_tools", [])) | (parent_tools - child_tools) + ), resource_scope=final_scope, max_tool_calls=child_budget, max_duration_s=int(requested_ttl), @@ -1160,6 +1285,7 @@ def derive_child_passport( max_delegation_depth=child_depth, parent_jti=parent["jti"], cwd=final_cwd, + risk_budget=final_risk_budget, ) child_chain: list[dict[str, str]] = [{"jti": str(parent["jti"])}] # Embed parent's own token hash in the chain link. This is ONE of two @@ -1201,12 +1327,16 @@ def _ttl_from_payload(data: dict[str, Any]) -> int | None: reference = int(data.get("issued_at", time.time())) ttl = int(data["expires_at"]) - reference if ttl <= 0: - raise ValueError("mission file expires_at must be greater than issued_at/current time") + raise ValueError( + "mission file expires_at must be greater than issued_at/current time" + ) return ttl return None -def load_mission_file(path: str | Path) -> tuple[MissionPassport, int | None, dict[str, Any]]: +def load_mission_file( + path: str | Path, +) -> tuple[MissionPassport, int | None, dict[str, Any]]: mission_path = Path(path).expanduser() payload = json.loads(mission_path.read_text(encoding="utf-8")) mission = MissionPassport.from_dict(payload) diff --git a/python/vibap/policy_conformance.py b/python/vibap/policy_conformance.py index f5a3364e..49ae46fe 100644 --- a/python/vibap/policy_conformance.py +++ b/python/vibap/policy_conformance.py @@ -10,6 +10,7 @@ import stat import sys import time +import uuid import unicodedata from collections.abc import Mapping, Sequence from pathlib import Path @@ -231,7 +232,7 @@ def _evaluate_delegation_scenario( parent, private_key, ttl_s=300, - extra_claims={"jti": str(claims["jti"])}, + jti_override=str(uuid.uuid5(uuid.NAMESPACE_URL, str(claims["jti"]))), ) try: derive_child_passport( diff --git a/python/vibap/proxy.py b/python/vibap/proxy.py index c90ec429..cf427139 100644 --- a/python/vibap/proxy.py +++ b/python/vibap/proxy.py @@ -35,6 +35,17 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec +from .risk_budget import ( + FileRiskBudgetLedger, + RiskBudgetError, + RiskOutcomeResult, + RiskBudgetReplayError, + RiskFactError, + ToolRiskRegistry, + attenuate_risk_budget, + normalize_risk_budget, + validate_action_risk, +) from .aat_adapter import ( AAT_CREDENTIAL_FORMAT, decode_aat_claims, @@ -42,6 +53,7 @@ ) from .approvals import ApprovalRateTracker from .attestation import issue_attestation, verify_attestation +from .canonical_json import canonical_json_bytes from .denial import DenialReason from .lineage_budget import ( FileLineageBudgetLedger, @@ -123,6 +135,32 @@ def __init__(self) -> None: # local import there breaks the topological cycle without changing semantics. # See ``_build_receipt_log_entry`` for the deferred import. +# Session IDs are UUIDs — reject anything else to prevent path traversal +_SESSION_ID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE +) +_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE) +MAX_REQUEST_BODY = 1024 * 1024 # 1 MiB +_API_TOKEN_COMPARE_MAX_BYTES = 4096 + + +# Per-session in-process coordination for shared state_dir access. ``flock`` +# closes the cross-process hole, but same-process proxies can still share a +# PID, so we need a process-local lock keyed by the absolute lockfile path. +class _SessionCoordinationLock: + """Weakref-able wrapper for a per-session reentrant process lock.""" + + __slots__ = ("lock", "__weakref__") + + def __init__(self) -> None: + self.lock = threading.RLock() + + +_SESSION_COORDINATION_LOCKS: weakref.WeakValueDictionary[ + str, _SessionCoordinationLock +] = weakref.WeakValueDictionary() +_SESSION_COORDINATION_LOCKS_GUARD = threading.Lock() + DEFAULT_STATE_DIR = Path( os.environ.get("VIBAP_STATE_DIR", DEFAULT_HOME / "state") ).expanduser() @@ -1553,6 +1591,10 @@ class PolicyEvent: duration_ms: float = 0.0 budget_delta: dict[str, Any] | None = None evidence_proof_ref: dict[str, Any] | None = None + measurements: dict[str, Any] | None = None + risk_budget_remaining: dict[str, int] = field(default_factory=dict) + risk_lifecycle_id: str | None = None + risk_receipt_entry: dict[str, Any] | None = None # Ordered list of per-backend decisions (native + additional_policies). # Empty for missions without additional_policies. Each dict carries # {"backend", "label", "decision", "reasons", "eval_ms"}. @@ -1584,6 +1626,10 @@ def to_dict(self) -> dict[str, Any]: if self.budget_delta is not None else None, "evidence_proof_ref": copy.deepcopy(self.evidence_proof_ref), + "measurements": copy.deepcopy(self.measurements), + "risk_budget_remaining": dict(self.risk_budget_remaining), + "risk_lifecycle_id": self.risk_lifecycle_id, + "risk_receipt_entry": copy.deepcopy(self.risk_receipt_entry), "policy_decisions": list(self.policy_decisions), } @@ -1632,6 +1678,15 @@ def from_dict(cls, data: dict[str, Any]) -> "PolicyEvent": if isinstance(data.get("budget_delta"), dict) else None, evidence_proof_ref=copy.deepcopy(data.get("evidence_proof_ref")), + measurements=copy.deepcopy(data.get("measurements")), + risk_budget_remaining={ + str(key): int(value) + for key, value in dict( + data.get("risk_budget_remaining", {}) or {} + ).items() + }, + risk_lifecycle_id=data.get("risk_lifecycle_id"), + risk_receipt_entry=copy.deepcopy(data.get("risk_receipt_entry")), policy_decisions=list(data.get("policy_decisions", []) or []), ) @@ -1654,6 +1709,18 @@ def __init__( self.denial_reason = denial_reason +@dataclass(frozen=True, slots=True) +class _RiskPreflight: + governed: bool + accepted: bool + reserved: bool = False + request_id: str | None = None + reason: str | None = None + denial_reason: DenialReason | None = None + measurements: dict[str, Any] | None = None + remaining: dict[str, int] = field(default_factory=dict) + + @dataclass class GovernanceSession: passport_token: str @@ -1672,6 +1739,7 @@ class GovernanceSession: last_memory_record_id: str | None = None last_receipt_id: str | None = None last_receipt_full_hash: str | None = None + risk_policy_snapshot: dict[str, Any] | None = None run_nonce: str = field(default_factory=lambda: secrets.token_urlsafe(24)) _lock: threading.RLock = field( default_factory=threading.RLock, repr=False, compare=False @@ -1905,6 +1973,8 @@ def to_dict(self) -> dict[str, Any]: "start_time": self.start_time, "summary": self.summary, } + if self.risk_policy_snapshot is not None: + payload["risk_policy_snapshot"] = copy.deepcopy(self.risk_policy_snapshot) if self.end_time is not None: payload["end_time"] = self.end_time if self.attestation_token is not None: @@ -1956,6 +2026,12 @@ def from_dict(cls, data: dict[str, Any]) -> "GovernanceSession": ) session.last_receipt_id = data.get("last_receipt_id") session.last_receipt_full_hash = data.get("last_receipt_full_hash") + raw_risk_snapshot = data.get("risk_policy_snapshot") + session.risk_policy_snapshot = ( + copy.deepcopy(raw_risk_snapshot) + if isinstance(raw_risk_snapshot, dict) + else None + ) session.memory_stores = {} return session @@ -1997,6 +2073,7 @@ def _delegation_request_metadata( child_ttl_s: int | None, child_max_tool_calls: int | None, child_resource_scope: Sequence[str] | None, + child_risk_budget: Mapping[str, Any] | None, ) -> dict[str, Any]: return { "version": 1, @@ -2013,6 +2090,7 @@ def _delegation_request_metadata( "child_max_tool_calls": int(child_max_tool_calls) if child_max_tool_calls is not None else None, + "child_risk_budget": copy.deepcopy(child_risk_budget), } @staticmethod @@ -2060,6 +2138,8 @@ def _delegation_claims_match_record( ) if claim_scope != stored_scope: return False + if claims.get("risk_budget") != child_record.get("child_risk_budget"): + return False return True def __init__( @@ -2072,6 +2152,8 @@ def __init__( receipts_log_path: str | Path | None = None, policy_store: Any | None = None, lineage_budget_ledger: LineageBudgetLedger | None = None, + risk_registry: ToolRiskRegistry | None = None, + risk_budget_ledger: FileRiskBudgetLedger | None = None, biscuit_issuer_public_key: Any | None = None, biscuit_peer_trust_bundle: Any | None = None, biscuit_svid_audience: str = "ardur-proxy", @@ -2113,6 +2195,11 @@ def __init__( self.lineage_budget_ledger = lineage_budget_ledger or FileLineageBudgetLedger( self.state_dir ) + self.risk_registry = risk_registry or ToolRiskRegistry() + self.risk_registry.freeze() + self.risk_budget_ledger = risk_budget_ledger or FileRiskBudgetLedger( + self.state_dir + ) self._biscuit_issuer_public_key = biscuit_issuer_public_key if ( not isinstance(biscuit_svid_audience, str) @@ -2395,6 +2482,7 @@ def _signed_policy_decisions( def _receipt_budget_remaining( session: GovernanceSession, policy_claims: dict[str, Any], + event: PolicyEvent, ) -> dict[str, int]: remaining: dict[str, int] = {} for key, raw_cap in dict( @@ -2406,8 +2494,19 @@ def _receipt_budget_remaining( continue used = int(session.tool_call_count_by_class.get(str(key), 0)) remaining[str(key)] = max(0, cap - used) + remaining.update(event.risk_budget_remaining) return remaining + @staticmethod + def _is_internal_risk_event(event: PolicyEvent) -> bool: + return ( + event.tool_name == "risk_budget_lifecycle" + and event.action_class == "observe" + and event.resource_family == "governance" + and event.side_effect_class == "none" + and event.arguments == {} + ) + @staticmethod def _receipt_budget_delta( session: GovernanceSession, @@ -2473,7 +2572,7 @@ def _build_receipt_log_entry( signed_policy_decisions = self._signed_policy_decisions( event, decision, audit_reason ) - if event.budget_delta is None: + if event.budget_delta is None and not self._is_internal_risk_event(event): event.budget_delta = self._receipt_budget_delta( session, event, @@ -2495,7 +2594,11 @@ def _build_receipt_log_entry( parent_receipt_hash=session.last_receipt_full_hash, policy_decisions=signed_policy_decisions, reason=audit_reason, - budget_remaining=self._receipt_budget_remaining(session, policy_claims), + budget_remaining=self._receipt_budget_remaining( + session, + policy_claims, + event, + ), ) signed_jwt = sign_receipt(receipt, self.receipt_private_key) session.last_receipt_id = receipt.receipt_id @@ -2547,6 +2650,34 @@ def _resolve_authoritative_policy_claims( DenialReason.REVOKED, ) claims = mission.policy_claims() + presented_risk = passport_claims.get("risk_budget") + authoritative_risk = claims.get("risk_budget") + if presented_risk is not None: + if not isinstance(presented_risk, dict) or not isinstance( + authoritative_risk, dict + ): + raise _MissionPolicyResolutionError( + Decision.VIOLATION, + "risk_policy_invalid", + DenialReason.RISK_POLICY_INVALID, + ) + try: + claims["risk_budget"] = attenuate_risk_budget( + authoritative_risk, + presented_risk, + ) + except (PermissionError, RiskBudgetError): + try: + claims["risk_budget"] = attenuate_risk_budget( + presented_risk, + authoritative_risk, + ) + except (PermissionError, RiskBudgetError) as exc: + raise _MissionPolicyResolutionError( + Decision.VIOLATION, + "risk_policy_invalid", + DenialReason.RISK_POLICY_INVALID, + ) from exc claims["mission_ref"] = copy.deepcopy(mission_ref_raw) claims["mission_digest"] = mission.payload_digest # H5 (2026-04-19): propagate mission_id from the session @@ -3087,6 +3218,7 @@ def start_session_from_aat( signing_key or self.receipt_private_key, ttl_s=material.ttl_s, extra_claims=material.extra_claims, + jti_override=material.grant_id, ) return self.start_session(internal_token) @@ -3382,16 +3514,368 @@ def get_session(self, session_id: str) -> GovernanceSession: self.sessions[session_id] = loaded return loaded + @staticmethod + def _risk_measurements( + facts: Mapping[str, int | str] | None = None, + *, + fact_digest: str | None = None, + ) -> tuple[str, dict[str, Any]]: + if (facts is None) == (fact_digest is None): + raise RiskBudgetError("exactly one risk fact source is required") + if fact_digest is None: + digest = hashlib.sha256(canonical_json_bytes(dict(facts or {}))).digest() + else: + if ( + not fact_digest.startswith("sha256:") + or len(fact_digest) != 71 + or fact_digest[7:] != fact_digest[7:].lower() + ): + raise RiskBudgetError("risk fact digest is invalid") + try: + digest = bytes.fromhex(fact_digest[7:]) + except ValueError as exc: + raise RiskBudgetError("risk fact digest is invalid") from exc + hex_digest = f"sha256:{digest.hex()}" + encoded = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return hex_digest, { + "risk_facts": { + "alg": "sha-256", + "canonicalization": "jcs-rfc8785", + "scope": "measurement", + "value": encoded, + } + } + + @staticmethod + def _risk_metric( + *, + operation: str, + outcome: str, + facts: Sequence[str] = (), + reason: str = "none", + ) -> None: + for fact in facts or ("none",): + ardur_metrics.risk_budget_operations_total.inc( + operation=operation, + outcome=outcome, + fact=fact, + reason=reason, + ) + + def _risk_preflight( + self, + session: GovernanceSession, + tool_name: str, + arguments: dict[str, Any], + policy_claims: dict[str, Any], + risk_request_id: str | None, + ) -> _RiskPreflight: + raw_policy = policy_claims.get("risk_budget") + if raw_policy is None and session.risk_policy_snapshot is None: + return _RiskPreflight(governed=False, accepted=True) + try: + if raw_policy is None: + raise RiskBudgetError("snapshotted risk policy disappeared") + current_policy = normalize_risk_budget(raw_policy) + if session.risk_policy_snapshot is None: + session.risk_policy_snapshot = copy.deepcopy(current_policy) + elif current_policy != session.risk_policy_snapshot: + raise RiskBudgetError("risk policy changed during the session") + policy = copy.deepcopy(session.risk_policy_snapshot) + except RiskBudgetError: + self._risk_metric( + operation="preflight", + outcome="denied", + reason="policy_invalid", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_policy_invalid", + denial_reason=DenialReason.RISK_POLICY_INVALID, + ) + + contract = self.risk_registry.resolve(tool_name) + tool_policy = policy["tools"].get(tool_name) + if tool_name in {MEMORY_STORE_WRITE_TOOL, MEMORY_STORE_READ_TOOL} and ( + contract is not None or tool_policy is not None + ): + self._risk_metric( + operation="preflight", + outcome="denied", + reason="contract_invalid", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_contract_invalid", + denial_reason=DenialReason.RISK_CONTRACT_INVALID, + ) + if contract is None and tool_policy is None: + return _RiskPreflight(governed=False, accepted=True) + if contract is None or tool_policy is None: + self._risk_metric( + operation="preflight", + outcome="denied", + reason="contract_invalid", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_contract_invalid", + denial_reason=DenialReason.RISK_CONTRACT_INVALID, + ) + if ( + not isinstance(risk_request_id, str) + or not risk_request_id.strip() + or len(risk_request_id.encode("utf-8")) > 1024 + ): + self._risk_metric( + operation="preflight", + outcome="denied", + reason="request_id_invalid", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_request_id_invalid", + denial_reason=DenialReason.RISK_REQUEST_ID_INVALID, + ) + + try: + facts = contract.extract(arguments) + except RiskFactError: + self._risk_metric( + operation="preflight", + outcome="denied", + reason="fact_invalid", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_fact_invalid", + denial_reason=DenialReason.RISK_FACT_INVALID, + ) + fact_names = tuple(sorted(facts)) + fact_digest, measurements = self._risk_measurements(facts) + try: + numeric_facts = validate_action_risk(policy, contract, facts) + except RiskBudgetError as exc: + reason = str(exc) + if reason == "risk_action_cap_exceeded": + denial_reason = DenialReason.RISK_ACTION_CAP_EXCEEDED + metric_reason = "action_cap" + elif reason == "risk_contract_digest_mismatch": + denial_reason = DenialReason.RISK_CONTRACT_INVALID + metric_reason = "contract_invalid" + reason = "risk_contract_invalid" + else: + denial_reason = DenialReason.RISK_POLICY_INVALID + metric_reason = "policy_invalid" + reason = "risk_policy_invalid" + self._risk_metric( + operation="preflight", + outcome="denied", + facts=fact_names, + reason=metric_reason, + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason=reason, + denial_reason=denial_reason, + measurements=measurements, + ) + + policy_digest = ( + f"sha256:{hashlib.sha256(canonical_json_bytes(policy)).hexdigest()}" + ) + fingerprint = hashlib.sha256( + canonical_json_bytes( + { + "session": session.jti, + "tool": tool_name, + "arguments": arguments, + "risk_request_id": risk_request_id, + } + ) + ).hexdigest() + try: + reservation = self.risk_budget_ledger.reserve( + lineage_id=policy["lineage_id"], + session_id=session.jti, + agent_id=str(session.passport_claims.get("sub", "unknown")), + request_id=risk_request_id, + fingerprint=fingerprint, + numeric_facts=numeric_facts, + ceilings=policy["ceilings"], + policy_digest=policy_digest, + contract_digest=contract.digest, + fact_digest=fact_digest, + expires_at=int(session.passport_claims["exp"]), + ) + except RiskBudgetReplayError: + self._risk_metric( + operation="reserve", + outcome="denied", + facts=fact_names, + reason="replay", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_request_replay", + denial_reason=DenialReason.RISK_REPLAY, + measurements=measurements, + ) + except (OSError, RiskBudgetError): + self._risk_metric( + operation="reserve", + outcome="error", + facts=fact_names, + reason="state_unavailable", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_state_unavailable", + denial_reason=DenialReason.RISK_STATE_UNAVAILABLE, + measurements=measurements, + ) + if not reservation.accepted: + self._risk_metric( + operation="reserve", + outcome="denied", + facts=fact_names, + reason="budget_exhausted", + ) + return _RiskPreflight( + governed=True, + accepted=False, + reason="risk_budget_exhausted", + denial_reason=DenialReason.RISK_BUDGET_EXHAUSTED, + measurements=measurements, + remaining=reservation.remaining, + ) + self._risk_metric( + operation="reserve", + outcome="accepted", + facts=fact_names, + ) + return _RiskPreflight( + governed=True, + accepted=True, + reserved=True, + request_id=risk_request_id, + measurements=measurements, + remaining=reservation.remaining, + ) + + def _evaluate_ordinary_tool_policy( + self, + target: GovernanceSession, + tool_name: str, + arguments: dict[str, Any], + policy_claims: dict[str, Any], + ) -> tuple[Decision, str, PolicyEvent]: + ts = time.time() + approval_policy = policy_claims.get("approval_policy") + need_rate = ( + isinstance(approval_policy, dict) + and approval_policy.get("max_approvals_per_hour_per_operator") is not None + ) + if need_rate: + try: + max_approvals = int( + approval_policy["max_approvals_per_hour_per_operator"] + ) + window_s = float(approval_policy.get("window_s", 3600.0)) + tracker = self._approval_tracker(max_approvals, window_s) + except (TypeError, ValueError): + decision, reason = ( + Decision.INSUFFICIENT_EVIDENCE, + "approval_policy_invalid", + ) + self._record_tool_policy_event( + target, + tool_name, + arguments, + decision, + reason, + DenialReason.TELEMETRY_MISSING, + verifier_id=self.verifier_id, + ) + return decision, reason, target.events[-1] + operator_id = self._approval_operator_id(policy_claims, arguments) + if operator_id is None: + decision, reason = ( + Decision.INSUFFICIENT_EVIDENCE, + "approval_operator_unavailable", + ) + self._record_tool_policy_event( + target, + tool_name, + arguments, + decision, + reason, + DenialReason.APPROVAL_OPERATOR_UNAVAILABLE, + verifier_id=self.verifier_id, + ) + return decision, reason, target.events[-1] + if not tracker.check(operator_id, ts): + decision, reason = ( + Decision.INSUFFICIENT_EVIDENCE, + "approval_fatigue_threshold", + ) + self._record_tool_policy_event( + target, + tool_name, + arguments, + decision, + reason, + DenialReason.APPROVAL_FATIGUE_THRESHOLD, + verifier_id=self.verifier_id, + ) + return decision, reason, target.events[-1] + else: + tracker = None + operator_id = None + + decision, reason, event = target.check_and_record( + tool_name, + arguments, + policy_claims=policy_claims, + verifier_id=self.verifier_id, + ) + if decision == Decision.PERMIT: + decision, reason = self._apply_memory_post_permit( + target, + tool_name, + arguments, + ) + event = target.events[-1] + if ( + decision == Decision.PERMIT + and tracker is not None + and operator_id is not None + ): + tracker.record_approval(operator_id, ts) + return decision, reason, event + def evaluate_tool_call( self, session: GovernanceSession | str, tool_name: str, arguments: dict[str, Any], *, + risk_request_id: str | None = None, receipt_callback: Callable[[str], None] | None = None, ) -> tuple[Decision, str]: + self.flush_risk_lifecycle_outbox(session) arguments_snapshot = copy.deepcopy(arguments) receipt_entry: dict[str, Any] | None = None + receipt_callback_id: str | None = None + released: RiskOutcomeResult | None = None # Refresh persisted state under a per-session coordination lock before # mutating. Without this, separate proxies that share a state_dir can # both approve from stale in-memory snapshots and last-writer-wins the @@ -3492,106 +3976,99 @@ def evaluate_tool_call( event = target.events[-1] self._persist_session(target) else: - ts = time.time() - ap = policy_claims.get("approval_policy") - need_rate = ( - isinstance(ap, dict) - and ap.get("max_approvals_per_hour_per_operator") - is not None + risk_preflight = self._risk_preflight( + target, + tool_name, + arguments_snapshot, + policy_claims, + risk_request_id, ) - if need_rate: - try: - max_ap = int( - ap["max_approvals_per_hour_per_operator"] - ) - window_s = float(ap.get("window_s", 3600.0)) - tracker = self._approval_tracker(max_ap, window_s) - except (TypeError, ValueError): - decision, reason = ( - Decision.INSUFFICIENT_EVIDENCE, - "approval_policy_invalid", - ) - self._record_tool_policy_event( - target, - tool_name, - arguments_snapshot, - decision, - reason, - DenialReason.TELEMETRY_MISSING, - verifier_id=self.verifier_id, - ) - event = target.events[-1] - self._persist_session(target) + if not risk_preflight.accepted: + denial_reason = risk_preflight.denial_reason + if denial_reason in { + DenialReason.RISK_ACTION_CAP_EXCEEDED, + DenialReason.RISK_BUDGET_EXHAUSTED, + DenialReason.RISK_REPLAY, + }: + decision = Decision.DENY else: - operator_id = self._approval_operator_id( - policy_claims, arguments_snapshot - ) - if operator_id is None: - decision, reason = ( - Decision.INSUFFICIENT_EVIDENCE, - "approval_operator_unavailable", - ) - self._record_tool_policy_event( + decision = Decision.INSUFFICIENT_EVIDENCE + reason = risk_preflight.reason or "risk_policy_invalid" + self._record_tool_policy_event( + target, + tool_name, + arguments_snapshot, + decision, + reason, + denial_reason, + verifier_id=self.verifier_id, + ) + event = target.events[-1] + else: + try: + decision, reason, event = ( + self._evaluate_ordinary_tool_policy( target, tool_name, arguments_snapshot, - decision, - reason, - DenialReason.APPROVAL_OPERATOR_UNAVAILABLE, - verifier_id=self.verifier_id, + policy_claims, ) - event = target.events[-1] - self._persist_session(target) - elif not tracker.check(operator_id, ts): - decision, reason = ( - Decision.INSUFFICIENT_EVIDENCE, - "approval_fatigue_threshold", + ) + except BaseException: + # An exception cannot prove that execution did + # not begin (internal memory tools are the + # canonical counterexample). Keep the + # reservation charged for explicit executor + # reconciliation or quarantine. + raise + if ( + risk_preflight.reserved + and decision != Decision.PERMIT + ): + try: + policy = normalize_risk_budget( + policy_claims["risk_budget"] ) - self._record_tool_policy_event( - target, - tool_name, - arguments_snapshot, - decision, - reason, - DenialReason.APPROVAL_FATIGUE_THRESHOLD, - verifier_id=self.verifier_id, + released = ( + self.risk_budget_ledger.record_outcome( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_id=str( + risk_preflight.request_id + ), + outcome="released", + ) + ) + except (OSError, RiskBudgetError): + decision = Decision.INSUFFICIENT_EVIDENCE + reason = "risk_state_unavailable" + event.decision = decision + event.reason = reason + event.denial_reason = ( + DenialReason.RISK_STATE_UNAVAILABLE + ) + self._risk_metric( + operation="release", + outcome="error", + reason="state_unavailable", ) - event = target.events[-1] - self._persist_session(target) else: - decision, reason, _event = ( - target.check_and_record( - tool_name, - arguments_snapshot, - policy_claims=policy_claims, - verifier_id=self.verifier_id, - ) + risk_preflight = _RiskPreflight( + governed=True, + accepted=True, + remaining=released.remaining, + measurements=risk_preflight.measurements, ) - if decision == Decision.PERMIT: - decision, reason = ( - self._apply_memory_post_permit( - target, - tool_name, - arguments_snapshot, - ) - ) - if decision == Decision.PERMIT: - tracker.record_approval(operator_id, ts) - event = target.events[-1] - self._persist_session(target) - else: - decision, reason, _event = target.check_and_record( - tool_name, - arguments_snapshot, - policy_claims=policy_claims, - verifier_id=self.verifier_id, - ) - if decision == Decision.PERMIT: - decision, reason = self._apply_memory_post_permit( - target, tool_name, arguments_snapshot - ) - event = target.events[-1] - self._persist_session(target) + self._risk_metric( + operation="release", + outcome="released", + ) + event.risk_lifecycle_id = released.lifecycle_id + event.measurements = copy.deepcopy( + risk_preflight.measurements + ) + event.risk_budget_remaining = dict(risk_preflight.remaining) + self._persist_session(target) receipt_entry = self._build_receipt_log_entry( target, event, @@ -3599,7 +4076,26 @@ def evaluate_tool_call( reason, receipt_policy_claims, ) + if event.risk_lifecycle_id is not None: + event.risk_receipt_entry = copy.deepcopy(receipt_entry) self._persist_session(target) + if event.risk_lifecycle_id is not None: + self._log_receipt_once(receipt_entry) + policy = target.risk_policy_snapshot + if policy is None or released is None: + raise RiskBudgetError( + "risk lifecycle delivery state is unavailable" + ) + self.risk_budget_ledger.mark_lifecycle_delivered( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_hash=released.request_hash, + lifecycle_id=released.lifecycle_id, + receipt_id=str(receipt_entry["receipt_id"]), + ) + else: + self._log_receipt(receipt_entry) + receipt_callback_id = str(receipt_entry["receipt_id"]) call_number = target.tool_call_count self._log( { @@ -3611,10 +4107,8 @@ def evaluate_tool_call( "call_number": call_number, } ) - if receipt_entry is not None: - self._log_receipt(receipt_entry) - if receipt_callback is not None: - receipt_callback(str(receipt_entry["receipt_id"])) + if receipt_callback is not None and receipt_callback_id is not None: + receipt_callback(receipt_callback_id) return decision, reason def record_tool_result( @@ -3627,13 +4121,219 @@ def record_tool_result( with target._lock: if target.summary is not None: raise PermissionError("session already ended") - if not target.events: + tool_event = next( + ( + event + for event in reversed(target.events) + if not self._is_internal_risk_event(event) + ), + None, + ) + if tool_event is None or tool_event.decision != Decision.PERMIT: raise ValueError( - "cannot record tool result without a prior tool event" + "cannot record tool result without a prior permitted tool event" ) - target.events[-1].response = response - target.events[-1].duration_ms = duration_ms + tool_event.response = response + tool_event.duration_ms = duration_ms + self._persist_session(target) + + def record_risk_outcome( + self, + session: GovernanceSession | str, + *, + risk_request_id: str, + outcome: str, + receipt_callback: Callable[[str], None] | None = None, + ) -> dict[str, Any]: + """Close a pre-action risk reservation with explicit executor evidence. + + ``released`` is valid only when the executor did not start the action. + Once execution may have started, callers must record ``committed``. + Quarantined crash records remain charged until reconciled here. + """ + + receipt_entry: dict[str, Any] | None = None + policy: dict[str, Any] | None = None + receipt_callback_id: str | None = None + with self._locked_persisted_session(session) as target: + with target._lock: + if target.summary is not None: + raise PermissionError("session already ended") + if target.risk_policy_snapshot is None: + raw_policy = target.passport_claims.get("risk_budget") + if raw_policy is not None: + target.risk_policy_snapshot = normalize_risk_budget(raw_policy) + if target.risk_policy_snapshot is None: + raise ValueError("session has no risk_budget policy") + policy = copy.deepcopy(target.risk_policy_snapshot) + result = self.risk_budget_ledger.record_outcome( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_id=risk_request_id, + outcome=outcome, + ) + receipt_entry = self._ensure_risk_lifecycle_event(target, result) self._persist_session(target) + self._log_receipt_once(receipt_entry) + self.risk_budget_ledger.mark_lifecycle_delivered( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_hash=result.request_hash, + lifecycle_id=result.lifecycle_id, + receipt_id=str(receipt_entry["receipt_id"]), + ) + receipt_callback_id = str(receipt_entry["receipt_id"]) + self._risk_metric( + operation="outcome", + outcome=result.status, + ) + if receipt_callback is not None and receipt_callback_id is not None: + receipt_callback(receipt_callback_id) + return { + "status": result.status, + "idempotent": result.idempotent, + "remaining": dict(result.remaining), + "receipt_id": ( + str(receipt_entry["receipt_id"]) if receipt_entry is not None else None + ), + } + + def quarantine_stale_risk_reservations( + self, + session: GovernanceSession | str, + *, + stale_after_s: float, + ) -> int: + if stale_after_s < 0: + raise ValueError("stale_after_s must be non-negative") + receipt_entries: list[tuple[RiskOutcomeResult, dict[str, Any]]] = [] + policy: dict[str, Any] | None = None + with self._locked_persisted_session(session) as target: + with target._lock: + if target.risk_policy_snapshot is None: + raw_policy = target.passport_claims.get("risk_budget") + if raw_policy is not None: + target.risk_policy_snapshot = normalize_risk_budget(raw_policy) + if target.risk_policy_snapshot is None: + return 0 + policy = copy.deepcopy(target.risk_policy_snapshot) + quarantined = self.risk_budget_ledger.quarantine_stale( + lineage_id=policy["lineage_id"], + session_id=target.jti, + stale_before=time.time() - stale_after_s, + ) + for result in quarantined: + receipt_entries.append( + (result, self._ensure_risk_lifecycle_event(target, result)) + ) + if receipt_entries: + self._persist_session(target) + for result, receipt_entry in receipt_entries: + self._log_receipt_once(receipt_entry) + self.risk_budget_ledger.mark_lifecycle_delivered( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_hash=result.request_hash, + lifecycle_id=result.lifecycle_id, + receipt_id=str(receipt_entry["receipt_id"]), + ) + if quarantined: + self._risk_metric( + operation="quarantine", + outcome="quarantined", + ) + return sum(not result.idempotent for result in quarantined) + + def _ensure_risk_lifecycle_event( + self, + session: GovernanceSession, + result: RiskOutcomeResult, + ) -> dict[str, Any]: + for event in session.events: + if event.risk_lifecycle_id == result.lifecycle_id: + if not isinstance(event.risk_receipt_entry, dict): + raise RiskBudgetError("risk lifecycle outbox is incomplete") + return copy.deepcopy(event.risk_receipt_entry) + + _, measurements = self._risk_measurements(fact_digest=result.fact_digest) + now = time.time() + timestamp = ( + time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now)) + + f".{int((now % 1) * 1_000_000_000):09d}Z" + ) + event = PolicyEvent( + timestamp=timestamp, + step_id=_receipt_step_id( + session.jti, + timestamp, + "risk_budget_lifecycle", + {}, + ), + actor=str(session.passport_claims.get("sub", "unknown")), + verifier_id=self.verifier_id, + tool_name="risk_budget_lifecycle", + arguments={}, + action_class="observe", + target="risk_budget", + resource_family="governance", + side_effect_class="none", + decision=Decision.PERMIT, + reason=f"risk_outcome_{result.status}", + passport_jti=session.jti, + trace_id=session.jti, + run_nonce=session.run_nonce, + measurements=measurements, + risk_budget_remaining=dict(result.remaining), + risk_lifecycle_id=result.lifecycle_id, + ) + session.events.append(event) + receipt_entry = self._build_receipt_log_entry( + session, + event, + Decision.PERMIT, + event.reason, + dict(session.passport_claims), + ) + event.risk_receipt_entry = copy.deepcopy(receipt_entry) + return receipt_entry + + def flush_risk_lifecycle_outbox( + self, + session: GovernanceSession | str, + ) -> int: + """Deliver every pending lifecycle receipt for one governed session.""" + + receipt_entries: list[tuple[RiskOutcomeResult, dict[str, Any]]] = [] + policy: dict[str, Any] | None = None + with self._locked_persisted_session(session) as target: + with target._lock: + if target.risk_policy_snapshot is None: + raw_policy = target.passport_claims.get("risk_budget") + if raw_policy is not None: + target.risk_policy_snapshot = normalize_risk_budget(raw_policy) + if target.risk_policy_snapshot is None: + return 0 + policy = copy.deepcopy(target.risk_policy_snapshot) + pending = self.risk_budget_ledger.pending_lifecycles_for_session( + lineage_id=policy["lineage_id"], + session_id=target.jti, + ) + for result in pending: + receipt_entries.append( + (result, self._ensure_risk_lifecycle_event(target, result)) + ) + if receipt_entries: + self._persist_session(target) + for result, receipt_entry in receipt_entries: + self._log_receipt_once(receipt_entry) + self.risk_budget_ledger.mark_lifecycle_delivered( + lineage_id=policy["lineage_id"], + session_id=target.jti, + request_hash=result.request_hash, + lifecycle_id=result.lifecycle_id, + receipt_id=str(receipt_entry["receipt_id"]), + ) + return len(receipt_entries) def summarize_session(self, session: GovernanceSession | str) -> dict[str, Any]: with self._locked_persisted_session(session) as target: @@ -3641,6 +4341,7 @@ def summarize_session(self, session: GovernanceSession | str) -> dict[str, Any]: return self._build_summary(target) def end_session(self, session: GovernanceSession | str) -> dict[str, Any]: + self.flush_risk_lifecycle_outbox(session) created_summary = False with self._locked_persisted_session(session) as target: with target._lock: @@ -3658,6 +4359,7 @@ def issue_attestation_for_session( *, kernel_enforcement: dict[str, Any] | None = None, ) -> tuple[str, dict[str, Any]]: + self.flush_risk_lifecycle_outbox(session_id) created_summary = False token = "" with self._locked_persisted_session(session_id) as target: @@ -3711,6 +4413,32 @@ def lifecycle_rollup_for_session(self, session_id: str) -> dict[str, Any]: def _finalize_session_locked( self, session: GovernanceSession ) -> tuple[dict[str, Any], bool]: + risk_budget = ( + session.risk_policy_snapshot + if session.risk_policy_snapshot is not None + else session.passport_claims.get("risk_budget") + ) + if risk_budget is not None: + try: + normalized = normalize_risk_budget(risk_budget) + except RiskBudgetError as exc: + raise PermissionError("risk_budget_policy_invalid") from exc + unresolved = self.risk_budget_ledger.unresolved_for_session( + lineage_id=normalized["lineage_id"], + session_id=session.jti, + ) + if unresolved: + raise PermissionError( + "risk_budget_outcome_unresolved: record committed or released outcome" + ) + pending = self.risk_budget_ledger.pending_lifecycles_for_session( + lineage_id=normalized["lineage_id"], + session_id=session.jti, + ) + if pending: + raise PermissionError( + "risk_budget_lifecycle_pending: deliver lifecycle receipts before finalization" + ) if session.summary is not None: return dict(session.summary), False session.end_time = time.time() @@ -3719,7 +4447,9 @@ def _finalize_session_locked( return dict(summary), True def _build_summary(self, session: GovernanceSession) -> dict[str, Any]: - events = list(session.events) + events = [ + event for event in session.events if not self._is_internal_risk_event(event) + ] permits = sum(1 for e in events if e.decision == Decision.PERMIT) denials = sum( 1 @@ -3867,6 +4597,8 @@ def _session_no_out_of_scope_permits(session: GovernanceSession) -> bool: allowed = set(claims.get("allowed_tools", []) or []) tool_scope_mode = str(claims.get("tool_scope_mode", "allowlist")) for event in session.events: + if GovernanceProxy._is_internal_risk_event(event): + continue if event.decision != Decision.PERMIT: continue if event.tool_name in forbidden: @@ -4572,6 +5304,7 @@ def _copy_session_state( ) target.last_receipt_id = getattr(source, "last_receipt_id", None) target.last_receipt_full_hash = getattr(source, "last_receipt_full_hash", None) + target.risk_policy_snapshot = copy.deepcopy(source.risk_policy_snapshot) target.run_nonce = getattr(source, "run_nonce", target.run_nonce) target.last_memory_record_id = ( preserved_last @@ -4654,6 +5387,7 @@ def delegate_passport( child_ttl_s: int | None = None, child_max_tool_calls: int | None = None, child_resource_scope: list[str] | None = None, + child_risk_budget: Mapping[str, Any] | None = None, delegation_request_id: str | None = None, ) -> tuple[str, dict[str, Any], int]: derivation_parent_token, parent_claims = ( @@ -4672,6 +5406,7 @@ def delegate_passport( child_ttl_s=child_ttl_s, child_max_tool_calls=child_max_tool_calls, child_resource_scope=child_resource_scope, + child_risk_budget=child_risk_budget, ) request_fingerprint = self._delegation_request_fingerprint(request_metadata) receipt_entry: dict[str, Any] | None = None @@ -4779,6 +5514,7 @@ def delegate_passport( # conservation rule. parent_reserved_for_descendants=reserved_for_derivation, child_resource_scope=child_resource_scope, + child_risk_budget=child_risk_budget, ) child_claims = self.verify_passport_token( child_token, @@ -4817,6 +5553,7 @@ def delegate_passport( "child_resource_scope": list( child_claims.get("resource_scope", []) ), + "child_risk_budget": copy.deepcopy(child_claims.get("risk_budget")), "child_tool_scope_mode": child_claims.get( "tool_scope_mode", "allowlist", @@ -4902,6 +5639,7 @@ def delegate_passport( parent_session.passport_claims, ) self._persist_session(parent_session) + self._log_receipt(receipt_entry) self._log( { "type": "delegation", @@ -4911,8 +5649,6 @@ def delegate_passport( "parent_calls_remaining_at_delegation": parent_calls_remaining, } ) - if receipt_entry is not None: - self._log_receipt(receipt_entry) return child_token, child_claims, parent_calls_remaining def _load_replay_cache_locked(self) -> dict[str, dict[str, int]]: @@ -5243,6 +5979,64 @@ def _log_receipt(self, entry: dict[str, Any]) -> None: with self._last_seen_receipts_lock: self._last_seen_receipts[grant_id] = receipt_id + @contextlib.contextmanager + def _receipt_log_file_lock(self): + lock_path = self.receipts_log_path.with_name( + f"{self.receipts_log_path.name}.lock" + ) + if lock_path.is_symlink(): + raise RiskBudgetError("receipt log lock must not be a symlink") + fd = os.open( + lock_path, + os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + os.fchmod(fd, 0o600) + try: + with os.fdopen(fd, "a+b", closefd=False) as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + finally: + os.close(fd) + + def _log_receipt_once(self, entry: dict[str, Any]) -> None: + """Durably append one receipt id at most once for outbox recovery.""" + + receipt_id = entry.get("receipt_id") + if not isinstance(receipt_id, str) or not receipt_id: + raise ValueError("receipt entry requires a non-empty receipt_id") + line = json.dumps(entry) + "\n" + found = False + with self._receipts_log_lock: + with self._receipt_log_file_lock(): + if self.receipts_log_path.exists(): + with self.receipts_log_path.open("r", encoding="utf-8") as handle: + for raw_line in handle: + try: + existing = json.loads(raw_line) + except json.JSONDecodeError: + continue + if existing.get("receipt_id") == receipt_id: + found = True + break + if not found: + with self.receipts_log_path.open("a", encoding="utf-8") as handle: + handle.write(line) + handle.flush() + os.fsync(handle.fileno()) + signed_jwt = entry.get("jwt") + if isinstance(signed_jwt, str): + from .transparency import queue_receipt_anchor_best_effort + + queue_receipt_anchor_best_effort(signed_jwt, self.receipts_log_path) + grant_id = entry.get("grant_id") + if grant_id: + with self._last_seen_receipts_lock: + self._last_seen_receipts[grant_id] = receipt_id + PUBLIC_PATHS = frozenset({"/health", "/healthz", "/.well-known/jwks.json"}) @@ -5799,10 +6593,17 @@ def do_POST(self) -> None: # noqa: N802 session_id = payload.get("session_id") or payload.get("session") if session_id is None: session_id = get_active_session_id() + risk_request_id = payload.get("risk_request_id") + if risk_request_id is not None and ( + not isinstance(risk_request_id, str) + or not risk_request_id.strip() + ): + raise ValueError("risk_request_id must be a non-empty string") decision, reason = proxy.evaluate_tool_call( str(session_id), str(self._require_field(payload, "tool_name")), dict(arguments), + risk_request_id=risk_request_id, ) if reason == "passport_revoked": self._send_json(403, {"error": "passport_revoked"}) @@ -5830,6 +6631,24 @@ def do_POST(self) -> None: # noqa: N802 self._send_json(200, {"status": "recorded"}) return + if path == "/risk/outcome": + session_id = str( + payload.get("session_id") + or self._require_field(payload, "session") + ) + risk_request_id = self._require_string_field( + payload, + "risk_request_id", + ) + outcome = self._require_string_field(payload, "outcome") + result = proxy.record_risk_outcome( + session_id, + risk_request_id=risk_request_id, + outcome=outcome, + ) + self._send_json(200, result) + return + if path in {"/session/end", "/end"}: session_id = str( payload.get("session_id") @@ -5874,6 +6693,12 @@ def do_POST(self) -> None: # noqa: N802 child_ttl = payload.get("child_ttl_s") child_max_calls = payload.get("child_max_tool_calls") child_scope = payload.get("child_resource_scope") + child_risk_budget = payload.get("child_risk_budget") + if child_risk_budget is not None and not isinstance( + child_risk_budget, + dict, + ): + raise ValueError("child_risk_budget must be a JSON object") delegation_request_id = payload.get("delegation_request_id") if delegation_request_id is not None and ( not isinstance(delegation_request_id, str) @@ -5907,6 +6732,7 @@ def do_POST(self) -> None: # noqa: N802 child_ttl_s=child_ttl_int, child_max_tool_calls=child_max_calls_int, child_resource_scope=child_scope_list, + child_risk_budget=child_risk_budget, delegation_request_id=delegation_request_id, ) ) diff --git a/python/vibap/receipt.py b/python/vibap/receipt.py index 206aae36..8fc34751 100644 --- a/python/vibap/receipt.py +++ b/python/vibap/receipt.py @@ -23,7 +23,11 @@ import jwt from cryptography.hazmat.primitives.asymmetric import ec -from .canonical_json import RFC8785JSONEncoder, canonical_json_bytes, canonical_json_text +from .canonical_json import ( + RFC8785JSONEncoder, + canonical_json_bytes, + canonical_json_text, +) from .passport import ( ALGORITHM, @@ -107,29 +111,52 @@ _LEGACY_ALLOWED_CLAIMS = set(_REQUIRED_CLAIMS) | _OPTIONAL_CLAIMS _ALLOWED_CLAIMS = _LEGACY_ALLOWED_CLAIMS | _V02_REQUIRED_CLAIMS _ACTION_CLASSES = { - "search", "read", "write", "query", "delegate", "send", "summarize", "observe", + "search", + "read", + "write", + "query", + "delegate", + "send", + "summarize", + "observe", # Claude Code hook adapter extensions — tool-execution semantics not covered # by the original proxy-centric schema: - "execute", # Bash / shell execution + "execute", # Bash / shell execution "dispatch", # Task / subagent dispatch - "fetch", # WebFetch / HTTP read - "invoke", # LS, TodoRead, TodoWrite, other passive invocations + "fetch", # WebFetch / HTTP read + "invoke", # LS, TodoRead, TodoWrite, other passive invocations } _SIDE_EFFECT_CLASSES = { - "none", "internal_write", "external_send", "state_change", + "none", + "internal_write", + "external_send", + "state_change", # Claude Code hook adapter extensions: "filesystem_write", # Write/Edit to local filesystem - "process_launch", # Bash spawns a subprocess - "network_read", # WebFetch/WebSearch makes an outbound read request - "subagent_launch", # Task spawns a sub-agent + "process_launch", # Bash spawns a subprocess + "network_read", # WebFetch/WebSearch makes an outbound read request + "subagent_launch", # Task spawns a sub-agent } _VERDICTS = {"compliant", "violation", "insufficient_evidence"} _EVIDENCE_LEVELS = {"self_signed", "counter_signed", "transparency_logged"} _DIGEST_ALGS = {"sha-256", "sha-384", "sha-512"} _DIGEST_CANONICALIZATIONS = {"jcs-rfc8785", "none"} _DIGEST_SCOPES = {"result", "normalized_input", "measurement", "custom"} -_DENIAL_REASONS = {"policy_denied", "budget_exhausted", "insufficient_evidence", "revoked", "chain_invalid"} -_SENSITIVITY_LEVELS = {"public", "internal", "confidential", "restricted", "regulated", "unknown"} +_DENIAL_REASONS = { + "policy_denied", + "budget_exhausted", + "insufficient_evidence", + "revoked", + "chain_invalid", +} +_SENSITIVITY_LEVELS = { + "public", + "internal", + "confidential", + "restricted", + "regulated", + "unknown", +} _SHA256_HEX_RE = re.compile(r"^[A-Fa-f0-9]{64}$") _BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]+$") _TOKEN_FIELD_RE = re.compile(r"^[A-Za-z0-9._:-]+$") @@ -226,7 +253,9 @@ def _validate_digest_object(value: Any, key: str) -> None: ): _schema_violation(f"{key}.canonicalization has invalid value") scope = value.get("scope") - if scope is not None and (not isinstance(scope, str) or scope not in _DIGEST_SCOPES): + if scope is not None and ( + not isinstance(scope, str) or scope not in _DIGEST_SCOPES + ): _schema_violation(f"{key}.scope has invalid value") digest_value = value.get("value") if not isinstance(digest_value, str) or not _BASE64URL_RE.fullmatch(digest_value): @@ -268,10 +297,19 @@ def _validate_budget_delta(value: Any) -> None: _schema_violation("budget_delta.operation has invalid value") _require_string(value, "resource") _require_string(value, "unit") - for key in ("amount", "remaining_for_parent", "remaining_after", "used_total", "reserved_total"): + for key in ( + "amount", + "remaining_for_parent", + "remaining_after", + "used_total", + "reserved_total", + ): if key in value and (not isinstance(value[key], int) or value[key] < 0): _schema_violation(f"budget_delta.{key} must be a non-negative integer") - if "side_effect_class" in value and value["side_effect_class"] not in _SIDE_EFFECT_CLASSES: + if ( + "side_effect_class" in value + and value["side_effect_class"] not in _SIDE_EFFECT_CLASSES + ): _schema_violation("budget_delta.side_effect_class has invalid value") if "delegation_request_id" in value: _require_string(value, "delegation_request_id") @@ -289,11 +327,11 @@ def _validate_receipt_claim_schema(claims: dict[str, Any]) -> None: allowed_claims = _ALLOWED_CLAIMS missing_v02 = sorted(_V02_REQUIRED_CLAIMS - set(claims)) if missing_v02: - _schema_violation(f"{missing_v02[0]} is required for {RECEIPT_SCHEMA_VERSION}") - if claims.get("canonicalization") != RECEIPT_CANONICALIZATION: _schema_violation( - f"canonicalization must be {RECEIPT_CANONICALIZATION!r}" + f"{missing_v02[0]} is required for {RECEIPT_SCHEMA_VERSION}" ) + if claims.get("canonicalization") != RECEIPT_CANONICALIZATION: + _schema_violation(f"canonicalization must be {RECEIPT_CANONICALIZATION!r}") if claims.get("receipt_kind") != RECEIPT_KIND_ACTION: _schema_violation(f"receipt_kind must be {RECEIPT_KIND_ACTION!r}") else: @@ -317,7 +355,10 @@ def _validate_receipt_claim_schema(claims: dict[str, Any]) -> None: "jti", ): _require_string(claims, key) - if not _BASE64URL_RE.fullmatch(claims["run_nonce"]) or len(claims["run_nonce"]) < 16: + if ( + not _BASE64URL_RE.fullmatch(claims["run_nonce"]) + or len(claims["run_nonce"]) < 16 + ): _schema_violation("run_nonce must be base64url and at least 16 characters") if claims.get("parent_receipt_id") is not None: _require_string(claims, "parent_receipt_id") @@ -347,7 +388,11 @@ def _validate_receipt_claim_schema(claims: dict[str, Any]) -> None: _schema_violation("policy_decisions item contains unknown fields") _require_string(item, "backend") _require_string(item, "decision") - if "reason" in item and item["reason"] is not None and not isinstance(item["reason"], str): + if ( + "reason" in item + and item["reason"] is not None + and not isinstance(item["reason"], str) + ): _schema_violation("policy_decisions.reason must be string or null") if "rule_id" in item: rule_id = _require_string(item, "rule_id") @@ -374,11 +419,15 @@ def _validate_receipt_claim_schema(claims: dict[str, Any]) -> None: if claims.get("public_denial_reason") not in _DENIAL_REASONS: _schema_violation("public_denial_reason has invalid value") internal_code = claims.get("internal_denial_code") - if not isinstance(internal_code, str) or not _TOKEN_FIELD_RE.fullmatch(internal_code): + if not isinstance(internal_code, str) or not _TOKEN_FIELD_RE.fullmatch( + internal_code + ): _schema_violation("internal_denial_code must be an audit token") if "sensitivity" in claims and claims["sensitivity"] not in _SENSITIVITY_LEVELS: _schema_violation("sensitivity has invalid value") - if "instruction_bearing" in claims and not isinstance(claims["instruction_bearing"], bool): + if "instruction_bearing" in claims and not isinstance( + claims["instruction_bearing"], bool + ): _schema_violation("instruction_bearing must be boolean") if "budget_delta" in claims: _validate_budget_delta(claims["budget_delta"]) @@ -423,7 +472,7 @@ def _public_denial_reason(verdict: str, internal_denial_code: str | None) -> str return None if verdict == "insufficient_evidence": return "insufficient_evidence" - if internal_denial_code in {"budget_exhausted"}: + if internal_denial_code in {"budget_exhausted", "risk_budget_exhausted"}: return "budget_exhausted" if internal_denial_code in {"revoked", "mission_revoked"}: return "revoked" @@ -589,15 +638,15 @@ def build_receipt( # Default json.dumps escapes non-ASCII (ensure_ascii=True) while # _canonical_json does not — flagged in Phase 3 audit HIGH #2. arguments_hash = hashlib.sha256( - _canonical_json( - dict(getattr(event, "arguments", {}) or {}) - ).encode("utf-8") + _canonical_json(dict(getattr(event, "arguments", {}) or {})).encode("utf-8") ).hexdigest() remaining_budget = dict(budget_remaining or {}) timestamp = str(getattr(event, "timestamp", "")) trace_id = _trace_id_from_event(event) run_nonce = _run_nonce_from_event(event, trace_id) - invocation_digest = _digest_object("normalized_input", _invocation_digest_payload(event)) + invocation_digest = _digest_object( + "normalized_input", _invocation_digest_payload(event) + ) observed_at = _numeric_date(timestamp) now = int(time.time()) iat = max(now, observed_at or now) @@ -621,7 +670,9 @@ def build_receipt( "receipt_kind": RECEIPT_KIND_ACTION, "grant_id": str(getattr(event, "passport_jti", "")), "parent_receipt_hash": parent_receipt_hash, - "parent_receipt_id": parent_receipt_hash[:16] if parent_receipt_hash is not None else None, + "parent_receipt_id": parent_receipt_hash[:16] + if parent_receipt_hash is not None + else None, "actor": str(getattr(event, "actor", "")), "verifier_id": verifier_id, "step_id": step_id, @@ -631,7 +682,9 @@ def build_receipt( "resource_family": str(getattr(event, "resource_family", "") or "general"), "side_effect_class": str(getattr(event, "side_effect_class", "") or "none"), "verdict": verdict, - "evidence_level": str(getattr(event, "evidence_level", "") or DEFAULT_EVIDENCE_LEVEL), + "evidence_level": str( + getattr(event, "evidence_level", "") or DEFAULT_EVIDENCE_LEVEL + ), "reason": reason_text, "policy_decisions": payload_policy_decisions, "arguments_hash": arguments_hash, @@ -690,7 +743,9 @@ def build_receipt( ) -def sign_receipt(receipt: ExecutionReceipt, private_key: ec.EllipticCurvePrivateKey) -> str: +def sign_receipt( + receipt: ExecutionReceipt, private_key: ec.EllipticCurvePrivateKey +) -> str: return jwt.encode( receipt.to_dict(), private_key, @@ -722,7 +777,8 @@ def verify_receipt( expected_run_nonce: str | None = None, expected_invocation_digest: dict[str, Any] | None = None, replay_cache: MutableSet[str] | None = None, - trusted_issuer_bindings: dict[str, set[str] | list[str] | tuple[str, ...]] | None = None, + trusted_issuer_bindings: dict[str, set[str] | list[str] | tuple[str, ...]] + | None = None, verify_expiry: bool = True, iat_future_skew_s: int | None = _IAT_FUTURE_SKEW_S, iat_past_skew_s: int | None = _IAT_PAST_SKEW_S, @@ -782,7 +838,9 @@ def verify_receipt( verdict = claims.get("verdict") if verdict == "compliant": if "public_denial_reason" in claims or "internal_denial_code" in claims: - raise jwt.InvalidTokenError("compliant receipts must not carry denial reasons") + raise jwt.InvalidTokenError( + "compliant receipts must not carry denial reasons" + ) else: if "public_denial_reason" not in claims: raise jwt.MissingRequiredClaimError("public_denial_reason") @@ -798,8 +856,13 @@ def verify_receipt( raise jwt.InvalidTokenError("receipt trace_id does not match replay context") if expected_run_nonce is not None and claims.get("run_nonce") != expected_run_nonce: raise jwt.InvalidTokenError("receipt run_nonce does not match replay context") - if expected_invocation_digest is not None and invocation_digest != expected_invocation_digest: - raise jwt.InvalidTokenError("receipt invocation_digest does not match replay context") + if ( + expected_invocation_digest is not None + and invocation_digest != expected_invocation_digest + ): + raise jwt.InvalidTokenError( + "receipt invocation_digest does not match replay context" + ) if replay_cache is not None: max_entries = getattr(replay_cache, "max_entries", None) if not isinstance(max_entries, int) or max_entries <= 0: @@ -890,11 +953,13 @@ def _signed_policy_decisions( ) -> list[dict[str, Any]]: raw_policy_decisions = list(getattr(event, "policy_decisions", []) or []) if not raw_policy_decisions: - return [{ - "backend": "native", - "decision": "Allow" if _decision_name(decision) == "PERMIT" else "Deny", - "reason": reason or None, - }] + return [ + { + "backend": "native", + "decision": "Allow" if _decision_name(decision) == "PERMIT" else "Deny", + "reason": reason or None, + } + ] compact: list[dict[str, Any]] = [] for item in raw_policy_decisions: backend = str(item.get("backend", "unknown")) @@ -916,4 +981,6 @@ def _receipt_token(receipt: str | dict[str, Any]) -> str: return receipt if isinstance(receipt, dict) and isinstance(receipt.get("jwt"), str): return str(receipt["jwt"]) - raise TypeError("receipt chain entries must be JWT strings or dicts with a 'jwt' field") + raise TypeError( + "receipt chain entries must be JWT strings or dicts with a 'jwt' field" + ) diff --git a/python/vibap/risk_budget.py b/python/vibap/risk_budget.py new file mode 100644 index 00000000..07646c51 --- /dev/null +++ b/python/vibap/risk_budget.py @@ -0,0 +1,1499 @@ +"""Typed dangerous-action impact contracts and durable blast-radius budgets. + +Risk facts are derived locally from authenticated tool schemas and a closed, +declarative extractor vocabulary. A signed ``risk_budget`` passport claim +binds each governed tool to its contract digest, per-action caps, and additive +session/agent/lineage ceilings. The file ledger reserves every numeric fact +across every scope in one transaction before the tool may execute. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import hashlib +import json +import os +import stat +import threading +import time +import uuid +import weakref +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +from jsonschema import Draft202012Validator +from jsonschema.exceptions import SchemaError, ValidationError + +from .canonical_json import canonical_json_bytes + +RISK_BUDGET_VERSION = 1 +MAX_RISK_VALUE = 2**63 - 1 +MAX_CONTRACT_BYTES = 64 * 1024 +MAX_ARGUMENT_BYTES = 64 * 1024 +MAX_SCHEMA_NODES = 4096 +MAX_SCHEMA_DEPTH = 64 +MAX_RESERVATIONS = 4096 +QUARANTINE_RETENTION_S = 24 * 60 * 60 +REPLAY_TOMBSTONE_RETENTION_S = 60 * 60 + +NUMERIC_RISK_FACTS = ( + "destructive_targets", + "objects_affected", + "bytes_affected", +) +RISK_SCOPES = ("session", "agent", "lineage") +CATEGORICAL_RISK_LEVELS: dict[str, tuple[str, ...]] = { + "secret_sensitivity": ( + "none", + "public", + "internal", + "confidential", + "restricted", + "regulated", + "unknown", + ), + "destination_risk": ( + "local", + "private_network", + "trusted_service", + "public_internet", + "untrusted", + "unknown", + ), + "filesystem_scope": ( + "none", + "declared", + "workspace", + "external", + "system", + "unknown", + ), + "irreversibility": ( + "reversible", + "compensatable", + "irreversible", + "unknown", + ), +} +ALL_RISK_FACTS = frozenset(NUMERIC_RISK_FACTS) | frozenset(CATEGORICAL_RISK_LEVELS) +EXTRACTOR_KINDS = frozenset({"integer", "array_length", "constant", "enum"}) + + +class RiskBudgetError(ValueError): + """Fail-closed risk policy, contract, extraction, or ledger error.""" + + +class RiskFactError(RiskBudgetError): + """A trusted contract could not derive a valid typed risk fact.""" + + +class RiskBudgetConflictError(RiskBudgetError): + """A request id was replayed with different authorization semantics.""" + + +class RiskBudgetReplayError(RiskBudgetError): + """A request id already has an active or terminal ledger record.""" + + +def _require_object(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise RiskBudgetError(f"{label} must be a JSON object") + return dict(value) + + +def _require_string(value: Any, label: str, *, max_bytes: int = 256) -> str: + if not isinstance(value, str) or not value.strip(): + raise RiskBudgetError(f"{label} must be a non-empty string") + normalized = value.strip() + if len(normalized.encode("utf-8")) > max_bytes: + raise RiskBudgetError(f"{label} exceeds {max_bytes} bytes") + return normalized + + +def _require_risk_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise RiskBudgetError(f"{label} must be an integer") + if value < 0 or value > MAX_RISK_VALUE: + raise RiskBudgetError(f"{label} must be between 0 and {MAX_RISK_VALUE}") + return value + + +def _bounded_json_bytes(value: Any, label: str, limit: int) -> bytes: + try: + encoded = canonical_json_bytes(value) + except (TypeError, ValueError) as exc: + raise RiskBudgetError(f"{label} must be RFC 8785 canonicalizable JSON") from exc + if len(encoded) > limit: + raise RiskBudgetError(f"{label} exceeds {limit} canonical bytes") + return encoded + + +def _validate_schema_shape(value: Any) -> None: + nodes = 0 + + def visit(node: Any, depth: int) -> None: + nonlocal nodes + nodes += 1 + if nodes > MAX_SCHEMA_NODES: + raise RiskBudgetError("tool input schema exceeds the node limit") + if depth > MAX_SCHEMA_DEPTH: + raise RiskBudgetError("tool input schema exceeds the nesting limit") + if isinstance(node, dict): + for key, child in node.items(): + if key in {"$ref", "$dynamicRef", "$recursiveRef"}: + if not isinstance(child, str) or not child.startswith("#"): + raise RiskBudgetError( + "external JSON Schema references are forbidden" + ) + visit(child, depth + 1) + elif isinstance(node, list): + for child in node: + visit(child, depth + 1) + + visit(value, 0) + + +def _json_pointer(document: Mapping[str, Any], pointer: str) -> Any: + if pointer == "": + return document + if not isinstance(pointer, str) or not pointer.startswith("/"): + raise RiskFactError("extractor pointer must be an RFC 6901 JSON Pointer") + current: Any = document + for raw_part in pointer[1:].split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + if isinstance(current, Mapping): + if part not in current: + raise RiskFactError("required risk fact source is missing") + current = current[part] + elif isinstance(current, list): + if ( + not part.isascii() + or not part.isdigit() + or (part.startswith("0") and part != "0") + ): + raise RiskFactError("risk fact array pointer is invalid") + index = int(part) + if index >= len(current): + raise RiskFactError("risk fact array pointer is out of range") + current = current[index] + else: + raise RiskFactError("risk fact pointer traverses a scalar") + return current + + +def _pointer_syntax_is_valid(pointer: str) -> bool: + if pointer and not pointer.startswith("/"): + return False + index = 0 + while index < len(pointer): + if pointer[index] == "~": + if index + 1 >= len(pointer) or pointer[index + 1] not in {"0", "1"}: + return False + index += 2 + continue + index += 1 + return True + + +@dataclass(frozen=True, slots=True) +class ToolRiskContract: + """Trusted typed risk facts for one authenticated tool definition.""" + + tool_name: str + _input_schema_json: bytes + _risk_contract_json: bytes + digest: str + + @property + def input_schema(self) -> dict[str, Any]: + """Return a detached view; canonical bytes remain authoritative.""" + + return json.loads(self._input_schema_json) + + @property + def risk_contract(self) -> dict[str, Any]: + """Return a detached view; canonical bytes remain authoritative.""" + + return json.loads(self._risk_contract_json) + + @classmethod + def from_schema( + cls, + tool_name: str, + input_schema: Mapping[str, Any], + risk_contract: Mapping[str, Any], + ) -> "ToolRiskContract": + name = _require_string(tool_name, "tool_name") + schema = _require_object(input_schema, "input_schema") + contract = _require_object(risk_contract, "risk_contract") + _bounded_json_bytes(schema, "input_schema", MAX_CONTRACT_BYTES) + _bounded_json_bytes(contract, "risk_contract", MAX_CONTRACT_BYTES) + _validate_schema_shape(schema) + try: + Draft202012Validator.check_schema(schema) + except SchemaError as exc: + raise RiskBudgetError( + "input_schema is not valid JSON Schema 2020-12" + ) from exc + + if set(contract) != {"version", "mandatory_facts", "extractors"}: + raise RiskBudgetError( + "risk_contract fields must be version, mandatory_facts, and extractors" + ) + if contract.get("version") != RISK_BUDGET_VERSION: + raise RiskBudgetError("unsupported risk_contract version") + mandatory = contract.get("mandatory_facts") + extractors = contract.get("extractors") + if not isinstance(mandatory, list) or not mandatory: + raise RiskBudgetError("mandatory_facts must be a non-empty list") + if not isinstance(extractors, dict) or not extractors: + raise RiskBudgetError("extractors must be a non-empty object") + if len(set(mandatory)) != len(mandatory): + raise RiskBudgetError("mandatory_facts must not contain duplicates") + if any( + not isinstance(fact, str) or fact not in ALL_RISK_FACTS + for fact in mandatory + ): + raise RiskBudgetError("mandatory_facts contains an unknown risk fact") + if set(extractors) != set(mandatory): + raise RiskBudgetError("extractors must exactly cover mandatory_facts") + + normalized_extractors: dict[str, dict[str, Any]] = {} + for fact in mandatory: + spec = _require_object(extractors[fact], f"extractors.{fact}") + kind = spec.get("kind") + if kind not in EXTRACTOR_KINDS: + raise RiskBudgetError(f"extractors.{fact}.kind is unsupported") + expected_fields = ( + {"kind", "value"} if kind == "constant" else {"kind", "pointer"} + ) + if set(spec) != expected_fields: + raise RiskBudgetError( + f"extractors.{fact} must contain exactly {sorted(expected_fields)}" + ) + if kind != "constant": + pointer = spec.get("pointer") + if not isinstance(pointer, str) or not _pointer_syntax_is_valid( + pointer + ): + raise RiskBudgetError(f"extractors.{fact}.pointer is invalid") + if kind in {"integer", "array_length"} and fact not in NUMERIC_RISK_FACTS: + raise RiskBudgetError(f"extractors.{fact} requires a numeric fact") + if kind == "enum" and fact not in CATEGORICAL_RISK_LEVELS: + raise RiskBudgetError(f"extractors.{fact} requires a categorical fact") + if kind == "constant": + cls._validate_fact(fact, spec.get("value")) + normalized_extractors[fact] = spec + + normalized_contract = { + "version": RISK_BUDGET_VERSION, + "mandatory_facts": list(mandatory), + "extractors": normalized_extractors, + } + material = { + "tool_name": name, + "input_schema": schema, + "risk_contract": normalized_contract, + } + digest = f"sha256:{hashlib.sha256(canonical_json_bytes(material)).hexdigest()}" + return cls( + name, + canonical_json_bytes(schema), + canonical_json_bytes(normalized_contract), + digest, + ) + + @staticmethod + def _validate_fact(fact: str, value: Any) -> int | str: + if fact in NUMERIC_RISK_FACTS: + try: + return _require_risk_int(value, fact) + except RiskBudgetError as exc: + raise RiskFactError(str(exc)) from exc + levels = CATEGORICAL_RISK_LEVELS.get(fact) + if levels is None or not isinstance(value, str) or value not in levels: + raise RiskFactError(f"{fact} has an invalid categorical value") + return value + + def extract(self, arguments: Mapping[str, Any]) -> dict[str, int | str]: + if not isinstance(arguments, Mapping): + raise RiskFactError("tool arguments must be a JSON object") + args = dict(arguments) + try: + _bounded_json_bytes(args, "tool arguments", MAX_ARGUMENT_BYTES) + except RiskBudgetError as exc: + raise RiskFactError(str(exc)) from exc + schema = self.input_schema + risk_contract = self.risk_contract + try: + Draft202012Validator(schema).validate(args) + except ValidationError as exc: + raise RiskFactError( + "tool arguments do not satisfy the authenticated schema" + ) from exc + + facts: dict[str, int | str] = {} + for fact in risk_contract["mandatory_facts"]: + spec = risk_contract["extractors"][fact] + kind = spec["kind"] + if kind == "constant": + raw = spec["value"] + else: + raw = _json_pointer(args, spec["pointer"]) + if kind == "array_length": + if not isinstance(raw, list): + raise RiskFactError(f"{fact} source must be an array") + raw = len(raw) + elif kind == "integer": + if isinstance(raw, bool) or not isinstance(raw, int): + raise RiskFactError(f"{fact} source must be an integer") + elif kind == "enum" and not isinstance(raw, str): + raise RiskFactError(f"{fact} source must be a string") + facts[fact] = self._validate_fact(fact, raw) + return facts + + +class ToolRiskRegistry: + """Immutable-after-start registry of authenticated risk contracts.""" + + def __init__(self) -> None: + self._contracts: dict[str, ToolRiskContract] = {} + self._frozen = False + self._lock = threading.RLock() + + def register(self, contract: ToolRiskContract) -> None: + if not isinstance(contract, ToolRiskContract): + raise TypeError("contract must be a ToolRiskContract") + with self._lock: + if self._frozen: + raise RuntimeError("tool risk registry is frozen") + if contract.tool_name in self._contracts: + raise RiskBudgetConflictError( + f"risk contract already registered for {contract.tool_name!r}" + ) + self._contracts[contract.tool_name] = contract + + def freeze(self) -> None: + with self._lock: + self._frozen = True + + def resolve(self, tool_name: str) -> ToolRiskContract | None: + with self._lock: + return self._contracts.get(tool_name) + + +def normalize_risk_budget( + value: Mapping[str, Any], + *, + lineage_id: str | None = None, +) -> dict[str, Any]: + """Validate and canonicalize the signed ``risk_budget`` claim.""" + + policy = _require_object(value, "risk_budget") + allowed = {"version", "lineage_id", "tools", "ceilings"} + unknown = set(policy) - allowed + if unknown: + raise RiskBudgetError(f"risk_budget contains unknown fields: {sorted(unknown)}") + if policy.get("version") != RISK_BUDGET_VERSION: + raise RiskBudgetError("unsupported risk_budget version") + effective_lineage = ( + lineage_id if lineage_id is not None else policy.get("lineage_id") + ) + effective_lineage = _require_string(effective_lineage, "risk_budget.lineage_id") + + raw_tools = _require_object(policy.get("tools"), "risk_budget.tools") + raw_ceilings = _require_object(policy.get("ceilings"), "risk_budget.ceilings") + if not raw_tools: + raise RiskBudgetError("risk_budget.tools must not be empty") + tools: dict[str, Any] = {} + for raw_name, raw_tool_policy in raw_tools.items(): + name = _require_string(raw_name, "risk_budget tool name") + if name in tools: + raise RiskBudgetError( + f"risk_budget contains duplicate normalized tool name {name!r}" + ) + tool_policy = _require_object(raw_tool_policy, f"risk_budget.tools.{name}") + if set(tool_policy) != {"contract_digest", "max_facts"}: + raise RiskBudgetError( + f"risk_budget.tools.{name} must contain contract_digest and max_facts" + ) + digest = _require_string( + tool_policy.get("contract_digest"), + f"risk_budget.tools.{name}.contract_digest", + ) + if not digest.startswith("sha256:") or len(digest) != 71: + raise RiskBudgetError( + f"risk_budget.tools.{name}.contract_digest is invalid" + ) + try: + int(digest[7:], 16) + except ValueError as exc: + raise RiskBudgetError( + f"risk_budget.tools.{name}.contract_digest is invalid" + ) from exc + if digest[7:] != digest[7:].lower(): + raise RiskBudgetError( + f"risk_budget.tools.{name}.contract_digest must use lowercase hex" + ) + raw_caps = _require_object( + tool_policy.get("max_facts"), f"risk_budget.tools.{name}.max_facts" + ) + if not raw_caps or any(fact not in ALL_RISK_FACTS for fact in raw_caps): + raise RiskBudgetError(f"risk_budget.tools.{name}.max_facts is invalid") + caps: dict[str, int | str] = {} + for fact, cap in raw_caps.items(): + caps[fact] = ToolRiskContract._validate_fact(fact, cap) + tools[name] = {"contract_digest": digest, "max_facts": caps} + + ceilings: dict[str, dict[str, int]] = {} + for fact, raw_scopes in raw_ceilings.items(): + if fact not in NUMERIC_RISK_FACTS: + raise RiskBudgetError("risk_budget.ceilings only accepts numeric facts") + scopes = _require_object(raw_scopes, f"risk_budget.ceilings.{fact}") + if set(scopes) != set(RISK_SCOPES): + raise RiskBudgetError( + f"risk_budget.ceilings.{fact} must contain session, agent, and lineage" + ) + ceilings[fact] = { + scope: _require_risk_int( + scopes[scope], f"risk_budget.ceilings.{fact}.{scope}" + ) + for scope in RISK_SCOPES + } + required_numeric = { + fact + for tool_policy in tools.values() + for fact in tool_policy["max_facts"] + if fact in NUMERIC_RISK_FACTS + } + if set(ceilings) != required_numeric: + raise RiskBudgetError( + "risk_budget.ceilings must exactly cover numeric facts in tool policies" + ) + return { + "version": RISK_BUDGET_VERSION, + "lineage_id": effective_lineage, + "tools": tools, + "ceilings": ceilings, + } + + +def attenuate_risk_budget( + parent: Mapping[str, Any], + child: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Return a child policy that is provably no broader than ``parent``.""" + + normalized_parent = normalize_risk_budget(parent) + if child is None: + return normalized_parent + if child.get("lineage_id") != normalized_parent["lineage_id"]: + raise PermissionError("risk_budget lineage_id escalation") + normalized_child = normalize_risk_budget( + child, + lineage_id=normalized_parent["lineage_id"], + ) + if normalized_child["lineage_id"] != normalized_parent["lineage_id"]: + raise PermissionError("risk_budget lineage_id escalation") + if not set(normalized_child["tools"]).issubset(normalized_parent["tools"]): + raise PermissionError("risk_budget tool escalation") + for tool, child_policy in normalized_child["tools"].items(): + parent_policy = normalized_parent["tools"][tool] + if child_policy["contract_digest"] != parent_policy["contract_digest"]: + raise PermissionError("risk_budget contract digest escalation") + if set(child_policy["max_facts"]) != set(parent_policy["max_facts"]): + raise PermissionError("risk_budget fact set must be preserved") + for fact, child_cap in child_policy["max_facts"].items(): + parent_cap = parent_policy["max_facts"][fact] + if fact in NUMERIC_RISK_FACTS: + broader = int(child_cap) > int(parent_cap) + else: + levels = CATEGORICAL_RISK_LEVELS[fact] + broader = levels.index(str(child_cap)) > levels.index(str(parent_cap)) + if broader: + raise PermissionError(f"risk_budget {fact} cap escalation") + if not set(normalized_child["ceilings"]).issubset(normalized_parent["ceilings"]): + raise PermissionError("risk_budget ceiling fact escalation") + for fact, child_scopes in normalized_child["ceilings"].items(): + for scope in RISK_SCOPES: + if child_scopes[scope] > normalized_parent["ceilings"][fact][scope]: + raise PermissionError(f"risk_budget {fact}.{scope} ceiling escalation") + return normalized_child + + +def project_risk_budget( + policy: Mapping[str, Any], + allowed_tools: set[str], +) -> dict[str, Any] | None: + """Project a policy onto retained tools without retaining unused ceilings.""" + + normalized = normalize_risk_budget(policy) + retained_tools = { + tool: tool_policy + for tool, tool_policy in normalized["tools"].items() + if tool in allowed_tools + } + if not retained_tools: + return None + retained_numeric_facts = { + fact + for tool_policy in retained_tools.values() + for fact in tool_policy["max_facts"] + if fact in NUMERIC_RISK_FACTS + } + projected = { + "version": RISK_BUDGET_VERSION, + "lineage_id": normalized["lineage_id"], + "tools": retained_tools, + "ceilings": { + fact: scopes + for fact, scopes in normalized["ceilings"].items() + if fact in retained_numeric_facts + }, + } + return normalize_risk_budget(projected) + + +def validate_action_risk( + policy: Mapping[str, Any], + contract: ToolRiskContract, + facts: Mapping[str, int | str], +) -> dict[str, int]: + """Validate contract binding and per-action caps; return numeric facts.""" + + normalized = normalize_risk_budget(policy) + tool_policy = normalized["tools"].get(contract.tool_name) + if tool_policy is None: + raise RiskBudgetError("risk_policy_tool_missing") + if tool_policy["contract_digest"] != contract.digest: + raise RiskBudgetError("risk_contract_digest_mismatch") + if set(facts) != set(contract.risk_contract["mandatory_facts"]): + raise RiskBudgetError("risk_fact_set_mismatch") + caps = tool_policy["max_facts"] + if set(caps) != set(facts): + raise RiskBudgetError("risk_policy_fact_cap_missing") + numeric: dict[str, int] = {} + for fact, value in facts.items(): + validated = ToolRiskContract._validate_fact(fact, value) + cap = caps[fact] + if fact in NUMERIC_RISK_FACTS: + if int(validated) > int(cap): + raise RiskBudgetError("risk_action_cap_exceeded") + numeric[fact] = int(validated) + else: + levels = CATEGORICAL_RISK_LEVELS[fact] + if str(validated) == "unknown" or levels.index( + str(validated) + ) > levels.index(str(cap)): + raise RiskBudgetError("risk_action_cap_exceeded") + return numeric + + +@dataclass(frozen=True, slots=True) +class RiskReservationResult: + accepted: bool + request_hash: str + status: str + remaining: dict[str, int] + blocking_fact: str | None = None + blocking_scope: str | None = None + + +@dataclass(frozen=True, slots=True) +class RiskOutcomeResult: + request_hash: str + status: str + remaining: dict[str, int] + fact_digest: str + lifecycle_id: str + lifecycle_state: str + receipt_id: str | None = None + idempotent: bool = False + + +class _ProcessLock: + __slots__ = ("lock", "__weakref__") + + def __init__(self) -> None: + self.lock = threading.RLock() + + +_LOCKS: weakref.WeakValueDictionary[str, _ProcessLock] = weakref.WeakValueDictionary() +_LOCKS_GUARD = threading.Lock() + + +class FileRiskBudgetLedger: + """Atomic multi-fact, multi-scope reservation ledger across all lineages.""" + + def __init__(self, state_dir: str | Path) -> None: + self.state_dir = Path(state_dir).expanduser() + self.ledger_dir = self.state_dir / "risk_budgets" + if self.ledger_dir.is_symlink(): + raise RiskBudgetError("risk ledger directory must not be a symlink") + self.ledger_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(self.ledger_dir, stat.S_IRWXU) + + @staticmethod + def _hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + @classmethod + def _request_hash(cls, lineage_id: str, request_id: str) -> str: + return cls._hash(f"{cls._hash(lineage_id)}:{request_id}") + + @staticmethod + def _remaining( + payload: Mapping[str, Any], + ceilings: Mapping[str, Mapping[str, int]], + scope_keys: Mapping[str, str], + ) -> dict[str, int]: + result: dict[str, int] = {} + accounts = payload.get("accounts", {}) + for fact, fact_ceilings in ceilings.items(): + for scope in RISK_SCOPES: + account_key = f"{fact}:{scope}:{scope_keys[scope]}" + account = accounts.get(account_key, {}) + used = int(account.get("spent", 0)) + int(account.get("reserved", 0)) + result[f"{fact}.{scope}"] = max(0, int(fact_ceilings[scope]) - used) + return result + + def reserve( + self, + *, + lineage_id: str, + session_id: str, + agent_id: str, + request_id: str, + fingerprint: str, + numeric_facts: Mapping[str, int], + ceilings: Mapping[str, Mapping[str, int]], + policy_digest: str, + contract_digest: str, + fact_digest: str, + expires_at: int, + now: float | None = None, + ) -> RiskReservationResult: + for label, value in { + "lineage_id": lineage_id, + "session_id": session_id, + "agent_id": agent_id, + "request_id": request_id, + "fingerprint": fingerprint, + }.items(): + _require_string(value, label, max_bytes=1024) + if set(numeric_facts) != set(ceilings): + raise RiskBudgetError("numeric facts and ceilings must have identical keys") + amounts = { + fact: _require_risk_int(value, fact) + for fact, value in numeric_facts.items() + } + request_hash = self._request_hash(lineage_id, request_id) + fingerprint_hash = self._hash(fingerprint) + scope_keys = { + "session": self._hash(session_id), + "agent": self._hash(agent_id), + "lineage": self._hash(lineage_id), + } + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + timestamp = float(time.time() if now is None else now) + prune_result = self._prune_payload(payload, timestamp) + if prune_result["pruned"] or prune_result["quarantined"]: + self._validate(payload) + self._persist(lineage_id, payload) + reservations = payload["reservations"] + tombstone = payload["tombstones"].get(request_hash) + if isinstance(tombstone, dict): + if tombstone.get("fingerprint_hash") != fingerprint_hash: + raise RiskBudgetConflictError( + "risk request id tombstone has different semantics" + ) + raise RiskBudgetReplayError( + f"risk request id already archived as {tombstone.get('status', 'unknown')}" + ) + existing = reservations.get(request_hash) + if isinstance(existing, dict): + expected = { + "fingerprint_hash": fingerprint_hash, + "policy_digest": policy_digest, + "contract_digest": contract_digest, + "fact_digest": fact_digest, + "amounts": amounts, + "scope_keys": scope_keys, + } + actual = {key: existing.get(key) for key in expected} + if actual != expected: + raise RiskBudgetConflictError( + "risk request id already used for different semantics" + ) + raise RiskBudgetReplayError( + f"risk request id already recorded as {existing.get('status', 'unknown')}" + ) + if len(reservations) >= MAX_RESERVATIONS: + raise RiskBudgetError("risk reservation retention limit reached") + + remaining = self._remaining(payload, ceilings, scope_keys) + for fact, amount in amounts.items(): + for scope in RISK_SCOPES: + if amount > remaining[f"{fact}.{scope}"]: + return RiskReservationResult( + accepted=False, + request_hash=request_hash, + status="rejected", + remaining=remaining, + blocking_fact=fact, + blocking_scope=scope, + ) + + accounts = payload["accounts"] + for fact, amount in amounts.items(): + for scope in RISK_SCOPES: + account_key = f"{fact}:{scope}:{scope_keys[scope]}" + account = accounts.setdefault( + account_key, + {"spent": 0, "reserved": 0, "archived_spent": 0}, + ) + account["reserved"] = int(account["reserved"]) + amount + reservations[request_hash] = { + "fingerprint_hash": fingerprint_hash, + "policy_digest": policy_digest, + "contract_digest": contract_digest, + "fact_digest": fact_digest, + "amounts": amounts, + "scope_keys": scope_keys, + "ceilings": json.loads(json.dumps(ceilings)), + "status": "active", + "created_at": timestamp, + "updated_at": timestamp, + "expires_at": _require_risk_int(expires_at, "expires_at"), + "lifecycle": None, + } + self._validate(payload) + self._persist(lineage_id, payload) + return RiskReservationResult( + accepted=True, + request_hash=request_hash, + status="active", + remaining=self._remaining(payload, ceilings, scope_keys), + ) + + def record_outcome( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + outcome: str, + now: float | None = None, + ) -> RiskOutcomeResult: + if outcome not in {"committed", "released"}: + raise RiskBudgetError("risk outcome must be committed or released") + request_hash = self._request_hash( + lineage_id, + _require_string(request_id, "request_id", max_bytes=1024), + ) + session_hash = self._hash( + _require_string(session_id, "session_id", max_bytes=1024) + ) + lineage_hash = self._hash( + _require_string(lineage_id, "lineage_id", max_bytes=1024) + ) + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + reservation = payload["reservations"].get(request_hash) + if not isinstance(reservation, dict): + raise RiskBudgetError("risk reservation does not exist") + if reservation.get("scope_keys", {}).get("session") != session_hash: + raise RiskBudgetConflictError( + "risk reservation belongs to a different session" + ) + if reservation.get("scope_keys", {}).get("lineage") != lineage_hash: + raise RiskBudgetConflictError( + "risk reservation belongs to a different lineage" + ) + status = reservation.get("status") + scope_keys = reservation["scope_keys"] + ceilings = reservation["ceilings"] + if status == outcome: + lifecycle = reservation["lifecycle"] + return RiskOutcomeResult( + request_hash=request_hash, + status=outcome, + remaining=self._remaining(payload, ceilings, scope_keys), + fact_digest=reservation["fact_digest"], + lifecycle_id=lifecycle["id"], + lifecycle_state=lifecycle["state"], + receipt_id=lifecycle.get("receipt_id"), + idempotent=True, + ) + if status == "quarantined" and outcome == "released": + raise RiskBudgetConflictError( + "quarantined risk reservation cannot be released" + ) + if ( + status == "quarantined" + and reservation["lifecycle"]["state"] != "delivered" + ): + raise RiskBudgetError( + "quarantine lifecycle receipt must be delivered before reconciliation" + ) + if status not in {"active", "quarantined"}: + raise RiskBudgetConflictError( + f"risk reservation is {status!r}, not reconcilable" + ) + for fact, amount in reservation["amounts"].items(): + for scope in RISK_SCOPES: + account_key = f"{fact}:{scope}:{scope_keys[scope]}" + account = payload["accounts"][account_key] + account["reserved"] = int(account["reserved"]) - int(amount) + if outcome == "committed": + account["spent"] = int(account["spent"]) + int(amount) + reservation["status"] = outcome + reservation["updated_at"] = float(time.time() if now is None else now) + lifecycle_id = self._hash(f"outcome:{request_hash}:{outcome}") + reservation["lifecycle"] = { + "id": lifecycle_id, + "state": "pending", + "receipt_id": None, + } + self._validate(payload) + self._persist(lineage_id, payload) + return RiskOutcomeResult( + request_hash=request_hash, + status=outcome, + remaining=self._remaining(payload, ceilings, scope_keys), + fact_digest=reservation["fact_digest"], + lifecycle_id=lifecycle_id, + lifecycle_state="pending", + ) + + def mark_lifecycle_delivered( + self, + *, + lineage_id: str, + session_id: str, + request_hash: str, + lifecycle_id: str, + receipt_id: str, + ) -> None: + """Atomically acknowledge that a lifecycle receipt is durable.""" + + session_hash = self._hash( + _require_string(session_id, "session_id", max_bytes=1024) + ) + lineage_hash = self._hash( + _require_string(lineage_id, "lineage_id", max_bytes=1024) + ) + _require_string(lifecycle_id, "lifecycle_id", max_bytes=128) + _require_string(receipt_id, "receipt_id", max_bytes=1024) + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + record = payload["reservations"].get(request_hash) + if not isinstance(record, dict): + record = payload["tombstones"].get(request_hash) + if not isinstance(record, dict): + raise RiskBudgetError("risk lifecycle reservation does not exist") + record_session_hash = record.get("session_hash") + if record_session_hash is None: + record_session_hash = record.get("scope_keys", {}).get("session") + if record_session_hash != session_hash: + raise RiskBudgetConflictError( + "risk lifecycle belongs to a different session" + ) + record_lineage_hash = record.get("lineage_hash") + if record_lineage_hash is None: + record_lineage_hash = record.get("scope_keys", {}).get("lineage") + if record_lineage_hash != lineage_hash: + raise RiskBudgetConflictError( + "risk lifecycle belongs to a different lineage" + ) + lifecycle = record.get("lifecycle") + if not isinstance(lifecycle, dict) or lifecycle.get("id") != lifecycle_id: + raise RiskBudgetConflictError("risk lifecycle binding mismatch") + if lifecycle.get("state") == "delivered": + if lifecycle.get("receipt_id") != receipt_id: + raise RiskBudgetConflictError( + "risk lifecycle receipt binding mismatch" + ) + return + lifecycle["state"] = "delivered" + lifecycle["receipt_id"] = receipt_id + self._validate(payload) + self._persist(lineage_id, payload) + + def quarantine_stale( + self, + *, + lineage_id: str, + session_id: str, + stale_before: float, + now: float | None = None, + ) -> list[RiskOutcomeResult]: + quarantined: list[RiskOutcomeResult] = [] + newly_quarantined: set[str] = set() + session_hash = self._hash( + _require_string(session_id, "session_id", max_bytes=1024) + ) + lineage_hash = self._hash( + _require_string(lineage_id, "lineage_id", max_bytes=1024) + ) + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + timestamp = float(time.time() if now is None else now) + for request_hash, reservation in payload["reservations"].items(): + if ( + reservation.get("status") == "active" + and reservation.get("scope_keys", {}).get("session") == session_hash + and reservation.get("scope_keys", {}).get("lineage") == lineage_hash + and float(reservation.get("created_at", timestamp)) <= stale_before + ): + reservation["status"] = "quarantined" + reservation["updated_at"] = timestamp + reservation["lifecycle"] = { + "id": self._hash(f"outcome:{request_hash}:quarantined"), + "state": "pending", + "receipt_id": None, + } + newly_quarantined.add(request_hash) + for request_hash, reservation in payload["reservations"].items(): + lifecycle = reservation.get("lifecycle") + if ( + reservation.get("status") == "quarantined" + and reservation.get("scope_keys", {}).get("session") == session_hash + and reservation.get("scope_keys", {}).get("lineage") == lineage_hash + and isinstance(lifecycle, dict) + and lifecycle.get("state") == "pending" + ): + quarantined.append( + RiskOutcomeResult( + request_hash=request_hash, + status="quarantined", + remaining=self._remaining( + payload, + reservation["ceilings"], + reservation["scope_keys"], + ), + fact_digest=reservation["fact_digest"], + lifecycle_id=lifecycle["id"], + lifecycle_state="pending", + idempotent=request_hash not in newly_quarantined, + ) + ) + for request_hash, tombstone in payload["tombstones"].items(): + lifecycle = tombstone.get("lifecycle") + if ( + tombstone.get("status") == "quarantined_committed" + and tombstone.get("session_hash") == session_hash + and tombstone.get("lineage_hash") == lineage_hash + and isinstance(lifecycle, dict) + and lifecycle.get("state") == "pending" + ): + quarantined.append( + RiskOutcomeResult( + request_hash=request_hash, + status="quarantined", + remaining={}, + fact_digest=tombstone["fact_digest"], + lifecycle_id=lifecycle["id"], + lifecycle_state="pending", + idempotent=True, + ) + ) + if quarantined: + self._validate(payload) + self._persist(lineage_id, payload) + return quarantined + + def unresolved_for_session(self, *, lineage_id: str, session_id: str) -> list[str]: + session_hash = self._hash(session_id) + lineage_hash = self._hash(lineage_id) + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + return [ + request_hash + for request_hash, reservation in payload["reservations"].items() + if reservation.get("status") in {"active", "quarantined"} + and reservation.get("scope_keys", {}).get("session") == session_hash + and reservation.get("scope_keys", {}).get("lineage") == lineage_hash + ] + [ + request_hash + for request_hash, tombstone in payload["tombstones"].items() + if tombstone.get("status") == "quarantined_committed" + and tombstone.get("session_hash") == session_hash + and tombstone.get("lineage_hash") == lineage_hash + and tombstone.get("lifecycle", {}).get("state") == "pending" + ] + + def pending_lifecycles_for_session( + self, + *, + lineage_id: str, + session_id: str, + ) -> list[RiskOutcomeResult]: + """Return retryable lifecycle outbox records owned by one session.""" + + session_hash = self._hash( + _require_string(session_id, "session_id", max_bytes=1024) + ) + lineage_hash = self._hash( + _require_string(lineage_id, "lineage_id", max_bytes=1024) + ) + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + pending: list[RiskOutcomeResult] = [] + for request_hash, reservation in payload["reservations"].items(): + lifecycle = reservation.get("lifecycle") + if ( + reservation.get("scope_keys", {}).get("session") == session_hash + and reservation.get("scope_keys", {}).get("lineage") == lineage_hash + and isinstance(lifecycle, dict) + and lifecycle.get("state") == "pending" + ): + pending.append( + RiskOutcomeResult( + request_hash=request_hash, + status=str(reservation["status"]), + remaining=self._remaining( + payload, + reservation["ceilings"], + reservation["scope_keys"], + ), + fact_digest=reservation["fact_digest"], + lifecycle_id=lifecycle["id"], + lifecycle_state="pending", + idempotent=True, + ) + ) + for request_hash, tombstone in payload["tombstones"].items(): + lifecycle = tombstone["lifecycle"] + if ( + tombstone["session_hash"] == session_hash + and tombstone["lineage_hash"] == lineage_hash + and lifecycle["state"] == "pending" + ): + status = str(tombstone["status"]) + if status == "quarantined_committed": + status = "quarantined" + pending.append( + RiskOutcomeResult( + request_hash=request_hash, + status=status, + remaining={}, + fact_digest=tombstone["fact_digest"], + lifecycle_id=lifecycle["id"], + lifecycle_state="pending", + idempotent=True, + ) + ) + return pending + + def remaining( + self, + *, + lineage_id: str, + session_id: str, + agent_id: str, + ceilings: Mapping[str, Mapping[str, int]], + ) -> dict[str, int]: + scope_keys = { + "session": self._hash(session_id), + "agent": self._hash(agent_id), + "lineage": self._hash(lineage_id), + } + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + return self._remaining(payload, ceilings, scope_keys) + + def snapshot(self, lineage_id: str) -> dict[str, Any]: + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + return json.loads(json.dumps(payload)) + + def prune_expired( + self, + *, + lineage_id: str, + now: float | None = None, + ) -> dict[str, int]: + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._validate(payload) + result = self._prune_payload( + payload, + float(time.time() if now is None else now), + ) + if result["pruned"] or result["quarantined"]: + self._validate(payload) + self._persist(lineage_id, payload) + return result + + @staticmethod + def _prune_payload(payload: dict[str, Any], now: float) -> dict[str, int]: + working = json.loads(json.dumps(payload)) + pruned = 0 + quarantined = 0 + reservations = working["reservations"] + tombstones = working["tombstones"] + for request_hash, tombstone in list(tombstones.items()): + if ( + float(tombstone["replay_until"]) <= now + and tombstone["lifecycle"]["state"] == "delivered" + ): + del tombstones[request_hash] + pruned += 1 + for request_hash, reservation in list(reservations.items()): + expires_at = _require_risk_int( + reservation.get("expires_at"), + "risk ledger expires_at", + ) + if expires_at > now: + continue + status_value = reservation["status"] + if status_value == "active": + reservation["status"] = "quarantined" + reservation["updated_at"] = now + reservation["lifecycle"] = { + "id": hashlib.sha256( + f"outcome:{request_hash}:quarantined".encode("utf-8") + ).hexdigest(), + "state": "pending", + "receipt_id": None, + } + quarantined += 1 + continue + if status_value == "quarantined": + if now < float(reservation["updated_at"]) + QUARANTINE_RETENTION_S: + continue + for fact, amount in reservation["amounts"].items(): + for scope in RISK_SCOPES: + account_key = ( + f"{fact}:{scope}:{reservation['scope_keys'][scope]}" + ) + account = working["accounts"][account_key] + account["reserved"] = int(account["reserved"]) - int(amount) + account["spent"] = int(account["spent"]) + int(amount) + account["archived_spent"] = int( + account["archived_spent"] + ) + int(amount) + status_value = "quarantined_committed" + lifecycle = reservation.get("lifecycle") + if ( + status_value in {"committed", "released"} + and isinstance(lifecycle, dict) + and lifecycle.get("state") != "delivered" + ): + continue + if status_value == "committed": + for fact, amount in reservation["amounts"].items(): + for scope in RISK_SCOPES: + account_key = ( + f"{fact}:{scope}:{reservation['scope_keys'][scope]}" + ) + account = working["accounts"][account_key] + account["archived_spent"] = int( + account["archived_spent"] + ) + int(amount) + if len(tombstones) >= MAX_RESERVATIONS: + raise RiskBudgetError("risk replay tombstone retention limit reached") + tombstones[request_hash] = { + "fingerprint_hash": reservation["fingerprint_hash"], + "fact_digest": reservation["fact_digest"], + "session_hash": reservation["scope_keys"]["session"], + "lineage_hash": reservation["scope_keys"]["lineage"], + "status": status_value, + "replay_until": max(expires_at, int(now)) + + REPLAY_TOMBSTONE_RETENTION_S, + "lifecycle": json.loads(json.dumps(reservation["lifecycle"])), + } + del reservations[request_hash] + pruned += 1 + payload.clear() + payload.update(working) + return {"pruned": pruned, "quarantined": quarantined} + + def _path(self, lineage_id: str) -> Path: + del lineage_id + return self.ledger_dir / "global.json" + + def _lock_path(self, lineage_id: str) -> Path: + return self._path(lineage_id).with_suffix(".lock") + + @contextlib.contextmanager + def _locked(self, lineage_id: str): + lock_path = self._lock_path(lineage_id) + if lock_path.is_symlink(): + raise RiskBudgetError("risk ledger lock must not be a symlink") + fd = os.open( + lock_path, + os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), + 0o600, + ) + os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) + key = str(lock_path.resolve()) + with _LOCKS_GUARD: + process_lock = _LOCKS.get(key) + if process_lock is None: + process_lock = _ProcessLock() + _LOCKS[key] = process_lock + try: + with process_lock.lock: + with os.fdopen(fd, "a+b", closefd=False) as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + finally: + os.close(fd) + + def _load(self, lineage_id: str) -> dict[str, Any]: + path = self._path(lineage_id) + if not path.exists(): + return { + "version": RISK_BUDGET_VERSION, + "ledger_scope": "global", + "accounts": {}, + "reservations": {}, + "tombstones": {}, + } + if path.is_symlink(): + raise RiskBudgetError("risk ledger file must not be a symlink") + fd = -1 + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "r", encoding="utf-8") as handle: + fd = -1 + payload = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + raise RiskBudgetError("risk ledger is unavailable or corrupt") from exc + finally: + if fd != -1: + os.close(fd) + if not isinstance(payload, dict): + raise RiskBudgetError("risk ledger must contain a JSON object") + if payload.get("ledger_scope") != "global": + raise RiskBudgetError("risk ledger scope binding mismatch") + return payload + + @staticmethod + def _validate(payload: Mapping[str, Any]) -> None: + if set(payload) != { + "version", + "ledger_scope", + "accounts", + "reservations", + "tombstones", + }: + raise RiskBudgetError("risk ledger has an invalid top-level shape") + if payload.get("version") != RISK_BUDGET_VERSION: + raise RiskBudgetError("risk ledger version is unsupported") + accounts = _require_object(payload.get("accounts"), "risk ledger accounts") + reservations = _require_object( + payload.get("reservations"), "risk ledger reservations" + ) + tombstones = _require_object( + payload.get("tombstones"), "risk ledger tombstones" + ) + if len(reservations) > MAX_RESERVATIONS: + raise RiskBudgetError("risk ledger exceeds reservation retention limit") + if len(tombstones) > MAX_RESERVATIONS: + raise RiskBudgetError("risk ledger exceeds tombstone retention limit") + for request_hash, tombstone_value in tombstones.items(): + if not isinstance(request_hash, str) or len(request_hash) != 64: + raise RiskBudgetError("risk ledger tombstone request hash is invalid") + tombstone = _require_object(tombstone_value, "risk ledger tombstone") + if set(tombstone) != { + "fingerprint_hash", + "fact_digest", + "session_hash", + "lineage_hash", + "status", + "replay_until", + "lifecycle", + }: + raise RiskBudgetError("risk ledger tombstone shape is invalid") + if ( + not isinstance(tombstone["fingerprint_hash"], str) + or len(tombstone["fingerprint_hash"]) != 64 + ): + raise RiskBudgetError("risk ledger tombstone fingerprint is invalid") + fact_digest = tombstone["fact_digest"] + if ( + not isinstance(fact_digest, str) + or not fact_digest.startswith("sha256:") + or len(fact_digest) != 71 + or fact_digest[7:] != fact_digest[7:].lower() + ): + raise RiskBudgetError("risk ledger tombstone fact digest is invalid") + try: + bytes.fromhex(fact_digest[7:]) + except ValueError as exc: + raise RiskBudgetError( + "risk ledger tombstone fact digest is invalid" + ) from exc + if ( + not isinstance(tombstone["session_hash"], str) + or len(tombstone["session_hash"]) != 64 + ): + raise RiskBudgetError("risk ledger tombstone session hash is invalid") + if ( + not isinstance(tombstone["lineage_hash"], str) + or len(tombstone["lineage_hash"]) != 64 + ): + raise RiskBudgetError("risk ledger tombstone lineage hash is invalid") + if tombstone["status"] not in { + "committed", + "released", + "quarantined_committed", + }: + raise RiskBudgetError("risk ledger tombstone status is invalid") + _require_risk_int(tombstone["replay_until"], "risk ledger replay_until") + lifecycle = _require_object( + tombstone["lifecycle"], "risk ledger tombstone lifecycle" + ) + if set(lifecycle) != {"id", "state", "receipt_id"}: + raise RiskBudgetError( + "risk ledger tombstone lifecycle shape is invalid" + ) + if not isinstance(lifecycle["id"], str) or len(lifecycle["id"]) != 64: + raise RiskBudgetError("risk ledger tombstone lifecycle id is invalid") + if lifecycle["state"] not in {"pending", "delivered"}: + raise RiskBudgetError( + "risk ledger tombstone lifecycle state is invalid" + ) + if lifecycle["state"] == "pending" and lifecycle["receipt_id"] is not None: + raise RiskBudgetError( + "pending risk tombstone lifecycle has a receipt id" + ) + if lifecycle["state"] == "delivered": + _require_string( + lifecycle["receipt_id"], "risk ledger tombstone receipt id" + ) + expected_reserved: dict[str, int] = {} + retained_spent: dict[str, int] = {} + for request_hash, reservation_value in reservations.items(): + if len(request_hash) != 64: + raise RiskBudgetError("risk ledger request hash is invalid") + reservation = _require_object(reservation_value, "risk ledger reservation") + _require_risk_int( + reservation.get("expires_at"), + "risk ledger expires_at", + ) + status_value = reservation.get("status") + if status_value not in {"active", "quarantined", "committed", "released"}: + raise RiskBudgetError("risk ledger reservation status is invalid") + amounts = _require_object(reservation.get("amounts"), "risk ledger amounts") + scope_keys = _require_object( + reservation.get("scope_keys"), "risk ledger scope keys" + ) + ceilings = _require_object( + reservation.get("ceilings"), "risk ledger ceilings" + ) + if set(ceilings) != set(amounts): + raise RiskBudgetError("risk ledger ceiling facts are incomplete") + for fact, fact_ceilings_value in ceilings.items(): + fact_ceilings = _require_object( + fact_ceilings_value, f"risk ledger ceilings {fact}" + ) + if set(fact_ceilings) != set(RISK_SCOPES): + raise RiskBudgetError("risk ledger ceiling scopes are incomplete") + for scope in RISK_SCOPES: + _require_risk_int( + fact_ceilings[scope], f"risk ledger ceiling {fact}.{scope}" + ) + lifecycle = reservation.get("lifecycle") + if status_value == "active": + if lifecycle is not None: + raise RiskBudgetError("active risk reservation has lifecycle state") + else: + lifecycle = _require_object(lifecycle, "risk ledger lifecycle") + if set(lifecycle) != {"id", "state", "receipt_id"}: + raise RiskBudgetError("risk ledger lifecycle shape is invalid") + if not isinstance(lifecycle["id"], str) or len(lifecycle["id"]) != 64: + raise RiskBudgetError("risk ledger lifecycle id is invalid") + if lifecycle["state"] not in {"pending", "delivered"}: + raise RiskBudgetError("risk ledger lifecycle state is invalid") + if ( + lifecycle["state"] == "pending" + and lifecycle["receipt_id"] is not None + ): + raise RiskBudgetError("pending risk lifecycle has a receipt id") + if lifecycle["state"] == "delivered": + _require_string(lifecycle["receipt_id"], "risk ledger receipt id") + if set(scope_keys) != set(RISK_SCOPES): + raise RiskBudgetError("risk ledger scope keys are incomplete") + for fact, raw_amount in amounts.items(): + if fact not in NUMERIC_RISK_FACTS: + raise RiskBudgetError( + "risk ledger contains an unknown numeric fact" + ) + amount = _require_risk_int(raw_amount, "risk ledger amount") + for scope in RISK_SCOPES: + scope_hash = scope_keys[scope] + if not isinstance(scope_hash, str) or len(scope_hash) != 64: + raise RiskBudgetError("risk ledger scope hash is invalid") + account_key = f"{fact}:{scope}:{scope_hash}" + if status_value in {"active", "quarantined"}: + expected_reserved[account_key] = ( + expected_reserved.get(account_key, 0) + amount + ) + elif status_value == "committed": + retained_spent[account_key] = ( + retained_spent.get(account_key, 0) + amount + ) + for account_key, account_value in accounts.items(): + account = _require_object( + account_value, f"risk ledger account {account_key}" + ) + if set(account) != {"spent", "reserved", "archived_spent"}: + raise RiskBudgetError("risk ledger account shape is invalid") + spent = _require_risk_int(account["spent"], "risk ledger spent") + reserved = _require_risk_int(account["reserved"], "risk ledger reserved") + archived = _require_risk_int( + account["archived_spent"], "risk ledger archived_spent" + ) + if reserved != expected_reserved.get(account_key, 0): + raise RiskBudgetError("risk ledger reserved invariant failed") + if spent != archived + retained_spent.get(account_key, 0): + raise RiskBudgetError("risk ledger spent invariant failed") + referenced_accounts = set(expected_reserved) | set(retained_spent) + if not referenced_accounts.issubset(accounts): + raise RiskBudgetError("risk ledger references a missing account") + + def _persist(self, lineage_id: str, payload: Mapping[str, Any]) -> None: + path = self._path(lineage_id) + tmp = path.with_name(f"{path.stem}.{uuid.uuid4().hex}.tmp") + data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + fd = -1 + try: + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR) + with os.fdopen(fd, "wb", closefd=False) as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.close(fd) + fd = -1 + os.replace(tmp, path) + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + dir_fd = os.open(self.ledger_dir, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except Exception: + if fd != -1: + os.close(fd) + with contextlib.suppress(OSError): + tmp.unlink() + raise diff --git a/site/content/source/README.md b/site/content/source/README.md index 23ff1758..88115dc4 100644 --- a/site/content/source/README.md +++ b/site/content/source/README.md @@ -2,7 +2,7 @@ title: "Ardur" description: "Ardur governs AI-agent tool calls that pass through a configured adapter or" source_path: "README.md" -source_sha256: "521195f3c5e80fc19944148a0963d378e403e8b7620c15ebbd4844fb37b23b84" +source_sha256: "d0832dc5ebaa5038669abe2e4f87bfea8fa1376ce64ec4516f44f296119bfce4" weight: 100 maturity: ["public-now"] claim_types: ["orientation", "runtime-boundary"] @@ -22,6 +22,14 @@ proxy. It checks mission, resource, budget, and delegation constraints before that integration dispatches the call, then emits an issuer-signed, hash-linked receipt for the decision. +For issuer-selected dangerous tools, an optional signed `risk_budget` claim +binds authenticated tool schemas to typed per-action impact caps and atomic +session, agent, and lineage ceilings. The executor must explicitly close every +permitted reservation as committed once execution may have started, or as +released only when execution never started. This does not infer semantic risk +or hidden side effects; see the +[typed risk-budget reference](/__ardur_internal__/source/docs/reference/risk-budgets/). + [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/ArdurAI/ardur/blob/__ARDUR_SOURCE_REF__/LICENSE) [![Status](https://img.shields.io/badge/status-pre--release-blue)](/__ardur_internal__/source/status/) [![Discussions](https://img.shields.io/badge/GitHub-Discussions-181717?logo=github)](https://github.com/ArdurAI/ardur/discussions) @@ -233,6 +241,7 @@ Concretely — these are the design principles the repo is being built to meet, - **Composable with what already exists.** Designed around SPIFFE for workload identity, Biscuit for first-party-attenuation credentials, Cedar for policy, the individual AAT Internet-Draft for delegation-token semantics, and EAT (RFC 9711) for attestation-token semantics. We didn't reinvent the substrate. - **Cryptographically bound by design.** Mission credentials are designed to be signed by an issuer key and produce signed receipts chain-hashed to the previous one. The Python Biscuit path reports SPIFFE holder binding only when the proxy has a server-owned Biscuit issuer key, JWT-SVID trust bundle, and audience and the presented credentials verify against them; request payloads cannot choose those verifier inputs. JWT-SVID itself remains a replayable bearer credential, so this is bounded holder evidence rather than universal replay prevention. The design is documented in the [ADRs](/__ardur_internal__/source/docs/decisions/readme/); the public code that implements it is being curated in phases. - **Delegation that narrows, never widens.** Child sessions get strictly narrower authority than their parent — fewer tools, smaller resource scope, smaller budget. The narrowing discipline is formalised in [ADR-017](/__ardur_internal__/source/docs/decisions/adr-017-biscuit-attenuation-narrowing-semantics/). +- **Impact caps before dangerous actions.** Opted-in Mission Passports bind trusted tool contracts to typed action caps and atomically conserved session/agent/lineage ceilings. Crash reservations quarantine instead of silently refunding authority; the design is recorded in [ADR-026](/__ardur_internal__/source/docs/decisions/adr-026-typed-dangerous-action-risk-budgets/). - **No authority by omission.** An absent or empty `resource_scope` grants no resource authority. Operators who intentionally permit every resource must sign the sole explicit wildcard `resource_scope: ["**"]`; issuance and governed-run surfaces warn when they do. The decision and format-specific attenuation rules are documented in [ADR-023](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/). - **Explicit about what it doesn't do.** Scope-level governance can't catch semantic misuse — if an allowed tool is used on an allowed resource for the wrong reason, that's a different layer's job. - **MIT licensed.** The research foundation (the Silence Theorem, the protocol formalism, the benchmark methodology) will be linked from this repo when the paper's public identifier is assigned. Articles in this repo paraphrase the research in original prose; they do not reproduce paper content. @@ -246,6 +255,7 @@ This repo currently includes: - current status and what is still being resolved - public v0.1 specs for mission declarations, execution receipts, verifier contracts, conformance profiles, and related protocol surfaces, plus a draft-10-pinned DRP mapping and executable profile with RFC 8785/P-256 emit, external-trust full-chain and critical-bound verification, and a portable seven-scenario implementation self-test bundle/report (not an IETF or independent interoperability claim), the v0.2 Execution Receipt hardening profile with versioned RFC 8785 payloads and legacy verification, a transparency-anchor sidecar profile with offline-verifiable Rekor v1 and separately keyed self-hosted proofs, a receiver-attestation profile with a two-key offline verifier and MCP shim fixture, a full offline-verification bundle/profile with redacted CLI/JSON/static HTML explorer reports, and a verified-receipt governance telemetry profile with redacted JSONL plus OTLP/HTTP trace/log export - Python governance runtime under `python/`; Go eBPF/K8s packages and version-dispatched JWT AAT credential attenuation under `go/`: the existing draft-00 DG v0.1 contract plus the explicit `ardur.dg.aat-draft-01.v0.2` profile with chain-position roles, audience-bound PoP, fresh per-hop holder keys, approval gates, and a deterministic self-test fixture (CWT and independent interoperability are not claimed) +- optional Python typed dangerous-action risk budgets with authenticated schema/extractor digests, signed attenuation, fsync-backed multi-scope reservations, explicit executor outcomes, and privacy-bounded signed receipts; the existing DRP profile does not project this extension - a Linux governance-overhead harness with a closed report schema, PR smoke workflow, manual stress profile, owner-only artifacts, and an opt-in shell-free paired-sensor mode - the Ardur Personal Hub service and CLI under `python/vibap/` (`ardur hub`, `ardur setup`, `ardur status`, `ardur protect claude-code`, `ardur profile init`, `ardur doctor-claude-code`, full offline evidence verification, verified redacted receipt telemetry export, receiver-envelope verification, detached normalized/Tetragon/Falco runtime-evidence correlation, static non-executing MCP/tool-server preflight, and no-key DRP/receiver/offline-verification fixtures), plus deterministic `ardur-drp-fixtures` and `ardur-policy-conformance` runners - the Claude Code plugin under `plugins/claude-code/` with `PreToolUse`, `PostToolUse`, `SubagentStart`, and `SubagentStop` hooks emitting signed receipts diff --git a/site/content/source/_index.md b/site/content/source/_index.md index 22b3e07f..691acec8 100644 --- a/site/content/source/_index.md +++ b/site/content/source/_index.md @@ -11,4 +11,4 @@ evidence_levels: ["code-and-doc", "spec", "archival-media", "doc-and-manifest", -The pages in this section are generated from 116 public Markdown files in the repo. The site also mirrors 113 documentation artifacts such as schemas, mission examples, helper source files, casts, and deployment manifests. Generated site content, local review context, and dependency/vendor directories are excluded from publication. The CI check fails when generated documentation drifts from its source hash. +The pages in this section are generated from 118 public Markdown files in the repo. The site also mirrors 113 documentation artifacts such as schemas, mission examples, helper source files, casts, and deployment manifests. Generated site content, local review context, and dependency/vendor directories are excluded from publication. The CI check fails when generated documentation drifts from its source hash. diff --git a/site/content/source/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md b/site/content/source/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md new file mode 100644 index 00000000..d6f103a6 --- /dev/null +++ b/site/content/source/docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md @@ -0,0 +1,120 @@ +--- +title: "ADR-026: Typed dangerous-action risk budgets" +description: "**Status:** Accepted" +source_path: "docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md" +source_sha256: "39354f077e406ba680ea772e52f8d2f6373653199d877d847fbcbd94311caf1a" +weight: 100 +maturity: ["public-now"] +claim_types: ["decision-record"] +surfaces: ["docs"] +frameworks: ["framework-agnostic"] +evidence_levels: ["code-and-doc"] +--- + + + +{{< proof-status state="public" label="Source-backed mirror" source="docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md" >}} +This page is generated from the public repository source file. Edit the source file, then run `python3 site/scripts/sync_source_docs.py` to refresh the Hugo mirror. +{{< /proof-status >}} + +**Status:** Accepted + +**Date:** 2026-07-14 + +## Context + +Tool allowlists and total call counts limit which operations an agent may +invoke and how often, but not the impact of one permitted invocation. One +allowed deletion could address one object or one million; one allowed send +could remain private or disclose regulated data publicly. Prompt-based risk +labels and MCP annotations are caller/server assertions, not a trustworthy +pre-action enforcement input. + +Impact caps must also survive concurrent agents and delegated sessions. A +read-check-write counter per process lets siblings simultaneously observe the +same remaining authority. Charging only after execution allows irreversible +actions to oversubscribe before the runtime records them. Automatically +returning a timed-out charge can race an executor that is still running. + +Primary inputs to the decision were RFC 8785, JSON Schema 2020-12, the current +MCP tools specification, OAuth Attenuating Agent Tokens draft-01, Agent +Delegation Receipt Protocol draft-10, and Python's `flock`/`os.replace` +contracts. See +the [risk-budget reference](/__ardur_internal__/source/docs/reference/risk-budgets/#protocol-boundary-and-primary-sources). + +## Decision + +1. Mission Passports may carry an optional versioned `risk_budget` claim. + Absence preserves existing runtime behavior. +2. Each governed tool is bound to a trusted `ToolRiskContract` digest over the + authenticated tool name, JSON Schema, and a closed declarative extractor + program. The registry freezes at proxy startup. +3. Contracts derive mandatory typed facts locally. Numeric facts are additive; + categorical facts use closed ordered vocabularies. Missing, unknown, + malformed, negative, non-integral, oversized, or schema-invalid input fails + closed. +4. Signed policy contains per-action fact caps plus numeric session, agent, and + lineage ceilings. Delegation preserves lineage and contract/fact identity + while allowing only tool subsets and lower/equal caps. The first governed + call freezes a normalized session snapshot; reservations retain the exact + accounting ceilings used at authorization. Tools removed during delegation + also remove numeric ceilings that no retained tool references. +5. `evaluate_tool_call` atomically reserves all numeric facts across all three + scopes before ordinary policy can return `PERMIT`. A unique request ID is + mandatory for governed actions; active or terminal replay cannot re-permit. +6. The executor explicitly records `committed` once execution may have started + or `released` only when it did not start. Session finalization refuses active + or quarantined reservations and resolved lifecycle events whose receipts are + not yet durable. Exceptions never imply release, and quarantined reservations + may only reconcile as committed. +7. Stale active reservations quarantine while retaining authority. Explicit + reconciliation is preferred. After expiry and a bounded quarantine window, + pruning conservatively archives uncertainty as spent. Terminal compaction + preserves request/fingerprint tombstones for a bounded replay window and + never refunds committed authority. +8. Action and lifecycle receipts contain only a canonical fact digest, bounded + remaining counters, and stable denial classes. Raw facts, request IDs, + targets, paths, URLs, and secrets are excluded. Ledger lifecycle state and + persisted session receipt material form a retry-safe outbox; compaction does + not discard a terminal record before receipt delivery. +9. The existing DRP profile does not project `risk_budget`. Emitters must fail + closed instead of silently dropping the extension. + +## Consequences + +- Configured dangerous tools can enforce per-action and cumulative impact caps + before dispatch, including across processes and delegated agents. +- The proxy adds JSON Schema validation and an fsync-backed reservation plus + outcome transaction for each governed action. All lineages serialize on one + global ledger lock so agent ceilings remain atomic across lineage boundaries; + this favors safety over high-throughput authorization. +- An executor crash conservatively consumes/quarantines authority until an + operator or recovery controller explicitly reconciles it, or bounded + post-expiry maintenance archives the uncertainty as spent. +- Contract authors become part of the trusted computing base: a schema or + extractor that understates impact cannot be repaired by the ledger. +- The feature does not discover hidden side effects, validate the truth of + tool arguments, classify semantic intent, or govern calls that bypass the + configured adapter. +- The runtime adds no network dependency or cloud-service cost, but durable + receipt/ledger/tombstone storage and high-throughput lineage contention are + operating costs. Receipt-sink failure intentionally retains outbox records + and can exhaust the bounded ledger rather than lose audit evidence. + +## Alternatives considered + +- **Trust a caller-provided risk object.** Rejected because the actor seeking + authorization could choose its own impact label. +- **Use MCP annotations directly.** Rejected because the MCP specification + treats annotations as untrusted unless the server is trusted; annotations + also do not provide cumulative atomic accounting. +- **Charge after tool completion.** Rejected because concurrent irreversible + actions could all pass before any charge was recorded. +- **Return stale reservations automatically.** Rejected because timeout does + not prove the executor stopped. +- **Use the existing delegation-call ledger.** Rejected because its single + call-count dimension cannot atomically conserve multiple typed facts across + session, agent, and lineage scopes. +- **Store raw facts and identifiers for easier debugging.** Rejected because + targets, destinations, and secret classifications are sensitive audit data; + digests and bounded counters are sufficient for enforcement evidence. diff --git a/site/content/source/docs/decisions/README.md b/site/content/source/docs/decisions/README.md index 54bc5c7a..fcd66063 100644 --- a/site/content/source/docs/decisions/README.md +++ b/site/content/source/docs/decisions/README.md @@ -2,7 +2,7 @@ title: "Architecture Decision Records" description: "ADRs document load-bearing design decisions behind Ardur's runtime, protocol, and deployment shape. Each record captures the context, the decision, and the trade-offs known at the " source_path: "docs/decisions/README.md" -source_sha256: "84309234880b2334351b79faf2ed8d2cb17c4a838141561f8803f12eb0cf1d7d" +source_sha256: "33ad64624315c6f7216bf1b97a52fdfc33cca0365fc6545afe97cb7a9bcd566a" weight: 100 maturity: ["public-now"] claim_types: ["decision-record"] @@ -37,9 +37,14 @@ ADRs are migrated from the private research repo with the two-pass cleanup appli | 022 | [SPIFFE mTLS identity for operator telemetry](/__ardur_internal__/source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/) | Accepted | 2026-07-11 | | 023 | [Explicit resource-scope authority](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/) | Accepted | 2026-07-12 | | 024 | [Self-asserted owner identity assurance](/__ardur_internal__/source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/) | Accepted | 2026-07-12 | +| 026 | [Typed dangerous-action risk budgets](/__ardur_internal__/source/docs/decisions/adr-026-typed-dangerous-action-risk-budgets/) | Accepted | 2026-07-14 | + +ADR-025 is reserved by a concurrently reviewed spend-gate decision. Parallel +issue branches may therefore show ADR-026 before ADR-025 lands in `dev`; the +reservation avoids a guaranteed rename conflict between focused changes. ## Conventions - **Status**: `Proposed`, `Accepted`, `Superseded by ADR-NNN`, `Deprecated`. A `Proposed` status means the design is documented but not yet landed in code; it can still change. -- **Numbering**: sequential, no gaps. The formal ADR-file practice began at ADR-015 in the private research repo; earlier design decisions were captured in running decision logs rather than individual ADR files. Public numbering preserves the original sequence so cross-references stay stable. +- **Numbering**: sequential with no gaps on `dev`. Concurrent branches may reserve the next number when the reservation is documented; an abandoned reservation must be reclaimed or later ADRs renumbered before merge. The formal ADR-file practice began at ADR-015 in the private research repo; earlier design decisions were captured in running decision logs rather than individual ADR files. Public numbering preserves the original sequence so cross-references stay stable. - **Scope**: ADRs record decisions about the protocol (MCEP), the runtime (Ardur), and deployment shapes. They do not duplicate spec content — the v0.1 specs live in [`docs/specs/`](/__ardur_internal__/source/docs/specs/readme/). diff --git a/site/content/source/docs/decisions/_index.md b/site/content/source/docs/decisions/_index.md index 9802d314..9be000a3 100644 --- a/site/content/source/docs/decisions/_index.md +++ b/site/content/source/docs/decisions/_index.md @@ -25,4 +25,5 @@ This section lists hosted documentation and mirrored artifacts generated from `d - [`ADR-022-operator-telemetry-spiffe-mtls.md`](/__ardur_internal__/source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/) - [`ADR-023-explicit-resource-scope-authority.md`](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/) - [`ADR-024-self-asserted-owner-identity-assurance.md`](/__ardur_internal__/source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/) +- [`ADR-026-typed-dangerous-action-risk-budgets.md`](/__ardur_internal__/source/docs/decisions/adr-026-typed-dangerous-action-risk-budgets/) - [`README.md`](/__ardur_internal__/source/docs/decisions/readme/) diff --git a/site/content/source/docs/reference/README.md b/site/content/source/docs/reference/README.md index 22d40097..60036f65 100644 --- a/site/content/source/docs/reference/README.md +++ b/site/content/source/docs/reference/README.md @@ -2,7 +2,7 @@ title: "Technical Reference" description: "Flat technical reference pages for the public Ardur surface. These describe" source_path: "docs/reference/README.md" -source_sha256: "f1354da872c6c097e8b84fa717780d4358bd8398f20ce39d1a993ee18bd68728" +source_sha256: "890c549127f99894fb685df657a03a2fb0eb5dd297ebd2c8ba549178ceb7ca28" weight: 100 maturity: ["public-now"] claim_types: ["documentation"] @@ -39,6 +39,9 @@ walkthroughs see [`../guides/`](/__ardur_internal__/source/docs/guides/); for pr - [Advisory AI Controls](/__ardur_internal__/source/docs/reference/advisory-ai-controls/) — semantic-judge and behavioral-fingerprint defaults, non-authoritative status, failure policy, cost, and integration requirements +- [Typed Dangerous-Action Risk Budgets](/__ardur_internal__/source/docs/reference/risk-budgets/) — authenticated tool + contracts, signed impact caps, atomic session/agent/lineage accounting, + executor outcomes, receipts, and crash recovery - [Agent Recognition Evaluation](/__ardur_internal__/source/docs/reference/agent-recognition-evaluation/) — versioned maintained corpus, deterministic metrics, Wilson intervals, CI thresholds, and claim boundaries @@ -54,5 +57,6 @@ These pages mirror the public source. When the underlying surface changes `go/pkg/kernelcapture/testdata/agent_recognition_corpus.json`, `go/pkg/kernelcapture/testdata/agent_recognition_thresholds.json`, `python/vibap/semantic_judge.py`, `python/vibap/behavioral_fingerprint.py`, +`python/vibap/risk_budget.py`, `Dockerfile.proxy`, or its release workflow), update the matching page in the same change. They are deliberately mechanical so the diff is easy to review. diff --git a/site/content/source/docs/reference/_index.md b/site/content/source/docs/reference/_index.md index 779bccdd..76467810 100644 --- a/site/content/source/docs/reference/_index.md +++ b/site/content/source/docs/reference/_index.md @@ -23,3 +23,4 @@ This section lists hosted documentation and mirrored artifacts generated from `d - [`kernel-capture-daemon.md`](/__ardur_internal__/source/docs/reference/kernel-capture-daemon/) - [`personal-hub-api.md`](/__ardur_internal__/source/docs/reference/personal-hub-api/) - [`proxy-oci-image.md`](/__ardur_internal__/source/docs/reference/proxy-oci-image/) +- [`risk-budgets.md`](/__ardur_internal__/source/docs/reference/risk-budgets/) diff --git a/site/content/source/docs/reference/risk-budgets.md b/site/content/source/docs/reference/risk-budgets.md new file mode 100644 index 00000000..14027698 --- /dev/null +++ b/site/content/source/docs/reference/risk-budgets.md @@ -0,0 +1,299 @@ +--- +title: "Typed Dangerous-Action Risk Budgets" +description: "Ardur's optional `risk_budget` claim reserves signed impact ceilings before a configured dangerous tool may run." +source_path: "docs/reference/risk-budgets.md" +source_sha256: "eb8ad1def867eb8c2f9635c71ed04dc6101a08cf1ff5868eb78be896fb4232ab" +weight: 100 +maturity: ["public-now"] +claim_types: ["documentation"] +surfaces: ["docs"] +frameworks: ["framework-agnostic"] +evidence_levels: ["code-and-doc"] +--- + + + +{{< proof-status state="public" label="Source-backed mirror" source="docs/reference/risk-budgets.md" >}} +This page is generated from the public repository source file. Edit the source file, then run `python3 site/scripts/sync_source_docs.py` to refresh the Hugo mirror. +{{< /proof-status >}} + +Ardur's optional `risk_budget` claim reserves signed impact ceilings before a configured dangerous tool may run. + +A trusted tool contract derives typed facts from schema-validated arguments, +compares them with signed per-action caps, and atomically reserves additive +session, agent, and lineage ceilings. Passports without `risk_budget` keep the +existing behavior. + +This is an enforcement boundary for configured proxy/adaptor calls. It is not +automatic discovery of every side effect, a semantic-risk classifier, or +proof that the tool reported truthful arguments. + +## Decision flow + +```mermaid +flowchart LR + A["Authenticated tool schema and risk contract"] --> B["Validate arguments and derive typed facts"] + B --> C["Check signed per-action caps"] + C --> D["Atomically reserve session, agent, and lineage ceilings"] + D --> E["Run ordinary Ardur policy and approval checks"] + E --> F["PERMIT: adapter may dispatch"] + F --> G["Executor records committed or released"] + G --> H["Signed lifecycle receipt"] +``` + +The reservation happens before ordinary policy returns `PERMIT`. If ordinary +policy denies, Ardur releases the reservation because the external executor +has not started. If policy evaluation raises, Ardur retains the reservation: +an exception is not evidence that execution never began. Proxy-internal memory +tools execute during policy evaluation and therefore cannot be registered for +typed risk governance. + +## Trusted tool contract + +`ToolRiskContract.from_schema(tool_name, input_schema, risk_contract)` binds +the following RFC 8785-canonical object to a `sha256:` digest: + +```json +{ + "tool_name": "storage.delete_objects", + "input_schema": { + "type": "object", + "properties": { + "targets": {"type": "array", "items": {"type": "string"}}, + "bytes": {"type": "integer", "minimum": 0}, + "irreversibility": { + "type": "string", + "enum": ["reversible", "compensatable", "irreversible"] + } + }, + "required": ["targets", "bytes", "irreversibility"], + "additionalProperties": false + }, + "risk_contract": { + "version": 1, + "mandatory_facts": [ + "objects_affected", + "bytes_affected", + "irreversibility" + ], + "extractors": { + "objects_affected": {"kind": "array_length", "pointer": "/targets"}, + "bytes_affected": {"kind": "integer", "pointer": "/bytes"}, + "irreversibility": {"kind": "enum", "pointer": "/irreversibility"} + } + } +} +``` + +The extractor vocabulary is closed: + +| Kind | Source | Result | +|---|---|---| +| `integer` | RFC 6901 pointer | Exact non-negative integer; booleans and floats deny | +| `array_length` | RFC 6901 pointer | Non-negative array length | +| `enum` | RFC 6901 pointer | A value in the fact's versioned categorical order | +| `constant` | Contract value | A trusted numeric or categorical constant | + +Input schemas use JSON Schema 2020-12. Invalid schemas, external references, +oversized/deep schemas, malformed pointers, oversized arguments, missing fact +sources, and schema-invalid arguments fail closed. The registry rejects +replacement and freezes at proxy startup. Contracts retain canonical byte +snapshots internally; returned schema and extractor objects are detached views +that cannot mutate registered authority or its digest. + +MCP `inputSchema` is suitable contract input only after the server and tool +definition have been authenticated. MCP tool annotations are untrusted hints +unless the server itself is trusted, so Ardur does not use them as risk facts. +Prompt text, model-generated risk labels, network lookups, scanners, and tool +execution are also outside extraction. + +## Fact vocabulary + +Version 1 supports additive numeric facts: + +- `destructive_targets` +- `objects_affected` +- `bytes_affected` + +It also supports ordered categorical facts: + +- `secret_sensitivity`: `none`, `public`, `internal`, `confidential`, + `restricted`, `regulated`, `unknown` +- `destination_risk`: `local`, `private_network`, `trusted_service`, + `public_internet`, `untrusted`, `unknown` +- `filesystem_scope`: `none`, `declared`, `workspace`, `external`, `system`, + `unknown` +- `irreversibility`: `reversible`, `compensatable`, `irreversible`, `unknown` + +`unknown` facts deny a governed action. Numeric values are bounded to signed +64-bit non-negative integers. + +## Mission Passport claim + +```json +{ + "risk_budget": { + "version": 1, + "lineage_id": "", + "tools": { + "storage.delete_objects": { + "contract_digest": "sha256:<64 lowercase hex>", + "max_facts": { + "objects_affected": 10, + "bytes_affected": 1048576, + "irreversibility": "compensatable" + } + } + }, + "ceilings": { + "objects_affected": {"session": 20, "agent": 50, "lineage": 100}, + "bytes_affected": { + "session": 2097152, + "agent": 5242880, + "lineage": 10485760 + } + } + } +} +``` + +Every numeric fact used by a tool policy requires all three ceilings. Tool +entries must be a subset of `allowed_tools`. A root issuer fills an omitted +`lineage_id` with the new passport JTI. Child passports inherit the policy or +provide an explicit policy with the same lineage, a subset of tools, the same +contract digests and fact sets for retained tools, and caps/ceilings no greater +than their parent. Removing a tool also removes numeric ceilings referenced +only by that tool. A parent without `risk_budget` cannot introduce it in a child. + +The first governed call freezes a normalized risk-policy snapshot in the +persisted session. Later mission-policy refreshes may continue to affect other +authorization rules, but a changed or removed risk policy fails closed. Outcome +accounting uses the ceilings captured in the reservation, not a later policy +view. + +## Runtime API + +Create and freeze contracts before constructing the proxy: + +```python +registry = ToolRiskRegistry() +registry.register(contract) +proxy = GovernanceProxy(risk_registry=registry, ...) +``` + +Each governed invocation requires a unique executor-generated request ID: + +```python +decision, reason = proxy.evaluate_tool_call( + session, + "storage.delete_objects", + arguments, + risk_request_id=request_id, +) +``` + +The HTTP equivalent supplies `risk_request_id` to `POST /evaluate`. After a +`PERMIT`, report exactly one explicit outcome: + +```python +proxy.record_risk_outcome( + session, + risk_request_id=request_id, + outcome="committed", # or "released" +) +``` + +The HTTP equivalent is `POST /risk/outcome` with `session_id`, +`risk_request_id`, and `outcome`. `released` is valid only when the executor +did not start. If execution may have started, record `committed` even when the +tool later reports an error. A repeated active, committed, or released request +ID cannot receive another `PERMIT`. + +## Crash and lifecycle behavior + +The global file ledger stores only hashes of lineage, session, agent, request, +and fingerprint identities. One `flock`-protected, fsync-backed replacement +transaction updates every fact and scope across all lineages, so agent ceilings +cannot be spent independently in separate lineages. Ledger invariants require account +`reserved` totals to equal active plus quarantined reservations and account +`spent` totals to equal retained plus archived committed reservations. + +`quarantine_stale_risk_reservations(session, stale_after_s=...)` converts stale +active reservations for that session to `quarantined` without returning +authority. Quarantine and later explicit reconciliation produce separate +signed lifecycle receipts. A session cannot end or issue its final attestation +while active or quarantined reservations remain, or while a resolved lifecycle +receipt is still pending delivery. + +Quarantined reservations cannot be released; explicit reconciliation may only +commit them. After passport expiry and at least 24 hours in quarantine, pruning +conservatively archives them as spent. Expired committed/released records are +pruned only after their lifecycle receipt is delivered. Committed amounts move +to `archived_spent`, so pruning never restores spent authority. Every pruned +record leaves a request/fingerprint hash tombstone for at least one additional +hour, preventing compaction from re-permitting the same request during its +authorization lifetime. Both reservation and tombstone stores are bounded; +capacity exhaustion fails closed until maintenance advances retention. + +Outcome and quarantine transitions use a durable receipt outbox. The ledger +records a deterministic lifecycle ID, the session atomically persists the +signed receipt material, the receipt journal appends that receipt ID at most +once with `fsync`, and only then does the ledger mark delivery complete. A retry +after any intermediate crash resumes the same receipt rather than minting a +second chain entry. Ledger files and locks reject symlink substitution and use +private `0700`/`0600` modes. + +## Receipts, metrics, and privacy + +Action and lifecycle receipts include: + +- `measurements.risk_facts`: a SHA-256 digest of canonical typed facts; +- bounded `budget_remaining` keys such as `objects_affected.lineage`; and +- stable internal denial codes, with risk exhaustion mapped to the public + `budget_exhausted` class. + +They do not include raw targets, paths, URLs, secrets, facts, request IDs, or +ledger identity hashes. Lifecycle events are excluded from ordinary action +permit/denial counts and tool-scope checks. Prometheus metrics use only fixed +operation, outcome, fact, and reason labels. + +## Failure behavior + +| Condition | Decision | +|---|---| +| Missing/invalid request ID, policy, contract, fact, or ledger state | `INSUFFICIENT_EVIDENCE` | +| Per-action cap exceeded | `DENY` | +| Session/agent/lineage ceiling exhausted | `DENY` | +| Active or terminal request replay | `DENY` | +| Ordinary policy denies after reservation | Ordinary denial; reservation released | +| Policy evaluation raises after reservation | Exception propagated; reservation retained | +| Attempt to release a quarantined reservation | Reconciliation denied; authority retained | +| Unresolved action at session end | Session finalization denied | + +## Operability and cost + +The runtime performs no risk-classification network calls and adds no cloud +service charge by itself. Each governed action adds a local canonicalization, +JSON Schema validation, and fsync-backed reservation; each outcome adds another +ledger transaction. Mutations within one lineage serialize on one lock, so a +single very high-throughput lineage may need sharding at issuance. Metrics stay +bounded; receipt and ledger retention still consume local storage and should be +included in operational capacity planning. A prolonged receipt-sink failure +retains terminal outbox records and can deliberately stop new reservations at +the bounded capacity limit. + +## Protocol boundary and primary sources + +`risk_budget` is currently an Ardur Mission Passport/runtime extension. The +repository's existing DRP profile does not project or verify it; a DRP emitter +must fail closed rather than drop it. This change does not claim DRP, MCP, or +AAT interoperability for the extension. + +Primary references: + +- [RFC 8785: JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) +- [MCP tools specification, 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +- [OAuth Attenuating Agent Tokens, draft-01](https://datatracker.ietf.org/doc/draft-niyikiza-oauth-attenuating-agent-tokens/) +- [Delegation Receipt Protocol, draft-10](https://datatracker.ietf.org/doc/html/draft-nelson-agent-delegation-receipts-10) +- [Python `fcntl` locking](https://docs.python.org/3/library/fcntl.html) +- [Python atomic `os.replace`](https://docs.python.org/3/library/os.html#os.replace) diff --git a/site/content/source/docs/specs/ardur-drp-implementation-interop-v0.1.md b/site/content/source/docs/specs/ardur-drp-implementation-interop-v0.1.md index da977e93..a8b2dcf2 100644 --- a/site/content/source/docs/specs/ardur-drp-implementation-interop-v0.1.md +++ b/site/content/source/docs/specs/ardur-drp-implementation-interop-v0.1.md @@ -2,7 +2,7 @@ title: "Ardur DRP Implementation and Interoperability Note v0.1" description: "This note records the exact DRP behavior implemented and exercised by Ardur" source_path: "docs/specs/ardur-drp-implementation-interop-v0.1.md" -source_sha256: "f1a7ed28a4811bf0d98c15ffbb9062ffddf01e54ec3b1845f63ef1a51516b05b" +source_sha256: "a3e5fec9689d3fd719aa9ee953c7589c98708b3dc5a0a3fee0e907f8c8e924b7" weight: 100 maturity: ["public-now"] claim_types: ["protocol-spec"] @@ -65,6 +65,7 @@ conformance designation. | Strict scope attenuation | `supported` | Every child effective allowed-action set must be a strict proper subset; signed widening denies. | | No and bounded re-delegation | `extension` | `metadata.x-ardur.redelegation` carries mode, depth, and maximum depth because draft-10 does not serialize all of them. | | Resource, argument, cwd, and budget attenuation | `extension` | Critical Ardur fields are verified transitively and against the concrete requested action. Unknown critical fields deny. | +| Typed dangerous-action risk budgets | `not-yet` | The Python Mission Passport/runtime supports `risk_budget`, but this DRP emitter/verifier does not project its contract digest, typed facts, or multi-scope ledger. Input carrying the claim must fail closed; no DRP compatibility is claimed. | | Operator instruction commitment | `supported` | Signed text/hash are compared with current externally supplied instructions. | | Tool schema commitment | `partial` | A finite tool-universe digest is verified. Broader model/provider state attestation is not implemented here. | | Delegation-log policy | `partial` | Signed backend/subject policy and preverified inclusion facts are required. Raw RFC 3161 response parsing and trust validation are not implemented by this module. | diff --git a/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md b/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md index 5de25cf6..7621d8f1 100644 --- a/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md +++ b/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md @@ -2,7 +2,7 @@ title: "Ardur DRP Mapping Profile v0.1" description: "This document maps the current Ardur delegation and action-receipt surfaces to" source_path: "docs/specs/ardur-drp-mapping-v0.1.md" -source_sha256: "c675c0dfef6f10af6f2b0ed2a435fe5f92ae03b8e1bbfdf3b0b091ff0cecae26" +source_sha256: "24cd46ccfe61242e2dc30a60502e4aca3fdde43a63734afb4342d8a8d6de65a8" weight: 100 maturity: ["public-now"] claim_types: ["protocol-spec"] @@ -46,7 +46,8 @@ members: `authorization_details`, argument-constraint members, `mission_ref`, `reserved_budget_share`, and `lineage_budget_share`; 2. the JWT mission passport emitted by `python/vibap/passport.py`, including - child-lineage claims added by `derive_child_passport`; and + child-lineage claims added by `derive_child_passport` and the optional + runtime-only `risk_budget` extension; and 3. every top-level property in `docs/specs/execution-receipt-v0.2.schema.json`. @@ -93,6 +94,7 @@ transformations are: | `cnf.jwk` | `metadata.x-ardur.capabilityTokenRef.holderConfirmation.jwk` | The holder key is not the DRP receipt-signing key. | | depth and delegation policy | `metadata.x-ardur.redelegation` | DRP describes depth behavior but has no Authorization Object fields for mode, depth, or maximum depth. | | budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. | +| Python `risk_budget` | No projection in the current profile | The current emitter/verifier does not implement typed fact contracts or atomic session/agent/lineage risk accounting. An emitter presented with this claim MUST deny/fail closed instead of dropping it. A future profile may define a critical `metadata.x-ardur.riskBudget` extension. | | `mission_ref` | `metadata.x-ardur.missionRef` | DRP instruction commitment does not replace the governing Mission Declaration reference. | ### 3.1. Critical Extension Rule diff --git a/site/content/source/python/README.md b/site/content/source/python/README.md index 5b165392..64d8c7e5 100644 --- a/site/content/source/python/README.md +++ b/site/content/source/python/README.md @@ -2,7 +2,7 @@ title: "Ardur — Python Reference Implementation" description: "The public Python runtime for Ardur lives here: a runtime governance and evidence layer for AI agents that issues signed mission passports, enforces them at execution time, and rec" source_path: "python/README.md" -source_sha256: "0a48e07a21ab40fc4ca976c4f4fb2d003efb77730ee0d7238777256454843e65" +source_sha256: "0a4ebde09be93448f7944ebb9e61541967941d4e2c9c6d9fced4f1db063c4acf" weight: 100 maturity: ["public-now"] claim_types: ["runtime-boundary"] @@ -211,6 +211,7 @@ python/ │ ├── policy_backend.py # PolicyBackend protocol │ ├── proxy.py # Governance proxy + session lifecycle │ ├── receipt.py # Execution Receipt issuance + verify +│ ├── risk_budget.py # Typed impact contracts + atomic risk ledger │ ├── runtime_evidence.py # Offline normalized/Tetragon/Falco correlation │ └── ... └── tests/ # Curated runtime, adapter, security, and release tests @@ -218,6 +219,19 @@ python/ A couple of pinned dependencies worth flagging: `biscuit-python==0.4.0` (the Biscuit token format we use for delegated capabilities) and `spiffe>=0.2,<0.4` (workload identity). These pins are deliberate — both libraries have had breaking minor releases, so we hold them until we explicitly retest. +## Typed dangerous-action budgets + +Library callers can register authenticated `ToolRiskContract` definitions +before constructing `GovernanceProxy`. An optional signed `risk_budget` +Mission Passport claim then enforces typed per-action caps and atomic +session/agent/lineage ceilings before dispatch. Each governed call requires a +unique `risk_request_id`; after `PERMIT`, the executor must call +`record_risk_outcome(..., outcome="committed")` once execution may have +started, or use `outcome="released"` only when it never started. Unresolved or +quarantined reservations block session finalization. See the full +[risk-budget reference](/__ardur_internal__/source/docs/reference/risk-budgets/) for schemas, +failure behavior, privacy, and cost boundaries. + Library deployments that enable Biscuit JWT-SVID holder binding configure a server-owned Biscuit issuer key, `TrustBundle`, and expected audience on `GovernanceProxy`; clients present only `peer_jwt_svid`. Once configured, the diff --git a/site/data/source_routes.json b/site/data/source_routes.json index f3ce5501..83be70b3 100644 --- a/site/data/source_routes.json +++ b/site/data/source_routes.json @@ -222,6 +222,7 @@ "docs/decisions/ADR-022-operator-telemetry-spiffe-mtls.md": "source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/", "docs/decisions/ADR-023-explicit-resource-scope-authority.md": "source/docs/decisions/adr-023-explicit-resource-scope-authority/", "docs/decisions/ADR-024-self-asserted-owner-identity-assurance.md": "source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/", + "docs/decisions/ADR-026-typed-dangerous-action-risk-budgets.md": "source/docs/decisions/adr-026-typed-dangerous-action-risk-budgets/", "docs/decisions/README.md": "source/docs/decisions/readme/", "docs/demo/enforce-e2e.md": "source/docs/demo/enforce-e2e/", "docs/engineering-standards.md": "source/docs/engineering-standards/", @@ -243,6 +244,7 @@ "docs/reference/kernel-capture-daemon.md": "source/docs/reference/kernel-capture-daemon/", "docs/reference/personal-hub-api.md": "source/docs/reference/personal-hub-api/", "docs/reference/proxy-oci-image.md": "source/docs/reference/proxy-oci-image/", + "docs/reference/risk-budgets.md": "source/docs/reference/risk-budgets/", "docs/research/epic-b-performance-fp-budget.md": "source/docs/research/epic-b-performance-fp-budget/", "docs/research/epic-b-policy-selection.md": "source/docs/research/epic-b-policy-selection/", "docs/roadmap/epic-b-auto-detection-plan.md": "source/docs/roadmap/epic-b-auto-detection-plan/", diff --git a/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json b/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json index c4e12e06..1c37f570 100644 --- a/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json +++ b/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json @@ -25,7 +25,7 @@ }, "legacy_python_passport": { "contract": "python/vibap/passport.py", - "scope": "JWT mission-passport claims emitted by issue_passport plus derive_child_passport lineage extensions." + "scope": "JWT mission-passport claims emitted by issue_passport plus derive_child_passport lineage extensions, including explicit inventory of runtime-only claims that the current DRP profile must reject." }, "execution_receipt_v0.2": { "contract": "docs/specs/execution-receipt-v0.2.schema.json", @@ -96,6 +96,7 @@ "child_time_window_contained": "A child notBefore MUST be no earlier than its parent and child notAfter MUST be no later.", "widening_rejected": "Tools, resources, argument constraints, budgets, time, and delegation depth MUST never widen.", "unknown_critical_extension_rejected": "A verifier that does not understand every path listed in metadata.x-ardur.critical MUST DENY and MUST NOT downgrade to base DRP authorization.", + "unprojected_risk_budget_rejected": "A source credential carrying risk_budget MUST NOT be emitted or verified by this profile because typed contract and multi-scope ledger semantics are not implemented; dropping the claim is forbidden.", "tri_state_extension": "Ardur insufficient_evidence MUST project to DRP DENY while remaining distinguishable in the signed extension.", "no_redelegation": "redelegation.mode=none MUST prohibit creation of any sub-receipt.", "bounded_redelegation": "redelegation.mode=bounded MUST require depth below maxDepth and child maxDepth no greater than the parent.", @@ -521,6 +522,13 @@ "drp_path": "metadata.x-ardur.policy.additional", "rationale": "Policy engine references and digests remain critical extensions." }, + { + "source_surface": "legacy_python_passport", + "source_path": "risk_budget", + "classification": "out_of_scope", + "drp_path": null, + "rationale": "The Python runtime enforces typed contract digests and atomic session, agent, and lineage risk accounting, but the current DRP emitter/verifier does not. Input carrying this claim must fail closed rather than drop authority-narrowing semantics." + }, { "source_surface": "legacy_python_passport", "source_path": "cnf",