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 ( + "" + f"{_esc(art.requirement.article)}" + f"{_esc(art.requirement.title)}" + f"{_pill(art.status)}" + f"{_esc(art.strength.value)}" + f"{art.evidence_count}" + "" + ) + + +def _article_detail(art: ArticleEvidence, domain: str) -> str: + out: list[str] = [ + '
', + f"

{_esc(domain.upper())} {_esc(art.requirement.article)} — ", + f"{_esc(art.requirement.title)}

", + '
', + f"
Status
{_pill(art.status)}
", + f"
Strength
{_esc(art.strength.value)}
", + f"
Evidence records
{art.evidence_count}
", + ] + if art.evidence_count > 0: + fresh = art.freshest_evidence_age_hours + old = art.oldest_evidence_age_hours + if fresh is not None and fresh != float("inf"): + out.append(f"
Freshest evidence
{fresh:.1f} hours ago
") + if old is not None and old != float("inf"): + out.append(f"
Oldest evidence
{old:.1f} hours ago
") + out.append("
") + if art.requirement.description: + out.append(f'
{_esc(art.requirement.description)}
') + if art.gaps: + out.append('Gaps") + if art.recommendations: + out.append('Recommendations") + if art.sample_record_ids: + out.append("Sample audit record IDs") + out.append("
") + return "".join(out) + + +def render_html(report: ConformityReport) -> str: + """Render the ConformityReport as a single self-contained HTML page.""" + ts = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(report.generated_at)) + by_domain: dict[str, list[ArticleEvidence]] = {} + for a in report.articles: + by_domain.setdefault(a.requirement.domain.value, []).append(a) + + head = ( + '' + '' + f"Vaara compliance evidence — {_esc(report.system_name)}" + f"
" + ) + chain_cls = "chain-intact" if report.trail_chain_intact else "chain-broken" + chain_lbl = "intact" if report.trail_chain_intact else "BROKEN" + + system_block = ( + "

Article-level evidence report

" + '
This is an evidence artefact assembled from ' + "the Vaara runtime audit trail. It is not a " + "conformity determination. The deployer (and where applicable a " + "Notified Body) owns the conformity verdict under the EU AI Act " + "and other applicable law.
" + "

System

" + f'
Name
{_esc(report.system_name)}
' + f"
Version
{_esc(report.system_version)}
" + f"
Generated
{_esc(ts)}
" + f"
Overall status
{_pill(report.overall_status)}
" + "

Audit trail integrity

" + f'
Trail size
{report.trail_size} records
' + f'
Hash chain
{chain_lbl}
' + ) + + parts = [head, system_block] + if not report.trail_chain_intact: + parts.append( + '
The hash chain is broken. Every article ' + "is reported as insufficient until the chain is reconstructed " + "or re-verified.
" + ) + parts.append("

Summary

") + parts.append(f"

{_esc(report.summary or '(no summary)')}

") + + if report.critical_gaps: + parts.append('

Critical gaps

") + + for domain in sorted(by_domain): + arts = by_domain[domain] + parts.append(f"

{_esc(domain.upper())} — article evidence

") + parts.append( + "" + "" + ) + parts.extend(_article_table_row(a) for a in arts) + parts.append("
ArticleTitleStatusStrengthRecords
") + parts.extend(_article_detail(a, domain) for a in arts) + + parts.append( + "

Generated by Vaara. Article-level evidence is collected " + "from the runtime audit trail; the deployer owns conformity " + "determination.

" + ) + return "".join(parts) diff --git a/src/vaara/detect/__init__.py b/src/vaara/detect/__init__.py new file mode 100644 index 0000000..af8d619 --- /dev/null +++ b/src/vaara/detect/__init__.py @@ -0,0 +1,31 @@ +"""Named detector aliases over Vaara's existing scoring surface. + +Three buyer-visible categories that the EU AI Act and the agentic-AI +peer landscape brand as headline features: + +- **Prompt injection** — wraps the existing adversarial scorer for free + text. Routes through the same model that produces vaara-bench-v1's + published numbers. +- **PII** — small regex-based extractor for emails, phone numbers, SSNs, + IPv4 addresses, credit-card numbers (with Luhn check), and IBANs. + Zero extra dependencies. + +The detectors return structured ``DetectionResult`` objects with a +boolean ``detected`` flag, a normalized score in [0, 1] when meaningful, +and per-finding details suitable for audit-trail attachment. +""" + +from vaara.detect.injection import InjectionResult, detect_injection +from vaara.detect.pii import ( + PIIFinding, + PIIResult, + detect_pii, +) + +__all__ = [ + "InjectionResult", + "PIIFinding", + "PIIResult", + "detect_injection", + "detect_pii", +] diff --git a/src/vaara/detect/injection.py b/src/vaara/detect/injection.py new file mode 100644 index 0000000..dc25df3 --- /dev/null +++ b/src/vaara/detect/injection.py @@ -0,0 +1,135 @@ +"""Prompt-injection detection over Vaara's adversarial classifier. + +Wraps the model that produced vaara-bench-v1's headline numbers +(``bench/vaara-bench-v1-results.json``) so the buyer-visible "prompt +injection blocking" feature is the same engine that ships the cross- +validated recall and FPR reported there. + +The classifier was trained on tool-call shapes (``tool_name`` + +``parameters`` + ``context``). For free text we wrap the input as a +synthetic call so the same model surface applies; the heuristic +recipient field is ``ml.text`` so the bundle's tool-name vocabulary +does not bias the score toward any specific tool family. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +_DEFAULT_THRESHOLD = 0.55 # matches vaara-bench-v1's escalation band + + +@dataclass(frozen=True) +class InjectionResult: + """Result of a single prompt-injection check. + + Attributes: + detected: ``True`` iff the score meets or exceeds the threshold. + score: Adversarial probability in [0, 1]. + threshold: Decision threshold applied. + bundle_version: Classifier bundle the score came from. + backend: Identifier of the scoring backend ("vaara_adversarial" + for the shipped ML bundle, "heuristic" for the regex + fallback when the ml extra is not installed). + """ + + detected: bool + score: float + threshold: float + bundle_version: str + backend: str + + def to_dict(self) -> dict: + return { + "detected": self.detected, + "score": self.score, + "threshold": self.threshold, + "bundle_version": self.bundle_version, + "backend": self.backend, + } + + +# Conservative heuristic patterns for the no-ml fallback. Matched +# case-insensitively, OR-joined. Intentionally narrow so the heuristic +# does not become Vaara's de-facto detector — the ML path is preferred. +_HEURISTIC_INJECTION_PATTERNS = ( + r"ignore (?:all )?(?:the )?(?:previous|prior|above)(?: instructions)?", + r"disregard (?:the )?(?:above|previous|prior)", + r"system prompt", + r"you are now", + r"jailbreak", + r"do anything now", + r"DAN mode", + r"developer mode", + r"forget (?:the )?(?:above|previous|prior|all)", + r"reveal (?:the )?(?:system )?prompt", + r"\bsudo\s+(?:rm|cat|chmod|chown)\b", + r"<\|.*?\|>", +) + + +def _heuristic_score(text: str) -> float: + import re + + hits = 0 + for pat in _HEURISTIC_INJECTION_PATTERNS: + if re.search(pat, text, flags=re.IGNORECASE): + hits += 1 + if not hits: + return 0.0 + # Diminishing returns: one hit lands at 0.65, two at 0.85, three+ at 0.95. + return min(0.95, 0.40 + 0.25 * hits) + + +def detect_injection( + text: str, + *, + threshold: Optional[float] = None, + use_ml: bool = True, +) -> InjectionResult: + """Score free text for prompt-injection likelihood. + + Args: + text: The text to scan. Typical inputs are user-supplied prompt + contents, retrieved-document chunks, or tool-output strings + that flow back into an agent's context. + threshold: Decision threshold in [0, 1]. Defaults to 0.55 + (vaara-bench-v1's escalation band). + use_ml: When True and the ml extra is installed, route through + the AdversarialClassifier. When False or the extra is + absent, fall back to the heuristic regex set. + """ + if not isinstance(text, str): + raise TypeError(f"text must be str, got {type(text).__name__}") + th = _DEFAULT_THRESHOLD if threshold is None else float(threshold) + + if use_ml: + try: + from vaara.adversarial_classifier import AdversarialClassifier + + clf = AdversarialClassifier() + score = clf.score( + tool_name="ml.text", + parameters={"prompt": text}, + context={}, + ) + return InjectionResult( + detected=score >= th, + score=score, + threshold=th, + bundle_version=clf.bundle_version, + backend="vaara_adversarial", + ) + except (ImportError, FileNotFoundError): + pass # fall through to heuristic + + score = _heuristic_score(text) + return InjectionResult( + detected=score >= th, + score=score, + threshold=th, + bundle_version="heuristic/v1", + backend="heuristic", + ) diff --git a/src/vaara/detect/pii.py b/src/vaara/detect/pii.py new file mode 100644 index 0000000..7003ff9 --- /dev/null +++ b/src/vaara/detect/pii.py @@ -0,0 +1,135 @@ +"""Regex-based PII extractor. + +Six categories cover the buyer-visible "PII detection" feature without +adding a heavy ML dependency: + +- email +- phone (E.164 with country code + common US shapes) +- US Social Security Number (with a 000-area and trailing-0000 reject) +- IPv4 address +- credit-card number (13–19 digits, Luhn-checked) +- IBAN (rough length + checksum) + +Findings carry the literal match, the category, and (offset, length) so +audit trails can highlight or redact in place. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PIIFinding: + """One PII match in the scanned text.""" + + category: str + value: str + offset: int + length: int + + def to_dict(self) -> dict: + return { + "category": self.category, + "value": self.value, + "offset": self.offset, + "length": self.length, + } + + +@dataclass(frozen=True) +class PIIResult: + """Aggregate result of a PII scan.""" + + detected: bool + findings: tuple[PIIFinding, ...] + categories: tuple[str, ...] + + def to_dict(self) -> dict: + return { + "detected": self.detected, + "categories": list(self.categories), + "findings": [f.to_dict() for f in self.findings], + } + + +_RE_EMAIL = re.compile( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" +) +_RE_PHONE = re.compile( + r"(?:(?<=\s)|(?<=^)|(?<=[(]))" + r"\+?\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}" + r"(?:(?=\s)|(?=$)|(?=[).,;:!?]))" +) +_RE_SSN = re.compile(r"\b(?!000|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b") +_RE_IPV4 = re.compile( + r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\b" +) +_RE_CARD = re.compile(r"\b(?:\d[ -]?){13,19}\b") +_RE_IBAN = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b") + + +def _luhn_ok(digits: str) -> bool: + s = 0 + parity = (len(digits) - 2) % 2 + for i, ch in enumerate(digits): + d = int(ch) + if i % 2 == parity: + d *= 2 + if d > 9: + d -= 9 + s += d + return s % 10 == 0 + + +def _iban_ok(value: str) -> bool: + rotated = value[4:] + value[:4] + expanded = "".join( + ch if ch.isdigit() else str(ord(ch) - 55) for ch in rotated + ) + try: + return int(expanded) % 97 == 1 + except ValueError: + return False + + +def detect_pii(text: str) -> PIIResult: + """Scan free text for the six supported PII categories.""" + if not isinstance(text, str): + raise TypeError(f"text must be str, got {type(text).__name__}") + + findings: list[PIIFinding] = [] + for m in _RE_EMAIL.finditer(text): + findings.append(PIIFinding("email", m.group(0), m.start(), m.end() - m.start())) + for m in _RE_PHONE.finditer(text): + raw = m.group(0) + digits = re.sub(r"\D", "", raw) + if 7 <= len(digits) <= 15: + findings.append(PIIFinding( + "phone", raw, m.start(), m.end() - m.start(), + )) + for m in _RE_SSN.finditer(text): + findings.append(PIIFinding("ssn", m.group(0), m.start(), m.end() - m.start())) + for m in _RE_IPV4.finditer(text): + findings.append(PIIFinding("ipv4", m.group(0), m.start(), m.end() - m.start())) + for m in _RE_CARD.finditer(text): + raw = m.group(0) + digits = re.sub(r"[ -]", "", raw) + if 13 <= len(digits) <= 19 and _luhn_ok(digits): + findings.append(PIIFinding( + "credit_card", raw, m.start(), m.end() - m.start(), + )) + for m in _RE_IBAN.finditer(text): + raw = m.group(0) + if _iban_ok(raw): + findings.append(PIIFinding( + "iban", raw, m.start(), m.end() - m.start(), + )) + + categories = tuple(sorted({f.category for f in findings})) + return PIIResult( + detected=bool(findings), + findings=tuple(findings), + categories=categories, + ) diff --git a/src/vaara/policy/__init__.py b/src/vaara/policy/__init__.py index e0ee8e0..e08a430 100644 --- a/src/vaara/policy/__init__.py +++ b/src/vaara/policy/__init__.py @@ -34,6 +34,7 @@ Thresholds, ) from vaara.policy.loader import from_dict, from_json, from_yaml +from vaara.policy.controller import PolicyController, ReloadResult from vaara.policy.validate import ( IssueLevel, PolicyIssue, @@ -57,10 +58,12 @@ "EvaluationResult", "IssueLevel", "Policy", + "PolicyController", "PolicyError", "PolicyIssue", "PolicyTestCase", "PolicyTestResult", + "ReloadResult", "SequencePattern", "Thresholds", "ValidationReport", diff --git a/src/vaara/policy/controller.py b/src/vaara/policy/controller.py new file mode 100644 index 0000000..9908d18 --- /dev/null +++ b/src/vaara/policy/controller.py @@ -0,0 +1,118 @@ +"""Hot policy reload. + +The controller owns the live ``Policy`` and the listeners that need to +re-bind to its fields when it changes. ``reload(source)`` parses and +validates the new document before any listener runs; if validation +fails, the old policy stays in place. If validation succeeds, the swap +plus notification runs under a single write lock so listeners observe +the same generation of the policy. + +Listeners (e.g. ``AdaptiveScorer.apply_policy``) are responsible for +rebinding their own internal fields atomically under their own lock. +In-flight ``evaluate`` calls that already read the old thresholds keep +running against the old thresholds; the next call sees the new ones. +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Union + +from vaara.policy.loader import from_dict, from_json, from_yaml +from vaara.policy.schema import Policy, PolicyError + + +PolicyListener = Callable[[Policy], None] + + +@dataclass(frozen=True) +class ReloadResult: + """Outcome of a single ``PolicyController.reload`` call.""" + version: int + thresholds_default_escalate: float + thresholds_default_deny: float + sequence_count: int + action_class_count: int + escalation_route_count: int + + +class PolicyController: + """Holds the live ``Policy`` and atomically applies replacements.""" + + def __init__(self, policy: Policy) -> None: + self._policy = policy + self._version = 1 + self._listeners: list[PolicyListener] = [] + self._lock = threading.RLock() + + @property + def policy(self) -> Policy: + with self._lock: + return self._policy + + @property + def version(self) -> int: + with self._lock: + return self._version + + def add_listener(self, listener: PolicyListener) -> None: + """Register a callable invoked on every successful reload. + + The new policy is also applied to the listener immediately so a + component registered after construction picks up the current + state without a separate manual call. + """ + with self._lock: + self._listeners.append(listener) + listener(self._policy) + + def reload( + self, source: Union[str, Path, dict], *, format: Optional[str] = None + ) -> ReloadResult: + """Parse, validate, and apply a new policy. + + ``format`` may be ``"json"`` or ``"yaml"`` to force the parser. + When omitted, ``.yaml``/``.yml`` paths use the YAML loader, dicts + bypass parsing, and everything else goes through JSON. + + Raises ``PolicyError`` if the source is malformed; in that case + the previously loaded policy remains live. + """ + new_policy = _load(source, format) + with self._lock: + self._policy = new_policy + self._version += 1 + for fn in self._listeners: + fn(new_policy) + return ReloadResult( + version=self._version, + thresholds_default_escalate=new_policy.thresholds_default.escalate, + thresholds_default_deny=new_policy.thresholds_default.deny, + sequence_count=len(new_policy.sequences), + action_class_count=len(new_policy.action_classes), + escalation_route_count=len(new_policy.escalation_routes), + ) + + +def _load(source: Union[str, Path, dict], fmt: Optional[str]) -> Policy: + if isinstance(source, dict): + return from_dict(source) + if fmt == "yaml": + return from_yaml(source) + if fmt == "json": + return from_json(source) + if isinstance(source, Path): + if source.suffix in (".yaml", ".yml"): + return from_yaml(source) + return from_json(source) + # str: treat as path if it looks like one; otherwise JSON content. + if isinstance(source, str): + if source.lstrip().startswith("{"): + return from_json(source) + path = Path(source) + if path.suffix in (".yaml", ".yml"): + return from_yaml(path) + return from_json(path) + raise PolicyError(f"unsupported policy source type: {type(source).__name__}") diff --git a/src/vaara/scorer/adaptive.py b/src/vaara/scorer/adaptive.py index 6c15b1d..929bc29 100644 --- a/src/vaara/scorer/adaptive.py +++ b/src/vaara/scorer/adaptive.py @@ -31,7 +31,10 @@ from collections import OrderedDict, deque from dataclasses import dataclass, field from enum import Enum -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from vaara.policy.schema import Policy logger = logging.getLogger(__name__) @@ -684,6 +687,33 @@ def __init__( # RLock lets nested calls inside the scorer re-enter safely. self._lock = threading.RLock() + def apply_policy(self, policy: "Policy") -> None: + """Rebind thresholds and sequence patterns from a loaded policy. + + Runs under the scorer's RLock so an evaluate() in flight on + another thread either sees the old or the new pair completely. + The conformal calibrator, MWU expert state, agent profiles, and + cross-agent history are intentionally preserved — the operator + intent of a hot reload is "change the policy I'm enforcing," + not "wipe the model state I've been calibrating against." + """ + from vaara.policy.schema import Policy # local import to avoid cycles + if not isinstance(policy, Policy): + raise TypeError( + f"apply_policy requires a Policy, got {type(policy).__name__}" + ) + new_allow = policy.thresholds_default.escalate + new_deny = policy.thresholds_default.deny + new_sequences = list(policy.sequences) + with self._lock: + self._threshold_allow = new_allow + self._threshold_deny = new_deny + self._sequences = new_sequences + # Per-(agent, pattern) last-match cache keys may reference + # removed patterns; clear so the next match transition logs + # cleanly against the new pattern set. + self._seq_match_state.clear() + def _seed_conformal_prior(self) -> None: """Seed the calibrator with 50 synthetic benign (predicted, actual) pairs. diff --git a/src/vaara/server/app.py b/src/vaara/server/app.py index 5b7ffdb..17d24d8 100644 --- a/src/vaara/server/app.py +++ b/src/vaara/server/app.py @@ -16,6 +16,7 @@ from fastapi import FastAPI from vaara.audit.trail import AuditTrail +from vaara.policy.controller import PolicyController from vaara.scorer.adaptive import AdaptiveScorer from vaara.server.routes import register from vaara.server.state import ServerState @@ -24,14 +25,21 @@ def create_app( scorer: Optional[AdaptiveScorer] = None, audit: Optional[AuditTrail] = None, + policy_controller: Optional[PolicyController] = None, ) -> FastAPI: """Build the FastAPI application. Args: scorer: Pre-configured scorer, or None for default `AdaptiveScorer()`. audit: Pre-configured audit trail, or None for default in-memory. + policy_controller: Pre-loaded ``PolicyController``. When supplied, + the scorer is registered as a listener and ``POST + /v1/policy/reload`` becomes available. When omitted, the + reload endpoint returns ``409 policy_not_configured``. """ - state = ServerState(scorer=scorer, audit=audit) + state = ServerState( + scorer=scorer, audit=audit, policy_controller=policy_controller + ) app = FastAPI( title="Vaara HTTP API", version="1.0.0", diff --git a/src/vaara/server/routes.py b/src/vaara/server/routes.py index 22ba022..c3fb5b8 100644 --- a/src/vaara/server/routes.py +++ b/src/vaara/server/routes.py @@ -199,3 +199,62 @@ async def verify_audit_chain(_req: Optional[S.VerifyRequest] = None): events_checked=state.audit.size, first_break=None, ) + + @app.post( + "/v1/detect/injection", response_model=S.DetectInjectionResponse, + ) + async def detect_injection_endpoint(req: S.DetectInjectionRequest): + from vaara.detect import detect_injection + + result = detect_injection(req.text, threshold=req.threshold) + return S.DetectInjectionResponse(**result.to_dict()) + + @app.post("/v1/detect/pii", response_model=S.DetectPIIResponse) + async def detect_pii_endpoint(req: S.DetectPIIRequest): + from vaara.detect import detect_pii + + result = detect_pii(req.text) + return S.DetectPIIResponse(**result.to_dict()) + + @app.post("/v1/policy/reload", response_model=S.PolicyReloadResponse) + async def reload_policy(req: S.PolicyReloadRequest): + from vaara.policy.schema import PolicyError + + controller = state.policy_controller + if controller is None: + raise _error( + code="policy_not_configured", + message=( + "Server has no PolicyController; start with " + "`vaara serve --policy PATH` to enable reload." + ), + http_status=status.HTTP_409_CONFLICT, + ) + + if (req.path is None) == (req.body is None): + raise _error( + code="invalid_request", + message="Exactly one of `path` or `body` must be supplied.", + http_status=status.HTTP_400_BAD_REQUEST, + ) + + source = req.body if req.body is not None else req.path + try: + result = controller.reload(source, format=req.format) + except PolicyError as exc: + raise _error( + code="policy_invalid", + message=str(exc), + http_status=status.HTTP_422_UNPROCESSABLE_ENTITY, + ) + + return S.PolicyReloadResponse( + version=result.version, + thresholds_default={ + "escalate": result.thresholds_default_escalate, + "deny": result.thresholds_default_deny, + }, + sequence_count=result.sequence_count, + action_class_count=result.action_class_count, + escalation_route_count=result.escalation_route_count, + ) diff --git a/src/vaara/server/schemas.py b/src/vaara/server/schemas.py index 9f1fe07..74671ca 100644 --- a/src/vaara/server/schemas.py +++ b/src/vaara/server/schemas.py @@ -161,3 +161,53 @@ class ErrorBody(BaseModel): class ErrorResponse(BaseModel): error: ErrorBody + + +class DetectInjectionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + text: str = Field(max_length=100_000) + threshold: Optional[float] = Field(default=None, ge=0, le=1) + + +class DetectInjectionResponse(BaseModel): + detected: bool + score: float = Field(ge=0, le=1) + threshold: float = Field(ge=0, le=1) + bundle_version: str + backend: str + + +class DetectPIIRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + text: str = Field(max_length=100_000) + + +class DetectPIIFinding(BaseModel): + category: str + value: str + offset: int + length: int + + +class DetectPIIResponse(BaseModel): + detected: bool + categories: list[str] + findings: list[DetectPIIFinding] + + +class PolicyReloadRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + path: Optional[str] = Field(default=None, max_length=4096) + body: Optional[dict[str, Any]] = None + format: Optional[Literal["json", "yaml"]] = None + + +class PolicyReloadResponse(BaseModel): + version: int + thresholds_default: dict[str, float] + sequence_count: int + action_class_count: int + escalation_route_count: int diff --git a/src/vaara/server/state.py b/src/vaara/server/state.py index 58d15b6..eca3baf 100644 --- a/src/vaara/server/state.py +++ b/src/vaara/server/state.py @@ -1,4 +1,4 @@ -"""Server state container — scorer + audit trail singletons.""" +"""Server state container — scorer + audit trail + policy controller singletons.""" from __future__ import annotations @@ -7,6 +7,7 @@ from typing import Optional from vaara.audit.trail import AuditTrail +from vaara.policy.controller import PolicyController from vaara.scorer.adaptive import AdaptiveScorer @@ -25,9 +26,13 @@ def __init__( self, scorer: Optional[AdaptiveScorer] = None, audit: Optional[AuditTrail] = None, + policy_controller: Optional[PolicyController] = None, ) -> None: self.scorer = scorer or AdaptiveScorer() self.audit = audit or AuditTrail() + self.policy_controller = policy_controller + if policy_controller is not None: + policy_controller.add_listener(self.scorer.apply_policy) self._lock = threading.Lock() # action_id → info captured at score time so outcome reports can # feed the MWU update without the client having to resend context. diff --git a/tests/test_attestation_iap.py b/tests/test_attestation_iap.py new file mode 100644 index 0000000..8c28110 --- /dev/null +++ b/tests/test_attestation_iap.py @@ -0,0 +1,212 @@ +"""OVERT Phase 3 IAP + transparency-log tests.""" + +from __future__ import annotations + +import uuid + +import pytest + +try: + import cbor2 # noqa: F401 + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + ) +except ImportError: + pytest.skip( + "attestation extra not installed (pip install 'vaara[attestation]')", + allow_module_level=True, + ) + +from vaara.attestation.iap import ( + IAPError, + Phase3Attestation, + emit_phase3_attestation, + envelope_to_canonical_cbor, + verify_phase3_attestation, +) +from vaara.attestation.overt import ( + emit_base_envelope, + encoder_binary_identity, + make_request_commitment, +) +from vaara.attestation.transparency_log import ( + InProcessTransparencyLog, + TransparencyLogError, + verify_inclusion, +) + + +def _make_envelope(arbiter_key, counter=1): + return emit_base_envelope( + signing_key=arbiter_key, + request_commitment=make_request_commitment( + b"req-content", operator_key=b"k" * 32, + ), + encoder_binary_identity=encoder_binary_identity( + arbiter_version="vaara/test", policy_hash=b"\x00" * 32, + ), + non_content_metadata={"decision": "allow"}, + monotonic_counter=counter, + arbiter_instance_identifier=uuid.uuid4().bytes, + ) + + +def _raw_pub(key): + return key.public_key().public_bytes_raw() + + +def test_emit_and_verify_roundtrip(): + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + env = _make_envelope(arbiter) + att = emit_phase3_attestation( + envelope=env, notary_signing_key=notary, + transparency_log=log, iap_identifier="vaara-iap-test/1.0", + ) + assert isinstance(att, Phase3Attestation) + assert att.log_index == 0 and att.log_tree_size == 1 + assert verify_phase3_attestation( + attestation=att, + notary_public_key_raw=_raw_pub(notary), + expected_log_root=log.root_hash, + arbiter_public_key_raw=_raw_pub(arbiter), + ) + + +def test_rejects_arbiter_acting_as_notary(): + arbiter = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + env = _make_envelope(arbiter) + with pytest.raises(IAPError, match="structural independence"): + emit_phase3_attestation( + envelope=env, notary_signing_key=arbiter, + transparency_log=log, iap_identifier="vaara-iap-test/1.0", + ) + + +def test_verify_rejects_wrong_notary_pubkey(): + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + other = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + att = emit_phase3_attestation( + envelope=_make_envelope(arbiter), notary_signing_key=notary, + transparency_log=log, iap_identifier="iap-x", + ) + assert not verify_phase3_attestation( + attestation=att, notary_public_key_raw=_raw_pub(other), + ) + + +def test_verify_rejects_tampered_envelope_cbor(): + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + att = emit_phase3_attestation( + envelope=_make_envelope(arbiter), notary_signing_key=notary, + transparency_log=log, iap_identifier="iap-x", + ) + bad = Phase3Attestation( + envelope_cbor=att.envelope_cbor[:-1] + b"\xff", + notary_signature=att.notary_signature, + notary_key_identifier=att.notary_key_identifier, + log_index=att.log_index, + log_tree_size=att.log_tree_size, + log_root_at_append=att.log_root_at_append, + inclusion_proof_siblings=att.inclusion_proof_siblings, + iap_identifier=att.iap_identifier, + attestation_timestamp_ns=att.attestation_timestamp_ns, + ) + assert not verify_phase3_attestation( + attestation=bad, notary_public_key_raw=_raw_pub(notary), + ) + + +def test_inclusion_proof_round_trip_many_entries(): + log = InProcessTransparencyLog() + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + attestations = [] + for i in range(11): + attestations.append(emit_phase3_attestation( + envelope=_make_envelope(arbiter, counter=i + 1), + notary_signing_key=notary, transparency_log=log, + iap_identifier="iap-x", + )) + root_after_all = log.root_hash + for att in attestations: + proof = log.inclusion_proof(att.log_index) + assert verify_inclusion( + leaf_data=att.envelope_cbor, proof=proof, + expected_root=root_after_all, + ) + + +def test_log_rejects_non_bytes_leaf(): + log = InProcessTransparencyLog() + with pytest.raises(TransparencyLogError): + log.append("not-bytes") # type: ignore[arg-type] + + +def test_log_inclusion_proof_bad_index_raises(): + log = InProcessTransparencyLog() + log.append(b"x") + with pytest.raises(TransparencyLogError): + log.inclusion_proof(5) + + +def test_verify_rejects_inclusion_proof_against_wrong_root(): + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + att = emit_phase3_attestation( + envelope=_make_envelope(arbiter), notary_signing_key=notary, + transparency_log=log, iap_identifier="iap-x", + ) + assert not verify_phase3_attestation( + attestation=att, notary_public_key_raw=_raw_pub(notary), + expected_log_root=b"\x00" * 32, + ) + + +def test_verify_rejects_inner_signature_when_arbiter_key_supplied(): + arbiter = Ed25519PrivateKey.generate() + other = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + att = emit_phase3_attestation( + envelope=_make_envelope(arbiter), notary_signing_key=notary, + transparency_log=log, iap_identifier="iap-x", + ) + assert not verify_phase3_attestation( + attestation=att, notary_public_key_raw=_raw_pub(notary), + arbiter_public_key_raw=_raw_pub(other), + ) + + +def test_envelope_to_canonical_cbor_includes_signature(): + import cbor2 + arbiter = Ed25519PrivateKey.generate() + blob = envelope_to_canonical_cbor(_make_envelope(arbiter)) + decoded = cbor2.loads(blob) + assert set(decoded.keys()) == { + "blinded_identifier", "request_commitment", + "encoder_binary_identity", "non_content_metadata", + "monotonic_counter", "nanosecond_timestamp", + "key_identifier", "arbiter_instance_identifier", + "signature", + } + + +def test_to_dict_is_jsonable(): + import json + arbiter = Ed25519PrivateKey.generate() + notary = Ed25519PrivateKey.generate() + log = InProcessTransparencyLog() + att = emit_phase3_attestation( + envelope=_make_envelope(arbiter), notary_signing_key=notary, + transparency_log=log, iap_identifier="iap-x", + ) + body = json.dumps(att.to_dict()) + assert "envelope_cbor" in body and "log_root_at_append" in body diff --git a/tests/test_compliance_dashboard.py b/tests/test_compliance_dashboard.py new file mode 100644 index 0000000..d04f5d9 --- /dev/null +++ b/tests/test_compliance_dashboard.py @@ -0,0 +1,131 @@ +"""Tests for the HTML article-coverage dashboard renderer.""" + +from __future__ import annotations + +import re + +from vaara.audit.trail import AuditTrail +from vaara.compliance.dashboard import render_html +from vaara.compliance.engine import create_default_engine +from vaara.taxonomy.actions import ( + ActionCategory, + ActionRequest, + ActionType, + BlastRadius, + RegulatoryDomain, + Reversibility, + UrgencyClass, +) + + +def _populated_trail() -> AuditTrail: + trail = AuditTrail() + action_type = ActionType( + name="data.read", + category=ActionCategory.DATA, + reversibility=Reversibility.FULLY, + blast_radius=BlastRadius.LOCAL, + urgency=UrgencyClass.DEFERRABLE, + regulatory_domains=frozenset({RegulatoryDomain.EU_AI_ACT}), + ) + req = ActionRequest( + agent_id="a-1", tool_name="data.read", + action_type=action_type, parameters={}, confidence=0.9, + ) + action_id = trail.record_action_requested(req) + trail.record_risk_scored( + action_id=action_id, agent_id="a-1", tool_name="data.read", + assessment={"point_estimate": 0.2, "decision": "allow"}, + regulatory_domains=frozenset({RegulatoryDomain.EU_AI_ACT}), + ) + trail.record_decision( + action_id=action_id, agent_id="a-1", tool_name="data.read", + decision="allow", reason="risk below threshold", risk_score=0.2, + regulatory_domains=frozenset({RegulatoryDomain.EU_AI_ACT}), + ) + return trail + + +def _report(): + trail = _populated_trail() + engine = create_default_engine() + return engine.assess(trail, system_name="TestSys", system_version="1.0") + + +def test_html_is_well_formed_doctype(): + out = render_html(_report()) + assert out.startswith("") + assert out.endswith("") + + +def test_html_includes_system_and_status_block(): + out = render_html(_report()) + assert "Article-level evidence report" in out + assert "TestSys" in out + assert "Audit trail integrity" in out + assert "intact" in out + assert 'class="pill' in out + + +def test_html_escapes_user_supplied_strings(): + trail = _populated_trail() + engine = create_default_engine() + report = engine.assess( + trail, + system_name="", + system_version="v\"&'<>", + ) + out = render_html(report) + assert "" not in out + assert "<script>" in out + assert "&" in out + assert """ in out + + +def test_html_renders_per_domain_table(): + out = render_html(_report()) + assert "EU_AI_ACT — article evidence" in out + assert "" in out + assert "Article" in out and "Status" in out and "Records" in out + + +def test_html_renders_status_pills_for_known_statuses(): + out = render_html(_report()) + pill_classes = set(re.findall(r'pill (ok|warn|bad|na)', out)) + assert pill_classes # at least one pill rendered + + +def test_html_renders_broken_chain_notice(): + trail = _populated_trail() + # Tamper to break the chain. + if trail._records: + bad = trail._records[-1] + object.__setattr__(bad, "previous_hash", "deadbeef") + + engine = create_default_engine() + report = engine.assess(trail, system_name="Tampered", system_version="1.0") + out = render_html(report) + assert "chain-broken" in out + assert "BROKEN" in out + + +def test_html_has_print_friendly_stylesheet(): + out = render_html(_report()) + assert "@media print" in out + + +def test_html_is_self_contained_no_external_assets(): + out = render_html(_report()) + # No external link/script tags. + assert "