From eec0a873d51786f6532f588badf6ac4c229f25a1 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Sat, 28 Feb 2026 14:07:26 -0800 Subject: [PATCH] P1: post-execution verification - cooperative --- src/index.ts | 40 ++++ src/verify/comparators.ts | 126 +++++++++++ src/verify/index.ts | 75 +++++++ src/verify/types.ts | 442 ++++++++++++++++++++++++++++++++++++++ src/verify/verifier.ts | 421 ++++++++++++++++++++++++++++++++++++ tests/verify.test.ts | 278 ++++++++++++++++++++++++ 6 files changed, 1382 insertions(+) create mode 100644 src/verify/comparators.ts create mode 100644 src/verify/index.ts create mode 100644 src/verify/types.ts create mode 100644 src/verify/verifier.ts create mode 100644 tests/verify.test.ts diff --git a/src/index.ts b/src/index.ts index 8341ab5..1a75e05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,46 @@ export { type VerificationSignalProvider, } from "./evidence/non-web.js"; +// Post-execution verification module +export { + // Evidence types (discriminated union) + type EvidenceType, + type ExecutionEvidence, + type FileEvidence, + type CliEvidence, + type BrowserEvidence, + type HttpEvidence, + type DbEvidence, + type GenericEvidence, + // Core types + type ActualOperation, + type AuthorizedOperation, + type MandateDetails, + type RecordVerificationRequest, + type RecordVerificationResponse, + type VerificationFailureReason, + type VerifyRequest, + type VerifyResult, + type ResourceMatchOptions, + type MandateProvider, + type VerifierOptions, + // Type guards and helpers + getEvidenceType, + isMandateDetails, + isRecordVerificationResponse, + isFileEvidence, + isCliEvidence, + isBrowserEvidence, + isHttpEvidence, + isDbEvidence, + // Comparators + actionsMatch, + normalizeResource, + resourcesMatch, + // Verifier class + Verifier, +} from "./verify/index.js"; + // Canonicalization module for reproducible state hashes export { // Types diff --git a/src/verify/comparators.ts b/src/verify/comparators.ts new file mode 100644 index 0000000..e87329a --- /dev/null +++ b/src/verify/comparators.ts @@ -0,0 +1,126 @@ +/** + * Resource comparison functions for post-execution verification. + * + * These functions compare authorized resources against actual resources, + * handling path normalization and glob pattern matching. + */ + +import { normalizePath } from "../canonicalization/utils.js"; +import { globMatch } from "../policy/matching.js"; + +/** + * Options for resource matching. + */ +export interface ResourceMatchOptions { + /** Enable glob pattern matching for authorized resource */ + allowGlob?: boolean; +} + +/** + * Normalize a resource path for comparison. + * + * Applies the following transformations: + * - Expands ~ to home directory + * - Collapses multiple slashes + * - Removes ./ segments + * - Removes trailing slashes + * - Resolves . and .. + * + * @param resource - Resource path to normalize + * @returns Normalized path + */ +export function normalizeResource(resource: string): string { + // Use existing normalizePath for filesystem paths + if (resource.startsWith("/") || resource.startsWith("~") || resource.startsWith(".")) { + let normalized = normalizePath(resource); + // normalizePath doesn't strip trailing slashes, so we do it here + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized; + } + + // For URLs, handle protocol specially + const urlMatch = resource.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)/); + if (urlMatch) { + const protocol = urlMatch[1]; // e.g., "https://" + const rest = resource.slice(protocol.length); + + // Normalize the rest (collapse slashes, remove ./, remove trailing /) + const normalized = rest + .replace(/\/+/g, "/") // Collapse multiple slashes + .replace(/\/\.\//g, "/") // Remove ./ + .replace(/\/$/g, ""); // Remove trailing slash + + return protocol + normalized; + } + + // For other non-path resources, do basic cleanup + return resource + .replace(/\/+/g, "/") // Collapse multiple slashes + .replace(/\/\.\//g, "/") // Remove ./ + .replace(/\/$/g, ""); // Remove trailing slash +} + +/** + * Check if an actual resource matches an authorized resource. + * + * Handles: + * - Path normalization (~ expansion, . and .., etc.) + * - Optional glob pattern matching (* wildcards) + * + * @param authorized - Resource from the mandate (may contain glob patterns) + * @param actual - Resource that was actually accessed + * @param options - Matching options + * @returns True if resources match + */ +export function resourcesMatch( + authorized: string, + actual: string, + options: ResourceMatchOptions = {}, +): boolean { + const { allowGlob = true } = options; + + // Normalize both resources + const normalizedAuth = normalizeResource(authorized); + const normalizedActual = normalizeResource(actual); + + // Exact match after normalization + if (normalizedAuth === normalizedActual) { + return true; + } + + // Glob pattern match (if enabled and authorized resource contains wildcards) + if (allowGlob && authorized.includes("*")) { + return globMatch(normalizedActual, authorized); + } + + return false; +} + +/** + * Check if an actual action matches an authorized action. + * + * Actions are compared case-sensitively after trimming whitespace. + * Supports glob patterns in the authorized action. + * + * @param authorized - Action from the mandate (may contain glob patterns) + * @param actual - Action that was actually performed + * @returns True if actions match + */ +export function actionsMatch(authorized: string, actual: string): boolean { + const normalizedAuth = authorized.trim(); + const normalizedActual = actual.trim(); + + // Exact match + if (normalizedAuth === normalizedActual) { + return true; + } + + // Glob pattern match (e.g., "fs.*" matches "fs.read") + if (authorized.includes("*")) { + return globMatch(normalizedActual, authorized); + } + + return false; +} diff --git a/src/verify/index.ts b/src/verify/index.ts new file mode 100644 index 0000000..6369921 --- /dev/null +++ b/src/verify/index.ts @@ -0,0 +1,75 @@ +/** + * Post-execution verification module. + * + * This module provides verification capability to compare actual operations + * against what was authorized via a mandate, detecting unauthorized deviations. + * + * @example + * ```typescript + * import { Verifier } from '@predicatesystems/authority'; + * + * const verifier = new Verifier({ baseUrl: 'http://127.0.0.1:8787' }); + * + * // After executing an authorized operation + * const result = await verifier.verify({ + * mandateId: decision.mandate_id, + * actual: { + * action: 'fs.read', + * resource: '/src/index.ts', + * }, + * }); + * + * if (!result.verified) { + * console.error('Operation mismatch:', result.reason, result.details); + * } + * ``` + * + * @module verify + */ + +// Evidence types (discriminated union) +export type { + EvidenceType, + ExecutionEvidence, + FileEvidence, + CliEvidence, + BrowserEvidence, + HttpEvidence, + DbEvidence, + GenericEvidence, +} from "./types.js"; + +// Core types +export type { + ActualOperation, + AuthorizedOperation, + MandateDetails, + RecordVerificationRequest, + RecordVerificationResponse, + VerificationFailureReason, + VerifyRequest, + VerifyResult, +} from "./types.js"; + +// Type guards and helpers +export { + getEvidenceType, + isMandateDetails, + isRecordVerificationResponse, + isFileEvidence, + isCliEvidence, + isBrowserEvidence, + isHttpEvidence, + isDbEvidence, +} from "./types.js"; + +// Comparators +export { + actionsMatch, + normalizeResource, + resourcesMatch, + type ResourceMatchOptions, +} from "./comparators.js"; + +// Verifier +export { Verifier, type MandateProvider, type VerifierOptions } from "./verifier.js"; diff --git a/src/verify/types.ts b/src/verify/types.ts new file mode 100644 index 0000000..8c7a0fb --- /dev/null +++ b/src/verify/types.ts @@ -0,0 +1,442 @@ +/** + * 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 + */ + +// ============================================================================= +// Discriminated Union Evidence Types +// ============================================================================= + +/** + * Evidence type discriminator. + */ +export type EvidenceType = "file" | "cli" | "browser" | "http" | "db" | "generic"; + +/** + * Base interface for all evidence types. + */ +interface BaseEvidence { + /** Discriminator field for type narrowing */ + type: EvidenceType; + + /** The action that was actually performed (e.g., "fs.read", "cli.exec") */ + action: string; + + /** The resource that was accessed (path, URL, command, etc.) */ + resource: string; + + /** Timestamp when operation was executed (ISO 8601) */ + executedAt?: string; +} + +/** + * Evidence for file system operations (fs.read, fs.write, etc.) + */ +export interface FileEvidence extends BaseEvidence { + type: "file"; + + /** Hash of file content (SHA-256) */ + contentHash?: string; + + /** File size in bytes */ + fileSize?: number; + + /** File permissions (octal string, e.g., "644") */ + permissions?: string; + + /** Last modified timestamp (ISO 8601) */ + modifiedAt?: string; +} + +/** + * Evidence for terminal/CLI operations (cli.exec, cli.spawn, etc.) + */ +export interface CliEvidence extends BaseEvidence { + type: "cli"; + + /** The exact command that was executed */ + command?: string; + + /** Exit code of the process */ + exitCode?: number; + + /** Hash of stdout transcript */ + stdoutHash?: string; + + /** Hash of stderr transcript */ + stderrHash?: string; + + /** Combined transcript hash (stdout + stderr) */ + transcriptHash?: string; + + /** Working directory where command was executed */ + cwd?: string; + + /** Duration in milliseconds */ + durationMs?: number; +} + +/** + * Evidence for browser/web operations (browser.click, browser.navigate, etc.) + */ +export interface BrowserEvidence extends BaseEvidence { + type: "browser"; + + /** Final URL after navigation */ + finalUrl?: string; + + /** DOM selector that was interacted with */ + selector?: string; + + /** Hash of accessibility tree state */ + a11yTreeHash?: string; + + /** Hash of visible DOM state */ + domStateHash?: string; + + /** Screenshot hash (if captured) */ + screenshotHash?: string; + + /** Page title after operation */ + pageTitle?: string; +} + +/** + * Evidence for HTTP operations (http.get, http.post, etc.) + */ +export interface HttpEvidence extends BaseEvidence { + type: "http"; + + /** HTTP method used */ + method?: string; + + /** Response status code */ + statusCode?: number; + + /** Hash of response body */ + responseBodyHash?: string; + + /** Response content type */ + contentType?: string; + + /** Response size in bytes */ + responseSize?: number; + + /** Request duration in milliseconds */ + durationMs?: number; +} + +/** + * Evidence for database operations (db.query, db.insert, etc.) + */ +export interface DbEvidence extends BaseEvidence { + type: "db"; + + /** Hash of query/statement */ + queryHash?: string; + + /** Number of rows affected */ + rowsAffected?: number; + + /** Hash of result set (for queries) */ + resultHash?: string; + + /** Query duration in milliseconds */ + durationMs?: number; +} + +/** + * Generic evidence for unknown or custom action types. + */ +export interface GenericEvidence extends BaseEvidence { + type: "generic"; + + /** Arbitrary evidence hash */ + evidenceHash?: string; + + /** Additional metadata */ + metadata?: Record; +} + +/** + * Discriminated union of all evidence types. + * + * Use the `type` field to narrow to a specific evidence type: + * + * @example + * ```typescript + * function processEvidence(evidence: ExecutionEvidence) { + * switch (evidence.type) { + * case "file": + * console.log("File hash:", evidence.contentHash); + * break; + * case "cli": + * console.log("Exit code:", evidence.exitCode); + * break; + * case "browser": + * console.log("Final URL:", evidence.finalUrl); + * break; + * } + * } + * ``` + */ +export type ExecutionEvidence = + | FileEvidence + | CliEvidence + | BrowserEvidence + | HttpEvidence + | DbEvidence + | GenericEvidence; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Extract the domain from an action string. + * + * @example + * getActionDomain("fs.read") // => "file" + * getActionDomain("cli.exec") // => "cli" + * getActionDomain("browser.click") // => "browser" + * getActionDomain("custom.action") // => "generic" + */ +export function getEvidenceType(action: string): EvidenceType { + const prefix = action.split(".")[0]; + const domainMap: Record = { + 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 domainMap[prefix] ?? "generic"; +} + +/** + * Type guard for FileEvidence. + */ +export function isFileEvidence(evidence: ExecutionEvidence): evidence is FileEvidence { + return evidence.type === "file"; +} + +/** + * Type guard for CliEvidence. + */ +export function isCliEvidence(evidence: ExecutionEvidence): evidence is CliEvidence { + return evidence.type === "cli"; +} + +/** + * Type guard for BrowserEvidence. + */ +export function isBrowserEvidence(evidence: ExecutionEvidence): evidence is BrowserEvidence { + return evidence.type === "browser"; +} + +/** + * Type guard for HttpEvidence. + */ +export function isHttpEvidence(evidence: ExecutionEvidence): evidence is HttpEvidence { + return evidence.type === "http"; +} + +/** + * Type guard for DbEvidence. + */ +export function isDbEvidence(evidence: ExecutionEvidence): evidence is DbEvidence { + return evidence.type === "db"; +} + +// ============================================================================= +// Core Verification Types +// ============================================================================= + +/** + * Reason codes for verification failure. + */ +export type VerificationFailureReason = + | "resource_mismatch" + | "action_mismatch" + | "mandate_expired" + | "mandate_not_found" + | "evidence_mismatch"; + +/** + * Details about an authorized operation from a mandate. + */ +export interface AuthorizedOperation { + action: string; + resource: string; +} + +/** + * Legacy ActualOperation interface for backward compatibility. + * + * @deprecated Use ExecutionEvidence discriminated union instead. + */ +export interface ActualOperation { + /** The action that was actually performed */ + action: string; + + /** The resource that was actually accessed */ + resource: string; + + /** Timestamp when operation was executed (ISO 8601) */ + executedAt?: string; + + /** @deprecated Use FileEvidence.contentHash instead */ + contentHash?: string; + + /** @deprecated Use CliEvidence.transcriptHash instead */ + transcriptHash?: string; +} + +/** + * Request to verify an operation against its mandate. + * + * Supports both the legacy ActualOperation format and the new + * discriminated union ExecutionEvidence format. + */ +export interface VerifyRequest { + /** Mandate ID from the authorization decision */ + mandateId: string; + + /** + * The actual operation that was performed. + * + * Can be either: + * - ExecutionEvidence (discriminated union with `type` field) - recommended + * - ActualOperation (legacy format without `type` field) - deprecated + */ + actual: ExecutionEvidence | ActualOperation; +} + +/** + * Result of verification. + */ +export interface VerifyResult { + /** Whether the operation matched the authorization */ + verified: boolean; + + /** Reason for verification failure (if verified is false) */ + reason?: VerificationFailureReason; + + /** Details about the mismatch (if verification failed) */ + details?: { + authorized: AuthorizedOperation; + actual: ExecutionEvidence | ActualOperation; + }; + + /** Audit trail ID from the sidecar (if verification succeeded) */ + auditId?: string; +} + +// ============================================================================= +// Mandate Types +// ============================================================================= + +/** + * Mandate details retrieved from the sidecar. + */ +export interface MandateDetails { + /** Unique mandate identifier */ + mandate_id: string; + + /** Principal that was granted authorization */ + principal: string; + + /** Action that was authorized */ + action: string; + + /** Resource that was authorized */ + resource: string; + + /** Hash of the stated intent */ + intent_hash: string; + + /** When the mandate was issued (ISO 8601) */ + issued_at: string; + + /** When the mandate expires (ISO 8601) */ + expires_at: string; +} + +/** + * Type guard for MandateDetails. + */ +export function isMandateDetails(value: unknown): value is MandateDetails { + if (typeof value !== "object" || value === null) { + return false; + } + const obj = value as Record; + return ( + typeof obj.mandate_id === "string" && + typeof obj.principal === "string" && + typeof obj.action === "string" && + typeof obj.resource === "string" && + typeof obj.intent_hash === "string" && + typeof obj.issued_at === "string" && + typeof obj.expires_at === "string" + ); +} + +// ============================================================================= +// Audit Types +// ============================================================================= + +/** + * Request to record a verification in the audit log. + */ +export interface RecordVerificationRequest { + /** Mandate ID that was verified */ + mandateId: string; + + /** Whether verification succeeded */ + verified: boolean; + + /** The actual operation details */ + actual: ExecutionEvidence | ActualOperation; + + /** Reason for failure (if verified is false) */ + reason?: VerificationFailureReason; +} + +/** + * Response from recording a verification. + */ +export interface RecordVerificationResponse { + /** Audit trail ID */ + audit_id: string; +} + +/** + * Type guard for RecordVerificationResponse. + */ +export function isRecordVerificationResponse( + value: unknown, +): value is RecordVerificationResponse { + if (typeof value !== "object" || value === null) { + return false; + } + const obj = value as Record; + return typeof obj.audit_id === "string"; +} diff --git a/src/verify/verifier.ts b/src/verify/verifier.ts new file mode 100644 index 0000000..44b1a9e --- /dev/null +++ b/src/verify/verifier.ts @@ -0,0 +1,421 @@ +/** + * Post-execution verification module. + * + * The Verifier class compares actual operations against what was + * authorized via a mandate, detecting unauthorized deviations. + */ + +import { AuthorityClientError } from "../errors.js"; +import { actionsMatch, resourcesMatch } from "./comparators.js"; +import { + type ActualOperation, + type ExecutionEvidence, + type GenericEvidence, + type MandateDetails, + type RecordVerificationRequest, + type VerifyRequest, + type VerifyResult, + isBrowserEvidence, + isCliEvidence, + isDbEvidence, + isFileEvidence, + isHttpEvidence, + isMandateDetails, + isRecordVerificationResponse, +} from "./types.js"; + +/** + * Interface for mandate retrieval. + * + * Can be implemented by AuthorityClient or a custom provider. + */ +export interface MandateProvider { + /** + * Retrieve mandate details by ID. + * @param mandateId - The mandate ID to look up + * @returns Mandate details or null if not found + */ + getMandate(mandateId: string): Promise; + + /** + * Record a verification result in the audit log. + * @param request - Verification details to record + * @returns Audit trail ID + */ + recordVerification(request: RecordVerificationRequest): Promise; +} + +/** + * Options for creating a Verifier. + */ +export interface VerifierOptions { + /** Base URL of the sidecar */ + baseUrl: string; + + /** Request timeout in milliseconds */ + timeoutMs?: number; +} + +/** + * Verifier for post-execution authorization checks. + * + * Compares actual operations against mandates to detect unauthorized + * deviations from what was authorized. + * + * @example + * ```typescript + * const verifier = new Verifier({ baseUrl: 'http://127.0.0.1:8787' }); + * + * const result = await verifier.verify({ + * mandateId: decision.mandate_id, + * actual: { + * action: 'fs.read', + * resource: '/src/index.ts', + * }, + * }); + * + * if (!result.verified) { + * console.error('Operation mismatch:', result.reason); + * } + * ``` + */ +export class Verifier implements MandateProvider { + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor(options: VerifierOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.timeoutMs = options.timeoutMs ?? 2000; + } + + /** + * Verify that an actual operation matches its mandate. + * + * @param request - Verification request with mandate ID and actual operation + * @returns Verification result + */ + async verify(request: VerifyRequest): Promise { + // 1. Retrieve mandate details + const mandate = await this.getMandate(request.mandateId); + + if (!mandate) { + return { + verified: false, + reason: "mandate_not_found", + }; + } + + // 2. Check mandate expiration + const expiresAt = new Date(mandate.expires_at).getTime(); + if (expiresAt < Date.now()) { + return { + verified: false, + reason: "mandate_expired", + }; + } + + // 3. Compare action + if (!actionsMatch(mandate.action, request.actual.action)) { + const result: VerifyResult = { + verified: false, + reason: "action_mismatch", + details: { + authorized: { action: mandate.action, resource: mandate.resource }, + actual: { + action: request.actual.action, + resource: request.actual.resource, + }, + }, + }; + + // Record failed verification + await this.recordVerification({ + mandateId: request.mandateId, + verified: false, + actual: request.actual, + reason: "action_mismatch", + }); + + return result; + } + + // 4. Compare resource (with normalization) + if (!resourcesMatch(mandate.resource, request.actual.resource)) { + const result: VerifyResult = { + verified: false, + reason: "resource_mismatch", + details: { + authorized: { action: mandate.action, resource: mandate.resource }, + actual: { + action: request.actual.action, + resource: request.actual.resource, + }, + }, + }; + + // Record failed verification + await this.recordVerification({ + mandateId: request.mandateId, + verified: false, + actual: request.actual, + reason: "resource_mismatch", + }); + + return result; + } + + // 5. Record successful verification + const auditId = await this.recordVerification({ + mandateId: request.mandateId, + verified: true, + actual: request.actual, + }); + + return { + verified: true, + auditId, + }; + } + + /** + * 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. + * + * @param mandate - Known mandate details + * @param request - Verification request + * @returns Verification result (without auditId) + */ + verifyLocal(mandate: MandateDetails, request: VerifyRequest): VerifyResult { + // Check mandate expiration + const expiresAt = new Date(mandate.expires_at).getTime(); + if (expiresAt < Date.now()) { + return { + verified: false, + reason: "mandate_expired", + }; + } + + // Compare action + if (!actionsMatch(mandate.action, request.actual.action)) { + return { + verified: false, + reason: "action_mismatch", + details: { + authorized: { action: mandate.action, resource: mandate.resource }, + actual: { + action: request.actual.action, + resource: request.actual.resource, + }, + }, + }; + } + + // Compare resource + if (!resourcesMatch(mandate.resource, request.actual.resource)) { + return { + verified: false, + reason: "resource_mismatch", + details: { + authorized: { action: mandate.action, resource: mandate.resource }, + actual: { + action: request.actual.action, + resource: request.actual.resource, + }, + }, + }; + } + + return { verified: true }; + } + + /** + * Retrieve mandate details from the sidecar. + * + * @param mandateId - Mandate ID to look up + * @returns Mandate details or null if not found + */ + async getMandate(mandateId: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(`${this.baseUrl}/v1/mandates/${encodeURIComponent(mandateId)}`, { + method: "GET", + headers: { + accept: "application/json", + }, + signal: controller.signal, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new AuthorityClientError(`mandate lookup failed: ${response.status}`, { + code: "server_error", + status: response.status, + }); + } + + const payload = await response.json(); + + if (!isMandateDetails(payload)) { + throw new AuthorityClientError("invalid mandate response payload", { + code: "protocol_error", + status: response.status, + details: payload, + }); + } + + return payload; + } catch (error) { + if (error instanceof AuthorityClientError) { + throw error; + } + if (error instanceof Error && error.name === "AbortError") { + throw new AuthorityClientError("mandate lookup timed out", { + code: "timeout", + cause: error, + }); + } + throw new AuthorityClientError("mandate lookup failed", { + code: "network_error", + cause: error, + }); + } finally { + clearTimeout(timer); + } + } + + /** + * Record a verification result in the sidecar's audit log. + * + * @param request - Verification details to record + * @returns Audit trail ID + */ + async recordVerification(request: RecordVerificationRequest): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const actualPayload = this.buildActualPayload(request.actual); + + const response = await fetch(`${this.baseUrl}/v1/verify`, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + mandate_id: request.mandateId, + verified: request.verified, + actual: actualPayload, + reason: request.reason, + verified_at: new Date().toISOString(), + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new AuthorityClientError(`record verification failed: ${response.status}`, { + code: "server_error", + status: response.status, + }); + } + + const payload = await response.json(); + + if (!isRecordVerificationResponse(payload)) { + // Graceful fallback: generate a local audit ID if sidecar doesn't return one + return `local_audit_${Date.now()}_${Math.random().toString(36).slice(2)}`; + } + + return payload.audit_id; + } catch (error) { + if (error instanceof AuthorityClientError) { + throw error; + } + if (error instanceof Error && error.name === "AbortError") { + throw new AuthorityClientError("record verification timed out", { + code: "timeout", + cause: error, + }); + } + throw new AuthorityClientError("record verification failed", { + code: "network_error", + cause: error, + }); + } finally { + clearTimeout(timer); + } + } + + /** + * Build the actual operation payload for the verification request. + * Handles the discriminated union by extracting type-specific fields. + */ + private buildActualPayload( + actual: ExecutionEvidence | ActualOperation, + ): Record { + // Common fields present on all evidence types + const payload: Record = { + action: actual.action, + resource: actual.resource, + executed_at: actual.executedAt, + }; + + // Check if it has a type discriminator (new ExecutionEvidence types) + if ("type" in actual) { + payload.type = actual.type; + + if (isFileEvidence(actual)) { + payload.content_hash = actual.contentHash; + payload.file_size = actual.fileSize; + payload.permissions = actual.permissions; + payload.modified_at = actual.modifiedAt; + } else if (isCliEvidence(actual)) { + payload.command = actual.command; + payload.exit_code = actual.exitCode; + payload.stdout_hash = actual.stdoutHash; + payload.stderr_hash = actual.stderrHash; + payload.transcript_hash = actual.transcriptHash; + payload.cwd = actual.cwd; + payload.duration_ms = actual.durationMs; + } else if (isBrowserEvidence(actual)) { + payload.final_url = actual.finalUrl; + payload.selector = actual.selector; + payload.a11y_tree_hash = actual.a11yTreeHash; + payload.dom_state_hash = actual.domStateHash; + payload.screenshot_hash = actual.screenshotHash; + payload.page_title = actual.pageTitle; + } else if (isHttpEvidence(actual)) { + payload.method = actual.method; + payload.status_code = actual.statusCode; + payload.response_body_hash = actual.responseBodyHash; + payload.content_type = actual.contentType; + payload.response_size = actual.responseSize; + payload.duration_ms = actual.durationMs; + } else if (isDbEvidence(actual)) { + payload.query_hash = actual.queryHash; + payload.rows_affected = actual.rowsAffected; + payload.result_hash = actual.resultHash; + payload.duration_ms = actual.durationMs; + } else { + // GenericEvidence + const generic = actual as GenericEvidence; + payload.evidence_hash = generic.evidenceHash; + payload.metadata = generic.metadata; + } + } else { + // Legacy ActualOperation - extract known fields + const legacy = actual as ActualOperation; + payload.content_hash = legacy.contentHash; + payload.transcript_hash = legacy.transcriptHash; + } + + return payload; + } +} diff --git a/tests/verify.test.ts b/tests/verify.test.ts new file mode 100644 index 0000000..4be0f4c --- /dev/null +++ b/tests/verify.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { + type MandateDetails, + Verifier, + type VerifyRequest, + actionsMatch, + isMandateDetails, + normalizeResource, + resourcesMatch, +} from "../src/index.js"; + +describe("verification comparators", () => { + describe("normalizeResource", () => { + it("normalizes filesystem paths", () => { + // Multiple slashes + expect(normalizeResource("/src//index.ts")).toBe("/src/index.ts"); + + // Trailing slash + expect(normalizeResource("/src/")).toBe("/src"); + + // Dot segments + expect(normalizeResource("/src/./index.ts")).toBe("/src/index.ts"); + }); + + it("normalizes URL-like resources", () => { + expect(normalizeResource("https://api.example.com//users")).toBe( + "https://api.example.com/users", + ); + expect(normalizeResource("https://api.example.com/users/")).toBe( + "https://api.example.com/users", + ); + }); + + it("preserves URL protocol", () => { + expect(normalizeResource("https://api.example.com/path")).toBe( + "https://api.example.com/path", + ); + }); + }); + + describe("resourcesMatch", () => { + it("matches identical resources", () => { + expect(resourcesMatch("/src/index.ts", "/src/index.ts")).toBe(true); + }); + + it("matches after normalization", () => { + expect(resourcesMatch("/src//index.ts", "/src/index.ts")).toBe(true); + expect(resourcesMatch("/src/index.ts", "/src//index.ts")).toBe(true); + }); + + it("supports glob patterns in authorized resource", () => { + expect(resourcesMatch("/src/*.ts", "/src/index.ts")).toBe(true); + expect(resourcesMatch("/src/**/*.ts", "/src/utils/helpers.ts")).toBe(true); + expect(resourcesMatch("/src/*.ts", "/src/index.js")).toBe(false); + }); + + it("rejects mismatched resources", () => { + expect(resourcesMatch("/src/index.ts", "/src/main.ts")).toBe(false); + expect(resourcesMatch("/src/index.ts", "/lib/index.ts")).toBe(false); + }); + + it("can disable glob matching", () => { + expect(resourcesMatch("/src/*.ts", "/src/index.ts", { allowGlob: false })).toBe(false); + }); + }); + + describe("actionsMatch", () => { + it("matches identical actions", () => { + expect(actionsMatch("fs.read", "fs.read")).toBe(true); + }); + + it("handles whitespace", () => { + expect(actionsMatch("fs.read", " fs.read ")).toBe(true); + expect(actionsMatch(" fs.read ", "fs.read")).toBe(true); + }); + + it("supports glob patterns", () => { + expect(actionsMatch("fs.*", "fs.read")).toBe(true); + expect(actionsMatch("fs.*", "fs.write")).toBe(true); + expect(actionsMatch("http.*", "fs.read")).toBe(false); + }); + + it("is case-sensitive", () => { + expect(actionsMatch("fs.read", "fs.READ")).toBe(false); + }); + }); +}); + +describe("isMandateDetails type guard", () => { + it("accepts valid mandate details", () => { + const mandate: MandateDetails = { + mandate_id: "m_123", + principal: "agent:claude", + action: "fs.read", + resource: "/src/index.ts", + intent_hash: "ih_test", + issued_at: "2024-02-28T12:00:00Z", + expires_at: "2024-02-28T12:15:00Z", + }; + expect(isMandateDetails(mandate)).toBe(true); + }); + + it("rejects invalid objects", () => { + expect(isMandateDetails(null)).toBe(false); + expect(isMandateDetails(undefined)).toBe(false); + expect(isMandateDetails({})).toBe(false); + expect(isMandateDetails({ mandate_id: "m_123" })).toBe(false); + }); +}); + +describe("Verifier.verifyLocal", () => { + const verifier = new Verifier({ baseUrl: "http://127.0.0.1:8787" }); + + const baseMandate: MandateDetails = { + mandate_id: "m_123", + principal: "agent:claude", + action: "fs.read", + resource: "/src/index.ts", + intent_hash: "ih_test", + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(), // 15 minutes from now + }; + + it("verifies matching operation", () => { + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/index.ts", + }, + }; + + const result = verifier.verifyLocal(baseMandate, request); + expect(result.verified).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("detects action mismatch", () => { + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.write", // Different action + resource: "/src/index.ts", + }, + }; + + const result = verifier.verifyLocal(baseMandate, request); + expect(result.verified).toBe(false); + expect(result.reason).toBe("action_mismatch"); + expect(result.details?.authorized.action).toBe("fs.read"); + expect(result.details?.actual.action).toBe("fs.write"); + }); + + it("detects resource mismatch", () => { + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/default.ts", // Different resource + }, + }; + + const result = verifier.verifyLocal(baseMandate, request); + expect(result.verified).toBe(false); + expect(result.reason).toBe("resource_mismatch"); + expect(result.details?.authorized.resource).toBe("/src/index.ts"); + expect(result.details?.actual.resource).toBe("/src/default.ts"); + }); + + it("detects expired mandate", () => { + const expiredMandate: MandateDetails = { + ...baseMandate, + expires_at: new Date(Date.now() - 1000).toISOString(), // 1 second ago + }; + + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/index.ts", + }, + }; + + const result = verifier.verifyLocal(expiredMandate, request); + expect(result.verified).toBe(false); + expect(result.reason).toBe("mandate_expired"); + }); + + it("supports glob patterns in mandate resource", () => { + const globMandate: MandateDetails = { + ...baseMandate, + resource: "/src/*.ts", + }; + + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/index.ts", + }, + }; + + const result = verifier.verifyLocal(globMandate, request); + expect(result.verified).toBe(true); + }); + + it("supports glob patterns in mandate action", () => { + const globMandate: MandateDetails = { + ...baseMandate, + action: "fs.*", + }; + + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/index.ts", + }, + }; + + const result = verifier.verifyLocal(globMandate, request); + expect(result.verified).toBe(true); + }); +}); + +describe("verification edge cases", () => { + const verifier = new Verifier({ baseUrl: "http://127.0.0.1:8787" }); + + it("handles path traversal attempts", () => { + const mandate: MandateDetails = { + mandate_id: "m_123", + principal: "agent:claude", + action: "fs.read", + resource: "/src/index.ts", + intent_hash: "ih_test", + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }; + + // Attempt to read a different file via path traversal + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/../etc/passwd", + }, + }; + + const result = verifier.verifyLocal(mandate, request); + expect(result.verified).toBe(false); + expect(result.reason).toBe("resource_mismatch"); + }); + + it("handles content hash in actual operation", () => { + const mandate: MandateDetails = { + mandate_id: "m_123", + principal: "agent:claude", + action: "fs.read", + resource: "/src/index.ts", + intent_hash: "ih_test", + issued_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(), + }; + + const request: VerifyRequest = { + mandateId: "m_123", + actual: { + action: "fs.read", + resource: "/src/index.ts", + contentHash: "sha256:abc123...", + executedAt: new Date().toISOString(), + }, + }; + + const result = verifier.verifyLocal(mandate, request); + expect(result.verified).toBe(true); + }); +});