mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
feat(core): add typed policy and integrity contracts
This commit is contained in:
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type EvidenceFilePolicy,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
EvidenceIntegrityError,
|
||||
InvestigationWorkspaceError,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { BinarySessionPort } from "./BinarySession.js";
|
||||
import type { ProgressReporter } from "./ProgressReporter.js";
|
||||
import {
|
||||
AUTOMATIC_RUN_LIMITATION,
|
||||
createInventoryEvidencePages,
|
||||
@@ -54,6 +56,8 @@ export interface CrossVersionInvestigationExecution {
|
||||
readonly inputRoots: readonly string[];
|
||||
readonly session?: BinarySessionPort;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly progress?: ProgressReporter;
|
||||
readonly integrityContinueEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Run or resume a deterministic cross-version artifact investigation. */
|
||||
@@ -76,19 +80,36 @@ export const runCrossVersionInvestigation = async (
|
||||
if (!initial.ok) return initial;
|
||||
const preflight = validateWorkspaceRequest(initial.value, parsed.data);
|
||||
if (!preflight.ok) return preflight;
|
||||
if (isCancelled(execution.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
await execution.progress?.report({
|
||||
phase: "scan_versions",
|
||||
completed: 0,
|
||||
total: 4,
|
||||
message: "Scanning both version inputs",
|
||||
});
|
||||
|
||||
const snapshots = await scanVersions(
|
||||
parsed.data,
|
||||
execution.inputRoots,
|
||||
execution.signal,
|
||||
execution.integrityContinueEnabled,
|
||||
);
|
||||
if (!snapshots.ok) return snapshots;
|
||||
await execution.progress?.report({
|
||||
phase: "scan_versions",
|
||||
completed: 1,
|
||||
total: 4,
|
||||
message: "Version inputs scanned",
|
||||
});
|
||||
return continueInvestigation({
|
||||
input: parsed.data,
|
||||
initial: initial.value,
|
||||
snapshots: snapshots.value,
|
||||
policy,
|
||||
session: execution.session,
|
||||
signal: execution.signal,
|
||||
progress: execution.progress,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,6 +124,8 @@ const continueInvestigation = async (context: {
|
||||
readonly snapshots: VersionSnapshots;
|
||||
readonly policy: EvidenceFilePolicy;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly progress: ProgressReporter | undefined;
|
||||
}): Promise<Result<CrossVersionInvestigationOutcome, AnalysisError>> => {
|
||||
const left = targetFor(context.snapshots.left);
|
||||
const right = targetFor(context.snapshots.right);
|
||||
@@ -110,6 +133,9 @@ const continueInvestigation = async (context: {
|
||||
left,
|
||||
right,
|
||||
options: context.input.options,
|
||||
integrity_policy: context.input.integrity_policy,
|
||||
integrity_continue_approved: context.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: context.input.max_integrity_mismatches,
|
||||
});
|
||||
const existing = context.initial?.runs.find(
|
||||
({ run_id: id }) => id === identity.runId,
|
||||
@@ -118,6 +144,8 @@ const continueInvestigation = async (context: {
|
||||
return completeOutcome(context.initial, existing, true, context.session);
|
||||
const pages = createInventoryEvidencePages(context.input, context.snapshots);
|
||||
if (!pages.ok) return pages;
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const inventoried = await ensureInventoryCheckpoint({
|
||||
input: context.input,
|
||||
current: context.initial,
|
||||
@@ -127,20 +155,47 @@ const continueInvestigation = async (context: {
|
||||
policy: context.policy,
|
||||
});
|
||||
if (!inventoried.ok) return inventoried;
|
||||
await context.progress?.report({
|
||||
phase: "inventory_versions",
|
||||
completed: 2,
|
||||
total: 4,
|
||||
message: "Both artifact inventories checkpointed",
|
||||
});
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const compared = await ensureComparisonCheckpoint(
|
||||
context.input,
|
||||
inventoried.value,
|
||||
context.policy,
|
||||
);
|
||||
if (!compared.ok) return compared;
|
||||
return finalizeInvestigation(
|
||||
await context.progress?.report({
|
||||
phase: "compare_artifacts",
|
||||
completed: 3,
|
||||
total: 4,
|
||||
message: "Artifact comparison checkpointed",
|
||||
});
|
||||
if (isCancelled(context.signal))
|
||||
return err(new AnalysisCancelledError("find_changed_behavior"));
|
||||
const finalized = await finalizeInvestigation(
|
||||
context.input,
|
||||
compared.value,
|
||||
context.policy,
|
||||
context.session,
|
||||
);
|
||||
await context.progress?.report({
|
||||
phase: "changed_behavior",
|
||||
completed: 4,
|
||||
total: 4,
|
||||
message: finalized.ok ? "Investigation completed" : "Investigation failed",
|
||||
terminal: true,
|
||||
});
|
||||
return finalized;
|
||||
};
|
||||
|
||||
const isCancelled = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const ensureInventoryCheckpoint = async (input: {
|
||||
readonly input: CrossVersionInvestigationInput;
|
||||
readonly current: InvestigationWorkspace | null;
|
||||
@@ -162,6 +217,9 @@ const ensureInventoryCheckpoint = async (input: {
|
||||
left,
|
||||
right,
|
||||
options: input.input.options,
|
||||
integrity_policy: input.input.integrity_policy,
|
||||
integrity_continue_approved: input.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: input.input.max_integrity_mismatches,
|
||||
});
|
||||
const run = investigationRunSchema.parse({
|
||||
schema_version: 1,
|
||||
@@ -170,6 +228,9 @@ const ensureInventoryCheckpoint = async (input: {
|
||||
left,
|
||||
right,
|
||||
options: input.input.options,
|
||||
integrity_policy: input.input.integrity_policy,
|
||||
integrity_continue_approved: input.input.integrity_continue_approved,
|
||||
max_integrity_mismatches: input.input.max_integrity_mismatches,
|
||||
status: "running",
|
||||
completed_stages: ["inventory_left", "inventory_right"],
|
||||
left_inventory_evidence_ids: evidenceIds(input.pages.left),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
]),
|
||||
|
||||
@@ -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(
|
||||
|
||||
+54
-3
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
+107
-22
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
+252
-14
@@ -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 => {
|
||||
|
||||
@@ -53,17 +53,32 @@ export type InvestigationRunOptions = z.infer<
|
||||
>;
|
||||
|
||||
/** Explicit write approval and two local versions for an automatic run. */
|
||||
export const crossVersionInvestigationInputSchema = z.object({
|
||||
approved: z.literal(true),
|
||||
workspace_path: z.string().min(1).max(4_096),
|
||||
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
||||
expected_workspace_revision: z.number().int().min(1).optional(),
|
||||
left_path: z.string().min(1).max(4_096),
|
||||
right_path: z.string().min(1).max(4_096),
|
||||
options: investigationRunOptionsSchema.default(
|
||||
DEFAULT_INVESTIGATION_RUN_OPTIONS,
|
||||
),
|
||||
});
|
||||
export const crossVersionInvestigationInputSchema = z
|
||||
.object({
|
||||
approved: z.literal(true),
|
||||
workspace_path: z.string().min(1).max(4_096),
|
||||
workspace_name: z.string().trim().min(1).max(200).default("default"),
|
||||
expected_workspace_revision: z.number().int().min(1).optional(),
|
||||
left_path: z.string().min(1).max(4_096),
|
||||
right_path: z.string().min(1).max(4_096),
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
|
||||
integrity_continue_approved: z.boolean().default(false),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100).default(10),
|
||||
options: investigationRunOptionsSchema.default(
|
||||
DEFAULT_INVESTIGATION_RUN_OPTIONS,
|
||||
),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
if (
|
||||
input.integrity_policy === "record-and-continue" &&
|
||||
input.integrity_continue_approved !== true
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["integrity_continue_approved"],
|
||||
message: "record-and-continue requires explicit approval",
|
||||
});
|
||||
});
|
||||
|
||||
export type CrossVersionInvestigationInput = z.infer<
|
||||
typeof crossVersionInvestigationInputSchema
|
||||
@@ -92,6 +107,9 @@ export const investigationRunSchema = z.object({
|
||||
left: targetSchema,
|
||||
right: targetSchema,
|
||||
options: investigationRunOptionsSchema,
|
||||
integrity_policy: z.enum(["fail", "record-and-continue"]),
|
||||
integrity_continue_approved: z.boolean(),
|
||||
max_integrity_mismatches: z.number().int().min(1).max(100),
|
||||
status: z.enum(["running", "complete"]),
|
||||
completed_stages: z.array(stageSchema).max(4),
|
||||
left_inventory_evidence_ids: z.array(evidenceIdSchema).min(1).max(100),
|
||||
@@ -137,12 +155,20 @@ export const createInvestigationRunIdentity = (input: {
|
||||
readonly left: InvestigationRunTarget;
|
||||
readonly right: InvestigationRunTarget;
|
||||
readonly options: InvestigationRunOptions;
|
||||
readonly integrity_policy: CrossVersionInvestigationInput["integrity_policy"];
|
||||
readonly integrity_continue_approved: boolean;
|
||||
readonly max_integrity_mismatches: number;
|
||||
}): { readonly runId: string; readonly requestSha256: string } => {
|
||||
const requestSha256 = digestCanonical({
|
||||
schema: "rea.cross-version-investigation-request/v1",
|
||||
left: input.left,
|
||||
right: input.right,
|
||||
options: input.options,
|
||||
integrity: {
|
||||
policy: input.integrity_policy,
|
||||
approved: input.integrity_continue_approved,
|
||||
max_mismatches: input.max_integrity_mismatches,
|
||||
},
|
||||
});
|
||||
return { runId: `run_${requestSha256}`, requestSha256 };
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
@@ -12,6 +12,7 @@ export const comparisonStatusSchema = z.enum([
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
"contradiction",
|
||||
]);
|
||||
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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:
|
||||
|
||||
+134
-9
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createPackageWithOptions } from "@electron/asar";
|
||||
|
||||
import { runCrossVersionInvestigation } from "../src/application/CrossVersionInvestigation.js";
|
||||
import {
|
||||
@@ -211,6 +212,86 @@ describe("persistent cross-version investigation workspace", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checkpoints bounded integrity contradictions and safely reuses them", async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), "rea-workspace-integrity-"));
|
||||
const source = join(directory, "source");
|
||||
await mkdir(source);
|
||||
await writeFile(join(source, "addon.node"), "verified native addon\n");
|
||||
const left = join(directory, "left.asar");
|
||||
const right = join(directory, "right.asar");
|
||||
await Promise.all([
|
||||
createPackageWithOptions(source, left, { unpack: "*.node" }),
|
||||
createPackageWithOptions(source, right, { unpack: "*.node" }),
|
||||
]);
|
||||
await writeFile(join(`${right}.unpacked`, "addon.node"), "tampered\n");
|
||||
const base = {
|
||||
approved: true as const,
|
||||
workspace_path: join(directory, "strict.json"),
|
||||
workspace_name: "integrity-diff",
|
||||
left_path: left,
|
||||
right_path: right,
|
||||
options: { page_size: 500, change_limit: 100 },
|
||||
};
|
||||
const strict = await runCrossVersionInvestigation(
|
||||
crossVersionInvestigationInputSchema.parse(base),
|
||||
policy(directory),
|
||||
{ inputRoots: [directory] },
|
||||
);
|
||||
expect(strict).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
|
||||
const continuedInput = crossVersionInvestigationInputSchema.parse({
|
||||
...base,
|
||||
workspace_path: join(directory, "continued.json"),
|
||||
integrity_policy: "record-and-continue",
|
||||
integrity_continue_approved: true,
|
||||
max_integrity_mismatches: 2,
|
||||
});
|
||||
const continued = await runCrossVersionInvestigation(
|
||||
continuedInput,
|
||||
policy(directory),
|
||||
{ inputRoots: [directory], integrityContinueEnabled: true },
|
||||
);
|
||||
expect(continued).toMatchObject({
|
||||
ok: true,
|
||||
value: { reused: false, workspace: { revision: 3 } },
|
||||
});
|
||||
if (!continued.ok) throw continued.error;
|
||||
expect(
|
||||
continued.value.workspace.bundle.records.some((record) =>
|
||||
JSON.stringify(record.normalized_result).includes(
|
||||
'"integrity_contradictions":[{',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
continued.value.workspace.bundle.records.find(
|
||||
({ operation }) => operation === "compare_artifacts",
|
||||
)?.normalized_result,
|
||||
).toMatchObject({ status: "contradiction" });
|
||||
await expect(
|
||||
runCrossVersionInvestigation(continuedInput, policy(directory), {
|
||||
inputRoots: [directory],
|
||||
integrityContinueEnabled: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true, value: { reused: true } });
|
||||
|
||||
const strictAfterContinuation = crossVersionInvestigationInputSchema.parse({
|
||||
...base,
|
||||
workspace_path: continuedInput.workspace_path,
|
||||
});
|
||||
await expect(
|
||||
runCrossVersionInvestigation(strictAfterContinuation, policy(directory), {
|
||||
inputRoots: [directory],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "integrity" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on locks, CAS conflicts, tampering, and cancellation", async () => {
|
||||
const { path, input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
|
||||
@@ -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")]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user