From 190efcaf1728743a02fb65a35319abdc14ea2227 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:12:27 +0000 Subject: [PATCH] feat(core): add typed policy and integrity contracts --- docs/error-contract.schema.json | 831 ++++++++++++++++++ docs/mcp-contracts.md | 116 +++ scripts/generate-error-schema.mjs | 13 + src/application/AnalysisProvider.ts | 4 +- src/application/ArtifactGraphConstruction.ts | 3 +- src/application/ArtifactInventory.ts | 179 +++- .../AuthorizedArtifactInventory.ts | 10 +- src/application/BinarySession.ts | 113 +++ src/application/BinarySessionPort.ts | 10 + src/application/CrossVersionInventory.ts | 29 +- src/application/CrossVersionInvestigation.ts | 63 +- src/application/DirectAnalysis.ts | 150 +++- src/application/PermissionAuthority.ts | 278 ++++++ src/application/PermissionConfiguration.ts | 52 ++ src/application/ProcessCaptureError.ts | 19 + src/application/ProcessCaptureLifecycle.ts | 4 +- src/application/ProcessCli.ts | 33 +- src/application/ProcessHarness.ts | 5 +- src/application/ProgressReporter.ts | 55 ++ src/application/ProjectPermissionStore.ts | 145 +++ src/application/runtime.ts | 5 +- src/artifacts/ArtifactProvider.ts | 21 +- src/cli.ts | 57 +- src/cliEvidenceCommands.ts | 56 +- src/cliInvestigationCommands.ts | 62 +- src/cliPolicyCommands.ts | 160 ++++ src/config.ts | 129 ++- src/contracts/artifactToolContracts.ts | 25 +- src/contracts/errorSchemas.ts | 105 +++ src/contracts/toolContracts.ts | 12 +- src/contracts/toolOutputSchemas.ts | 103 ++- src/domain/artifactComparison.ts | 59 +- src/domain/artifactGraph.ts | 30 +- src/domain/artifactInventoryEvidence.ts | 30 + src/domain/changedBehavior.ts | 8 +- src/domain/errors.ts | 266 +++++- src/domain/investigationWorkspace.ts | 48 +- src/domain/permissionPolicy.ts | 322 +++++++ src/domain/processComparison.ts | 1 + src/domain/reconstructionVerification.ts | 3 +- .../reconstructionVerificationSchemas.ts | 1 + src/domain/staticRuntimeCorrelation.ts | 6 +- tests/artifactProvider.test.ts | 11 + tests/binarySession.test.ts | 47 +- tests/config.test.ts | 2 +- tests/errors.test.ts | 143 ++- tests/investigationWorkspace.test.ts | 81 ++ tests/permissionAuthority.test.ts | 102 +++ tests/permissionPolicy.test.ts | 206 +++++ tests/processCli.test.ts | 13 +- tests/progressReporter.test.ts | 51 ++ tests/projectPermissionStore.test.ts | 63 ++ 52 files changed, 4159 insertions(+), 181 deletions(-) create mode 100644 docs/error-contract.schema.json create mode 100644 docs/mcp-contracts.md create mode 100644 scripts/generate-error-schema.mjs create mode 100644 src/application/PermissionAuthority.ts create mode 100644 src/application/PermissionConfiguration.ts create mode 100644 src/application/ProgressReporter.ts create mode 100644 src/application/ProjectPermissionStore.ts create mode 100644 src/cliPolicyCommands.ts create mode 100644 src/contracts/errorSchemas.ts create mode 100644 src/domain/permissionPolicy.ts create mode 100644 tests/permissionAuthority.test.ts create mode 100644 tests/permissionPolicy.test.ts create mode 100644 tests/progressReporter.test.ts create mode 100644 tests/projectPermissionStore.test.ts diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json new file mode 100644 index 000000000..4c31f236d --- /dev/null +++ b/docs/error-contract.schema.json @@ -0,0 +1,831 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "invalid_request" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "unreadable_output" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "capability_unavailable" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "provider_unavailable" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "provider_timeout" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "cancelled" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "artifact_integrity_mismatch" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "properties": { + "logical_path": { "type": "string" }, + "declared_sha256": { + "anyOf": [{ "type": "string" }, { "type": "null" }] + }, + "calculated_sha256": { + "anyOf": [{ "type": "string" }, { "type": "null" }] + }, + "unpacked": { "type": "boolean" } + }, + "required": [ + "logical_path", + "declared_sha256", + "calculated_sha256", + "unpacked" + ], + "additionalProperties": false + } + }, + "required": [ + "code", + "category", + "message", + "retryable", + "remediation", + "details" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "artifact_operation_failed" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "evidence_integrity_mismatch" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "truncated" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "permission_required" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "properties": { + "capability": { "type": "string" }, + "requested": { + "type": "object", + "properties": { + "capability": { "type": "string" }, + "roots": { "type": "array", "items": { "type": "string" } }, + "executables": { + "type": "array", + "items": { "type": "string" } + }, + "environment_names": { + "type": "array", + "items": { "type": "string" } + }, + "network": { + "type": "string", + "enum": ["none", "loopback", "external"] + }, + "mount": { "type": "boolean" } + }, + "required": [ + "capability", + "roots", + "executables", + "environment_names", + "network", + "mount" + ], + "additionalProperties": false + }, + "missing": { + "type": "object", + "properties": { + "roots": { "type": "array", "items": { "type": "string" } }, + "executables": { + "type": "array", + "items": { "type": "string" } + }, + "environment_names": { + "type": "array", + "items": { "type": "string" } + }, + "network": { + "type": "string", + "enum": ["none", "loopback", "external"] + }, + "mount": { "type": "boolean", "const": true } + }, + "additionalProperties": false + }, + "ceiling": { + "anyOf": [ + { + "type": "object", + "properties": { + "capability": { "type": "string" }, + "roots": { "type": "array", "items": { "type": "string" } }, + "executables": { + "type": "array", + "items": { "type": "string" } + }, + "environment_names": { + "type": "array", + "items": { "type": "string" } + }, + "network": { + "type": "string", + "enum": ["none", "loopback", "external"] + }, + "mount": { "type": "boolean" } + }, + "required": [ + "capability", + "roots", + "executables", + "environment_names", + "network", + "mount" + ], + "additionalProperties": false + }, + { "type": "null" } + ] + } + }, + "required": ["capability", "requested", "missing", "ceiling"], + "additionalProperties": false + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "process_capture_failed" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "cleanup_incomplete" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "revision_conflict" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "outside_approved_root" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "configuration_invalid" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "target_unavailable" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "execution_failure" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { + "action": { "type": "string", "minLength": 1 }, + "restart_required": { "type": "boolean" }, + "elicitation_supported": { "type": "boolean" } + }, + "required": ["action", "restart_required"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + } + ], + "$defs": { + "__schema0": { + "anyOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" }, + { "type": "null" }, + { "type": "array", "items": { "$ref": "#/$defs/__schema0" } }, + { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "$ref": "#/$defs/__schema0" } + } + ] + } + } +} diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md new file mode 100644 index 000000000..0199f1834 --- /dev/null +++ b/docs/mcp-contracts.md @@ -0,0 +1,116 @@ +# MCP runtime contracts + +## Identity and discovery + +`rea://server/identity` and `binary_session` report the package, server, SDK, +negotiated protocol, skill, and schema-sensitive catalog identities separately. +An absent live comparison is `unknown`, never aligned. Callers can supply the +expected package version, catalog digest, or registered server path to +`binary_session`; a mismatch means the registered MCP process must be restarted. +`rea doctor` separately inspects supported JSON/TOML client registrations, +reports their command vectors as aligned, stale, missing, or invalid, and keeps +live-server state `unknown` unless the active connection supplies identity. + +Tool names remain stable. `binary_session.tool_availability` explains each +operation with a stable availability reason instead of silently hiding it. +Opening or closing a target, reloading policy, or observing a provider health +transition emits `notifications/tools/list_changed`. + +## Progress and cancellation + +REA accepts ordinary `tools/call` progress tokens. Updates are monotonic, +rate-bounded to at most one intermediate update per 100 ms, and always allow a +terminal update. Unknown totals are omitted; REA does not fabricate percentages. +Provider calls receive the request cancellation signal. Artifact traversal, +hashing, cross-version scanning/checkpoints, Hopper requests, and process capture +check the same signal. Cancellation is distinct from timeout. A cleanup failure +uses `cleanup_incomplete` and lists only the owned resource kinds that remain. +Derived comparisons and reconstruction verification yield before computation +and before publication, so cancellation cannot race with successful Evidence. + +CLI calls work without a progress token and translate SIGINT into the same +AbortSignal used by providers. Existing controlled-process cleanup and provider +shutdown rules still apply; REA never kills a process it cannot prove it owns. + +## Evidence resources + +Evidence remains inline for compatibility and successful Evidence-producing +tools also return `resource_link` content. Session resources are connection-local: + +- `rea://evidence/{evidenceId}` returns the complete immutable Evidence v2 record. +- `rea://evidence/{evidenceId}/section/{section}` returns a bounded result section. + Stable sections include `result`, `terminal`, `filesystem`, `process`, + `protocol`, `nodes`, `occurrences`, and `edges` when present. +- `rea://unknown/{unknownId}` returns the current residual-unknown head and its + immutable revision history. +- `rea://unknowns/active` returns current unresolved heads. +- `rea://snapshot/{snapshotDigest}` returns the current immutable analysis + snapshot when its canonical content digest matches. +- `rea://artifact/{manifestId}/{collection}` returns a canonical artifact + `nodes`, `occurrences`, or `edges` page with Evidence provenance. +- `rea://function/{targetSha256}/{address}` returns a retained function dossier + for the exact target and address. +- `rea://workspace/{workspaceId}/revision/{revision}` returns an immutable, + CAS-linked investigation workspace revision retained by this session. + +Successful automatic cross-version investigations include both Evidence and +workspace `resource_link` blocks. Workspace resources preserve revision and +`previous_revision_digest` commitments; persistent workspace files remain +subject to configured read/write roots. + +An Evidence URI is discoverability, not authorization. It cannot authorize file +access, extraction, mounting, execution, or networking. IDs disappear when the +session ends unless the existing Evidence bundle or workspace persistence flow +explicitly retains them. + +## Permission policy + +All local side effects use one scope vocabulary: capability, canonical roots, +executables, environment variable names, network mode, mount permission, exact +operation identity, and grant lifetime. Environment values and captured content +are never part of a grant or denial. + +Existing environment settings map to administrator ceilings. They remain the +maximum authority: + +- process roots, executables, environment names, and external networking; +- Evidence read/write roots; +- investigation input and workspace roots; +- snapshot read/write roots; +- reference-source roots; +- native mount enablement. + +Artifact extraction retains its existing explicit per-call approval and maps to +an administrator root ceiling of `/` for compatibility. + +`rea policy status`, `list`, `explain`, and `revoke` inspect the same evaluator +used by MCP. Optional project grants require both +`REA_PERMISSION_PROJECT_ROOT` and `REA_PERMISSION_PROJECT_STORE`. The store is +atomically written with mode `0600`, bound to the canonical project root, and is +never enabled by default. Send `SIGHUP` to the REA MCP process after a trusted +local policy change; ceilings, administrator grants, and project grants reload +without restarting. Revocation affects future operations; an already-running +operation retains the decision made at its preflight boundary. + +Denials use the shared `permission_required` schema with requested scope, missing +scope, administrator ceiling, elicitation support, and exact restart status. +Client-provided roots are context only and never grants. + +## Integrity record-and-continue + +Artifact integrity remains fail-closed by default. Record-and-continue requires +all three conditions: + +1. operator policy `REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED=true`; +2. `integrity_policy=record-and-continue`; +3. explicit per-call `integrity_continue_approved=true`. + +Contradictory bytes are quarantined from nested expansion, recorded with declared +and observed hashes, trust, provenance, path, and unpacked state, and bounded by +`max_integrity_mismatches`. Verified siblings continue. Comparisons classify the +result as a contradiction and reconstruction cannot treat it as unchanged. +`investigate-versions` accepts the same policy through +`--integrity-policy record-and-continue --integrity-continue-approved` and +`--max-integrity-mismatches`. Its Evidence pages and CAS-linked workspace retain +contradictions, so a completed run can resume without rescanning or weakening +trust labels. diff --git a/scripts/generate-error-schema.mjs b/scripts/generate-error-schema.mjs new file mode 100644 index 000000000..e9c412e9c --- /dev/null +++ b/scripts/generate-error-schema.mjs @@ -0,0 +1,13 @@ +import { writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { analysisErrorJsonSchema } from "../dist/contracts/errorSchemas.js"; +import { format } from "prettier"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +await writeFile( + join(root, "docs/error-contract.schema.json"), + await format(JSON.stringify(analysisErrorJsonSchema), { parser: "json" }), + "utf8", +); diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 72414cdab..c57621843 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -9,9 +9,11 @@ import type { Result } from "../domain/result.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import type { EvidenceSubjectTarget } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { ProgressReporter } from "./ProgressReporter.js"; -interface ExecutionOptions { +export interface ExecutionOptions { readonly signal?: AbortSignal; + readonly progress?: ProgressReporter; } /** Closed operations accepted by provider-backed analysis sessions. */ diff --git a/src/application/ArtifactGraphConstruction.ts b/src/application/ArtifactGraphConstruction.ts index 3f09dfb0f..5c10db6e7 100644 --- a/src/application/ArtifactGraphConstruction.ts +++ b/src/application/ArtifactGraphConstruction.ts @@ -134,6 +134,7 @@ export const createArtifactNode = (input: { readonly format: ArtifactNode["format"]; readonly executable: boolean; readonly contentState: ArtifactNode["content_state"]; + readonly limitations?: readonly string[]; }): ArtifactNode => ({ artifact_id: `art_${digestCanonical({ schema_version: 1, sha256: input.sha256 })}`, kind: input.kind, @@ -144,7 +145,7 @@ export const createArtifactNode = (input: { architecture: null, executable: input.executable, content_state: input.contentState, - limitations: [], + limitations: [...(input.limitations ?? [])], }); export const rootOccurrenceFor = ( diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index 68d7d2fad..a3b128c8b 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -23,6 +23,7 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, type ArtifactNode, + type IntegrityContradiction, } from "../domain/artifactGraph.js"; import { classifyArtifactPath, @@ -61,6 +62,21 @@ const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = { nativeMountEnabled: false, }; +/** Explicit caller approval bounded by operator-owned integrity policy. */ +export interface ArtifactIntegrityPolicy { + readonly mode: "fail" | "record-and-continue"; + readonly approved: boolean; + readonly enabled: boolean; + readonly maxMismatches: number; +} + +const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { + mode: "fail", + approved: false, + enabled: false, + maxMismatches: 1, +}; + /** Immutable inventory produced by one complete artifact scan. */ export interface ArtifactInventorySnapshot { readonly manifest: ArtifactInventoryResult["manifest"]; @@ -71,6 +87,7 @@ export interface ArtifactInventorySnapshot { readonly provenance: ReadonlyArray< ArtifactInventoryResult["provenance"][number] >; + readonly integrity_contradictions: readonly IntegrityContradiction[]; readonly limitations: readonly string[]; } @@ -82,6 +99,7 @@ export const inventoryArtifact = async ( options: { readonly signal?: AbortSignal; readonly nativeMount?: ArtifactNativeMountPolicy; + readonly integrity?: ArtifactIntegrityPolicy; } = {}, ): Promise => { const snapshot = await scanArtifactInventory( @@ -89,6 +107,7 @@ export const inventoryArtifact = async ( limits, options.signal, options.nativeMount ?? NATIVE_MOUNT_DISABLED, + options.integrity ?? STRICT_INTEGRITY_POLICY, ); return paginateArtifactInventory(snapshot, page); }; @@ -99,10 +118,17 @@ export const scanArtifactInventory = async ( limits: ArtifactLimits, signal?: AbortSignal, nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED, + integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY, ): Promise => { abortIfNeeded(signal); const path = await realpath(inputPath); - return scanCanonicalArtifactInventory(path, limits, signal, nativeMount); + return scanCanonicalArtifactInventory( + path, + limits, + signal, + nativeMount, + integrity, + ); }; /** Scan one already-canonical artifact path without resolving it again. */ @@ -111,7 +137,16 @@ export const scanCanonicalArtifactInventory = async ( limits: ArtifactLimits, signal?: AbortSignal, nativeMount: ArtifactNativeMountPolicy = NATIVE_MOUNT_DISABLED, + integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY, ): Promise => { + if ( + integrity.mode === "record-and-continue" && + (!integrity.approved || !integrity.enabled) + ) + throw new ArtifactReaderFailure( + "unavailable", + "Integrity continuation requires explicit approval and operator policy", + ); const metadata = await lstat(path); const rootFormat = await classifyRoot(path, metadata.isDirectory()); const rootDigest = metadata.isDirectory() @@ -120,7 +155,12 @@ export const scanCanonicalArtifactInventory = async ( const reader = await createReader(path, rootFormat, nativeMount, signal); try { - const { nodes, occurrences } = await scanReader(reader, limits, signal); + const { nodes, occurrences, pendingContradictions } = await scanReader( + reader, + limits, + signal, + integrity, + ); materializeDirectoryNodes(occurrences, nodes); const rootNode = createRootNode({ @@ -137,6 +177,46 @@ export const scanCanonicalArtifactInventory = async ( if (occurrence.parent_occurrence_id === null) occurrence.parent_occurrence_id = rootOccurrence.occurrence_id; occurrences.unshift(rootOccurrence); + const occurrenceById = new Map( + occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]), + ); + const integrityContradictions = pendingContradictions.map( + (contradiction): IntegrityContradiction => { + const occurrence = occurrences.find( + ({ logical_path: path }) => path === contradiction.logicalPath, + ); + if (occurrence === undefined) + throw new ArtifactReaderFailure( + "integrity", + "Integrity contradiction lost its graph occurrence", + ); + const parent = + occurrence.parent_occurrence_id === null + ? undefined + : occurrenceById.get(occurrence.parent_occurrence_id); + const parentArtifactId = parent?.artifact_id ?? rootNode.artifact_id; + return { + contradiction_id: `ic_${digestCanonical({ + root_artifact_id: rootNode.artifact_id, + logical_path: contradiction.logicalPath, + declared_sha256: contradiction.declaredSha256, + observed_sha256: contradiction.observedSha256, + })}`, + occurrence_id: occurrence.occurrence_id, + parent_artifact_id: parentArtifactId, + logical_path: contradiction.logicalPath, + declared_sha256: contradiction.declaredSha256, + observed_sha256: contradiction.observedSha256, + entry_kind: contradiction.entryKind, + unpacked: contradiction.unpacked, + trust: "observed-untrusted", + provenance: "container-integrity-metadata-versus-observed-bytes", + limitations: [ + "Observed bytes contradict declared integrity metadata and cannot support equivalence.", + ], + }; + }, + ); const edges = createArtifactEdges( rootNode.artifact_id, occurrences, @@ -170,6 +250,7 @@ export const scanCanonicalArtifactInventory = async ( nodes: orderedNodes, occurrences: orderedOccurrences, edges: orderedEdges, + integrity_contradictions: integrityContradictions, }); const manifestId = `agm_${digestCanonical({ schema_version: 1, @@ -193,7 +274,15 @@ export const scanCanonicalArtifactInventory = async ( edges: orderedEdges, limits: toOutputLimits(limits), provenance: reader?.provenance() ?? [], - limitations: inventoryLimitations(rootFormat, reader), + integrity_contradictions: integrityContradictions, + limitations: [ + ...inventoryLimitations(rootFormat, reader), + ...(integrityContradictions.length === 0 + ? [] + : [ + `${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`, + ]), + ], }; } finally { await reader?.close(); @@ -283,18 +372,30 @@ const isExpandableAsar = (entry: ArtifactEntry, logicalPath: string): boolean => const emptyScan = (): { readonly nodes: Map; readonly occurrences: MutableOccurrence[]; -} => ({ nodes: new Map(), occurrences: [] }); + readonly pendingContradictions: PendingIntegrityContradiction[]; +} => ({ nodes: new Map(), occurrences: [], pendingContradictions: [] }); + +interface PendingIntegrityContradiction { + readonly logicalPath: string; + readonly declaredSha256: string; + readonly observedSha256: string; + readonly entryKind: "file" | "slice"; + readonly unpacked: boolean; +} const scanReader = async ( reader: ArtifactReader | undefined, limits: ArtifactLimits, signal?: AbortSignal, + integrity: ArtifactIntegrityPolicy = STRICT_INTEGRITY_POLICY, ): Promise<{ readonly nodes: Map; readonly occurrences: MutableOccurrence[]; + readonly pendingContradictions: PendingIntegrityContradiction[]; }> => { - const { nodes, occurrences } = emptyScan(); - if (reader === undefined) return { nodes, occurrences }; + const { nodes, occurrences, pendingContradictions } = emptyScan(); + if (reader === undefined) + return { nodes, occurrences, pendingContradictions }; const occurrenceByPath = new Map(); const registry = new ArtifactPathRegistry(); let totalBytes = 0; @@ -302,7 +403,9 @@ const scanReader = async ( currentReader: ArtifactReader, entry: ArtifactEntry, logicalPath: string, - ): Promise => { + ): Promise< + { readonly node: ArtifactNode; readonly mismatched: boolean } | undefined + > => { if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted) return undefined; const remainingBytes = limits.maxTotalBytes - totalBytes; @@ -322,7 +425,9 @@ const scanReader = async ( signal, ); totalBytes += digest.bytes; - if (entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256) + const mismatched = + entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256; + if (mismatched && integrity.mode === "fail") throw new ArtifactReaderFailure( "integrity", `Artifact integrity metadata disagrees with content: ${logicalPath}`, @@ -334,18 +439,40 @@ const scanReader = async ( unpacked: entry.unpacked, }, ); + if (mismatched && entry.declaredSha256 !== null) { + if (pendingContradictions.length >= integrity.maxMismatches) + throw new ArtifactReaderFailure( + "limit", + "Artifact integrity mismatch limit exceeded", + ); + pendingContradictions.push({ + logicalPath, + declaredSha256: entry.declaredSha256, + observedSha256: digest.sha256, + entryKind: entry.kind, + unpacked: entry.unpacked, + }); + } const classified = entry.kind === "slice" ? ({ kind: "universal-slice", format: "mach-o" } as const) : classifyArtifactContent(logicalPath, digest.prefix); - return createArtifactNode({ - sha256: digest.sha256, - size: digest.bytes, - kind: classified.kind, - format: classified.format, - executable: entry.executable, - contentState: "embedded", - }); + return { + node: createArtifactNode({ + sha256: digest.sha256, + size: digest.bytes, + kind: classified.kind, + format: classified.format, + executable: entry.executable, + contentState: "embedded", + limitations: mismatched + ? [ + "Observed content contradicts declared integrity metadata and is untrusted.", + ] + : [], + }), + mismatched, + }; }; const visit = async ( currentReader: ArtifactReader, @@ -370,20 +497,26 @@ const scanReader = async ( logicalPath, parent?.occurrence_id ?? null, ); - const node = await digestEntry(currentReader, entry, logicalPath); - if (node !== undefined) { - nodes.set(node.artifact_id, node); - occurrence.artifact_id = node.artifact_id; - occurrence.hash_status = "verified"; + const digested = await digestEntry(currentReader, entry, logicalPath); + if (digested !== undefined) { + nodes.set(digested.node.artifact_id, digested.node); + occurrence.artifact_id = digested.node.artifact_id; + occurrence.hash_status = digested.mismatched + ? "mismatched" + : "verified"; + if (digested.mismatched) + occurrence.limitations.push( + "Declared integrity metadata contradicts observed bytes.", + ); } occurrences.push(occurrence); occurrenceByPath.set(logicalPath, occurrence); - if (expandableAsar) + if (expandableAsar && digested?.mismatched !== true) await visitNestedAsar(entry.adapterKey, logicalPath, visit); } }; await visit(reader, ""); - return { nodes, occurrences }; + return { nodes, occurrences, pendingContradictions }; }; const classifyRoot = async ( diff --git a/src/application/AuthorizedArtifactInventory.ts b/src/application/AuthorizedArtifactInventory.ts index 6be9d9b3c..f58641625 100644 --- a/src/application/AuthorizedArtifactInventory.ts +++ b/src/application/AuthorizedArtifactInventory.ts @@ -5,6 +5,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { withinRoot } from "../reference/ReferenceSourceReaderPaths.js"; import { scanCanonicalArtifactInventory, + type ArtifactIntegrityPolicy, type ArtifactInventorySnapshot, } from "./ArtifactInventory.js"; @@ -14,6 +15,7 @@ export const scanAuthorizedArtifactInventory = async ( roots: readonly string[], limits: ArtifactLimits, signal?: AbortSignal, + integrity?: ArtifactIntegrityPolicy, ): Promise => { if (roots.length === 0) throw new ArtifactReaderFailure( @@ -29,5 +31,11 @@ export const scanAuthorizedArtifactInventory = async ( "path", "Artifact path is outside approved input roots", ); - return scanCanonicalArtifactInventory(path, limits, signal); + return scanCanonicalArtifactInventory( + path, + limits, + signal, + undefined, + integrity, + ); }; diff --git a/src/application/BinarySession.ts b/src/application/BinarySession.ts index af842a7d5..ee3ecf80d 100644 --- a/src/application/BinarySession.ts +++ b/src/application/BinarySession.ts @@ -46,6 +46,10 @@ import { unknownMutationEvidence, } from "./UnknownEvidence.js"; import type { BinarySessionPort } from "./BinarySessionPort.js"; +import { + parseInvestigationWorkspace, + type InvestigationWorkspace, +} from "../domain/investigationWorkspace.js"; export type { BinarySessionPort } from "./BinarySessionPort.js"; const OFFICIAL_OPERATIONS: ReadonlySet = new Set( OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name), @@ -73,6 +77,12 @@ export class BinarySession implements BinarySessionPort { maxBytes: 64 * 1024 * 1024, }); readonly #snapshot = new AnalysisSnapshotCache(); + readonly #investigationWorkspaces = new Map(); + readonly #runtimeAvailability = new Map< + string, + { readonly available: boolean; readonly reason: string | null } + >(); + readonly #availabilityListeners = new Set<() => void>(); readonly #snapshotsEnabled: boolean; #snapshotInvalidated = false; @@ -121,6 +131,12 @@ export class BinarySession implements BinarySessionPort { return this.#providerIdentity; } + /** Observe runtime provider-health changes that affect discovery metadata. */ + onAvailabilityChanged(listener: () => void): () => void { + this.#availabilityListeners.add(listener); + return () => this.#availabilityListeners.delete(listener); + } + /** Add one successful public observation to the session ledger. */ recordEvidence( evidence: Evidence, @@ -191,6 +207,36 @@ export class BinarySession implements BinarySessionPort { ); } + /** Retain one validated immutable workspace revision for session resources. */ + retainInvestigationWorkspace( + workspace: InvestigationWorkspace, + ): "added" | "duplicate" { + const parsed = parseInvestigationWorkspace(workspace); + const key = `${parsed.workspace_id}:${String(parsed.revision)}`; + if (this.#investigationWorkspaces.has(key)) return "duplicate"; + this.#investigationWorkspaces.set(key, parsed); + return "added"; + } + + /** Read one session-retained immutable workspace revision. */ + investigationWorkspace( + workspaceId: string, + revision: number, + ): InvestigationWorkspace | undefined { + return this.#investigationWorkspaces.get( + `${workspaceId}:${String(revision)}`, + ); + } + + /** List retained workspace revisions in canonical identity order. */ + investigationWorkspaces(): readonly InvestigationWorkspace[] { + return [...this.#investigationWorkspaces.values()].sort( + (left, right) => + left.workspace_id.localeCompare(right.workspace_id) || + left.revision - right.revision, + ); + } + /** Create an approved residual unknown and immutable mutation evidence. */ recordUnknown( input: RecordUnknownInput, @@ -323,6 +369,7 @@ export class BinarySession implements BinarySessionPort { return err(new AnalysisCancelledError("open_binary")); } this.#active = { target: parsed.value, client }; + this.#clearRuntimeAvailability(); this.#snapshot.select(parsed.value); if (options.snapshot !== undefined) { const imported = this.importAnalysisSnapshot(options.snapshot); @@ -348,6 +395,7 @@ export class BinarySession implements BinarySessionPort { this.#evidence.clear(); this.#snapshot.clear(); this.#snapshotInvalidated = false; + this.#clearRuntimeAvailability(); return ok(null); }); } @@ -375,6 +423,10 @@ export class BinarySession implements BinarySessionPort { .sort((left, right) => left.operation.localeCompare(right.operation), ) + .map((descriptor) => ({ + ...descriptor, + ...this.#runtimeAvailability.get(descriptor.operation), + })) .map((descriptor) => ({ operation: descriptor.operation, available: descriptor.available, @@ -456,6 +508,7 @@ export class BinarySession implements BinarySessionPort { this.#calls.add(call); try { const result = await call; + this.#observeRuntimeAvailability(name, result); if (result.ok && cacheable) this.#snapshot.record(active.target, name, arguments_, result.value); else if (result.ok && capability?.effects.mutatesArtifact === true) { @@ -468,6 +521,66 @@ export class BinarySession implements BinarySessionPort { } } + #observeRuntimeAvailability( + operation: AnalysisOperation, + result: Result, + ): void { + if (result.ok) { + if (this.#setRuntimeAvailability(operation, true, null)) + this.#emitAvailabilityChanged(); + return; + } + if (result.error._tag === "AnalysisCapabilityUnavailableError") { + if (this.#setRuntimeAvailability(operation, false, result.error.message)) + this.#emitAvailabilityChanged(); + return; + } + if ( + [ + "ProviderAdapterError", + "HopperProcessError", + "HopperStartError", + "HopperRemoteError", + ].includes(result.error._tag) + ) { + const providerId = this.#capabilities?.get(operation)?.provider.id; + let changed = false; + for (const descriptor of this.#capabilities?.values() ?? []) + if (providerId !== undefined && descriptor.provider.id === providerId) + changed = + this.#setRuntimeAvailability( + descriptor.operation, + false, + "Provider became unavailable during this session.", + ) || changed; + if (changed) this.#emitAvailabilityChanged(); + } + } + + #setRuntimeAvailability( + operation: string, + available: boolean, + reason: string | null, + ): boolean { + const current = this.#runtimeAvailability.get(operation); + if (current?.available === available && current.reason === reason) + return false; + if (available && current === undefined) return false; + if (available) this.#runtimeAvailability.delete(operation); + else this.#runtimeAvailability.set(operation, { available, reason }); + return true; + } + + #clearRuntimeAvailability(): void { + if (this.#runtimeAvailability.size === 0) return; + this.#runtimeAvailability.clear(); + this.#emitAvailabilityChanged(); + } + + #emitAvailabilityChanged(): void { + for (const listener of this.#availabilityListeners) listener(); + } + #serialize(operation: () => Promise): Promise { const result = this.#transition.then(operation, operation); this.#transition = result.then( diff --git a/src/application/BinarySessionPort.ts b/src/application/BinarySessionPort.ts index 659eabb85..1cd6f3111 100644 --- a/src/application/BinarySessionPort.ts +++ b/src/application/BinarySessionPort.ts @@ -16,6 +16,7 @@ import type { UnknownStatus, UpdateUnknownInput, } from "../domain/residualUnknown.js"; +import type { InvestigationWorkspace } from "../domain/investigationWorkspace.js"; import type { AnalysisOperation, AnalysisOperationPort, @@ -48,6 +49,14 @@ export interface BinarySessionPort extends AnalysisOperationPort { importAnalysisSnapshot( snapshot: AnalysisSnapshot, ): Result; + retainInvestigationWorkspace( + workspace: InvestigationWorkspace, + ): "added" | "duplicate"; + investigationWorkspace( + workspaceId: string, + revision: number, + ): InvestigationWorkspace | undefined; + investigationWorkspaces(): readonly InvestigationWorkspace[]; recordUnknown( input: RecordUnknownInput, ): Result; @@ -72,4 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort { UnknownRegistryError >; providerIdentity(operation?: AnalysisOperation): ProviderIdentity; + onAvailabilityChanged?(listener: () => void): () => void; } diff --git a/src/application/CrossVersionInventory.ts b/src/application/CrossVersionInventory.ts index 93c0ef941..49d0d34b7 100644 --- a/src/application/CrossVersionInventory.ts +++ b/src/application/CrossVersionInventory.ts @@ -15,6 +15,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { paginateArtifactInventory, + type ArtifactIntegrityPolicy, type ArtifactInventorySnapshot, } from "./ArtifactInventory.js"; import { scanAuthorizedArtifactInventory } from "./AuthorizedArtifactInventory.js"; @@ -38,22 +39,31 @@ export const scanVersions = async ( input: CrossVersionInvestigationInput, inputRoots: readonly string[], signal?: AbortSignal, + integrityContinueEnabled = false, ): Promise> => { try { if (signal?.aborted === true) return err(new AnalysisCancelledError("find_changed_behavior")); const limits = artifactLimits(input.options); + const integrity: ArtifactIntegrityPolicy = { + mode: input.integrity_policy, + approved: input.integrity_continue_approved, + enabled: integrityContinueEnabled, + maxMismatches: input.max_integrity_mismatches, + }; const left = await scanAuthorizedArtifactInventory( input.left_path, inputRoots, limits, signal, + integrity, ); const right = await scanAuthorizedArtifactInventory( input.right_path, inputRoots, limits, signal, + integrity, ); return ok({ left, right }); } catch (cause: unknown) { @@ -74,12 +84,8 @@ export const createInventoryEvidencePages = ( input: CrossVersionInvestigationInput, snapshots: VersionSnapshots, ): Result => { - const left = inventoryPages(input.left_path, snapshots.left, input.options); - const right = inventoryPages( - input.right_path, - snapshots.right, - input.options, - ); + const left = inventoryPages(input.left_path, snapshots.left, input); + const right = inventoryPages(input.right_path, snapshots.right, input); if (left.length > 100 || right.length > 100) return err(new ArtifactOperationError("inventory_artifact", "limit")); return ok({ left, right }); @@ -108,8 +114,9 @@ export const runLimitations = (snapshots: VersionSnapshots): string[] => const inventoryPages = ( path: string, snapshot: ArtifactInventorySnapshot, - options: InvestigationRunOptions, + input: CrossVersionInvestigationInput, ): Evidence[] => { + const options = input.options; const count = Math.max( 1, Math.ceil(snapshot.nodes.length / options.page_size), @@ -117,16 +124,17 @@ const inventoryPages = ( Math.ceil(snapshot.edges.length / options.page_size), ); return Array.from({ length: count }, (_, index) => - inventoryPage(path, snapshot, options, index * options.page_size), + inventoryPage(path, snapshot, input, index * options.page_size), ); }; const inventoryPage = ( path: string, snapshot: ArtifactInventorySnapshot, - options: InvestigationRunOptions, + input: CrossVersionInvestigationInput, offset: number, ): Evidence => { + const options = input.options; const result = paginateArtifactInventory(snapshot, { nodeOffset: offset, nodeLimit: options.page_size, @@ -157,6 +165,9 @@ const inventoryPage = ( max_compression_ratio: options.max_compression_ratio, max_depth: options.max_depth, max_path_bytes: options.max_path_bytes, + integrity_policy: input.integrity_policy, + integrity_continue_approved: input.integrity_continue_approved, + max_integrity_mismatches: input.max_integrity_mismatches, }, result: jsonValueSchema.parse(result), rawResult: null, diff --git a/src/application/CrossVersionInvestigation.ts b/src/application/CrossVersionInvestigation.ts index 7ba4408fa..f714652f8 100644 --- a/src/application/CrossVersionInvestigation.ts +++ b/src/application/CrossVersionInvestigation.ts @@ -6,6 +6,7 @@ import { type EvidenceFilePolicy, } from "../domain/evidenceBundle.js"; import { + AnalysisCancelledError, AnalysisInputError, EvidenceIntegrityError, InvestigationWorkspaceError, @@ -25,6 +26,7 @@ import { import { jsonValueSchema } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import type { BinarySessionPort } from "./BinarySession.js"; +import type { ProgressReporter } from "./ProgressReporter.js"; import { AUTOMATIC_RUN_LIMITATION, createInventoryEvidencePages, @@ -54,6 +56,8 @@ export interface CrossVersionInvestigationExecution { readonly inputRoots: readonly string[]; readonly session?: BinarySessionPort; readonly signal?: AbortSignal; + readonly progress?: ProgressReporter; + readonly integrityContinueEnabled?: boolean; } /** Run or resume a deterministic cross-version artifact investigation. */ @@ -76,19 +80,36 @@ export const runCrossVersionInvestigation = async ( if (!initial.ok) return initial; const preflight = validateWorkspaceRequest(initial.value, parsed.data); if (!preflight.ok) return preflight; + if (isCancelled(execution.signal)) + return err(new AnalysisCancelledError("find_changed_behavior")); + await execution.progress?.report({ + phase: "scan_versions", + completed: 0, + total: 4, + message: "Scanning both version inputs", + }); const snapshots = await scanVersions( parsed.data, execution.inputRoots, execution.signal, + execution.integrityContinueEnabled, ); if (!snapshots.ok) return snapshots; + await execution.progress?.report({ + phase: "scan_versions", + completed: 1, + total: 4, + message: "Version inputs scanned", + }); return continueInvestigation({ input: parsed.data, initial: initial.value, snapshots: snapshots.value, policy, session: execution.session, + signal: execution.signal, + progress: execution.progress, }); }; @@ -103,6 +124,8 @@ const continueInvestigation = async (context: { readonly snapshots: VersionSnapshots; readonly policy: EvidenceFilePolicy; readonly session: BinarySessionPort | undefined; + readonly signal: AbortSignal | undefined; + readonly progress: ProgressReporter | undefined; }): Promise> => { const left = targetFor(context.snapshots.left); const right = targetFor(context.snapshots.right); @@ -110,6 +133,9 @@ const continueInvestigation = async (context: { left, right, options: context.input.options, + integrity_policy: context.input.integrity_policy, + integrity_continue_approved: context.input.integrity_continue_approved, + max_integrity_mismatches: context.input.max_integrity_mismatches, }); const existing = context.initial?.runs.find( ({ run_id: id }) => id === identity.runId, @@ -118,6 +144,8 @@ const continueInvestigation = async (context: { return completeOutcome(context.initial, existing, true, context.session); const pages = createInventoryEvidencePages(context.input, context.snapshots); if (!pages.ok) return pages; + if (isCancelled(context.signal)) + return err(new AnalysisCancelledError("find_changed_behavior")); const inventoried = await ensureInventoryCheckpoint({ input: context.input, current: context.initial, @@ -127,20 +155,47 @@ const continueInvestigation = async (context: { policy: context.policy, }); if (!inventoried.ok) return inventoried; + await context.progress?.report({ + phase: "inventory_versions", + completed: 2, + total: 4, + message: "Both artifact inventories checkpointed", + }); + if (isCancelled(context.signal)) + return err(new AnalysisCancelledError("find_changed_behavior")); const compared = await ensureComparisonCheckpoint( context.input, inventoried.value, context.policy, ); if (!compared.ok) return compared; - return finalizeInvestigation( + await context.progress?.report({ + phase: "compare_artifacts", + completed: 3, + total: 4, + message: "Artifact comparison checkpointed", + }); + if (isCancelled(context.signal)) + return err(new AnalysisCancelledError("find_changed_behavior")); + const finalized = await finalizeInvestigation( context.input, compared.value, context.policy, context.session, ); + await context.progress?.report({ + phase: "changed_behavior", + completed: 4, + total: 4, + message: finalized.ok ? "Investigation completed" : "Investigation failed", + terminal: true, + }); + return finalized; }; +const isCancelled = (signal: AbortSignal | undefined): boolean => + signal?.aborted === true; + const ensureInventoryCheckpoint = async (input: { readonly input: CrossVersionInvestigationInput; readonly current: InvestigationWorkspace | null; @@ -162,6 +217,9 @@ const ensureInventoryCheckpoint = async (input: { left, right, options: input.input.options, + integrity_policy: input.input.integrity_policy, + integrity_continue_approved: input.input.integrity_continue_approved, + max_integrity_mismatches: input.input.max_integrity_mismatches, }); const run = investigationRunSchema.parse({ schema_version: 1, @@ -170,6 +228,9 @@ const ensureInventoryCheckpoint = async (input: { left, right, options: input.input.options, + integrity_policy: input.input.integrity_policy, + integrity_continue_approved: input.input.integrity_continue_approved, + max_integrity_mismatches: input.input.max_integrity_mismatches, status: "running", completed_stages: ["inventory_left", "inventory_right"], left_inventory_evidence_ids: evidenceIds(input.pages.left), diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index b40843f95..2247371b8 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -1,4 +1,6 @@ import { parseConfig } from "../config.js"; +import { jsonObjectSchema } from "../domain/jsonValue.js"; +import { createServerIdentity } from "../serverIdentity.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { EnhancedTools } from "./EnhancedTools.js"; import { createBinarySession } from "./runtime.js"; @@ -9,6 +11,8 @@ import type { NativeToolName } from "../contracts/nativeToolContracts.js"; import type { ArtifactToolName } from "../contracts/artifactToolContracts.js"; import { EvidenceIntegrityError, + AnalysisProtocolError, + PermissionRequiredError, projectAnalysisError, type AnalysisError, } from "../domain/errors.js"; @@ -24,6 +28,8 @@ import { } from "../domain/analysisSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisSnapshot } from "../domain/analysisSnapshot.js"; +import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js"; +import type { PermissionAuthority } from "./PermissionAuthority.js"; const WORKFLOW_PROVIDER = { id: "rea-workflow", @@ -52,12 +58,16 @@ export const runDirectAnalysis = async ( options: { readonly logger?: Logger; readonly snapshotPath?: string | undefined; + readonly signal?: AbortSignal; } = {}, ): Promise => - runAnalysis(path, tool, arguments_, { - logger: options.logger ?? silentLogger, - snapshotPath: options.snapshotPath, - }); + withProcessCancellation(options.signal, (signal) => + runAnalysis(path, tool, arguments_, { + logger: options.logger ?? silentLogger, + snapshotPath: options.snapshotPath, + signal, + }), + ); /** Execute one provider-native semantic operation with atomic provenance. */ export const runProviderAnalysis = async ( @@ -65,8 +75,15 @@ export const runProviderAnalysis = async ( tool: NativeToolName | ArtifactToolName, arguments_: Readonly>, logger: Logger = silentLogger, + signal?: AbortSignal, ): Promise => - runAnalysis(path, tool, arguments_, { logger, snapshotPath: undefined }); + withProcessCancellation(signal, (operationSignal) => + runAnalysis(path, tool, arguments_, { + logger, + snapshotPath: undefined, + signal: operationSignal, + }), + ); /** Describe configured providers without opening a target or launching Hopper. */ export const runSessionStatus = async ( @@ -76,12 +93,94 @@ export const runSessionStatus = async ( if (!config.ok) return cliError(config.error); const session = createBinarySession(config.value, logger); try { - return session.status(); + return { + ...jsonObjectSchema.parse(session.status()), + server_identity: createServerIdentity({ + startedAt: new Date().toISOString(), + }), + }; } finally { await session.close(); } }; +const authorizeAnalysis = async ( + authority: PermissionAuthority, + tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, + arguments_: Readonly>, + snapshotPath: string | undefined, +): Promise> => { + const requests = []; + if (snapshotPath !== undefined) { + requests.push( + { + capability: "snapshot_read" as const, + path: snapshotPath, + access: "write" as const, + }, + { + capability: "snapshot_write" as const, + path: snapshotPath, + access: "write" as const, + }, + ); + } + if (tool === "extract_artifact" && typeof arguments_.output_root === "string") + requests.push({ + capability: "artifact_extract" as const, + path: arguments_.output_root, + access: "write" as const, + }); + for (const request of requests) { + const result = await authority.authorize( + { + capability: request.capability, + roots: [request.path], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `${tool}:${request.capability}:${request.path}`, + }, + request.access, + ); + if (!result.ok) + return err( + result.error instanceof PermissionRequiredError + ? result.error + : new AnalysisProtocolError(result.error.message, { + cause: result.error, + }), + ); + } + if ( + tool === "inventory_artifact" && + arguments_.native_mount_approved === true + ) { + const result = await authority.authorize( + { + capability: "native_mount", + roots: [], + executables: [], + environment_names: [], + network: "none", + mount: true, + operation_identity: "inventory_artifact:native_mount", + }, + "read", + ); + if (!result.ok) + return err( + result.error instanceof PermissionRequiredError + ? result.error + : new AnalysisProtocolError(result.error.message, { + cause: result.error, + }), + ); + } + return ok(null); +}; + const runAnalysis = async ( path: string, tool: NativeToolName | ArtifactToolName | DirectAnalysisTool, @@ -89,11 +188,23 @@ const runAnalysis = async ( options: { readonly logger: Logger; readonly snapshotPath: string | undefined; + readonly signal: AbortSignal; }, ): Promise => { - const { logger, snapshotPath } = options; + const { logger, signal, snapshotPath } = options; const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); + const permissionAuthority = await loadConfiguredPermissionAuthority( + config.value, + ); + if (!permissionAuthority.ok) return cliError(permissionAuthority.error); + const authorized = await authorizeAnalysis( + permissionAuthority.value, + tool, + arguments_, + snapshotPath, + ); + if (!authorized.ok) return cliError(authorized.error); const unavailable = snapshotUnavailable( snapshotPath, config.value.hopperLoaderArgs, @@ -114,7 +225,7 @@ const runAnalysis = async ( const { snapshot } = prepared.value; const opened = await session.open( path, - snapshot === undefined ? {} : { snapshot }, + snapshot === undefined ? { signal } : { signal, snapshot }, ); if (!opened.ok) return cliError(opened.error); let output: JsonValue; @@ -124,7 +235,11 @@ const runAnalysis = async ( tool === "analyze_function" || tool === "trace_feature" ) { - const result = await new EnhancedTools(session).execute(tool, arguments_); + const result = await new EnhancedTools(session).execute( + tool, + arguments_, + signal, + ); if (!result.ok) output = cliError(result.error); else { evidence = createEvidence( @@ -143,7 +258,7 @@ const runAnalysis = async ( output = evidence; } } else { - const result = await session.execute(tool, arguments_); + const result = await session.execute(tool, arguments_, { signal }); if (!result.ok) output = cliError(result.error); else { evidence = createEvidence( @@ -179,6 +294,21 @@ const runAnalysis = async ( } }; +const withProcessCancellation = async ( + suppliedSignal: AbortSignal | undefined, + operation: (signal: AbortSignal) => Promise, +): Promise => { + if (suppliedSignal !== undefined) return operation(suppliedSignal); + const controller = new AbortController(); + const cancel = (): void => controller.abort(); + process.once("SIGINT", cancel); + try { + return await operation(controller.signal); + } finally { + process.off("SIGINT", cancel); + } +}; + const prepareSnapshot = async (options: { readonly path: string; readonly tool: string; diff --git a/src/application/PermissionAuthority.ts b/src/application/PermissionAuthority.ts new file mode 100644 index 000000000..81a4bd8a8 --- /dev/null +++ b/src/application/PermissionAuthority.ts @@ -0,0 +1,278 @@ +import { realpath } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +import type { + PermissionCeiling, + PermissionDecision, + PermissionGrant, + PermissionPolicy, + PermissionRequest, + PermissionScope, +} from "../domain/permissionPolicy.js"; +import { + consumePermission, + clearSessionPermissions, + createPermissionPolicy, + evaluatePermission, + grantPermission, + reloadPermissionCeilings, + revokePermission, +} from "../domain/permissionPolicy.js"; +import { PermissionRequiredError } from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; + +/** Failure to establish a filesystem object's canonical authorization identity. */ +export class PermissionPathError extends Error { + readonly _tag = "PermissionPathError" as const; + + constructor( + readonly path: string, + readonly reason: "not_found" | "io", + options?: ErrorOptions, + ) { + super(`Cannot canonicalize permission path: ${path}`, options); + } +} + +/** Canonicalize administrator ceilings before they become authorization state. */ +export const canonicalizePermissionCeilings = async ( + ceilings: readonly PermissionCeiling[], +): Promise> => { + const normalized: PermissionCeiling[] = []; + for (const ceiling of ceilings) { + const canonical = await canonicalizeScope(ceiling, "read"); + if (!canonical.ok) return canonical; + normalized.push(canonical.value); + } + return ok(normalized); +}; + +/** Canonicalize one exact operation request before subset evaluation. */ +export const canonicalizePermissionRequest = async ( + request: PermissionRequest, + access: "read" | "write", +): Promise> => { + const canonical = await canonicalizeScope(request, access); + return canonical.ok + ? ok({ ...canonical.value, operation_identity: request.operation_identity }) + : canonical; +}; + +const canonicalizeScope = async ( + scope: PermissionScope, + access: "read" | "write", +): Promise> => { + const roots = await canonicalizePaths(scope.roots, access); + if (!roots.ok) return roots; + const executables = await canonicalizePaths(scope.executables, "read"); + if (!executables.ok) return executables; + return ok({ ...scope, roots: roots.value, executables: executables.value }); +}; + +const canonicalizePaths = async ( + paths: readonly string[], + access: "read" | "write", +): Promise> => { + const canonical: string[] = []; + for (const path of paths) { + const result = await canonicalizePath(path, access); + if (!result.ok) return result; + canonical.push(result.value); + } + return ok(canonical); +}; + +const canonicalizePath = async ( + path: string, + access: "read" | "write", +): Promise> => { + const requested = resolve(path); + try { + return ok(await realpath(requested)); + } catch (cause: unknown) { + if (access !== "write" || !isNotFound(cause)) + return err(new PermissionPathError(path, errorReason(cause), { cause })); + } + try { + const parent = await realpath(dirname(requested)); + return ok(join(parent, basename(requested))); + } catch (cause: unknown) { + return err(new PermissionPathError(path, errorReason(cause), { cause })); + } +}; + +const isNotFound = (error: unknown): boolean => + error instanceof Error && "code" in error && error.code === "ENOENT"; + +const errorReason = (error: unknown): PermissionPathError["reason"] => + isNotFound(error) ? "not_found" : "io"; + +/** Stateful owner for permission evaluation, consumption, reload, and revoke. */ +export class PermissionAuthority { + private policy: PermissionPolicy; + + constructor(policy: PermissionPolicy) { + this.policy = policy; + } + + /** Canonicalize and authorize one exact operation before its side effect. */ + async authorize( + request: PermissionRequest, + access: "read" | "write", + options: { + readonly elicitationSupported?: boolean; + readonly restartRequired?: boolean; + } = {}, + ): Promise< + Result + > { + const evaluated = await this.explain(request, access, options); + if (!evaluated.ok) return evaluated; + const decision = evaluated.value; + this.policy = consumePermission(this.policy, decision); + return ok(decision); + } + + /** Dry-run the exact shared evaluation without consuming a once grant. */ + async explain( + request: PermissionRequest, + access: "read" | "write", + options: { + readonly elicitationSupported?: boolean; + readonly restartRequired?: boolean; + } = {}, + ): Promise< + Result< + PermissionDecision & { readonly allowed: true }, + PermissionPathError | PermissionRequiredError + > + > { + const canonical = await canonicalizePermissionRequest(request, access); + if (!canonical.ok) return canonical; + const decision = evaluatePermission(this.policy, canonical.value); + if (!decision.allowed) + return err( + new PermissionRequiredError( + canonical.value, + decision.missing, + ceilingFor(this.policy, canonical.value.capability), + options.elicitationSupported ?? false, + options.restartRequired ?? false, + ), + ); + return ok(decision); + } + + /** Add authority beneath the administrator ceiling. */ + grant(grant: PermissionGrant): ReturnType { + const result = grantPermission(this.policy, grant); + if (result.ok) this.policy = result.value; + return result; + } + + /** Revoke authority immediately for subsequent operations. */ + revoke(grantId: string): void { + this.policy = revokePermission(this.policy, grantId); + } + + /** Drop once/session authority when its owning connection ends. */ + clearSessionGrants(): void { + this.policy = clearSessionPermissions(this.policy); + } + + /** Atomically replace optional persisted project grants. */ + async replaceProjectGrants( + grants: readonly PermissionGrant[], + ): Promise> { + let candidate: PermissionPolicy = { + ...this.policy, + grants: this.policy.grants.filter( + ({ lifetime }) => lifetime !== "project", + ), + }; + for (const grant of grants) { + const canonical = await canonicalizeGrant(grant); + if (!canonical.ok) return canonical; + const added = grantPermission(candidate, canonical.value); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { + cause: added.error, + }), + ); + candidate = added.value; + } + this.policy = candidate; + return ok(null); + } + + /** Replace grants derived from the currently loaded administrator ceiling. */ + async replaceAdministratorGrants( + grants: readonly PermissionGrant[], + ): Promise> { + let candidate: PermissionPolicy = { + ...this.policy, + grants: this.policy.grants.filter( + ({ lifetime }) => lifetime !== "administrator", + ), + }; + for (const grant of grants) { + const canonical = await canonicalizeGrant(grant); + if (!canonical.ok) return canonical; + const added = grantPermission(candidate, canonical.value); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { + cause: added.error, + }), + ); + candidate = added.value; + } + this.policy = candidate; + return ok(null); + } + + /** Replace ceilings without restarting the MCP process. */ + async reload( + ceilings: readonly PermissionCeiling[], + ): Promise> { + const canonical = await canonicalizePermissionCeilings(ceilings); + if (!canonical.ok) return canonical; + this.policy = reloadPermissionCeilings(this.policy, canonical.value); + return ok(null); + } +} + +/** Create one canonical policy and optional grants at the composition root. */ +export const createPermissionAuthority = async ( + ceilings: readonly PermissionCeiling[], + grants: readonly PermissionGrant[] = [], +): Promise> => { + const canonical = await canonicalizePermissionCeilings(ceilings); + if (!canonical.ok) return canonical; + let policy = createPermissionPolicy(canonical.value); + for (const grant of grants) { + const canonicalGrant = await canonicalizeGrant(grant); + if (!canonicalGrant.ok) return canonicalGrant; + const added = grantPermission(policy, canonicalGrant.value); + if (!added.ok) + return err( + new PermissionPathError(grant.grant_id, "io", { cause: added.error }), + ); + policy = added.value; + } + return ok(new PermissionAuthority(policy)); +}; + +const canonicalizeGrant = async ( + grant: PermissionGrant, +): Promise> => { + const scope = await canonicalizeScope(grant, "read"); + return scope.ok ? ok({ ...grant, ...scope.value }) : scope; +}; + +const ceilingFor = ( + policy: PermissionPolicy, + capability: PermissionRequest["capability"], +): PermissionScope | null => + policy.ceilings.find((ceiling) => ceiling.capability === capability) ?? null; diff --git a/src/application/PermissionConfiguration.ts b/src/application/PermissionConfiguration.ts new file mode 100644 index 000000000..5a8d57500 --- /dev/null +++ b/src/application/PermissionConfiguration.ts @@ -0,0 +1,52 @@ +import type { AppConfig } from "../config.js"; +import { ConfigurationError } from "../domain/errors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + createPermissionAuthority, + type PermissionAuthority, +} from "./PermissionAuthority.js"; +import { readProjectPermissionStore } from "./ProjectPermissionStore.js"; + +/** Load the same administrator and optional project policy for CLI and MCP. */ +export const loadConfiguredPermissionAuthority = async ( + config: AppConfig, +): Promise> => { + const authority = await createPermissionAuthority( + config.permissionCeilings, + config.administratorPermissionGrants, + ); + if (!authority.ok) + return err( + new ConfigurationError("Permission policy could not be loaded", { + cause: authority.error, + }), + ); + if ( + config.permissionProjectRoot === undefined || + config.permissionProjectStore === undefined + ) + return ok(authority.value); + const project = await readProjectPermissionStore( + config.permissionProjectStore, + config.permissionProjectRoot, + ); + if (!project.ok) + return err( + new ConfigurationError("Project permission policy could not be loaded", { + cause: project.error, + }), + ); + const replaced = await authority.value.replaceProjectGrants( + project.value?.grants ?? [], + ); + return replaced.ok + ? ok(authority.value) + : err( + new ConfigurationError( + "Project permission policy could not be applied", + { + cause: replaced.error, + }, + ), + ); +}; diff --git a/src/application/ProcessCaptureError.ts b/src/application/ProcessCaptureError.ts index 9d1cb6b84..0d9d1c1f7 100644 --- a/src/application/ProcessCaptureError.ts +++ b/src/application/ProcessCaptureError.ts @@ -3,6 +3,12 @@ import { AnalysisError } from "../domain/errors.js"; interface ProcessCaptureErrorOptions extends ErrorOptions { readonly userMessage?: string; readonly userCategory?: "permission_required" | "cancelled"; + readonly reason?: + | "capture_failed" + | "cleanup_incomplete" + | "permission_required" + | "cancelled"; + readonly cleanupResources?: readonly string[]; } /** Typed application failure produced by controlled process capture. */ @@ -14,11 +20,23 @@ export class ProcessCaptureError extends AnalysisError { | "permission_required" | "cancelled" | undefined; + readonly reason: NonNullable; + override readonly cleanupIncomplete: boolean; + override readonly cleanupResources: readonly string[]; constructor(message: string, options?: ProcessCaptureErrorOptions) { super(message, options); this.userMessage = options?.userMessage; this.userCategory = options?.userCategory; + this.reason = + options?.reason ?? + (options?.userCategory === "permission_required" + ? "permission_required" + : options?.userCategory === "cancelled" + ? "cancelled" + : "capture_failed"); + this.cleanupIncomplete = this.reason === "cleanup_incomplete"; + this.cleanupResources = options?.cleanupResources ?? []; } } @@ -26,5 +44,6 @@ export class ProcessCaptureError extends AnalysisError { export const processCaptureCancelled = (): ProcessCaptureError => new ProcessCaptureError("process capture was cancelled", { userCategory: "cancelled", + reason: "cancelled", userMessage: "Process capture was cancelled. Start it again when ready.", }); diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts index 5de864e90..5a66d142e 100644 --- a/src/application/ProcessCaptureLifecycle.ts +++ b/src/application/ProcessCaptureLifecycle.ts @@ -389,7 +389,9 @@ export const resolveProcessResult = ( cause: executionFailure, }); if (cleanupFailure !== undefined) - throw new ProcessCaptureError(cleanupFailure); + throw new ProcessCaptureError(cleanupFailure, { + reason: "cleanup_incomplete", + }); if (capture === undefined) throw new ProcessCaptureError("process capture produced no result"); return capture; diff --git a/src/application/ProcessCli.ts b/src/application/ProcessCli.ts index 97cc2d573..8e9d9e5c8 100644 --- a/src/application/ProcessCli.ts +++ b/src/application/ProcessCli.ts @@ -1,7 +1,12 @@ import { readFile } from "node:fs/promises"; import { parseConfig } from "../config.js"; -import { AnalysisError, projectAnalysisError } from "../domain/errors.js"; +import { + AnalysisError, + AnalysisProtocolError, + PermissionRequiredError, + projectAnalysisError, +} from "../domain/errors.js"; import { createEvidence, parseEvidence } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { @@ -15,6 +20,7 @@ import { PROCESS_PROVIDER, createProcessCaptureEvidence, } from "./ProcessEvidence.js"; +import { loadConfiguredPermissionAuthority } from "./PermissionConfiguration.js"; const MAX_INPUT_BYTES = 64 * 1024 * 1024; @@ -40,6 +46,31 @@ export const captureProcessScenarioFile = async (path: string) => { } const config = parseConfig(process.env); if (!config.ok) return cliAnalysisError(config.error); + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) return cliAnalysisError(authority.error); + const authorized = await authority.value.authorize( + { + capability: "process_capture", + roots: [scenario.working_directory, ...scenario.filesystem_roots], + executables: [scenario.executable], + environment_names: [ + ...Object.keys(scenario.environment), + ...scenario.inherit_environment, + ], + network: scenario.network_access === "host" ? "external" : "none", + mount: false, + operation_identity: `capture_process_scenario:${scenario.executable}`, + }, + "read", + ); + if (!authorized.ok) + return cliAnalysisError( + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }), + ); const captured = await captureProcessScenario( scenario, config.value.processExecutionPolicy, diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index 2483f89d9..539e6ab08 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -174,7 +174,10 @@ const cleanupFailedStartup = async (options: { sampledProcessGroupIds: [], }); if (cleanupFailure !== undefined) - throw new ProcessCaptureError(cleanupFailure, { cause: options.cause }); + throw new ProcessCaptureError(cleanupFailure, { + cause: options.cause, + reason: "cleanup_incomplete", + }); throw options.cause; }; diff --git a/src/application/ProgressReporter.ts b/src/application/ProgressReporter.ts new file mode 100644 index 000000000..cf5ed7b7f --- /dev/null +++ b/src/application/ProgressReporter.ts @@ -0,0 +1,55 @@ +/** Provider-neutral progress observation for one ordinary request. */ +export interface ProgressUpdate { + readonly phase: string; + readonly completed: number; + readonly total: number | null; + readonly message: string; + readonly terminal?: boolean; +} + +/** Progress boundary shared by CLI, MCP, application workflows, and providers. */ +export interface ProgressReporter { + report(update: ProgressUpdate): Promise; +} + +/** Create a monotonic, rate-bounded reporter around a surface-specific sink. */ +export const createProgressReporter = ( + sink: ( + update: ProgressUpdate & { readonly sequence: number }, + ) => Promise, + options: { + readonly minimumIntervalMs?: number; + readonly now?: () => number; + } = {}, +): ProgressReporter => { + const minimumIntervalMs = options.minimumIntervalMs ?? 100; + const now = options.now ?? Date.now; + let lastCompleted = -1; + let lastSentAt = Number.NEGATIVE_INFINITY; + let sequence = 0; + return { + async report(update) { + if (!Number.isFinite(update.completed) || update.completed < 0) + throw new TypeError("Progress completed must be a nonnegative number"); + if (update.total !== null && update.total < update.completed) + throw new TypeError("Progress total cannot be less than completed"); + if (update.completed < lastCompleted) + throw new TypeError("Progress cannot move backwards"); + lastCompleted = update.completed; + const observedAt = now(); + if ( + update.terminal !== true && + observedAt - lastSentAt < minimumIntervalMs + ) + return; + lastSentAt = observedAt; + sequence += 1; + await sink({ ...update, sequence }); + }, + }; +}; + +/** Surface that deliberately ignores progress while preserving one workflow API. */ +export const silentProgressReporter: ProgressReporter = { + report: () => Promise.resolve(), +}; diff --git a/src/application/ProjectPermissionStore.ts b/src/application/ProjectPermissionStore.ts new file mode 100644 index 000000000..bfed9ad89 --- /dev/null +++ b/src/application/ProjectPermissionStore.ts @@ -0,0 +1,145 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, realpath, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +import writeFileAtomic from "write-file-atomic"; +import { z } from "zod"; + +import type { PermissionGrant } from "../domain/permissionPolicy.js"; +import { err, ok, type Result } from "../domain/result.js"; + +const grantSchema = z.object({ + grant_id: z.string().min(1), + capability: z.enum([ + "process_capture", + "evidence_read", + "evidence_write", + "investigation_input", + "investigation_workspace_read", + "investigation_workspace_write", + "snapshot_read", + "snapshot_write", + "artifact_extract", + "native_mount", + "reference_read", + ]), + roots: z.array(z.string()), + executables: z.array(z.string()), + environment_names: z.array(z.string()), + network: z.enum(["none", "loopback", "external"]), + mount: z.boolean(), + lifetime: z.literal("project"), + operation_identity: z.string().nullable(), + expires_at: z.iso.datetime().nullable(), +}); + +const storeSchema = z.object({ + schema_version: z.literal(1), + project_id: z.string().regex(/^project_[a-f0-9]{64}$/u), + project_root: z.string(), + grants: z.array(grantSchema).max(1_000), +}); + +export type ProjectPermissionStore = z.infer; + +/** Owner-only project policy persistence failure. */ +export class ProjectPermissionStoreError extends Error { + readonly _tag = "ProjectPermissionStoreError" as const; + + constructor( + readonly reason: "project_not_found" | "not_owner_only" | "invalid" | "io", + options?: ErrorOptions, + ) { + super(`Project permission store failed: ${reason}`, options); + } +} + +/** Derive relocation-explicit project identity from its canonical root. */ +const identifyPermissionProject = async ( + projectRoot: string, +): Promise< + Result< + { readonly id: string; readonly root: string }, + ProjectPermissionStoreError + > +> => { + try { + const root = await realpath(projectRoot); + return ok({ + id: `project_${createHash("sha256").update(root).digest("hex")}`, + root, + }); + } catch (cause: unknown) { + return err(new ProjectPermissionStoreError("project_not_found", { cause })); + } +}; + +/** Read and validate an owner-only store bound to the requested project. */ +export const readProjectPermissionStore = async ( + path: string, + projectRoot: string, +): Promise< + Result +> => { + const project = await identifyPermissionProject(projectRoot); + if (!project.ok) return project; + try { + const metadata = await stat(path); + if ((metadata.mode & 0o077) !== 0) + return err(new ProjectPermissionStoreError("not_owner_only")); + const parsed = storeSchema.safeParse( + JSON.parse(await readFile(path, "utf8")), + ); + if ( + !parsed.success || + parsed.data.project_id !== project.value.id || + parsed.data.project_root !== project.value.root + ) + return err(new ProjectPermissionStoreError("invalid")); + return ok(parsed.data); + } catch (cause: unknown) { + if (isNotFound(cause)) return ok(null); + return err(new ProjectPermissionStoreError("io", { cause })); + } +}; + +/** Atomically replace explicit project grants with owner-only permissions. */ +export const writeProjectPermissionStore = async ( + path: string, + projectRoot: string, + grants: readonly PermissionGrant[], +): Promise> => { + const project = await identifyPermissionProject(projectRoot); + if (!project.ok) return project; + const candidate = storeSchema.safeParse({ + schema_version: 1, + project_id: project.value.id, + project_root: project.value.root, + grants, + }); + if (!candidate.success) + return err( + new ProjectPermissionStoreError("invalid", { cause: candidate.error }), + ); + try { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFileAtomic( + path, + `${JSON.stringify(candidate.data, null, 2)}\n`, + { + encoding: "utf8", + mode: 0o600, + }, + ); + const metadata = await stat(path); + if ((metadata.mode & 0o077) !== 0) { + return err(new ProjectPermissionStoreError("not_owner_only")); + } + return ok(candidate.data); + } catch (cause: unknown) { + return err(new ProjectPermissionStoreError("io", { cause })); + } +}; + +const isNotFound = (cause: unknown): boolean => + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; diff --git a/src/application/runtime.ts b/src/application/runtime.ts index ed7f647a9..edf36310f 100644 --- a/src/application/runtime.ts +++ b/src/application/runtime.ts @@ -17,7 +17,10 @@ export const createBinarySession = ( ): BinarySession => { return new BinarySession( new CompositeProvider([ - new ArtifactProvider(config.artifactNativeMountEnabled), + new ArtifactProvider( + config.artifactNativeMountEnabled, + config.artifactIntegrityContinueEnabled, + ), new NativeMacOSProvider(), new HopperProvider(config, logger), ]), diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index ecba03aaf..e0ef5729f 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -5,6 +5,7 @@ import { type AnalysisProvider, type CapabilityDescriptor, type ProviderIdentity, + type ExecutionOptions, } from "../application/AnalysisProvider.js"; import { inventoryArtifact } from "../application/ArtifactInventory.js"; import { extractArtifact } from "../application/ArtifactExtraction.js"; @@ -32,7 +33,10 @@ const IDENTITY: ProviderIdentity = Object.freeze(ARTIFACT_GRAPH_PROVIDER); /** Read-only inventory and exclusively owned extraction provider. */ export class ArtifactProvider implements AnalysisProvider { - constructor(private readonly nativeMountEnabled = false) {} + constructor( + private readonly nativeMountEnabled = false, + private readonly integrityContinueEnabled = false, + ) {} readonly #capabilities: readonly CapabilityDescriptor[] = Object.freeze( ARTIFACT_TOOL_CONTRACTS.map((contract) => @@ -76,7 +80,11 @@ export class ArtifactProvider implements AnalysisProvider { } createClient(target: BinaryTarget): AnalysisClient { - return new ArtifactClient(target, this.nativeMountEnabled); + return new ArtifactClient( + target, + this.nativeMountEnabled, + this.integrityContinueEnabled, + ); } } @@ -84,12 +92,13 @@ class ArtifactClient implements AnalysisClient { constructor( private readonly target: BinaryTarget, private readonly nativeMountEnabled: boolean, + private readonly integrityContinueEnabled: boolean, ) {} async execute( operation: AnalysisOperation, parameters: Readonly>, - options?: { readonly signal?: AbortSignal }, + options?: ExecutionOptions, ) { if (operation === "health") return ok(createAnalysisExecution(null, IDENTITY)); @@ -151,6 +160,12 @@ class ArtifactClient implements AnalysisClient { nativeMountApproved: parsed.native_mount_approved === true, nativeMountEnabled: this.nativeMountEnabled, }, + integrity: { + mode: parsed.integrity_policy, + approved: parsed.integrity_continue_approved === true, + enabled: this.integrityContinueEnabled, + maxMismatches: parsed.max_integrity_mismatches, + }, }, ); return ok( diff --git a/src/cli.ts b/src/cli.ts index d667e1a97..a67e4dfb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,8 +19,14 @@ import { importReferenceSource } from "./application/ReferenceSourceImport.js"; import { registerEvidenceCommands } from "./cliEvidenceCommands.js"; import { registerProcessCommands } from "./cliProcessCommands.js"; import { registerInvestigationCommands } from "./cliInvestigationCommands.js"; -import { projectAnalysisError } from "./domain/errors.js"; +import { + AnalysisProtocolError, + PermissionRequiredError, + projectAnalysisError, +} from "./domain/errors.js"; import { projectReferenceSourceImportError } from "./application/ReferenceSourceImportTypes.js"; +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import { registerPolicyCommands } from "./cliPolicyCommands.js"; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -35,7 +41,7 @@ export const createCli = (): ReturnType => { : parseLogLevel(process.env.REA_LOG_LEVEL), ); const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, { - version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development", + version: PRODUCT_IDENTITY.packageVersion, description: "Reverse engineer anything from your terminal or agent.", mcp: { command: PRODUCT_IDENTITY.mcpCommand, @@ -66,6 +72,7 @@ export const createCli = (): ReturnType => { registerEvidenceCommands(cli, logger); registerReferenceSourceCommand(cli, logger); registerProcessCommands(cli, logger); + registerPolicyCommands(cli, logger); return cli; }; @@ -190,7 +197,7 @@ const registerSetupCommands = ( run: ({ formatExplicit }) => logCliCommand(logger, "upgrade", () => runUpgrade( - process.env.REA_PACKAGE_VERSION ?? "0.0.0-development", + PRODUCT_IDENTITY.packageVersion, systemUpgradeHost(), formatExplicit ? "structured" : "human", ), @@ -391,6 +398,36 @@ const registerReferenceSourceCommand = ( error: "Import failed", ...projectAnalysisError(config.error), }; + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) + return { + error: "Import failed", + ...projectAnalysisError(authority.error), + }; + const authorized = await authority.value.authorize( + { + capability: "reference_read", + roots: [args.root], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `import_reference_source:${args.root}`, + }, + "read", + ); + if (!authorized.ok) { + const error = + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }); + return { + error: "Import failed", + ...projectAnalysisError(error), + }; + } const imported = await importReferenceSource({ root: args.root, caller: "rea-cli", @@ -420,7 +457,17 @@ const registerArtifactCommands = ( options: z.object({ offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(500).default(100), + integrityPolicy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrityContinueApproved: z.boolean().default(false), + maxIntegrityMismatches: z.number().int().min(1).max(100).default(10), + nativeMountApproved: z.boolean().default(false), }), + alias: { + integrityPolicy: "integrity-policy", + integrityContinueApproved: "integrity-continue-approved", + maxIntegrityMismatches: "max-integrity-mismatches", + nativeMountApproved: "native-mount-approved", + }, run: ({ args, options }) => logCliCommand(logger, "inventory-artifact", () => runProviderAnalysis( @@ -433,6 +480,10 @@ const registerArtifactCommands = ( occurrence_limit: options.limit, edge_offset: options.offset, edge_limit: options.limit, + integrity_policy: options.integrityPolicy, + integrity_continue_approved: options.integrityContinueApproved, + max_integrity_mismatches: options.maxIntegrityMismatches, + native_mount_approved: options.nativeMountApproved, }, logger, ), diff --git a/src/cliEvidenceCommands.ts b/src/cliEvidenceCommands.ts index 59a5cf2e1..7a6a20cbd 100644 --- a/src/cliEvidenceCommands.ts +++ b/src/cliEvidenceCommands.ts @@ -9,7 +9,14 @@ import { parseConfig } from "./config.js"; import { logCliCommand } from "./cliLogging.js"; import type { Logger } from "./logger.js"; import type { JsonValue } from "./domain/jsonValue.js"; -import { projectAnalysisError, type AnalysisError } from "./domain/errors.js"; +import { + AnalysisProtocolError, + PermissionRequiredError, + projectAnalysisError, + type AnalysisError, +} from "./domain/errors.js"; +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import type { AppConfig } from "./config.js"; /** Register filesystem-gated Evidence v2 commands. */ export const registerEvidenceCommands = ( @@ -25,6 +32,10 @@ export const registerEvidenceCommands = ( logCliCommand(logger, "evidence-import", async () => { const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); + const denied = await authorizeEvidence(config.value, [ + { capability: "evidence_read", path: args.path, access: "read" }, + ]); + if (denied !== undefined) return cliError(denied); const imported = await importEvidenceBundleCommand( args.path, config.value.evidenceFilePolicy, @@ -45,6 +56,11 @@ export const registerEvidenceCommands = ( logCliCommand(logger, "evidence-export", async () => { const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); + const denied = await authorizeEvidence(config.value, [ + { capability: "evidence_read", path: args.source, access: "read" }, + { capability: "evidence_write", path: args.output, access: "write" }, + ]); + if (denied !== undefined) return cliError(denied); const exported = await exportEvidenceBundleCommand( args.source, args.output, @@ -69,6 +85,11 @@ export const registerEvidenceCommands = ( logCliCommand(logger, "compare", async () => { const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); + const denied = await authorizeEvidence(config.value, [ + { capability: "evidence_read", path: args.left, access: "read" }, + { capability: "evidence_read", path: args.right, access: "read" }, + ]); + if (denied !== undefined) return cliError(denied); const compared = await compareEvidenceBundlesCommand({ leftPath: args.left, rightPath: args.right, @@ -81,6 +102,39 @@ export const registerEvidenceCommands = ( }); }; +const authorizeEvidence = async ( + config: AppConfig, + requests: readonly { + readonly capability: "evidence_read" | "evidence_write"; + readonly path: string; + readonly access: "read" | "write"; + }[], +): Promise => { + const authority = await loadConfiguredPermissionAuthority(config); + if (!authority.ok) return authority.error; + for (const request of requests) { + const result = await authority.value.authorize( + { + capability: request.capability, + roots: [request.path], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `cli:${request.capability}:${request.path}`, + }, + request.access, + ); + if (!result.ok) + return result.error instanceof PermissionRequiredError + ? result.error + : new AnalysisProtocolError(result.error.message, { + cause: result.error, + }); + } + return undefined; +}; + const cliError = (error: AnalysisError): JsonValue => ({ error: "Analysis failed", ...projectAnalysisError(error), diff --git a/src/cliInvestigationCommands.ts b/src/cliInvestigationCommands.ts index 4c932cf1e..f0bb01508 100644 --- a/src/cliInvestigationCommands.ts +++ b/src/cliInvestigationCommands.ts @@ -3,8 +3,14 @@ import { Cli, z } from "incur"; import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; -import { projectAnalysisError, type AnalysisError } from "./domain/errors.js"; +import { + AnalysisProtocolError, + PermissionRequiredError, + projectAnalysisError, + type AnalysisError, +} from "./domain/errors.js"; import type { Logger } from "./logger.js"; +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; const investigationOptionsSchema = z.object({ yes: z @@ -28,6 +34,9 @@ const investigationOptionsSchema = z.object({ .default(268_435_456), pageSize: z.number().int().min(1).max(500).default(500), changeLimit: z.number().int().min(1).max(500).default(500), + integrityPolicy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrityContinueApproved: z.boolean().default(false), + maxIntegrityMismatches: z.number().int().min(1).max(100).default(10), }); /** Register the persistent cross-version CLI workflow. */ @@ -52,6 +61,9 @@ export const registerInvestigationCommands = ( maxEntryBytes: "max-entry-bytes", pageSize: "page-size", changeLimit: "change-limit", + integrityPolicy: "integrity-policy", + integrityContinueApproved: "integrity-continue-approved", + maxIntegrityMismatches: "max-integrity-mismatches", yes: "y", }, run: ({ args, options }) => @@ -76,6 +88,46 @@ const runInvestigation = async ( }; const config = parseConfig(process.env); if (!config.ok) return cliError(config.error); + const authority = await loadConfiguredPermissionAuthority(config.value); + if (!authority.ok) return cliError(authority.error); + for (const scope of [ + { + capability: "investigation_workspace_read" as const, + roots: [args.workspacePath], + access: "write" as const, + }, + { + capability: "investigation_workspace_write" as const, + roots: [args.workspacePath], + access: "write" as const, + }, + { + capability: "investigation_input" as const, + roots: [args.leftPath, args.rightPath], + access: "read" as const, + }, + ]) { + const authorized = await authority.value.authorize( + { + capability: scope.capability, + roots: scope.roots, + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: `investigate_versions:${scope.capability}:${scope.roots.join(":")}`, + }, + scope.access, + ); + if (!authorized.ok) + return cliError( + authorized.error instanceof PermissionRequiredError + ? authorized.error + : new AnalysisProtocolError(authorized.error.message, { + cause: authorized.error, + }), + ); + } const investigated = await runCrossVersionInvestigation( { approved: true, @@ -86,6 +138,9 @@ const runInvestigation = async ( : { expected_workspace_revision: options.expectedRevision }), left_path: args.leftPath, right_path: args.rightPath, + integrity_policy: options.integrityPolicy, + integrity_continue_approved: options.integrityContinueApproved, + max_integrity_mismatches: options.maxIntegrityMismatches, options: { max_entries: options.maxEntries, max_total_bytes: options.maxTotalBytes, @@ -98,7 +153,10 @@ const runInvestigation = async ( }, }, config.value.evidenceFilePolicy, - { inputRoots: config.value.investigationInputRoots }, + { + inputRoots: config.value.investigationInputRoots, + integrityContinueEnabled: config.value.artifactIntegrityContinueEnabled, + }, ); return investigated.ok ? investigated.value.evidence diff --git a/src/cliPolicyCommands.ts b/src/cliPolicyCommands.ts new file mode 100644 index 000000000..d0dc12818 --- /dev/null +++ b/src/cliPolicyCommands.ts @@ -0,0 +1,160 @@ +import { Cli, z } from "incur"; + +import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; +import { + readProjectPermissionStore, + writeProjectPermissionStore, +} from "./application/ProjectPermissionStore.js"; +import { logCliCommand } from "./cliLogging.js"; +import { parseConfig, type AppConfig } from "./config.js"; +import { + PermissionRequiredError, + projectAnalysisError, +} from "./domain/errors.js"; +import type { PermissionCapability } from "./domain/permissionPolicy.js"; +import type { Logger } from "./logger.js"; + +const capabilitySchema = z.enum([ + "process_capture", + "evidence_read", + "evidence_write", + "investigation_input", + "investigation_workspace_read", + "investigation_workspace_write", + "snapshot_read", + "snapshot_write", + "artifact_extract", + "native_mount", + "reference_read", +]); + +/** Register inspectable, dry-run, and revocation policy UX for the CLI. */ +export const registerPolicyCommands = ( + cli: ReturnType, + logger: Logger, +): void => { + cli.command("policy", { + description: "Inspect, explain, or revoke unified local permission grants", + args: z.object({ + action: z.enum(["status", "list", "explain", "revoke"]), + value: z.string().optional(), + }), + options: z.object({ + root: z.string().optional(), + executable: z.string().optional(), + environmentNames: z.array(z.string()).default([]), + network: z.enum(["none", "loopback", "external"]).default("none"), + mount: z.boolean().default(false), + write: z.boolean().default(false), + }), + alias: { environmentNames: "environment-names" }, + run: ({ args, options }) => + logCliCommand(logger, "policy", async () => { + const config = parseConfig(process.env); + if (!config.ok) return failure(config.error); + if (args.action === "status") + return { + reload: "SIGHUP", + restart_required: false, + administrator_ceilings: config.value.permissionCeilings, + project_store: config.value.permissionProjectStore ?? null, + }; + if (args.action === "list") + return readProjectGrants(config.value).then((result) => + result.ok ? { grants: result.grants } : result.error, + ); + if (args.action === "revoke") { + if (args.value === undefined) + return { error: "Grant ID is required for policy revoke" }; + const result = await readProjectGrants(config.value); + if (!result.ok) return result.error; + const retained = result.grants.filter( + ({ grant_id }) => grant_id !== args.value, + ); + if (retained.length === result.grants.length) + return { error: "Grant was not found", grant_id: args.value }; + const written = await writeProjectPermissionStore( + result.path, + result.root, + retained, + ); + return written.ok + ? { + revoked: args.value, + reload: "Send SIGHUP to the REA MCP process", + } + : { error: written.error.message }; + } + const capability = capabilitySchema.safeParse(args.value); + if (!capability.success) + return { + error: "A registered capability is required for policy explain", + }; + return explain(config.value, capability.data, options); + }), + }); +}; + +const explain = async ( + config: AppConfig, + capability: PermissionCapability, + options: { + readonly root?: string | undefined; + readonly executable?: string | undefined; + readonly environmentNames: readonly string[]; + readonly network: "none" | "loopback" | "external"; + readonly mount: boolean; + readonly write: boolean; + }, +) => { + const authority = await loadConfiguredPermissionAuthority(config); + if (!authority.ok) return failure(authority.error); + const evaluated = await authority.value.explain( + { + capability, + roots: options.root === undefined ? [] : [options.root], + executables: options.executable === undefined ? [] : [options.executable], + environment_names: options.environmentNames, + network: options.network, + mount: options.mount, + operation_identity: `policy_explain:${capability}`, + }, + options.write ? "write" : "read", + ); + return evaluated.ok + ? { allowed: true, grant_id: evaluated.value.grant_id } + : evaluated.error instanceof PermissionRequiredError + ? { allowed: false, error: projectAnalysisError(evaluated.error) } + : { allowed: false, error: evaluated.error.message }; +}; + +const readProjectGrants = async (config: { + readonly permissionProjectRoot: string | undefined; + readonly permissionProjectStore: string | undefined; +}) => { + if ( + config.permissionProjectRoot === undefined || + config.permissionProjectStore === undefined + ) + return { + ok: false as const, + error: { error: "Project policy is not configured" }, + }; + const store = await readProjectPermissionStore( + config.permissionProjectStore, + config.permissionProjectRoot, + ); + return store.ok + ? { + ok: true as const, + grants: store.value?.grants ?? [], + path: config.permissionProjectStore, + root: config.permissionProjectRoot, + } + : { ok: false as const, error: { error: store.error.message } }; +}; + +const failure = (error: Parameters[0]) => ({ + error: "Policy command failed", + ...projectAnalysisError(error), +}); diff --git a/src/config.ts b/src/config.ts index fbb45557b..d49ac02c8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,10 @@ import type { LogLevel } from "./logger.js"; import type { ProcessExecutionPolicy } from "./domain/processCapture.js"; import type { EvidenceFilePolicy } from "./domain/evidenceBundle.js"; import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js"; +import type { + PermissionCeiling, + PermissionGrant, +} from "./domain/permissionPolicy.js"; const DEFAULT_HOPPER_LAUNCHER_PATH = "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; @@ -22,34 +26,58 @@ export interface AppConfig { readonly logLevel: LogLevel; readonly processExecutionPolicy: ProcessExecutionPolicy; readonly artifactNativeMountEnabled: boolean; + readonly artifactIntegrityContinueEnabled: boolean; readonly evidenceFilePolicy: EvidenceFilePolicy; readonly investigationInputRoots: readonly string[]; readonly analysisSnapshotFilePolicy: EvidenceFilePolicy; readonly referenceSourcePolicy: ReferenceSourcePolicy; + readonly permissionCeilings: readonly PermissionCeiling[]; + readonly administratorPermissionGrants: readonly PermissionGrant[]; + readonly permissionProjectRoot: string | undefined; + readonly permissionProjectStore: string | undefined; } -const environmentSchema = z.object({ - HOPPER_LAUNCHER_PATH: z.string().min(1).optional(), - HOPPER_TARGET_PATH: z.string().min(1).optional(), - HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"), - HOPPER_LOADER_ARGS_JSON: z.string().optional(), - REA_LOG_LEVEL: z - .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) - .default("info"), - REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"), - REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z.enum(["true", "false"]).default("false"), - REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z - .enum(["true", "false"]) - .default("false"), - REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"), - REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"), - REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"), - REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"), - REA_INVESTIGATION_INPUT_ROOTS_JSON: z.string().default("[]"), - REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"), - REA_REFERENCE_ROOTS_JSON: z.string().default("[]"), - REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"), -}); +const environmentSchema = z + .object({ + HOPPER_LAUNCHER_PATH: z.string().min(1).optional(), + HOPPER_TARGET_PATH: z.string().min(1).optional(), + HOPPER_TARGET_KIND: z + .enum(["executable", "database"]) + .default("executable"), + HOPPER_LOADER_ARGS_JSON: z.string().optional(), + REA_LOG_LEVEL: z + .enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]) + .default("info"), + REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"), + REA_ARTIFACT_NATIVE_MOUNT_ENABLED: z + .enum(["true", "false"]) + .default("false"), + REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED: z + .enum(["true", "false"]) + .default("false"), + REA_PROCESS_ALLOW_EXTERNAL_NETWORK: z + .enum(["true", "false"]) + .default("false"), + REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"), + REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"), + REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"), + REA_EVIDENCE_ROOTS_JSON: z.string().default("[]"), + REA_INVESTIGATION_INPUT_ROOTS_JSON: z.string().default("[]"), + REA_ANALYSIS_SNAPSHOT_ROOTS_JSON: z.string().default("[]"), + REA_REFERENCE_ROOTS_JSON: z.string().default("[]"), + REA_REFERENCE_SECRET_PATTERNS_JSON: z.string().default("[]"), + REA_PERMISSION_PROJECT_ROOT: z.string().min(1).optional(), + REA_PERMISSION_PROJECT_STORE: z.string().min(1).optional(), + }) + .refine( + (value) => + (value.REA_PERMISSION_PROJECT_ROOT === undefined) === + (value.REA_PERMISSION_PROJECT_STORE === undefined), + { + message: + "REA_PERMISSION_PROJECT_ROOT and REA_PERMISSION_PROJECT_STORE must be configured together", + }, + ); const parseStringArray = ( encoded: string, @@ -80,6 +108,30 @@ const filePolicy = (roots: readonly string[]): EvidenceFilePolicy => ({ maxNodes: 1_000_000, }); +const permissionScope = ( + capability: PermissionCeiling["capability"], + roots: readonly string[], + options: Partial> = {}, +): PermissionCeiling => ({ + capability, + roots, + executables: options.executables ?? [], + environment_names: options.environment_names ?? [], + network: options.network ?? "none", + mount: options.mount ?? false, +}); + +const administratorGrants = ( + ceilings: readonly PermissionCeiling[], +): readonly PermissionGrant[] => + ceilings.map((ceiling) => ({ + ...ceiling, + grant_id: `administrator:${ceiling.capability}`, + lifetime: "administrator", + operation_identity: null, + expires_at: null, + })); + const parseLoaderArgs = ( encoded: string | undefined, ): Result => { @@ -166,6 +218,33 @@ export const parseConfig = ( "REA_REFERENCE_SECRET_PATTERNS_JSON", ); if (!secretPatterns.ok) return secretPatterns; + const permissionCeilings = [ + ...(parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true" + ? [ + permissionScope("process_capture", workingRoots.value, { + executables: executableRoots.value, + environment_names: allowedEnvironment.value, + network: + parsedEnvironment.data.REA_PROCESS_ALLOW_EXTERNAL_NETWORK === + "true" + ? "external" + : "none", + }), + ] + : []), + permissionScope("evidence_read", evidenceRoots.value), + permissionScope("evidence_write", evidenceRoots.value), + permissionScope("investigation_input", investigationInputRoots.value), + permissionScope("investigation_workspace_read", evidenceRoots.value), + permissionScope("investigation_workspace_write", evidenceRoots.value), + permissionScope("snapshot_read", analysisSnapshotRoots.value), + permissionScope("snapshot_write", analysisSnapshotRoots.value), + permissionScope("artifact_extract", ["/"]), + permissionScope("reference_read", referenceRoots.value), + ...(parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true" + ? [permissionScope("native_mount", [], { mount: true })] + : []), + ] satisfies readonly PermissionCeiling[]; return ok({ hopperLauncherPath: parsedEnvironment.data.HOPPER_LAUNCHER_PATH ?? @@ -184,6 +263,8 @@ export const parseConfig = ( }, artifactNativeMountEnabled: parsedEnvironment.data.REA_ARTIFACT_NATIVE_MOUNT_ENABLED === "true", + artifactIntegrityContinueEnabled: + parsedEnvironment.data.REA_ARTIFACT_INTEGRITY_CONTINUE_ENABLED === "true", evidenceFilePolicy: filePolicy(evidenceRoots.value), investigationInputRoots: investigationInputRoots.value, analysisSnapshotFilePolicy: filePolicy(analysisSnapshotRoots.value), @@ -195,5 +276,9 @@ export const parseConfig = ( maxDepth: 32, maxPathBytes: 4_096, }, + permissionCeilings, + administratorPermissionGrants: administratorGrants(permissionCeilings), + permissionProjectRoot: parsedEnvironment.data.REA_PERMISSION_PROJECT_ROOT, + permissionProjectStore: parsedEnvironment.data.REA_PERMISSION_PROJECT_STORE, }); }; diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts index bfe9694df..ecaf77771 100644 --- a/src/contracts/artifactToolContracts.ts +++ b/src/contracts/artifactToolContracts.ts @@ -33,11 +33,26 @@ const traversalLimitsInput = { }; /** Exact caller boundary for deterministic artifact inventory. */ -export const artifactInventoryInputSchema = z.object({ - native_mount_approved: z.boolean().default(false), - ...pageInput, - ...traversalLimitsInput, -}); +export const artifactInventoryInputSchema = z + .object({ + native_mount_approved: z.boolean().default(false), + integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_continue_approved: z.boolean().default(false), + max_integrity_mismatches: z.number().int().min(1).max(100).default(10), + ...pageInput, + ...traversalLimitsInput, + }) + .superRefine((input, context) => { + if ( + input.integrity_policy === "record-and-continue" && + input.integrity_continue_approved !== true + ) + context.addIssue({ + code: "custom", + path: ["integrity_continue_approved"], + message: "record-and-continue requires explicit approval", + }); + }); /** Exact caller boundary for approved artifact extraction. */ export const artifactExtractionInputSchema = z.object({ diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts new file mode 100644 index 000000000..cc7c68163 --- /dev/null +++ b/src/contracts/errorSchemas.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; + +const categorySchema = z.enum([ + "invalid_input", + "permission_required", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "unavailable", + "execution_failure", +]); +const remediationSchema = z + .object({ + action: z.string().min(1), + restart_required: z.boolean(), + elicitation_supported: z.boolean().optional(), + }) + .strict(); +const common = { + category: categorySchema, + message: z.string().min(1), + retryable: z.boolean(), + remediation: remediationSchema, +}; +const genericDetails = z.record(z.string(), z.json()).optional(); +const generic = (code: Code) => + z + .object({ code: z.literal(code), ...common, details: genericDetails }) + .strict(); + +const scopeSchema = z + .object({ + capability: z.string(), + roots: z.array(z.string()), + executables: z.array(z.string()), + environment_names: z.array(z.string()), + network: z.enum(["none", "loopback", "external"]), + mount: z.boolean(), + }) + .strict(); +const missingScopeSchema = z + .object({ + roots: z.array(z.string()).optional(), + executables: z.array(z.string()).optional(), + environment_names: z.array(z.string()).optional(), + network: z.enum(["none", "loopback", "external"]).optional(), + mount: z.literal(true).optional(), + }) + .strict(); + +/** Stable discriminated schema shared by every CLI and MCP error surface. */ +export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ + generic("invalid_request"), + generic("unreadable_output"), + generic("capability_unavailable"), + generic("provider_unavailable"), + generic("provider_timeout"), + generic("cancelled"), + z + .object({ + code: z.literal("artifact_integrity_mismatch"), + ...common, + details: z + .object({ + logical_path: z.string(), + declared_sha256: z.string().nullable(), + calculated_sha256: z.string().nullable(), + unpacked: z.boolean(), + }) + .strict(), + }) + .strict(), + generic("artifact_operation_failed"), + generic("evidence_integrity_mismatch"), + generic("truncated"), + z + .object({ + code: z.literal("permission_required"), + ...common, + details: z + .object({ + capability: z.string(), + requested: scopeSchema, + missing: missingScopeSchema, + ceiling: scopeSchema.nullable(), + }) + .strict() + .optional(), + }) + .strict(), + generic("process_capture_failed"), + generic("cleanup_incomplete"), + generic("revision_conflict"), + generic("outside_approved_root"), + generic("configuration_invalid"), + generic("target_unavailable"), + generic("execution_failure"), +]); + +/** JSON Schema document used by generated API documentation and clients. */ +export const analysisErrorJsonSchema = z.toJSONSchema( + analysisErrorProjectionSchema, +); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index a7c7557e3..63fa3ec77 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -389,6 +389,16 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), ] as const satisfies readonly ToolContract[]; +/** Caller observations used to compare a live server with expected identity. */ +export const binarySessionInputSchema = z.object({ + expected_package_version: z.string().min(1).optional(), + expected_catalog_digest: z + .string() + .regex(/^[a-f0-9]{64}$/u) + .optional(), + expected_server_path: z.string().min(1).optional(), +}); + /** Target lifecycle tools available only on the long-lived MCP adapter. */ export const SESSION_TOOL_CONTRACTS = [ session( @@ -404,7 +414,7 @@ export const SESSION_TOOL_CONTRACTS = [ session( "binary_session", "Report provider identity, deterministic capability descriptors, and whether a target is open; open targets include canonical path, format, and kind. Use availability, effects, limits, and limitations before selecting analysis operations.", - z.object({}), + binarySessionInputSchema, ), session( "export_evidence_bundle", diff --git a/src/contracts/toolOutputSchemas.ts b/src/contracts/toolOutputSchemas.ts index 6772a9925..3ee878c64 100644 --- a/src/contracts/toolOutputSchemas.ts +++ b/src/contracts/toolOutputSchemas.ts @@ -25,6 +25,7 @@ import { changedBehaviorResultSchema } from "../domain/changedBehavior.js"; import { callPathResultSchema } from "../domain/callPath.js"; import { staticRuntimeCorrelationResultSchema } from "../domain/staticRuntimeCorrelation.js"; import { reconstructionVerificationResultSchema } from "../domain/reconstructionVerification.js"; +import { analysisErrorProjectionSchema } from "./errorSchemas.js"; const resultOf = (schema: z.ZodType) => evidenceSchema @@ -96,6 +97,69 @@ const sessionProvider = z.object({ provider: providerIdentity, providers: z.array(providerIdentity), capabilities: z.array(providerCapability), + tool_availability: z.array( + z.object({ + name: z.string(), + surface: z.string(), + available: z.boolean(), + reason: z.enum([ + "available", + "target_required", + "provider_missing", + "provider_unavailable", + "target_unsupported", + "unsupported_host", + "policy_disabled", + ]), + remediation: z.string().nullable(), + annotations: z.object({ + read_only: z.boolean(), + destructive: z.boolean(), + idempotent: z.boolean(), + open_world: z.boolean(), + }), + }), + ), + client_features: z.object({ + elicitation_form: z.boolean(), + elicitation_url: z.boolean(), + roots: z.boolean(), + sampling: z.boolean(), + }), + server_identity: z.object({ + package: z.object({ + name: z.string(), + version: z.string(), + root_path: z.string(), + build_commit: z.string().nullable(), + }), + server: z.object({ + name: z.string(), + version: z.string(), + started_at: z.string(), + command_path: z.string(), + }), + sdk: z.object({ + server: z.string(), + client_test: z.string(), + core: z.string(), + }), + negotiated_protocol_version: z.string().nullable(), + client: z.object({ name: z.string(), version: z.string() }).nullable(), + skill: z.object({ name: z.string(), expected_version: z.string() }), + catalog: z.record(z.string(), z.json()), + protocol_features: z.object({ + progress: z.boolean(), + cancellation: z.boolean(), + evidence_resources: z.boolean(), + elicitation: z.boolean(), + }), + alignment: z.object({ + state: z.enum(["aligned", "mcp_server_restart_required", "unknown"]), + reasons: z.array(z.string()), + remediation: z.string().nullable(), + }), + }), }); const nullableText = z.string().nullable(); const addressList = z.array(z.string()); @@ -177,45 +241,6 @@ const symbolDiscoveryOutput = (property: "classes" | "protocols") => [property]: z.array(addressedEntry), }), ); -const analysisErrorProjectionSchema = z - .object({ - category: z.enum([ - "invalid_input", - "permission_required", - "unsupported_provider", - "integrity_mismatch", - "truncated", - "cancelled", - "timeout", - "unavailable", - "execution_failure", - ]), - message: z.string(), - details: z - .object({ - logical_path: z.string(), - declared_sha256: z.string().nullable(), - calculated_sha256: z.string().nullable(), - unpacked: z.boolean(), - }) - .strict() - .optional(), - code: z - .enum([ - "private_display_unavailable", - "x11_authorization_failed", - "unsupported_hopper_build", - "invalid_launch_command", - "process_ownership_mismatch", - "hopper_exited_during_startup", - "unsupported_demo_dialog", - "unexpected_display_geometry", - "x11_input_failed", - "runtime_dependency_unavailable", - ]) - .optional(), - }) - .strict(); const graphNode = z.discriminatedUnion("status", [ z.object({ address: z.string(), diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 0df4803d5..9cfb2ed8e 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -20,8 +20,15 @@ const comparisonStatusSchema = z.enum([ "changed", "truncated", "unknown", + "contradiction", +]); +const changeKindSchema = z.enum([ + "added", + "removed", + "changed", + "unknown", + "contradiction", ]); -const changeKindSchema = z.enum(["added", "removed", "changed", "unknown"]); const comparisonDimensionSchema = z.enum([ "content", "kind", @@ -31,6 +38,7 @@ const comparisonDimensionSchema = z.enum([ "relations", "metadata", "availability", + "integrity", ]); /** Strict Evidence-backed input for deterministic artifact comparison. */ @@ -80,6 +88,7 @@ export const artifactComparisonResultSchema = z.object({ removed: z.number().int().min(0), changed: z.number().int().min(0), unknown: z.number().int().min(0), + contradiction: z.number().int().min(0).default(0), }), changes: z.object({ items: z.array(artifactChangeSchema).max(500), @@ -107,9 +116,10 @@ export const compareArtifacts = ( const right = parseArtifactInventoryEvidence(rightEvidence); const leftComplete = left.inventory.complete; const rightComplete = right.inventory.complete; - const leftCovered = leftComplete && left.inventory.limitations.length === 0; + const leftCovered = + leftComplete && !hasCoverageLimitation(left.inventory.limitations); const rightCovered = - rightComplete && right.inventory.limitations.length === 0; + rightComplete && !hasCoverageLimitation(right.inventory.limitations); const limitations = [ ...left.inventory.limitations.map((item) => `Left: ${item}`), ...right.inventory.limitations.map((item) => `Right: ${item}`), @@ -136,13 +146,17 @@ export const compareArtifacts = ( status: !leftComplete || !rightComplete ? "truncated" - : !leftCovered || !rightCovered - ? "unknown" - : changes.some(({ classification }) => classification === "unknown") + : changes.some( + ({ classification }) => classification === "contradiction", + ) + ? "contradiction" + : !leftCovered || !rightCovered ? "unknown" - : changes.length === 0 - ? "unchanged" - : "changed", + : changes.some(({ classification }) => classification === "unknown") + ? "unknown" + : changes.length === 0 + ? "unchanged" + : "changed", left_manifest_id: left.inventory.manifest.manifest_id, right_manifest_id: right.inventory.manifest.manifest_id, summary, @@ -230,6 +244,15 @@ const classifyPath = (input: { classification: input.complete ? "removed" : "unknown", dimensions: [input.complete ? "content" : "availability"], }; + if ( + input.leftOccurrence.hash_status === "mismatched" || + input.rightOccurrence.hash_status === "mismatched" + ) + return { + ...base, + classification: "contradiction", + dimensions: ["integrity"], + }; if ( input.leftOccurrence.hash_status !== "verified" || input.rightOccurrence.hash_status !== "verified" || @@ -362,7 +385,13 @@ const summarize = ( changes: readonly ArtifactChange[], covered: boolean, ) => { - const counts = { added: 0, removed: 0, changed: 0, unknown: 0 }; + const counts = { + added: 0, + removed: 0, + changed: 0, + unknown: 0, + contradiction: 0, + }; for (const change of changes) counts[change.classification] += 1; return { unchanged: covered @@ -370,6 +399,7 @@ const summarize = ( 0, Math.min(left.occurrences.length, right.occurrences.length) - counts.changed - + counts.contradiction - counts.unknown, ) : 0, @@ -384,7 +414,14 @@ const summarize = ( ) - counts.added - counts.removed - - counts.changed, + counts.changed - + counts.contradiction, ), }; }; + +const hasCoverageLimitation = (limitations: readonly string[]): boolean => + limitations.some( + (limitation) => + !/integrity contradiction\(s\) were recorded/u.test(limitation), + ); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index a83d733f6..a57b69dd1 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -6,6 +6,7 @@ const occurrenceIdSchema = z.string().regex(/^occ_[a-f0-9]{64}$/u); const edgeIdSchema = z.string().regex(/^edge_[a-f0-9]{64}$/u); const manifestIdSchema = z.string().regex(/^agm_[a-f0-9]{64}$/u); const extractionIdSchema = z.string().regex(/^aex_[a-f0-9]{64}$/u); +const contradictionIdSchema = z.string().regex(/^ic_[a-f0-9]{64}$/u); const boundedRelativePathSchema = z .string() .min(1) @@ -121,11 +122,31 @@ const artifactOccurrenceSchema = z.object({ .nullable(), executable: z.boolean(), encrypted: z.boolean(), - hash_status: z.enum(["verified", "not-hashed-limit", "unavailable"]), + hash_status: z.enum([ + "verified", + "mismatched", + "not-hashed-limit", + "unavailable", + ]), source_location: artifactByteRangeSchema.nullable(), limitations: z.array(z.string()), }); +/** Observed bytes that contradict a container's declared integrity identity. */ +const integrityContradictionSchema = z.object({ + contradiction_id: contradictionIdSchema, + occurrence_id: occurrenceIdSchema, + parent_artifact_id: artifactIdSchema, + logical_path: boundedRelativePathSchema, + declared_sha256: sha256Schema, + observed_sha256: sha256Schema, + entry_kind: z.enum(["file", "slice"]), + unpacked: z.boolean(), + trust: z.literal("observed-untrusted"), + provenance: z.literal("container-integrity-metadata-versus-observed-bytes"), + limitations: z.array(z.string()).min(1), +}); + /** Parent-child derivation relation between two content-addressed artifacts. */ const artifactEdgeSchema = z.object({ edge_id: edgeIdSchema, @@ -177,6 +198,10 @@ export const artifactInventoryResultSchema = z.object({ edges: artifactPageSchema(artifactEdgeSchema), limits: artifactTraversalLimitsSchema, provenance: z.array(artifactCommandSchema).max(256), + integrity_contradictions: z + .array(integrityContradictionSchema) + .max(100) + .default([]), limitations: z.array(z.string()), }); @@ -224,6 +249,9 @@ export type ArtifactEdge = z.infer; /** Parsed artifact occurrence. */ export type ArtifactOccurrence = z.infer; +export type IntegrityContradiction = z.infer< + typeof integrityContradictionSchema +>; /** Parsed deterministic artifact graph manifest. */ export type ArtifactGraphManifest = z.infer; diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 652efff92..76e9393cf 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -7,6 +7,7 @@ import { type ArtifactInventoryResult, type ArtifactNode, type ArtifactOccurrence, + type IntegrityContradiction, } from "./artifactGraph.js"; import { parseEvidence, type Evidence } from "./evidence.js"; @@ -16,6 +17,7 @@ export interface InventorySet { readonly nodes: readonly ArtifactNode[]; readonly occurrences: readonly ArtifactOccurrence[]; readonly edges: readonly ArtifactInventoryResult["edges"]["items"][number][]; + readonly integrityContradictions: readonly IntegrityContradiction[]; readonly limitations: readonly string[]; readonly complete: boolean; } @@ -99,6 +101,13 @@ const assembleInventorySet = ( for (const page of pages) { if (canonicalJson(page.inventory.manifest) !== manifestJson) throw new TypeError("Artifact inventory pages do not share one manifest"); + if ( + canonicalJson(page.inventory.integrity_contradictions) !== + canonicalJson(first.inventory.integrity_contradictions) + ) + throw new TypeError( + "Artifact inventory pages do not share integrity contradictions", + ); for (const node of page.inventory.nodes.items) mergeExact(nodes, node.artifact_id, node, "node"); for (const occurrence of page.inventory.occurrences.items) { @@ -148,6 +157,7 @@ const orderedInventory = ( nodes, occurrences, edges, + integrityContradictions: first.integrity_contradictions, limitations: [ ...new Set(pages.flatMap(({ inventory }) => inventory.limitations)), ].sort((left, right) => left.localeCompare(right)), @@ -174,10 +184,30 @@ const validateCompleteInventory = (inventory: InventorySet): void => { validateOccurrence(occurrence, inventory, nodeIds, occurrenceIds); for (const edge of inventory.edges) validateEdge(edge, nodeIds, occurrenceIds); + for (const contradiction of inventory.integrityContradictions) { + if ( + !nodeIds.has(contradiction.parent_artifact_id) || + !occurrenceIds.has(contradiction.occurrence_id) + ) + throw new TypeError( + "Integrity contradiction references a missing graph member", + ); + const expectedId = `ic_${digestCanonical({ + root_artifact_id: inventory.manifest.root_artifact_id, + logical_path: contradiction.logical_path, + declared_sha256: contradiction.declared_sha256, + observed_sha256: contradiction.observed_sha256, + })}`; + if (contradiction.contradiction_id !== expectedId) + throw new TypeError( + "Integrity contradiction ID does not match its identity", + ); + } const graphSha256 = digestCanonical({ nodes: inventory.nodes, occurrences: inventory.occurrences, edges: inventory.edges, + integrity_contradictions: inventory.integrityContradictions, }); if (graphSha256 !== inventory.manifest.graph_sha256) throw new TypeError("Artifact graph commitment does not match its members"); diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index 4891bb6b3..4c4e087fc 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -331,9 +331,11 @@ const artifactFindings = (evidence: Evidence): Finding[] => { scope: "static_candidate", status: change.classification, classification: - change.classification === "unknown" - ? "unresolved_branch" - : "derived_relationship", + change.classification === "contradiction" + ? "contradiction" + : change.classification === "unknown" + ? "unresolved_branch" + : "derived_relationship", limitations: result.limitations, links: change.evidence_links, }), diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 3afb2a9fd..227ff5921 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,4 +1,9 @@ import type { JsonValue } from "./jsonValue.js"; +import type { + MissingPermissionScope, + PermissionRequest, + PermissionScope, +} from "./permissionPolicy.js"; import { hopperStartupFailure, type HopperStartupFailureCode, @@ -31,6 +36,7 @@ const ANALYSIS_ERROR_TAGS = [ "ConfigurationError", "NoBinaryOpenError", "BinaryTargetError", + "PermissionRequiredError", ] as const; /** Stable tag for an expected analysis failure. */ @@ -42,6 +48,8 @@ export abstract class AnalysisError extends Error { readonly userMessage: string | undefined = undefined; readonly userCategory: "permission_required" | "cancelled" | undefined = undefined; + readonly cleanupIncomplete: boolean = false; + readonly cleanupResources: readonly string[] = []; } /** Base class for failures produced specifically by the Hopper provider. */ @@ -336,6 +344,25 @@ export class BinaryTargetError extends AnalysisError { export interface AnalysisErrorProjection extends Readonly> { + readonly code: + | "invalid_request" + | "unreadable_output" + | "capability_unavailable" + | "provider_unavailable" + | "provider_timeout" + | "cancelled" + | "artifact_integrity_mismatch" + | "artifact_operation_failed" + | "evidence_integrity_mismatch" + | "truncated" + | "permission_required" + | "process_capture_failed" + | "cleanup_incomplete" + | "revision_conflict" + | "outside_approved_root" + | "configuration_invalid" + | "target_unavailable" + | "execution_failure"; readonly category: | "invalid_input" | "permission_required" @@ -347,38 +374,246 @@ export interface AnalysisErrorProjection | "unavailable" | "execution_failure"; readonly message: string; - readonly code?: HopperStartupFailureCode; + readonly retryable: boolean; + readonly remediation: Readonly<{ + action: string; + restart_required: boolean; + elicitation_supported?: boolean; + }>; readonly details?: Readonly>; } +/** Exact denied authority used by both CLI and MCP remediation. */ +export class PermissionRequiredError extends AnalysisError { + readonly _tag = "PermissionRequiredError" as const; + + constructor( + readonly requested: PermissionRequest, + readonly missing: MissingPermissionScope, + readonly ceiling: PermissionScope | null, + readonly elicitationSupported: boolean, + readonly restartRequired: boolean, + ) { + super(`Permission required for ${requested.capability}`); + } +} + /** Project expected failures into exhaustive, secret-safe caller fields. */ export const projectAnalysisError = ( error: AnalysisError, ): AnalysisErrorProjection => { assertKnownTag(error._tag); - const code = - error instanceof HopperProcessError ? error.failureCode : undefined; - const artifact = - error instanceof ArtifactOperationError && error.artifactDetails; - const details = artifact - ? { - logical_path: artifact.logicalPath, - declared_sha256: artifact.declaredSha256, - calculated_sha256: artifact.calculatedSha256, - unpacked: artifact.unpacked, - } - : undefined; + const code = errorCode(error); + const details = errorDetails(error); return { + code, category: errorCategory(error), message: userMessage(error), - ...(code === undefined ? {} : { code }), + retryable: RETRYABLE_CODES.has(code), + remediation: { + action: remediationAction(error), + restart_required: + error instanceof PermissionRequiredError && error.restartRequired, + ...(error instanceof PermissionRequiredError + ? { elicitation_supported: error.elicitationSupported } + : {}), + }, ...(details === undefined ? {} : { details }), }; }; +const errorCode = (error: AnalysisError): AnalysisErrorProjection["code"] => { + if (error instanceof PermissionRequiredError) return "permission_required"; + if (error instanceof ArtifactOperationError) + return error.reason === "integrity" && error.artifactDetails !== undefined + ? "artifact_integrity_mismatch" + : error.reason === "limit" + ? "truncated" + : error.reason === "cancelled" + ? "cancelled" + : "artifact_operation_failed"; + if (error instanceof EvidenceFileError) + return error.reason === "outside-root" + ? "outside_approved_root" + : error.reason === "too-large" + ? "truncated" + : error.reason === "disabled" + ? "capability_unavailable" + : "execution_failure"; + if (error instanceof InvestigationWorkspaceError) { + if ( + error.reason === "revision-conflict" || + error.reason === "name-conflict" + ) + return "revision_conflict"; + if (error.reason === "too-large") return "truncated"; + if (error.reason === "disabled") return "capability_unavailable"; + if (error.reason === "outside-root") return "outside_approved_root"; + if (error.reason === "integrity" || error.reason === "invalid-json") + return "evidence_integrity_mismatch"; + return "execution_failure"; + } + if (error instanceof UnknownRegistryError) + return error.reason === "revision-conflict" || + error.reason === "already-exists" + ? "revision_conflict" + : error.reason === "limit" + ? "truncated" + : error.reason === "integrity" + ? "evidence_integrity_mismatch" + : "execution_failure"; + if (error._tag === "ProcessCaptureError") + return error.cleanupIncomplete + ? "cleanup_incomplete" + : error.userCategory === "cancelled" + ? "cancelled" + : error.userCategory === "permission_required" + ? "permission_required" + : "process_capture_failed"; + switch (error._tag) { + case "AnalysisProtocolError": + case "AnalysisOutputError": + case "HopperProtocolError": + return "unreadable_output"; + case "AnalysisInputError": + return "invalid_request"; + case "AnalysisCapabilityUnavailableError": + case "ProviderSelectionError": + return "capability_unavailable"; + case "AnalysisCancelledError": + case "HopperCancelledError": + return "cancelled"; + case "AnalysisTimeoutError": + case "HopperTimeoutError": + return "provider_timeout"; + case "HopperProcessError": + case "HopperStartError": + return "provider_unavailable"; + case "ConfigurationError": + return "configuration_invalid"; + case "NoBinaryOpenError": + case "BinaryTargetError": + return "target_unavailable"; + case "EvidenceIntegrityError": + return "evidence_integrity_mismatch"; + case "EvidenceLimitError": + return "truncated"; + case "ProviderAdapterError": + case "HopperRemoteError": + return "execution_failure"; + case "ArtifactOperationError": + case "EvidenceFileError": + case "InvestigationWorkspaceError": + case "UnknownRegistryError": + case "PermissionRequiredError": + throw new TypeError("Unhandled specialized analysis error"); + } +}; + +const errorDetails = ( + error: AnalysisError, +): Readonly> | undefined => { + if (error instanceof PermissionRequiredError) + return { + capability: error.requested.capability, + requested: scopeDetails(error.requested), + missing: missingScopeDetails(error.missing), + ceiling: error.ceiling === null ? null : scopeDetails(error.ceiling), + }; + if (error instanceof ArtifactOperationError && error.artifactDetails) + return { + logical_path: error.artifactDetails.logicalPath, + declared_sha256: error.artifactDetails.declaredSha256, + calculated_sha256: error.artifactDetails.calculatedSha256, + unpacked: error.artifactDetails.unpacked, + }; + if (error instanceof ArtifactOperationError) + return { + operation: error.operation, + reason: error.reason, + ...(error.reason === "limit" ? { truncated: true } : {}), + }; + if (error instanceof AnalysisCancelledError) + return { operation: error.operation, cleanup: "complete" }; + if (error instanceof HopperCancelledError) + return { operation: "hopper", cleanup: "complete" }; + if (error instanceof AnalysisTimeoutError) + return { operation: error.operation, timeout_ms: error.timeoutMs }; + if (error instanceof HopperTimeoutError) + return { operation: "hopper", timeout_ms: error.timeoutMs }; + if (error instanceof EvidenceLimitError) + return { limit: error.limit, maximum: error.maximum, truncated: true }; + if (error instanceof InvestigationWorkspaceError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof UnknownRegistryError) return { reason: error.reason }; + if (error instanceof EvidenceFileError) + return { operation: error.operation, reason: error.reason }; + if (error instanceof AnalysisCapabilityUnavailableError) + return { provider_id: error.providerId, operation: error.operation }; + if (error instanceof ProviderAdapterError) + return { provider_id: error.providerId, operation: error.operation }; + if (error instanceof HopperRemoteError) + return { provider_code: error.code, diagnostic_type: error.diagnosticType }; + if (error instanceof HopperProcessError && error.failureCode !== undefined) + return { failure_code: error.failureCode, exit_code: error.exitCode }; + if (error._tag === "ProcessCaptureError" && error.cleanupIncomplete) + return { + cleanup: "incomplete", + resources: [...error.cleanupResources], + }; + if ( + error._tag === "ProcessCaptureError" && + error.userCategory === "cancelled" + ) + return { operation: "process_capture", cleanup: "complete" }; + if (error instanceof BinaryTargetError) return { path: error.path }; + return undefined; +}; + +const scopeDetails = ( + scope: PermissionScope, +): Readonly> => ({ + capability: scope.capability, + roots: [...scope.roots], + executables: [...scope.executables], + environment_names: [...scope.environment_names], + network: scope.network, + mount: scope.mount, +}); + +const missingScopeDetails = ( + scope: MissingPermissionScope, +): Readonly> => ({ + ...(scope.roots === undefined ? {} : { roots: [...scope.roots] }), + ...(scope.executables === undefined + ? {} + : { executables: [...scope.executables] }), + ...(scope.environment_names === undefined + ? {} + : { environment_names: [...scope.environment_names] }), + ...(scope.network === undefined ? {} : { network: scope.network }), + ...(scope.mount === undefined ? {} : { mount: scope.mount }), +}); + +const RETRYABLE_CODES: ReadonlySet = new Set([ + "provider_timeout", + "cancelled", + "revision_conflict", + "provider_unavailable", +]); + +const remediationAction = (error: AnalysisError): string => { + if (error instanceof PermissionRequiredError) + return error.elicitationSupported + ? "Approve the exact missing scope, then retry the operation." + : "Add the exact missing scope beneath the administrator ceiling, then retry."; + return userMessage(error); +}; + const errorCategory = ( error: AnalysisError, ): AnalysisErrorProjection["category"] => { + if (error instanceof PermissionRequiredError) return "permission_required"; if (error._tag === "ProcessCaptureError") return error.userCategory ?? "execution_failure"; if ( @@ -437,6 +672,8 @@ const STATIC_ERROR_CATEGORIES: Readonly< }; const userMessage = (error: AnalysisError): string => { + if (error instanceof PermissionRequiredError) + return "This operation needs additional local permission. Review the requested scope and remediation."; if (error instanceof AnalysisInputError) return "Analysis input is invalid. Check the arguments and try again."; if (error.userMessage !== undefined) return error.userMessage; @@ -578,6 +815,7 @@ const KNOWN_ERROR_TAGS = { ConfigurationError: true, NoBinaryOpenError: true, BinaryTargetError: true, + PermissionRequiredError: true, } as const satisfies Readonly>; const assertKnownTag = (tag: AnalysisErrorTag): void => { diff --git a/src/domain/investigationWorkspace.ts b/src/domain/investigationWorkspace.ts index 509881516..b41f48ddf 100644 --- a/src/domain/investigationWorkspace.ts +++ b/src/domain/investigationWorkspace.ts @@ -53,17 +53,32 @@ export type InvestigationRunOptions = z.infer< >; /** Explicit write approval and two local versions for an automatic run. */ -export const crossVersionInvestigationInputSchema = z.object({ - approved: z.literal(true), - workspace_path: z.string().min(1).max(4_096), - workspace_name: z.string().trim().min(1).max(200).default("default"), - expected_workspace_revision: z.number().int().min(1).optional(), - left_path: z.string().min(1).max(4_096), - right_path: z.string().min(1).max(4_096), - options: investigationRunOptionsSchema.default( - DEFAULT_INVESTIGATION_RUN_OPTIONS, - ), -}); +export const crossVersionInvestigationInputSchema = z + .object({ + approved: z.literal(true), + workspace_path: z.string().min(1).max(4_096), + workspace_name: z.string().trim().min(1).max(200).default("default"), + expected_workspace_revision: z.number().int().min(1).optional(), + left_path: z.string().min(1).max(4_096), + right_path: z.string().min(1).max(4_096), + integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"), + integrity_continue_approved: z.boolean().default(false), + max_integrity_mismatches: z.number().int().min(1).max(100).default(10), + options: investigationRunOptionsSchema.default( + DEFAULT_INVESTIGATION_RUN_OPTIONS, + ), + }) + .superRefine((input, context) => { + if ( + input.integrity_policy === "record-and-continue" && + input.integrity_continue_approved !== true + ) + context.addIssue({ + code: "custom", + path: ["integrity_continue_approved"], + message: "record-and-continue requires explicit approval", + }); + }); export type CrossVersionInvestigationInput = z.infer< typeof crossVersionInvestigationInputSchema @@ -92,6 +107,9 @@ export const investigationRunSchema = z.object({ left: targetSchema, right: targetSchema, options: investigationRunOptionsSchema, + integrity_policy: z.enum(["fail", "record-and-continue"]), + integrity_continue_approved: z.boolean(), + max_integrity_mismatches: z.number().int().min(1).max(100), status: z.enum(["running", "complete"]), completed_stages: z.array(stageSchema).max(4), left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), @@ -137,12 +155,20 @@ export const createInvestigationRunIdentity = (input: { readonly left: InvestigationRunTarget; readonly right: InvestigationRunTarget; readonly options: InvestigationRunOptions; + readonly integrity_policy: CrossVersionInvestigationInput["integrity_policy"]; + readonly integrity_continue_approved: boolean; + readonly max_integrity_mismatches: number; }): { readonly runId: string; readonly requestSha256: string } => { const requestSha256 = digestCanonical({ schema: "rea.cross-version-investigation-request/v1", left: input.left, right: input.right, options: input.options, + integrity: { + policy: input.integrity_policy, + approved: input.integrity_continue_approved, + max_mismatches: input.max_integrity_mismatches, + }, }); return { runId: `run_${requestSha256}`, requestSha256 }; }; diff --git a/src/domain/permissionPolicy.ts b/src/domain/permissionPolicy.ts new file mode 100644 index 000000000..afa22af3c --- /dev/null +++ b/src/domain/permissionPolicy.ts @@ -0,0 +1,322 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; + +import { err, ok, type Result } from "./result.js"; + +/** Local capabilities governed by the shared REA permission policy. */ +export type PermissionCapability = + | "process_capture" + | "evidence_read" + | "evidence_write" + | "investigation_input" + | "investigation_workspace_read" + | "investigation_workspace_write" + | "snapshot_read" + | "snapshot_write" + | "artifact_extract" + | "native_mount" + | "reference_read"; + +/** Ordered network authority; each mode includes the modes before it. */ +type PermissionNetwork = "none" | "loopback" | "external"; + +/** Exact normalized scope requested by one local operation. */ +export interface PermissionScope { + readonly capability: PermissionCapability; + readonly roots: readonly string[]; + readonly executables: readonly string[]; + readonly environment_names: readonly string[]; + readonly network: PermissionNetwork; + readonly mount: boolean; +} + +/** A request identity associates one-shot grants with exactly one operation. */ +export interface PermissionRequest extends PermissionScope { + readonly operation_identity: string; +} + +/** Administrator-owned maximum authority for one capability. */ +export type PermissionCeiling = PermissionScope; + +/** Authority issued beneath the administrator ceiling. */ +export interface PermissionGrant extends PermissionScope { + readonly grant_id: string; + readonly lifetime: "once" | "session" | "project" | "administrator"; + readonly operation_identity: string | null; + readonly expires_at: string | null; +} + +/** Missing requested authority, containing names and paths but never values. */ +export interface MissingPermissionScope { + readonly roots?: readonly string[]; + readonly executables?: readonly string[]; + readonly environment_names?: readonly string[]; + readonly network?: PermissionNetwork; + readonly mount?: true; +} + +/** Immutable shared policy state. */ +export interface PermissionPolicy { + readonly ceilings: readonly PermissionCeiling[]; + readonly grants: readonly PermissionGrant[]; + readonly revoked_grant_ids: ReadonlySet; + readonly consumed_grant_ids: ReadonlySet; +} + +/** A proposed grant exceeds the administrator-owned maximum authority. */ +export class PermissionGrantError extends Error { + readonly _tag = "PermissionGrantError" as const; + + constructor( + readonly reason: "outside_administrator_ceiling" | "invalid_once_scope", + readonly missing: MissingPermissionScope, + ) { + super(`Permission grant rejected: ${reason}`); + } +} + +/** Observable decision returned before any governed side effect. */ +export type PermissionDecision = + | { + readonly allowed: true; + readonly grant_id: string; + readonly lifetime: PermissionGrant["lifetime"]; + } + | { + readonly allowed: false; + readonly reason: + | "outside_administrator_ceiling" + | "grant_required" + | "grant_revoked_or_consumed" + | "grant_expired"; + readonly missing: MissingPermissionScope; + }; + +/** Create a fail-closed policy with no implicit grants. */ +export const createPermissionPolicy = ( + ceilings: readonly PermissionCeiling[], + grants: readonly PermissionGrant[] = [], +): PermissionPolicy => ({ + ceilings: ceilings.map(normalizeScope), + grants: grants.map(normalizeGrant), + revoked_grant_ids: new Set(), + consumed_grant_ids: new Set(), +}); + +/** Add one grant only when it is a subset of its administrator ceiling. */ +export const grantPermission = ( + policy: PermissionPolicy, + grant: PermissionGrant, +): Result => { + const normalized = normalizeGrant(grant); + if (normalized.lifetime === "once" && normalized.operation_identity === null) + return err(new PermissionGrantError("invalid_once_scope", {})); + const missing = missingFromScopes(normalized, policy.ceilings); + if (hasMissing(missing)) + return err( + new PermissionGrantError("outside_administrator_ceiling", missing), + ); + return ok({ ...policy, grants: [...policy.grants, normalized] }); +}; + +/** Evaluate exact requested authority against the ceiling and active grants. */ +export const evaluatePermission = ( + policy: PermissionPolicy, + request: PermissionRequest, + now: Date = new Date(), +): PermissionDecision => { + const normalized = normalizeRequest(request); + const outsideCeiling = missingFromScopes(normalized, policy.ceilings); + if (hasMissing(outsideCeiling)) + return { + allowed: false, + reason: "outside_administrator_ceiling", + missing: outsideCeiling, + }; + const candidates = policy.grants.filter( + (grant) => + grant.capability === normalized.capability && + !policy.revoked_grant_ids.has(grant.grant_id) && + !policy.consumed_grant_ids.has(grant.grant_id) && + !isExpired(grant, now) && + (grant.lifetime !== "once" || + grant.operation_identity === normalized.operation_identity), + ); + const selected = candidates.find( + (grant) => !hasMissing(missingFromScopes(normalized, [grant])), + ); + if (selected !== undefined) + return { + allowed: true, + grant_id: selected.grant_id, + lifetime: selected.lifetime, + }; + const coveringGrant = policy.grants.find( + (grant) => + grant.capability === normalized.capability && + (grant.lifetime !== "once" || + grant.operation_identity === normalized.operation_identity) && + !hasMissing(missingFromScopes(normalized, [grant])), + ); + return { + allowed: false, + reason: + coveringGrant !== undefined && isExpired(coveringGrant, now) + ? "grant_expired" + : candidates.length === 0 && + policy.grants.some( + (grant) => grant.capability === normalized.capability, + ) + ? "grant_revoked_or_consumed" + : "grant_required", + missing: missingFromScopes(normalized, candidates), + }; +}; + +/** Consume an allowed one-shot decision; longer-lived grants are unchanged. */ +export const consumePermission = ( + policy: PermissionPolicy, + decision: Extract, +): PermissionPolicy => + decision.lifetime === "once" + ? { + ...policy, + consumed_grant_ids: new Set([ + ...policy.consumed_grant_ids, + decision.grant_id, + ]), + } + : policy; + +/** Revoke one grant for every future evaluation. */ +export const revokePermission = ( + policy: PermissionPolicy, + grantId: string, +): PermissionPolicy => ({ + ...policy, + revoked_grant_ids: new Set([...policy.revoked_grant_ids, grantId]), +}); + +/** Drop connection-bound authority when an MCP or CLI session ends. */ +export const clearSessionPermissions = ( + policy: PermissionPolicy, +): PermissionPolicy => ({ + ...policy, + grants: policy.grants.filter( + ({ lifetime }) => lifetime !== "once" && lifetime !== "session", + ), +}); + +/** Atomically replace administrator ceilings while retaining auditable grants. */ +export const reloadPermissionCeilings = ( + policy: PermissionPolicy, + ceilings: readonly PermissionCeiling[], +): PermissionPolicy => ({ + ...policy, + ceilings: ceilings.map(normalizeScope), +}); + +const normalizeScope = (scope: PermissionScope): PermissionScope => ({ + capability: scope.capability, + roots: uniqueSorted(scope.roots.map((path) => resolve(path))), + executables: uniqueSorted(scope.executables.map((path) => resolve(path))), + environment_names: uniqueSorted(scope.environment_names), + network: scope.network, + mount: scope.mount, +}); + +const normalizeGrant = (grant: PermissionGrant): PermissionGrant => ({ + ...normalizeScope(grant), + grant_id: grant.grant_id, + lifetime: grant.lifetime, + operation_identity: grant.operation_identity, + expires_at: grant.expires_at, +}); + +const normalizeRequest = (request: PermissionRequest): PermissionRequest => ({ + ...normalizeScope(request), + operation_identity: request.operation_identity, +}); + +const missingFromScopes = ( + request: PermissionScope, + scopes: readonly PermissionScope[], +): MissingPermissionScope => { + const relevant = scopes.filter( + ({ capability }) => capability === request.capability, + ); + const roots = request.roots.filter( + (path) => !relevant.some((scope) => containsAny(scope.roots, path)), + ); + const executables = request.executables.filter( + (path) => !relevant.some((scope) => containsAny(scope.executables, path)), + ); + const environmentNames = request.environment_names.filter( + (name) => !relevant.some((scope) => scope.environment_names.includes(name)), + ); + const network = + request.network === "none" || + relevant.some( + (scope) => networkRank(scope.network) >= networkRank(request.network), + ) + ? undefined + : request.network; + const mount = + !request.mount || relevant.some((scope) => scope.mount) + ? undefined + : (true as const); + return { + ...(roots.length === 0 ? {} : { roots }), + ...(executables.length === 0 ? {} : { executables }), + ...(environmentNames.length === 0 + ? {} + : { environment_names: environmentNames }), + ...(network === undefined ? {} : { network }), + ...(mount === undefined ? {} : { mount }), + }; +}; + +interface PathOperations { + readonly relative: typeof relative; + readonly isAbsolute: typeof isAbsolute; + readonly sep: string; +} + +const HOST_PATH_OPERATIONS: PathOperations = { relative, isAbsolute, sep }; + +/** Test whether a candidate is inside a root using one platform path flavor. */ +export const isPathContained = ( + root: string, + candidate: string, + operations: PathOperations = HOST_PATH_OPERATIONS, +): boolean => { + const relation = operations.relative(root, candidate); + return ( + relation === "" || + (relation !== ".." && + !relation.startsWith(`..${operations.sep}`) && + !operations.isAbsolute(relation)) + ); +}; + +const containsAny = (roots: readonly string[], candidate: string): boolean => + roots.some((root) => isPathContained(root, candidate)); + +const networkRank = (network: PermissionNetwork): number => { + switch (network) { + case "none": + return 0; + case "loopback": + return 1; + case "external": + return 2; + } +}; + +const isExpired = (grant: PermissionGrant, now: Date): boolean => + grant.expires_at !== null && Date.parse(grant.expires_at) <= now.getTime(); + +const hasMissing = (missing: MissingPermissionScope): boolean => + Object.keys(missing).length > 0; + +const uniqueSorted = (values: readonly string[]): readonly string[] => + [...new Set(values)].sort((left, right) => left.localeCompare(right)); diff --git a/src/domain/processComparison.ts b/src/domain/processComparison.ts index def58ebe1..6e02bb335 100644 --- a/src/domain/processComparison.ts +++ b/src/domain/processComparison.ts @@ -12,6 +12,7 @@ export const comparisonStatusSchema = z.enum([ "changed", "truncated", "unknown", + "contradiction", ]); type ComparisonStatus = z.infer; diff --git a/src/domain/reconstructionVerification.ts b/src/domain/reconstructionVerification.ts index c9090b74b..95edff2a8 100644 --- a/src/domain/reconstructionVerification.ts +++ b/src/domain/reconstructionVerification.ts @@ -389,7 +389,8 @@ const classify = ( unknowns: readonly ResidualUnknown[], ): ClaimResult["status"] => { if (limitations.length > 0) return "unknown"; - if (["changed", "added", "removed"].includes(observed)) return "fail"; + if (["changed", "added", "removed", "contradiction"].includes(observed)) + return "fail"; if (observed === "unknown" || observed === "truncated" || unknowns.length > 0) return "unknown"; return "pass"; diff --git a/src/domain/reconstructionVerificationSchemas.ts b/src/domain/reconstructionVerificationSchemas.ts index df3ef1bfe..50f9ca319 100644 --- a/src/domain/reconstructionVerificationSchemas.ts +++ b/src/domain/reconstructionVerificationSchemas.ts @@ -96,6 +96,7 @@ export const reconstructionClaimResultSchema = z.object({ "changed", "truncated", "unknown", + "contradiction", ]), comparison_evidence_id: evidenceIdSchema, left_evidence_ids: z.array(evidenceIdSchema).min(1).max(100), diff --git a/src/domain/staticRuntimeCorrelation.ts b/src/domain/staticRuntimeCorrelation.ts index eaea50856..ad7a4fe5b 100644 --- a/src/domain/staticRuntimeCorrelation.ts +++ b/src/domain/staticRuntimeCorrelation.ts @@ -338,7 +338,11 @@ const selectStatic = ( return { status, conclusion_kind: - status === "unknown" ? "unresolved_branch" : "derived_relationship", + status === "contradiction" + ? "contradiction" + : status === "unknown" + ? "unresolved_branch" + : "derived_relationship", }; }; diff --git a/tests/artifactProvider.test.ts b/tests/artifactProvider.test.ts index 9092e9399..4db2aa009 100644 --- a/tests/artifactProvider.test.ts +++ b/tests/artifactProvider.test.ts @@ -124,6 +124,17 @@ describe("artifact graph provider", () => { provider: { id: "rea-artifact-graph" }, subject: { format: "apk" }, }); + const cancellation = new AbortController(); + cancellation.abort(); + await expect( + runProviderAnalysis( + zipPath, + "inventory_artifact", + {}, + undefined, + cancellation.signal, + ), + ).resolves.toMatchObject({ code: "cancelled" }); const source = join(root, "asar-source"); await mkdir(source); diff --git a/tests/binarySession.test.ts b/tests/binarySession.test.ts index 9c2075abf..f296c6a5a 100644 --- a/tests/binarySession.test.ts +++ b/tests/binarySession.test.ts @@ -9,7 +9,10 @@ import type { AnalysisProvider, CapabilityDescriptor, } from "../src/application/AnalysisProvider.js"; -import { HopperStartError } from "../src/domain/errors.js"; +import { + HopperStartError, + ProviderAdapterError, +} from "../src/domain/errors.js"; import { err } from "../src/domain/result.js"; import { observed as ok } from "./fixtures/analysisExecution.js"; @@ -178,6 +181,48 @@ describe("binary session", () => { await session.close(); }); + it("publishes provider-health availability changes and resets on target switch", async () => { + const [first, second] = await targets(); + const provider = cacheProvider([]); + provider.createClient = () => ({ + execute: (operation) => + Promise.resolve( + operation === "health" + ? ok(null) + : err(new ProviderAdapterError("fixture", operation)), + ), + close: () => Promise.resolve(), + }); + const session = new BinarySession(provider); + let changes = 0; + session.onAvailabilityChanged(() => { + changes += 1; + }); + expect((await session.open(first)).ok).toBe(true); + expect((await session.execute("address_name", {})).ok).toBe(false); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: false, + reason: "Provider became unavailable during this session.", + }), + ]), + }); + expect(changes).toBe(1); + expect((await session.open(second)).ok).toBe(true); + expect(session.status()).toMatchObject({ + capabilities: expect.arrayContaining([ + expect.objectContaining({ + operation: "address_name", + available: true, + reason: null, + }), + ]), + }); + expect(changes).toBe(2); + }); + it("does not replay operations with filesystem side effects", async () => { const [first] = await targets(); const calls: string[] = []; diff --git a/tests/config.test.ts b/tests/config.test.ts index 0454a5ff7..369e0bafb 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -36,7 +36,7 @@ describe("runtime configuration", () => { HOPPER_TARGET_KIND: "database", HOPPER_LOADER_ARGS_JSON: '["-l","FAT","--aarch64","-l","Mach-O"]', }), - ).toEqual({ + ).toMatchObject({ ok: true, value: { hopperLauncherPath: diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 4ae3c5df4..afe287c47 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -23,12 +23,17 @@ import { NoBinaryOpenError, ProviderAdapterError, ProviderSelectionError, + PermissionRequiredError, UnknownRegistryError, projectAnalysisError, type AnalysisError, type AnalysisErrorTag, } from "../src/domain/errors.js"; import { ProcessCaptureError } from "../src/application/ProcessHarness.js"; +import { + analysisErrorJsonSchema, + analysisErrorProjectionSchema, +} from "../src/contracts/errorSchemas.js"; describe("analysis error projection", () => { it("uses the correct provider-neutral protocol tag", () => { @@ -52,7 +57,10 @@ describe("analysis error projection", () => { ] as const; for (const [exitCode, code] of expected) { const projected = projectAnalysisError(new HopperProcessError(exitCode)); - expect(projected.code).toBe(code); + expect(projected).toMatchObject({ + code: "provider_unavailable", + details: { failure_code: code, exit_code: exitCode }, + }); expect(projected.message.length).toBeGreaterThan(20); expect(JSON.stringify(projected)).not.toContain("/proc/"); } @@ -121,20 +129,34 @@ describe("analysis error projection", () => { cause: secretCause, }, ), + PermissionRequiredError: new PermissionRequiredError( + { + capability: "evidence_read", + roots: ["/workspace/evidence.json"], + executables: [], + environment_names: ["TOKEN_NAME"], + network: "none", + mount: false, + operation_identity: "read:evidence", + }, + { environment_names: ["TOKEN_NAME"] }, + null, + false, + true, + ), } satisfies Readonly>; const errors = Object.values(byTag); const projected = errors.map(projectAnalysisError); - expect(projected).toHaveLength(24); + expect(projected).toHaveLength(25); const serialized = JSON.stringify(projected); for (const hidden of [ "secret-token", - "/secret/local/path", - "fixture", - "overview", "AnalysisCapabilityUnavailableError", "declaredSha256", ]) expect(serialized).not.toContain(hidden); + expect(serialized).toContain("/secret/local/path"); + expect(serialized).toContain('"environment_names":["TOKEN_NAME"]'); expect( projectAnalysisError(byTag.AnalysisCapabilityUnavailableError), ).toMatchObject({ @@ -160,11 +182,114 @@ describe("analysis error projection", () => { }); expect(projected.every(({ message }) => message.length > 0)).toBe(true); expect( - projected.every(({ message }) => - /try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy/u.test( - message, - ), + projected.every( + (value) => analysisErrorProjectionSchema.safeParse(value).success, ), ).toBe(true); + expect(analysisErrorJsonSchema).toMatchObject({ oneOf: expect.any(Array) }); + expect( + projectAnalysisError( + new ProcessCaptureError("terminal cleanup failed", { + reason: "cleanup_incomplete", + cleanupResources: ["process_group"], + }), + ), + ).toMatchObject({ + code: "cleanup_incomplete", + details: { cleanup: "incomplete", resources: ["process_group"] }, + }); + expect( + projected + .map(({ message }) => message) + .filter( + (message) => + !/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy|review the requested scope/iu.test( + message, + ), + ), + ).toEqual([]); + }); + + it("validates every closed error-reason variant against the generated contract", () => { + const variants: AnalysisError[] = [ + ...( + [ + "cancelled", + "format", + "integrity", + "limit", + "path", + "unavailable", + "io", + ] as const + ).map( + (reason) => new ArtifactOperationError("inventory_artifact", reason), + ), + ...( + [ + "disabled", + "outside-root", + "not-file", + "too-large", + "exists", + "invalid-json", + "io", + ] as const + ).map((reason) => new EvidenceFileError("read", reason)), + ...( + [ + "disabled", + "outside-root", + "not-file", + "too-large", + "invalid-json", + "integrity", + "locked", + "revision-conflict", + "name-conflict", + "io", + ] as const + ).map((reason) => new InvestigationWorkspaceError("update", reason)), + ...( + [ + "not-found", + "already-exists", + "revision-conflict", + "invalid-transition", + "integrity", + "limit", + ] as const + ).map((reason) => new UnknownRegistryError(reason)), + ...( + [ + "capture_failed", + "cleanup_incomplete", + "permission_required", + "cancelled", + ] as const + ).map( + (reason) => + new ProcessCaptureError("SECRET capture diagnostic", { reason }), + ), + ...( + [ + "remote", + "authorization", + "invalid_request", + "bridge_exception", + ] as const + ).map((diagnostic) => new HopperRemoteError(9, "safe", diagnostic)), + ]; + + expect(variants).toHaveLength(38); + for (const variant of variants) { + const projected = projectAnalysisError(variant); + const parsed = analysisErrorProjectionSchema.safeParse(projected); + expect(parsed, JSON.stringify({ variant, projected })).toMatchObject({ + success: true, + }); + expect(projected.code).toMatch(/^[a-z][a-z0-9_]*$/u); + expect(JSON.stringify(projected)).not.toContain("SECRET"); + } }); }); diff --git a/tests/investigationWorkspace.test.ts b/tests/investigationWorkspace.test.ts index 1938dec65..8a5741ba9 100644 --- a/tests/investigationWorkspace.test.ts +++ b/tests/investigationWorkspace.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { createPackageWithOptions } from "@electron/asar"; import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js"; import { @@ -211,6 +212,86 @@ describe("persistent cross-version investigation workspace", () => { ).toBe(true); }); + it("checkpoints bounded integrity contradictions and safely reuses them", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-workspace-integrity-")); + const source = join(directory, "source"); + await mkdir(source); + await writeFile(join(source, "addon.node"), "verified native addon\n"); + const left = join(directory, "left.asar"); + const right = join(directory, "right.asar"); + await Promise.all([ + createPackageWithOptions(source, left, { unpack: "*.node" }), + createPackageWithOptions(source, right, { unpack: "*.node" }), + ]); + await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n"); + const base = { + approved: true as const, + workspace_path: join(directory, "strict.json"), + workspace_name: "integrity-diff", + left_path: left, + right_path: right, + options: { page_size: 500, change_limit: 100 }, + }; + const strict = await runCrossVersionInvestigation( + crossVersionInvestigationInputSchema.parse(base), + policy(directory), + { inputRoots: [directory] }, + ); + expect(strict).toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); + + const continuedInput = crossVersionInvestigationInputSchema.parse({ + ...base, + workspace_path: join(directory, "continued.json"), + integrity_policy: "record-and-continue", + integrity_continue_approved: true, + max_integrity_mismatches: 2, + }); + const continued = await runCrossVersionInvestigation( + continuedInput, + policy(directory), + { inputRoots: [directory], integrityContinueEnabled: true }, + ); + expect(continued).toMatchObject({ + ok: true, + value: { reused: false, workspace: { revision: 3 } }, + }); + if (!continued.ok) throw continued.error; + expect( + continued.value.workspace.bundle.records.some((record) => + JSON.stringify(record.normalized_result).includes( + '"integrity_contradictions":[{', + ), + ), + ).toBe(true); + expect( + continued.value.workspace.bundle.records.find( + ({ operation }) => operation === "compare_artifacts", + )?.normalized_result, + ).toMatchObject({ status: "contradiction" }); + await expect( + runCrossVersionInvestigation(continuedInput, policy(directory), { + inputRoots: [directory], + integrityContinueEnabled: true, + }), + ).resolves.toMatchObject({ ok: true, value: { reused: true } }); + + const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({ + ...base, + workspace_path: continuedInput.workspace_path, + }); + await expect( + runCrossVersionInvestigation(strictAfterContinuation, policy(directory), { + inputRoots: [directory], + }), + ).resolves.toMatchObject({ + ok: false, + error: { _tag: "ArtifactOperationError", reason: "integrity" }, + }); + }); + it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => { const { path, input } = await fixture(); if (directory === undefined) throw new Error("missing fixture root"); diff --git a/tests/permissionAuthority.test.ts b/tests/permissionAuthority.test.ts new file mode 100644 index 000000000..74f5e3fb0 --- /dev/null +++ b/tests/permissionAuthority.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { + canonicalizePermissionCeilings, + canonicalizePermissionRequest, +} from "../src/application/PermissionAuthority.js"; +import { + createPermissionPolicy, + evaluatePermission, +} from "../src/domain/permissionPolicy.js"; + +describe("permission authority", () => { + it("accepts canonical aliases but rejects a symlink escape", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-")); + const root = join(sandbox, "root"); + const outside = join(sandbox, "outside"); + await Promise.all([mkdir(root), mkdir(outside)]); + await writeFile(join(root, "inside"), "inside"); + await writeFile(join(outside, "secret"), "secret"); + await symlink(root, join(sandbox, "root-alias")); + await symlink(join(outside, "secret"), join(root, "escape")); + const ceiling = await canonicalizePermissionCeilings([ + { + capability: "evidence_read", + roots: [join(sandbox, "root-alias")], + executables: [], + environment_names: [], + network: "none", + mount: false, + }, + ]); + expect(ceiling.ok).toBe(true); + if (!ceiling.ok) return; + const policy = createPermissionPolicy(ceiling.value, [ + { + ...ceiling.value[0]!, + grant_id: "administrator:evidence-read", + lifetime: "administrator", + operation_identity: null, + expires_at: null, + }, + ]); + const inside = await canonicalizePermissionRequest( + { + capability: "evidence_read", + roots: [join(root, "inside")], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: "read:inside", + }, + "read", + ); + const escaped = await canonicalizePermissionRequest( + { + capability: "evidence_read", + roots: [join(root, "escape")], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: "read:escape", + }, + "read", + ); + expect(inside.ok).toBe(true); + expect(escaped.ok).toBe(true); + if (!inside.ok || !escaped.ok) return; + expect(evaluatePermission(policy, inside.value)).toMatchObject({ + allowed: true, + }); + expect(evaluatePermission(policy, escaped.value)).toMatchObject({ + allowed: false, + reason: "outside_administrator_ceiling", + }); + }); + + it("canonicalizes a new write through its existing parent", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "rea-permission-write-")); + const result = await canonicalizePermissionRequest( + { + capability: "evidence_write", + roots: [join(sandbox, "new.json")], + executables: [], + environment_names: [], + network: "none", + mount: false, + operation_identity: "write:new.json", + }, + "write", + ); + + expect(result).toMatchObject({ ok: true }); + if (result.ok) + expect(result.value.roots).toEqual([join(sandbox, "new.json")]); + }); +}); diff --git a/tests/permissionPolicy.test.ts b/tests/permissionPolicy.test.ts new file mode 100644 index 000000000..b3e372211 --- /dev/null +++ b/tests/permissionPolicy.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { win32 } from "node:path"; + +import { + consumePermission, + clearSessionPermissions, + createPermissionPolicy, + evaluatePermission, + grantPermission, + isPathContained, + reloadPermissionCeilings, + revokePermission, +} from "../src/domain/permissionPolicy.js"; + +describe("permission policy", () => { + it("rejects sibling escapes for both POSIX and Windows path separators", () => { + expect(isPathContained("/allowed", "/secret")).toBe(false); + expect( + isPathContained("C:\\allowed", "C:\\secret", { + relative: win32.relative, + isAbsolute: win32.isAbsolute, + sep: win32.sep, + }), + ).toBe(false); + expect( + isPathContained("C:\\allowed", "C:\\allowed\\nested", { + relative: win32.relative, + isAbsolute: win32.isAbsolute, + sep: win32.sep, + }), + ).toBe(true); + }); + + it("never lets a session grant exceed the administrator ceiling", () => { + const policy = createPermissionPolicy([ + { + capability: "process_capture", + roots: ["/workspace/project"], + executables: ["/workspace/project/bin/tool"], + environment_names: ["LANG"], + network: "loopback", + mount: false, + }, + ]); + const granted = grantPermission(policy, { + grant_id: "grant_session_1", + capability: "process_capture", + roots: ["/workspace/project"], + executables: ["/workspace/project/bin/tool"], + environment_names: ["LANG"], + network: "loopback", + mount: false, + lifetime: "session", + operation_identity: null, + expires_at: null, + }); + + expect(granted.ok).toBe(true); + if (!granted.ok) return; + expect( + evaluatePermission(granted.value, { + capability: "process_capture", + roots: ["/workspace/project/data"], + executables: ["/workspace/project/bin/tool"], + environment_names: ["LANG"], + network: "loopback", + mount: false, + operation_identity: "capture-1", + }), + ).toMatchObject({ allowed: true }); + expect( + evaluatePermission(granted.value, { + capability: "process_capture", + roots: ["/workspace/project/data"], + executables: ["/workspace/project/bin/tool"], + environment_names: ["LANG", "TOKEN"], + network: "external", + mount: false, + operation_identity: "capture-2", + }), + ).toMatchObject({ + allowed: false, + reason: "outside_administrator_ceiling", + missing: { + environment_names: ["TOKEN"], + network: "external", + }, + }); + }); + + it("consumes once grants and applies revocation and ceiling reload immediately", () => { + const ceiling = { + capability: "evidence_write" as const, + roots: ["/workspace/evidence"], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, + }; + const granted = grantPermission(createPermissionPolicy([ceiling]), { + ...ceiling, + grant_id: "grant_once_1", + lifetime: "once", + operation_identity: "write:report.json", + expires_at: null, + }); + expect(granted.ok).toBe(true); + if (!granted.ok) return; + const request = { + ...ceiling, + roots: ["/workspace/evidence/report.json"], + operation_identity: "write:report.json", + }; + const decision = evaluatePermission(granted.value, request); + expect(decision).toMatchObject({ allowed: true }); + if (!decision.allowed) return; + + const consumed = consumePermission(granted.value, decision); + expect(evaluatePermission(consumed, request)).toMatchObject({ + allowed: false, + reason: "grant_revoked_or_consumed", + }); + expect( + evaluatePermission( + revokePermission(granted.value, "grant_once_1"), + request, + ), + ).toMatchObject({ + allowed: false, + reason: "grant_revoked_or_consumed", + }); + expect( + evaluatePermission(reloadPermissionCeilings(granted.value, []), request), + ).toMatchObject({ + allowed: false, + reason: "outside_administrator_ceiling", + }); + }); + + it("expires grants, removes session authority on disconnect, and never combines partial grants", () => { + const ceiling = { + capability: "process_capture" as const, + roots: ["/workspace"], + executables: ["/bin/tool"], + environment_names: ["LANG"], + network: "loopback" as const, + mount: false, + }; + const base = createPermissionPolicy([ceiling]); + const rootOnly = grantPermission(base, { + ...ceiling, + executables: [], + environment_names: [], + network: "none", + grant_id: "root-only", + lifetime: "project", + operation_identity: null, + expires_at: null, + }); + expect(rootOnly.ok).toBe(true); + if (!rootOnly.ok) return; + const executableOnly = grantPermission(rootOnly.value, { + ...ceiling, + roots: [], + grant_id: "exec-only", + lifetime: "session", + operation_identity: null, + expires_at: "2026-07-14T00:00:00.000Z", + }); + expect(executableOnly.ok).toBe(true); + if (!executableOnly.ok) return; + const request = { + ...ceiling, + operation_identity: "capture", + }; + + expect( + evaluatePermission( + executableOnly.value, + request, + new Date("2026-07-13T00:00:00.000Z"), + ), + ).toMatchObject({ allowed: false, reason: "grant_required" }); + const expiring = grantPermission(base, { + ...ceiling, + grant_id: "expiring", + lifetime: "session", + operation_identity: null, + expires_at: "2026-07-14T00:00:00.000Z", + }); + expect(expiring.ok).toBe(true); + if (!expiring.ok) return; + expect( + evaluatePermission( + expiring.value, + request, + new Date("2026-07-15T00:00:00.000Z"), + ), + ).toMatchObject({ allowed: false, reason: "grant_expired" }); + expect( + clearSessionPermissions(executableOnly.value).grants.map( + ({ grant_id }) => grant_id, + ), + ).toEqual(["root-only"]); + }); +}); diff --git a/tests/processCli.test.ts b/tests/processCli.test.ts index 7576f5638..eef94c0de 100644 --- a/tests/processCli.test.ts +++ b/tests/processCli.test.ts @@ -97,11 +97,20 @@ describe("process CLI errors", () => { working_directory: "/tmp", }), ); - expect(await captureProcessScenarioFile(scenario)).toEqual({ + expect(await captureProcessScenarioFile(scenario)).toMatchObject({ error: "Process command failed", + code: "permission_required", category: "permission_required", message: - "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.", + "This operation needs additional local permission. Review the requested scope and remediation.", + details: { + capability: "process_capture", + ceiling: null, + }, + remediation: { + restart_required: false, + elicitation_supported: false, + }, }); }); diff --git a/tests/progressReporter.test.ts b/tests/progressReporter.test.ts new file mode 100644 index 000000000..50aab0565 --- /dev/null +++ b/tests/progressReporter.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { createProgressReporter } from "../src/application/ProgressReporter.js"; + +describe("progress reporter", () => { + it("preserves monotonic order, rate bounds intermediate updates, and always emits terminal state", async () => { + let now = 0; + const observed: unknown[] = []; + const reporter = createProgressReporter( + (update) => { + observed.push(update); + return Promise.resolve(); + }, + { minimumIntervalMs: 100, now: () => now }, + ); + await reporter.report({ + phase: "scan", + completed: 0, + total: 3, + message: "start", + }); + now = 10; + await reporter.report({ + phase: "scan", + completed: 1, + total: 3, + message: "one", + }); + now = 20; + await reporter.report({ + phase: "scan", + completed: 3, + total: 3, + message: "done", + terminal: true, + }); + + expect(observed).toEqual([ + expect.objectContaining({ sequence: 1, completed: 0 }), + expect.objectContaining({ sequence: 2, completed: 3, terminal: true }), + ]); + await expect( + reporter.report({ + phase: "scan", + completed: 2, + total: 3, + message: "back", + }), + ).rejects.toThrow(/backwards/u); + }); +}); diff --git a/tests/projectPermissionStore.test.ts b/tests/projectPermissionStore.test.ts new file mode 100644 index 000000000..572d442b5 --- /dev/null +++ b/tests/projectPermissionStore.test.ts @@ -0,0 +1,63 @@ +import { chmod, mkdtemp, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + readProjectPermissionStore, + writeProjectPermissionStore, +} from "../src/application/ProjectPermissionStore.js"; + +describe("project permission store", () => { + it("persists explicit project grants atomically with owner-only permissions", async () => { + const project = await mkdtemp(join(tmpdir(), "rea-policy-project-")); + const path = join(project, ".rea", "permissions.json"); + const grant = { + grant_id: "project:evidence-read", + capability: "evidence_read" as const, + roots: [project], + executables: [], + environment_names: [], + network: "none" as const, + mount: false, + lifetime: "project" as const, + operation_identity: null, + expires_at: null, + }; + + const written = await writeProjectPermissionStore(path, project, [grant]); + expect(written.ok).toBe(true); + expect((await stat(path)).mode & 0o077).toBe(0); + const loaded = await readProjectPermissionStore(path, project); + expect(loaded).toMatchObject({ + ok: true, + value: { grants: [grant] }, + }); + }); + + it("rejects stores readable by another user class", async () => { + const project = await mkdtemp(join(tmpdir(), "rea-policy-mode-")); + const path = join(project, "permissions.json"); + const written = await writeProjectPermissionStore(path, project, []); + expect(written.ok).toBe(true); + await chmod(path, 0o644); + + expect(await readProjectPermissionStore(path, project)).toMatchObject({ + ok: false, + error: { reason: "not_owner_only" }, + }); + }); + + it("binds grants to canonical project identity", async () => { + const first = await mkdtemp(join(tmpdir(), "rea-policy-first-")); + const second = await mkdtemp(join(tmpdir(), "rea-policy-second-")); + const path = join(first, "permissions.json"); + expect((await writeProjectPermissionStore(path, first, [])).ok).toBe(true); + + expect(await readProjectPermissionStore(path, second)).toMatchObject({ + ok: false, + error: { reason: "invalid" }, + }); + }); +});