From f20cc7a83711f55cfed4f5162c58f3517189476f Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 17 May 2026 02:45:46 +0300 Subject: [PATCH] feat: v0.13.0 hot policy reload, OVERT Phase 3 IAP, named detectors, HTML dashboard Theme: operator surface + OVERT Phase 3 path. Four additions that close the most legible competitive gaps without diluting the kernel position. Hot policy reload. PolicyController owns the live Policy and runs registered listeners under a write lock on reload. AdaptiveScorer .apply_policy rebinds thresholds and sequence patterns atomically under the scorer's own RLock; an evaluate() in flight either sees the old (allow, deny) pair or the new one, never a torn half. Conformal calibration, MWU expert state, and agent profiles are preserved across reloads. Malformed reloads are rejected with the previous policy left live. POST /v1/policy/reload accepts a server-side path or an inline body; vaara serve --policy PATH enables the endpoint; vaara policy reload POLICY_PATH triggers it over HTTP. OVERT 1.0 AAL-4 Phase 3 IAP reference. vaara.attestation.iap ships a Phase3Attestation that wraps a Vaara BaseEnvelope with a notary Ed25519 signature over canonical CBOR of all nine envelope fields (inner Arbiter signature bound by reference) plus a transparency-log inclusion proof. Structural independence between the Arbiter key and the notary key is enforced at both emit and verify. InProcessTransparencyLog is an RFC 6962-style binary Merkle log with domain-separated leaf and internal hashes; append, inclusion_proof, and root_hash match the shape a sigstore Rekor adapter would expose, so a production deployment can swap in Rekor at the same call sites. Named injection + PII detector aliases. vaara.detect.detect_injection routes free text through the same AdversarialClassifier behind vaara-bench-v1's published numbers (heuristic fallback when the ml extra is absent; the backend field reports which path served). vaara.detect.detect_pii is a zero-dependency regex extractor over email, phone, US SSN, IPv4, credit_card (Luhn-checked), and IBAN (mod-97 checksum). POST /v1/detect/injection and POST /v1/detect/pii mirror the CLI. vaara detect injection and vaara detect pii read text from --text, --file, or --stdin and exit non-zero when the detector fires. Static HTML article-coverage dashboard. vaara.compliance.dashboard.render_html produces a single self-contained HTML page with embedded CSS, no JavaScript, no external assets, no network calls. Same content as render_markdown (system metadata, audit-trail integrity, summary, critical gaps, per-domain article tables, per-article detail) with status badges as colored pills and a print-friendly stylesheet. vaara compliance dashboard --db PATH --out PATH writes the page. docs/openapi.yaml adds /v1/policy/reload, /v1/detect/injection, and /v1/detect/pii with full request and response schemas. COMPLIANCE.md TOOL-1.4 and TOOL-5 rows updated to reflect that Phase 3 is now in tree; the AAL-4 requirement that the notary keys live with an independent operator is preserved. 586 tests pass, 12 skipped. --- CHANGELOG.md | 66 ++++++ COMPLIANCE.md | 33 ++- README.md | 6 +- docs/openapi.yaml | 182 +++++++++++++++ pyproject.toml | 2 +- src/vaara/__init__.py | 2 +- src/vaara/attestation/__init__.py | 24 ++ src/vaara/attestation/iap.py | 264 +++++++++++++++++++++ src/vaara/attestation/transparency_log.py | 196 ++++++++++++++++ src/vaara/cli.py | 268 +++++++++++++++++++++- src/vaara/compliance/dashboard.py | 190 +++++++++++++++ src/vaara/detect/__init__.py | 31 +++ src/vaara/detect/injection.py | 135 +++++++++++ src/vaara/detect/pii.py | 135 +++++++++++ src/vaara/policy/__init__.py | 3 + src/vaara/policy/controller.py | 118 ++++++++++ src/vaara/scorer/adaptive.py | 32 ++- src/vaara/server/app.py | 10 +- src/vaara/server/routes.py | 59 +++++ src/vaara/server/schemas.py | 50 ++++ src/vaara/server/state.py | 7 +- tests/test_attestation_iap.py | 212 +++++++++++++++++ tests/test_compliance_dashboard.py | 131 +++++++++++ tests/test_detect.py | 165 +++++++++++++ tests/test_policy_controller.py | 167 ++++++++++++++ tests/test_policy_reload_http.py | 136 +++++++++++ 26 files changed, 2608 insertions(+), 16 deletions(-) create mode 100644 src/vaara/attestation/iap.py create mode 100644 src/vaara/attestation/transparency_log.py create mode 100644 src/vaara/compliance/dashboard.py create mode 100644 src/vaara/detect/__init__.py create mode 100644 src/vaara/detect/injection.py create mode 100644 src/vaara/detect/pii.py create mode 100644 src/vaara/policy/controller.py create mode 100644 tests/test_attestation_iap.py create mode 100644 tests/test_compliance_dashboard.py create mode 100644 tests/test_detect.py create mode 100644 tests/test_policy_controller.py create mode 100644 tests/test_policy_reload_http.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0184a..453a65b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,72 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.13.0] - 2026-05-17 + +**Theme: operator surface + OVERT Phase 3 path.** Four additions that +close the most legible competitive gaps without diluting the kernel +position. Hot policy reload meets the Galileo Agent Control selling +point on its own ground. The OVERT 1.0 Phase 3 Independent Attestation +Provider (IAP) reference closes the AAL-3 → AAL-4 promotion path that +v0.11.0's Provisional Receipt opens, so Vaara owns the full path +without forcing dependence on an external IAP vendor. Named injection +and PII detectors expose existing scoring surface under buyer-visible +labels. A static HTML article-coverage dashboard adds the auditor- +facing visual artefact that the peer set has converged on. + +### Added +- **Hot policy reload.** New `vaara.policy.controller.PolicyController` + owns the live `Policy` and runs registered listeners under a write + lock on `reload()`. `AdaptiveScorer.apply_policy(policy)` rebinds + thresholds and sequence patterns atomically under the scorer's own + RLock; an `evaluate()` call in flight on another thread either sees + the old `(allow, deny)` pair or the new one, never a torn half. + Conformal calibration, MWU expert state, and agent profiles are + preserved across reloads. Malformed reloads are rejected with the + previous policy left live. `POST /v1/policy/reload` accepts a + server-side path or an inline body; `vaara serve --policy PATH` + enables the endpoint; `vaara policy reload POLICY_PATH` triggers + reload over HTTP from the operator's shell. +- **OVERT 1.0 AAL-4 Phase 3 IAP reference.** New + `vaara.attestation.iap` ships a `Phase3Attestation` dataclass that + wraps a Vaara `BaseEnvelope` with a notary Ed25519 signature (over a + domain-separated prefix + canonical-CBOR of the inner envelope + including its signature) and a transparency-log inclusion proof. + Structural independence between the Arbiter key and the notary key + is enforced at both emit and verify. New + `vaara.attestation.transparency_log.InProcessTransparencyLog` + implements an RFC 6962-style binary Merkle tree with domain- + separated leaf and internal hashes; `append()` / + `inclusion_proof()` / `root_hash` match the shape a sigstore Rekor + adapter would expose, so a production deployment can swap in Rekor + at the same call sites without changing the IAP contract. +- **Named injection + PII detector aliases.** `vaara.detect.detect_injection` + routes free text through the same AdversarialClassifier behind + vaara-bench-v1's published numbers (heuristic fallback when the ml + extra is absent; the `backend` field reports which path served the + call). `vaara.detect.detect_pii` is a zero-dependency regex extractor + over six categories — email, phone, US SSN, IPv4, credit_card + (Luhn-checked), IBAN (mod-97 checksum). `POST /v1/detect/injection` + and `POST /v1/detect/pii` mirror the CLI. `vaara detect injection` + and `vaara detect pii` read text from `--text`, `--file`, or + `--stdin` and exit non-zero when the detector fires. +- **Static HTML article-coverage dashboard.** + `vaara.compliance.dashboard.render_html` produces a single + self-contained HTML page with embedded CSS, no JavaScript, no + external assets, no network calls. Same content as the Markdown + renderer (system metadata, audit-trail integrity, summary, critical + gaps, per-domain article tables, detailed per-article sections) with + status badges as colored pills and a print-friendly stylesheet. + `vaara compliance dashboard --db PATH --out PATH` writes the page; + a trailing slash or existing directory drops `index.html` inside. +- **OpenAPI spec coverage.** `docs/openapi.yaml` adds + `/v1/detect/injection`, `/v1/detect/pii`, and `/v1/policy/reload` + with full request and response schemas. The spec remains the + authoritative integration surface. +- 53 new tests (14 PolicyController + reload HTTP; 11 IAP + + transparency-log; 19 detect (injection + PII + HTTP); 9 HTML + dashboard). Total 586 passing, 12 skipped. + ## [0.12.0] - 2026-05-16 **Theme: agentic OVERT reference, published benchmark, product liability hook.** diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 83d2f92..64b7ccb 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -245,8 +245,19 @@ Vaara's position in this picture: change.** The hash chain, the commit-prove receipt pair, and the HTTP API surface all produce structured, signable artefacts that an IAP can co-sign, batch into a transparency log, or seal under - eIDAS qualified trust services. Future work: a documented IAP - adapter interface. + eIDAS qualified trust services. +- **Vaara ships a reference IAP from v0.13.0 + (`vaara.attestation.iap`).** `Phase3Attestation` wraps an AAL-3 + `BaseEnvelope` with a notary Ed25519 signature over canonical CBOR + of all nine envelope fields (the inner Arbiter signature bound by + reference) plus a transparency-log inclusion proof. Structural + independence between the Arbiter key and the notary key is + enforced at both emit and verify. `InProcessTransparencyLog` is an + RFC 6962-style binary Merkle log; production deployments swap in + sigstore Rekor or an equivalent independently-operated log at the + same call sites. Operators MAY run the reference IAP themselves; + an *independent* IAP for AAL-4 still requires a separate operator + controlling the notary keys. This positioning is deliberate. Vaara does not claim AAL-4 conformance and does not market a self-attestation pattern. @@ -279,10 +290,15 @@ correspondence. type) - ✅. Denials emit a `DENY` event on the hash chain with policy id and violation reason. - **TOOL-1.4** (provisional receipt before execution, upgrade to full - attestation after notary validation) - ◐ at AAL-3. The Article 12 - commit-prove receipt pair (shipped v0.10.0) is the Phase 2 - Provisional Receipt. Phase 3 (full notary attestation) requires an - external IAP per the OVERT-position section above. + attestation after notary validation) - ✅ structurally at AAL-3, + with the AAL-3 → AAL-4 path now implementable in-tree. The Article + 12 commit-prove receipt pair (shipped v0.10.0) is the Phase 2 + Provisional Receipt; the v0.11.0 OVERT Base Envelope is the + attested form. v0.13.0 ships a reference Phase 3 IAP + (`vaara.attestation.iap.emit_phase3_attestation`) that notary-signs + the Provisional Receipt and anchors it in a transparency log. + Reaching AAL-4 still requires the notary keys to live with an + independent operator. - **TOOL-2.1** (explicit function allowlist with hash in policy attestation) - ✅. Policy hash flows into `encoder_binary_identity` in the Base Envelope (v0.11.0). @@ -310,7 +326,10 @@ correspondence. - **TOOL-5** (tamper-evident tool-call log with epoch attestation) - ✅ for TOOL-5.1 and TOOL-5.2 (hash-chained `AuditTrail`, Article 12 commit-prove receipt pair). TOOL-5.3 epoch notary - attestation is the external-IAP layer. + attestation is satisfied by the v0.13.0 reference IAP + (`vaara.attestation.iap`) paired with the in-process transparency + log; a sigstore Rekor-backed log can substitute at the same call + sites. ### Section 11.5 - MCP Server Trust Governance diff --git a/README.md b/README.md index db5edcc..1453b59 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ curl -sX POST http://localhost:8000/v1/score \ The contract is in [docs/openapi.yaml](docs/openapi.yaml). Vaara defines the interface. Control-plane and orchestration vendors call it. Integration recipes for adopters live under `examples/recipes/`. +v0.13.0 adds three operator-facing endpoints. `POST /v1/policy/reload` atomically swaps the running policy without restarting the agent process (start with `vaara serve --policy PATH` to enable; in-flight requests keep the old thresholds, the next request sees the new ones). `POST /v1/detect/injection` and `POST /v1/detect/pii` expose Vaara's adversarial scorer and a zero-dependency PII extractor as named buyer-visible endpoints; the corresponding `vaara detect injection` and `vaara detect pii` CLI subcommands exit non-zero when the detector fires, so they slot into CI gates. `vaara compliance dashboard --db PATH --out site/` renders a single-file static HTML article-coverage page from the same evidence model as `vaara compliance report`. + ## OVERT 1.0 attestation -Vaara implements the OVERT 1.0 ([overt.is](https://overt.is/)) Protocol Profile 1.0 Base Envelope. OVERT 1.0 is an open standard for runtime trust in AI systems, authored by Glacis Technologies and published 25 March 2026. Closed-schema 9-field structure at AAL-3 Phase 2 (Provisional Receipt), canonical CBOR (RFC 8949), Ed25519 signatures, HMAC-SHA256 keyed commitments, IEEE-754 float rejection. External Independent Attestation Providers can promote AAL-3 emission to AAL-4 by attaching Phase 3 notary signatures and transparency-log inclusion proofs. +Vaara implements the OVERT 1.0 ([overt.is](https://overt.is/)) Protocol Profile 1.0 Base Envelope. OVERT 1.0 is an open standard for runtime trust in AI systems, authored by Glacis Technologies and published 25 March 2026. Closed-schema 9-field structure at AAL-3 Phase 2 (Provisional Receipt), canonical CBOR (RFC 8949), Ed25519 signatures, HMAC-SHA256 keyed commitments, IEEE-754 float rejection. v0.13.0 adds a reference Phase 3 IAP (`vaara.attestation.iap`) that notary-signs the Provisional Receipt and anchors it in a transparency log; production deployments can swap in sigstore Rekor or an equivalent independently-operated log at the same call sites. ``` pip install 'vaara[attestation]' @@ -87,7 +89,7 @@ from vaara.attestation.overt import emit_base_envelope, make_request_commitment, envelope = emit_base_envelope( signing_key=key, request_commitment=make_request_commitment(payload, operator_key=op_key), - encoder_binary_identity=encoder_binary_identity(arbiter_version="vaara/0.12.0", policy_hash=ph), + encoder_binary_identity=encoder_binary_identity(arbiter_version="vaara/0.13.0", policy_hash=ph), non_content_metadata={"action_class": "tx.transfer", "decision": "escalate"}, monotonic_counter=42, arbiter_instance_identifier=uuid_bytes, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4b16aa6..7573893 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -166,6 +166,95 @@ paths: application/json: schema: { $ref: "#/components/schemas/VerifyResponse" } + /v1/detect/injection: + post: + tags: [detect] + summary: Score text for prompt-injection likelihood. + description: | + Routes through the Vaara adversarial scorer (the same model + behind vaara-bench-v1). Falls back to a small regex heuristic + set when the `ml` extra is not installed; the backend field + in the response reports which path served the call. + operationId: detectInjection + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DetectInjectionRequest" } + responses: + "200": + description: Detection result. + content: + application/json: + schema: { $ref: "#/components/schemas/DetectInjectionResponse" } + + /v1/detect/pii: + post: + tags: [detect] + summary: Scan text for PII. + description: | + Regex-based extractor over six categories: email, phone, ssn, + ipv4, credit_card (Luhn-checked), iban (checksum-checked). + Returns per-finding category, value, offset, and length so + callers can highlight or redact in place. + operationId: detectPII + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DetectPIIRequest" } + responses: + "200": + description: Detection result. + content: + application/json: + schema: { $ref: "#/components/schemas/DetectPIIResponse" } + + /v1/policy/reload: + post: + tags: [policy] + summary: Atomically reload the running policy. + description: | + Parse and validate a new policy document, then swap it in atomically. + The scorer's thresholds and sequence patterns rebind under its own + lock so an evaluate() call in flight on another thread either sees + the old pair or the new pair, never a torn half. Conformal + calibration, MWU expert state, and agent profiles are preserved. + + Exactly one of `path` (server-side file path) or `body` (parsed + policy document) must be supplied. The server must have been + started with `vaara serve --policy PATH` for this endpoint to be + available; otherwise it returns `409 policy_not_configured`. A + malformed document returns `422 policy_invalid` and leaves the + previously loaded policy in place. + operationId: reloadPolicy + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PolicyReloadRequest" } + responses: + "200": + description: Reload accepted, new policy active. + content: + application/json: + schema: { $ref: "#/components/schemas/PolicyReloadResponse" } + "400": + description: Request supplied both `path` and `body`, or neither. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "409": + description: Server has no PolicyController; reload is disabled. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": + description: Policy document failed to parse or validate. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + /v1/server: get: tags: [server] @@ -416,6 +505,99 @@ components: threshold_deny: { type: number, format: float } alpha: { type: number, format: float } + DetectInjectionRequest: + type: object + required: [text] + properties: + text: { type: string, maxLength: 100000 } + threshold: + type: number + format: float + minimum: 0 + maximum: 1 + nullable: true + + DetectInjectionResponse: + type: object + required: [detected, score, threshold, bundle_version, backend] + properties: + detected: { type: boolean } + score: { type: number, format: float, minimum: 0, maximum: 1 } + threshold: { type: number, format: float, minimum: 0, maximum: 1 } + bundle_version: { type: string } + backend: + type: string + enum: [vaara_adversarial, heuristic] + + DetectPIIRequest: + type: object + required: [text] + properties: + text: { type: string, maxLength: 100000 } + + DetectPIIFinding: + type: object + required: [category, value, offset, length] + properties: + category: + type: string + enum: [email, phone, ssn, ipv4, credit_card, iban] + value: { type: string } + offset: { type: integer, minimum: 0 } + length: { type: integer, minimum: 1 } + + DetectPIIResponse: + type: object + required: [detected, categories, findings] + properties: + detected: { type: boolean } + categories: + type: array + items: { type: string } + findings: + type: array + items: { $ref: "#/components/schemas/DetectPIIFinding" } + + PolicyReloadRequest: + type: object + description: | + Exactly one of `path` or `body` must be supplied. `path` lets the + server read the policy file directly; `body` carries an inline + parsed document for cases where the operator runs on a different + host than the server. + properties: + path: { type: string, maxLength: 4096 } + body: + type: object + additionalProperties: true + format: + type: string + enum: [json, yaml] + nullable: true + + PolicyReloadResponse: + type: object + required: + - version + - thresholds_default + - sequence_count + - action_class_count + - escalation_route_count + properties: + version: + type: integer + minimum: 1 + description: Monotonic generation counter; increments on every accepted reload. + thresholds_default: + type: object + required: [escalate, deny] + properties: + escalate: { type: number, format: float, minimum: 0, maximum: 1 } + deny: { type: number, format: float, minimum: 0, maximum: 1 } + sequence_count: { type: integer, minimum: 0 } + action_class_count: { type: integer, minimum: 0 } + escalation_route_count: { type: integer, minimum: 0 } + Error: type: object required: [error] diff --git a/pyproject.toml b/pyproject.toml index d85ce27..bba44ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.12.0" +version = "0.13.0" description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 373769f..7207e5f 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.12.0" +__version__ = "0.13.0" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/attestation/__init__.py b/src/vaara/attestation/__init__.py index 2e0d500..3ae43d0 100644 --- a/src/vaara/attestation/__init__.py +++ b/src/vaara/attestation/__init__.py @@ -36,19 +36,43 @@ regularized_incomplete_beta, verify_s3p_attestation, ) +from vaara.attestation.iap import ( + IAPError, + Phase3Attestation, + emit_phase3_attestation, + envelope_to_canonical_cbor, + verify_phase3_attestation, +) +from vaara.attestation.transparency_log import ( + InProcessTransparencyLog, + InclusionProof, + LogEntry, + TransparencyLogError, + verify_inclusion, +) __all__ = [ "BaseEnvelope", "ConformalExtension", "EnvelopeError", + "IAPError", + "InProcessTransparencyLog", + "InclusionProof", + "LogEntry", + "Phase3Attestation", "S3PAttestation", "S3PError", + "TransparencyLogError", "canonical_cbor", "clopper_pearson_ci", "emit_base_envelope", + "emit_phase3_attestation", "emit_s3p_attestation", + "envelope_to_canonical_cbor", "make_epoch_nonce_commitment", "regularized_incomplete_beta", "verify_base_envelope", + "verify_inclusion", + "verify_phase3_attestation", "verify_s3p_attestation", ] diff --git a/src/vaara/attestation/iap.py b/src/vaara/attestation/iap.py new file mode 100644 index 0000000..909b0f1 --- /dev/null +++ b/src/vaara/attestation/iap.py @@ -0,0 +1,264 @@ +"""OVERT Phase 3 Independent Attestation Provider (IAP) reference. + +A Vaara AAL-3 ``BaseEnvelope`` is a Provisional Receipt signed by the +Arbiter. Reaching AAL-4 requires Phase 3: an independent notary signs +over the envelope and the resulting attestation is anchored in a +transparency log so it cannot be silently retracted. + +This module ships a reference IAP that runs end-to-end without a hard +dependency on an external transparency log. The signing surface and the +log adapter shape are kept narrow so a production deployment can swap in +sigstore Rekor (or equivalent) at the same call sites. + +Structural independence is enforced: the notary key identifier must +differ from the arbiter key identifier carried inside the AAL-3 +envelope. Any attestation where the two collide is rejected at both +emission and verification. + +CBOR canonicalisation, domain-separated hashing, and Ed25519 signing +mirror the AAL-3 emitter so an OVERT-aware verifier can reconstruct +every signed payload offline. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Any, Optional + +from vaara.attestation.overt import ( + BaseEnvelope, + EnvelopeError, + canonical_cbor, + _canonical_signing_payload, + _legacy_raw, + _sha256, +) +from vaara.attestation.transparency_log import ( + InProcessTransparencyLog, + InclusionProof, + LogEntry, + verify_inclusion, +) + + +class IAPError(RuntimeError): + """Raised when Phase 3 attestation emission or verification fails.""" + + +_NOTARY_SIGNING_PREFIX = b"vaara/overt-pp1/iap-notary/v1\x00" + + +@dataclass(frozen=True) +class Phase3Attestation: + """OVERT 1.0 AAL-4 Phase 3 attestation wrapping an AAL-3 envelope.""" + + envelope_cbor: bytes + notary_signature: bytes + notary_key_identifier: bytes + log_index: int + log_tree_size: int + log_root_at_append: bytes + inclusion_proof_siblings: tuple[bytes, ...] + iap_identifier: str + attestation_timestamp_ns: int + + def to_dict(self) -> dict[str, Any]: + return { + "envelope_cbor": self.envelope_cbor.hex(), + "notary_signature": self.notary_signature.hex(), + "notary_key_identifier": self.notary_key_identifier.hex(), + "log_index": self.log_index, + "log_tree_size": self.log_tree_size, + "log_root_at_append": self.log_root_at_append.hex(), + "inclusion_proof_siblings": [ + s.hex() for s in self.inclusion_proof_siblings + ], + "iap_identifier": self.iap_identifier, + "attestation_timestamp_ns": self.attestation_timestamp_ns, + } + + +def envelope_to_canonical_cbor(envelope: BaseEnvelope) -> bytes: + """Canonical CBOR over all 9 envelope fields, including the signature. + + The Phase 3 notary signs over this byte string so the inner Arbiter + signature is bound by reference, and the same bytes are appended to + the transparency log as the leaf. + """ + full_payload = { + "blinded_identifier": envelope.blinded_identifier, + "request_commitment": envelope.request_commitment, + "encoder_binary_identity": envelope.encoder_binary_identity, + "non_content_metadata": envelope.non_content_metadata, + "monotonic_counter": int(envelope.monotonic_counter), + "nanosecond_timestamp": int(envelope.nanosecond_timestamp), + "key_identifier": envelope.key_identifier, + "arbiter_instance_identifier": envelope.arbiter_instance_identifier, + "signature": envelope.signature, + } + return canonical_cbor(full_payload) + + +def _notary_pubkey_identifier(signing_key) -> bytes: + pub = signing_key.public_key() + pub_raw = ( + pub.public_bytes_raw() + if hasattr(pub, "public_bytes_raw") + else _legacy_raw(pub) + ) + return _sha256(pub_raw) + + +def emit_phase3_attestation( + *, + envelope: BaseEnvelope, + notary_signing_key, + transparency_log: InProcessTransparencyLog, + iap_identifier: str, + attestation_timestamp_ns: Optional[int] = None, +) -> Phase3Attestation: + """Notary-sign an AAL-3 envelope and anchor it in the transparency log. + + Rejects the call if the notary key identifier matches the arbiter + key identifier — OVERT Phase 3 requires structural independence. + """ + try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) + except ImportError as exc: + raise EnvelopeError( + "cryptography not installed. Install with: pip install " + "'vaara[attestation]'" + ) from exc + if not isinstance(notary_signing_key, Ed25519PrivateKey): + raise IAPError("notary_signing_key must be an Ed25519PrivateKey") + + notary_key_id = _notary_pubkey_identifier(notary_signing_key) + if notary_key_id == envelope.key_identifier: + raise IAPError( + "notary key identifier equals arbiter key identifier; OVERT " + "Phase 3 requires structural independence between the arbiter " + "and the IAP. Generate the IAP keypair from a separate root." + ) + + envelope_cbor = envelope_to_canonical_cbor(envelope) + log_entry: LogEntry = transparency_log.append(envelope_cbor) + proof: InclusionProof = transparency_log.inclusion_proof(log_entry.log_index) + + signature = notary_signing_key.sign( + _NOTARY_SIGNING_PREFIX + envelope_cbor + ) + ts = ( + attestation_timestamp_ns + if attestation_timestamp_ns is not None + else time.time_ns() + ) + + return Phase3Attestation( + envelope_cbor=envelope_cbor, + notary_signature=signature, + notary_key_identifier=notary_key_id, + log_index=log_entry.log_index, + log_tree_size=log_entry.tree_size_at_append, + log_root_at_append=log_entry.root_hash_at_append, + inclusion_proof_siblings=proof.siblings, + iap_identifier=iap_identifier, + attestation_timestamp_ns=ts, + ) + + +def verify_phase3_attestation( + *, + attestation: Phase3Attestation, + notary_public_key_raw: bytes, + expected_log_root: Optional[bytes] = None, + arbiter_public_key_raw: Optional[bytes] = None, +) -> bool: + """Verify the notary signature, inclusion proof, and structural independence. + + Args: + attestation: The Phase 3 attestation to verify. + notary_public_key_raw: 32-byte raw Ed25519 public key of the + notary. Must match the attestation's ``notary_key_identifier``. + expected_log_root: When supplied, the inclusion proof is checked + against this root. Pass the root the verifier observed at + audit time, or pin ``attestation.log_root_at_append`` if + using the per-append root. + arbiter_public_key_raw: When supplied, the inner envelope's + Arbiter signature is also verified. + """ + try: + from cryptography.exceptions import ( + InvalidSignature, + UnsupportedAlgorithm, + ) + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PublicKey, + ) + except ImportError as exc: + raise EnvelopeError( + "cryptography not installed. Install with: pip install " + "'vaara[attestation]'" + ) from exc + + if _sha256(notary_public_key_raw) != attestation.notary_key_identifier: + return False + + try: + Ed25519PublicKey.from_public_bytes(notary_public_key_raw).verify( + attestation.notary_signature, + _NOTARY_SIGNING_PREFIX + attestation.envelope_cbor, + ) + except (InvalidSignature, ValueError, UnsupportedAlgorithm): + return False + + if expected_log_root is not None: + proof = InclusionProof( + log_index=attestation.log_index, + tree_size=attestation.log_tree_size, + siblings=attestation.inclusion_proof_siblings, + ) + if not verify_inclusion( + leaf_data=attestation.envelope_cbor, + proof=proof, + expected_root=expected_log_root, + ): + return False + + if arbiter_public_key_raw is not None: + try: + import cbor2 + except ImportError as exc: + raise EnvelopeError( + "cbor2 not installed. Install with: pip install " + "'vaara[attestation]'" + ) from exc + + decoded = cbor2.loads(attestation.envelope_cbor) + if not isinstance(decoded, dict): + return False + if _sha256(arbiter_public_key_raw) != decoded.get("key_identifier"): + return False + if decoded.get("key_identifier") == attestation.notary_key_identifier: + return False + + signing_payload = _canonical_signing_payload( + blinded_identifier=decoded["blinded_identifier"], + request_commitment=decoded["request_commitment"], + encoder_binary_identity=decoded["encoder_binary_identity"], + non_content_metadata=decoded["non_content_metadata"], + monotonic_counter=decoded["monotonic_counter"], + nanosecond_timestamp=decoded["nanosecond_timestamp"], + key_identifier=decoded["key_identifier"], + arbiter_instance_identifier=decoded["arbiter_instance_identifier"], + ) + try: + Ed25519PublicKey.from_public_bytes(arbiter_public_key_raw).verify( + decoded["signature"], signing_payload, + ) + except (InvalidSignature, ValueError, UnsupportedAlgorithm): + return False + + return True diff --git a/src/vaara/attestation/transparency_log.py b/src/vaara/attestation/transparency_log.py new file mode 100644 index 0000000..f2727e8 --- /dev/null +++ b/src/vaara/attestation/transparency_log.py @@ -0,0 +1,196 @@ +"""In-process Merkle transparency log for OVERT Phase 3 inclusion proofs. + +Reference log compatible with RFC 6962-style binary Merkle trees. The IAP +appends a leaf for each Phase 3 attestation it issues, then hands the +inclusion proof to the verifier alongside the signed envelope. The +verifier reconstructs the root from (leaf, log_index, proof_siblings) +and checks it against a published root hash. + +The log is in-process so the protocol stays demonstrable without a hard +dependency on an external transparency log such as sigstore Rekor. The +public surface (append → entry, inclusion_proof, root_hash) is shaped to +match what a Rekor-backed adapter would expose, so a future +``RekorTransparencyLog`` can drop into the same call site. + +Domain separators (`b"\\x00"` for leaves, `b"\\x01"` for internal nodes) +match RFC 6962 to prevent second-preimage attacks across the leaf / +internal boundary. +""" + +from __future__ import annotations + +import hashlib +import threading +from dataclasses import dataclass + + +class TransparencyLogError(RuntimeError): + """Raised when log operations fail (e.g. malformed entry, bad index).""" + + +@dataclass(frozen=True) +class LogEntry: + """One appended leaf in the transparency log. + + Attributes: + log_index: 0-based position of this leaf. + leaf_hash: SHA-256(0x00 || leaf_bytes). + root_hash_at_append: Merkle root hash after this append. + tree_size_at_append: Total leaves in the tree after this append. + """ + + log_index: int + leaf_hash: bytes + root_hash_at_append: bytes + tree_size_at_append: int + + +@dataclass(frozen=True) +class InclusionProof: + """RFC 6962-style inclusion proof. + + The verifier recomputes the root by hashing ``leaf_hash`` with the + sibling hashes along the Merkle path. ``log_index`` and ``tree_size`` + determine the direction (left or right) at each level. + + The proof is verified against ``root_hash`` published independently + by the log operator. If the IAP is the log operator (the reference + case), the verifier must obtain the root hash from a separate + audit-time fetch or pinned configuration; the proof alone does not + bind to a specific root. + """ + + log_index: int + tree_size: int + siblings: tuple[bytes, ...] + + +def _hash_leaf(data: bytes) -> bytes: + return hashlib.sha256(b"\x00" + data).digest() + + +def _hash_node(left: bytes, right: bytes) -> bytes: + return hashlib.sha256(b"\x01" + left + right).digest() + + +def _root_from_leaves(leaves: list[bytes]) -> bytes: + """Compute the RFC 6962 root from a list of leaf hashes.""" + if not leaves: + return hashlib.sha256(b"").digest() + nodes = list(leaves) + while len(nodes) > 1: + next_level: list[bytes] = [] + for i in range(0, len(nodes), 2): + if i + 1 < len(nodes): + next_level.append(_hash_node(nodes[i], nodes[i + 1])) + else: + next_level.append(nodes[i]) + nodes = next_level + return nodes[0] + + +def _proof_for(leaves: list[bytes], index: int) -> tuple[bytes, ...]: + """Sibling hashes for ``leaves[index]`` in the current tree.""" + if not (0 <= index < len(leaves)): + raise TransparencyLogError( + f"index {index} out of range for tree size {len(leaves)}" + ) + proof: list[bytes] = [] + nodes = list(leaves) + idx = index + while len(nodes) > 1: + sibling = idx ^ 1 + if sibling < len(nodes): + proof.append(nodes[sibling]) + # Else this node has no sibling at this level; promoted unchanged. + next_level: list[bytes] = [] + for i in range(0, len(nodes), 2): + if i + 1 < len(nodes): + next_level.append(_hash_node(nodes[i], nodes[i + 1])) + else: + next_level.append(nodes[i]) + nodes = next_level + idx //= 2 + return tuple(proof) + + +def verify_inclusion( + *, + leaf_data: bytes, + proof: InclusionProof, + expected_root: bytes, +) -> bool: + """Recompute the root from leaf_data + proof and compare.""" + if not (0 <= proof.log_index < proof.tree_size): + return False + node = _hash_leaf(leaf_data) + idx = proof.log_index + size = proof.tree_size + sib_iter = iter(proof.siblings) + while size > 1: + last_in_level = size - 1 + if idx == last_in_level and idx % 2 == 0: + # Unpaired right edge: promoted without consuming a sibling. + pass + else: + try: + sibling = next(sib_iter) + except StopIteration: + return False + if idx % 2 == 0: + node = _hash_node(node, sibling) + else: + node = _hash_node(sibling, node) + idx //= 2 + size = (size + 1) // 2 + # All proof entries must be consumed. + if next(sib_iter, None) is not None: + return False + return node == expected_root + + +class InProcessTransparencyLog: + """Append-only RFC 6962-style Merkle log held in memory. + + Thread-safe via an internal lock. The log is the reference operator + for Vaara's IAP demonstrations; a production deployment would back + the same interface with sigstore Rekor or an equivalent + independently-operated log. + """ + + def __init__(self) -> None: + self._leaves: list[bytes] = [] + self._lock = threading.Lock() + + def append(self, leaf_data: bytes) -> LogEntry: + if not isinstance(leaf_data, (bytes, bytearray)): + raise TransparencyLogError("leaf_data must be bytes") + leaf_hash = _hash_leaf(bytes(leaf_data)) + with self._lock: + self._leaves.append(leaf_hash) + idx = len(self._leaves) - 1 + root = _root_from_leaves(self._leaves) + return LogEntry( + log_index=idx, + leaf_hash=leaf_hash, + root_hash_at_append=root, + tree_size_at_append=len(self._leaves), + ) + + def inclusion_proof(self, log_index: int) -> InclusionProof: + with self._lock: + size = len(self._leaves) + siblings = _proof_for(list(self._leaves), log_index) + return InclusionProof( + log_index=log_index, tree_size=size, siblings=siblings, + ) + + @property + def root_hash(self) -> bytes: + with self._lock: + return _root_from_leaves(self._leaves) + + @property + def tree_size(self) -> int: + with self._lock: + return len(self._leaves) diff --git a/src/vaara/cli.py b/src/vaara/cli.py index 8726563..17bd895 100644 --- a/src/vaara/cli.py +++ b/src/vaara/cli.py @@ -58,6 +58,11 @@ matched_sequences, and an expected verdict / route. Exit 0 if every case passes. + vaara policy reload POLICY_PATH [--server URL] [--inline] [--format json|yaml] + Trigger an atomic policy reload on a running ``vaara serve`` + process. The new thresholds and sequence patterns take effect + on the next ``evaluate()``; in-flight calls keep the old ones. + vaara version Print the installed Vaara version. @@ -75,6 +80,7 @@ import sys import time from pathlib import Path +from typing import Optional from vaara import __version__ @@ -589,6 +595,34 @@ def _cmd_trail_receipt(args: argparse.Namespace) -> int: return 0 +def _cmd_compliance_dashboard(args: argparse.Namespace) -> int: + from vaara.audit.sqlite_backend import SQLiteAuditTrail + from vaara.compliance.dashboard import render_html + from vaara.compliance.engine import ComplianceEngine + + db_path = Path(args.db).expanduser() + if not db_path.is_file(): + print(f"vaara compliance dashboard: not a file: {db_path}", file=sys.stderr) + return 2 + + trail = SQLiteAuditTrail(str(db_path)) + engine = ComplianceEngine() + report = engine.assess( + trail=trail, + system_name=args.system_name, + system_version=args.system_version, + ) + out = Path(args.out).expanduser() + if out.is_dir() or args.out.endswith("/"): + out.mkdir(parents=True, exist_ok=True) + out = out / "index.html" + else: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(render_html(report), encoding="utf-8") + print(str(out)) + return 0 + + def _cmd_compliance_report(args: argparse.Namespace) -> int: from vaara.audit.sqlite_backend import SQLiteAuditBackend from vaara.compliance.engine import create_default_engine @@ -635,6 +669,36 @@ def _cmd_compliance_report(args: argparse.Namespace) -> int: return 0 +def _read_text_input(args: argparse.Namespace) -> str: + if args.text is not None: + return args.text + if args.file is not None: + return Path(args.file).expanduser().read_text(encoding="utf-8") + if args.stdin: + return sys.stdin.read() + raise SystemExit( + "vaara detect: supply --text, --file PATH, or --stdin" + ) + + +def _cmd_detect_injection(args: argparse.Namespace) -> int: + from vaara.detect import detect_injection + + text = _read_text_input(args) + result = detect_injection(text, threshold=args.threshold) + print(json.dumps(result.to_dict(), indent=2 if args.pretty else None)) + return 1 if result.detected else 0 + + +def _cmd_detect_pii(args: argparse.Namespace) -> int: + from vaara.detect import detect_pii + + text = _read_text_input(args) + result = detect_pii(text) + print(json.dumps(result.to_dict(), indent=2 if args.pretty else None)) + return 1 if result.detected else 0 + + def _cmd_serve(args: argparse.Namespace) -> int: try: import uvicorn @@ -648,11 +712,98 @@ def _cmd_serve(args: argparse.Namespace) -> int: from vaara.server import create_app - app = create_app() + controller = None + policy_path = getattr(args, "policy", None) + if policy_path: + from vaara.policy.controller import PolicyController + from vaara.policy.validate import validate_source + + policy_obj, report = validate_source(Path(policy_path).expanduser()) + if policy_obj is None: + print( + f"vaara serve: policy {policy_path} failed to parse:", + file=sys.stderr, + ) + for issue in report.issues: + print(f" {issue.level.value}: {issue.message}", file=sys.stderr) + return 2 + controller = PolicyController(policy_obj) + + app = create_app(policy_controller=controller) uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level) return 0 +def _cmd_policy_reload(args: argparse.Namespace) -> int: + import json as _json + import urllib.error + import urllib.request + + policy_path = Path(args.policy).expanduser().resolve() + if not policy_path.is_file(): + print(f"vaara policy reload: not a file: {policy_path}", file=sys.stderr) + return 2 + + fmt: Optional[str] = args.format + if fmt is None: + if policy_path.suffix in (".yaml", ".yml"): + fmt = "yaml" + elif policy_path.suffix == ".json": + fmt = "json" + + if args.inline: + # Send the parsed body so the server doesn't need filesystem access + # to the same path the operator is reading. + from vaara.policy.loader import from_dict as _from_dict # noqa: F401 + if fmt == "yaml": + try: + import yaml # type: ignore[import-untyped] + except ImportError: + print( + "vaara policy reload --inline on a YAML file needs the " + "yaml extra. Install with: pip install 'vaara[yaml]'", + file=sys.stderr, + ) + return 2 + body = yaml.safe_load(policy_path.read_text(encoding="utf-8")) + else: + body = _json.loads(policy_path.read_text(encoding="utf-8")) + payload = {"body": body} + if fmt is not None: + payload["format"] = fmt + else: + payload = {"path": str(policy_path)} + if fmt is not None: + payload["format"] = fmt + + url = args.server.rstrip("/") + "/v1/policy/reload" + req = urllib.request.Request( + url, + data=_json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=args.timeout) as resp: + body_bytes = resp.read() + except urllib.error.HTTPError as exc: + print(f"vaara policy reload: HTTP {exc.code}", file=sys.stderr) + try: + print(exc.read().decode("utf-8"), file=sys.stderr) + except Exception: + pass + return 1 + except urllib.error.URLError as exc: + print( + f"vaara policy reload: cannot reach {url}: {exc.reason}", + file=sys.stderr, + ) + return 1 + + print(body_bytes.decode("utf-8")) + return 0 + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="vaara", description="Vaara AI Agent Execution Layer") sub = p.add_subparsers(dest="cmd", required=True) @@ -835,7 +986,7 @@ def build_parser() -> argparse.ArgumentParser: pp_policy = sub.add_parser( "policy", - help="Policy artifact commands (validate, test)", + help="Policy artifact commands (validate, test, reload)", ) psub = pp_policy.add_subparsers(dest="policy_cmd", required=True) @@ -896,12 +1047,49 @@ def build_parser() -> argparse.ArgumentParser: ) pcrep.set_defaults(func=_cmd_compliance_report) + pcdash = csub.add_parser( + "dashboard", + help=( + "Render the article-level evidence report as a single " + "self-contained HTML page (auditor-facing static dashboard)" + ), + ) + pcdash.add_argument( + "--db", required=True, + help="Path to the audit SQLite DB to read evidence from", + ) + pcdash.add_argument( + "--out", required=True, + help=( + "Output path. A trailing slash or existing directory writes " + "index.html inside; otherwise the given path is the file." + ), + ) + pcdash.add_argument( + "--system-name", default="Vaara-governed AI system", + help="System name to include in the dashboard header", + ) + pcdash.add_argument( + "--system-version", default="unspecified", + help="System version to include in the dashboard header", + ) + pcdash.set_defaults(func=_cmd_compliance_dashboard) + pserve = sub.add_parser( "serve", help="Run the Vaara HTTP API reference server (requires vaara[server])", ) pserve.add_argument("--host", default="127.0.0.1", help="Bind host") pserve.add_argument("--port", type=int, default=8000, help="Bind port") + pserve.add_argument( + "--policy", + default=None, + help=( + "Path to a YAML or JSON policy file. Enables POST " + "/v1/policy/reload; the policy's default thresholds and " + "sequence patterns are applied to the scorer at startup." + ), + ) pserve.add_argument( "--log-level", default="info", @@ -909,6 +1097,82 @@ def build_parser() -> argparse.ArgumentParser: ) pserve.set_defaults(func=_cmd_serve) + pdetect = sub.add_parser( + "detect", + help="Named detectors (injection, pii) over Vaara's scoring surface", + ) + dsub = pdetect.add_subparsers(dest="detect_cmd", required=True) + + def _add_text_input_args(p_): + g = p_.add_mutually_exclusive_group(required=True) + g.add_argument("--text", help="Inline text to scan") + g.add_argument("--file", help="Path to a text file to scan") + g.add_argument( + "--stdin", action="store_true", + help="Read text from standard input", + ) + p_.add_argument( + "--pretty", action="store_true", + help="Pretty-print the JSON output", + ) + + pdinj = dsub.add_parser( + "injection", + help=( + "Score text for prompt-injection likelihood via Vaara's " + "adversarial scorer (the same model behind vaara-bench-v1)" + ), + ) + _add_text_input_args(pdinj) + pdinj.add_argument( + "--threshold", type=float, default=None, + help="Decision threshold (default 0.55, the bench escalation band)", + ) + pdinj.set_defaults(func=_cmd_detect_injection) + + pdpii = dsub.add_parser( + "pii", + help=( + "Scan text for PII categories (email, phone, ssn, ipv4, " + "credit_card, iban)" + ), + ) + _add_text_input_args(pdpii) + pdpii.set_defaults(func=_cmd_detect_pii) + + preload = psub.add_parser( + "reload", + help=( + "Trigger an atomic policy reload on a running Vaara server. " + "The new thresholds and sequence patterns are applied without " + "restarting the agent process." + ), + ) + preload.add_argument( + "policy", help="Path to the new YAML or JSON policy file", + ) + preload.add_argument( + "--server", default="http://127.0.0.1:8000", + help="Base URL of the running vaara serve process", + ) + preload.add_argument( + "--inline", action="store_true", + help=( + "Send the parsed policy body in the HTTP request instead of " + "asking the server to read the file. Use when the server runs " + "on a different host than the operator." + ), + ) + preload.add_argument( + "--format", choices=["json", "yaml"], default=None, + help="Override the policy format detection (default: by file suffix)", + ) + preload.add_argument( + "--timeout", type=float, default=10.0, + help="HTTP request timeout in seconds (default 10)", + ) + preload.set_defaults(func=_cmd_policy_reload) + return p diff --git a/src/vaara/compliance/dashboard.py b/src/vaara/compliance/dashboard.py new file mode 100644 index 0000000..eea3388 --- /dev/null +++ b/src/vaara/compliance/dashboard.py @@ -0,0 +1,190 @@ +"""Static HTML article-coverage dashboard renderer. + +Produces a single self-contained HTML page from a ``ConformityReport``. +Embedded CSS keeps the output one-file portable — the auditor or +compliance officer can email it, attach it to a regulator submission, or +open it offline. No JavaScript, no external assets, no network calls. + +The page lays out the same structure as ``render_markdown`` (system +metadata, audit-trail integrity, summary, critical gaps, per-domain +article tables, detailed per-article sections) with status badges +rendered as colored pills and a print-friendly stylesheet. +""" + +from __future__ import annotations + +import html +import time + +from vaara.compliance.engine import ( + ArticleEvidence, + ConformityReport, + EvidenceStatus, +) + + +_STATUS_CLASS = { + EvidenceStatus.EVIDENCE_SUFFICIENT: "ok", + EvidenceStatus.EVIDENCE_PARTIAL: "warn", + EvidenceStatus.EVIDENCE_INSUFFICIENT: "bad", + EvidenceStatus.NOT_APPLICABLE: "na", +} + +_STATUS_LABEL = { + EvidenceStatus.EVIDENCE_SUFFICIENT: "sufficient", + EvidenceStatus.EVIDENCE_PARTIAL: "partial", + EvidenceStatus.EVIDENCE_INSUFFICIENT: "insufficient", + EvidenceStatus.NOT_APPLICABLE: "not applicable", +} + +_CSS = """ +:root{--ok:#1f7a3a;--warn:#a86b00;--bad:#a32a2a;--na:#666;--bg:#fafafa; +--card:#fff;--border:#e0e0e0;--text:#1a1a1a;--muted:#666;} +*{box-sizing:border-box} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; +margin:0;padding:2rem 1rem;background:var(--bg);color:var(--text);line-height:1.5;} +.container{max-width:1100px;margin:0 auto;} +h1{margin-top:0;font-size:1.8rem} +h2{margin-top:2.5rem;font-size:1.3rem;border-bottom:1px solid var(--border);padding-bottom:0.4rem} +h3{margin-top:1.5rem;font-size:1.05rem} +.notice{background:#fff8e6;border-left:4px solid #d4a017;padding:0.75rem 1rem;margin:1rem 0;} +.card{background:var(--card);border:1px solid var(--border);border-radius:6px;padding:1rem 1.25rem;margin:0.75rem 0;} +.kv{display:grid;grid-template-columns:max-content 1fr;gap:0.25rem 1rem;margin:0.5rem 0;} +.kv dt{font-weight:600;color:var(--muted)} +table{width:100%;border-collapse:collapse;margin:0.5rem 0;} +th,td{text-align:left;padding:0.5rem 0.6rem;border-bottom:1px solid var(--border);font-size:0.95rem;} +th{background:#f3f3f3;font-weight:600} +.pill{display:inline-block;padding:0.1rem 0.55rem;border-radius:999px;font-size:0.85rem;font-weight:600;color:#fff;} +.pill.ok{background:var(--ok)} .pill.warn{background:var(--warn)} +.pill.bad{background:var(--bad)} .pill.na{background:var(--na)} +.chain-broken{color:var(--bad);font-weight:700} .chain-intact{color:var(--ok)} +.gaps li{color:var(--bad)} .recs li{color:var(--warn)} +code{background:#f0f0f0;padding:0.05rem 0.3rem;border-radius:3px;font-size:0.9em;} +@media print{body{background:#fff;padding:0}.card{break-inside:avoid}} +""".strip() + + +def _esc(value) -> str: + return html.escape(str(value), quote=True) + + +def _pill(status: EvidenceStatus) -> str: + cls = _STATUS_CLASS.get(status, "na") + label = _STATUS_LABEL.get(status, status.value) + return f'{_esc(label)}' + + +def _article_table_row(art: ArticleEvidence) -> str: + return ( + "
{_esc(art.requirement.article)}{_esc(art.strength.value)}{_esc(art.strength.value)}{_esc(art.requirement.description)}') + if art.gaps: + out.append('Gaps
{_esc(rid)}{_esc(report.summary or '(no summary)')}
") + + if report.critical_gaps: + parts.append('| Article | Title | Status | " + "Strength | Records |
|---|
Generated by Vaara. Article-level evidence is collected " + "from the runtime audit trail; the deployer owns conformity " + "determination.