From 73d7bb379a3a017bad089dff3fa5e77aa1910a10 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sat, 28 Feb 2026 13:57:53 -0800 Subject: [PATCH 1/2] P1: post-execution verification - cooperative --- .flake8 | 13 + predicate_authority/__init__.py | 28 ++ predicate_authority/verify/__init__.py | 71 ++++ predicate_authority/verify/comparators.py | 122 +++++++ predicate_authority/verify/types.py | 414 ++++++++++++++++++++++ predicate_authority/verify/verifier.py | 400 +++++++++++++++++++++ tests/test_verify.py | 378 ++++++++++++++++++++ 7 files changed, 1426 insertions(+) create mode 100644 .flake8 create mode 100644 predicate_authority/verify/__init__.py create mode 100644 predicate_authority/verify/comparators.py create mode 100644 predicate_authority/verify/types.py create mode 100644 predicate_authority/verify/verifier.py create mode 100644 tests/test_verify.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6b88793 --- /dev/null +++ b/.flake8 @@ -0,0 +1,13 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503, E501 +exclude = + .git, + __pycache__, + venv, + .venv, + build, + dist, + .eggs, + *.egg-info, +max-complexity = 15 diff --git a/predicate_authority/__init__.py b/predicate_authority/__init__.py index e94e889..4f41ec9 100644 --- a/predicate_authority/__init__.py +++ b/predicate_authority/__init__.py @@ -55,8 +55,36 @@ run_sidecar, ) from predicate_authority.telemetry import OpenTelemetryTraceEmitter +from predicate_authority.verify import ( + ActualOperation, + AuthorizedOperation, + MandateDetails, + RecordVerificationRequest, + RecordVerificationResponse, + VerificationFailureReason, + Verifier, + VerifyRequest, + VerifyResult, + actions_match, + normalize_resource, + resources_match, +) __all__ = [ + # Verification module + "ActualOperation", + "AuthorizedOperation", + "MandateDetails", + "RecordVerificationRequest", + "RecordVerificationResponse", + "VerificationFailureReason", + "Verifier", + "VerifyRequest", + "VerifyResult", + "actions_match", + "normalize_resource", + "resources_match", + # Authorization "ActionExecutionResult", "ActionGuard", "AuthorityClient", diff --git a/predicate_authority/verify/__init__.py b/predicate_authority/verify/__init__.py new file mode 100644 index 0000000..7bdebd1 --- /dev/null +++ b/predicate_authority/verify/__init__.py @@ -0,0 +1,71 @@ +""" +Post-execution verification module. + +This module provides verification capability to compare actual operations +against what was authorized via a mandate, detecting unauthorized deviations. + +Example: + >>> from predicate_authority.verify import Verifier + >>> verifier = Verifier(base_url="http://127.0.0.1:8787") + >>> result = verifier.verify( + ... mandate_id=decision.mandate_id, + ... actual={ + ... "action": "fs.read", + ... "resource": "/src/index.ts", + ... }, + ... ) + >>> if not result.verified: + ... print(f"Operation mismatch: {result.reason}") +""" + +from predicate_authority.verify.comparators import ( + actions_match, + normalize_resource, + resources_match, +) +from predicate_authority.verify.types import ( # Evidence types (discriminated union); Core types + ActualOperation, + AuthorizedOperation, + BrowserEvidence, + CliEvidence, + DbEvidence, + ExecutionEvidence, + FileEvidence, + GenericEvidence, + HttpEvidence, + MandateDetails, + RecordVerificationRequest, + RecordVerificationResponse, + VerificationFailureReason, + VerifyRequest, + VerifyResult, + get_evidence_type, +) +from predicate_authority.verify.verifier import Verifier + +__all__ = [ + # Evidence types (discriminated union) + "BrowserEvidence", + "CliEvidence", + "DbEvidence", + "ExecutionEvidence", + "FileEvidence", + "GenericEvidence", + "HttpEvidence", + "get_evidence_type", + # Core types + "ActualOperation", + "AuthorizedOperation", + "MandateDetails", + "RecordVerificationRequest", + "RecordVerificationResponse", + "VerificationFailureReason", + "VerifyRequest", + "VerifyResult", + # Comparators + "actions_match", + "normalize_resource", + "resources_match", + # Verifier + "Verifier", +] diff --git a/predicate_authority/verify/comparators.py b/predicate_authority/verify/comparators.py new file mode 100644 index 0000000..9a1d97a --- /dev/null +++ b/predicate_authority/verify/comparators.py @@ -0,0 +1,122 @@ +""" +Resource comparison functions for post-execution verification. + +These functions compare authorized resources against actual resources, +handling path normalization and glob pattern matching. +""" + +from __future__ import annotations + +import re +from fnmatch import fnmatch + +from predicate_contracts import normalize_path + + +def normalize_resource(resource: str) -> str: + """ + Normalize a resource path for comparison. + + Applies the following transformations: + - Expands ~ to home directory + - Collapses multiple slashes + - Removes ./ segments + - Removes trailing slashes + - Resolves . and .. + + Args: + resource: Resource path to normalize + + Returns: + Normalized path + """ + # Use existing normalize_path for filesystem paths + if resource.startswith("/") or resource.startswith("~") or resource.startswith("."): + normalized = normalize_path(resource) + # normalize_path doesn't strip trailing slashes, so we do it here + if len(normalized) > 1 and normalized.endswith("/"): + normalized = normalized[:-1] + return normalized + + # For URLs, handle protocol specially + url_match = re.match(r"^([a-zA-Z][a-zA-Z0-9+.-]*://)", resource) + if url_match: + protocol = url_match.group(1) # e.g., "https://" + rest = resource[len(protocol) :] + + # Normalize the rest (collapse slashes, remove ./, remove trailing /) + normalized = re.sub(r"/+", "/", rest) # Collapse multiple slashes + normalized = re.sub(r"/\./", "/", normalized) # Remove ./ + normalized = re.sub(r"/$", "", normalized) # Remove trailing slash + + return protocol + normalized + + # For other non-path resources, do basic cleanup + normalized = re.sub(r"/+", "/", resource) # Collapse multiple slashes + normalized = re.sub(r"/\./", "/", normalized) # Remove ./ + normalized = re.sub(r"/$", "", normalized) # Remove trailing slash + return normalized + + +def resources_match( + authorized: str, + actual: str, + *, + allow_glob: bool = True, +) -> bool: + """ + Check if an actual resource matches an authorized resource. + + Handles: + - Path normalization (~ expansion, . and .., etc.) + - Optional glob pattern matching (* wildcards) + + Args: + authorized: Resource from the mandate (may contain glob patterns) + actual: Resource that was actually accessed + allow_glob: Enable glob pattern matching for authorized resource + + Returns: + True if resources match + """ + # Normalize both resources + normalized_auth = normalize_resource(authorized) + normalized_actual = normalize_resource(actual) + + # Exact match after normalization + if normalized_auth == normalized_actual: + return True + + # Glob pattern match (if enabled and authorized resource contains wildcards) + if allow_glob and "*" in authorized: + return fnmatch(normalized_actual, authorized) + + return False + + +def actions_match(authorized: str, actual: str) -> bool: + """ + Check if an actual action matches an authorized action. + + Actions are compared case-sensitively after trimming whitespace. + Supports glob patterns in the authorized action. + + Args: + authorized: Action from the mandate (may contain glob patterns) + actual: Action that was actually performed + + Returns: + True if actions match + """ + normalized_auth = authorized.strip() + normalized_actual = actual.strip() + + # Exact match + if normalized_auth == normalized_actual: + return True + + # Glob pattern match (e.g., "fs.*" matches "fs.read") + if "*" in authorized: + return fnmatch(normalized_actual, authorized) + + return False diff --git a/predicate_authority/verify/types.py b/predicate_authority/verify/types.py new file mode 100644 index 0000000..c5ad35f --- /dev/null +++ b/predicate_authority/verify/types.py @@ -0,0 +1,414 @@ +""" +Types for post-execution verification. + +These types support verifying that actual operations match +what was authorized via a mandate. + +The verification system uses discriminated unions to support different +evidence schemas based on the action domain: + +- `file`: File system operations with content hashes +- `cli`: Terminal/shell operations with transcript evidence +- `browser`: Web operations with DOM/A11y state +- `http`: HTTP requests with response evidence +- `db`: Database operations with query evidence +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Literal, Union + +# ============================================================================= +# Evidence Type Discriminator +# ============================================================================= + +EvidenceType = Literal["file", "cli", "browser", "http", "db", "generic"] + + +def get_evidence_type(action: str) -> EvidenceType: + """ + Extract the evidence type from an action string. + + Args: + action: Action string (e.g., "fs.read", "cli.exec") + + Returns: + Evidence type based on action prefix + + Examples: + >>> get_evidence_type("fs.read") + 'file' + >>> get_evidence_type("cli.exec") + 'cli' + >>> get_evidence_type("browser.click") + 'browser' + >>> get_evidence_type("custom.action") + 'generic' + """ + prefix = action.split(".")[0] + domain_map: dict[str, EvidenceType] = { + "fs": "file", + "file": "file", + "cli": "cli", + "shell": "cli", + "terminal": "cli", + "browser": "browser", + "web": "browser", + "http": "http", + "https": "http", + "db": "db", + "database": "db", + "sql": "db", + } + return domain_map.get(prefix, "generic") + + +# ============================================================================= +# Discriminated Union Evidence Types +# ============================================================================= + + +@dataclass(frozen=True) +class FileEvidence: + """Evidence for file system operations (fs.read, fs.write, etc.)""" + + type: Literal["file"] + """Discriminator field.""" + + action: str + """The action that was performed (e.g., 'fs.read').""" + + resource: str + """The file path that was accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + content_hash: str | None = None + """Hash of file content (SHA-256).""" + + file_size: int | None = None + """File size in bytes.""" + + permissions: str | None = None + """File permissions (octal string, e.g., '644').""" + + modified_at: str | None = None + """Last modified timestamp (ISO 8601).""" + + +@dataclass(frozen=True) +class CliEvidence: + """Evidence for terminal/CLI operations (cli.exec, cli.spawn, etc.)""" + + type: Literal["cli"] + """Discriminator field.""" + + action: str + """The action that was performed (e.g., 'cli.exec').""" + + resource: str + """The command that was executed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + command: str | None = None + """The exact command that was executed.""" + + exit_code: int | None = None + """Exit code of the process.""" + + stdout_hash: str | None = None + """Hash of stdout transcript.""" + + stderr_hash: str | None = None + """Hash of stderr transcript.""" + + transcript_hash: str | None = None + """Combined transcript hash (stdout + stderr).""" + + cwd: str | None = None + """Working directory where command was executed.""" + + duration_ms: int | None = None + """Duration in milliseconds.""" + + +@dataclass(frozen=True) +class BrowserEvidence: + """Evidence for browser/web operations (browser.click, browser.navigate, etc.)""" + + type: Literal["browser"] + """Discriminator field.""" + + action: str + """The action that was performed (e.g., 'browser.click').""" + + resource: str + """The URL or selector that was accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + final_url: str | None = None + """Final URL after navigation.""" + + selector: str | None = None + """DOM selector that was interacted with.""" + + a11y_tree_hash: str | None = None + """Hash of accessibility tree state.""" + + dom_state_hash: str | None = None + """Hash of visible DOM state.""" + + screenshot_hash: str | None = None + """Screenshot hash (if captured).""" + + page_title: str | None = None + """Page title after operation.""" + + +@dataclass(frozen=True) +class HttpEvidence: + """Evidence for HTTP operations (http.get, http.post, etc.)""" + + type: Literal["http"] + """Discriminator field.""" + + action: str + """The action that was performed (e.g., 'http.get').""" + + resource: str + """The URL that was accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + method: str | None = None + """HTTP method used.""" + + status_code: int | None = None + """Response status code.""" + + response_body_hash: str | None = None + """Hash of response body.""" + + content_type: str | None = None + """Response content type.""" + + response_size: int | None = None + """Response size in bytes.""" + + duration_ms: int | None = None + """Request duration in milliseconds.""" + + +@dataclass(frozen=True) +class DbEvidence: + """Evidence for database operations (db.query, db.insert, etc.)""" + + type: Literal["db"] + """Discriminator field.""" + + action: str + """The action that was performed (e.g., 'db.query').""" + + resource: str + """The table or collection that was accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + query_hash: str | None = None + """Hash of query/statement.""" + + rows_affected: int | None = None + """Number of rows affected.""" + + result_hash: str | None = None + """Hash of result set (for queries).""" + + duration_ms: int | None = None + """Query duration in milliseconds.""" + + +@dataclass(frozen=True) +class GenericEvidence: + """Evidence for unknown or custom action types.""" + + type: Literal["generic"] + """Discriminator field.""" + + action: str + """The action that was performed.""" + + resource: str + """The resource that was accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + evidence_hash: str | None = None + """Arbitrary evidence hash.""" + + metadata: dict | None = None + """Additional metadata.""" + + +# Discriminated union of all evidence types +ExecutionEvidence = Union[ + FileEvidence, + CliEvidence, + BrowserEvidence, + HttpEvidence, + DbEvidence, + GenericEvidence, +] + + +# ============================================================================= +# Core Verification Types +# ============================================================================= + + +class VerificationFailureReason(str, Enum): + """Reason codes for verification failure.""" + + RESOURCE_MISMATCH = "resource_mismatch" + ACTION_MISMATCH = "action_mismatch" + MANDATE_EXPIRED = "mandate_expired" + MANDATE_NOT_FOUND = "mandate_not_found" + EVIDENCE_MISMATCH = "evidence_mismatch" + + +@dataclass(frozen=True) +class AuthorizedOperation: + """Details about an authorized operation from a mandate.""" + + action: str + resource: str + + +@dataclass(frozen=True) +class ActualOperation: + """ + Legacy interface for backward compatibility. + + Deprecated: Use ExecutionEvidence discriminated union instead. + """ + + action: str + """The action that was actually performed.""" + + resource: str + """The resource that was actually accessed.""" + + executed_at: str | None = None + """Timestamp when operation was executed (ISO 8601).""" + + content_hash: str | None = None + """Deprecated: Use FileEvidence.content_hash instead.""" + + transcript_hash: str | None = None + """Deprecated: Use CliEvidence.transcript_hash instead.""" + + +@dataclass(frozen=True) +class VerifyRequest: + """ + Request to verify an operation against its mandate. + + Supports both the legacy ActualOperation format and the new + discriminated union ExecutionEvidence format. + """ + + mandate_id: str + """Mandate ID from the authorization decision.""" + + actual: ExecutionEvidence | ActualOperation + """The actual operation that was performed.""" + + +@dataclass(frozen=True) +class VerifyResult: + """Result of verification.""" + + verified: bool + """Whether the operation matched the authorization.""" + + reason: VerificationFailureReason | None = None + """Reason for verification failure (if verified is False).""" + + authorized: AuthorizedOperation | None = None + """Authorized operation details (if verification failed).""" + + actual: ExecutionEvidence | ActualOperation | None = None + """Actual operation details (if verification failed).""" + + audit_id: str | None = None + """Audit trail ID from the sidecar (if verification succeeded).""" + + +# ============================================================================= +# Mandate Types +# ============================================================================= + + +@dataclass(frozen=True) +class MandateDetails: + """Mandate details retrieved from the sidecar.""" + + mandate_id: str + """Unique mandate identifier.""" + + principal: str + """Principal that was granted authorization.""" + + action: str + """Action that was authorized.""" + + resource: str + """Resource that was authorized.""" + + intent_hash: str + """Hash of the stated intent.""" + + issued_at: str + """When the mandate was issued (ISO 8601).""" + + expires_at: str + """When the mandate expires (ISO 8601).""" + + +# ============================================================================= +# Audit Types +# ============================================================================= + + +@dataclass(frozen=True) +class RecordVerificationRequest: + """Request to record a verification in the audit log.""" + + mandate_id: str + """Mandate ID that was verified.""" + + verified: bool + """Whether verification succeeded.""" + + actual: ExecutionEvidence | ActualOperation + """The actual operation details.""" + + reason: VerificationFailureReason | None = None + """Reason for failure (if verified is False).""" + + +@dataclass(frozen=True) +class RecordVerificationResponse: + """Response from recording a verification.""" + + audit_id: str + """Audit trail ID.""" diff --git a/predicate_authority/verify/verifier.py b/predicate_authority/verify/verifier.py new file mode 100644 index 0000000..741a0f0 --- /dev/null +++ b/predicate_authority/verify/verifier.py @@ -0,0 +1,400 @@ +""" +Post-execution verification module. + +The Verifier class compares actual operations against what was +authorized via a mandate, detecting unauthorized deviations. +""" + +from __future__ import annotations + +import secrets +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol + +import httpx + +from predicate_authority.verify.comparators import actions_match, resources_match +from predicate_authority.verify.types import ( + ActualOperation, + AuthorizedOperation, + BrowserEvidence, + CliEvidence, + DbEvidence, + ExecutionEvidence, + FileEvidence, + GenericEvidence, + HttpEvidence, + MandateDetails, + RecordVerificationRequest, + VerificationFailureReason, + VerifyRequest, + VerifyResult, +) + + +class MandateProvider(Protocol): + """Interface for mandate retrieval.""" + + def get_mandate(self, mandate_id: str) -> MandateDetails | None: + """ + Retrieve mandate details by ID. + + Args: + mandate_id: The mandate ID to look up + + Returns: + Mandate details or None if not found + """ + ... + + def record_verification(self, request: RecordVerificationRequest) -> str: + """ + Record a verification result in the audit log. + + Args: + request: Verification details to record + + Returns: + Audit trail ID + """ + ... + + +@dataclass +class VerifierOptions: + """Options for creating a Verifier.""" + + base_url: str + """Base URL of the sidecar.""" + + timeout_seconds: float = 2.0 + """Request timeout in seconds.""" + + +class Verifier: + """ + Verifier for post-execution authorization checks. + + Compares actual operations against mandates to detect unauthorized + deviations from what was authorized. + + Example: + >>> verifier = Verifier(base_url="http://127.0.0.1:8787") + >>> result = verifier.verify(VerifyRequest( + ... mandate_id=decision.mandate_id, + ... actual=ActualOperation( + ... action="fs.read", + ... resource="/src/index.ts", + ... ), + ... )) + >>> if not result.verified: + ... print(f"Operation mismatch: {result.reason}") + """ + + def __init__( + self, + base_url: str, + timeout_seconds: float = 2.0, + ) -> None: + """ + Initialize a Verifier. + + Args: + base_url: Base URL of the sidecar + timeout_seconds: Request timeout in seconds + """ + self._base_url = base_url.rstrip("/") + self._timeout = timeout_seconds + self._client = httpx.Client(timeout=timeout_seconds) + + def verify(self, request: VerifyRequest) -> VerifyResult: + """ + Verify that an actual operation matches its mandate. + + Args: + request: Verification request with mandate ID and actual operation + + Returns: + Verification result + """ + # 1. Retrieve mandate details + mandate = self.get_mandate(request.mandate_id) + + if mandate is None: + return VerifyResult( + verified=False, + reason=VerificationFailureReason.MANDATE_NOT_FOUND, + ) + + # 2. Check mandate expiration + expires_at = datetime.fromisoformat(mandate.expires_at.replace("Z", "+00:00")) + if expires_at.timestamp() < time.time(): + return VerifyResult( + verified=False, + reason=VerificationFailureReason.MANDATE_EXPIRED, + ) + + # 3. Compare action + if not actions_match(mandate.action, request.actual.action): + result = VerifyResult( + verified=False, + reason=VerificationFailureReason.ACTION_MISMATCH, + authorized=AuthorizedOperation( + action=mandate.action, + resource=mandate.resource, + ), + actual=request.actual, + ) + + # Record failed verification + self.record_verification( + RecordVerificationRequest( + mandate_id=request.mandate_id, + verified=False, + actual=request.actual, + reason=VerificationFailureReason.ACTION_MISMATCH, + ) + ) + + return result + + # 4. Compare resource (with normalization) + if not resources_match(mandate.resource, request.actual.resource): + result = VerifyResult( + verified=False, + reason=VerificationFailureReason.RESOURCE_MISMATCH, + authorized=AuthorizedOperation( + action=mandate.action, + resource=mandate.resource, + ), + actual=request.actual, + ) + + # Record failed verification + self.record_verification( + RecordVerificationRequest( + mandate_id=request.mandate_id, + verified=False, + actual=request.actual, + reason=VerificationFailureReason.RESOURCE_MISMATCH, + ) + ) + + return result + + # 5. Record successful verification + audit_id = self.record_verification( + RecordVerificationRequest( + mandate_id=request.mandate_id, + verified=True, + actual=request.actual, + ) + ) + + return VerifyResult( + verified=True, + audit_id=audit_id, + ) + + def verify_local(self, mandate: MandateDetails, request: VerifyRequest) -> VerifyResult: + """ + Verify an operation locally without sidecar communication. + + Use this when the sidecar endpoints are not available yet (Phase 2). + This performs the same matching logic but skips mandate retrieval + and audit logging. + + Args: + mandate: Known mandate details + request: Verification request + + Returns: + Verification result (without audit_id) + """ + # Check mandate expiration + expires_at = datetime.fromisoformat(mandate.expires_at.replace("Z", "+00:00")) + if expires_at.timestamp() < time.time(): + return VerifyResult( + verified=False, + reason=VerificationFailureReason.MANDATE_EXPIRED, + ) + + # Compare action + if not actions_match(mandate.action, request.actual.action): + return VerifyResult( + verified=False, + reason=VerificationFailureReason.ACTION_MISMATCH, + authorized=AuthorizedOperation( + action=mandate.action, + resource=mandate.resource, + ), + actual=request.actual, + ) + + # Compare resource + if not resources_match(mandate.resource, request.actual.resource): + return VerifyResult( + verified=False, + reason=VerificationFailureReason.RESOURCE_MISMATCH, + authorized=AuthorizedOperation( + action=mandate.action, + resource=mandate.resource, + ), + actual=request.actual, + ) + + return VerifyResult(verified=True) + + def get_mandate(self, mandate_id: str) -> MandateDetails | None: + """ + Retrieve mandate details from the sidecar. + + Args: + mandate_id: Mandate ID to look up + + Returns: + Mandate details or None if not found + """ + try: + response = self._client.get( + f"{self._base_url}/v1/mandates/{mandate_id}", + headers={"Accept": "application/json"}, + ) + + if response.status_code == 404: + return None + + response.raise_for_status() + data = response.json() + + return MandateDetails( + mandate_id=data["mandate_id"], + principal=data["principal"], + action=data["action"], + resource=data["resource"], + intent_hash=data["intent_hash"], + issued_at=data["issued_at"], + expires_at=data["expires_at"], + ) + except httpx.HTTPError: + raise + except Exception: + return None + + def record_verification(self, request: RecordVerificationRequest) -> str: + """ + Record a verification result in the sidecar's audit log. + + Args: + request: Verification details to record + + Returns: + Audit trail ID + """ + try: + # Build actual payload based on evidence type + actual_payload = self._build_actual_payload(request.actual) + + response = self._client.post( + f"{self._base_url}/v1/verify", + headers={"Content-Type": "application/json"}, + json={ + "mandate_id": request.mandate_id, + "verified": request.verified, + "actual": actual_payload, + "reason": request.reason.value if request.reason else None, + "verified_at": datetime.utcnow().isoformat() + "Z", + }, + ) + + response.raise_for_status() + data = response.json() + + if isinstance(data, dict) and "audit_id" in data: + audit_id = data["audit_id"] + if isinstance(audit_id, str): + return audit_id + + # Graceful fallback: generate a local audit ID + return f"local_audit_{int(time.time() * 1000)}_{secrets.token_hex(4)}" + except httpx.HTTPError: + raise + except Exception: + # Graceful fallback: generate a local audit ID + return f"local_audit_{int(time.time() * 1000)}_{secrets.token_hex(4)}" + + def _build_actual_payload( + self, actual: ExecutionEvidence | ActualOperation + ) -> dict[str, object]: + """ + Build the actual operation payload for the verification request. + + Handles the discriminated union by extracting type-specific fields. + """ + # Common fields present on all evidence types + payload: dict[str, object] = { + "action": actual.action, + "resource": actual.resource, + "executed_at": actual.executed_at, + } + + # Add type-specific fields based on evidence type + if isinstance(actual, FileEvidence): + payload["type"] = "file" + payload["content_hash"] = actual.content_hash + payload["file_size"] = actual.file_size + payload["permissions"] = actual.permissions + payload["modified_at"] = actual.modified_at + elif isinstance(actual, CliEvidence): + payload["type"] = "cli" + payload["command"] = actual.command + payload["exit_code"] = actual.exit_code + payload["stdout_hash"] = actual.stdout_hash + payload["stderr_hash"] = actual.stderr_hash + payload["transcript_hash"] = actual.transcript_hash + payload["cwd"] = actual.cwd + payload["duration_ms"] = actual.duration_ms + elif isinstance(actual, BrowserEvidence): + payload["type"] = "browser" + payload["final_url"] = actual.final_url + payload["selector"] = actual.selector + payload["a11y_tree_hash"] = actual.a11y_tree_hash + payload["dom_state_hash"] = actual.dom_state_hash + payload["screenshot_hash"] = actual.screenshot_hash + payload["page_title"] = actual.page_title + elif isinstance(actual, HttpEvidence): + payload["type"] = "http" + payload["method"] = actual.method + payload["status_code"] = actual.status_code + payload["response_body_hash"] = actual.response_body_hash + payload["content_type"] = actual.content_type + payload["response_size"] = actual.response_size + payload["duration_ms"] = actual.duration_ms + elif isinstance(actual, DbEvidence): + payload["type"] = "db" + payload["query_hash"] = actual.query_hash + payload["rows_affected"] = actual.rows_affected + payload["result_hash"] = actual.result_hash + payload["duration_ms"] = actual.duration_ms + elif isinstance(actual, GenericEvidence): + payload["type"] = "generic" + payload["evidence_hash"] = actual.evidence_hash + payload["metadata"] = actual.metadata + elif isinstance(actual, ActualOperation): + # Legacy ActualOperation - extract known fields + payload["content_hash"] = actual.content_hash + payload["transcript_hash"] = actual.transcript_hash + + return payload + + def close(self) -> None: + """Close the HTTP client.""" + self._client.close() + + def __enter__(self) -> Verifier: + return self + + def __exit__(self, *args: object) -> None: + self.close() diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000..09f4aa1 --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,378 @@ +"""Tests for post-execution verification module.""" + +from datetime import datetime, timedelta + +from predicate_authority.verify import ( + ActualOperation, + BrowserEvidence, + CliEvidence, + DbEvidence, + FileEvidence, + GenericEvidence, + HttpEvidence, + MandateDetails, + VerificationFailureReason, + Verifier, + VerifyRequest, + actions_match, + get_evidence_type, + normalize_resource, + resources_match, +) + + +class TestNormalizeResource: + """Tests for normalize_resource function.""" + + def test_normalizes_filesystem_paths_multiple_slashes(self) -> None: + assert normalize_resource("/src//index.ts") == "/src/index.ts" + + def test_normalizes_filesystem_paths_trailing_slash(self) -> None: + assert normalize_resource("/src/") == "/src" + + def test_normalizes_filesystem_paths_dot_segments(self) -> None: + assert normalize_resource("/src/./index.ts") == "/src/index.ts" + + def test_normalizes_url_like_resources(self) -> None: + assert ( + normalize_resource("https://api.example.com//users") == "https://api.example.com/users" + ) + assert ( + normalize_resource("https://api.example.com/users/") == "https://api.example.com/users" + ) + + def test_preserves_url_protocol(self) -> None: + assert normalize_resource("https://api.example.com/path") == "https://api.example.com/path" + + +class TestResourcesMatch: + """Tests for resources_match function.""" + + def test_matches_identical_resources(self) -> None: + assert resources_match("/src/index.ts", "/src/index.ts") is True + + def test_matches_after_normalization(self) -> None: + assert resources_match("/src//index.ts", "/src/index.ts") is True + assert resources_match("/src/index.ts", "/src//index.ts") is True + + def test_supports_glob_patterns_in_authorized_resource(self) -> None: + assert resources_match("/src/*.ts", "/src/index.ts") is True + assert resources_match("/src/**/*.ts", "/src/utils/helpers.ts") is True + assert resources_match("/src/*.ts", "/src/index.js") is False + + def test_rejects_mismatched_resources(self) -> None: + assert resources_match("/src/index.ts", "/src/main.ts") is False + assert resources_match("/src/index.ts", "/lib/index.ts") is False + + def test_can_disable_glob_matching(self) -> None: + assert resources_match("/src/*.ts", "/src/index.ts", allow_glob=False) is False + + +class TestActionsMatch: + """Tests for actions_match function.""" + + def test_matches_identical_actions(self) -> None: + assert actions_match("fs.read", "fs.read") is True + + def test_handles_whitespace(self) -> None: + assert actions_match("fs.read", " fs.read ") is True + assert actions_match(" fs.read ", "fs.read") is True + + def test_supports_glob_patterns(self) -> None: + assert actions_match("fs.*", "fs.read") is True + assert actions_match("fs.*", "fs.write") is True + assert actions_match("http.*", "fs.read") is False + + def test_is_case_sensitive(self) -> None: + assert actions_match("fs.read", "fs.READ") is False + + +class TestVerifierVerifyLocal: + """Tests for Verifier.verify_local method.""" + + def setup_method(self) -> None: + self.verifier = Verifier(base_url="http://127.0.0.1:8787") + now = datetime.utcnow() + self.base_mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.read", + resource="/src/index.ts", + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now + timedelta(minutes=15)).isoformat() + "Z", + ) + + def test_verifies_matching_operation(self) -> None: + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/index.ts", + ), + ) + + result = self.verifier.verify_local(self.base_mandate, request) + assert result.verified is True + assert result.reason is None + + def test_detects_action_mismatch(self) -> None: + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.write", # Different action + resource="/src/index.ts", + ), + ) + + result = self.verifier.verify_local(self.base_mandate, request) + assert result.verified is False + assert result.reason == VerificationFailureReason.ACTION_MISMATCH + assert result.authorized is not None + assert result.authorized.action == "fs.read" + assert result.actual is not None + assert result.actual.action == "fs.write" + + def test_detects_resource_mismatch(self) -> None: + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/default.ts", # Different resource + ), + ) + + result = self.verifier.verify_local(self.base_mandate, request) + assert result.verified is False + assert result.reason == VerificationFailureReason.RESOURCE_MISMATCH + assert result.authorized is not None + assert result.authorized.resource == "/src/index.ts" + assert result.actual is not None + assert result.actual.resource == "/src/default.ts" + + def test_detects_expired_mandate(self) -> None: + now = datetime.utcnow() + expired_mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.read", + resource="/src/index.ts", + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now - timedelta(seconds=1)).isoformat() + "Z", # Expired + ) + + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/index.ts", + ), + ) + + result = self.verifier.verify_local(expired_mandate, request) + assert result.verified is False + assert result.reason == VerificationFailureReason.MANDATE_EXPIRED + + def test_supports_glob_patterns_in_mandate_resource(self) -> None: + now = datetime.utcnow() + glob_mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.read", + resource="/src/*.ts", # Glob pattern + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now + timedelta(minutes=15)).isoformat() + "Z", + ) + + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/index.ts", + ), + ) + + result = self.verifier.verify_local(glob_mandate, request) + assert result.verified is True + + def test_supports_glob_patterns_in_mandate_action(self) -> None: + now = datetime.utcnow() + glob_mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.*", # Glob pattern + resource="/src/index.ts", + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now + timedelta(minutes=15)).isoformat() + "Z", + ) + + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/index.ts", + ), + ) + + result = self.verifier.verify_local(glob_mandate, request) + assert result.verified is True + + +class TestVerificationEdgeCases: + """Tests for edge cases in verification.""" + + def setup_method(self) -> None: + self.verifier = Verifier(base_url="http://127.0.0.1:8787") + + def test_handles_path_traversal_attempts(self) -> None: + now = datetime.utcnow() + mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.read", + resource="/src/index.ts", + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now + timedelta(minutes=15)).isoformat() + "Z", + ) + + # Attempt to read a different file via path traversal + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/../etc/passwd", + ), + ) + + result = self.verifier.verify_local(mandate, request) + assert result.verified is False + assert result.reason == VerificationFailureReason.RESOURCE_MISMATCH + + def test_handles_content_hash_in_actual_operation(self) -> None: + now = datetime.utcnow() + mandate = MandateDetails( + mandate_id="m_123", + principal="agent:claude", + action="fs.read", + resource="/src/index.ts", + intent_hash="ih_test", + issued_at=now.isoformat() + "Z", + expires_at=(now + timedelta(minutes=15)).isoformat() + "Z", + ) + + request = VerifyRequest( + mandate_id="m_123", + actual=ActualOperation( + action="fs.read", + resource="/src/index.ts", + content_hash="sha256:abc123...", + executed_at=now.isoformat() + "Z", + ), + ) + + result = self.verifier.verify_local(mandate, request) + assert result.verified is True + + +class TestGetEvidenceType: + """Tests for get_evidence_type function.""" + + def test_returns_file_for_fs_actions(self) -> None: + assert get_evidence_type("fs.read") == "file" + assert get_evidence_type("fs.write") == "file" + assert get_evidence_type("file.delete") == "file" + + def test_returns_cli_for_terminal_actions(self) -> None: + assert get_evidence_type("cli.exec") == "cli" + assert get_evidence_type("shell.run") == "cli" + assert get_evidence_type("terminal.spawn") == "cli" + + def test_returns_browser_for_web_actions(self) -> None: + assert get_evidence_type("browser.click") == "browser" + assert get_evidence_type("browser.navigate") == "browser" + assert get_evidence_type("web.scrape") == "browser" + + def test_returns_http_for_network_actions(self) -> None: + assert get_evidence_type("http.get") == "http" + assert get_evidence_type("http.post") == "http" + assert get_evidence_type("https.request") == "http" + + def test_returns_db_for_database_actions(self) -> None: + assert get_evidence_type("db.query") == "db" + assert get_evidence_type("database.insert") == "db" + assert get_evidence_type("sql.execute") == "db" + + def test_returns_generic_for_unknown_actions(self) -> None: + assert get_evidence_type("custom.action") == "generic" + assert get_evidence_type("unknown.operation") == "generic" + + +class TestEvidenceTypes: + """Tests for discriminated union evidence types.""" + + def test_file_evidence_has_correct_type(self) -> None: + evidence = FileEvidence( + type="file", + action="fs.read", + resource="/src/index.ts", + content_hash="sha256:abc123", + ) + assert evidence.type == "file" + assert evidence.action == "fs.read" + assert evidence.content_hash == "sha256:abc123" + + def test_cli_evidence_has_correct_type(self) -> None: + evidence = CliEvidence( + type="cli", + action="cli.exec", + resource="ls -la", + exit_code=0, + transcript_hash="sha256:def456", + ) + assert evidence.type == "cli" + assert evidence.exit_code == 0 + + def test_browser_evidence_has_correct_type(self) -> None: + evidence = BrowserEvidence( + type="browser", + action="browser.navigate", + resource="https://example.com", + final_url="https://example.com/redirected", + ) + assert evidence.type == "browser" + assert evidence.final_url == "https://example.com/redirected" + + def test_http_evidence_has_correct_type(self) -> None: + evidence = HttpEvidence( + type="http", + action="http.get", + resource="https://api.example.com/users", + status_code=200, + method="GET", + ) + assert evidence.type == "http" + assert evidence.status_code == 200 + + def test_db_evidence_has_correct_type(self) -> None: + evidence = DbEvidence( + type="db", + action="db.query", + resource="users", + rows_affected=5, + ) + assert evidence.type == "db" + assert evidence.rows_affected == 5 + + def test_generic_evidence_has_correct_type(self) -> None: + evidence = GenericEvidence( + type="generic", + action="custom.action", + resource="some-resource", + metadata={"key": "value"}, + ) + assert evidence.type == "generic" + assert evidence.metadata == {"key": "value"} From 6f736d5cf3e9ba6356a5a839a7e24c4b7f92d8d7 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sat, 28 Feb 2026 14:03:24 -0800 Subject: [PATCH 2/2] fix tests --- predicate_authority/pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/predicate_authority/pyproject.toml b/predicate_authority/pyproject.toml index e80b201..34ca0ab 100644 --- a/predicate_authority/pyproject.toml +++ b/predicate_authority/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "predicate-contracts>=0.1.0,<0.5.0", "pyyaml>=6.0", "cryptography>=42.0.0", + "httpx>=0.25.0", ] [project.optional-dependencies] @@ -32,8 +33,9 @@ predicate-download-sidecar = "predicate_authority.sidecar_binary:_cli_download" Documentation = "https://www.PredicateSystems.ai/docs" [tool.setuptools] -packages = ["predicate_authority", "predicate_authority.integrations"] +packages = ["predicate_authority", "predicate_authority.integrations", "predicate_authority.verify"] [tool.setuptools.package-dir] "predicate_authority" = "." "predicate_authority.integrations" = "integrations" +"predicate_authority.verify" = "verify"