From d94f43f904bf729d8cdee07abf0c89a5b1ab20e9 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Tue, 3 Mar 2026 19:49:46 -0800 Subject: [PATCH] support for additional actions --- docs/authorityd-operations.md | 128 +++++- docs/predicate-authority-user-manual.md | 74 ++++ predicate_authority/__init__.py | 13 + predicate_authority/sidecar_client.py | 492 ++++++++++++++++++++++++ predicate_contracts/__init__.py | 35 +- predicate_contracts/models.py | 165 ++++++++ tests/test_execute.py | 354 +++++++++++++++++ 7 files changed, 1259 insertions(+), 2 deletions(-) create mode 100644 predicate_authority/sidecar_client.py create mode 100644 tests/test_execute.py diff --git a/docs/authorityd-operations.md b/docs/authorityd-operations.md index 4735e94..82f24bc 100644 --- a/docs/authorityd-operations.md +++ b/docs/authorityd-operations.md @@ -665,7 +665,133 @@ Expected startup output: predicate-authorityd listening on http://127.0.0.1:8787 (mode=local_only) ``` -## 3) Endpoint checks +## 3) API Endpoints + +### Core Authorization + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/authorize` | POST | Core authorization check - returns mandate if allowed | +| `/v1/delegate` | POST | Delegate mandate to sub-agent | +| `/v1/execute` | POST | Execute operation via sidecar (zero-trust mode) | + +### Operations + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check | +| `/status` | GET | Stats and status | +| `/metrics` | GET | Prometheus metrics | +| `/policy/reload` | POST | Hot-reload policy | +| `/ledger/flush-now` | POST | Trigger immediate audit flush | +| `/ledger/dead-letter` | GET | Inspect quarantined events | +| `/ledger/requeue` | POST | Requeue a dead-letter item | + +--- + +## 3a) Execution Proxying (Zero-Trust Mode) + +The `/v1/execute` endpoint enables **zero-trust execution** where the sidecar executes operations on behalf of agents. This prevents "confused deputy" attacks where an agent requests authorization for one resource but accesses another. + +### Flow Comparison + +``` +Traditional (Cooperative): Zero-Trust (Execution Proxy): +┌─────────┐ authorize ┌─────────┐ ┌─────────┐ execute ┌─────────┐ +│ Agent │────────────▶│ Sidecar │ │ Agent │───────────▶│ Sidecar │ +│ │◀────────────│ │ │ │◀───────────│ │ +│ │ ALLOWED │ │ │ │ result │ (reads │ +│ │ │ │ │ │ │ file) │ +│ reads │ │ │ └─────────┘ └─────────┘ +│ file │ │ │ +│ (could │ │ │ Agent never touches the resource +│ cheat) │ │ │ directly - sidecar is the executor +└─────────┘ └─────────┘ +``` + +### Using Execute Proxy + +**Step 1: Authorize and get a mandate** + +```bash +curl -X POST http://127.0.0.1:8787/v1/authorize \ + -H "Content-Type: application/json" \ + -d '{"principal":"agent:web","action":"fs.read","resource":"/src/index.ts"}' +# Returns: {"allowed":true,"reason":"allowed","mandate_id":"m_abc123"} +``` + +**Step 2: Execute through the sidecar** + +```bash +curl -X POST http://127.0.0.1:8787/v1/execute \ + -H "Content-Type: application/json" \ + -d '{ + "mandate_id": "m_abc123", + "action": "fs.read", + "resource": "/src/index.ts" + }' +# Returns: {"success":true,"result":{"type":"file_read","content":"...","size":1234,"content_hash":"sha256:..."}} +``` + +### Supported Actions + +| Action | Payload | Result | +|--------|---------|--------| +| `fs.read` | None | `FileRead { content, size, content_hash }` | +| `fs.write` | `{ type: "file_write", content, create?, append? }` | `FileWrite { bytes_written, content_hash }` | +| `fs.list` | None | `FileList { entries: [{ name, type, size, modified? }], total_entries }` | +| `fs.delete` | `{ type: "file_delete", recursive? }` | `FileDelete { paths_removed }` | +| `cli.exec` | `{ type: "cli_exec", command, args?, cwd?, timeout_ms? }` | `CliExec { exit_code, stdout, stderr, duration_ms }` | +| `http.fetch` | `{ type: "http_fetch", method, headers?, body? }` | `HttpFetch { status_code, headers, body, body_hash }` | +| `env.read` | `{ type: "env_read", keys: ["VAR_NAME"] }` | `EnvRead { values: { "VAR_NAME": "..." } }` | + +### Security Guarantees + +- **Mandate validation**: Mandate must exist and not be expired +- **Action matching**: Requested action must match mandate's action +- **Resource scope**: Requested resource must match mandate's resource scope +- **Audit trail**: All executions logged to proof ledger with evidence hashes +- **Recursive delete safety**: `fs.delete` with `recursive: true` requires explicit policy allowlist +- **Env var filtering**: `env.read` only returns values for explicitly authorized keys + +### SDK Integration + +**Python:** + +```python +from predicate_authority import SidecarClient, AuthorizeAndExecuteOptions + +async with SidecarClient() as client: + # Combined authorize + execute in one call + response = await client.authorize_and_execute( + AuthorizeAndExecuteOptions( + principal="agent:web", + action="fs.read", + resource="/src/index.ts" + ) + ) + print(response.result.content) +``` + +**TypeScript:** + +```typescript +import { AuthorityClient } from "@predicatesystems/authority"; + +const client = new AuthorityClient({ baseUrl: "http://127.0.0.1:8787" }); + +// Combined authorize + execute +const response = await client.authorizeAndExecute({ + principal: "agent:web", + action: "fs.read", + resource: "/src/index.ts", +}); +console.log(response.result?.content); +``` + +--- + +## 3b) Endpoint checks ### Health diff --git a/docs/predicate-authority-user-manual.md b/docs/predicate-authority-user-manual.md index 47f7510..02fe742 100644 --- a/docs/predicate-authority-user-manual.md +++ b/docs/predicate-authority-user-manual.md @@ -470,6 +470,80 @@ Expected delegation path output: --- +## Execution Proxying (Zero-Trust Mode) + +The `/v1/execute` endpoint enables **zero-trust execution** where the sidecar executes operations on behalf of agents. This prevents "confused deputy" attacks where an agent requests authorization for one resource but accesses another. + +### Why Zero-Trust? + +In cooperative mode, the agent asks for permission and then executes the operation itself. A compromised agent could authorize `fs.read /safe/file` but actually read `/etc/passwd`. In zero-trust mode, the sidecar executes the operation, ensuring the authorized resource is what gets accessed. + +### Using Execute Proxy + +```python +from predicate_authority import SidecarClient, AuthorizeAndExecuteOptions + +async with SidecarClient() as client: + # Combined authorize + execute in one call + response = await client.authorize_and_execute( + AuthorizeAndExecuteOptions( + principal="agent:web", + action="fs.read", + resource="/src/index.ts" + ) + ) + print(response.result.content) # File content from sidecar +``` + +Or step-by-step: + +```python +from predicate_authority import SidecarClient +from predicate_contracts import ExecuteRequest + +async with SidecarClient() as client: + # Step 1: Authorize + auth = await client.authorize( + principal="agent:web", + action="fs.read", + resource="/src/index.ts" + ) + + if not auth.allowed: + raise RuntimeError(f"Denied: {auth.reason}") + + # Step 2: Execute through sidecar + result = await client.execute(ExecuteRequest( + mandate_id=auth.mandate_id, + action="fs.read", + resource="/src/index.ts" + )) + + print(result.result.content) # File content +``` + +### Supported Actions + +| Action | Payload | Result | +|--------|---------|--------| +| `fs.read` | None | `FileReadResult { content, size, content_hash }` | +| `fs.write` | `FileWritePayload { content, create?, append? }` | `FileWriteResult { bytes_written, content_hash }` | +| `fs.list` | None | `FileListResult { entries, total_entries }` | +| `fs.delete` | `FileDeletePayload { recursive? }` | `FileDeleteResult { paths_removed }` | +| `cli.exec` | `CliExecPayload { command, args?, cwd?, timeout_ms? }` | `CliExecResult { exit_code, stdout, stderr, duration_ms }` | +| `http.fetch` | `HttpFetchPayload { method, headers?, body? }` | `HttpFetchResult { status_code, headers, body, body_hash }` | +| `env.read` | `EnvReadPayload { keys }` | `EnvReadResult { values }` | + +### Security Guarantees + +- Mandate must exist and not be expired +- Requested action/resource must match mandate +- All executions logged to proof ledger with evidence hashes +- `fs.delete` with `recursive: true` requires explicit policy allowlist +- `env.read` only returns values for explicitly authorized keys + +--- + ## Local identity registry + flush queue Enable ephemeral task identity registry and local ledger queue: diff --git a/predicate_authority/__init__.py b/predicate_authority/__init__.py index 4f41ec9..1a19b2e 100644 --- a/predicate_authority/__init__.py +++ b/predicate_authority/__init__.py @@ -54,6 +54,13 @@ is_sidecar_available, run_sidecar, ) +from predicate_authority.sidecar_client import AuthorizationResponse as SidecarAuthorizationResponse +from predicate_authority.sidecar_client import ( + AuthorizeAndExecuteOptions, + SidecarClient, + SidecarClientConfig, + SidecarClientError, +) from predicate_authority.telemetry import OpenTelemetryTraceEmitter from predicate_authority.verify import ( ActualOperation, @@ -135,4 +142,10 @@ "get_sidecar_version", "is_sidecar_available", "run_sidecar", + # Sidecar HTTP client (Phase 5: Execution Proxying) + "AuthorizeAndExecuteOptions", + "SidecarAuthorizationResponse", + "SidecarClient", + "SidecarClientConfig", + "SidecarClientError", ] diff --git a/predicate_authority/sidecar_client.py b/predicate_authority/sidecar_client.py new file mode 100644 index 0000000..43081e4 --- /dev/null +++ b/predicate_authority/sidecar_client.py @@ -0,0 +1,492 @@ +""" +Sidecar HTTP client for Phase 5: Execution Proxying (Zero-Trust). + +This client communicates with the predicate-authorityd sidecar via HTTP +to authorize and execute operations in a zero-trust manner. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import httpx + +from predicate_contracts import ( + CliExecPayload, + CliExecResult, + DirectoryEntry, + EnvReadPayload, + EnvReadResult, + ExecuteRequest, + ExecuteResponse, + FileDeletePayload, + FileDeleteResult, + FileListResult, + FileReadResult, + FileWritePayload, + FileWriteResult, + HttpFetchPayload, + HttpFetchResult, +) + + +class SidecarClientError(Exception): + """Error communicating with the sidecar.""" + + def __init__( + self, + message: str, + *, + code: str = "unknown", + status: int | None = None, + details: Any = None, + ) -> None: + super().__init__(message) + self.code = code + self.status = status + self.details = details + + +@dataclass(frozen=True) +class SidecarClientConfig: + """Configuration for the SidecarClient.""" + + base_url: str = "http://127.0.0.1:8787" + timeout_s: float = 2.0 + max_retries: int = 0 + backoff_initial_s: float = 0.2 + authorize_endpoint: str = "/v1/authorize" + execute_endpoint: str = "/v1/execute" + + +@dataclass +class AuthorizationResponse: + """Response from the /v1/authorize endpoint.""" + + allowed: bool + reason: str + mandate_id: str | None = None + missing_labels: list[str] = field(default_factory=list) + + +@dataclass +class AuthorizeAndExecuteOptions: + """Options for authorize_and_execute convenience method.""" + + principal: str + action: str + resource: str + intent_hash: str | None = None + labels: list[str] = field(default_factory=list) + payload: FileWritePayload | CliExecPayload | HttpFetchPayload | None = None + + +class SidecarClient: + """ + HTTP client for communicating with the predicate-authorityd sidecar. + + This client supports Phase 5 Execution Proxying (Zero-Trust) where the sidecar + executes operations on behalf of agents, preventing "confused deputy" attacks. + + Example: + >>> client = SidecarClient() + >>> # Direct execute with existing mandate + >>> response = await client.execute(ExecuteRequest( + ... mandate_id="m_abc123", + ... action="fs.read", + ... resource="/src/index.ts" + ... )) + >>> print(response.result) + + >>> # Combined authorize + execute + >>> response = await client.authorize_and_execute(AuthorizeAndExecuteOptions( + ... principal="agent:web", + ... action="fs.read", + ... resource="/src/index.ts" + ... )) + """ + + def __init__(self, config: SidecarClientConfig | None = None) -> None: + self._config = config or SidecarClientConfig() + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self._config.base_url, + timeout=self._config.timeout_s, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client is not None: + await self._client.aclose() + self._client = None + + async def __aenter__(self) -> SidecarClient: + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + await self.close() + + async def authorize( + self, + principal: str, + action: str, + resource: str, + intent_hash: str | None = None, + labels: list[str] | None = None, + ) -> AuthorizationResponse: + """ + Request authorization from the sidecar. + + Args: + principal: The principal making the request (e.g., "agent:web") + action: The action to perform (e.g., "fs.read") + resource: The resource to operate on (e.g., "/src/index.ts") + intent_hash: Optional intent hash for the mandate + labels: Optional labels for authorization + + Returns: + AuthorizationResponse with allowed status and mandate_id if allowed + """ + client = await self._get_client() + + request_body = { + "principal": principal, + "action": action, + "resource": resource, + "intent_hash": intent_hash or f"{action}:{resource}", + "labels": labels or [], + } + + attempts = self._config.max_retries + 1 + last_error: Exception | None = None + + for attempt in range(attempts): + try: + response = await client.post( + self._config.authorize_endpoint, + json=request_body, + ) + + # Sidecar returns 403 for deny decisions with valid response body + if response.status_code == 403: + data = response.json() + return AuthorizationResponse( + allowed=data.get("allowed", False), + reason=data.get("reason", "unknown"), + mandate_id=data.get("mandate_id"), + missing_labels=data.get("missing_labels", []), + ) + + if response.status_code >= 500 and attempt < self._config.max_retries: + await self._backoff(attempt) + continue + + if not response.is_success: + raise SidecarClientError( + f"authorize request failed: {response.status_code}", + code="server_error" if response.status_code >= 500 else "client_error", + status=response.status_code, + details=response.text, + ) + + data = response.json() + return AuthorizationResponse( + allowed=data.get("allowed", False), + reason=data.get("reason", "unknown"), + mandate_id=data.get("mandate_id"), + missing_labels=data.get("missing_labels", []), + ) + + except httpx.TimeoutException as e: + last_error = e + if attempt < self._config.max_retries: + await self._backoff(attempt) + continue + raise SidecarClientError( + "authorize request timed out", + code="timeout", + ) from e + except httpx.RequestError as e: + last_error = e + if attempt < self._config.max_retries: + await self._backoff(attempt) + continue + raise SidecarClientError( + f"authorize request failed: {e}", + code="network_error", + ) from e + + raise SidecarClientError( + "authorize request exhausted retry budget", + code="network_error", + ) from last_error + + async def execute(self, request: ExecuteRequest) -> ExecuteResponse: + """ + Execute an operation through the sidecar (Phase 5: Execution Proxying). + + The sidecar validates the mandate and executes the operation on behalf of + the agent, preventing "confused deputy" attacks where an agent could request + authorization for one resource but access another. + + Args: + request: Execute request with mandate_id from prior authorization + + Returns: + ExecuteResponse with success status and action-specific result + """ + client = await self._get_client() + + request_body = _execute_request_to_dict(request) + + attempts = self._config.max_retries + 1 + last_error: Exception | None = None + + for attempt in range(attempts): + try: + response = await client.post( + self._config.execute_endpoint, + json=request_body, + ) + + # Execute may return 4xx with valid ExecuteResponse containing error info + if response.status_code >= 400 and response.status_code < 500: + data = response.json() + if "success" in data and "audit_id" in data: + return _parse_execute_response(data) + + if response.status_code >= 500 and attempt < self._config.max_retries: + await self._backoff(attempt) + continue + + if not response.is_success: + raise SidecarClientError( + f"execute request failed: {response.status_code}", + code="server_error" if response.status_code >= 500 else "client_error", + status=response.status_code, + details=response.text, + ) + + data = response.json() + return _parse_execute_response(data) + + except httpx.TimeoutException as e: + last_error = e + if attempt < self._config.max_retries: + await self._backoff(attempt) + continue + raise SidecarClientError( + "execute request timed out", + code="timeout", + ) from e + except httpx.RequestError as e: + last_error = e + if attempt < self._config.max_retries: + await self._backoff(attempt) + continue + raise SidecarClientError( + f"execute request failed: {e}", + code="network_error", + ) from e + + raise SidecarClientError( + "execute request exhausted retry budget", + code="network_error", + ) from last_error + + async def authorize_and_execute( + self, + options: AuthorizeAndExecuteOptions, + ) -> ExecuteResponse: + """ + Convenience method that combines authorize + execute in a single call. + + This is the recommended pattern for zero-trust execution: + 1. Authorize the action and obtain a mandate + 2. Execute the operation through the sidecar using the mandate + + Args: + options: Authorization and execution options + + Returns: + ExecuteResponse with success status and action-specific result + + Raises: + SidecarClientError: If authorization is denied or execution fails + """ + # Step 1: Authorize and get mandate + auth_response = await self.authorize( + principal=options.principal, + action=options.action, + resource=options.resource, + intent_hash=options.intent_hash, + labels=options.labels, + ) + + if not auth_response.allowed: + raise SidecarClientError( + f"authorization denied: {auth_response.reason}", + code="forbidden", + details={ + "reason": auth_response.reason, + "missing_labels": auth_response.missing_labels, + }, + ) + + if not auth_response.mandate_id: + raise SidecarClientError( + "authorization succeeded but no mandate_id returned", + code="protocol_error", + details={"auth_response": auth_response}, + ) + + # Step 2: Execute through sidecar + return await self.execute( + ExecuteRequest( + mandate_id=auth_response.mandate_id, + action=options.action, + resource=options.resource, + payload=options.payload, + ) + ) + + async def _backoff(self, attempt: int) -> None: + """Exponential backoff between retry attempts.""" + delay = self._config.backoff_initial_s * (attempt + 1) + await _async_sleep(delay) + + +async def _async_sleep(seconds: float) -> None: + """Async sleep helper.""" + import asyncio + + await asyncio.sleep(seconds) + + +def _execute_request_to_dict(request: ExecuteRequest) -> dict[str, Any]: + """Convert ExecuteRequest to wire format dict.""" + result: dict[str, Any] = { + "mandate_id": request.mandate_id, + "action": request.action, + "resource": request.resource, + } + + if request.payload is not None: + if isinstance(request.payload, FileWritePayload): + result["payload"] = { + "type": "file_write", + "content": request.payload.content, + "create": request.payload.create, + "append": request.payload.append, + } + elif isinstance(request.payload, CliExecPayload): + payload_dict: dict[str, Any] = { + "type": "cli_exec", + "command": request.payload.command, + "args": list(request.payload.args), + } + if request.payload.cwd is not None: + payload_dict["cwd"] = request.payload.cwd + if request.payload.timeout_ms is not None: + payload_dict["timeout_ms"] = request.payload.timeout_ms + result["payload"] = payload_dict + elif isinstance(request.payload, HttpFetchPayload): + payload_dict = { + "type": "http_fetch", + "method": request.payload.method, + } + if request.payload.headers is not None: + payload_dict["headers"] = request.payload.headers + if request.payload.body is not None: + payload_dict["body"] = request.payload.body + result["payload"] = payload_dict + elif isinstance(request.payload, FileDeletePayload): + result["payload"] = { + "type": "file_delete", + "recursive": request.payload.recursive, + } + elif isinstance(request.payload, EnvReadPayload): + result["payload"] = { + "type": "env_read", + "keys": list(request.payload.keys), + } + + return result + + +def _parse_execute_response(data: dict[str, Any]) -> ExecuteResponse: + """Parse execute response from wire format.""" + result: ( + FileReadResult + | FileWriteResult + | CliExecResult + | HttpFetchResult + | FileListResult + | FileDeleteResult + | EnvReadResult + | None + ) = None + if "result" in data and data["result"] is not None: + result_data = data["result"] + result_type = result_data.get("type") + + if result_type == "file_read": + result = FileReadResult( + content=result_data["content"], + size=result_data["size"], + content_hash=result_data["content_hash"], + ) + elif result_type == "file_write": + result = FileWriteResult( + bytes_written=result_data["bytes_written"], + content_hash=result_data["content_hash"], + ) + elif result_type == "cli_exec": + result = CliExecResult( + exit_code=result_data["exit_code"], + stdout=result_data["stdout"], + stderr=result_data["stderr"], + duration_ms=result_data["duration_ms"], + ) + elif result_type == "http_fetch": + result = HttpFetchResult( + status_code=result_data["status_code"], + headers=result_data["headers"], + body=result_data["body"], + body_hash=result_data["body_hash"], + ) + elif result_type == "file_list": + entries = tuple( + DirectoryEntry( + name=e["name"], + entry_type=e["type"], + size=e["size"], + modified=e.get("modified"), + ) + for e in result_data["entries"] + ) + result = FileListResult( + entries=entries, + total_entries=result_data["total_entries"], + ) + elif result_type == "file_delete": + result = FileDeleteResult( + paths_removed=result_data["paths_removed"], + ) + elif result_type == "env_read": + result = EnvReadResult( + values=result_data["values"], + ) + + return ExecuteResponse( + success=data["success"], + audit_id=data["audit_id"], + result=result, + error=data.get("error"), + evidence_hash=data.get("evidence_hash"), + ) diff --git a/predicate_contracts/__init__.py b/predicate_contracts/__init__.py index 8e1dbab..80f4d39 100644 --- a/predicate_contracts/__init__.py +++ b/predicate_contracts/__init__.py @@ -20,11 +20,27 @@ sha256, strip_ansi, ) -from predicate_contracts.models import ( +from predicate_contracts.models import ( # Execute types for Phase 5: Execution Proxying (Zero-Trust) ActionRequest, ActionSpec, AuthorizationDecision, AuthorizationReason, + CliExecPayload, + CliExecResult, + DirectoryEntry, + EnvReadPayload, + EnvReadResult, + ExecuteErrorCode, + ExecuteRequest, + ExecuteResponse, + FileDeletePayload, + FileDeleteResult, + FileListResult, + FileReadResult, + FileWritePayload, + FileWriteResult, + HttpFetchPayload, + HttpFetchResult, MandateClaims, PolicyEffect, PolicyRule, @@ -58,6 +74,23 @@ "VerificationEvidence", "VerificationSignal", "VerificationStatus", + # Execute types for Phase 5: Execution Proxying (Zero-Trust) + "ExecuteErrorCode", + "FileWritePayload", + "CliExecPayload", + "HttpFetchPayload", + "FileDeletePayload", + "EnvReadPayload", + "ExecuteRequest", + "FileReadResult", + "FileWriteResult", + "CliExecResult", + "HttpFetchResult", + "DirectoryEntry", + "FileListResult", + "FileDeleteResult", + "EnvReadResult", + "ExecuteResponse", # Protocols "StateEvidenceProvider", "TraceEmitter", diff --git a/predicate_contracts/models.py b/predicate_contracts/models.py index a1dd6e0..412a473 100644 --- a/predicate_contracts/models.py +++ b/predicate_contracts/models.py @@ -134,3 +134,168 @@ class ProofEvent: allowed: bool mandate_id: str | None emitted_at_epoch_s: int + + +# ============================================================================= +# Execute types for Phase 5: Execution Proxying (Zero-Trust) +# ============================================================================= + + +class ExecuteErrorCode(str, Enum): + """Execution error codes returned by the sidecar.""" + + MANDATE_NOT_FOUND = "mandate_not_found" + MANDATE_EXPIRED = "mandate_expired" + ACTION_MISMATCH = "action_mismatch" + RESOURCE_MISMATCH = "resource_mismatch" + EXECUTION_FAILED = "execution_failed" + UNSUPPORTED_ACTION = "unsupported_action" + INVALID_PAYLOAD = "invalid_payload" + + +@dataclass(frozen=True) +class FileWritePayload: + """Payload for fs.write operations.""" + + content: str + create: bool = False + append: bool = False + + +@dataclass(frozen=True) +class CliExecPayload: + """Payload for cli.exec operations.""" + + command: str + args: tuple[str, ...] = field(default_factory=tuple) + cwd: str | None = None + timeout_ms: int | None = None + + +@dataclass(frozen=True) +class HttpFetchPayload: + """Payload for http.fetch operations.""" + + method: str + headers: dict[str, str] | None = None + body: str | None = None + + +@dataclass(frozen=True) +class FileDeletePayload: + """Payload for fs.delete operations.""" + + recursive: bool = False + + +@dataclass(frozen=True) +class EnvReadPayload: + """Payload for env.read operations.""" + + keys: tuple[str, ...] + + +@dataclass(frozen=True) +class ExecuteRequest: + """POST /v1/execute request body.""" + + mandate_id: str + action: str + resource: str + payload: ( + FileWritePayload + | CliExecPayload + | HttpFetchPayload + | FileDeletePayload + | EnvReadPayload + | None + ) = None + + +@dataclass(frozen=True) +class FileReadResult: + """Result of fs.read operation.""" + + content: str + size: int + content_hash: str + + +@dataclass(frozen=True) +class FileWriteResult: + """Result of fs.write operation.""" + + bytes_written: int + content_hash: str + + +@dataclass(frozen=True) +class CliExecResult: + """Result of cli.exec operation.""" + + exit_code: int + stdout: str + stderr: str + duration_ms: int + + +@dataclass(frozen=True) +class HttpFetchResult: + """Result of http.fetch operation.""" + + status_code: int + headers: dict[str, str] + body: str + body_hash: str + + +@dataclass(frozen=True) +class DirectoryEntry: + """Directory entry for fs.list result.""" + + name: str + entry_type: str # "file", "dir", "symlink" + size: int + modified: int | None = None + + +@dataclass(frozen=True) +class FileListResult: + """Result of fs.list operation.""" + + entries: tuple[DirectoryEntry, ...] + total_entries: int + + +@dataclass(frozen=True) +class FileDeleteResult: + """Result of fs.delete operation.""" + + paths_removed: int + + +@dataclass(frozen=True) +class EnvReadResult: + """Result of env.read operation.""" + + values: dict[str, str] + + +@dataclass(frozen=True) +class ExecuteResponse: + """POST /v1/execute response body.""" + + success: bool + audit_id: str + result: ( + FileReadResult + | FileWriteResult + | CliExecResult + | HttpFetchResult + | FileListResult + | FileDeleteResult + | EnvReadResult + | None + ) = None + error: str | None = None + evidence_hash: str | None = None diff --git a/tests/test_execute.py b/tests/test_execute.py new file mode 100644 index 0000000..ea75cfa --- /dev/null +++ b/tests/test_execute.py @@ -0,0 +1,354 @@ +""" +Tests for Phase 5: Execution Proxying (Zero-Trust) types and SidecarClient. +""" + +from __future__ import annotations + +import os + +import pytest + +from predicate_contracts import ( + CliExecPayload, + CliExecResult, + ExecuteErrorCode, + ExecuteRequest, + ExecuteResponse, + FileReadResult, + FileWritePayload, + FileWriteResult, + HttpFetchPayload, + HttpFetchResult, +) + + +class TestExecuteTypes: + """Tests for execute types serialization and dataclass behavior.""" + + def test_execute_request_without_payload(self) -> None: + request = ExecuteRequest( + mandate_id="m_abc123", + action="fs.read", + resource="/src/index.ts", + ) + assert request.mandate_id == "m_abc123" + assert request.action == "fs.read" + assert request.resource == "/src/index.ts" + assert request.payload is None + + def test_execute_request_with_file_write_payload(self) -> None: + payload = FileWritePayload( + content="hello world", + create=True, + append=False, + ) + request = ExecuteRequest( + mandate_id="m_xyz789", + action="fs.write", + resource="/tmp/test.txt", + payload=payload, + ) + assert isinstance(request.payload, FileWritePayload) + assert request.payload.content == "hello world" + assert request.payload.create is True + + def test_execute_request_with_cli_exec_payload(self) -> None: + payload = CliExecPayload( + command="ls", + args=("-la",), + cwd="/tmp", + timeout_ms=5000, + ) + request = ExecuteRequest( + mandate_id="m_cli456", + action="cli.exec", + resource="ls", + payload=payload, + ) + assert isinstance(request.payload, CliExecPayload) + assert request.payload.command == "ls" + assert request.payload.args == ("-la",) + assert request.payload.cwd == "/tmp" + assert request.payload.timeout_ms == 5000 + + def test_execute_request_with_http_fetch_payload(self) -> None: + payload = HttpFetchPayload( + method="POST", + headers={"Content-Type": "application/json"}, + body='{"key": "value"}', + ) + request = ExecuteRequest( + mandate_id="m_http789", + action="http.fetch", + resource="https://api.example.com/data", + payload=payload, + ) + assert isinstance(request.payload, HttpFetchPayload) + assert request.payload.method == "POST" + assert request.payload.headers == {"Content-Type": "application/json"} + + +class TestExecuteResults: + """Tests for execute result types.""" + + def test_file_read_result(self) -> None: + result = FileReadResult( + content="file content", + size=12, + content_hash="sha256:abc123", + ) + assert result.content == "file content" + assert result.size == 12 + assert result.content_hash == "sha256:abc123" + + def test_file_write_result(self) -> None: + result = FileWriteResult( + bytes_written=100, + content_hash="sha256:def456", + ) + assert result.bytes_written == 100 + assert result.content_hash == "sha256:def456" + + def test_cli_exec_result(self) -> None: + result = CliExecResult( + exit_code=0, + stdout="output", + stderr="", + duration_ms=150, + ) + assert result.exit_code == 0 + assert result.stdout == "output" + assert result.stderr == "" + assert result.duration_ms == 150 + + def test_http_fetch_result(self) -> None: + result = HttpFetchResult( + status_code=200, + headers={"content-type": "application/json"}, + body='{"ok": true}', + body_hash="sha256:xyz789", + ) + assert result.status_code == 200 + assert result.headers == {"content-type": "application/json"} + assert result.body == '{"ok": true}' + assert result.body_hash == "sha256:xyz789" + + +class TestExecuteResponse: + """Tests for ExecuteResponse.""" + + def test_execute_response_success(self) -> None: + result = FileReadResult( + content="file content", + size=12, + content_hash="sha256:abc123", + ) + response = ExecuteResponse( + success=True, + audit_id="exec_123", + result=result, + evidence_hash="sha256:def456", + ) + assert response.success is True + assert response.audit_id == "exec_123" + assert isinstance(response.result, FileReadResult) + assert response.error is None + assert response.evidence_hash == "sha256:def456" + + def test_execute_response_failure(self) -> None: + response = ExecuteResponse( + success=False, + audit_id="exec_456", + error="Mandate not found", + ) + assert response.success is False + assert response.audit_id == "exec_456" + assert response.result is None + assert response.error == "Mandate not found" + assert response.evidence_hash is None + + +class TestExecuteErrorCode: + """Tests for ExecuteErrorCode enum.""" + + def test_error_codes(self) -> None: + assert ExecuteErrorCode.MANDATE_NOT_FOUND.value == "mandate_not_found" + assert ExecuteErrorCode.MANDATE_EXPIRED.value == "mandate_expired" + assert ExecuteErrorCode.ACTION_MISMATCH.value == "action_mismatch" + assert ExecuteErrorCode.RESOURCE_MISMATCH.value == "resource_mismatch" + assert ExecuteErrorCode.EXECUTION_FAILED.value == "execution_failed" + assert ExecuteErrorCode.UNSUPPORTED_ACTION.value == "unsupported_action" + assert ExecuteErrorCode.INVALID_PAYLOAD.value == "invalid_payload" + + +class TestSidecarClientWireFormat: + """Tests for SidecarClient wire format conversion.""" + + def test_execute_request_to_dict(self) -> None: + from predicate_authority.sidecar_client import _execute_request_to_dict + + # Request without payload + request = ExecuteRequest( + mandate_id="m_abc123", + action="fs.read", + resource="/src/index.ts", + ) + result = _execute_request_to_dict(request) + assert result == { + "mandate_id": "m_abc123", + "action": "fs.read", + "resource": "/src/index.ts", + } + + def test_execute_request_to_dict_with_file_write_payload(self) -> None: + from predicate_authority.sidecar_client import _execute_request_to_dict + + request = ExecuteRequest( + mandate_id="m_xyz789", + action="fs.write", + resource="/tmp/test.txt", + payload=FileWritePayload(content="hello", create=True, append=False), + ) + result = _execute_request_to_dict(request) + assert result["payload"] == { + "type": "file_write", + "content": "hello", + "create": True, + "append": False, + } + + def test_execute_request_to_dict_with_cli_exec_payload(self) -> None: + from predicate_authority.sidecar_client import _execute_request_to_dict + + request = ExecuteRequest( + mandate_id="m_cli456", + action="cli.exec", + resource="ls", + payload=CliExecPayload(command="ls", args=("-la",), cwd="/tmp", timeout_ms=5000), + ) + result = _execute_request_to_dict(request) + assert result["payload"] == { + "type": "cli_exec", + "command": "ls", + "args": ["-la"], + "cwd": "/tmp", + "timeout_ms": 5000, + } + + def test_execute_request_to_dict_with_http_fetch_payload(self) -> None: + from predicate_authority.sidecar_client import _execute_request_to_dict + + request = ExecuteRequest( + mandate_id="m_http789", + action="http.fetch", + resource="https://api.example.com/data", + payload=HttpFetchPayload( + method="POST", + headers={"Content-Type": "application/json"}, + body='{"key": "value"}', + ), + ) + result = _execute_request_to_dict(request) + assert result["payload"] == { + "type": "http_fetch", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body": '{"key": "value"}', + } + + def test_parse_execute_response_file_read(self) -> None: + from predicate_authority.sidecar_client import _parse_execute_response + + data = { + "success": True, + "audit_id": "exec_123", + "result": { + "type": "file_read", + "content": "file content", + "size": 12, + "content_hash": "sha256:abc123", + }, + "evidence_hash": "sha256:def456", + } + response = _parse_execute_response(data) + assert response.success is True + assert response.audit_id == "exec_123" + assert isinstance(response.result, FileReadResult) + assert response.result.content == "file content" + assert response.evidence_hash == "sha256:def456" + + def test_parse_execute_response_cli_exec(self) -> None: + from predicate_authority.sidecar_client import _parse_execute_response + + data = { + "success": True, + "audit_id": "exec_456", + "result": { + "type": "cli_exec", + "exit_code": 0, + "stdout": "output", + "stderr": "", + "duration_ms": 150, + }, + } + response = _parse_execute_response(data) + assert response.success is True + assert isinstance(response.result, CliExecResult) + assert response.result.exit_code == 0 + assert response.result.stdout == "output" + + def test_parse_execute_response_failure(self) -> None: + from predicate_authority.sidecar_client import _parse_execute_response + + data = { + "success": False, + "audit_id": "exec_789", + "error": "Mandate not found", + } + response = _parse_execute_response(data) + assert response.success is False + assert response.audit_id == "exec_789" + assert response.result is None + assert response.error == "Mandate not found" + + +# Integration tests that require a running sidecar +@pytest.mark.skipif( + os.environ.get("RUN_SIDECAR_INTEGRATION_TESTS") != "true" + or not os.environ.get("SIDECAR_BASE_URL"), + reason="Sidecar integration tests not enabled", +) +class TestSidecarClientIntegration: + """Integration tests that require a running sidecar.""" + + @pytest.fixture + def client(self): + from predicate_authority import SidecarClient, SidecarClientConfig + + base_url = os.environ.get("SIDECAR_BASE_URL", "http://127.0.0.1:8787") + return SidecarClient(SidecarClientConfig(base_url=base_url)) + + @pytest.mark.asyncio + async def test_execute_returns_mandate_not_found_for_invalid_mandate(self, client) -> None: + response = await client.execute( + ExecuteRequest( + mandate_id="m_nonexistent", + action="fs.read", + resource="/tmp/test.txt", + ) + ) + assert response.success is False + assert "not found" in (response.error or "").lower() + await client.close() + + @pytest.mark.asyncio + async def test_authorize_returns_decision(self, client) -> None: + response = await client.authorize( + principal="agent:test", + action="http.get", + resource="https://example.com", + ) + # Response should have allowed status and reason + assert isinstance(response.allowed, bool) + assert isinstance(response.reason, str) + await client.close()