Compare commits

...

11 Commits

Author SHA1 Message Date
morluto aef82abba7
Merge pull request #125 from morluto/feat/mcp-progress-resources
feat(mcp): expose progress, resources, and availability
2026-07-14 09:54:32 +08:00
morluto ee1b55b1a6
Merge pull request #124 from morluto/feat/integrity-permission-contracts
feat(core): add typed policy and integrity contracts
2026-07-14 09:54:20 +08:00
morluto e636437041 test(mcp): cover live identity and list changes 2026-07-14 01:52:33 +00:00
morluto 3d6ccee17a feat(mcp): expose progress resources and availability 2026-07-14 01:52:21 +00:00
morluto 97f5694385 docs: generate typed error schema 2026-07-14 01:45:51 +00:00
morluto d4e49c5b7c fix(ci): keep generated error docs with their owner 2026-07-14 01:45:16 +00:00
morluto 3e43c72fc1 fix(ci): keep generated error docs with their owner 2026-07-14 01:44:04 +00:00
morluto 009fa3311e fix(workspace): migrate legacy integrity identities 2026-07-14 01:37:43 +00:00
morluto 6540d64848 feat(core): add typed policy and integrity contracts 2026-07-14 01:37:43 +00:00
morluto 87e66c381f test(identity): defer live MCP integration proof 2026-07-14 01:37:16 +00:00
morluto 5fa553a644 feat(identity): derive MCP surface metadata 2026-07-14 01:12:05 +00:00
95 changed files with 7516 additions and 471 deletions

View File

@ -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" }
}
]
}
}
}

116
docs/mcp-contracts.md Normal file
View File

@ -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.

33
package-lock.json generated
View File

@ -7,12 +7,13 @@
"": {
"name": "rea-agents",
"version": "1.3.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@ -36,7 +37,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",
@ -245,12 +246,13 @@
}
},
"node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.3.tgz",
"integrity": "sha512-o9z9YCGNxWNdklPdjcD0tr8pocC9OgjdArqgx3OjPMlTe0M/SSPC2olOJs5ueq77HSpz36mND0f0KteqEhKF3A==",
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz",
"integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"cross-spawn": "^7.0.5",
"eventsource": "^3.0.2",
"eventsource-parser": "^3.0.0",
@ -262,10 +264,10 @@
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.3.tgz",
"integrity": "sha512-EQxVIWf2XMAgjR38ZRZnX1iiOWH42tT/YJG8N4dVjAnrEGSEfPirR6FYfDC72yuNSvVYbPDTD1H3KHLLHPd18A==",
"node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz",
"integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==",
"license": "MIT",
"dependencies": {
"zod": "^4.2.0"
@ -274,6 +276,19 @@
"node": ">=20"
}
},
"node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz",
"integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4",
"zod": "^4.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",

View File

@ -49,6 +49,7 @@
"LICENSE"
],
"scripts": {
"prebuild": "node scripts/generate-package-metadata.mjs",
"build": "tsc -p tsconfig.build.json",
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
"format": "prettier --write .",
@ -89,7 +90,7 @@
"@babel/parser": "8.0.4",
"@babel/types": "8.0.4",
"@electron/asar": "4.2.0",
"@modelcontextprotocol/server": "2.0.0-beta.3",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.0.0",
"@zip.js/zip.js": "2.8.26",
@ -110,7 +111,7 @@
},
"devDependencies": {
"@fast-check/vitest": "0.4.1",
"@modelcontextprotocol/client": "2.0.0-beta.3",
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@types/node": "24.10.1",
"@types/write-file-atomic": "4.0.3",
"@types/ws": "8.18.1",

View File

@ -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",
);

View File

@ -0,0 +1,35 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const packageJson = JSON.parse(
await readFile(join(root, "package.json"), "utf8"),
);
const lock = JSON.parse(
await readFile(join(root, "package-lock.json"), "utf8"),
);
const skill = await readFile(
join(root, "skills/rea-analysis/SKILL.md"),
"utf8",
);
const skillVersion = /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(skill)?.[1];
if (skillVersion === undefined)
throw new Error("Missing rea-analysis skill metadata version");
const versionOf = (name) => {
const value = lock.packages?.[`node_modules/${name}`]?.version;
if (typeof value !== "string")
throw new Error(`Missing locked ${name} version`);
return value;
};
const source = `/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: ${JSON.stringify(packageJson.name)},
version: ${JSON.stringify(packageJson.version)},
serverSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/server"))},
clientSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/client"))},
coreSdkVersion: ${JSON.stringify(versionOf("@modelcontextprotocol/core"))},
skillVersion: ${JSON.stringify(skillVersion)},
} as const;
`;
await writeFile(join(root, "src/generatedPackageMetadata.ts"), source, "utf8");

View File

@ -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. */

View File

@ -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 = (

View File

@ -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<ArtifactInventoryResult> => {
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<ArtifactInventorySnapshot> => {
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<ArtifactInventorySnapshot> => {
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<string, ArtifactNode>;
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<string, ArtifactNode>;
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<string, MutableOccurrence>();
const registry = new ArtifactPathRegistry();
let totalBytes = 0;
@ -302,7 +403,9 @@ const scanReader = async (
currentReader: ArtifactReader,
entry: ArtifactEntry,
logicalPath: string,
): Promise<ArtifactNode | undefined> => {
): 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 (

View File

@ -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<ArtifactInventorySnapshot> => {
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,
);
};

View File

@ -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<string> = 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<string, InvestigationWorkspace>();
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<AnalysisExecution, AnalysisError>,
): 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<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#transition.then(operation, operation);
this.#transition = result.then(

View File

@ -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<number, AnalysisError>;
retainInvestigationWorkspace(
workspace: InvestigationWorkspace,
): "added" | "duplicate";
investigationWorkspace(
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined;
investigationWorkspaces(): readonly InvestigationWorkspace[];
recordUnknown(
input: RecordUnknownInput,
): Result<ResidualUnknown, AnalysisError>;
@ -72,4 +81,5 @@ export interface BinarySessionPort extends AnalysisOperationPort {
UnknownRegistryError
>;
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
onAvailabilityChanged?(listener: () => void): () => void;
}

View File

@ -0,0 +1,193 @@
import { z } from "zod";
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import type { JsonValue } from "../domain/jsonValue.js";
const statusSchema = z.object({
open: z.boolean(),
kind: z.enum(["executable", "database", "archive", "artifact"]).optional(),
format: z.string().optional(),
capabilities: z.array(
z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
}),
),
});
export type ToolAvailabilityReason =
| "available"
| "target_required"
| "provider_missing"
| "provider_unavailable"
| "target_unsupported"
| "unsupported_host"
| "policy_disabled";
const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
swift_classes: ["list_procedures"],
get_objc_classes: ["list_names"],
get_objc_protocols: ["list_names"],
batch_decompile: ["procedure_pseudo_code"],
get_call_graph: ["procedure_callees", "procedure_callers"],
analyze_swift_types: ["list_procedures"],
find_xrefs_to_name: ["list_names", "xrefs"],
binary_overview: [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
],
analyze_function: ["analyze_function"],
trace_feature: [
"list_strings",
"list_procedures",
"xrefs",
"resolve_containing_procedure",
],
};
/** Build stable per-operation availability without hiding familiar tools. */
export const buildCapabilityInventory = (
sessionStatus: JsonValue,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
) => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map(
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
);
return TOOL_CONTRACTS.map((contract) => {
const availability = availabilityFor(
contract.name,
contract.kind,
status.open,
status.kind,
descriptors,
policy,
);
return {
name: contract.name,
surface: contract.kind,
available: availability.reason === "available",
reason: availability.reason,
remediation: availability.remediation,
annotations: {
read_only: contract.annotations.readOnlyHint ?? false,
destructive: contract.annotations.destructiveHint ?? false,
idempotent: contract.annotations.idempotentHint ?? false,
open_world: contract.annotations.openWorldHint ?? true,
},
};
}).sort((left, right) => left.name.localeCompare(right.name));
};
const availabilityFor = (
name: string,
kind: (typeof TOOL_CONTRACTS)[number]["kind"],
targetOpen: boolean,
targetKind: "executable" | "database" | "archive" | "artifact" | undefined,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
policy: {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
return {
reason: "policy_disabled",
remediation:
"Enable or grant process_capture within the administrator ceiling.",
};
if (name === "import_evidence_bundle" && policy.evidenceFileRoots === 0)
return {
reason: "policy_disabled",
remediation: "Configure or grant an evidence_read root.",
};
if (kind === "session") return { reason: "available", remediation: null };
if (!targetOpen)
return {
reason: "target_required",
remediation: "Call open_binary with a supported local target.",
};
if (
targetKind !== undefined &&
targetKind !== "executable" &&
targetKind !== "database" &&
(kind === "official-proxy" || kind === "enhanced")
)
return {
reason: "target_unsupported",
remediation:
"Inventory or extract a native executable, then call open_binary on that executable.",
};
if (kind === "enhanced") return composedAvailability(name, descriptors);
const descriptor = descriptors.get(name);
if (descriptor === undefined)
return {
reason: "provider_missing",
remediation:
"Install or configure a provider that declares this operation.",
};
if (
!descriptor.available &&
descriptor.reason?.toLowerCase().includes("require macos") === true
)
return {
reason: "unsupported_host",
remediation: descriptor.reason,
};
return descriptor.available
? { reason: "available", remediation: null }
: {
reason: "provider_unavailable",
remediation:
descriptor.reason ?? "Choose another target or configured provider.",
};
};
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
} => {
const requirements = ENHANCED_REQUIREMENTS[name];
if (requirements === undefined)
return {
reason: "provider_missing",
remediation: "No provider composition is declared for this operation.",
};
const missing = requirements.find((operation) => !descriptors.has(operation));
if (missing !== undefined)
return {
reason: "provider_missing",
remediation: `Configure a provider for required operation ${missing}.`,
};
const unavailable = requirements
.map((operation) => descriptors.get(operation))
.find((descriptor) => descriptor?.available === false);
if (unavailable !== undefined)
return {
reason:
unavailable.reason?.toLowerCase().includes("require macos") === true
? "unsupported_host"
: "provider_unavailable",
remediation:
unavailable.reason ?? "Choose another target or configured provider.",
};
return { reason: "available", remediation: null };
};

View File

@ -0,0 +1,119 @@
import { access, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parse as parseToml } from "smol-toml";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients } from "./SupportedClients.js";
export interface ClientRegistrationStatus {
readonly client: string;
readonly config_path: string;
readonly command: readonly string[];
readonly state: "aligned" | "stale" | "missing" | "invalid";
readonly remediation: string | null;
}
const objectSchema = z.record(z.string(), z.unknown());
const registrationSchema = z
.object({ command: z.string().min(1), args: z.array(z.string()).default([]) })
.passthrough();
/** Inspect supported client registrations without reading their environment. */
export const readClientRegistrationStatuses = async (
home: string,
currentCommandPath: string = resolve(process.argv[1] ?? "unknown"),
): Promise<readonly ClientRegistrationStatus[]> => {
const statuses: ClientRegistrationStatus[] = [];
for (const client of supportedClients(home)) {
if (client.format === "unsupported" || !(await exists(client.markerPath)))
continue;
try {
const content = await readFile(client.configPath, "utf8");
const document = objectSchema.parse(
client.format === "toml" ? parseToml(content) : JSON.parse(content),
);
const servers = objectSchema.parse(
document[client.format === "toml" ? "mcp_servers" : "mcpServers"] ?? {},
);
const raw = servers[PRODUCT_IDENTITY.mcpServerKey];
if (raw === undefined) {
statuses.push(status(client.name, client.configPath, [], "missing"));
continue;
}
const registration = registrationSchema.parse(raw);
const command = [registration.command, ...registration.args];
statuses.push(
status(
client.name,
client.configPath,
command,
registrationAligned(command, currentCommandPath)
? "aligned"
: "stale",
),
);
} catch (cause: unknown) {
statuses.push(
status(
client.name,
client.configPath,
[],
isMissing(cause) ? "missing" : "invalid",
),
);
}
}
return statuses.sort((left, right) =>
left.client.localeCompare(right.client),
);
};
const registrationAligned = (
command: readonly string[],
currentCommandPath: string,
): boolean => {
if (
command.length === 4 &&
command[0] === "npx" &&
command[1] === "-y" &&
command[2] === PRODUCT_IDENTITY.packageName &&
command[3] === "mcp"
)
return true;
return (
command.length === 2 &&
command[1] === "mcp" &&
resolve(command[0] ?? "") === currentCommandPath
);
};
const status = (
client: string,
configPath: string,
command: readonly string[],
state: ClientRegistrationStatus["state"],
): ClientRegistrationStatus => ({
client,
config_path: configPath,
command,
state,
remediation:
state === "aligned"
? null
: "Run rea setup to refresh this registration, then restart the client.",
});
const exists = async (path: string | undefined): Promise<boolean> => {
if (path === undefined) return false;
try {
await access(path);
return true;
} catch {
return false;
}
};
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";

View File

@ -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<Result<VersionSnapshots, AnalysisError>> => {
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<InventoryEvidencePages, AnalysisError> => {
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,

View File

@ -6,12 +6,14 @@ import {
type EvidenceFilePolicy,
} from "../domain/evidenceBundle.js";
import {
AnalysisCancelledError,
AnalysisInputError,
EvidenceIntegrityError,
InvestigationWorkspaceError,
type AnalysisError,
} from "../domain/errors.js";
import {
createLegacyInvestigationRunIdentity,
createInvestigationRunIdentity,
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
@ -25,6 +27,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 +57,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 +81,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 +125,8 @@ const continueInvestigation = async (context: {
readonly snapshots: VersionSnapshots;
readonly policy: EvidenceFilePolicy;
readonly session: BinarySessionPort | undefined;
readonly signal: AbortSignal | undefined;
readonly progress: ProgressReporter | undefined;
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
const left = targetFor(context.snapshots.left);
const right = targetFor(context.snapshots.right);
@ -110,14 +134,31 @@ 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 legacyIdentity = createLegacyInvestigationRunIdentity({
left,
right,
options: context.input.options,
});
const existing = context.initial?.runs.find(
({ run_id: id }) => id === identity.runId,
(run) =>
run.run_id === identity.runId ||
(context.input.integrity_policy === "fail" &&
!context.input.integrity_continue_approved &&
context.input.max_integrity_mismatches === 10 &&
"legacy_request_identity" in run &&
run.max_integrity_mismatches === 10 &&
run.run_id === legacyIdentity.runId),
);
if (context.initial !== null && existing?.status === "complete")
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 +168,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 +230,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 +241,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),

View File

@ -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<JsonValue> =>
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<Record<string, JsonValue>>,
logger: Logger = silentLogger,
signal?: AbortSignal,
): Promise<JsonValue> =>
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<Record<string, JsonValue>>,
snapshotPath: string | undefined,
): Promise<Result<null, AnalysisError>> => {
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<JsonValue> => {
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 <Value>(
suppliedSignal: AbortSignal | undefined,
operation: (signal: AbortSignal) => Promise<Value>,
): Promise<Value> => {
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;

View File

@ -1,6 +1,5 @@
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import { readdir } from "node:fs/promises";
import { access, readFile, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile } from "node:child_process";
@ -16,6 +15,12 @@ import {
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
import { CATALOG_IDENTITY } from "../catalogIdentity.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
import {
readClientRegistrationStatuses,
type ClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
@ -48,6 +53,9 @@ export interface DoctorHost {
linuxDemoRuntimeReady(): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
installationPaths?(): Promise<readonly string[]>;
installedSkillVersion?(): Promise<string | undefined>;
clientRegistrations?(): Promise<readonly ClientRegistrationStatus[]>;
}
/**
@ -62,6 +70,26 @@ export const runDoctor = async (
readonly healthy: boolean;
readonly hopperPath?: string;
readonly checks: readonly DoctorCheck[];
readonly identity?: {
readonly cli_package_version: string;
readonly expected_skill_version: string;
readonly sdk: typeof SDK_IDENTITY;
readonly catalog: typeof CATALOG_IDENTITY;
readonly live_server: {
readonly state: "unknown";
readonly remediation: string;
};
readonly installations: {
readonly paths: readonly string[];
readonly state: "single" | "multiple" | "unknown";
};
readonly skill: {
readonly installed_version: string | null;
readonly state: "aligned" | "stale" | "unknown";
readonly remediation: string | null;
};
readonly registrations: readonly ClientRegistrationStatus[];
};
}> => {
const checks: DoctorCheck[] = [];
checks.push(
@ -148,10 +176,59 @@ export const runDoctor = async (
classification: "config_drift",
}),
);
const installationPaths = (await host.installationPaths?.()) ?? [];
const installedSkillVersion = await host.installedSkillVersion?.();
const registrations = (await host.clientRegistrations?.()) ?? [];
for (const registration of registrations)
if (registration.state !== "aligned")
checks.push({
name: `registration:${registration.client}`,
ok: false,
classification: "config_drift",
detail: registration.config_path,
remediation:
registration.remediation ??
"Run rea setup, then restart the affected client.",
});
return {
healthy: checks.every(({ ok }) => ok),
...(hopperPath === undefined ? {} : { hopperPath }),
checks,
identity: {
cli_package_version: PRODUCT_IDENTITY.packageVersion,
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
sdk: SDK_IDENTITY,
catalog: CATALOG_IDENTITY,
live_server: {
state: "unknown",
remediation:
"Compare rea://server/identity or binary_session from the active client; an on-disk registration cannot prove the running server version.",
},
installations: {
paths: installationPaths,
state:
installationPaths.length === 0
? "unknown"
: installationPaths.length === 1
? "single"
: "multiple",
},
skill: {
installed_version: installedSkillVersion ?? null,
state:
installedSkillVersion === undefined
? "unknown"
: installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? "aligned"
: "stale",
remediation:
installedSkillVersion === undefined ||
installedSkillVersion === PRODUCT_IDENTITY.skillVersion
? null
: "Run rea setup to update the installed REA skill.",
},
registrations,
},
};
};
@ -241,10 +318,44 @@ export const systemDoctorHost = (): DoctorHost => ({
}
return paths;
},
async installationPaths() {
try {
const command = process.platform === "win32" ? "where" : "which";
const arguments_ = process.platform === "win32" ? ["rea"] : ["-a", "rea"];
return uniqueLines((await execFileAsync(command, arguments_)).stdout);
} catch {
return [];
}
},
async installedSkillVersion() {
try {
const content = await readFile(
join(
homedir(),
".agents/skills",
PRODUCT_IDENTITY.skillName,
"SKILL.md",
),
"utf8",
);
return /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1];
} catch {
return undefined;
}
},
clientRegistrations: () => readClientRegistrationStatuses(homedir()),
});
const parseMajor = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const uniqueLines = (value: string): string[] => [
...new Set(
value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0),
),
];
const check = (
name: string,
ok: boolean,

View File

@ -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<Result<readonly PermissionCeiling[], PermissionPathError>> => {
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<Result<PermissionRequest, PermissionPathError>> => {
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<Result<PermissionScope, PermissionPathError>> => {
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<Result<readonly string[], PermissionPathError>> => {
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<Result<string, PermissionPathError>> => {
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<PermissionDecision, PermissionPathError | PermissionRequiredError>
> {
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<typeof grantPermission> {
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<Result<null, PermissionPathError>> {
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<Result<null, PermissionPathError>> {
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<Result<null, PermissionPathError>> {
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<Result<PermissionAuthority, PermissionPathError>> => {
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<Result<PermissionGrant, PermissionPathError>> => {
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;

View File

@ -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<Result<PermissionAuthority, ConfigurationError>> => {
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,
},
),
);
};

View File

@ -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<ProcessCaptureErrorOptions["reason"]>;
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.",
});

View File

@ -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;

View File

@ -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,

View File

@ -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;
};

View File

@ -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<void>;
}
/** Create a monotonic, rate-bounded reporter around a surface-specific sink. */
export const createProgressReporter = (
sink: (
update: ProgressUpdate & { readonly sequence: number },
) => Promise<void>,
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(),
};

View File

@ -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<typeof storeSchema>;
/** 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<ProjectPermissionStore | null, ProjectPermissionStoreError>
> => {
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<Result<ProjectPermissionStore, ProjectPermissionStoreError>> => {
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";

View File

@ -8,7 +8,7 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
@ -23,6 +23,8 @@ import {
} from "./LinuxHopper.js";
import { installMacHopper } from "./MacHopper.js";
import { configureDetectedClients } from "./SetupClients.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
export type { SetupClient } from "./SupportedClients.js";
import { installCanonicalSkill } from "./SetupSkill.js";
import { setupPlan } from "./SetupPlan.js";
import {
@ -39,13 +41,6 @@ const registrationCommand = (): readonly string[] =>
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
@ -282,50 +277,6 @@ export const detectClients = async (
return detected;
};
/** Describe every client location that setup or uninstall may own. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml" as const,
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported" as const,
},
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,

View File

@ -0,0 +1,53 @@
import { join } from "node:path";
/** One supported client configuration location. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Describe every client location that setup, doctor, or uninstall may inspect. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml",
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported",
},
];

View File

@ -7,7 +7,7 @@ import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./Setup.js";
import { supportedClients, type SetupClient } from "./SupportedClients.js";
interface ManagedPathStats {
isSymbolicLink(): boolean;

View File

@ -52,6 +52,8 @@ export type UpgradeResult =
readonly versionCheck: "available" | "unavailable";
readonly installMethod: "npm";
readonly command: typeof UPGRADE_COMMAND;
readonly clientRestartRequired: true;
readonly remediation: string;
}
| {
readonly status: "failed";
@ -102,6 +104,9 @@ export const runUpgrade = async (
versionCheck: latestVersion === undefined ? "unavailable" : "available",
installMethod: "npm",
command: UPGRADE_COMMAND,
clientRestartRequired: true,
remediation:
"Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.",
};
};

View File

@ -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),
]),

View File

@ -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<Record<string, JsonValue>>,
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(

129
src/catalogIdentity.ts Normal file
View File

@ -0,0 +1,129 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
import { TOOL_CONTRACTS } from "./contracts/toolContracts.js";
/** Explicit CLI command inventory, kept separate from MCP tool counts. */
export const CLI_COMMAND_NAMES = [
"analyze",
"inspect",
"decompile",
"setup",
"doctor",
"uninstall",
"upgrade",
"xrefs",
"trace",
"capabilities",
"providers",
"function",
"search",
"import-reference-source",
"inventory-artifact",
"extract-artifact",
"inspect-macho",
"inspect-signature",
"list-architectures",
"inspect-plist",
"demangle-swift",
"evidence-import",
"evidence-export",
"compare",
"investigate-versions",
"capture-process",
"compare-process-captures",
"policy",
] as const;
const toolCatalog = TOOL_CONTRACTS.map((contract) => ({
name: contract.name,
surface: contract.kind,
description: contract.description,
annotations: contract.annotations,
input_schema: z.toJSONSchema(contract.inputSchema, {
unrepresentable: "any",
}),
output_schema: z.toJSONSchema(contract.outputSchema, {
unrepresentable: "any",
}),
})).sort((left, right) => left.name.localeCompare(right.name));
const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({
name: contract.name,
title: contract.title,
description: contract.description,
arguments: contract.arguments,
steps: contract.steps,
})).sort((left, right) => left.name.localeCompare(right.name));
const resourceCatalog = [
{ name: "server-identity", uri: "rea://server/identity" },
{ name: "active-residual-unknowns", uri: "rea://unknowns/active" },
] as const;
const resourceTemplateCatalog = [
{ name: "session-evidence", uri_template: "rea://evidence/{evidenceId}" },
{
name: "evidence-section",
uri_template: "rea://evidence/{evidenceId}/section/{section}",
},
{ name: "residual-unknown", uri_template: "rea://unknown/{unknownId}" },
{
name: "analysis-snapshot",
uri_template: "rea://snapshot/{snapshotDigest}",
},
{
name: "artifact-page",
uri_template: "rea://artifact/{manifestId}/{collection}",
},
{
name: "function-dossier",
uri_template: "rea://function/{targetSha256}/{address}",
},
{
name: "investigation-workspace-revision",
uri_template: "rea://workspace/{workspaceId}/revision/{revision}",
},
] as const;
const digest = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Catalog is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};
/** Stable, schema-sensitive identity for every public REA surface. */
export const CATALOG_IDENTITY = {
counts: {
cli_commands: CLI_COMMAND_NAMES.length,
mcp_tools: toolCatalog.length,
mcp_prompts: promptCatalog.length,
mcp_resources: resourceCatalog.length,
mcp_resource_templates: resourceTemplateCatalog.length,
},
digests: {
tools_sha256: digest(toolCatalog),
prompts_sha256: digest(promptCatalog),
resources_sha256: digest({ resourceCatalog, resourceTemplateCatalog }),
combined_sha256: digest({
cli: CLI_COMMAND_NAMES,
tools: toolCatalog,
prompts: promptCatalog,
resources: resourceCatalog,
resource_templates: resourceTemplateCatalog,
}),
},
tools: toolCatalog.map(({ name, surface, annotations }) => ({
name,
surface,
annotations: {
read_only: annotations.readOnlyHint ?? false,
destructive: annotations.destructiveHint ?? false,
idempotent: annotations.idempotentHint ?? false,
open_world: annotations.openWorldHint ?? true,
},
})),
} as const;

View File

@ -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<typeof Cli.create> => {
: 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<typeof Cli.create> => {
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,
),

View File

@ -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<AnalysisError | undefined> => {
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),

View File

@ -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

160
src/cliPolicyCommands.ts Normal file
View File

@ -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<typeof Cli.create>,
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<typeof projectAnalysisError>[0]) => ({
error: "Policy command failed",
...projectAnalysisError(error),
});

View File

@ -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<Omit<PermissionCeiling, "capability" | "roots">> = {},
): 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<readonly string[], ConfigurationError> => {
@ -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,
});
};

View File

@ -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({

View File

@ -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 extends string>(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,
);

View File

@ -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",

View File

@ -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(),

View File

@ -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),
);

View File

@ -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<typeof artifactEdgeSchema>;
/** Parsed artifact occurrence. */
export type ArtifactOccurrence = z.infer<typeof artifactOccurrenceSchema>;
export type IntegrityContradiction = z.infer<
typeof integrityContradictionSchema
>;
/** Parsed deterministic artifact graph manifest. */
export type ArtifactGraphManifest = z.infer<typeof artifactGraphManifestSchema>;

View File

@ -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");

View File

@ -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,
}),

View File

@ -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<Record<string, JsonValue>> {
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<Record<string, JsonValue>>;
}
/** 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<Record<string, JsonValue>> | 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<Record<string, JsonValue>> => ({
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<Record<string, JsonValue>> => ({
...(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<AnalysisErrorProjection["code"]> = 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<Record<AnalysisErrorTag, true>>;
const assertKnownTag = (tag: AnalysisErrorTag): void => {

View File

@ -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
@ -85,13 +100,16 @@ const stageSchema = z.enum([
"find_changed_behavior",
]);
export const investigationRunSchema = z.object({
const investigationRunBaseSchema = z.object({
schema_version: z.literal(1),
run_id: z.string().regex(/^run_[a-f0-9]{64}$/u),
request_sha256: digestSchema,
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),
@ -101,6 +119,31 @@ export const investigationRunSchema = z.object({
limitations: z.array(z.string().max(4_096)).max(100),
});
export const investigationRunSchema = z
.union([
investigationRunBaseSchema.extend({
legacy_request_identity: z.literal(true),
}),
investigationRunBaseSchema,
])
.superRefine((run, context) => {
if (!("legacy_request_identity" in run)) return;
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
if (
run.integrity_policy !== "fail" ||
run.integrity_continue_approved ||
run.max_integrity_mismatches !== 10 ||
run.run_id !== legacyIdentity.runId ||
run.request_sha256 !== legacyIdentity.requestSha256
)
context.addIssue({
code: "custom",
path: ["legacy_request_identity"],
message:
"Legacy request identity is valid only for migrated strict runs",
});
});
export type InvestigationRun = z.infer<typeof investigationRunSchema>;
export const investigationRunSummarySchema = z.object({
@ -128,6 +171,20 @@ const investigationWorkspaceSchema = z.object({
runs: z.array(investigationRunSchema).max(1_000),
});
const legacyInvestigationRunSchema = investigationRunBaseSchema
.omit({
integrity_policy: true,
integrity_continue_approved: true,
max_integrity_mismatches: true,
})
.strict();
const legacyInvestigationWorkspaceSchema = investigationWorkspaceSchema
.extend({
runs: z.array(legacyInvestigationRunSchema).max(1_000),
})
.strict();
export type InvestigationWorkspace = z.infer<
typeof investigationWorkspaceSchema
>;
@ -137,12 +194,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 };
};
@ -181,6 +246,7 @@ export const reviseInvestigationWorkspace = (
export const parseInvestigationWorkspace = (
input: unknown,
): InvestigationWorkspace => {
if (isLegacyWorkspace(input)) return migrateLegacyWorkspace(input);
const parsed = investigationWorkspaceSchema.parse(input);
const bundle = parseEvidenceBundle(parsed.bundle);
const canonical = buildWorkspace({
@ -242,9 +308,16 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
);
for (const run of workspace.runs) {
const expectedIdentity = createInvestigationRunIdentity(run);
const legacyIdentity = createLegacyInvestigationRunIdentity(run);
if (
run.run_id !== expectedIdentity.runId ||
run.request_sha256 !== expectedIdentity.requestSha256
(run.run_id !== expectedIdentity.runId ||
run.request_sha256 !== expectedIdentity.requestSha256) &&
(!("legacy_request_identity" in run) ||
run.integrity_policy !== "fail" ||
run.integrity_continue_approved ||
run.max_integrity_mismatches !== 10 ||
run.run_id !== legacyIdentity.runId ||
run.request_sha256 !== legacyIdentity.requestSha256)
)
throw new TypeError("Investigation run identity is invalid");
validateRunStages(run);
@ -261,6 +334,85 @@ const validateRunReferences = (workspace: InvestigationWorkspace): void => {
}
};
/** Derive the pre-integrity-policy run identity for strict workspace migration. */
export const createLegacyInvestigationRunIdentity = (input: {
readonly left: InvestigationRunTarget;
readonly right: InvestigationRunTarget;
readonly options: InvestigationRunOptions;
}): { 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,
});
return { runId: `run_${requestSha256}`, requestSha256 };
};
const isLegacyWorkspace = (input: unknown): boolean => {
if (typeof input !== "object" || input === null || !("runs" in input))
return false;
const runs = input.runs;
return (
Array.isArray(runs) &&
runs.some(
(run) =>
typeof run === "object" && run !== null && !("integrity_policy" in run),
)
);
};
const migrateLegacyWorkspace = (input: unknown): InvestigationWorkspace => {
const parsed = legacyInvestigationWorkspaceSchema.parse(input);
const bundle = parseEvidenceBundle(parsed.bundle);
const runs = [...parsed.runs].sort((left, right) =>
left.run_id.localeCompare(right.run_id),
);
const semantic = {
workspace_version: 1,
workspace_id: parsed.workspace_id,
name: parsed.name,
revision: parsed.revision,
previous_revision_digest: parsed.previous_revision_digest,
bundle,
runs,
} satisfies JsonValue;
if (parsed.workspace_id !== workspaceIdFor(parsed.name))
throw new TypeError("Investigation workspace ID does not match its name");
if (parsed.revision_digest !== `wrev_${digestCanonical(semantic)}`)
throw new TypeError("Investigation workspace revision digest is invalid");
const canonical = legacyInvestigationWorkspaceSchema.parse({
...semantic,
revision_digest: parsed.revision_digest,
});
if (JSON.stringify(parsed) !== JSON.stringify(canonical))
throw new TypeError("Investigation workspace is not canonical");
for (const run of runs) {
const identity = createLegacyInvestigationRunIdentity(run);
if (
run.run_id !== identity.runId ||
run.request_sha256 !== identity.requestSha256
)
throw new TypeError("Investigation run identity is invalid");
}
const migrated = buildWorkspace({
workspaceId: parsed.workspace_id,
name: parsed.name,
revision: parsed.revision + 1,
previousRevisionDigest: parsed.revision_digest,
bundle,
runs: runs.map((run) => ({
...run,
legacy_request_identity: true as const,
integrity_policy: "fail" as const,
integrity_continue_approved: false,
max_integrity_mismatches: 10,
})),
});
validateRunReferences(migrated);
return migrated;
};
const validateRunStages = (run: InvestigationRun): void => {
const expected = [
"inventory_left",

View File

@ -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<string>;
readonly consumed_grant_ids: ReadonlySet<string>;
}
/** 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<PermissionPolicy, PermissionGrantError> => {
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<PermissionDecision, { readonly allowed: true }>,
): 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));

View File

@ -12,6 +12,7 @@ export const comparisonStatusSchema = z.enum([
"changed",
"truncated",
"unknown",
"contradiction",
]);
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;

View File

@ -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";

View File

@ -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),

View File

@ -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",
};
};

View File

@ -0,0 +1,9 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "1.3.0",
serverSdkVersion: "2.0.0-beta.4",
clientSdkVersion: "2.0.0-beta.4",
coreSdkVersion: "2.0.0-beta.4",
skillVersion: "12",
} as const;

View File

@ -5,12 +5,20 @@
*/
export const PRODUCT_IDENTITY = {
displayName: "REA",
packageName: "rea-agents",
packageVersion: "1.1.0",
packageName: PACKAGE_METADATA.name,
packageVersion: PACKAGE_METADATA.version,
cliBinary: "rea",
mcpCommand: "npx -y rea-agents mcp",
mcpServerKey: "rea",
skillName: "rea-analysis",
skillVersion: "10",
skillVersion: PACKAGE_METADATA.skillVersion,
configFileName: "rea.json",
} as const;
/** Exact SDK package identities, distinct from negotiated protocol version. */
export const SDK_IDENTITY = {
server: PACKAGE_METADATA.serverSdkVersion,
client_test: PACKAGE_METADATA.clientSdkVersion,
core: PACKAGE_METADATA.coreSdkVersion,
} as const;
import { PACKAGE_METADATA } from "./generatedPackageMetadata.js";

View File

@ -13,6 +13,8 @@ import { createBinarySession } from "./application/runtime.js";
import { createServer } from "./server/createServer.js";
import { createLogger } from "./logger.js";
import { projectAnalysisError } from "./domain/errors.js";
import { readProjectPermissionStore } from "./application/ProjectPermissionStore.js";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
const MCP_CONNECTION_LOST =
"REA lost its MCP client connection. Restart REA from your MCP client.";
@ -30,6 +32,7 @@ interface RuntimeDependencies {
readonly writeStderr: (text: string) => void;
readonly setExitCode: (code: number) => void;
readonly registerShutdown: (handler: () => void) => void;
readonly registerReload?: (handler: () => void) => () => void;
}
const runtimeDependencies = (): RuntimeDependencies => ({
@ -45,6 +48,10 @@ const runtimeDependencies = (): RuntimeDependencies => ({
process.stdin.once("end", handler);
process.stdin.once("close", handler);
},
registerReload: (handler) => {
process.on("SIGHUP", handler);
return () => process.off("SIGHUP", handler);
},
});
/**
@ -62,6 +69,15 @@ export const run = async (
}
const logger = createLogger("mcp", config.value.logLevel);
const serverLogger = logger.child({ layer: "server" });
const permissionAuthority = await loadConfiguredPermissionAuthority(
config.value,
);
if (!permissionAuthority.ok) {
dependencies.writeStderr(
`${projectAnalysisError(permissionAuthority.error).message}\n`,
);
return 1;
}
const session = createBinarySession(config.value, logger);
if (config.value.hopperTargetPath !== undefined) {
@ -80,17 +96,46 @@ export const run = async (
return 1;
}
}
let currentConfig = config.value;
const runtimeProcessPolicy = {
...config.value.processExecutionPolicy,
executableRoots: [...config.value.processExecutionPolicy.executableRoots],
workingRoots: [...config.value.processExecutionPolicy.workingRoots],
allowedEnvironment: [
...config.value.processExecutionPolicy.allowedEnvironment,
],
};
const runtimeEvidencePolicy = {
...config.value.evidenceFilePolicy,
roots: [...config.value.evidenceFilePolicy.roots],
};
const runtimeSnapshotPolicy = {
...config.value.analysisSnapshotFilePolicy,
roots: [...config.value.analysisSnapshotFilePolicy.roots],
};
const runtimeInvestigationRoots = [...config.value.investigationInputRoots];
const liveServers = new Set<ReturnType<typeof createServer>>();
let handle: StdioServerHandle;
try {
handle = dependencies.serve(
() =>
createServer(session, session, {
() => {
const server = createServer(session, session, {
logger,
processPolicy: config.value.processExecutionPolicy,
evidenceFilePolicy: config.value.evidenceFilePolicy,
investigationInputRoots: config.value.investigationInputRoots,
analysisSnapshotFilePolicy: config.value.analysisSnapshotFilePolicy,
}),
processPolicy: runtimeProcessPolicy,
evidenceFilePolicy: runtimeEvidencePolicy,
investigationInputRoots: runtimeInvestigationRoots,
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
permissionAuthority: permissionAuthority.value,
artifactIntegrityContinueEnabled: () =>
currentConfig.artifactIntegrityContinueEnabled,
availabilityPolicy: () => ({
processCaptureEnabled: currentConfig.processExecutionPolicy.enabled,
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
}),
});
liveServers.add(server);
return server;
},
{
onerror: () => {
serverLogger.error(MCP_CONNECTION_LOST);
@ -105,11 +150,90 @@ export const run = async (
return 1;
}
const unregisterReload =
dependencies.registerReload?.(() => {
void (async () => {
const refreshed = parseConfig(dependencies.env);
if (!refreshed.ok) {
serverLogger.error("Reloaded permission policy is invalid");
return;
}
const reloaded = await permissionAuthority.value.reload(
refreshed.value.permissionCeilings,
);
if (!reloaded.ok) {
serverLogger.error(
"Reloaded permission ceiling could not be applied",
);
return;
}
const administrators =
await permissionAuthority.value.replaceAdministratorGrants(
refreshed.value.administratorPermissionGrants,
);
if (!administrators.ok) {
serverLogger.error(
"Reloaded administrator grants could not be applied",
);
return;
}
if (
refreshed.value.permissionProjectRoot !== undefined &&
refreshed.value.permissionProjectStore !== undefined
) {
const project = await readProjectPermissionStore(
refreshed.value.permissionProjectStore,
refreshed.value.permissionProjectRoot,
);
if (!project.ok) {
serverLogger.error("Reloaded project grants could not be read");
return;
}
const projects = await permissionAuthority.value.replaceProjectGrants(
project.value?.grants ?? [],
);
if (!projects.ok) {
serverLogger.error("Reloaded project grants could not be applied");
return;
}
} else {
const projects = await permissionAuthority.value.replaceProjectGrants(
[],
);
if (!projects.ok) {
serverLogger.error("Reloaded project grants could not be cleared");
return;
}
}
currentConfig = refreshed.value;
Object.assign(
runtimeProcessPolicy,
refreshed.value.processExecutionPolicy,
);
Object.assign(
runtimeEvidencePolicy,
refreshed.value.evidenceFilePolicy,
);
Object.assign(
runtimeSnapshotPolicy,
refreshed.value.analysisSnapshotFilePolicy,
);
runtimeInvestigationRoots.splice(
0,
runtimeInvestigationRoots.length,
...refreshed.value.investigationInputRoots,
);
for (const server of liveServers) server.sendToolListChanged();
})();
}) ?? (() => undefined);
let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
unregisterReload();
await handle.close();
await session.close();
permissionAuthority.value.clearSessionGrants();
})();
return shutdownPromise;
};

View File

@ -12,6 +12,9 @@ import { silentLogger, type Logger } from "../logger.js";
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
import { registerGuidedPrompts } from "./registerPrompts.js";
import { registerEvidenceResources } from "./registerEvidenceResources.js";
import { createServerIdentity } from "../serverIdentity.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
export interface CreateServerOptions {
readonly logger?: Logger;
@ -19,6 +22,12 @@ export interface CreateServerOptions {
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly investigationInputRoots?: readonly string[];
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
readonly artifactIntegrityContinueEnabled?: () => boolean;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
/**
@ -31,25 +40,71 @@ export const createServer = (
session?: BinarySessionPort,
options: CreateServerOptions = {},
): McpServer => {
const startedAt = new Date().toISOString();
const logger = options.logger ?? silentLogger;
const server = new McpServer(
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
{
capabilities: { tools: {} },
name: PRODUCT_IDENTITY.mcpServerKey,
version: PRODUCT_IDENTITY.packageVersion,
},
{
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true },
},
instructions:
session === undefined
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
},
);
session?.onAvailabilityChanged?.(() => server.sendToolListChanged());
server.registerResource(
"server-identity",
"rea://server/identity",
{
title: "REA server identity",
description: "Live package, SDK, protocol, and catalog identity.",
mimeType: "application/json",
},
(uri) => {
const client = server.server.getClientVersion();
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(
createServerIdentity({
startedAt,
...(client === undefined ? {} : { client }),
...(server.server.getNegotiatedProtocolVersion() === undefined
? {}
: {
protocolVersion:
server.server.getNegotiatedProtocolVersion(),
}),
}),
null,
2,
),
},
],
};
},
);
const toolLogger = logger.child({ layer: "server" });
const activeTarget =
session === undefined ? undefined : () => session.activeTarget();
const recordEvidence =
session === undefined
? undefined
: (evidence: Parameters<typeof session.recordEvidence>[0]) =>
session.recordEvidence(evidence);
: (evidence: Parameters<typeof session.recordEvidence>[0]) => {
const recorded = session.recordEvidence(evidence);
if (recorded.ok && recorded.value === "added")
server.sendResourceListChanged();
return recorded;
};
registerOfficialTools(server, analysis, {
logger: toolLogger,
activeTarget,
@ -77,9 +132,17 @@ export const createServer = (
logger: toolLogger,
activeTarget,
recordEvidence,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerGuidedPrompts(server, analysis, session);
if (session !== undefined)
registerSessionTools(server, session, toolLogger, options);
if (session !== undefined) {
registerEvidenceResources(server, session);
registerSessionTools(server, session, toolLogger, {
...options,
startedAt,
});
}
return server;
};

46
src/server/mcpProgress.ts Normal file
View File

@ -0,0 +1,46 @@
import type { ProgressReporter } from "../application/ProgressReporter.js";
import {
createProgressReporter,
silentProgressReporter,
} from "../application/ProgressReporter.js";
interface McpProgressContext {
readonly mcpReq: {
readonly _meta?: { readonly progressToken?: string | number | undefined };
notify(notification: {
readonly method: "notifications/progress";
readonly params: {
readonly progressToken: string | number;
readonly progress: number;
readonly total?: number;
readonly message?: string;
};
}): Promise<void>;
};
}
/** Adapt the request's negotiated MCP progress token to application progress. */
export const mcpProgressReporter = (
context: McpProgressContext,
): ProgressReporter => {
const token = context.mcpReq._meta?.progressToken;
if (token === undefined) return silentProgressReporter;
return createProgressReporter(
async (update) => {
try {
await context.mcpReq.notify({
method: "notifications/progress",
params: {
progressToken: token,
progress: update.completed,
...(update.total === null ? {} : { total: update.total }),
message: `${update.phase}: ${update.message}`,
},
});
} catch {
// Progress is observational; transport failure cannot change tool truth.
}
},
{ minimumIntervalMs: 100 },
);
};

View File

@ -14,6 +14,7 @@ import { resolveSessionEvidence } from "./sessionEvidence.js";
import { ARTIFACT_COMPARISON_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register Evidence-backed deterministic artifact comparison. */
export const registerArtifactComparisonTool = (
@ -24,18 +25,17 @@ export const registerArtifactComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = artifactComparisonInputSchema.parse(input);
const left = resolveSessionEvidence(session, parsed.left);
if (!left.ok) return toCallToolResult(left, contract);
const right = resolveSessionEvidence(session, parsed.right);
if (!right.ok) return toCallToolResult(right, contract);
const comparison = compareArtifacts(
left.value,
right.value,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareArtifacts(left.value, right.value, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const leftEvidenceIds = evidenceIds(parsed.left);
const rightEvidenceIds = evidenceIds(parsed.right);
const evidence = createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {

View File

@ -6,6 +6,7 @@ import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
import { registerEvidenceTools } from "./registerEvidenceTools.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
/** Register deterministic artifact inventory and safe extraction operations. */
export const registerArtifactTools = (
@ -15,6 +16,7 @@ export const registerArtifactTools = (
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly permissionAuthority?: PermissionAuthority;
},
): void => {
registerEvidenceTools(server, analysis, ARTIFACT_TOOL_CONTRACTS, options);

View File

@ -14,6 +14,7 @@ import { BUNDLE_COMPARISON_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { isSessionEvidence } from "./sessionEvidence.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register canonical Evidence bundle comparison. */
export const registerBundleComparisonTool = (
@ -24,7 +25,7 @@ export const registerBundleComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = bundleComparisonInputSchema.parse(input);
const sourceIds = [
...new Set([
@ -63,13 +64,17 @@ export const registerBundleComparisonTool = (
),
contract,
);
const comparison = compareBundles(
parsed.left,
parsed.right,
parsed.record_pairs,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareBundles(
parsed.left,
parsed.right,
parsed.record_pairs,
parsed.offset,
parsed.limit,
),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const evidence = createEvidence(undefined, BUNDLE_COMPARISON_PROVIDER, {
predicateType: "rea.bundle-comparison/v1",
operation: contract.name,

View File

@ -16,6 +16,7 @@ import { createEvidence } from "../domain/evidence.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { UnknownRegistryError } from "../domain/errors.js";
import { mcpProgressReporter } from "./mcpProgress.js";
/** Optional session services used by enhanced tool registration. */
export interface EnhancedToolRegistration {
@ -69,6 +70,13 @@ const registerEnhancedTool = (
annotations: contract.annotations,
},
async (input, context) => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: name,
completed: 0,
total: 1,
message: "started",
});
const parsedInput = jsonObjectSchema.parse(
enhancedInputSchemas[name].parse(input),
);
@ -76,6 +84,13 @@ const registerEnhancedTool = (
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
);
await progress.report({
phase: name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
if (result.ok) {
const evidence = createEvidence(
registration.activeTarget?.(),

View File

@ -0,0 +1,398 @@
import {
ResourceNotFoundError,
ResourceTemplate,
type McpServer,
} from "@modelcontextprotocol/server";
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import type { BinarySessionPort } from "../application/BinarySessionPort.js";
import type { JsonValue } from "../domain/jsonValue.js";
const evidenceUri = (evidenceId: string): string =>
`rea://evidence/${evidenceId}`;
/** Expose immutable session-owned Evidence v2 records as MCP resources. */
export const registerEvidenceResources = (
server: McpServer,
session: BinarySessionPort,
): void => {
server.registerResource(
"investigation-workspace-revision",
new ResourceTemplate("rea://workspace/{workspaceId}/revision/{revision}", {
list: () => ({
resources: session.investigationWorkspaces().map((workspace) => ({
uri: workspaceUri(workspace.workspace_id, workspace.revision),
name: `${workspace.workspace_id} revision ${String(workspace.revision)}`,
title: `${workspace.name} revision ${String(workspace.revision)}`,
description: "Immutable CAS-linked investigation workspace revision.",
mimeType: "application/json",
})),
}),
complete: {
workspaceId: (prefix) =>
[
...new Set(
session
.investigationWorkspaces()
.map(({ workspace_id }) => workspace_id),
),
].filter((workspaceId) => workspaceId.startsWith(prefix)),
revision: (prefix, context) =>
session
.investigationWorkspaces()
.filter(
({ workspace_id }) =>
context?.arguments?.workspaceId === workspace_id,
)
.map(({ revision }) => String(revision))
.filter((revision) => revision.startsWith(prefix)),
},
}),
{
title: "Investigation workspace revision",
description: "Immutable session-retained workspace with CAS linkage.",
mimeType: "application/json",
},
(uri, variables) => {
const workspaceId = stringVariable(variables.workspaceId, uri.href);
const revisionText = stringVariable(variables.revision, uri.href);
if (!/^\d+$/u.test(revisionText))
throw new ResourceNotFoundError(uri.href);
const workspace = session.investigationWorkspace(
workspaceId,
Number(revisionText),
);
if (workspace === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, workspace);
},
);
server.registerResource(
"session-evidence",
new ResourceTemplate("rea://evidence/{evidenceId}", {
list: () => ({
resources: session.exportEvidenceBundle().records.map((evidence) => ({
uri: evidenceUri(evidence.evidence_id),
name: evidence.evidence_id,
title: `${evidence.operation} evidence`,
description: `Session-owned Evidence v2 record for ${evidence.operation}.`,
mimeType: "application/json",
})),
}),
complete: {
evidenceId: (prefix) =>
session
.exportEvidenceBundle()
.records.map(({ evidence_id }) => evidence_id)
.filter((evidenceId) => evidenceId.startsWith(prefix)),
},
}),
{
title: "Session evidence",
description: "Immutable Evidence v2 records owned by this REA session.",
mimeType: "application/json",
},
(uri, variables) => {
const evidenceId = variables.evidenceId;
if (typeof evidenceId !== "string")
throw new ResourceNotFoundError(uri.href);
const evidence = session.evidenceById(evidenceId);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(evidence, null, 2),
},
],
};
},
);
server.registerResource(
"active-residual-unknowns",
"rea://unknowns/active",
{
title: "Active residual unknowns",
description: "Current unresolved session-owned residual unknown heads.",
mimeType: "application/json",
},
(uri) =>
jsonResource(
uri.href,
session.listUnknowns().filter(({ status }) => status !== "resolved"),
),
);
server.registerResource(
"analysis-snapshot",
new ResourceTemplate("rea://snapshot/{snapshotDigest}", {
list: () => {
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return { resources: [] };
const digest = contentDigest(snapshot.value);
return {
resources: [
{
uri: `rea://snapshot/${digest}`,
name: digest,
title: "Current analysis snapshot",
description: "Immutable projection of the current target cache.",
mimeType: "application/json",
},
],
};
},
}),
{
title: "Analysis snapshot",
description:
"Current provider-neutral analysis snapshot by content digest.",
mimeType: "application/json",
},
(uri, variables) => {
const requested = stringVariable(variables.snapshotDigest, uri.href);
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok || contentDigest(snapshot.value) !== requested)
throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, snapshot.value);
},
);
server.registerResource(
"artifact-page",
new ResourceTemplate("rea://artifact/{manifestId}/{collection}", {
list: undefined,
complete: {
collection: (prefix) =>
["nodes", "occurrences", "edges"].filter((value) =>
value.startsWith(prefix),
),
},
}),
{
title: "Artifact graph page",
description: "Deterministically paged artifact graph collection.",
mimeType: "application/json",
},
(uri, variables) => {
const manifestId = stringVariable(variables.manifestId, uri.href);
const collection = stringVariable(variables.collection, uri.href);
if (!ARTIFACT_COLLECTIONS.has(collection))
throw new ResourceNotFoundError(uri.href);
const evidence = session
.exportEvidenceBundle()
.records.find(
(record) =>
artifactManifestId(record.normalized_result) === manifestId,
);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
const result = objectValue(evidence.normalized_result);
const page = result?.[collection];
if (page === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, {
evidence_id: evidence.evidence_id,
manifest_id: manifestId,
collection,
page,
provider: evidence.provider,
limitations: evidence.limitations,
});
},
);
server.registerResource(
"function-dossier",
new ResourceTemplate("rea://function/{targetSha256}/{address}", {
list: undefined,
}),
{
title: "Function dossier",
description: "Session-owned analyze_function dossier with provenance.",
mimeType: "application/json",
},
(uri, variables) => {
const targetSha256 = stringVariable(variables.targetSha256, uri.href);
const address = stringVariable(variables.address, uri.href);
const evidence = session
.exportEvidenceBundle()
.records.find(
(record) =>
record.operation === "analyze_function" &&
record.subject?.digest.sha256 === targetSha256 &&
functionAddress(record.normalized_result) === address,
);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, evidence);
},
);
server.registerResource(
"evidence-section",
new ResourceTemplate("rea://evidence/{evidenceId}/section/{section}", {
list: undefined,
complete: {
evidenceId: (prefix) => evidenceIds(session, prefix),
section: (prefix) =>
EVIDENCE_SECTIONS.filter((section) => section.startsWith(prefix)),
},
}),
{
title: "Evidence section",
description:
"One independently useful bounded section of session Evidence v2.",
mimeType: "application/json",
},
(uri, variables) => {
const evidenceId = stringVariable(variables.evidenceId, uri.href);
const section = stringVariable(variables.section, uri.href);
const evidence = session.evidenceById(evidenceId);
if (evidence === undefined) throw new ResourceNotFoundError(uri.href);
const value = evidenceSection(evidence.normalized_result, section);
if (value === undefined) throw new ResourceNotFoundError(uri.href);
return jsonResource(uri.href, {
evidence_id: evidence.evidence_id,
operation: evidence.operation,
authority: evidence.authority,
confidence: evidence.confidence,
provider: evidence.provider,
limitations: evidence.limitations,
section,
value,
});
},
);
server.registerResource(
"residual-unknown",
new ResourceTemplate("rea://unknown/{unknownId}", {
list: () => ({
resources: session.listUnknowns().map((unknown) => ({
uri: `rea://unknown/${unknown.unknown_id}`,
name: unknown.unknown_id,
title: unknown.question,
description: `Current ${unknown.status} residual unknown revision.`,
mimeType: "application/json",
})),
}),
complete: {
unknownId: (prefix) =>
session
.listUnknowns()
.map(({ unknown_id }) => unknown_id)
.filter((unknownId) => unknownId.startsWith(prefix)),
},
}),
{
title: "Residual unknown",
description: "Current session-owned residual unknown head.",
mimeType: "application/json",
},
(uri, variables) => {
const unknownId = stringVariable(variables.unknownId, uri.href);
const unknown = session
.listUnknowns()
.find(({ unknown_id }) => unknown_id === unknownId);
if (unknown === undefined) throw new ResourceNotFoundError(uri.href);
const history = session
.exportEvidenceBundle()
.unknowns.filter(({ unknown_id }) => unknown_id === unknownId);
return jsonResource(uri.href, { current: unknown, history });
},
);
};
const workspaceUri = (workspaceId: string, revision: number): string =>
`rea://workspace/${workspaceId}/revision/${String(revision)}`;
const EVIDENCE_SECTIONS = [
"result",
"terminal",
"filesystem",
"process",
"protocol",
"nodes",
"occurrences",
"edges",
] as const;
const evidenceIds = (session: BinarySessionPort, prefix: string): string[] =>
session
.exportEvidenceBundle()
.records.map(({ evidence_id }) => evidence_id)
.filter((evidenceId) => evidenceId.startsWith(prefix));
const stringVariable = (value: unknown, uri: string): string => {
if (typeof value !== "string") throw new ResourceNotFoundError(uri);
return value;
};
const evidenceSection = (
result: JsonValue,
section: string,
): JsonValue | undefined => {
if (section === "result") return result;
if (typeof result !== "object" || result === null || Array.isArray(result))
return undefined;
if (section === "terminal")
return pick(result, ["frames", "rendered_frames", "interaction_events"]);
if (section === "filesystem")
return pick(result, [
"filesystem_checkpoints",
"filesystem_effects",
"files_before",
"files_after",
]);
if (section === "process")
return pick(result, ["process_samples", "exit", "settlement", "cleanup"]);
if (section === "protocol")
return pick(result, ["protocol_events", "shim_events"]);
const value = result[section];
return value === undefined ? undefined : value;
};
const pick = (
result: Readonly<Record<string, JsonValue>>,
names: readonly string[],
): JsonValue | undefined => {
const entries = names.flatMap((name) =>
result[name] === undefined ? [] : [[name, result[name]] as const],
);
return entries.length === 0 ? undefined : Object.fromEntries(entries);
};
const jsonResource = (uri: string, value: JsonValue) => ({
contents: [
{
uri,
mimeType: "application/json" as const,
text: JSON.stringify(value, null, 2),
},
],
});
const ARTIFACT_COLLECTIONS = new Set(["nodes", "occurrences", "edges"]);
const objectValue = (
value: JsonValue,
): Readonly<Record<string, JsonValue>> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value)
? value
: undefined;
const artifactManifestId = (value: JsonValue): string | undefined => {
const manifest = objectValue(objectValue(value)?.manifest ?? null);
return typeof manifest?.manifest_id === "string"
? manifest.manifest_id
: undefined;
};
const functionAddress = (value: JsonValue): string | undefined => {
const result = objectValue(value);
const procedure = objectValue(result?.procedure ?? null);
const candidate = procedure?.address ?? result?.address;
return typeof candidate === "string" ? candidate : undefined;
};
const contentDigest = (value: JsonValue): string => {
const encoded = canonicalize(value);
if (encoded === undefined)
throw new TypeError("Resource is not canonical JSON");
return createHash("sha256").update(encoded).digest("hex");
};

View File

@ -13,11 +13,19 @@ import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
} from "../domain/errors.js";
import { err } from "../domain/result.js";
interface EvidenceToolRegistration {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
readonly permissionAuthority?: PermissionAuthority;
}
/** Register provider-backed contracts that return atomic Evidence v2 observations. */
@ -32,17 +40,52 @@ export const registerEvidenceTools = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const parameters = jsonObjectSchema.parse(
contract.inputSchema.parse(input),
);
if (options.permissionAuthority !== undefined) {
const request = permissionRequest(contract.name, parameters);
if (request !== undefined) {
const authorized = await options.permissionAuthority.authorize(
request,
contract.name === "extract_artifact" ? "write" : "read",
);
if (!authorized.ok)
return toCallToolResult(
err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
),
contract,
);
}
}
const execution = await logToolExecution(
options.logger,
contract.name,
() =>
analysis.execute(contract.name, parameters, {
signal: context.mcpReq.signal,
progress,
}),
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: execution.ok ? "completed" : "failed",
terminal: true,
});
if (!execution.ok) return toCallToolResult(execution, contract);
const evidence = createEvidence(
execution.value.subject ?? options.activeTarget?.(),
@ -64,3 +107,38 @@ export const registerEvidenceTools = (
);
}
};
const permissionRequest = (
operation: string,
parameters: Readonly<
Record<string, import("../domain/jsonValue.js").JsonValue>
>,
) => {
if (
operation === "extract_artifact" &&
typeof parameters.output_root === "string"
)
return {
capability: "artifact_extract" as const,
roots: [parameters.output_root],
executables: [],
environment_names: [],
network: "none" as const,
mount: false,
operation_identity: `extract_artifact:${parameters.output_root}`,
};
if (
operation === "inventory_artifact" &&
parameters.native_mount_approved === true
)
return {
capability: "native_mount" as const,
roots: [],
executables: [],
environment_names: [],
network: "none" as const,
mount: true,
operation_identity: "inventory_artifact:native_mount",
};
return undefined;
};

View File

@ -14,6 +14,7 @@ import { toCallToolResult } from "./toolResult.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import { resolveSessionEvidence } from "./sessionEvidence.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register explicit Evidence-backed function comparison. */
export const registerFunctionComparisonTool = (
@ -24,7 +25,7 @@ export const registerFunctionComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = functionComparisonInputSchema.parse(input);
const left = resolveSessionEvidence(session, parsed.left);
if (!left.ok) return toCallToolResult(left, contract);
@ -32,12 +33,11 @@ export const registerFunctionComparisonTool = (
if (!right.ok) return toCallToolResult(right, contract);
const leftIds = evidenceIds(left.value);
const rightIds = evidenceIds(right.value);
const comparison = compareFunctions(
left.value,
right.value,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
compareFunctions(left.value, right.value, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const comparison = computed.value;
const evidence = createEvidence(undefined, FUNCTION_COMPARISON_PROVIDER, {
predicateType: "rea.function-comparison/v1",
operation: contract.name,

View File

@ -30,6 +30,14 @@ import {
} from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { recordDerivedEvidence } from "./recordDerivedEvidence.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
} from "../domain/errors.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { mcpProgressReporter } from "./mcpProgress.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register Evidence-composed differential investigation workflows. */
export const registerInvestigationTools = (
@ -52,13 +60,23 @@ export const registerInvestigationTools = (
interface InvestigationToolPolicies {
readonly evidenceFiles: EvidenceFilePolicy;
readonly inputRoots: readonly string[];
readonly permissionAuthority?: PermissionAuthority;
readonly integrityContinueEnabled?: () => boolean;
}
const investigationExecution = (
session: BinarySessionPort,
signal: AbortSignal,
inputRoots: readonly string[],
) => ({ session, signal, inputRoots });
integrityContinueEnabled: boolean,
progress?: ProgressReporter,
) => ({
session,
signal,
inputRoots,
integrityContinueEnabled,
...(progress === undefined ? {} : { progress }),
});
const isIncomplete = (status: string): boolean =>
status === "unknown" || status === "truncated";
@ -75,6 +93,50 @@ const registerChangedBehavior = (
async (input, context) => {
const parsed = changedBehaviorInputSchema.parse(input);
if (parsed.investigation_run !== undefined) {
const progress = mcpProgressReporter(context);
if (policies.permissionAuthority !== undefined) {
const scopes = [
{
capability: "investigation_workspace_read" as const,
roots: [parsed.investigation_run.workspace_path],
},
{
capability: "investigation_workspace_write" as const,
roots: [parsed.investigation_run.workspace_path],
},
{
capability: "investigation_input" as const,
roots: [
parsed.investigation_run.left_path,
parsed.investigation_run.right_path,
],
},
];
for (const scope of scopes) {
const authorized = await policies.permissionAuthority.authorize(
{
...scope,
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `${contract.name}:${scope.capability}:${scope.roots.join(":")}`,
},
scope.capability === "investigation_input" ? "read" : "write",
);
if (!authorized.ok)
return toCallToolResult(
err(
authorized.error instanceof PermissionRequiredError
? authorized.error
: new AnalysisProtocolError(authorized.error.message, {
cause: authorized.error,
}),
),
contract,
);
}
}
const investigated = await runCrossVersionInvestigation(
parsed.investigation_run,
policies.evidenceFiles,
@ -82,13 +144,18 @@ const registerChangedBehavior = (
session,
context.mcpReq.signal,
policies.inputRoots,
policies.integrityContinueEnabled?.() ?? false,
progress,
),
);
if (!investigated.ok) return toCallToolResult(investigated, contract);
const workspace = investigated.value.workspace;
if (session.retainInvestigationWorkspace(workspace) === "added")
server.sendResourceListChanged();
const result = changedBehaviorResultSchema.parse(
investigated.value.evidence.normalized_result,
);
return toCallToolResult(
const toolResult = toCallToolResult(
recordWorkflowEvidence(
session,
investigated.value.evidence,
@ -110,6 +177,20 @@ const registerChangedBehavior = (
),
contract,
);
return {
...toolResult,
content: [
...toolResult.content,
{
type: "resource_link" as const,
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
name: `${workspace.workspace_id} revision ${String(workspace.revision)}`,
description:
"Immutable CAS-linked investigation workspace revision",
mimeType: "application/json",
},
],
};
}
const closure = evidenceClosure(
session,
@ -117,11 +198,11 @@ const registerChangedBehavior = (
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = findChangedBehavior(
parsed.comparisons,
parsed.offset,
parsed.limit,
const computed = await runDerivedOperation(context, contract.name, () =>
findChangedBehavior(parsed.comparisons, parsed.offset, parsed.limit),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, CHANGED_BEHAVIOR_PROVIDER, {
predicateType: "rea.changed-behavior/v1",
operation: contract.name,
@ -168,54 +249,62 @@ const registerCallPath = (
session: BinarySessionPort,
contract: ToolContract<"build_call_path">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = callPathInputSchema.parse(input);
const closure = evidenceClosure(
session,
functionEvidenceIds(parsed.functions),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = buildCallPath(parsed);
const evidence = createEvidence(undefined, CALL_PATH_PROVIDER, {
predicateType: "rea.call-path/v1",
operation: contract.name,
parameters: {
start: parsed.start.address,
goal: parsed.goal.address,
max_depth: parsed.max_depth,
max_paths: parsed.max_paths,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
const recorded = recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Can the requested call path be established from complete analysis?",
domain: "call-path",
requiredAuthority: "shipped-artifact",
requiredConfidence: "derived",
probes: [
{
operation: "analyze_function",
rationale:
"Collect complete callee dossiers for the reported frontier addresses.",
},
],
},
);
return toCallToolResult(recorded, contract);
});
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = callPathInputSchema.parse(input);
const closure = evidenceClosure(
session,
functionEvidenceIds(parsed.functions),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const computed = await runDerivedOperation(context, contract.name, () =>
buildCallPath(parsed),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, CALL_PATH_PROVIDER, {
predicateType: "rea.call-path/v1",
operation: contract.name,
parameters: {
start: parsed.start.address,
goal: parsed.goal.address,
max_depth: parsed.max_depth,
max_paths: parsed.max_paths,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
const recorded = recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Can the requested call path be established from complete analysis?",
domain: "call-path",
requiredAuthority: "shipped-artifact",
requiredConfidence: "derived",
probes: [
{
operation: "analyze_function",
rationale:
"Collect complete callee dossiers for the reported frontier addresses.",
},
],
},
);
return toCallToolResult(recorded, contract);
},
);
};
const registerStaticRuntime = (
@ -223,56 +312,64 @@ const registerStaticRuntime = (
session: BinarySessionPort,
contract: ToolContract<"correlate_static_and_runtime">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = staticRuntimeCorrelationInputSchema.parse(input);
const closure = evidenceClosure(
session,
comparisonClosure([
...parsed.static_comparisons,
...parsed.runtime_comparisons,
]),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const result = correlateStaticAndRuntime(parsed);
const evidence = createEvidence(undefined, STATIC_RUNTIME_PROVIDER, {
predicateType: "rea.static-runtime-correlation/v1",
operation: contract.name,
parameters: {
mapping_count: parsed.mappings.length,
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "inferred",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = staticRuntimeCorrelationInputSchema.parse(input);
const closure = evidenceClosure(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Does runtime behavior match the available static analysis?",
domain: "static-runtime-correlation",
requiredAuthority: null,
requiredConfidence: "derived",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Repeat runtime observations and complete the mapped static comparison Evidence.",
},
],
comparisonClosure([
...parsed.static_comparisons,
...parsed.runtime_comparisons,
]),
);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const computed = await runDerivedOperation(context, contract.name, () =>
correlateStaticAndRuntime(parsed),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const evidence = createEvidence(undefined, STATIC_RUNTIME_PROVIDER, {
predicateType: "rea.static-runtime-correlation/v1",
operation: contract.name,
parameters: {
mapping_count: parsed.mappings.length,
offset: parsed.offset,
limit: parsed.limit,
},
),
contract,
);
});
result: jsonValueSchema.parse(result),
confidence: "inferred",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown" || result.status === "truncated",
{
question:
"Does runtime behavior match the available static analysis?",
domain: "static-runtime-correlation",
requiredAuthority: null,
requiredConfidence: "derived",
probes: [
{
operation: "capture_process_scenario",
rationale:
"Repeat runtime observations and complete the mapped static comparison Evidence.",
},
],
},
),
contract,
);
},
);
};
const registerReconstruction = (
@ -280,75 +377,85 @@ const registerReconstruction = (
session: BinarySessionPort,
contract: ToolContract<"verify_reconstruction">,
): void => {
server.registerTool(contract.name, contractOptions(contract), (input) => {
const parsed = reconstructionVerificationInputSchema.parse(input);
const owned = session.exportEvidenceBundle();
const ownedUnknowns = new Set(
owned.unknowns.map(({ revision_digest: digest }) => digest),
);
const ownedEvidenceIds = new Set(
owned.records.map(({ evidence_id: id }) => id),
);
if (
parsed.evidence_bundle.records.some(
({ evidence_id: id }) => !ownedEvidenceIds.has(id),
) ||
parsed.evidence_bundle.unknowns.some(
({ revision_digest: digest }) => !ownedUnknowns.has(digest),
server.registerTool(
contract.name,
contractOptions(contract),
async (input, context) => {
const parsed = reconstructionVerificationInputSchema.parse(input);
const owned = session.exportEvidenceBundle();
const ownedUnknowns = new Set(
owned.unknowns.map(({ revision_digest: digest }) => digest),
);
const ownedEvidenceIds = new Set(
owned.records.map(({ evidence_id: id }) => id),
);
if (
parsed.evidence_bundle.records.some(
({ evidence_id: id }) => !ownedEvidenceIds.has(id),
) ||
parsed.evidence_bundle.unknowns.some(
({ revision_digest: digest }) => !ownedUnknowns.has(digest),
)
)
)
return toCallToolResult(
err(
new EvidenceIntegrityError(
"Reconstruction input unknown history is not present in this session",
return toCallToolResult(
err(
new EvidenceIntegrityError(
"Reconstruction input unknown history is not present in this session",
),
),
contract,
);
const computed = await runDerivedOperation(context, contract.name, () =>
verifyReconstruction(
parsed.specification,
owned,
parsed.offset,
parsed.limit,
),
);
if (!computed.ok) return toCallToolResult(computed, contract);
const result = computed.value;
const closure = evidenceClosure(session, result.evidence_links);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const evidence = createEvidence(undefined, RECONSTRUCTION_PROVIDER, {
predicateType: "rea.reconstruction-verification/v1",
operation: contract.name,
parameters: {
specification_sha256: result.specification_sha256,
claim_ids: parsed.specification.claims.map(({ claim_id: id }) => id),
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown",
{
question: "Does the reconstruction satisfy every declared claim?",
domain: "reconstruction-verification",
requiredAuthority: null,
requiredConfidence: "derived",
probes: result.recommended_probes.map(
({ operation, rationale }) => ({
operation,
rationale,
}),
),
},
),
contract,
);
const result = verifyReconstruction(
parsed.specification,
owned,
parsed.offset,
parsed.limit,
);
const closure = evidenceClosure(session, result.evidence_links);
if (!closure.ok) return toCallToolResult(closure, contract);
const links = closure.value;
const evidence = createEvidence(undefined, RECONSTRUCTION_PROVIDER, {
predicateType: "rea.reconstruction-verification/v1",
operation: contract.name,
parameters: {
specification_sha256: result.specification_sha256,
claim_ids: parsed.specification.claims.map(({ claim_id: id }) => id),
offset: parsed.offset,
limit: parsed.limit,
},
result: jsonValueSchema.parse(result),
confidence: "derived",
authority: "analyst-inference",
limitations: result.limitations,
evidenceLinks: links,
});
return toCallToolResult(
recordWorkflowEvidence(
session,
evidence,
parsed.unknown_registry_approved,
result.status === "unknown",
{
question: "Does the reconstruction satisfy every declared claim?",
domain: "reconstruction-verification",
requiredAuthority: null,
requiredConfidence: "derived",
probes: result.recommended_probes.map(({ operation, rationale }) => ({
operation,
rationale,
})),
},
),
contract,
);
});
},
);
};
const contractOptions = (contract: ToolContract) => ({

View File

@ -17,6 +17,7 @@ import {
AnalysisCapabilityUnavailableError,
UnknownRegistryError,
} from "../domain/errors.js";
import { mcpProgressReporter } from "./mcpProgress.js";
/** Optional session services used by direct tool registration. */
export interface OfficialToolRegistration {
@ -63,14 +64,29 @@ const registerOfficialTool = (
},
async (input, context) => {
const arguments_ = projectOfficialArguments(contract, input);
const progress = mcpProgressReporter(context);
await progress.report({
phase: contract.name,
completed: 0,
total: 1,
message: "started",
});
const result = await logToolExecution(
registration.logger,
contract.name,
() =>
analysis.execute(contract.name, arguments_, {
signal: context.mcpReq.signal,
progress,
}),
);
await progress.report({
phase: contract.name,
completed: 1,
total: 1,
message: result.ok ? "completed" : "failed",
terminal: true,
});
if (result.ok) {
const evidence = createEvidence(
result.value.subject ?? registration.activeTarget?.(),

View File

@ -18,6 +18,7 @@ import { err } from "../domain/result.js";
import { PROCESS_PROVIDER } from "./sessionToolPolicies.js";
import { toCallToolResult } from "./toolResult.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { runDerivedOperation } from "./runDerivedOperation.js";
/** Register deterministic process-capture comparison and contradiction tracking. */
export const registerProcessComparisonTool = (
@ -29,7 +30,7 @@ export const registerProcessComparisonTool = (
server.registerTool(
contract.name,
toolRegistrationOptions(contract),
(input) => {
async (input, context) => {
const parsed = z
.object({
left_evidence_id: z.string(),
@ -44,12 +45,16 @@ export const registerProcessComparisonTool = (
if (!validated.ok) return toCallToolResult(validated, contract);
let comparison: ReturnType<typeof compareProcessCaptures>;
try {
comparison = compareProcessCaptures(parsed.left, parsed.right, {
...(parsed.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: parsed.max_capture_age_ms }),
now,
});
const computed = await runDerivedOperation(context, contract.name, () =>
compareProcessCaptures(parsed.left, parsed.right, {
...(parsed.max_capture_age_ms === undefined
? {}
: { maxCaptureAgeMs: parsed.max_capture_age_ms }),
now,
}),
);
if (!computed.ok) return toCallToolResult(computed, contract);
comparison = computed.value;
} catch (cause: unknown) {
return toCallToolResult(
err(

View File

@ -1,14 +1,25 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import {
binarySessionInputSchema,
SESSION_TOOL_CONTRACTS,
} from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
import { buildCapabilityInventory } from "../application/CapabilityInventory.js";
/** Register the read-only provider and target status operation. */
export const registerSessionStatusTool = (
server: McpServer,
session: BinarySessionPort,
contract: (typeof SESSION_TOOL_CONTRACTS)[2],
startedAt: string,
availabilityPolicy: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
},
): void => {
server.registerTool(
contract.name,
@ -18,6 +29,53 @@ export const registerSessionStatusTool = (
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
() => toCallToolResult({ ok: true, value: session.status() }, contract),
(input) => {
const parsed = binarySessionInputSchema.parse(input);
const client = server.server.getClientVersion();
const clientCapabilities = server.server.getClientCapabilities();
const status = session.status();
return toCallToolResult(
{
ok: true,
value: {
...jsonObjectSchema.parse(status),
tool_availability: buildCapabilityInventory(
status,
availabilityPolicy(),
),
client_features: {
elicitation_form:
clientCapabilities?.elicitation?.form !== undefined,
elicitation_url:
clientCapabilities?.elicitation?.url !== undefined,
roots: clientCapabilities?.roots !== undefined,
sampling: clientCapabilities?.sampling !== undefined,
},
server_identity: createServerIdentity({
startedAt,
expected: {
...(parsed.expected_package_version === undefined
? {}
: { package_version: parsed.expected_package_version }),
...(parsed.expected_catalog_digest === undefined
? {}
: { catalog_digest: parsed.expected_catalog_digest }),
...(parsed.expected_server_path === undefined
? {}
: { server_path: parsed.expected_server_path }),
},
...(client === undefined ? {} : { client }),
...(server.server.getNegotiatedProtocolVersion() === undefined
? {}
: {
protocolVersion:
server.server.getNegotiatedProtocolVersion(),
}),
}),
},
},
contract,
);
},
);
};

View File

@ -6,7 +6,7 @@ import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
import type { Logger } from "../logger.js";
import { logToolExecution } from "./toolLogging.js";
import { ok, type Result } from "../domain/result.js";
import { err, ok, type Result } from "../domain/result.js";
import type {
ProcessCapture,
ProcessExecutionPolicy,
@ -29,7 +29,12 @@ import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../application/AnalysisSnapshotFiles.js";
import { UnknownRegistryError, type AnalysisError } from "../domain/errors.js";
import {
AnalysisProtocolError,
PermissionRequiredError,
UnknownRegistryError,
type AnalysisError,
} from "../domain/errors.js";
import {
DENY_EVIDENCE_FILE_POLICY,
DENY_PROCESS_POLICY,
@ -45,6 +50,22 @@ import {
openBinaryInputSchema,
} from "../contracts/sessionLifecycleInputs.js";
import { registerSessionStatusTool } from "./registerSessionStatusTool.js";
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
import { mcpProgressReporter } from "./mcpProgress.js";
const permissionFailure = (
failure: Awaited<ReturnType<PermissionAuthority["authorize"]>>,
): Result<never, AnalysisError> => {
if (failure.ok)
return err(new AnalysisProtocolError("Expected a denied permission"));
return err(
failure.error instanceof PermissionRequiredError
? failure.error
: new AnalysisProtocolError(failure.error.message, {
cause: failure.error,
}),
);
};
const recordProcessResidualUnknowns = (
session: BinarySessionPort,
@ -91,6 +112,11 @@ interface ProcessToolRegistration {
readonly logger: Logger;
readonly processPolicy: ProcessExecutionPolicy;
readonly captureContract: (typeof SESSION_TOOL_CONTRACTS)[5];
readonly permissionAuthority?: PermissionAuthority;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
const registerProcessTools = ({
@ -99,6 +125,7 @@ const registerProcessTools = ({
logger,
processPolicy,
captureContract,
permissionAuthority,
}: ProcessToolRegistration): void => {
server.registerTool(
captureContract.name,
@ -110,6 +137,35 @@ const registerProcessTools = ({
},
async (input, context) => {
const scenario = processScenarioSchema.parse(input);
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.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 toCallToolResult(
permissionFailure(authorized),
captureContract,
);
}
const progress = mcpProgressReporter(context);
await progress.report({
phase: captureContract.name,
completed: 0,
total: 1,
message: "started",
});
const captured = await logToolExecution(
logger,
captureContract.name,
@ -120,6 +176,13 @@ const registerProcessTools = ({
context.mcpReq.signal,
),
);
await progress.report({
phase: captureContract.name,
completed: 1,
total: 1,
message: captured.ok ? "completed" : "failed",
terminal: true,
});
if (!captured.ok) return toCallToolResult(captured, captureContract);
const evidence = createProcessCaptureEvidence(scenario, captured.value);
const recorded = session.recordEvidence(evidence);
@ -142,6 +205,7 @@ interface EvidenceToolRegistration {
readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3];
readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4];
readonly filePolicy: EvidenceFilePolicy;
readonly permissionAuthority?: PermissionAuthority;
}
const registerEvidenceTools = ({
@ -150,6 +214,7 @@ const registerEvidenceTools = ({
exportContract,
importContract,
filePolicy,
permissionAuthority,
}: EvidenceToolRegistration): void => {
server.registerTool(
exportContract.name,
@ -169,6 +234,25 @@ const registerEvidenceTools = ({
const bundle = session.exportEvidenceBundle();
if (parsed.path === undefined)
return toCallToolResult(ok(bundle), exportContract);
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_write",
roots: [parsed.path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `export_evidence:${parsed.path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
exportContract,
);
}
const written = await writeEvidenceBundle(
bundle,
parsed.path,
@ -197,6 +281,25 @@ const registerEvidenceTools = ({
},
async (input) => {
const path = z.object({ path: z.string().min(1) }).parse(input).path;
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "evidence_read",
roots: [path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `import_evidence:${path}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
importContract,
);
}
const loaded = await readEvidenceBundle(path, filePolicy);
if (!loaded.ok) return toCallToolResult(loaded, importContract);
const imported = session.importEvidenceBundle(loaded.value);
@ -325,6 +428,12 @@ interface LifecycleToolRegistration {
(typeof SESSION_TOOL_CONTRACTS)[2],
];
readonly snapshotFilePolicy: EvidenceFilePolicy;
readonly startedAt: string;
readonly availabilityPolicy: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
readonly permissionAuthority?: PermissionAuthority;
}
const registerLifecycleTools = ({
@ -333,6 +442,9 @@ const registerLifecycleTools = ({
logger,
contracts,
snapshotFilePolicy,
startedAt,
availabilityPolicy,
permissionAuthority,
}: LifecycleToolRegistration): void => {
const [openContract, closeContract, statusContract] = contracts;
server.registerTool(
@ -347,6 +459,25 @@ const registerLifecycleTools = ({
const parsed = openBinaryInputSchema.parse(input);
let snapshot: AnalysisSnapshot | undefined;
if (parsed.snapshot_path !== undefined) {
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_read",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `open_binary:snapshot:${parsed.snapshot_path}`,
},
"read",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
openContract,
);
}
const loaded = await readAnalysisSnapshot(
parsed.snapshot_path,
snapshotFilePolicy,
@ -360,6 +491,7 @@ const registerLifecycleTools = ({
...(snapshot === undefined ? {} : { snapshot }),
}),
);
if (opened.ok) server.sendToolListChanged();
return opened.ok
? toCallToolResult(
{
@ -389,6 +521,25 @@ const registerLifecycleTools = ({
async (input) => {
const parsed = closeBinaryInputSchema.parse(input);
if (parsed.snapshot_path !== undefined) {
if (permissionAuthority !== undefined) {
const authorized = await permissionAuthority.authorize(
{
capability: "snapshot_write",
roots: [parsed.snapshot_path],
executables: [],
environment_names: [],
network: "none",
mount: false,
operation_identity: `close_binary:snapshot:${parsed.snapshot_path}`,
},
"write",
);
if (!authorized.ok)
return toCallToolResult(
permissionFailure(authorized),
closeContract,
);
}
const snapshot = session.exportAnalysisSnapshot();
if (!snapshot.ok) return toCallToolResult(snapshot, closeContract);
const written = await writeAnalysisSnapshot(
@ -401,6 +552,7 @@ const registerLifecycleTools = ({
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
if (closed.ok) server.sendToolListChanged();
return closed.ok
? toCallToolResult(
ok({
@ -412,15 +564,20 @@ const registerLifecycleTools = ({
)
: toCallToolResult(closed, closeContract);
}
return toCallToolResult(
await logToolExecution(logger, closeContract.name, () =>
session.close(),
),
closeContract,
const closed = await logToolExecution(logger, closeContract.name, () =>
session.close(),
);
if (closed.ok) server.sendToolListChanged();
return toCallToolResult(closed, closeContract);
},
);
registerSessionStatusTool(server, session, statusContract);
registerSessionStatusTool(
server,
session,
statusContract,
startedAt,
availabilityPolicy,
);
};
/** Register MCP-only target lifecycle operations on a long-lived session. */
@ -429,6 +586,13 @@ export interface SessionToolOptions {
readonly evidenceFilePolicy?: EvidenceFilePolicy;
readonly investigationInputRoots?: readonly string[];
readonly analysisSnapshotFilePolicy?: EvidenceFilePolicy;
readonly startedAt?: string;
readonly permissionAuthority?: PermissionAuthority;
readonly artifactIntegrityContinueEnabled?: () => boolean;
readonly availabilityPolicy?: () => {
readonly processCaptureEnabled: boolean;
readonly evidenceFileRoots: number;
};
}
export const registerSessionTools = (
@ -468,6 +632,16 @@ export const registerSessionTools = (
logger,
contracts: [openContract, closeContract, statusContract],
snapshotFilePolicy: analysisSnapshotFilePolicy,
startedAt: options.startedAt ?? new Date().toISOString(),
availabilityPolicy:
options.availabilityPolicy ??
(() => ({
processCaptureEnabled: processPolicy.enabled,
evidenceFileRoots: evidenceFilePolicy.roots.length,
})),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerEvidenceTools({
server,
@ -475,6 +649,9 @@ export const registerSessionTools = (
exportContract,
importContract,
filePolicy: evidenceFilePolicy,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerProcessTools({
server,
@ -482,6 +659,9 @@ export const registerSessionTools = (
logger,
processPolicy,
captureContract,
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerProcessComparisonTool(server, session, compareContract);
registerArtifactComparisonTool(server, session, compareArtifactsContract);
@ -496,6 +676,14 @@ export const registerSessionTools = (
registerInvestigationTools(server, session, investigationContracts, {
evidenceFiles: evidenceFilePolicy,
inputRoots: options.investigationInputRoots ?? [],
...(options.artifactIntegrityContinueEnabled === undefined
? {}
: {
integrityContinueEnabled: options.artifactIntegrityContinueEnabled,
}),
...(options.permissionAuthority === undefined
? {}
: { permissionAuthority: options.permissionAuthority }),
});
registerUnknownTools({
server,

View File

@ -0,0 +1,57 @@
import { setImmediate } from "node:timers/promises";
import { AnalysisCancelledError } from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { mcpProgressReporter } from "./mcpProgress.js";
interface DerivedOperationContext {
readonly mcpReq: {
readonly signal: AbortSignal;
readonly _meta?: { readonly progressToken?: string | number | undefined };
notify(notification: {
readonly method: "notifications/progress";
readonly params: {
readonly progressToken: string | number;
readonly progress: number;
readonly total?: number;
readonly message?: string;
};
}): Promise<void>;
};
}
/** Run synchronous derived work with transport yields and a publication guard. */
export const runDerivedOperation = async <Value>(
context: DerivedOperationContext,
operation: string,
compute: () => Value,
): Promise<Result<Value, AnalysisCancelledError>> => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: "prepare",
completed: 0,
total: 2,
message: `Preparing ${operation}`,
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
const value = compute();
await progress.report({
phase: "compute",
completed: 1,
total: 2,
message: `Computed ${operation}`,
});
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
await progress.report({
phase: "complete",
completed: 2,
total: 2,
message: `Completed ${operation}`,
terminal: true,
});
return ok(value);
};

View File

@ -60,11 +60,34 @@ const successResult = (
};
}
return {
content: [{ type: "text", text: formatValue(value) }],
content: [
{ type: "text", text: formatValue(value) },
...evidenceResourceLinks(value),
],
structuredContent: candidate,
};
};
const evidenceResourceLinks = (value: JsonValue): CallToolResult["content"] => {
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value) ||
typeof value.evidence_id !== "string" ||
!/^ev_[a-f0-9]{64}$/u.test(value.evidence_id)
)
return [];
return [
{
type: "resource_link",
uri: `rea://evidence/${value.evidence_id}`,
name: value.evidence_id,
description: "Session-owned Evidence v2 record",
mimeType: "application/json",
},
];
};
const formatValue = (value: JsonValue): string => {
if (value === null) return "OK";
if (typeof value === "string") return value;

100
src/serverIdentity.ts Normal file
View File

@ -0,0 +1,100 @@
import { dirname, basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { CATALOG_IDENTITY } from "./catalogIdentity.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "./identity.js";
/** Optional observations supplied by a client comparing its expected server. */
export interface ExpectedServerIdentity {
readonly package_version?: string;
readonly catalog_digest?: string;
readonly server_path?: string;
}
export interface ConnectedClientIdentity {
readonly name: string;
readonly version: string;
}
const packageRoot = (): string => {
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
return basename(moduleDirectory) === "src" &&
basename(dirname(moduleDirectory)) === "dist"
? resolve(moduleDirectory, "../..")
: resolve(moduleDirectory, "..");
};
/** Build one runtime identity without claiming alignment from absent observations. */
export const createServerIdentity = (input: {
readonly startedAt: string;
readonly expected?: ExpectedServerIdentity;
readonly client?: ConnectedClientIdentity;
readonly protocolVersion?: string | undefined;
}) => {
const serverPath = resolve(process.argv[1] ?? "unknown");
const reasons: string[] = [];
if (
input.expected?.package_version !== undefined &&
input.expected.package_version !== PRODUCT_IDENTITY.packageVersion
)
reasons.push("package_version_mismatch");
if (
input.expected?.catalog_digest !== undefined &&
input.expected.catalog_digest !== CATALOG_IDENTITY.digests.combined_sha256
)
reasons.push("catalog_digest_mismatch");
if (
input.expected?.server_path !== undefined &&
resolve(input.expected.server_path) !== serverPath
)
reasons.push("registration_path_mismatch");
const observedExpectation =
input.expected !== undefined &&
Object.values(input.expected).some((value) => value !== undefined);
return {
package: {
name: PRODUCT_IDENTITY.packageName,
version: PRODUCT_IDENTITY.packageVersion,
root_path: packageRoot(),
build_commit: process.env.REA_BUILD_COMMIT ?? null,
},
server: {
name: PRODUCT_IDENTITY.mcpServerKey,
version: PRODUCT_IDENTITY.packageVersion,
started_at: input.startedAt,
command_path: serverPath,
},
sdk: SDK_IDENTITY,
negotiated_protocol_version: input.protocolVersion ?? null,
client:
input.client === undefined
? null
: { name: input.client.name, version: input.client.version },
skill: {
name: PRODUCT_IDENTITY.skillName,
expected_version: PRODUCT_IDENTITY.skillVersion,
},
catalog: CATALOG_IDENTITY,
protocol_features: {
progress: true,
cancellation: true,
evidence_resources: true,
elicitation: false,
},
alignment: {
state:
reasons.length > 0
? "mcp_server_restart_required"
: observedExpectation
? "aligned"
: "unknown",
reasons,
remediation:
reasons.length > 0
? "Restart the registered MCP server/client, then compare identity again."
: observedExpectation
? null
: "Supply expected package, catalog, or registration identity to compare the live server.",
},
} as const;
};

View File

@ -66,6 +66,7 @@ describe("artifact graph MCP integration", () => {
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: {
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
@ -75,6 +76,12 @@ describe("artifact graph MCP integration", () => {
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
unpacked,
},
retryable: false,
remediation: {
action:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
restart_required: false,
},
},
});
} finally {
@ -87,6 +94,144 @@ describe("artifact graph MCP integration", () => {
},
);
it("records an approved mismatch, preserves verified siblings, and never reports equivalence", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-asar-continue-mcp-"));
const source = join(root, "source");
await mkdir(source);
const original = "console.log('ok');\n";
const changed = "console.log('no');\n";
const secondOriginal = "console.log('up');\n";
const secondChanged = "console.log('dn');\n";
await writeFile(join(source, "main.js"), original);
await writeFile(join(source, "second.js"), secondOriginal);
await writeFile(join(source, "sibling.js"), "console.log('sibling');\n");
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, {});
const bytes = await readFile(archive);
const contentOffset = bytes.indexOf(original);
const secondOffset = bytes.indexOf(secondOriginal);
expect(contentOffset).toBeGreaterThanOrEqual(0);
expect(secondOffset).toBeGreaterThanOrEqual(0);
bytes.write(changed, contentOffset, "utf8");
bytes.write(secondChanged, secondOffset, "utf8");
await writeFile(archive, bytes);
const session = new BinarySession(new ArtifactProvider(false, true));
const server = createServer(session, session);
const client = new Client({ name: "asar-continue-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
const limited = await client.callTool({
name: "inventory_artifact",
arguments: {
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
max_integrity_mismatches: 1,
},
});
expect(limited).toMatchObject({
isError: true,
structuredContent: { error: { code: "truncated" } },
});
const result = await client.callTool({
name: "inventory_artifact",
arguments: {
integrity_policy: "record-and-continue",
integrity_continue_approved: true,
},
});
expect(result.isError).not.toBe(true);
const evidence = parseEvidence(result.structuredContent);
const inventory = z
.object({
occurrences: z.object({
items: z.array(
z.object({ logical_path: z.string(), hash_status: z.string() }),
),
}),
integrity_contradictions: z.array(
z.object({
logical_path: z.string(),
declared_sha256: z.string(),
observed_sha256: z.string(),
trust: z.literal("observed-untrusted"),
}),
),
})
.parse(evidence.normalized_result);
expect(inventory.integrity_contradictions).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
trust: "observed-untrusted",
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
observed_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
}),
expect.objectContaining({ logical_path: "second.js" }),
]),
);
expect(inventory.integrity_contradictions).toHaveLength(2);
expect(inventory.occurrences.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
hash_status: "mismatched",
}),
expect.objectContaining({
logical_path: "sibling.js",
hash_status: "verified",
}),
expect.objectContaining({
logical_path: "second.js",
hash_status: "mismatched",
}),
]),
);
const compared = await client.callTool({
name: "compare_artifacts",
arguments: {
left: evidence,
right: evidence,
},
});
expect(compared.isError).not.toBe(true);
const comparison = parseEvidence(
z.object({ result: z.unknown() }).parse(compared.structuredContent)
.result,
);
expect(comparison.normalized_result).toMatchObject({
status: "contradiction",
summary: { contradiction: 2 },
changes: {
items: expect.arrayContaining([
expect.objectContaining({
logical_path: "main.js",
classification: "contradiction",
dimensions: ["integrity"],
}),
expect.objectContaining({
logical_path: "second.js",
classification: "contradiction",
}),
]),
},
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
it("rejects altered payloads that reuse session Evidence IDs", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),

View File

@ -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);

View File

@ -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[] = [];

View File

@ -0,0 +1,47 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readClientRegistrationStatuses } from "../src/application/ClientRegistrationStatus.js";
describe("client registration status", () => {
it("distinguishes aligned, stale, missing, and invalid registrations", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-registrations-"));
await Promise.all([
mkdir(join(home, ".codex")),
mkdir(join(home, ".cursor")),
mkdir(join(home, ".gemini")),
mkdir(join(home, ".claude")),
]);
await writeFile(
join(home, ".codex/config.toml"),
'[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "rea-agents", "mcp"]\n',
);
await writeFile(
join(home, ".cursor/mcp.json"),
JSON.stringify({
mcpServers: {
rea: { command: "/old/rea", args: ["mcp"], env: { SECRET: "x" } },
},
}),
);
await writeFile(join(home, ".gemini/settings.json"), "not-json");
const statuses = await readClientRegistrationStatuses(home, "/current/rea");
expect(statuses).toEqual(
expect.arrayContaining([
expect.objectContaining({ client: "codex", state: "aligned" }),
expect.objectContaining({
client: "cursor",
state: "stale",
command: ["/old/rea", "mcp"],
}),
expect.objectContaining({ client: "gemini_cli", state: "invalid" }),
expect.objectContaining({ client: "claude_code", state: "missing" }),
]),
);
expect(JSON.stringify(statuses)).not.toContain("SECRET");
});
});

View File

@ -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:

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { runDerivedOperation } from "../src/server/runDerivedOperation.js";
const context = (
controller: AbortController,
notifications: unknown[] = [],
) => ({
mcpReq: {
signal: controller.signal,
_meta: { progressToken: "derived-test" },
notify: (notification: unknown) => {
notifications.push(notification);
return Promise.resolve();
},
},
});
describe("derived MCP operation boundary", () => {
it("cancels before computation and never evaluates the operation", async () => {
const controller = new AbortController();
controller.abort();
let computed = false;
const result = await runDerivedOperation(
context(controller),
"compare",
() => {
computed = true;
return "impossible";
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(computed).toBe(false);
});
it("lets cancellation win immediately before result publication", async () => {
const controller = new AbortController();
const result = await runDerivedOperation(
context(controller),
"compare",
() => {
controller.abort();
return "must-not-publish";
},
);
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
});
it("emits bounded monotonic progress with one terminal state", async () => {
const controller = new AbortController();
const notifications: Array<{
params?: { progress?: number; total?: number };
}> = [];
const result = await runDerivedOperation(
context(controller, notifications),
"compare",
() => "complete",
);
expect(result).toEqual({ ok: true, value: "complete" });
expect(notifications.map(({ params }) => params?.progress)).toEqual([0, 2]);
expect(notifications.every(({ params }) => params?.total === 2)).toBe(true);
});
});

View File

@ -59,6 +59,57 @@ describe("doctor", () => {
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
});
it("distinguishes stale skills, path shadowing, and unobservable live state", async () => {
const result = await runDoctor(
undefined,
host({
installationPaths: () =>
Promise.resolve(["/usr/local/bin/rea", "/home/user/bin/rea"]),
installedSkillVersion: () => Promise.resolve("10"),
}),
);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
installations: { state: "multiple" },
skill: {
installed_version: "10",
state: "stale",
remediation: "Run rea setup to update the installed REA skill.",
},
});
});
it("reports stale client registration paths without claiming live state", async () => {
const result = await runDoctor(
undefined,
host({
clientRegistrations: () =>
Promise.resolve([
{
client: "codex",
config_path: "/home/user/.codex/config.toml",
command: ["/old/rea", "mcp"],
state: "stale",
remediation:
"Run rea setup to refresh this registration, then restart the client.",
},
]),
}),
);
expect(result.healthy).toBe(false);
expect(result.identity).toMatchObject({
live_server: { state: "unknown" },
registrations: [{ client: "codex", state: "stale" }],
});
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "registration:codex",
classification: "config_drift",
}),
);
});
it("accepts an officially supported Linux distribution", async () => {
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
const result = await runDoctor(

View File

@ -311,9 +311,16 @@ describe("enhanced MCP tools", () => {
address: "0x2",
status: "error",
error: {
code: "unreadable_output",
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
retryable: false,
remediation: {
action:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
restart_required: false,
},
},
},
],

View File

@ -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<Record<AnalysisErrorTag, AnalysisError>>;
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");
}
});
});

View File

@ -0,0 +1,141 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import { createEvidence } from "../src/domain/evidence.js";
import { observed } from "./fixtures/analysisExecution.js";
import { createServer } from "../src/server/createServer.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js";
const provider = { id: "fixture", name: "Fixture", version: "1" };
describe("evidence MCP resources", () => {
it("lists and reads only evidence owned by the current session", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const evidence = createEvidence(undefined, provider, {
operation: "resource_probe",
parameters: {},
result: { observed: true },
});
session.recordEvidence(evidence);
const capture = createEvidence(undefined, provider, {
operation: "capture_process_scenario",
parameters: {},
result: {
frames: [{ sequence: 0, at_ms: 0, data: "ready" }],
protocol_events: [],
},
});
session.recordEvidence(capture);
const workspace = createInvestigationWorkspace(
"resource test",
createEvidenceBundle([]),
[],
);
session.retainInvestigationWorkspace(workspace);
const unknown = session.recordUnknown({
approved: true,
question: "Was every branch observed?",
severity: "medium",
domain: "control-flow",
supporting_evidence_ids: [evidence.evidence_id],
contradicting_evidence_ids: [],
required_authority: "controlled-replay",
required_confidence: "observed",
required_environment: null,
recommended_probes: [],
relationships: [],
});
expect(unknown.ok).toBe(true);
const server = createServer(session, session);
const client = new Client({ name: "evidence-resource-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const templates = await client.listResourceTemplates();
expect(templates.resourceTemplates).toContainEqual(
expect.objectContaining({ uriTemplate: "rea://evidence/{evidenceId}" }),
);
expect(
templates.resourceTemplates.map(({ uriTemplate }) => uriTemplate),
).toEqual(
expect.arrayContaining([
"rea://artifact/{manifestId}/{collection}",
"rea://function/{targetSha256}/{address}",
"rea://snapshot/{snapshotDigest}",
"rea://workspace/{workspaceId}/revision/{revision}",
]),
);
expect(templates.resourceTemplates).toContainEqual(
expect.objectContaining({
uriTemplate: "rea://evidence/{evidenceId}/section/{section}",
}),
);
const listed = await client.listResources();
expect(listed.resources).toContainEqual(
expect.objectContaining({
uri: `rea://evidence/${evidence.evidence_id}`,
}),
);
const read = await client.readResource({
uri: `rea://evidence/${evidence.evidence_id}`,
});
expect(read.contents).toEqual([
expect.objectContaining({
uri: `rea://evidence/${evidence.evidence_id}`,
mimeType: "application/json",
text: expect.stringContaining(evidence.evidence_id),
}),
]);
const terminal = await client.readResource({
uri: `rea://evidence/${capture.evidence_id}/section/terminal`,
});
const workspaceResource = await client.readResource({
uri: `rea://workspace/${workspace.workspace_id}/revision/${String(workspace.revision)}`,
});
expect(workspaceResource.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(workspace.revision_digest),
}),
);
expect(terminal.contents[0]).toEqual(
expect.objectContaining({ text: expect.stringContaining("ready") }),
);
if (unknown.ok) {
const unknownResource = await client.readResource({
uri: `rea://unknown/${unknown.value.unknown_id}`,
});
expect(unknownResource.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(unknown.value.revision_digest),
}),
);
const active = await client.readResource({
uri: "rea://unknowns/active",
});
expect(active.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(unknown.value.unknown_id),
}),
);
}
await expect(
client.readResource({ uri: `rea://evidence/ev_${"0".repeat(64)}` }),
).rejects.toThrow(/not found/iu);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
});

View File

@ -22,6 +22,49 @@ import { readInvestigationWorkspace } from "../src/application/InvestigationWork
import type { EvidenceFilePolicy } from "../src/domain/evidenceBundle.js";
describe("investigation MCP workflows", () => {
it("never retains derived evidence after request cancellation", async () => {
const { session, server, client } = await connected();
const inputs = [
{
name: "find_changed_behavior",
arguments: INVESTIGATION_EXAMPLES.find_changed_behavior,
},
{
name: "build_call_path",
arguments: INVESTIGATION_EXAMPLES.build_call_path,
},
{
name: "correlate_static_and_runtime",
arguments: INVESTIGATION_EXAMPLES.correlate_static_and_runtime,
},
] as const;
for (const evidence of [
FUNCTION_COMPARISON_EXAMPLE.left,
FUNCTION_COMPARISON_EXAMPLE.right,
FUNCTION_COMPARISON_EVIDENCE,
PROCESS_CAPTURE_REFERENCE,
PROCESS_CAPTURE_RECONSTRUCTION,
PROCESS_COMPARISON_EVIDENCE,
])
expect(session.recordEvidence(evidence).ok).toBe(true);
const initialCount = session.exportEvidenceBundle().records.length;
try {
for (const input of inputs) {
const controller = new AbortController();
const request = client.callTool(input, {
signal: controller.signal,
onprogress: () => controller.abort(),
});
await expect(request).rejects.toThrow(/abort/iu);
}
await expect
.poll(() => session.exportEvidenceBundle().records.length)
.toBe(initialCount);
} finally {
await close(session, server, client);
}
});
it("runs and reuses a persistent cross-version workspace", async () => {
const directory = await mkdtemp(join(tmpdir(), "rea-investigation-mcp-"));
const left = join(directory, "left");
@ -64,6 +107,26 @@ describe("investigation MCP workflows", () => {
ok: true,
value: { revision: 3, runs: [{ status: "complete" }] },
});
expect(first.content).toContainEqual(
expect.objectContaining({
type: "resource_link",
uri: expect.stringMatching(
/^rea:\/\/workspace\/ws_[a-f0-9]{64}\/revision\/3$/u,
),
}),
);
const retainedWorkspace = session.investigationWorkspaces()[0];
expect(retainedWorkspace).toBeDefined();
if (retainedWorkspace !== undefined) {
const resource = await client.readResource({
uri: `rea://workspace/${retainedWorkspace.workspace_id}/revision/${String(retainedWorkspace.revision)}`,
});
expect(resource.contents[0]).toEqual(
expect.objectContaining({
text: expect.stringContaining(retainedWorkspace.revision_digest),
}),
);
}
const second = await client.callTool({
name: "find_changed_behavior",

View File

@ -8,10 +8,13 @@ import {
symlink,
writeFile,
} from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createPackageWithOptions } from "@electron/asar";
import canonicalize from "canonicalize";
import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js";
import {
@ -25,9 +28,16 @@ import {
createInvestigationWorkspace,
crossVersionInvestigationInputSchema,
investigationRunSchema,
parseInvestigationWorkspace,
serializeInvestigationWorkspace,
} from "../src/domain/investigationWorkspace.js";
const digestCanonical = (value: unknown): string => {
const encoded = canonicalize(value);
if (encoded === undefined) throw new TypeError("fixture is not canonical");
return createHash("sha256").update(encoded).digest("hex");
};
let directory: string | undefined;
afterEach(async () => {
@ -67,6 +77,116 @@ const fixture = async () => {
};
describe("persistent cross-version investigation workspace", () => {
it("migrates legacy v1 runs without losing their revision chain", async () => {
const { input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
const completed = await runCrossVersionInvestigation(
input,
policy(directory),
{ inputRoots: [directory] },
);
if (!completed.ok) throw completed.error;
const current = completed.value.workspace;
const currentRun = current.runs[0];
if (currentRun === undefined) throw new Error("missing completed run");
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
}),
).toThrow(/migrated strict runs/iu);
const legacyIdentity = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: currentRun.left,
right: currentRun.right,
options: currentRun.options,
});
expect(() =>
investigationRunSchema.parse({
...currentRun,
legacy_request_identity: true,
run_id: `run_${legacyIdentity}`,
request_sha256: legacyIdentity,
max_integrity_mismatches: 100,
}),
).toThrow(/migrated strict runs/iu);
const legacyRuns = current.runs.map((run) => {
const requestSha256 = digestCanonical({
schema: "rea.cross-version-investigation-request/v1",
left: run.left,
right: run.right,
options: run.options,
});
const {
integrity_policy: _integrityPolicy,
integrity_continue_approved: _integrityApproved,
max_integrity_mismatches: _integrityLimit,
...legacy
} = run;
return {
...legacy,
run_id: `run_${requestSha256}`,
request_sha256: requestSha256,
};
});
const legacySemantic = {
workspace_version: 1 as const,
workspace_id: current.workspace_id,
name: current.name,
revision: current.revision,
previous_revision_digest: current.previous_revision_digest,
bundle: current.bundle,
runs: legacyRuns,
};
const legacyDigest = `wrev_${digestCanonical(legacySemantic)}`;
expect(() =>
parseInvestigationWorkspace({
...legacySemantic,
runs: legacyRuns.map((run) => ({
...run,
max_integrity_mismatches: 100,
})),
revision_digest: legacyDigest,
}),
).toThrow(/unrecognized key/iu);
const migrated = parseInvestigationWorkspace({
...legacySemantic,
revision_digest: legacyDigest,
});
expect(migrated).toMatchObject({
revision: current.revision + 1,
previous_revision_digest: legacyDigest,
runs: [
{
legacy_request_identity: true,
integrity_policy: "fail",
integrity_continue_approved: false,
max_integrity_mismatches: 10,
},
],
});
const encodedLegacy = canonicalize({
...legacySemantic,
revision_digest: legacyDigest,
});
if (encodedLegacy === undefined)
throw new TypeError("legacy fixture is not canonical");
await writeFile(input.workspace_path, encodedLegacy, { mode: 0o600 });
await expect(
runCrossVersionInvestigation(input, policy(directory), {
inputRoots: [directory],
}),
).resolves.toMatchObject({ ok: true, value: { reused: true } });
await expect(
runCrossVersionInvestigation(
{ ...input, integrity_continue_approved: true },
policy(directory),
{ inputRoots: [directory] },
),
).resolves.toMatchObject({ ok: true, value: { reused: false } });
});
it("checkpoints, validates, and reuses a completed deterministic run", async () => {
const { path, input } = await fixture();
if (directory === undefined) throw new Error("missing fixture root");
@ -211,6 +331,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");

View File

@ -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")]);
});
});

View File

@ -0,0 +1,57 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { PermissionAuthority } from "../src/application/PermissionAuthority.js";
import { createPermissionPolicy } from "../src/domain/permissionPolicy.js";
import { createServer } from "../src/server/createServer.js";
describe("MCP permission preflight", () => {
it("denies extraction before dispatch and returns exact typed remediation", async () => {
let dispatches = 0;
const analysis: AnalysisOperationPort = {
execute: () => {
dispatches += 1;
throw new Error("provider must not run");
},
};
const server = createServer(analysis, undefined, {
permissionAuthority: new PermissionAuthority(createPermissionPolicy([])),
});
const client = new Client({ name: "permission-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: "extract_artifact",
arguments: {
approved: true,
output_root: "/tmp/rea-permission-denied",
occurrence_ids: [`occ_${"0".repeat(64)}`],
},
});
expect(dispatches).toBe(0);
expect(result).toMatchObject({
isError: true,
structuredContent: {
error: {
code: "permission_required",
details: {
capability: "artifact_extract",
missing: { roots: ["/tmp/rea-permission-denied"] },
},
remediation: {
restart_required: false,
elicitation_supported: false,
},
},
},
});
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
});

View File

@ -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"]);
});
});

View File

@ -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,
},
});
});

77
tests/progressMcp.test.ts Normal file
View File

@ -0,0 +1,77 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../src/domain/errors.js";
import { err } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
describe("ordinary MCP progress and cancellation", () => {
it("emits bounded progress for a normal tool request", async () => {
const analysis: AnalysisOperationPort = {
execute: () => Promise.resolve(observed("0x1000")),
};
const server = createServer(analysis);
const client = new Client({ name: "progress-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const progress: number[] = [];
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool(
{ name: "current_address", arguments: {} },
{ onprogress: (update) => progress.push(update.progress) },
);
expect(result.isError).not.toBe(true);
expect(progress).toEqual([0, 1]);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
it("propagates client cancellation to the active provider request", async () => {
let providerObservedCancellation = false;
let markProviderStarted: (() => void) | undefined;
const providerStarted = new Promise<void>((resolve) => {
markProviderStarted = resolve;
});
const analysis: AnalysisOperationPort = {
execute: async (operation, _parameters, options) => {
markProviderStarted?.();
await new Promise<void>((resolve) => {
options?.signal?.addEventListener(
"abort",
() => {
providerObservedCancellation = true;
resolve();
},
{ once: true },
);
});
return err(new AnalysisCancelledError(operation));
},
};
const server = createServer(analysis);
const client = new Client({ name: "cancellation-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const controller = new AbortController();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const request = client.callTool(
{ name: "current_address", arguments: {} },
{ signal: controller.signal },
);
await providerStarted;
controller.abort();
await expect(request).rejects.toThrow(/abort/iu);
await expect.poll(() => providerObservedCancellation).toBe(true);
} finally {
await Promise.allSettled([client.close(), server.close()]);
}
});
});

View File

@ -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);
});
});

View File

@ -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" },
});
});
});

View File

@ -0,0 +1,197 @@
import { readFile } from "node:fs/promises";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import { CATALOG_IDENTITY, CLI_COMMAND_NAMES } from "../src/catalogIdentity.js";
import { PACKAGE_METADATA } from "../src/generatedPackageMetadata.js";
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../src/identity.js";
import { createServer } from "../src/server/createServer.js";
import { createServerIdentity } from "../src/serverIdentity.js";
import { observed } from "./fixtures/analysisExecution.js";
import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js";
describe("server and catalog identity", () => {
it("derives package and SDK versions from canonical package metadata", async () => {
const packageJson = JSON.parse(await readFile("package.json", "utf8"));
expect(PACKAGE_METADATA).toMatchObject({
name: packageJson.name,
version: packageJson.version,
serverSdkVersion: "2.0.0-beta.4",
clientSdkVersion: "2.0.0-beta.4",
coreSdkVersion: "2.0.0-beta.4",
});
expect(PRODUCT_IDENTITY.packageVersion).toBe(packageJson.version);
expect(SDK_IDENTITY.server).toBe("2.0.0-beta.4");
expect(CLI_COMMAND_NAMES).toHaveLength(28);
expect(new Set(CLI_COMMAND_NAMES).size).toBe(28);
expect(CATALOG_IDENTITY.counts).toEqual({
cli_commands: 28,
mcp_tools: 68,
mcp_prompts: 6,
mcp_resources: 2,
mcp_resource_templates: 7,
});
expect(CATALOG_IDENTITY.digests.combined_sha256).toMatch(/^[a-f0-9]{64}$/u);
});
it("reports unknown without a live comparison and distinguishes aligned from stale", () => {
const unknown = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
});
expect(unknown.alignment.state).toBe("unknown");
const aligned = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
expected: {
package_version: PRODUCT_IDENTITY.packageVersion,
catalog_digest: CATALOG_IDENTITY.digests.combined_sha256,
server_path: process.argv[1] ?? "unknown",
},
});
expect(aligned.alignment).toMatchObject({ state: "aligned", reasons: [] });
const stale = createServerIdentity({
startedAt: "2026-07-13T00:00:00.000Z",
expected: {
package_version: "1.2.0",
catalog_digest: "0".repeat(64),
},
});
expect(stale.alignment).toMatchObject({
state: "mcp_server_restart_required",
reasons: ["package_version_mismatch", "catalog_digest_mismatch"],
});
});
it("reports composed, host, and target-specific availability truthfully", () => {
const policy = { processCaptureEnabled: true, evidenceFileRoots: 1 };
const composed = buildCapabilityInventory(
{
open: true,
kind: "executable",
format: "mach-o",
capabilities: [
"list_segments",
"list_documents",
"list_procedures",
"list_strings",
].map((operation) => ({ operation, available: true, reason: null })),
},
policy,
);
expect(composed).toContainEqual(
expect.objectContaining({ name: "binary_overview", available: true }),
);
const artifactTarget = buildCapabilityInventory(
{
open: true,
kind: "artifact",
format: "javascript",
capabilities: [
{ operation: "current_address", available: true, reason: null },
],
},
policy,
);
expect(artifactTarget).toContainEqual(
expect.objectContaining({
name: "current_address",
available: false,
reason: "target_unsupported",
}),
);
const unsupportedHost = buildCapabilityInventory(
{
open: true,
kind: "executable",
format: "elf",
capabilities: [
{
operation: "inspect_macho",
available: false,
reason: "Native macOS utilities require macOS.",
},
],
},
policy,
);
expect(unsupportedHost).toContainEqual(
expect.objectContaining({
name: "inspect_macho",
available: false,
reason: "unsupported_host",
}),
);
});
it("exposes live identity, labeled inventories, availability, and list changes", async () => {
const session = new BinarySession(() => ({
health: () => Promise.resolve(),
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const server = createServer(session, session);
const client = new Client({ name: "identity-test", version: "9" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
let toolListChanges = 0;
client.setNotificationHandler("notifications/tools/list_changed", () => {
toolListChanges += 1;
});
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const resource = await client.readResource({
uri: "rea://server/identity",
});
const content = resource.contents[0];
expect(content).toBeDefined();
if (content === undefined || !("text" in content)) return;
const live = JSON.parse(content.text);
expect(live).toMatchObject({
package: { version: PRODUCT_IDENTITY.packageVersion },
server: { version: PRODUCT_IDENTITY.packageVersion },
sdk: { server: "2.0.0-beta.4", client_test: "2.0.0-beta.4" },
client: { name: "identity-test", version: "9" },
alignment: { state: "unknown" },
});
const status = await client.callTool({
name: "binary_session",
arguments: { expected_package_version: "1.2.0" },
});
expect(status.structuredContent).toMatchObject({
result: {
server_identity: {
catalog: { counts: { mcp_tools: 68, cli_commands: 28 } },
alignment: { state: "mcp_server_restart_required" },
},
tool_availability: expect.arrayContaining([
expect.objectContaining({
name: "current_address",
available: false,
reason: "target_required",
}),
expect.objectContaining({
name: "capture_process_scenario",
available: false,
reason: "policy_disabled",
}),
]),
client_features: {
elicitation_form: false,
elicitation_url: false,
roots: false,
sampling: false,
},
},
});
await client.callTool({ name: "close_binary", arguments: {} });
await expect.poll(() => toolListChanges).toBeGreaterThan(0);
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
});

View File

@ -6,6 +6,7 @@ import { ok } from "../src/domain/result.js";
import { err } from "../src/domain/result.js";
import { HopperProcessError } from "../src/domain/errors.js";
import { toCallToolResult } from "../src/server/toolResult.js";
import { createEvidence, evidenceSchema } from "../src/domain/evidence.js";
const contract: ToolContract = {
name: "provider_neutral_fixture",
@ -27,7 +28,8 @@ describe("tool result projection", () => {
const result = toCallToolResult(err(new HopperProcessError(76)), contract);
expect(result.structuredContent).toMatchObject({
error: {
code: "unsupported_demo_dialog",
code: "provider_unavailable",
details: { failure_code: "unsupported_demo_dialog" },
category: "execution_failure",
},
});
@ -43,12 +45,45 @@ describe("tool result projection", () => {
],
structuredContent: {
error: {
code: "unreadable_output",
category: "execution_failure",
message:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
retryable: false,
remediation: {
action:
"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",
restart_required: false,
},
},
},
isError: true,
});
});
it("links successful evidence to its session resource URI", () => {
const evidence = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "fixture",
parameters: {},
result: { value: "observed" },
limitations: [],
},
);
const evidenceContract: ToolContract = {
...contract,
outputSchema: evidenceSchema,
};
expect(
toCallToolResult(ok(evidence), evidenceContract).content,
).toContainEqual(
expect.objectContaining({
type: "resource_link",
uri: `rea://evidence/${evidence.evidence_id}`,
}),
);
});
});

View File

@ -54,6 +54,9 @@ describe("CLI upgrade", () => {
versionCheck: "available",
installMethod: "npm",
command: "npm install --global rea-agents@latest",
clientRestartRequired: true,
remediation:
"Rerun rea setup to refresh registrations and the skill, then restart clients that may retain an older MCP server.",
});
expect(host.installs).toBe(1);
});