mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(tools)!: simplify direct invocation and harden boundary contracts (#555)
Remove REA permission grants, scope ceilings, approval confirmations, and inert settings. Preserve selected local evidence, accept caller-chosen commands and paths, and report actual runtime failures. Harden provider results, schema serialization, snapshot evidence binding, archive paths, source maps, native screenshots, managed EH parsing, and cancellation cleanup. Regenerate tool metadata and document direct invocation. BREAKING CHANGE: Removed permission configuration and policy commands, approval fields, filesystem scope inputs, and fixed confirmation options. Process capture inherits the host environment and accepts command names; callers must refresh tool schemas and migrate removed fields.
This commit is contained in:
@@ -13,7 +13,7 @@ Prioritize:
|
||||
|
||||
Installers must not install or upgrade Homebrew, Node.js, npm, Java, Ghidra, or other unrelated software. Ghidra is bring-your-own. `rea setup` must print its planned changes and require approval before writing files or installing Hopper.
|
||||
|
||||
REA is a local-only tool; do not sanitize actionable local diagnostics such as artifact paths, digests, mismatch locations, or analysis metadata, while continuing to redact genuine secrets such as credentials and authorization headers.
|
||||
REA is a local-only tool. Preserve caller-selected inputs, captured output, URLs, paths, digests, mismatch locations, and analysis metadata. Do not guess that local evidence is secret from environment-variable names, argument names, or text patterns. Redact transport authentication credentials and values the caller explicitly marks sensitive; do not persist the entire ambient environment merely because a child inherits it.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
@@ -52,7 +52,9 @@ Treat a boundary as a contract between the producer's actual representation and
|
||||
|
||||
Keep portable evidence and scenario validation distinct from host-native execution checks. Absolute filesystem paths, file URLs, and HTTP paths have different semantics; do not substitute one platform's syntax for the domain concept. Interpret provider metadata according to its documented or observed producer behavior. When a transformation loses information, preserve the reported value and an explicit unknown rather than guessing a canonical identity.
|
||||
|
||||
Preserve meaningful failure reasons through application and adapter layers. A configured permission ceiling is distinct from an active grant, and malformed input is distinct from unsupported or unauthorized input. Diagnostics should identify the failed constraint and what any reported missing scope was compared against. Recovery advice must address that reason and point to an available workflow; generic catches must not erase actionable validation details.
|
||||
Preserve meaningful failure reasons through application and adapter layers. Malformed input is distinct from an unsupported target, unavailable provider, or host operating-system permission denial. Diagnostics should identify the failed constraint and the target or lifecycle request it applies to. Recovery advice must address that reason and point to an available workflow; generic catches must not erase actionable validation details.
|
||||
|
||||
Leave meaningful target, action, capture, and output choices to the agent. A selected operation already expresses intent; do not require approval booleans or repeated permission declarations. Trace each setting to its consumer: remove ignored options and single-value confirmations, derive built-in lifecycle behavior, and supply defaults for omitted optional metadata. Report actual effects and limitations where they help interpret results rather than asking callers to restate them.
|
||||
|
||||
Verify the representation consumed at the next boundary. Internal validator success does not establish JSON Schema validity or client compatibility: validate advertised input and output schemas against their declared dialect after SDK conversion, and keep generated contracts aligned. Use representative producer data and regressions that exercise the failed behavior. Platform and provider support claims require the corresponding real workflow; package startup, capability probes, and mock transport tests establish only their narrower claims. Report unverified coverage explicitly.
|
||||
|
||||
@@ -78,7 +80,7 @@ See [docs/tool-design.md](docs/tool-design.md) for the design checklist. When us
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use fake launcher/socket seams; MCP tests connect with the client SDK version pinned in `package.json`. Preserve the canonical tool inventory defined by `TOOL_CONTRACTS` and verified through `CATALOG_IDENTITY` and generated product metadata. Cover malformed input, cancellation, lifecycle cleanup, and actual format, protocol, and permission boundaries. Do not add tests that merely freeze arbitrary caps or prescribed call sequences. Real Hopper, Ghidra, browser, JavaScript replay, managed conformance, and any real managed-tool claims cannot be replaced by mocks; use the corresponding `verify:*` command.
|
||||
Name tests `*.test.ts`. Use Vitest and production seams (`tests/fixtures/`) rather than module mocks. Domain tests assert pure behavior; adapter tests use fake launcher/socket seams; MCP tests connect with the client SDK version pinned in `package.json`. Preserve the canonical tool inventory defined by `TOOL_CONTRACTS` and verified through `CATALOG_IDENTITY` and generated product metadata. Cover malformed input, cancellation, lifecycle cleanup, and actual format, protocol, host-permission, and target-identity boundaries. Do not add tests that merely freeze arbitrary caps or prescribed call sequences. Real Hopper, Ghidra, browser, JavaScript replay, managed conformance, and any real managed-tool claims cannot be replaced by mocks; use the corresponding `verify:*` command.
|
||||
|
||||
Keep tool catalogs complete and self-describing; prefer capability- and session-scoped availability over schema truncation. Serialized bytes alone do not measure agent usability or model context cost.
|
||||
|
||||
|
||||
@@ -217,15 +217,15 @@ Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills,
|
||||
|
||||
### CLI or agent?
|
||||
|
||||
| If you want to… | Use |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Run setup, restart your agent, then describe the task |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Map a local JavaScript/Electron application without executing it | `rea analyze PATH` or `rea analyze-javascript-application` |
|
||||
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /approved/path/analysis.json` to a deep-analysis command |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
|
||||
| If you want to… | Use |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Ask an agent to investigate an app and build a feature | Run setup, restart your agent, then describe the task |
|
||||
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
|
||||
| Validate, canonicalize, or compare Evidence bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
|
||||
| Map a local JavaScript/Electron application without executing it | `rea analyze PATH` or `rea analyze-javascript-application` |
|
||||
| Reuse immutable analysis results without relaunching a provider | Pass `--snapshot /path/to/analysis.json` to a deep-analysis command |
|
||||
| Import source as historical reference | `rea import-reference-source` |
|
||||
| Capture or compare controlled process behavior | `rea capture-process` or `rea compare-process-captures` |
|
||||
|
||||
```bash
|
||||
rea evidence-import /absolute/path/to/evidence/bundle.json
|
||||
@@ -251,7 +251,7 @@ Import an older source tree as a reference. REA keeps it separate from observati
|
||||
rea import-reference-source /absolute/path/to/source
|
||||
```
|
||||
|
||||
Imports read the path supplied to the command and validate every Evidence ID and manifest. Exports never replace an existing file unless `--overwrite` is explicit.
|
||||
Imports read the path supplied to the command. File names do not cause automatic omissions; files are represented by hashes and metadata. To exclude selected paths, set `REA_REFERENCE_SECRET_PATTERNS_JSON` to a JSON string array of ignore patterns. Exports never replace an existing file unless `--overwrite` is explicit.
|
||||
|
||||
Use a snapshot to save successful analysis results and reuse them on later runs. REA reuses a result only when the target bytes, operation, parameters, analysis tool, and settings match. It does not cache changes or cursor-dependent calls. Snapshot files stay local and use owner-only permissions.
|
||||
|
||||
@@ -299,7 +299,7 @@ REA handles the app analysis in steps 1 through 5. The agent performs step 6 wit
|
||||
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
|
||||
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
|
||||
|
||||
See [native investigation](docs/native-investigation.md) for keyed archives, instruction/call/type primitives, typed dispatch metadata, value traces and approved native desktop observation.
|
||||
See [native investigation](docs/native-investigation.md) for keyed archives, instruction/call/type primitives, typed dispatch metadata, value traces and native desktop observation.
|
||||
|
||||
## Tool catalog for investigation
|
||||
|
||||
@@ -308,28 +308,28 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
| Native inspection | 39 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 15 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and approved extraction |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 8 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, runtime-correlation plans, and build comparisons |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, capture comparisons, and approved browser scenarios |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, static/runtime reconciliation, and approved click/wait scenarios |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, capture comparisons, and browser scenarios |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, static/runtime reconciliation, and click/wait scenarios |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
| Application workflows | 10 | cross-layer feature traces, build comparisons, historical source mapping, return-shape comparison, approved Linux replay, and reconstruction checks |
|
||||
| Application workflows | 10 | cross-layer feature traces, build comparisons, historical source mapping, return-shape comparison, Linux replay, and reconstruction checks |
|
||||
| Workspace and observation | 22 | sessions, evidence bundles, navigation context, finite replay machines, process/artifact/function comparisons, and open-question tracking |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness implements controlled behavioral capture.
|
||||
|
||||
## Current status
|
||||
|
||||
REA supports native application, JavaScript, Electron, .NET, and browser investigation on macOS and Linux. Individual tools have their own platform and approval requirements; use `rea capabilities` to check what is available on your host.
|
||||
REA supports native application, JavaScript, Electron, .NET, and browser investigation on macOS and Linux. Individual tools have platform and runtime prerequisites; use `rea capabilities` to check what is available on your host.
|
||||
|
||||
- **Native binaries:** Open Mach-O, ELF, PE, and Mac `.app` targets through Hopper or Ghidra. Inspect functions, strings, assembly, decompilation, calls, and references. Hopper also accepts `.hop` databases and supports annotations.
|
||||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Extraction requires approval. On macOS, DMG traversal also requires approval and `REA_ARTIFACT_NATIVE_MOUNT_ENABLED=true`.
|
||||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Artifact requests name the input and requested extraction or traversal directly; macOS DMG traversal also requires the host's native mounting support.
|
||||
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- **Websites:** Inspect an approved page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and approved screenshots. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
|
||||
- **Electron and Node runtime observation:** Inspect approved Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||
- **Websites:** Inspect a selected page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and screenshots requested by the call. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
|
||||
- **Electron and Node runtime observation:** Inspect selected Electron pages or attach to a Node/Electron V8 Inspector target. Inspector observation records script locations and execution-context events; it does not infer imports, IPC activity, or which modules executed. See [runtime observation](docs/javascript-runtime-observation.md).
|
||||
- **.NET assemblies:** Inspect metadata and CIL instructions, compare builds, and check declared native dependencies without loading or running the assembly. Imported decompiler output is labeled as analyst inference. Runtime-correlation plans are supported, but an executor is not implemented. See [managed-code analysis](docs/managed-code-analysis.md).
|
||||
- **Controlled behavior capture:** Run approved process, browser, or Electron scenarios and compare the resulting evidence. Each requires its own configuration and approval. Missing observations cannot establish that two runs behaved the same way.
|
||||
- **JavaScript replay:** Run selected extracted modules in the separately approved Linux sandbox. Replay requires the isolation controls described in [controlled JavaScript replay](docs/controlled-javascript-replay.md).
|
||||
- **Controlled behavior capture:** Run process, browser, or Electron scenarios with the target, actions, and lifecycle declared in each request, then compare the resulting evidence. Missing observations cannot establish that two runs behaved the same way.
|
||||
- **JavaScript replay:** Run selected extracted modules in the Linux sandbox. Each execution request is bound to the exact plan digest; replay also requires the isolation controls described in [controlled JavaScript replay](docs/controlled-javascript-replay.md).
|
||||
- **Evidence and comparison:** Save results with artifact identity, provider, locations, confidence, and limitations. Export or import bundles, compare artifacts and functions, and connect static findings to runtime observations without claiming causality from correlation.
|
||||
- **Open questions:** Track unresolved findings, contradictions, and follow-up probes. Reconstruction checks report pass, fail, or unknown rather than treating missing evidence as a pass.
|
||||
- **Guided workflows:** Start six [MCP investigation workflows](docs/mcp-prompts.md) with suggestions based on your current session.
|
||||
@@ -338,53 +338,40 @@ Windows Ghidra operations are currently unavailable. Hopper-only features, such
|
||||
|
||||
### Website observation with CDP
|
||||
|
||||
REA can inspect an already-running Chrome-family browser that you own. Browser observation is disabled by default and requires a literal loopback CDP endpoint plus exact approved page origins:
|
||||
REA can inspect an already-running Chrome-family browser through a literal loopback CDP endpoint. Each request names the endpoint and target; an optional origin filter can narrow discovery:
|
||||
|
||||
```bash
|
||||
export REA_BROWSER_OBSERVE_ENABLED=true
|
||||
export REA_BROWSER_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
|
||||
export REA_BROWSER_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
|
||||
|
||||
rea list-browser-targets http://127.0.0.1:9222 --approved --json
|
||||
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --approved --json
|
||||
rea list-browser-targets http://127.0.0.1:9222 --json
|
||||
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID --json
|
||||
```
|
||||
|
||||
The eight passive browser tools work through both CLI and MCP. They inspect the page without navigating, clicking, or evaluating its JavaScript. Credentials, cookies, authorization headers, and raw payload values are not retained. Script sources, accessibility text, screenshots, and selected console or payload summaries require separate capture approval. REA cannot observe activity that happened before it attached. See [browser observation](docs/browser-observation.md) for browser startup, capture options, and limits.
|
||||
The eight passive browser tools work through both CLI and MCP. They inspect the selected page without navigating, clicking, or evaluating its JavaScript. Credentials, cookies, authorization headers, and raw payload values are not retained. A request selects whether to include script sources, accessibility text, screenshots, or console and payload summaries. REA cannot observe activity that happened before it attached. See [browser observation](docs/browser-observation.md) for browser startup, capture options, and limits.
|
||||
|
||||
### Controlled browser scenarios
|
||||
|
||||
`capture_browser_scenario` runs an approved sequence of browser actions through
|
||||
`capture_browser_scenario` runs a caller-declared sequence of browser actions through
|
||||
Playwright. Unlike passive observation, it can interact with the page. Each
|
||||
step records evidence such as screenshots, page structure, navigation, and
|
||||
network activity. Missing or truncated observations cannot establish that two
|
||||
runs behaved the same way.
|
||||
|
||||
```bash
|
||||
export REA_BROWSER_SCENARIO_ENABLED=true
|
||||
export REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON='["/usr/bin"]'
|
||||
export REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
|
||||
export REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
|
||||
export REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON='["REA_TEST_PASSWORD"]'
|
||||
|
||||
rea capture-browser-scenario ./scenario.json --json
|
||||
```
|
||||
|
||||
Launch mode owns a temporary browser profile and removes it after terminating
|
||||
the launched browser. Connect mode accepts one exact loopback CDP target and
|
||||
disconnects without closing the external browser. Automation has no default
|
||||
grant: use the shared project/session policy, or set
|
||||
`REA_BROWSER_SCENARIO_AUTO_GRANT=true` only for a trusted unattended
|
||||
environment. Scenario JSON contains secret references and environment-variable
|
||||
names, never secret values. See the
|
||||
disconnects without closing the external browser. The request supplies the
|
||||
selected executable or endpoint, actions, and any origin or environment
|
||||
selections needed by the scenario. Scenario JSON contains secret references and
|
||||
environment-variable names, never secret values. See the
|
||||
[browser scenario contract](docs/browser-scenario-contract.md).
|
||||
|
||||
### Node and Electron V8 Inspector observation
|
||||
|
||||
Node and Electron runtime observation is disabled by default. Enable it to attach to an existing Inspector target:
|
||||
Node and Electron runtime observation attaches to an existing Inspector target named in the request:
|
||||
|
||||
```bash
|
||||
export REA_V8_INSPECTOR_OBSERVE_ENABLED=true
|
||||
|
||||
rea list-javascript-runtime-targets http://127.0.0.1:9229 --json
|
||||
rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \
|
||||
--runtime-kind node --json
|
||||
@@ -410,14 +397,14 @@ The [current status](#current-status) section describes shipped capabilities. Th
|
||||
### Next
|
||||
|
||||
1. **Test replay isolation:** add hostile fixtures and test the Linux replay sandbox across more kernels.
|
||||
2. **Connect more application layers:** add static extractors and approved runtime observations to feature traces.
|
||||
2. **Connect more application layers:** add static extractors and runtime observations to feature traces.
|
||||
3. **Extend .NET analysis:** improve comparisons of obfuscated assemblies and connect managed findings to verified native analysis. See the [managed-code guide](docs/managed-code-analysis.md).
|
||||
4. **Compare more runtime behavior:** expand process, protocol, filesystem, reconnect, and version-comparison coverage.
|
||||
|
||||
### Later
|
||||
|
||||
1. **Expand browser and Electron interaction:** add approved scenario actions beyond the current click and wait operations.
|
||||
2. **Observe native apps at runtime:** explore LLDB, Frida, system logs, and native API tracing with explicit approval.
|
||||
1. **Expand browser and Electron interaction:** add scenario actions beyond the current click and wait operations.
|
||||
2. **Observe native apps at runtime:** explore LLDB, Frida, system logs, and native API tracing.
|
||||
3. **Evaluate more tools and targets:** assess IDA/Hex-Rays, Binary Ninja, Rizin, LIEF, Windows-native tools, mobile apps, and firmware.
|
||||
|
||||
Setup already lets you choose agent integration and Hopper installation. Support for installing additional analysis tools is future work, described in the [installation roadmap](docs/roadmap.md).
|
||||
@@ -450,7 +437,7 @@ maintains that pin, upgrades the bundled skill at the same time, and gives Codex
|
||||
a 30-second startup allowance for a cold package-runner start. An interactive
|
||||
`rea upgrade` opens the updated setup plan after installing the new executable;
|
||||
structured or non-interactive upgrades tell you to run that sync explicitly.
|
||||
Restart clients whose approved registration changed.
|
||||
Restart clients whose MCP registration changed.
|
||||
|
||||
MCP clients that support prompts can also discover six ordered investigation
|
||||
workflows through `prompts/list`. Their optional identifier arguments use the
|
||||
@@ -541,15 +528,17 @@ The session also reports active work and cleanup status. If a caller times out,
|
||||
|
||||
### CLI exit status
|
||||
|
||||
| Status | Meaning |
|
||||
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `0` | The operation completed. Results may still include warnings, partial evidence, or unresolved questions. |
|
||||
| `1` | The operation could not complete, for example because of invalid input, missing permission, cancellation, or timeout. Structured output reports the reason when available. |
|
||||
| `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
|
||||
| Status | Meaning |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `0` | The operation completed. Results may still include warnings, partial evidence, or unresolved questions. |
|
||||
| `1` | The operation could not complete, for example because of invalid input, host permission denial, cancellation, or timeout. Structured output reports the reason when available. |
|
||||
| `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
|
||||
|
||||
`setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
|
||||
because configuration is not ready; rerun it after approval or remediation.
|
||||
`doctor` returns `1` when any check is unhealthy. Output format, full envelopes,
|
||||
`doctor` returns `1` when required checks for its readiness scope are unhealthy.
|
||||
Unavailable optional replay prerequisites remain visible as informational
|
||||
diagnostics and do not block setup or unrelated providers. Output format, full envelopes,
|
||||
filters, and token controls never change the operation status.
|
||||
|
||||
When REA feeds a shell pipeline, enable `pipefail` so a downstream formatter
|
||||
@@ -570,18 +559,12 @@ Use `rea instructions` when you only need assembly instructions for a function.
|
||||
|
||||
Closing a session shuts down REA's bridge and removes its temporary socket directory while preserving a Hopper application you may be using. If cleanup cannot be verified, `close_binary` reports `cleanup_incomplete` and the affected resources.
|
||||
|
||||
## Advanced process-capture setup
|
||||
## Process capture
|
||||
|
||||
Process capture is disabled by default. Enabling it requires
|
||||
`REA_PROCESS_CAPTURE_ENABLED=true`, approved executable and working roots in
|
||||
`REA_PROCESS_EXECUTABLE_ROOTS_JSON` and `REA_PROCESS_WORKING_ROOTS_JSON`, and an
|
||||
environment allowlist in `REA_PROCESS_ALLOWED_ENV_JSON`. Because the current PTY
|
||||
adapter uses host networking, it also requires
|
||||
`REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true`.
|
||||
|
||||
Set `REA_PROCESS_CAPTURE_AUTO_GRANT=false` to configure those process-capture
|
||||
limits as a ceiling without implicitly granting them. This mode remains
|
||||
fail-closed until a narrower grant is established.
|
||||
Process capture runs the exact executable and scenario declared in the request,
|
||||
with the requested working directory, filesystem roots, environment, and
|
||||
network behavior. The process runs with your user permissions; Process Capture
|
||||
records behavior and is not a security sandbox.
|
||||
|
||||
Capture a scenario or compare two saved Process Capture Evidence records:
|
||||
|
||||
@@ -613,7 +596,7 @@ verified or absent.
|
||||
|
||||
Analysis runs locally. REA communicates with Hopper and Ghidra through authenticated private local sockets. Your agent or model provider has its own data policy.
|
||||
|
||||
Runtime observation and execution features are disabled by default and require configuration and approval. Analysis tools and launched targets run with your user permissions. Extracted JavaScript replay uses a separate Linux sandbox and requires the isolation controls described in [ADR-0002](docs/adr/0002-controlled-replay-authority-and-sandbox.md).
|
||||
Runtime requests act on the declared target and lifecycle. Analysis tools and launched targets run with your user permissions, and native UI capture still depends on macOS Accessibility and Screen Recording access. Extracted JavaScript replay runs in a separate Linux sandbox and requires the isolation controls described in [controlled JavaScript replay](docs/controlled-javascript-replay.md).
|
||||
|
||||
Windows Ghidra operations are blocked until REA implements the required process ownership, private-directory permissions, and safe-path checks. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
|
||||
|
||||
|
||||
@@ -309,6 +309,12 @@ def _procedure_references(document, params):
|
||||
})
|
||||
return {
|
||||
"procedure": _procedure_identity(procedure), "direction": direction,
|
||||
"reference_kinds_available": False,
|
||||
# The public Hopper API returns observed references but does not expose
|
||||
# enough flow metadata to enumerate calls with no resolved target.
|
||||
# Keep the required list field explicit; the provider records this as
|
||||
# unknown coverage rather than claiming the list is exhaustive.
|
||||
"unresolved_calls": [],
|
||||
"references": items,
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@ func childCount(_ element: AXUIElement) -> Int {
|
||||
var count = 0
|
||||
return AXUIElementGetAttributeValueCount(element, kAXChildrenAttribute as CFString, &count) == .success ? count : 0
|
||||
}
|
||||
func children(_ element: AXUIElement, limit: Int = 2000) -> [AXUIElement] {
|
||||
func children(_ element: AXUIElement, limit: Int) -> [AXUIElement] {
|
||||
var value: CFArray?
|
||||
let count = min(limit, childCount(element))
|
||||
if count == 0 { return [] }
|
||||
return AXUIElementCopyAttributeValues(element, kAXChildrenAttribute as CFString, 0, count, &value) == .success ? value as? [AXUIElement] ?? [] : []
|
||||
}
|
||||
func text(_ element: AXUIElement, _ key: String) -> Any { (attribute(element, key) as? String).map { String($0.prefix(4096)) } ?? NSNull() as Any }
|
||||
func text(_ element: AXUIElement, _ key: String) -> Any { (attribute(element, key) as? String) ?? NSNull() as Any }
|
||||
func windowBounds(_ element: AXUIElement) -> CGRect? {
|
||||
guard let p = attribute(element, kAXPositionAttribute), let s = attribute(element, kAXSizeAttribute),
|
||||
CFGetTypeID(p) == AXValueGetTypeID(), CFGetTypeID(s) == AXValueGetTypeID() else { return nil }
|
||||
@@ -108,14 +108,15 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
var pending: [(AXUIElement, [Int])] = [(root, [])]
|
||||
while let (element, path) = pending.popLast() {
|
||||
if nodes.count >= request.max_nodes { truncated = true; break }
|
||||
let items = children(element)
|
||||
let totalChildren = childCount(element)
|
||||
var actions: CFArray?
|
||||
AXUIElementCopyActionNames(element, &actions)
|
||||
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": childCount(element)])
|
||||
if path.count >= 32 { if !items.isEmpty { truncated = true }; continue }
|
||||
nodes.append(["path": path, "role": text(element, kAXRoleAttribute), "title": text(element, kAXTitleAttribute), "value": text(element, kAXValueAttribute), "actions": actions as? [String] ?? [], "children_count": totalChildren])
|
||||
if path.count >= 32 { if totalChildren > 0 { truncated = true }; continue }
|
||||
let available = max(0, request.max_nodes - nodes.count - pending.count)
|
||||
let count = min(available, items.count)
|
||||
if childCount(element) > count { truncated = true }
|
||||
let count = min(available, totalChildren)
|
||||
let items = children(element, limit: count)
|
||||
if totalChildren > count { truncated = true }
|
||||
for index in (0..<count).reversed() { pending.append((items[index], path + [index])) }
|
||||
}
|
||||
}
|
||||
@@ -133,8 +134,8 @@ func observe(_ request: Request) async throws -> [String: Any] {
|
||||
configuration.width = max(1, Int(bounds.width * scale)); configuration.height = max(1, Int(bounds.height * scale))
|
||||
configuration.showsCursor = false
|
||||
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration)
|
||||
guard let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]), png.count <= 8 * 1024 * 1024 else {
|
||||
try fail("image-limit", "Selected-window PNG exceeded 8 MiB")
|
||||
guard let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else {
|
||||
try fail("capture-failed", "Selected-window PNG encoding failed")
|
||||
}
|
||||
screenshot = ["mime_type": "image/png", "base64": png.base64EncodedString(), "sha256": SHA256.hash(data: png).map({ String(format: "%02x", $0) }).joined(), "width": image.width, "height": image.height]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
Linux-x86_64 Bubblewrap, seccomp, and delegated systemd user-cgroup backend.
|
||||
Hosts that cannot establish every required boundary fail closed.
|
||||
|
||||
> Historical design note: REA's separate permission-policy, grant, and
|
||||
> per-call approval workflow was later removed. Current tool requests directly
|
||||
> name their target and lifecycle. The sandbox and exact plan-digest behavior
|
||||
> described here remain part of controlled replay.
|
||||
|
||||
## Context
|
||||
|
||||
REA can reconstruct JavaScript and Electron artifacts, observe an existing
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# ADR-0003: Managed-code evidence and provider boundary
|
||||
|
||||
> Historical design note: The admission record described below remains a
|
||||
> plan-only output. The separate administrator and per-call approval workflow
|
||||
> was removed; current tool requests directly declare their target and
|
||||
> lifecycle.
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-16
|
||||
- Implementation status: Read-only PE/CLI triage and exact identity are shipped
|
||||
@@ -19,11 +24,12 @@ inspect-managed-members` provides CLI parity. Static member comparison and build
|
||||
addresses. Decompiler reconstruction import is
|
||||
shipped through `import_managed_reconstruction` /
|
||||
`rea import-managed-reconstruction`; it records user-supplied C#/IL/pseudocode
|
||||
as analyst inference only after exact static member locks match. Default-disabled
|
||||
runtime-correlation admission planning is shipped through
|
||||
as analyst inference only after exact static member locks match. Non-executing
|
||||
runtime-correlation planning is shipped through
|
||||
`plan_managed_runtime_correlation` / `rea plan-managed-runtime-correlation`;
|
||||
it does not attach, load, debug, reflect, instrument, invoke, or execute
|
||||
target code. Managed static graph projection is shipped through
|
||||
it does not probe a runtime, establish confinement, attach, load, debug,
|
||||
reflect, instrument, invoke, or execute target code. Managed static graph
|
||||
projection is shipped through
|
||||
`project_managed_application_graph` /
|
||||
`rea project-managed-application-graph`; it projects authenticated managed
|
||||
Evidence into the application graph without changing managed tokens into
|
||||
@@ -356,15 +362,13 @@ emit full IL, decompiled source, runtime logs, user data, credentials, or
|
||||
service/account material. No proprietary binary, hash-locked application
|
||||
manifest, or derived dump is committed to REA.
|
||||
|
||||
### 8. Keep runtime correlation a different, default-disabled authority
|
||||
### 8. Keep runtime correlation planning distinct from execution
|
||||
|
||||
Static managed support grants no permission to attach, load, debug, reflect,
|
||||
instrument, invoke, or execute. The shipped runtime-correlation planning
|
||||
capability distinguishes those effects, remains disabled by default, requires
|
||||
explicit administrator and per-call approval, and binds to the exact artifact
|
||||
Runtime-correlation planning records requested effects and bounds without
|
||||
probing the runtime or establishing confinement. It binds to the exact artifact
|
||||
SHA-256, MVID, method signature, and CIL/body shape observed by the static
|
||||
path. It records an admission plan only; a future executor must pass a separate
|
||||
threat model before any effect can occur.
|
||||
path, and records that no target code ran. A future executor requires its own
|
||||
provider and threat model before any effect can occur.
|
||||
|
||||
Runtime admission must additionally validate host OS, CLR family, architecture,
|
||||
and supported build/tool versions; bound time, threads, outputs, UI and network
|
||||
@@ -440,8 +444,8 @@ or development-only oracles.
|
||||
5. Add source-built, pinned real-tool, package, CLI, and MCP conformance.
|
||||
Source-owned conformance is shipped; pinned external real-tool checks remain
|
||||
planned.
|
||||
6. Add separately authorized runtime-correlation admission planning. Shipped;
|
||||
runtime execution remains planned.
|
||||
6. Add non-executing runtime-correlation planning. Shipped; runtime execution
|
||||
and confinement remain planned.
|
||||
7. Project authenticated managed static Evidence into the application graph.
|
||||
Shipped.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Configuration"
|
||||
CONFIG["src/config.ts<br/>Zod-validated env parsing<br/>provider settings, origins, and approved roots"]
|
||||
CONFIG["src/config.ts<br/>Zod-validated env parsing<br/>provider settings and origins"]
|
||||
end
|
||||
|
||||
subgraph "Tests"
|
||||
|
||||
+28
-44
@@ -4,20 +4,20 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools
|
||||
|
||||
## Shipped surfaces
|
||||
|
||||
- `list_browser_targets` / `rea list-browser-targets` discovers page targets whose current URL matches an approved exact origin.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses approved script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally approved source-map/original-source evidence.
|
||||
- `list_browser_targets` / `rea list-browser-targets` discovers page targets at the selected target's current origin by default, with an optional exact-origin filter.
|
||||
- `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally selected storage key names, redacted storage-content fingerprints, or script sources.
|
||||
- `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence.
|
||||
- `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata.
|
||||
- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete structural input-schema summary inline. REA never exposes `WebMCP.invokeTool`; malformed protocol payloads fail explicitly instead of returning incomplete tool metadata.
|
||||
- `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns an explicitly approved, content-addressed visible-viewport PNG.
|
||||
- `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG.
|
||||
- `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services.
|
||||
- Every surface has equivalent CLI and MCP contracts and returns Evidence provenance.
|
||||
- MCP tools return complete results inline and include the Evidence ID. Session evidence can be exported with `export_evidence_bundle`.
|
||||
|
||||
Target discovery returns the complete approved target array in one result.
|
||||
Target discovery returns the complete in-scope target array in one result.
|
||||
|
||||
Electron `file://` pages use a separate provider, permission capability, and root model; see [electron-observation.md](electron-observation.md).
|
||||
Electron `file://` pages use a separate provider and target boundary; see [electron-observation.md](electron-observation.md).
|
||||
Existing static application Evidence and passive web/Electron captures can be
|
||||
combined later through
|
||||
[JavaScript static/runtime reconciliation](javascript-runtime-reconciliation.md).
|
||||
@@ -35,35 +35,20 @@ google-chrome \
|
||||
|
||||
REA does not launch, own, or terminate this browser. Use a dedicated profile and stop it yourself when the investigation is complete. Do not expose the debugging port on a non-loopback interface.
|
||||
|
||||
## Configure authority
|
||||
## Request boundary
|
||||
|
||||
Browser observation is fail-closed by default. Configure the administrator ceiling before starting REA:
|
||||
|
||||
```bash
|
||||
export REA_BROWSER_OBSERVE_ENABLED=true
|
||||
export REA_BROWSER_CDP_ENDPOINTS_JSON='["http://127.0.0.1:9222"]'
|
||||
export REA_BROWSER_ALLOWED_ORIGINS_JSON='["http://127.0.0.1:3000"]'
|
||||
```
|
||||
|
||||
`REA_BROWSER_CDP_ENDPOINTS_JSON` accepts at most 16 literal `http://127.0.0.1:PORT` or `http://[::1]:PORT` endpoints. `localhost`, private-LAN addresses, HTTPS endpoints, credentials, paths, queries, fragments, and implicit ports are rejected.
|
||||
|
||||
`REA_BROWSER_ALLOWED_ORIGINS_JSON` accepts at most 32 exact HTTP(S) origins. Paths, credentials, queries, fragments, and wildcards are rejected. An input must stay within both this administrator ceiling and any active project/session/once grant.
|
||||
|
||||
Configuration is reloadable through the existing `SIGHUP` permission-policy path. Each request is checked against the configured endpoint, origin, and permission grants.
|
||||
|
||||
Inspect the exact effective scope without contacting the browser:
|
||||
|
||||
```bash
|
||||
rea policy explain browser_observe \
|
||||
--origins http://127.0.0.1:9222 \
|
||||
--origins http://127.0.0.1:3000 \
|
||||
--network loopback \
|
||||
--json
|
||||
```
|
||||
Each request supplies a literal loopback CDP endpoint and target. Target
|
||||
discovery uses the selected target's current origin by default; an optional
|
||||
exact HTTP(S) origin filter can narrow the result. `localhost`, private-LAN
|
||||
addresses, HTTPS CDP endpoints, credentials, paths, queries, fragments, and
|
||||
implicit ports are not valid CDP endpoints. Origin filters contain only scheme,
|
||||
host, and port; paths, credentials, queries, fragments, and wildcards are not
|
||||
valid origins.
|
||||
|
||||
## CLI workflow
|
||||
|
||||
Configured origins are used by default. `--allowed-origins` can request a narrower set that still fits the configured ceiling.
|
||||
Pass the endpoint and selected target for each call. An origin filter is
|
||||
optional and narrows the target set.
|
||||
|
||||
```bash
|
||||
rea list-browser-targets http://127.0.0.1:9222 --json
|
||||
@@ -96,7 +81,7 @@ rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
|
||||
--json
|
||||
```
|
||||
|
||||
Accessibility text and script content may contain application secrets and become part of the returned Evidence. Credential-shaped console substrings are redacted. JSON and WebSocket captures retain complete paths, types, and counts, never values or examples. Storage values remain redacted even when key-name capture is requested. `--include-storage-fingerprints` requests SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely.
|
||||
Accessibility text, script content, query values, and explicitly requested primitive console text may contain application secrets and become part of the returned Evidence. URL username/password credentials are removed; ordinary query values, fragments, and parameter order remain intact. JSON and WebSocket captures retain complete paths, types, and counts, never values or examples. Storage values remain redacted even when key-name capture is requested. `--include-storage-fingerprints` requests SHA-256 identity/value fingerprints, which support comparison only when cookies, DOM storage, IndexedDB, and Cache Storage were all captured completely.
|
||||
|
||||
Screenshot capture returns the complete visible viewport as an inline image artifact:
|
||||
|
||||
@@ -110,7 +95,6 @@ rea capture-web-screenshot http://127.0.0.1:9222 TARGET_ID \
|
||||
```json
|
||||
{
|
||||
"cdp_endpoint": "http://127.0.0.1:9222",
|
||||
"allowed_origins": ["http://127.0.0.1:3000"],
|
||||
"target_id": "TARGET_ID_FROM_LIST_BROWSER_TARGETS"
|
||||
}
|
||||
```
|
||||
@@ -121,25 +105,25 @@ Collection counts are not capped. Per-value validation, the selected observation
|
||||
window, and CDP protocol limits still apply; any incomplete or truncated values
|
||||
are reported in the result.
|
||||
|
||||
Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching.
|
||||
Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching. Without an origin filter, discovery lists all eligible pages; an individual capture defaults to the selected page's current origin.
|
||||
|
||||
REA establishes an authorized main-frame boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
|
||||
REA establishes a main-frame origin boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
|
||||
|
||||
## Data minimization
|
||||
|
||||
REA removes sensitive values before normalized event data is retained:
|
||||
REA retains local observation data and removes only URL userinfo credentials, explicitly declared scenario secrets, and values from structured credential/storage fields:
|
||||
|
||||
- URLs retain a bounded path and at most 256 bounded query parameter names, replace every retained query value with `[REDACTED]`, and remove credentials and fragments.
|
||||
- URL observations preserve the complete local URL, including query values, fragments, duplicate parameters, and their order. Only username/password userinfo is removed; malformed original URL strings remain available with `origin: null`.
|
||||
- DOM snapshots retain node types, node names, value lengths, and attribute names, but not text or attribute values.
|
||||
- Accessibility structure and roles are retained by default, while names and descriptions require `include_accessibility_text: true`.
|
||||
- Network observations retain method, status, MIME type, size, type, initiator stack location, and redacted URLs. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
|
||||
- Network observations retain method, status, MIME type, size, type, initiator stack location, and complete URLs with userinfo credentials removed. Headers are discarded after an allowlisted projection of length/encoding, structured CSP/Link/policy fields, and untrusted agent hints. Cookies and authorization headers are never retained.
|
||||
- Request/response bodies are not requested or parsed by default. When selected, only allowed-origin JSON media types are read, converted immediately to complete value-free property paths/types, and discarded. `Network.getResponseBody` is never sent unless JSON body shape capture is selected.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and redacted source location. When console text capture is selected, already-delivered primitive values are retained after credential redaction; objects, getters, and remote properties are never expanded.
|
||||
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
|
||||
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
|
||||
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
|
||||
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
|
||||
|
||||
Cross-origin frames, resources, scripts, events, and workers are excluded unless their exact origins are also approved. Excluded target details are counted without being exposed.
|
||||
Frames, resources, scripts, events, and workers outside the selected origin scope are excluded. An explicit `allowed_origins` list can include additional origins; excluded target details are counted without being exposed.
|
||||
Retained workers include validated opener-target and parent-frame IDs when CDP
|
||||
provides them; these relationships do not expand the exact-origin boundary or
|
||||
prove static module ownership.
|
||||
@@ -156,12 +140,12 @@ coverage. It reports excluded items and any values omitted or truncated by
|
||||
validation; it does not drop records to meet an internal collection quota.
|
||||
Disallowed-origin entries are filtered before retention.
|
||||
|
||||
CDP discovery, WebSocket connection, and each command have a 5-second timeout. Version discovery is capped at 64 KiB, target discovery at 2 MiB, each WebSocket message at 16 MiB, and correlated pending commands at 128. Target and navigation-event inventories have no separate count cap. These transport bounds protect protocol framing and request correlation; they do not silently trim returned findings. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited protocol events poison the connection so later commands fail closed.
|
||||
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis can fetch them only after separate approval, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial.
|
||||
Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. Source maps are not fetched by inspection. Bundle analysis fetches them only when `fetch_source_maps` is selected, with exact-origin redirect checks and no credentials, cookies, or referrer. Every requested map, mapping, original source, AST node, and finding from parsed content is returned inline; failed maps remain explicitly unavailable and make bundle completeness partial.
|
||||
|
||||
## Non-goals and threat model
|
||||
|
||||
The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and requires separate literal approval. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
|
||||
The browser provider deliberately does not expose generic CDP commands, `Runtime.evaluate`, WebMCP invocation, navigation, input, downloads, page closure, or browser closure. Screenshot capture is the sole pixel surface and returns an image only when requested. REA disables the domains it enabled, detaches the target session, and closes only its own WebSocket.
|
||||
|
||||
This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, approve only origins you intend to inspect, and treat other same-user processes as outside this boundary.
|
||||
This feature is not a browser sandbox or network containment mechanism. The attached page and browser continue running with their existing privileges and may make external requests independently of REA. CDP gives deep access to the selected browser profile, so use a dedicated profile, include only origins you intend to inspect, and treat other same-user processes as outside this boundary.
|
||||
|
||||
@@ -7,38 +7,48 @@ it admits a fixed action vocabulary, exact HTTP(S) origins, deterministic
|
||||
browser settings, explicit storage and request replay, and provider-owned
|
||||
liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path.
|
||||
|
||||
The minimal request contains only browser launch/connect authority, `start_url`,
|
||||
`allowed_origins`, and at least one `actions` entry. Environment settings,
|
||||
empty storage, disabled request replay, the required redaction policy, and a
|
||||
final sanitized URL capture are supplied by default. Add secret declarations,
|
||||
storage seeds, replay routes, query redaction names, and additional artifact or
|
||||
event capture only when the investigation needs them. Action, secret, storage,
|
||||
The minimal request contains only browser launch/connect selection, `start_url`,
|
||||
and at least one `actions` entry. Launch `headless` defaults to `true` and can
|
||||
be set to `false` where the host supports a visible browser. `allowed_origins`
|
||||
defaults to the origin of
|
||||
`start_url`; add exact origins only when navigation, storage, or replay needs
|
||||
another origin. Environment settings (including service workers blocked by
|
||||
default), empty storage, disabled request replay, and a final sanitized URL
|
||||
capture are supplied by default. Set `environment.service_workers` to `allow`
|
||||
when the application requires them. Add secret declarations, storage seeds,
|
||||
replay routes, and additional artifact or event capture only when the
|
||||
investigation needs them. Action, secret, storage,
|
||||
and replay-route counts are not capped. Duration, action, and navigation
|
||||
timeouts remain fixed provider-owned liveness limits; the request does not
|
||||
accept a caller-controlled `limits` object.
|
||||
|
||||
The browser boundary is part of the contract. Launch mode requires a
|
||||
caller-selected absolute executable, a provider-owned temporary profile,
|
||||
headless operation, and close-plus-delete cleanup. Connect mode accepts only an
|
||||
explicit-port loopback CDP endpoint, declares the browser externally owned, and
|
||||
permits disconnect-only cleanup. The Playwright driver preserves this
|
||||
caller-selected executable and always uses a provider-owned temporary profile
|
||||
that is closed and deleted during cleanup. Connect mode accepts only an
|
||||
explicit-port loopback CDP endpoint. The Playwright driver preserves this
|
||||
distinction: it closes and deletes provider-owned profiles, but only disconnects
|
||||
from an external CDP browser. Real-browser verification checks both outcomes and
|
||||
confirms that an attached external browser remains alive.
|
||||
|
||||
URLs contain a query-free HTTP(S) base plus ordered query entries. Every query,
|
||||
form, storage, cookie, and replay value is either an explicitly public literal
|
||||
or a reference to a declared environment-backed secret. Raw credentials in
|
||||
URLs and literal credential headers are rejected. Secret query names must also
|
||||
appear in the redaction policy, and the required credential headers cannot be
|
||||
removed from that policy. Durable results must replace resolved secret values
|
||||
HTTP(S) URLs may include ordinary query values and fragments directly. Use
|
||||
ordered structured query entries when a value needs to reference a declared
|
||||
environment-backed secret. Form, storage, cookie, and replay values are either
|
||||
literal strings or declared secret references. Raw URL userinfo credentials
|
||||
and provider-owned replay headers are rejected. Captured credential-header
|
||||
values are not retained, and declared secret values are redacted automatically.
|
||||
A secret may be declared solely to
|
||||
redact matching observed content; every secret reference in an action, URL,
|
||||
storage value, or replay route still needs a declaration. Ordinary query values
|
||||
and fragments remain intact. Durable results replace resolved secret values
|
||||
with their secret references.
|
||||
|
||||
Every start, navigation, storage, replay, and redirect origin must be listed in
|
||||
`allowed_origins`. Unsupported action tags, unknown fields, duplicate step or
|
||||
route IDs, undeclared or unused secrets, and provider-owned replay headers fail
|
||||
Every start, navigation, storage, replay, and redirect origin must be in the
|
||||
effective origin set: the explicit `allowed_origins`, or the `start_url` origin
|
||||
when the list is omitted or empty. Unsupported action tags, unknown fields, duplicate step or
|
||||
route IDs, undeclared secret references, and provider-owned replay headers fail
|
||||
validation. HTTP and WebSocket routing enforces the same declared scope. Exact
|
||||
replay may abort unmatched requests or pass through only approved origins.
|
||||
replay may abort unmatched requests or pass through only origins included in
|
||||
`allowed_origins`.
|
||||
|
||||
The result is Evidence with an initial state followed by one record per
|
||||
declared action. Each step reports action status, elapsed time, sanitized URLs,
|
||||
@@ -53,21 +63,13 @@ inline without application-defined size ceilings. Other missing sections
|
||||
remain explicit and make the capture ineligible for equality claims.
|
||||
Attach-mode captures also declare the unavoidable pre-attach event gap.
|
||||
|
||||
Browser automation is disabled by default. The administrator ceiling is
|
||||
configured with:
|
||||
|
||||
- `REA_BROWSER_SCENARIO_ENABLED`
|
||||
- `REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON`
|
||||
- `REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON`
|
||||
|
||||
Unlike passive observation, browser automation is not granted automatically.
|
||||
Use an explicit project/session grant, or opt into the administrator grant with
|
||||
`REA_BROWSER_SCENARIO_AUTO_GRANT=true` for a trusted unattended environment.
|
||||
Authorization commits the exact executable or CDP endpoint, origins, environment
|
||||
variable names, network class, and scenario digest before any browser side
|
||||
effect.
|
||||
Each request names the exact launch executable or loopback CDP endpoint, target,
|
||||
actions, and capture behavior. Origin filters and environment-variable names
|
||||
are included when the scenario needs them.
|
||||
Launch owns a temporary profile; connect mode disconnects from the selected
|
||||
external browser without closing it. The request itself defines the operation;
|
||||
there is no additional REA grant step. The browser and host still enforce their
|
||||
own access and process rules.
|
||||
|
||||
## Scenario comparison
|
||||
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
# Controlled JavaScript replay
|
||||
|
||||
`run_controlled_replay` executes only operator-selected extracted modules. It
|
||||
`run_controlled_replay` executes only the extracted modules named in the request. It
|
||||
does not launch or drive the original application. Static inference, passive
|
||||
browser/Electron observation, isolated replay, and real application behavior
|
||||
remain different authorities.
|
||||
|
||||
## Enable the ceiling
|
||||
## Host prerequisites
|
||||
|
||||
Replay is disabled by default. Configure exact source roots before REA starts:
|
||||
|
||||
```sh
|
||||
export REA_JAVASCRIPT_REPLAY_ENABLED=true
|
||||
export REA_JAVASCRIPT_REPLAY_ROOTS_JSON='["/absolute/extracted/modules"]'
|
||||
```
|
||||
|
||||
The default trusted executables are the current Node.js runtime,
|
||||
Replay requires the current Node.js runtime,
|
||||
`/usr/bin/bwrap`, `/usr/bin/systemd-run`, `/usr/bin/systemctl`, and
|
||||
`/usr/bin/bash`. Override them only with the corresponding
|
||||
`/usr/bin/bash`. Override executable paths only with the corresponding
|
||||
`REA_JAVASCRIPT_REPLAY_*_PATH` variable. Configuration creates the
|
||||
administrator ceiling; it does not replace per-call approval. `rea doctor`
|
||||
reports disabled, available, and exact sandbox-probe failures without running
|
||||
module code. REA setup never installs these host components.
|
||||
runtime configuration; REA setup never installs these host components. Check
|
||||
availability with `rea doctor`, which reports exact sandbox-probe failures
|
||||
without running module code.
|
||||
|
||||
## Plan, review, execute
|
||||
|
||||
@@ -31,16 +24,19 @@ limits, no-network policy, private filesystems, and a `plan_digest`.
|
||||
Runtime commitments include the exact worker, seccomp filter, Node executable,
|
||||
ELF loader, and shared-library source/destination paths and SHA-256 digests.
|
||||
|
||||
The execute request must repeat the same manifest and include:
|
||||
The execute request repeats the same manifest and includes the exact digest
|
||||
returned by the plan request:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "execute",
|
||||
"approved": true,
|
||||
"plan_digest": "<exact digest returned by plan>"
|
||||
}
|
||||
```
|
||||
|
||||
The execute request directly selects that content-bound operation. There is no
|
||||
separate REA grant or approval field.
|
||||
|
||||
REA rebuilds the plan. Any changed module, stub, case, runtime, backend, limit,
|
||||
or export commitment returns `plan_stale` before worker admission.
|
||||
|
||||
@@ -82,7 +78,7 @@ comparison never erases the underlying observations.
|
||||
Optional `reproducer_export` is committed by the plan and writes to its
|
||||
caller-supplied path after the sandbox has stopped.
|
||||
The owner-only manifest is written only after complete sandbox cleanup. Source
|
||||
bytes are excluded unless `include_sources: true` was explicitly approved.
|
||||
bytes are excluded unless `include_sources: true` was selected in the request.
|
||||
An export failure is retained in the result and does not erase a completed
|
||||
replay observation.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Electron file-page observation
|
||||
|
||||
REA can attach to a user-owned Electron/Chromium CDP endpoint and inspect existing `file://` renderer pages without evaluating JavaScript or invoking Electron APIs. The caller supplies the endpoint directly; REA accepts only literal-port loopback HTTP endpoints. Selecting an endpoint exposes every Electron target it serves, including local file paths and page metadata.
|
||||
REA can attach to an Electron/Chromium CDP endpoint and inspect existing `file://` renderer pages without evaluating JavaScript or invoking Electron APIs. The request supplies the endpoint directly; REA accepts only literal-port loopback HTTP endpoints. Selecting an endpoint exposes every Electron target it serves, including local file paths and page metadata.
|
||||
|
||||
This passive runtime surface is distinct from the target-free static
|
||||
[`analyze_javascript_application`](javascript-artifact-reconstruction.md)
|
||||
@@ -11,15 +11,13 @@ Use the separate
|
||||
[`reconcile_javascript_runtime`](javascript-runtime-reconciliation.md) workflow
|
||||
when both Evidence sets already exist.
|
||||
|
||||
## Configure authority
|
||||
## Target boundary
|
||||
|
||||
The capability is disabled by default:
|
||||
|
||||
```bash
|
||||
export REA_ELECTRON_OBSERVE_ENABLED=true
|
||||
```
|
||||
|
||||
Its permission ceiling is loopback-only. REA accepts local hostless `file://` URLs that resolve to regular files; remote hosts, encoded path separators, and nonexistent paths are rejected. There is no separate filesystem-root configuration.
|
||||
The request supplies a literal-port loopback endpoint and, for inspection, a
|
||||
target ID. REA accepts local hostless `file://` URLs that resolve to regular
|
||||
files; remote hosts, encoded path separators, and nonexistent paths are
|
||||
rejected. The endpoint exposes every eligible target and its local path
|
||||
metadata.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -33,7 +31,8 @@ rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
--observation-ms 100 --json
|
||||
```
|
||||
|
||||
Script content is excluded by default. Requesting it includes every authorized script source:
|
||||
Script content is excluded by default. Setting `include_script_sources` in the
|
||||
request returns the selected target's script sources:
|
||||
|
||||
```bash
|
||||
rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
@@ -43,9 +42,9 @@ rea inspect-electron-page http://127.0.0.1:9223 TARGET_ID \
|
||||
|
||||
The normalized result contains canonical local paths, frame and DOM
|
||||
structure, resource metadata, stable script/resource identities, explicit
|
||||
completeness, and content-addressed approved source artifacts. Script metadata
|
||||
completeness, and content-addressed script artifacts. Script metadata
|
||||
retains its execution-context frame ID when CDP supplies one. The capture also
|
||||
inventories authorized worker, service-worker, and shared-worker targets with
|
||||
inventories worker, service-worker, and shared-worker targets with
|
||||
validated opener-target and parent-frame IDs. Worker discovery uses passive
|
||||
target metadata; REA does not attach to or execute code in those targets.
|
||||
Collection counts and aggregate script-source bytes are not capped. Like every
|
||||
@@ -59,31 +58,25 @@ invoke Electron IPC, close a target, or terminate the application.
|
||||
|
||||
## Active Electron scenarios
|
||||
|
||||
Active Electron authority is separate and disabled by default. When enabled,
|
||||
REA launches an operator-approved Electron executable through the official
|
||||
Playwright Electron API, owns its lifetime, accepts bounded click/wait actions,
|
||||
The scenario request supplies the Electron executable and application entry
|
||||
point, plus any arguments and actions. The application root defaults to the
|
||||
entry point's parent directory and can be supplied when the app uses a different
|
||||
root. REA launches that executable through
|
||||
the official Playwright Electron API and owns its lifetime. It accepts click/wait actions,
|
||||
window-targeted renderer reload/crash actions, and synthetic `open-url` or
|
||||
`second-instance` deep-link delivery. It records capture-scoped window and
|
||||
WebContents identities, process metrics, and IPC channel and value-shape metadata.
|
||||
Payload values are not retained.
|
||||
|
||||
Configure exact roots and run the real fixture verifier with an operator-owned
|
||||
Electron runtime:
|
||||
Run the real fixture verifier with an operator-provided Electron runtime:
|
||||
|
||||
```bash
|
||||
export REA_ELECTRON_AUTOMATE_ENABLED=true
|
||||
export REA_ELECTRON_AUTOMATE_AUTO_GRANT=false
|
||||
export REA_ELECTRON_AUTOMATE_EXECUTABLE_ROOTS_JSON='["/absolute/path/to/runtime"]'
|
||||
export REA_ELECTRON_AUTOMATE_APPLICATION_ROOTS_JSON='["/absolute/path/to/app"]'
|
||||
REA_ELECTRON_EXECUTABLE=/absolute/path/to/electron npm run verify:electron
|
||||
```
|
||||
|
||||
This capability actively launches and interacts with the target. It is not a
|
||||
passive CDP observation and must be granted separately as `electron_automate`.
|
||||
The default is fail-closed (`AUTO_GRANT=false`); an operator can instead issue
|
||||
a project/session/one-shot grant through the normal permission workflow. The
|
||||
owned process keeps normal host filesystem and network privileges, so this is
|
||||
an authority boundary and lifecycle boundary, not a sandbox.
|
||||
This operation actively launches and interacts with the selected target. It is
|
||||
not a passive CDP observation. The owned process keeps normal host filesystem
|
||||
and network privileges, so lifecycle ownership does not make it a sandbox.
|
||||
|
||||
The CLI and MCP surfaces accept the same schema. For a JSON request file:
|
||||
|
||||
@@ -106,7 +99,7 @@ caller-selected capture limits.
|
||||
|
||||
Active Electron Evidence can also be supplied to
|
||||
[`reconcile_javascript_runtime`](javascript-runtime-reconciliation.md). That
|
||||
projection is intentionally target-only and partial: it binds the approved
|
||||
projection is intentionally target-only and partial: it binds the selected
|
||||
application path and capture outcome to the static graph, while frames, scripts,
|
||||
workers, and execution claims remain unavailable until a separate passive runtime
|
||||
capture provides them.
|
||||
|
||||
+34
-250
@@ -9,7 +9,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -23,12 +22,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -48,7 +43,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -62,12 +56,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -87,7 +77,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -101,12 +90,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -126,7 +111,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -140,12 +124,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -165,7 +145,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -179,12 +158,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -204,7 +179,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -218,12 +192,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -243,7 +213,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -257,12 +226,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -304,7 +269,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -318,12 +282,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -343,7 +303,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -357,12 +316,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -382,7 +337,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -396,12 +350,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -413,137 +363,6 @@
|
||||
"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" }
|
||||
},
|
||||
"origins": { "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" }
|
||||
},
|
||||
"origins": { "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" }
|
||||
},
|
||||
"origins": {
|
||||
"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": {
|
||||
@@ -552,7 +371,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -566,12 +384,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -591,7 +405,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -605,12 +418,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -630,7 +439,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -644,12 +452,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -669,7 +473,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -683,12 +486,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -708,7 +507,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -722,12 +520,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -747,7 +541,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -761,12 +554,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
@@ -786,7 +575,6 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"invalid_input",
|
||||
"permission_required",
|
||||
"unsupported_provider",
|
||||
"integrity_mismatch",
|
||||
"truncated",
|
||||
@@ -800,12 +588,8 @@
|
||||
"retryable": { "type": "boolean" },
|
||||
"remediation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "minLength": 1 },
|
||||
"restart_required": { "type": "boolean" },
|
||||
"elicitation_supported": { "type": "boolean" }
|
||||
},
|
||||
"required": ["action", "restart_required"],
|
||||
"properties": { "action": { "type": "string", "minLength": 1 } },
|
||||
"required": ["action"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"details": {
|
||||
|
||||
@@ -146,8 +146,7 @@ For two operator-provided directories or ASARs, run:
|
||||
```bash
|
||||
npm run verify:application-workflows -- \
|
||||
--left /absolute/path/to/version-a \
|
||||
--right /absolute/path/to/version-b \
|
||||
--source-map-read-approved
|
||||
--right /absolute/path/to/version-b
|
||||
```
|
||||
|
||||
The verifier reconstructs both versions independently, compares them, runs one
|
||||
@@ -166,12 +165,11 @@ another persistence format.
|
||||
## Controlled replay boundary
|
||||
|
||||
Static graph workflows never execute a graph node or recovered module.
|
||||
`run_controlled_replay` is the separate extracted-module boundary: it requires
|
||||
the `javascript_replay` authority, a plan call followed by exact content-bound
|
||||
approval, and the mandatory Linux OS sandbox fixed by
|
||||
[ADR-0002](adr/0002-controlled-replay-authority-and-sandbox.md). Browser,
|
||||
Electron, Process Capture, artifact-read, and static-analysis approvals do not
|
||||
authorize that execution.
|
||||
`run_controlled_replay` is the separate extracted-module boundary. A plan
|
||||
request and execute request share the same manifest; execution must include the
|
||||
exact plan digest, and the worker runs inside the mandatory Linux OS sandbox.
|
||||
The sandbox is an execution boundary; it does not make replay a real application
|
||||
runtime observation.
|
||||
|
||||
Replay observations retain `controlled-replay` authority. They cannot
|
||||
promote static inference into passive runtime observation or prove that the
|
||||
|
||||
@@ -125,9 +125,10 @@ For `.node` bindings, member names mean “requested by JavaScript syntax.” A
|
||||
resolved add-on path does not convert them into verified native exports. Native
|
||||
symbol verification remains a separate deep-analysis claim.
|
||||
|
||||
Endpoint observations preserve useful local diagnostics while removing URL
|
||||
credentials, fragments, and query values. Artifact paths, digests, parse
|
||||
locations, and analysis metadata remain actionable because REA is local-only.
|
||||
Endpoint observations preserve useful local diagnostics, including query values
|
||||
and fragments, while removing only URL username/password credentials. Artifact
|
||||
paths, digests, parse locations, and analysis metadata remain actionable
|
||||
because REA is local-only.
|
||||
|
||||
## Safety boundary
|
||||
|
||||
|
||||
@@ -7,13 +7,11 @@ have equivalent `rea list-javascript-runtime-targets` and
|
||||
`rea observe-javascript-runtime` commands. Successful calls return
|
||||
deterministic Evidence.
|
||||
|
||||
This authority is disabled by default and is separate from browser CDP,
|
||||
Electron file-page inspection, Process Capture, and controlled JavaScript
|
||||
replay:
|
||||
Each request supplies the loopback Inspector endpoint and, for observation, the
|
||||
target ID. This surface is separate from browser CDP, Electron file-page
|
||||
inspection, Process Capture, and controlled JavaScript replay:
|
||||
|
||||
```bash
|
||||
export REA_V8_INSPECTOR_OBSERVE_ENABLED=true
|
||||
|
||||
rea list-javascript-runtime-targets http://127.0.0.1:9229 \
|
||||
--json
|
||||
rea observe-javascript-runtime http://127.0.0.1:9229 TARGET_ID \
|
||||
@@ -98,14 +96,14 @@ Electron role, so Evidence records the role authority as
|
||||
`caller-declared-unverified`.
|
||||
|
||||
Electron main, preload, and renderer behavior can therefore be observed only
|
||||
as separate approved Inspector targets. The provider does not infer that two
|
||||
as separate Inspector targets. The provider does not infer that two
|
||||
targets belong to the same Electron application.
|
||||
|
||||
## Capture and determinism
|
||||
|
||||
Every observation records its time window and returns all valid script and
|
||||
execution-context events received during it. Per-location protocol validation
|
||||
remains in force. Scripts are authorized after capture, deduplicated by stable
|
||||
remains in force. Scripts are validated after capture, deduplicated by stable
|
||||
metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs
|
||||
are excluded from the durable result, so identical inputs and captured metadata
|
||||
produce the same Evidence ID.
|
||||
@@ -120,8 +118,8 @@ behavior does not occur.
|
||||
Pass the resulting observation Evidence and an
|
||||
`analyze_javascript_application` Evidence record to
|
||||
`reconcile_javascript_runtime`. The reconciliation accepts this provider
|
||||
alongside passive web and Electron page captures. Exact approved file or URL
|
||||
mappings can correlate script presence with JavaScript Application Graph
|
||||
alongside passive web and Electron page captures. Exact file or URL mappings
|
||||
supplied with the request can correlate script presence with JavaScript Application Graph
|
||||
assets. Because this provider never reads source bytes, matches normally use a
|
||||
unique authorized location and remain weaker than captured-byte identity.
|
||||
Builtin `node:` scripts remain runtime-only nodes unless a future explicit
|
||||
|
||||
@@ -21,7 +21,7 @@ parameters, and normalized-result schemas are checked before reconciliation.
|
||||
supplied as additional layers.
|
||||
2. Run `inspect_web_page`, `inspect_electron_page`, or the attach-only
|
||||
`observe_javascript_runtime`. Capturing script sources
|
||||
is optional, but an approved source capture provides the strongest byte
|
||||
is optional, but a source capture provides the strongest byte
|
||||
identity.
|
||||
3. Pass the resulting Evidence records to `reconcile_javascript_runtime`.
|
||||
Browser URLs normally need an explicit URL-prefix mapping. Extracted
|
||||
@@ -77,18 +77,16 @@ npm run verify:javascript-runtime -- \
|
||||
--mapping '{"layer":1,"kind":"file-root","root":"/runtime/cache","artifact_prefix":""}'
|
||||
```
|
||||
|
||||
The verifier analyzes the supplied static paths under a temporary in-process
|
||||
permission policy, reads bounded Evidence JSON files, and prints graph IDs,
|
||||
evidence IDs, paths, digests, counts, reasons, and coverage. It does not print
|
||||
captured source text. `--source-map-read-approved` remains a distinct explicit
|
||||
approval.
|
||||
The verifier analyzes the supplied static paths, reads bounded Evidence JSON
|
||||
files, and prints graph IDs, evidence IDs, paths, digests, counts, reasons, and
|
||||
coverage. It does not print captured source text.
|
||||
|
||||
## Matching rules
|
||||
|
||||
Runtime targets, frames, scripts, and workers become capture-scoped graph
|
||||
nodes. REA evaluates compatible static candidates conservatively:
|
||||
|
||||
1. An approved captured-source SHA-256 plus one matching mapped location yields
|
||||
1. A captured-source SHA-256 plus one matching mapped location yields
|
||||
a content-and-location match.
|
||||
2. One unique exact content digest can match even when no location mapping is
|
||||
available. Whole-asset digests and reconstructed module-factory source
|
||||
@@ -144,8 +142,8 @@ EventEmitter activity, Electron IPC, or script-unload evidence. A unique
|
||||
authorized file or URL location can still correlate script presence with a
|
||||
static asset, but it does not prove module initialization or feature execution.
|
||||
|
||||
Source-map declarations and approved original-source reads stay under static
|
||||
source-map authority. They are reported in `source_map_authority` but never used
|
||||
Source-map declarations and original-source reads remain static evidence. They
|
||||
are reported in `source_map_authority` but never used
|
||||
as primary runtime byte matches. A source name or `sourcesContent` digest is not
|
||||
silently equated with the generated bytes observed by CDP.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The eight shipped tools are:
|
||||
| Compare members across builds and map build-local tokens | `compare_managed_members` |
|
||||
| Check native call declarations against supplied export or function evidence | `verify_managed_native_boundaries` |
|
||||
| Import decompiled code against verified static member identities | `import_managed_reconstruction` |
|
||||
| Plan a separately approved runtime experiment without executing it | `plan_managed_runtime_correlation` |
|
||||
| Plan a runtime experiment without executing it | `plan_managed_runtime_correlation` |
|
||||
| Add managed findings to an application graph | `project_managed_application_graph` |
|
||||
|
||||
Each has a matching CLI command: replace underscores with hyphens and prefix
|
||||
@@ -37,8 +37,8 @@ collapsing them:
|
||||
3. What source-like or behavioral structure can be reconstructed or inferred?
|
||||
4. Which findings survive an exact-build check or a cross-build structural
|
||||
comparison?
|
||||
5. Which remaining questions require native analysis or a separately
|
||||
authorized runtime experiment?
|
||||
5. Which remaining questions require native analysis or a future runtime
|
||||
experiment?
|
||||
|
||||
The ordinary workflow ends at question four. Static analysis never loads or
|
||||
executes the target.
|
||||
@@ -156,7 +156,7 @@ UTF-8 encoding, and lowercase hexadecimal output.
|
||||
### Epistemic commitment
|
||||
|
||||
- authority: static bytes, reconstruction, structural inference, independent
|
||||
validation, native provider, or separately authorized runtime;
|
||||
validation, native provider, or future runtime observation;
|
||||
- state: observed, inferred, unknown, or unavailable;
|
||||
- confidence independent of authority;
|
||||
- coverage and admitted/dropped counts;
|
||||
@@ -407,7 +407,8 @@ remains the admission requirement.
|
||||
The later runtime track is not an extension flag on a static operation. Its
|
||||
design must expose whether it attaches to an existing process, launches a new
|
||||
process, loads an assembly, uses reflection, installs a debugger/profiler, or
|
||||
instruments code. Each effect needs separate approval and exact-build checks.
|
||||
instruments code. Each effect must be declared and checked against the exact
|
||||
build.
|
||||
|
||||
At minimum, admission requires target SHA-256, MVID, normalized signature and
|
||||
body/CIL commitment, CLR family, OS, architecture, tool version, scenario
|
||||
@@ -416,11 +417,10 @@ the experiment. It must not contact real services/accounts or claim that an
|
||||
instrumented path represents an ordinary launch unless that proposition is
|
||||
independently tested.
|
||||
|
||||
The shipped `plan_managed_runtime_correlation` path admits only a
|
||||
default-disabled, permission-gated experiment plan and records that no target
|
||||
code was executed. Until a separate executor is designed and shipped, runtime
|
||||
behavior questions remain explicit unknowns with suggested probes; static
|
||||
support does not perform them.
|
||||
The shipped `plan_managed_runtime_correlation` path returns an experiment plan
|
||||
and records that no target code was executed. There is no runtime executor.
|
||||
Runtime behavior questions therefore remain explicit unknowns with suggested
|
||||
probes; static support does not perform them.
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
@@ -442,8 +442,8 @@ The managed-code track advances as reviewable pull requests:
|
||||
PE/CLI corpus shipped through `npm run verify:managed`; optional BYO
|
||||
`ilspycmd` real-tool oracle shipped through `REA_ILSPY_CMD_PATH`; dnSpy and
|
||||
pinned Windows checks remain planned); and
|
||||
8. separately authorized runtime-correlation admission planning (shipped; no
|
||||
runtime execution);
|
||||
8. non-executing runtime-correlation planning (shipped; no runtime prerequisite
|
||||
probe, execution, or confinement claim);
|
||||
9. managed static Evidence projection into the application graph (shipped).
|
||||
|
||||
Each implementation PR updates generated product facts only for behavior it
|
||||
|
||||
+24
-58
@@ -9,15 +9,13 @@ invalid, and keeps live-server state `unknown` unless the active connection
|
||||
supplies identity.
|
||||
|
||||
Canonical tool names remain stable, while `tools/list` advertises only the
|
||||
operations callable for the current target, provider, policy, host, and
|
||||
operations callable for the current target, provider, host, and
|
||||
negotiated client capabilities. `binary_session.tool_availability` remains the
|
||||
complete inventory: it explains advertised and hidden operations with stable
|
||||
availability reasons and remediation. Each entry also reports required and
|
||||
optional negotiated client features plus the currently missing features. Form elicitation is optional for
|
||||
`capture_process_scenario`: an existing grant remains usable without it, while
|
||||
a missing grant cannot be elicited by a client that does not advertise it.
|
||||
Opening or closing a target, reloading policy, or observing a provider health
|
||||
transition emits `notifications/tools/list_changed`.
|
||||
optional negotiated client features plus the currently missing features.
|
||||
Opening or closing a target or observing a provider health transition emits
|
||||
`notifications/tools/list_changed`.
|
||||
|
||||
`binary_session.analysis_provider_candidates` is authoritative for deep-engine
|
||||
discovery. Target-free discovery is sorted by provider ID, reports host
|
||||
@@ -74,65 +72,33 @@ unsupported facets are local `unavailable` outcomes. Use `current_document`,
|
||||
`current_address`, and `current_procedure` for direct single-field lookups; use
|
||||
the aggregate tools when you need related context together.
|
||||
|
||||
## Permission policy
|
||||
## Request scope and local effects
|
||||
|
||||
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.
|
||||
Each tool request names the target and lifecycle it will use. Browser calls
|
||||
carry a loopback CDP endpoint and target ID, with optional origin filters;
|
||||
process and Electron scenarios carry the executable, arguments, actions, and
|
||||
cleanup behavior; artifact tools carry the input path and requested operation.
|
||||
REA runs the declared request directly and does not infer a broader target or
|
||||
action from it.
|
||||
|
||||
Existing environment settings map to administrator ceilings. They remain the
|
||||
maximum authority:
|
||||
Host requirements remain in force. macOS may deny Accessibility,
|
||||
Screen Recording, or native mounting; Linux replay requires its sandbox
|
||||
prerequisites; provider tools require their selected analysis runtime. These
|
||||
failures are reported at the operation that needs them. A configured provider
|
||||
or an endpoint alone does not establish that a target is supported.
|
||||
|
||||
- process roots, executables, environment names, and external networking;
|
||||
- native mount enablement.
|
||||
Evidence bundles, snapshots, and extraction use the paths and output behavior
|
||||
declared by their tools. Artifact extraction materializes the selected regular
|
||||
files into a fresh REA-chosen temporary directory and reports its path.
|
||||
|
||||
Evidence bundles and analysis snapshots use the path supplied to the CLI or MCP
|
||||
tool; they do not require configured roots or separate read/write grants.
|
||||
|
||||
Artifact extraction takes no path or occurrence selectors. It materializes all
|
||||
regular files into a fresh REA-chosen temporary directory. The write still
|
||||
passes through the active permission evaluator; the tool caller does not choose
|
||||
an output path.
|
||||
|
||||
`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
|
||||
project-store change; project grants reload without restarting. Environment
|
||||
settings belong to the process environment, so changing an administrator
|
||||
ceiling in an MCP registration requires restarting that registered server or
|
||||
its owning client.
|
||||
Revocation affects future operations; an already-running operation retains the
|
||||
decision made at its preflight boundary.
|
||||
Once and session grants are connection-local overlays. They cannot authorize a
|
||||
second MCP connection, be consumed by another connection, or be cleared when a
|
||||
different connection closes. Live connections evaluate those overlays against
|
||||
the current process-wide ceilings and persisted grants, including successful
|
||||
SIGHUP reloads.
|
||||
`rea policy revoke <grant-id>` displays the exact grant and requires interactive
|
||||
confirmation; automation must pass `--yes` (or `-y`) explicitly.
|
||||
|
||||
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.
|
||||
|
||||
Elicitation can add a once or session grant only inside an existing
|
||||
administrator ceiling. A request outside that ceiling reports
|
||||
`elicitation_supported: false` and `restart_required: true`; interactive consent
|
||||
cannot silently widen administrator policy.
|
||||
|
||||
`analyze_javascript_application` reads the supplied local directory or ASAR path directly and returns its result and Evidence inline; no approval flag or root configuration is needed.
|
||||
`analyze_javascript_application` reads the supplied local directory or ASAR path
|
||||
and returns its result and Evidence inline.
|
||||
|
||||
## 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`.
|
||||
Artifact integrity fails closed by default. A request can explicitly select
|
||||
`integrity_policy=record-and-continue` when the investigation needs verified
|
||||
siblings to continue after a mismatch.
|
||||
|
||||
Contradictory bytes are quarantined from nested expansion and recorded with
|
||||
declared and observed hashes, trust, provenance, path, and unpacked state.
|
||||
|
||||
+4
-6
@@ -13,7 +13,7 @@ invoke tools by themselves.
|
||||
| `verify_reconstruction` | Evaluate a finite reconstruction specification against retained comparison Evidence. |
|
||||
| `trace_crash` | Correlate a crash symptom with static paths and optional process capture evidence. |
|
||||
| `audit_residual_unknowns` | Audit current residual-unknown heads and evidence-qualified resolution. |
|
||||
| `prepare_bounded_process_capture` | Design an approval-gated controlled process experiment before execution. |
|
||||
| `prepare_bounded_process_capture` | Design a bounded controlled process experiment before execution. |
|
||||
|
||||
Every rendered prompt provides optional starting points using current REA tool
|
||||
names. Agents can call tools directly, skip irrelevant suggestions, and inspect
|
||||
@@ -108,8 +108,6 @@ session are no longer suggested. Provider errors, unsupported operations, and
|
||||
malformed provider output produce an empty completion list rather than an
|
||||
unverified identifier.
|
||||
|
||||
Suggestions never grant permission. In particular, selecting an artifact
|
||||
occurrence does not authorize extraction, selecting an unknown does not
|
||||
authorize mutation, and selecting a prior capture does not authorize process
|
||||
execution. The corresponding tools retain their existing approval, policy,
|
||||
effect, and validation boundaries.
|
||||
Suggestions are starting points, not tool calls. A tool acts on the target,
|
||||
operation, and lifecycle fields in its own request; selecting a suggestion does
|
||||
not execute it or broaden those fields.
|
||||
|
||||
@@ -102,28 +102,30 @@ scaled ADD/BR instructions and the recovered target set. It reads exactly the
|
||||
proven count and preserves unknowns for other forms. Real ELF and host ARM64
|
||||
Mach-O fixtures check each case against source-owned return values.
|
||||
|
||||
## Approved native desktop observation
|
||||
## Native desktop observation
|
||||
|
||||
`observe_native_ui` captures one explicit existing PID/window ID after
|
||||
`observation_approved: true`. Screenshots use a selected-window ScreenCaptureKit
|
||||
`observe_native_ui` captures one explicitly selected existing PID/window ID.
|
||||
Screenshots use a selected-window ScreenCaptureKit
|
||||
filter on macOS 14+; accessibility reads stay within that window. Missing Screen
|
||||
Recording/Accessibility permissions produce actionable errors without broad
|
||||
capture or automatic permission prompts. Executable bytes and process launch
|
||||
time guard against a different target or PID reuse. AX selection requires one
|
||||
unique geometry match; ambiguity fails closed.
|
||||
|
||||
`capture_native_ui_scenario` additionally requires `actions_approved: true` and
|
||||
`restore: "leave-as-is"`. Steps select AX child-index paths for press, increment/
|
||||
`capture_native_ui_scenario` takes AX child-index paths for press, increment/
|
||||
decrement scrolling and text-value entry, or bounded waits. No global event
|
||||
injection is used. Unsupported AX actions fail explicitly. The result preserves
|
||||
ordered before/after captures and gaps; an action may have occurred before a
|
||||
post-action capture fails. The caller must choose whether to recover app state.
|
||||
post-action capture fails. Application state is left as-is; REA does not attempt
|
||||
to restore it.
|
||||
|
||||
Scenarios allow 16 steps, 30 seconds of total waits, 2,000 AX nodes per capture,
|
||||
8 MiB PNGs and 64 MiB output within a 180-second deadline. REA compiles one owned
|
||||
helper per scenario, removes its temporary compiler cache and stops its helper
|
||||
on cancellation. It does not launch or own the selected application. Approved
|
||||
UI actions may change application data or trigger network activity.
|
||||
Scenarios accept caller-selected action lists and accessibility node counts.
|
||||
Individual waits cannot exceed the operation's 180-second deadline, and the
|
||||
complete scenario result has a 64 MiB output budget. Screenshots are scaled to
|
||||
at most 2,048 pixels and captured only for the selected window. REA compiles one
|
||||
owned helper per scenario, removes its temporary compiler cache and stops its
|
||||
helper on cancellation. It does not launch or own the selected application. UI
|
||||
actions may change application data or trigger network activity.
|
||||
|
||||
## Provider and verification boundaries
|
||||
|
||||
@@ -143,11 +145,11 @@ to Hopper.
|
||||
- `npm run verify:apple-dispatch`: source-built Objective-C protocols/classes and
|
||||
Swift conformances/vtables, repeated after stripping local symbols.
|
||||
- `npm run verify:native-ui`: one source-owned fixture window, successful
|
||||
selected-window capture and approved actions, changed-target rejection, and
|
||||
cleanup. Missing OS permissions fail this lane.
|
||||
selected-window capture and actions, changed-target rejection, and cleanup.
|
||||
Missing OS permissions fail this lane.
|
||||
- `npm run verify:native-ui:permissions`: permits a permission-denial result and
|
||||
reports `positive_e2e: false` when capture is denied. That result verifies the
|
||||
permission boundary, not successful UI capture or actions.
|
||||
OS permission boundary, not successful UI capture or actions.
|
||||
|
||||
macOS ARM64 is the real host verified during this implementation. Admission of
|
||||
macOS Intel does not claim an Intel verification run. Unsupported metadata and
|
||||
|
||||
+20
-45
@@ -1,6 +1,6 @@
|
||||
# Process capture
|
||||
|
||||
Process Capture records one approved command as deterministic Evidence. It is
|
||||
Process Capture records one caller-selected command as Evidence. It is
|
||||
intended for authority-versus-reconstruction checks where terminal behavior,
|
||||
child lifetime, filesystem state, or dependency timing matters.
|
||||
|
||||
@@ -12,50 +12,27 @@ observations remain explicit; a truncated capture is never treated as
|
||||
equivalent to another capture.
|
||||
|
||||
Every capture includes a run manifest with canonical SHA-256 commitments for
|
||||
the secret-safe full scenario projection, comparison contract, executable,
|
||||
the caller-selected scenario projection, comparison contract, executable,
|
||||
normalization rules, command-shim plan, and replay plan. The manifest also
|
||||
records the REA/provider versions, platform, architecture, PTY backend, and UTC
|
||||
start/completion timestamps. Capture creation, Evidence import, and comparison
|
||||
recompute the self-contained commitments and reject invalid lifecycle data.
|
||||
|
||||
## Enable the capability
|
||||
## Request and host behavior
|
||||
|
||||
Process execution is disabled until the operator supplies policy roots and
|
||||
approves host networking:
|
||||
The scenario names the command and arguments, with optional working-directory,
|
||||
environment overrides, and filesystem observation paths. An omitted working
|
||||
directory uses the invocation's current directory. The process inherits the
|
||||
host environment, then applies any declared overrides. Filesystem observation
|
||||
paths select what REA records; they do not constrain what the child process can
|
||||
read or write. Process Capture is not a security sandbox: the target runs with
|
||||
the current user's permissions.
|
||||
|
||||
```bash
|
||||
export REA_PROCESS_CAPTURE_ENABLED=true
|
||||
export REA_PROCESS_EXECUTABLE_ROOTS_JSON='["/usr/bin","/absolute/project/bin"]'
|
||||
export REA_PROCESS_WORKING_ROOTS_JSON='["/absolute/project"]'
|
||||
export REA_PROCESS_ALLOWED_ENV_JSON='["PATH"]'
|
||||
export REA_PROCESS_ALLOW_EXTERNAL_NETWORK=true
|
||||
```
|
||||
|
||||
By default, enabling process capture also installs an administrator-lifetime
|
||||
grant for the configured boundary. Set
|
||||
`REA_PROCESS_CAPTURE_AUTO_GRANT=false` to install the same boundary only as an
|
||||
administrator ceiling. Captures then fail with a structured permission-required
|
||||
result until a narrower grant is established; this is the prerequisite mode for
|
||||
connection-scoped MCP elicitation. It does not itself authorize a capture.
|
||||
Each MCP connection owns its elicited once and session grants. A grant accepted
|
||||
on one connection is not visible to another connection, and disconnect cleanup
|
||||
removes only the grants owned by the connection that closed. Reloaded
|
||||
administrator ceilings and persisted grants still apply immediately to every
|
||||
live connection.
|
||||
|
||||
REA offers the once-or-session grant form only after the connection negotiates
|
||||
an MCP revision with multi-round tool results and advertises form elicitation.
|
||||
Older connections keep returning the structured
|
||||
permission-required result and never wait for an unsupported elicitation
|
||||
response. On the pinned `@modelcontextprotocol/server` 2.0.0 runtime, this is
|
||||
the official `2026-07-28` `inputRequired` continuation: the SDK seals the
|
||||
request state and validates the continuation envelope, while REA validates the
|
||||
untrusted input response before creating a connection-owned grant. The older
|
||||
push-style `elicitation/create` path is not used for this protocol revision.
|
||||
|
||||
Executables, working directories, filesystem roots, and requested environment
|
||||
variables are checked against operator policy before launch. Process Capture is an observation tool,
|
||||
not a security sandbox: the target runs with the current user's permissions.
|
||||
Local output, arguments, environment overrides, URL query values, shim results,
|
||||
and replay bodies are preserved without credential-name or text-pattern
|
||||
redaction. Inputs explicitly marked `sensitive` use placeholders in Evidence.
|
||||
The inherited environment is not copied into the manifest; its unrecorded
|
||||
influence remains an explicit limitation.
|
||||
|
||||
## Capture a scenario
|
||||
|
||||
@@ -70,10 +47,9 @@ A representative scenario is:
|
||||
|
||||
```json
|
||||
{
|
||||
"executable": "/absolute/path/to/node",
|
||||
"executable": "node",
|
||||
"arguments": ["./signup.mjs"],
|
||||
"working_directory": "/absolute/project",
|
||||
"filesystem_roots": ["/absolute/project/state"],
|
||||
"filesystem_observation_paths": ["./state"],
|
||||
"terminal": { "columns": 80, "rows": 24, "scrollback": 1000 },
|
||||
"events": [
|
||||
{
|
||||
@@ -110,10 +86,9 @@ A representative scenario is:
|
||||
```
|
||||
|
||||
Input events marked `sensitive` are dispatched to the PTY but stored only as a
|
||||
byte-count placeholder. Environment values listed in `secret_aliases` are also
|
||||
redacted from recorded text. The command-shim directory is placed first in the
|
||||
captured process `PATH`; any remaining `PATH` must be explicitly supplied or
|
||||
inherited under operator policy.
|
||||
byte-count placeholder. The command-shim directory is placed first in the
|
||||
captured process `PATH`; command lookup otherwise uses the inherited or
|
||||
scenario-overridden `PATH`.
|
||||
|
||||
Scenario arguments, environment names, timed interactions, shim routes and
|
||||
output chunks, filesystem checkpoints, and static HTTP/WebSocket scripts are
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 75,
|
||||
"primary_count": 74,
|
||||
"commands": [
|
||||
"analyze",
|
||||
"analyze-javascript-application",
|
||||
@@ -503,7 +503,6 @@
|
||||
"observe-native-ui",
|
||||
"observe-web-session",
|
||||
"plan-managed-runtime-correlation",
|
||||
"policy",
|
||||
"prepare-node-characterization",
|
||||
"project-managed-application-graph",
|
||||
"providers",
|
||||
@@ -532,14 +531,14 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 75,
|
||||
"cli_commands": 74,
|
||||
"mcp_tools": 122,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "49674f8c1bc073eb86d862d76cd196576750667529c115e20ffb8cf24824746e",
|
||||
"prompts_sha256": "fa889fc671185ac9c968c6dc8309cf609e709cdc837dbbf39b7cdfadb84ac2d7",
|
||||
"combined_sha256": "88df47ddee7253da2eb91f94cd6c84b3e0c1ac96272a23715a6420bb6006990a",
|
||||
"tools_sha256": "9f2b8a77ca7d972dedddf33294f5addb5fc7e00ce43ae5de21be989e0777871a",
|
||||
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
|
||||
"combined_sha256": "3a0e6d66df95b913797ce2e4548e97d409d6e8768023f8a7a23d8d85ae9d766f",
|
||||
"providers_sha256": "c5a88a49990fafc8244972ee8ff543fc19995eb79db960da55f6a311e15340d1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ check project-owned conformance fixtures and fail-closed report behavior.
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `discover-classify` | target-free startup, artifact inventory, compatible workflow selection, and exact visible limits |
|
||||
| `diagnose-environment` | broken runtime separated from analysis failure, version skew detected, and revision changed after recovery |
|
||||
| `acquire-authority` | exact missing scope, narrow grant, expansion re-prompt, and no launch after denial/cancellation |
|
||||
| `declare-request` | exact target, operation, and lifecycle are carried by the request before launch |
|
||||
| `static-analysis` | native and JavaScript routing plus preserved ambiguity, truncation, unsupported syntax, and residual unknowns |
|
||||
| `reactive-scenarios` | predicate-driven interaction, descendant settlement, Electron correlation, and cancellation cleanup Evidence |
|
||||
| `compare-authority-candidate` | explicit partial-order/finite-trace semantics, primary divergence localization, and unknown for incomplete comparison |
|
||||
@@ -63,7 +63,7 @@ The evaluator rejects or withholds readiness when:
|
||||
- contradiction Evidence or its affected comparison is missing;
|
||||
- the reconstruction obligation ledger is not `ready`, has no required
|
||||
obligation, or retains an unowned/unverified required obligation;
|
||||
- denied authority launches a process, cancellation loses diagnostics, or owned
|
||||
- a process exceeds its declared target/lifecycle, cancellation loses diagnostics, or owned
|
||||
resources remain;
|
||||
- replay is nondeterministic, tampering/staleness is not detected, or the
|
||||
expected source digest differs.
|
||||
|
||||
@@ -117,7 +117,7 @@ order rather than sorting or changing timestamps to conceal it.
|
||||
|
||||
Malformed archives, broken and cyclic references, unsupported metadata pointers,
|
||||
relative Objective-C method layouts, ambiguous call candidates, digest mismatch,
|
||||
cancellation, permission approval, bounded output and owned-resource cleanup
|
||||
cancellation, host permission denial, bounded output and owned-resource cleanup
|
||||
remain covered. These cases provide evidence beyond a successful real-tool run.
|
||||
The hand-assembled relative method fixture stays until compiler-produced
|
||||
relative-list fixtures establish equivalent assertions.
|
||||
|
||||
+3
-3
@@ -218,9 +218,9 @@ behavior also requires `npm run verify:package` and the applicable
|
||||
conformance/vtable fixtures, inspects their bytes and repeats after stripping
|
||||
local symbols. It requires macOS and the host Xcode toolchain; targets are not
|
||||
executed. `npm run verify:native-ui` launches exactly one source-owned fixture
|
||||
window and requires successful selected-window capture and approved actions.
|
||||
Permission denial fails the positive lane. `npm run verify:native-ui:permissions`
|
||||
allows a permission-boundary-only result and explicitly reports
|
||||
window and requires successful selected-window capture and selected actions.
|
||||
An OS permission denial fails the positive lane. `npm run verify:native-ui:permissions`
|
||||
allows a host-permission-boundary-only result and explicitly reports
|
||||
`positive_e2e: false`; it must not be reported as capture/action proof.
|
||||
Both commands reject a changed executable digest and clean up the fixture
|
||||
process and helper. These lanes require an interactive macOS desktop. See [native investigation](native-investigation.md)
|
||||
|
||||
+2
-1
@@ -72,7 +72,8 @@ or model-authored loop when one call can answer the question.
|
||||
- Separate observed facts from derived and inferred edges. Cite the evidence
|
||||
supporting every important relationship; preserve unresolved edges.
|
||||
- Declare read-only, mutation, process, filesystem, network, and UI effects
|
||||
truthfully in the contract and permission boundary.
|
||||
truthfully in the contract and enforce the target and lifecycle named in the
|
||||
request.
|
||||
- Keep provider-specific types out of provider-neutral domain and application
|
||||
layers. Normalize supported provider results without implying equal coverage.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_76203e23e7c067abbcc12c975ce71661056d9b09ac73fb10ecfc8373d9b10dc1",
|
||||
"manifest_id": "ecm_d8e34c1218d199b3639830b184f02f87642c42aa9e8e8e2474f8daa60e1645ac",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "5f5abf280da3fdf4d72cf450939f468d767a8f35dae333731f7650232ddc3ce5"
|
||||
"sha256": "7d767925a840318504e9f1712a6ecc4a985cd7a08f00bffe36105b75481a54c2"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -117,7 +117,9 @@ const browserScript = (port) => `
|
||||
const { generation } = await response.json();
|
||||
if (generation <= observedGeneration) return;
|
||||
observedGeneration = generation;
|
||||
history.pushState({}, "", "/app/session-" + String(generation));
|
||||
const sessionUrl = new URL(window.location.href);
|
||||
sessionUrl.pathname = "/app/session-" + String(generation);
|
||||
history.pushState({}, "", sessionUrl);
|
||||
}, 100);
|
||||
//# sourceMappingURL=/app.js.map
|
||||
`;
|
||||
|
||||
@@ -89,7 +89,7 @@ async function sdkToolCatalog() {
|
||||
}
|
||||
}
|
||||
const auxiliaryProviders = [
|
||||
new ArtifactProvider(false, false, CATALOG_PLATFORM),
|
||||
new ArtifactProvider(CATALOG_PLATFORM),
|
||||
new NativeMacOSProvider(undefined, CATALOG_PLATFORM),
|
||||
new ManagedStaticProvider(),
|
||||
].map((provider) => ({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { browserScenarioSchema } from "../../dist/domain/browserScenario.js";
|
||||
@@ -18,7 +17,6 @@ const duplicateOriginStorage = (origin) => ({
|
||||
value: {
|
||||
source: "literal",
|
||||
value: "first",
|
||||
classification: "public",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -31,9 +29,12 @@ const duplicateOriginStorage = (origin) => ({
|
||||
value: {
|
||||
source: "literal",
|
||||
value: "second",
|
||||
classification: "public",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rea-secret-seed",
|
||||
value: { source: "secret", secret_id: "verifier_storage" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -43,7 +44,15 @@ const duplicateOriginStorage = (origin) => ({
|
||||
export function browserScenario(browser, origin) {
|
||||
return browserScenarioSchema.parse({
|
||||
browser,
|
||||
start_url: { url: `${origin}/app`, query: [] },
|
||||
start_url: {
|
||||
url: `${origin}/app`,
|
||||
query: [
|
||||
{
|
||||
name: "scenario_token",
|
||||
value: { source: "secret", secret_id: "verifier_url" },
|
||||
},
|
||||
],
|
||||
},
|
||||
allowed_origins: [origin],
|
||||
environment: {
|
||||
viewport: { width: 1_280, height: 720, device_scale_factor: 1.25 },
|
||||
@@ -51,7 +60,6 @@ export function browserScenario(browser, origin) {
|
||||
timezone: "UTC",
|
||||
color_scheme: "light",
|
||||
reduced_motion: "reduce",
|
||||
service_workers: "block",
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
@@ -61,7 +69,6 @@ export function browserScenario(browser, origin) {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
state: "visible",
|
||||
},
|
||||
@@ -72,17 +79,21 @@ export function browserScenario(browser, origin) {
|
||||
kind: "role",
|
||||
role: "button",
|
||||
name: "ax-private-label-value",
|
||||
exact: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
storage: duplicateOriginStorage(origin),
|
||||
request_replay: { mode: "disabled" },
|
||||
secrets: [],
|
||||
redaction: {
|
||||
secret_values: "replace-with-secret-reference",
|
||||
query_parameter_names: ["token"],
|
||||
},
|
||||
secrets: [
|
||||
{
|
||||
secret_id: "verifier_url",
|
||||
environment_variable: "REA_BROWSER_VERIFIER_URL",
|
||||
},
|
||||
{
|
||||
secret_id: "verifier_storage",
|
||||
environment_variable: "REA_BROWSER_VERIFIER_SECRET",
|
||||
},
|
||||
],
|
||||
capture: {
|
||||
after_each_step: [
|
||||
"screenshot",
|
||||
@@ -115,7 +126,7 @@ export function browserScenario(browser, origin) {
|
||||
}
|
||||
|
||||
/** Run scenario capture through the public one-shot CLI. */
|
||||
export async function runScenarioCli(scenario, configuration) {
|
||||
export async function runScenarioCli(scenario) {
|
||||
const { stdout } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -126,16 +137,7 @@ export async function runScenarioCli(scenario, configuration) {
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
REA_BROWSER_SCENARIO_ENABLED: "true",
|
||||
REA_BROWSER_SCENARIO_AUTO_GRANT: "true",
|
||||
REA_BROWSER_SCENARIO_EXECUTABLE_ROOTS_JSON: JSON.stringify([
|
||||
dirname(scenario.browser.executable_path ?? process.execPath),
|
||||
]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ENV_JSON: "[]",
|
||||
...configuration,
|
||||
},
|
||||
env: process.env,
|
||||
maxBuffer: 64 * 1_024 * 1_024,
|
||||
},
|
||||
);
|
||||
@@ -175,6 +177,35 @@ export function assertScenarioCapture(capture) {
|
||||
!storageNames.includes("rea-second-seed")
|
||||
)
|
||||
throw new Error("Scenario launch did not retain duplicate-origin seeds");
|
||||
const secretSeed =
|
||||
final.artifacts.storage.state === "captured"
|
||||
? final.artifacts.storage.value.local_storage.find(
|
||||
({ name }) => name === "rea-secret-seed",
|
||||
)
|
||||
: undefined;
|
||||
if (secretSeed?.value_state !== "redacted-secret")
|
||||
throw new Error("Scenario launch did not redact its declared secret seed");
|
||||
const finalUrl =
|
||||
final.artifacts.url.state === "captured"
|
||||
? final.artifacts.url.value.url
|
||||
: "";
|
||||
if (!finalUrl.includes("[REDACTED:verifier_url]"))
|
||||
throw new Error("Scenario launch did not redact its declared URL secret");
|
||||
const stepUrl = capture.steps
|
||||
.map(({ after_url }) => after_url)
|
||||
.find(({ url }) => url.includes("[REDACTED:verifier_url]"));
|
||||
if (stepUrl?.redacted !== true)
|
||||
throw new Error(
|
||||
"Scenario step URL did not report declared-secret redaction",
|
||||
);
|
||||
const eventUrl = capture.events.items
|
||||
.filter((event) => "url" in event && event.url !== null)
|
||||
.map((event) => event.url)
|
||||
.find(({ url }) => url.includes("[REDACTED:verifier_url]"));
|
||||
if (eventUrl?.redacted !== true)
|
||||
throw new Error(
|
||||
"Scenario event URL did not report declared-secret redaction",
|
||||
);
|
||||
if (
|
||||
capture.events.items.length === 0 ||
|
||||
capture.completeness.equality_eligible !== true
|
||||
|
||||
@@ -21,9 +21,6 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([origin]),
|
||||
};
|
||||
let client;
|
||||
let transport;
|
||||
@@ -39,7 +36,15 @@ export async function verifyLargeScreenshotE2e(endpoint, origin) {
|
||||
);
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[entrypoint, "capture-web-screenshot", endpoint, target.id, "--json"],
|
||||
[
|
||||
entrypoint,
|
||||
"capture-web-screenshot",
|
||||
endpoint,
|
||||
target.id,
|
||||
"--allowed-origins",
|
||||
origin,
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 60000, maxBuffer: 72 * 1024 * 1024 },
|
||||
);
|
||||
const cliEvidence = parseEvidence(JSON.parse(stdout));
|
||||
|
||||
@@ -133,7 +133,7 @@ export function assertBundleAnalysis(result) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert explicitly approved console, JSON, and WebSocket shape capture. */
|
||||
/** Assert explicitly selected console text and JSON/WebSocket shape capture. */
|
||||
export function assertSensitiveShapes(result) {
|
||||
const request = result.network.requests.find(
|
||||
(item) =>
|
||||
@@ -151,12 +151,11 @@ export function assertSensitiveShapes(result) {
|
||||
(event) => event.text_capture.values,
|
||||
);
|
||||
if (
|
||||
!consoleText.some(({ text }) => text.includes("[REDACTED]")) ||
|
||||
consoleText.some(({ text }) => text.includes("console-secret-value"))
|
||||
!consoleText.some(({ text }) =>
|
||||
text.includes("authorization=Bearer console-secret-value"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Real Chrome approved console text was not safely redacted",
|
||||
);
|
||||
throw new Error("Real Chrome selected console text was not preserved");
|
||||
if (
|
||||
!result.network.websocket_events.some(
|
||||
(event) =>
|
||||
@@ -174,8 +173,8 @@ export function assertSensitiveShapes(result) {
|
||||
for (const secret of [
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"request-secret-value",
|
||||
"websocket-secret-value",
|
||||
"console-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(`Approved shape capture retained raw value: ${secret}`);
|
||||
|
||||
@@ -332,20 +332,6 @@ async function createTargets(root, includeManaged) {
|
||||
}
|
||||
|
||||
async function runCodex(prompt) {
|
||||
const mcpEnvironment = [
|
||||
["REA_BROWSER_OBSERVE_ENABLED", "true"],
|
||||
[
|
||||
"REA_BROWSER_CDP_ENDPOINTS_JSON",
|
||||
JSON.stringify(["http://127.0.0.1:9222"]),
|
||||
],
|
||||
[
|
||||
"REA_BROWSER_ALLOWED_ORIGINS_JSON",
|
||||
JSON.stringify(["http://127.0.0.1:3000"]),
|
||||
],
|
||||
];
|
||||
const mcpEnvironmentOverride = `mcp_servers.rea.env={${mcpEnvironment
|
||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
|
||||
.join(",")}}`;
|
||||
const arguments_ = [
|
||||
"exec",
|
||||
"--ignore-user-config",
|
||||
@@ -367,21 +353,12 @@ async function runCodex(prompt) {
|
||||
`mcp_servers.rea.args=${JSON.stringify([join(repositoryRoot, "scripts/rea.mjs"), "mcp"])}`,
|
||||
"-c",
|
||||
`mcp_servers.rea.startup_timeout_sec=${String(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
||||
"-c",
|
||||
mcpEnvironmentOverride,
|
||||
...(optionalModel === undefined ? [] : ["--model", optionalModel]),
|
||||
prompt,
|
||||
];
|
||||
const child = spawn(codex, arguments_, {
|
||||
cwd: evaluationRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
REA_BROWSER_OBSERVE_ENABLED: "true",
|
||||
REA_BROWSER_CDP_ENDPOINTS_JSON: JSON.stringify(["http://127.0.0.1:9222"]),
|
||||
REA_BROWSER_ALLOWED_ORIGINS_JSON: JSON.stringify([
|
||||
"http://127.0.0.1:3000",
|
||||
]),
|
||||
},
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const events = [];
|
||||
|
||||
@@ -12,7 +12,6 @@ import { importManagedReconstructionEvidence } from "../dist/application/Managed
|
||||
import { planManagedRuntimeCorrelationEvidence } from "../dist/application/ManagedRuntimeCorrelationService.js";
|
||||
import { projectManagedApplicationGraphEvidence } from "../dist/application/ManagedApplicationGraphService.js";
|
||||
import { traceApplicationFeatureEvidence } from "../dist/application/JavaScriptApplicationWorkflowService.js";
|
||||
import { createPermissionAuthority } from "../dist/application/PermissionAuthority.js";
|
||||
import { MANAGED_STATIC_PROVIDER } from "../dist/application/InvestigationProviders.js";
|
||||
import { createEvidence } from "../dist/domain/evidence.js";
|
||||
import {
|
||||
@@ -250,29 +249,7 @@ try {
|
||||
assert.equal(obfuscatedMembers.types[0]?.full_name, "Fixture.ꙮType");
|
||||
assert.equal(obfuscatedMembers.methods[0]?.name, "λ⛧");
|
||||
assert.equal(obfuscatedMembers.fields[0]?.name, "字段");
|
||||
const runtimeExecutable = join(workspace, "dotnet");
|
||||
await writeFile(runtimeExecutable, "#!/bin/sh\n");
|
||||
const runtimeCeiling = {
|
||||
capability: "managed_runtime",
|
||||
roots: [workspace],
|
||||
executables: [runtimeExecutable],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
};
|
||||
const runtimeAuthority = await createPermissionAuthority(
|
||||
[runtimeCeiling],
|
||||
[
|
||||
{
|
||||
...runtimeCeiling,
|
||||
grant_id: "administrator:managed_runtime",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(runtimeAuthority.ok, true);
|
||||
const runtimeExecutable = process.execPath;
|
||||
const runtimeMethod = obfuscatedMembers.methods[0];
|
||||
assert.ok(runtimeMethod);
|
||||
const obfuscatedMembersEvidence = createEvidence(
|
||||
@@ -317,14 +294,7 @@ try {
|
||||
);
|
||||
assert.equal(reconstructionImport.value.confidence, "inferred");
|
||||
const runtimePlan = await planManagedRuntimeCorrelationEvidence(
|
||||
{
|
||||
policy: () => ({
|
||||
status: "enabled",
|
||||
roots: [workspace],
|
||||
executablePath: runtimeExecutable,
|
||||
}),
|
||||
authority: runtimeAuthority.value,
|
||||
},
|
||||
{ configuration: () => ({ executablePath: runtimeExecutable }) },
|
||||
{
|
||||
static_members: obfuscatedMembersEvidence,
|
||||
method: {
|
||||
@@ -350,13 +320,17 @@ try {
|
||||
assert.equal(runtimePlan.ok, true);
|
||||
assert.equal(runtimePlan.value.normalized_result.executed, false);
|
||||
assert.equal(
|
||||
runtimePlan.value.normalized_result.authority_model.capability,
|
||||
"managed_runtime",
|
||||
runtimePlan.value.normalized_result.unsupported_until_executor_exists,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
runtimePlan.value.normalized_result.effect_taxonomy.uses_debugger,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
runtimePlan.value.normalized_result.requested_runtime.confinement,
|
||||
"not-established",
|
||||
);
|
||||
|
||||
const left = await fixture("token-drift-left.exe", {
|
||||
methodName: "StableSemanticSlice",
|
||||
|
||||
@@ -58,7 +58,6 @@ try {
|
||||
if (!target.ok) throw target.error;
|
||||
const observation = await observeNativeUi(target.value, "observe_native_ui", {
|
||||
...scope,
|
||||
observation_approved: true,
|
||||
});
|
||||
if (
|
||||
!observation.ok &&
|
||||
@@ -83,8 +82,6 @@ try {
|
||||
"capture_native_ui_scenario",
|
||||
{
|
||||
...scope,
|
||||
observation_approved: true,
|
||||
actions_approved: true,
|
||||
restore: "leave-as-is",
|
||||
screenshot: false,
|
||||
steps: [
|
||||
@@ -114,8 +111,6 @@ try {
|
||||
String(scope.pid),
|
||||
"--window-id",
|
||||
String(scope.window_id),
|
||||
"--observation-approved",
|
||||
"--actions-approved",
|
||||
"--restore",
|
||||
"leave-as-is",
|
||||
"--steps",
|
||||
@@ -135,14 +130,14 @@ try {
|
||||
evidence.normalized_result?.steps?.length !== 2
|
||||
)
|
||||
throw new Error(
|
||||
`Native UI CLI did not preserve the approved scenario: ${JSON.stringify(evidence.error ?? evidence.normalized_result?.steps?.map(({ outcome, reason }) => ({ outcome, reason })))}`,
|
||||
`Native UI CLI did not preserve the selected scenario: ${JSON.stringify(evidence.error ?? evidence.normalized_result?.steps?.map(({ outcome, reason }) => ({ outcome, reason })))}`,
|
||||
);
|
||||
scenarioStatus = "selected-AX-button-action-and-CLI-parity-observed";
|
||||
}
|
||||
const mismatch = await observeNativeUi(
|
||||
{ ...target.value, sha256: "0".repeat(64) },
|
||||
"observe_native_ui",
|
||||
{ ...scope, observation_approved: true },
|
||||
scope,
|
||||
);
|
||||
if (mismatch.ok || !mismatch.error.message.includes("target-mismatch"))
|
||||
throw new Error("Real helper did not reject changed target bytes");
|
||||
|
||||
@@ -153,8 +153,6 @@ const verifyManagedRuntimePlan = async ({
|
||||
],
|
||||
{
|
||||
...environment,
|
||||
REA_MANAGED_RUNTIME_ENABLED: "true",
|
||||
REA_MANAGED_RUNTIME_ROOTS_JSON: JSON.stringify([workspace]),
|
||||
REA_MANAGED_RUNTIME_EXECUTABLE_PATH: process.execPath,
|
||||
},
|
||||
),
|
||||
@@ -163,14 +161,21 @@ const verifyManagedRuntimePlan = async ({
|
||||
managedRuntimePlan.operation !== "plan_managed_runtime_correlation" ||
|
||||
managedRuntimePlan.provider?.id !== "rea-dotnet-workflows" ||
|
||||
managedRuntimePlan.normalized_result?.executed !== false ||
|
||||
managedRuntimePlan.normalized_result?.authority_model?.capability !==
|
||||
"managed_runtime" ||
|
||||
managedRuntimePlan.normalized_result?.unsupported_until_executor_exists !==
|
||||
true ||
|
||||
managedRuntimePlan.normalized_result?.effect_taxonomy?.attaches_process !==
|
||||
true ||
|
||||
managedRuntimePlan.normalized_result?.effect_taxonomy
|
||||
?.invokes_target_code !== false
|
||||
)
|
||||
throw new Error("packaged managed runtime-correlation CLI failed");
|
||||
if (
|
||||
managedRuntimePlan.normalized_result?.requested_runtime?.confinement !==
|
||||
"not-established"
|
||||
)
|
||||
throw new Error(
|
||||
"packaged managed runtime plan claimed unverified confinement",
|
||||
);
|
||||
};
|
||||
|
||||
const verifyManagedBoundaries = async ({ cli, managedPath, environment }) => {
|
||||
|
||||
@@ -35,14 +35,11 @@ import {
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs";
|
||||
|
||||
const SECRET_VALUES = [
|
||||
"network-secret-value",
|
||||
"console-secret-value",
|
||||
"storage-secret-value",
|
||||
"websocket-secret-value",
|
||||
"ax-private-label-value",
|
||||
];
|
||||
const REAL_BROWSER_STARTUP_TIMEOUT_MS = 30_000;
|
||||
const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret";
|
||||
const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret";
|
||||
process.env.REA_BROWSER_VERIFIER_SECRET = SCENARIO_SECRET_VALUE;
|
||||
process.env.REA_BROWSER_VERIFIER_URL = SCENARIO_URL_SECRET_VALUE;
|
||||
const verifierRun = createVerifierRun();
|
||||
|
||||
const executable = await browserExecutable();
|
||||
@@ -99,10 +96,25 @@ try {
|
||||
if (!observed.ok) throw observed.error;
|
||||
assertObservation(observed.value, site.origin);
|
||||
const serialized = JSON.stringify(observed.value);
|
||||
for (const secret of [...SECRET_VALUES, "browser-secret-value"])
|
||||
if (
|
||||
!observed.value.target.url.includes("startup=browser-secret-value") ||
|
||||
!observed.value.network.requests.some((request) =>
|
||||
request.url.includes("token=network-secret-value"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
"Passive browser result did not preserve ordinary URL queries",
|
||||
);
|
||||
for (const secret of [
|
||||
"request-secret-value",
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"storage-secret-value",
|
||||
"websocket-secret-value",
|
||||
])
|
||||
if (serialized.includes(secret))
|
||||
throw new Error(
|
||||
`Passive browser result retained redacted value: ${secret}`,
|
||||
`Passive browser result included unselected structured data: ${secret}`,
|
||||
);
|
||||
|
||||
const withSource = await provider.inspectPage(
|
||||
@@ -217,17 +229,12 @@ try {
|
||||
mode: "connect",
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target,
|
||||
ownership: "external",
|
||||
cleanup: "disconnect-only",
|
||||
},
|
||||
site.origin,
|
||||
);
|
||||
let attachedScenario;
|
||||
try {
|
||||
attachedScenario = await runScenarioCli(attachedScenarioInput, {
|
||||
REA_BROWSER_SCENARIO_CDP_ENDPOINTS_JSON: JSON.stringify([endpoint]),
|
||||
REA_BROWSER_SCENARIO_ALLOWED_ORIGINS_JSON: JSON.stringify([site.origin]),
|
||||
});
|
||||
attachedScenario = await runScenarioCli(attachedScenarioInput);
|
||||
} catch (cliError) {
|
||||
const direct =
|
||||
await new PlaywrightBrowserScenarioProvider().captureScenario(
|
||||
@@ -239,12 +246,8 @@ try {
|
||||
underlying instanceof Error
|
||||
? `${underlying.name}: ${underlying.message}`
|
||||
: String(underlying ?? direct.error.message);
|
||||
const redactedDetails = [...SECRET_VALUES, "browser-secret-value"].reduce(
|
||||
(message, secret) => message.replaceAll(secret, "<redacted>"),
|
||||
details,
|
||||
);
|
||||
throw new Error(
|
||||
`Attached browser scenario failed through both CLI and provider: ${redactedDetails}`,
|
||||
`Attached browser scenario failed through both CLI and provider: ${details}`,
|
||||
{ cause: cliError },
|
||||
);
|
||||
}
|
||||
@@ -273,9 +276,6 @@ try {
|
||||
{
|
||||
mode: "launch",
|
||||
executable_path: executable,
|
||||
headless: true,
|
||||
user_data: "temporary-owned",
|
||||
cleanup: "close-and-delete-profile",
|
||||
},
|
||||
site.origin,
|
||||
),
|
||||
@@ -290,13 +290,32 @@ try {
|
||||
if ([...profilesAfter].some((entry) => !profilesBefore.has(entry)))
|
||||
throw new Error("Scenario launch retained a temporary browser profile");
|
||||
assertScenarioCapture(launchedScenario.value);
|
||||
for (const secret of ["network-secret-value", "websocket-url-secret"])
|
||||
if (
|
||||
JSON.stringify([attachedScenario, launchedScenario.value]).includes(
|
||||
secret,
|
||||
)
|
||||
)
|
||||
throw new Error(`Browser scenario retained sensitive value: ${secret}`);
|
||||
const scenarioResults = JSON.stringify([
|
||||
attachedScenario,
|
||||
launchedScenario.value,
|
||||
]);
|
||||
for (const retainedValue of [
|
||||
"token=network-secret-value",
|
||||
"token=websocket-url-secret",
|
||||
"authorization=Bearer console-secret-value",
|
||||
"websocket-secret-value",
|
||||
])
|
||||
if (!scenarioResults.includes(retainedValue))
|
||||
throw new Error(
|
||||
`Browser scenario did not preserve selected value: ${retainedValue}`,
|
||||
);
|
||||
for (const unselectedValue of [
|
||||
SCENARIO_SECRET_VALUE,
|
||||
SCENARIO_URL_SECRET_VALUE,
|
||||
"request-secret-value",
|
||||
"request-body-secret-value",
|
||||
"response-secret-value",
|
||||
"storage-secret-value",
|
||||
])
|
||||
if (scenarioResults.includes(unselectedValue))
|
||||
throw new Error(
|
||||
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
||||
);
|
||||
|
||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||
process.stdout.write(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
const executable = process.env.REA_ELECTRON_EXECUTABLE;
|
||||
if (executable === undefined || executable.length === 0)
|
||||
throw new Error(
|
||||
"REA_ELECTRON_EXECUTABLE must be an absolute path to an approved Electron executable",
|
||||
"REA_ELECTRON_EXECUTABLE must identify the Electron executable for this verification lane",
|
||||
);
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
@@ -46,7 +46,6 @@ const input = electronActiveObservationInputSchema.parse({
|
||||
window_index: 1,
|
||||
},
|
||||
],
|
||||
approved: true,
|
||||
});
|
||||
const verifierRun = createVerifierRun();
|
||||
const result = await new PlaywrightElectronActiveProvider().capture(input);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { PermissionAuthority } from "../dist/application/PermissionAuthority.js";
|
||||
import { runControlledReplay } from "../dist/application/JavaScriptReplayService.js";
|
||||
import { createPermissionPolicy } from "../dist/domain/permissionPolicy.js";
|
||||
import { LinuxJavaScriptReplayRunner } from "../dist/replay/LinuxJavaScriptReplayRunner.js";
|
||||
import { SystemJavaScriptReplayHost } from "../dist/replay/SystemJavaScriptReplayHost.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
@@ -42,18 +40,7 @@ const suppliedRight =
|
||||
suppliedInput?.right === undefined
|
||||
? undefined
|
||||
: await canonicalSide(suppliedInput.right);
|
||||
const roots = [
|
||||
...new Set(
|
||||
suppliedLeft === undefined
|
||||
? [dirname(leftPath), dirname(rightPath)]
|
||||
: [...suppliedLeft.modules, ...(suppliedRight?.modules ?? [])].map(
|
||||
({ path }) => dirname(path),
|
||||
),
|
||||
),
|
||||
];
|
||||
const policy = {
|
||||
status: "enabled",
|
||||
roots,
|
||||
const configuration = {
|
||||
nodePath: process.execPath,
|
||||
bubblewrapPath:
|
||||
process.env.REA_JAVASCRIPT_REPLAY_BWRAP_PATH ?? "/usr/bin/bwrap",
|
||||
@@ -64,39 +51,10 @@ const policy = {
|
||||
process.env.REA_JAVASCRIPT_REPLAY_SYSTEMCTL_PATH ?? "/usr/bin/systemctl",
|
||||
shellPath: process.env.REA_JAVASCRIPT_REPLAY_SHELL_PATH ?? "/usr/bin/bash",
|
||||
};
|
||||
const ceiling = {
|
||||
capability: "javascript_replay",
|
||||
roots,
|
||||
executables: [
|
||||
policy.nodePath,
|
||||
policy.bubblewrapPath,
|
||||
policy.systemdRunPath,
|
||||
policy.systemctlPath,
|
||||
policy.shellPath,
|
||||
],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: true,
|
||||
};
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:javascript_replay",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
const dependencies = {
|
||||
policy: () => policy,
|
||||
configuration: () => configuration,
|
||||
host: new SystemJavaScriptReplayHost(),
|
||||
runner: new LinuxJavaScriptReplayRunner(),
|
||||
authority,
|
||||
};
|
||||
const defaultInput = {
|
||||
mode: "plan",
|
||||
|
||||
@@ -15,7 +15,6 @@ const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "rea-inspector-")));
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_V8_INSPECTOR_OBSERVE_ENABLED: "true",
|
||||
};
|
||||
const client = new Client({
|
||||
name: "rea-inspector-conformance",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or approved runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 122
|
||||
catalog_digest: "88df47ddee7253da2eb91f94cd6c84b3e0c1ac96272a23715a6420bb6006990a"
|
||||
catalog_digest: "3a0e6d66df95b913797ce2e4548e97d409d6e8768023f8a7a23d8d85ae9d766f"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -50,8 +50,8 @@ returned result leaves a specific question unanswered.
|
||||
|
||||
Every conclusion must distinguish observations, inferences, and unknowns. Cite
|
||||
Evidence IDs, preserve limitations and incomplete coverage, and never imply
|
||||
that static analysis observed execution. Ask for approval only where a tool or
|
||||
policy requires it; approval never broadens a different authority boundary.
|
||||
that static analysis observed execution. Runtime requests execute the declared
|
||||
target and lifecycle; do not broaden the target or action beyond those fields.
|
||||
|
||||
## Plan broader investigations
|
||||
|
||||
@@ -72,7 +72,7 @@ For requests that span multiple features or subsystems, use a staged workflow:
|
||||
infer behavior from filenames, strings, or layout alone.
|
||||
5. Corroborate a conclusion with the evidence type it requires. Use runtime
|
||||
observation or controlled replay only when static evidence cannot answer the
|
||||
question and the required authority is available.
|
||||
question and the required host runtime and OS access are available.
|
||||
6. Decompose work into independent questions. When parallel workers are
|
||||
available and the questions do not depend on one another, assign distinct
|
||||
scopes, point workers to existing evidence, and ask them to return sources,
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
type AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
AnalysisProtocolError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
@@ -92,6 +94,111 @@ describe("analysis context queries: navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis context queries: headless document resolution", () => {
|
||||
it("resolves a sole headless document when current-document selection is unavailable", async () => {
|
||||
const calls: string[] = [];
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
if (operation === "current_document")
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"headless sessions have no cursor-selected document",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (operation === "list_documents")
|
||||
return Promise.resolve(
|
||||
ok(createAnalysisExecution(["fixture"], provider)),
|
||||
);
|
||||
if (operation === "resolve_containing_procedure")
|
||||
return Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
query_address: "0x401000",
|
||||
found: false,
|
||||
procedure: null,
|
||||
reason: "not_in_procedure",
|
||||
},
|
||||
provider,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"unsupported",
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
document: "fixture",
|
||||
name: { state: "unavailable" },
|
||||
procedure: { state: "available" },
|
||||
comment: { state: "unavailable" },
|
||||
inline_comment: { state: "unavailable" },
|
||||
bookmarks: { state: "unavailable" },
|
||||
},
|
||||
});
|
||||
expect(calls.slice(0, 2)).toEqual(["current_document", "list_documents"]);
|
||||
});
|
||||
|
||||
it("does not use list-documents fallback for non-capability document errors", async () => {
|
||||
const failure = new AnalysisProtocolError(
|
||||
"current-document response malformed",
|
||||
);
|
||||
const calls: string[] = [];
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(err(failure));
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toEqual(err(failure));
|
||||
expect(calls).toEqual(["current_document"]);
|
||||
});
|
||||
|
||||
it("requires exactly one listed document for a headless fallback", async () => {
|
||||
const analysis: AnalysisOperationPort = {
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
operation === "current_document"
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
"no selected document",
|
||||
),
|
||||
)
|
||||
: ok(createAnalysisExecution(["one", "two"], provider)),
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
inspectAddressContext(analysis, { address: "0x401000" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: expect.any(AnalysisOutputError),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis context queries: address facets", () => {
|
||||
it("keeps unsupported address facets local and filters bookmarks", async () => {
|
||||
const bookmarkInputs: Readonly<Record<string, unknown>>[] = [];
|
||||
|
||||
@@ -3,8 +3,13 @@ import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { AnalysisOutputError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { parseDocuments } from "../domain/hopperValues.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
type Facet =
|
||||
@@ -138,7 +143,41 @@ const resolveDocument = async (
|
||||
{},
|
||||
executionOptions(signal),
|
||||
);
|
||||
if (!current.ok) return current;
|
||||
if (!current.ok) {
|
||||
if (!(current.error instanceof AnalysisCapabilityUnavailableError))
|
||||
return current;
|
||||
const listed = await analysis.execute(
|
||||
"list_documents",
|
||||
{},
|
||||
executionOptions(signal),
|
||||
);
|
||||
if (!listed.ok) return listed;
|
||||
const parsed = parseDocuments(listed.value.result);
|
||||
if (!parsed.ok)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_documents",
|
||||
"expected one document identity when resolving a headless provider",
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
if (parsed.value.length !== 1)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_documents",
|
||||
`expected exactly one document identity for a provider without current-document selection, received ${parsed.value.length}`,
|
||||
),
|
||||
);
|
||||
const [documentName] = parsed.value;
|
||||
if (documentName === undefined)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"list_documents",
|
||||
"provider returned no document identity",
|
||||
),
|
||||
);
|
||||
return ok(documentName);
|
||||
}
|
||||
return typeof current.value.result === "string"
|
||||
? ok(current.value.result)
|
||||
: err(
|
||||
@@ -167,8 +206,7 @@ const isMissingCurrentProcedure = (error: AnalysisError): boolean =>
|
||||
const isRequestLevelFailure = (error: AnalysisError): boolean =>
|
||||
error._tag === "AnalysisCancelledError" ||
|
||||
error._tag === "AnalysisTimeoutError" ||
|
||||
error._tag === "NoBinaryOpenError" ||
|
||||
error._tag === "PermissionRequiredError";
|
||||
error._tag === "NoBinaryOpenError";
|
||||
|
||||
const containingProcedureName = (value: JsonValue): string | null => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
} from "../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { createAnalysisExecution } from "./AnalysisProvider.js";
|
||||
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
||||
|
||||
@@ -16,7 +18,18 @@ describe("analysis snapshot cache partitioning", () => {
|
||||
const execution = createAnalysisExecution(
|
||||
"main",
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{ analysisProfile: ANALYSIS_SNAPSHOT_PROFILE },
|
||||
{
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
rawResult: "main",
|
||||
subject: {
|
||||
path: ANALYSIS_SNAPSHOT_TARGET.path,
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
format: ANALYSIS_SNAPSHOT_TARGET.format,
|
||||
...(ANALYSIS_SNAPSHOT_TARGET.architecture === undefined
|
||||
? {}
|
||||
: { architecture: ANALYSIS_SNAPSHOT_TARGET.architecture }),
|
||||
},
|
||||
},
|
||||
);
|
||||
cache.record({
|
||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||
@@ -25,13 +38,37 @@ describe("analysis snapshot cache partitioning", () => {
|
||||
parameters: { address: "0x1000", document: "fixture" },
|
||||
execution,
|
||||
});
|
||||
const evidence = createEvidence(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
operation: "address_name",
|
||||
parameters: { address: "0x1000", document: "fixture" },
|
||||
result: "main",
|
||||
rawResult: "main",
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
},
|
||||
);
|
||||
const exported = cache.export(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
createEvidenceBundle([]),
|
||||
createEvidenceBundle([evidence]),
|
||||
);
|
||||
expect(exported.ok).toBe(true);
|
||||
if (!exported.ok) throw new Error("snapshot export failed");
|
||||
const tampered = structuredClone(exported.value);
|
||||
const tamperedEntry = tampered.entries[0];
|
||||
if (tamperedEntry === undefined)
|
||||
throw new Error("exported snapshot entry missing");
|
||||
tamperedEntry.execution.result = "changed without Evidence";
|
||||
let mergedEvidence = false;
|
||||
expect(
|
||||
new AnalysisSnapshotCache().import(tampered, undefined, () => {
|
||||
mergedEvidence = true;
|
||||
return ok(0);
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(mergedEvidence).toBe(false);
|
||||
const entry = exported.value.entries[0];
|
||||
if (entry === undefined) throw new Error("exported snapshot entry missing");
|
||||
Reflect.set(entry.execution, "provider", {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
snapshotMatchesProfile,
|
||||
snapshotMatchesTarget,
|
||||
snapshotTarget,
|
||||
parseAnalysisSnapshot,
|
||||
type AnalysisSnapshot,
|
||||
type AnalysisSnapshotEntry,
|
||||
type AnalysisSnapshotBinding,
|
||||
@@ -127,15 +128,28 @@ export class AnalysisSnapshotCache {
|
||||
target: BinaryTarget | undefined,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
evidenceBundle: EvidenceBundle,
|
||||
): Result<AnalysisSnapshot, NoBinaryOpenError> {
|
||||
): Result<AnalysisSnapshot, AnalysisError> {
|
||||
if (target === undefined || profile === undefined)
|
||||
return err(new NoBinaryOpenError());
|
||||
return ok({
|
||||
target: snapshotTarget(target),
|
||||
binding: snapshotBinding(profile),
|
||||
entries: this.entries(),
|
||||
evidence_bundle: structuredClone(evidenceBundle),
|
||||
});
|
||||
try {
|
||||
const snapshotTargetIdentity = snapshotTarget(target);
|
||||
const binding = snapshotBinding(profile);
|
||||
const retainedEvidence = structuredClone(evidenceBundle);
|
||||
return ok(
|
||||
parseAnalysisSnapshot({
|
||||
target: snapshotTargetIdentity,
|
||||
binding,
|
||||
entries: this.entries(),
|
||||
evidence_bundle: retainedEvidence,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate target identity, merge evidence atomically, then stage entries. */
|
||||
@@ -151,18 +165,28 @@ export class AnalysisSnapshotCache {
|
||||
bundle: EvidenceBundle,
|
||||
) => Result<number, EvidenceIntegrityError>,
|
||||
): Result<number, AnalysisError> {
|
||||
let validated: AnalysisSnapshot;
|
||||
try {
|
||||
validated = parseAnalysisSnapshot(snapshot);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
active !== undefined &&
|
||||
(!snapshotMatchesTarget(snapshot.target, active.target) ||
|
||||
!snapshotMatchesProfile(snapshot.binding, active.profile))
|
||||
(!snapshotMatchesTarget(validated.target, active.target) ||
|
||||
!snapshotMatchesProfile(validated.binding, active.profile))
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the active binary",
|
||||
),
|
||||
);
|
||||
const importedEvidence = mergeEvidence(snapshot.evidence_bundle);
|
||||
return importedEvidence.ok ? ok(this.stage(snapshot)) : importedEvidence;
|
||||
const importedEvidence = mergeEvidence(validated.evidence_bundle);
|
||||
return importedEvidence.ok ? ok(this.stage(validated)) : importedEvidence;
|
||||
}
|
||||
|
||||
/** Return canonical entries for persistence. */
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { artifactExtractionPermissionRequest } from "./ArtifactExtractionDestination.js";
|
||||
import { createPermissionPolicy } from "../domain/permissionPolicy.js";
|
||||
|
||||
describe("artifact extraction permission", () => {
|
||||
it("authorizes the REA-selected temporary destination with one stable grant", async () => {
|
||||
const request = artifactExtractionPermissionRequest();
|
||||
const { operation_identity: operationIdentity, ...scope } = request;
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy(
|
||||
[scope],
|
||||
[
|
||||
{
|
||||
...scope,
|
||||
grant_id: "artifact-extraction-once",
|
||||
lifetime: "once",
|
||||
operation_identity: operationIdentity,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await expect(authority.authorize(request, "write")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
await expect(authority.authorize(request, "write")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { PermissionRequest } from "../domain/permissionPolicy.js";
|
||||
|
||||
/** Choose a fresh temporary destination for an artifact extraction. */
|
||||
export const createArtifactExtractionDestination = (): string =>
|
||||
join(tmpdir(), `rea-extracted-${randomUUID()}`);
|
||||
|
||||
/** Stable parent scope used to authorize REA-owned extraction destinations. */
|
||||
export const artifactExtractionPermissionRoot = (): string => tmpdir();
|
||||
|
||||
/** Stable filesystem scope for the destination selected internally by REA. */
|
||||
export const artifactExtractionPermissionRequest = (): PermissionRequest => ({
|
||||
capability: "artifact_extract",
|
||||
roots: [artifactExtractionPermissionRoot()],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: "extract_artifact:temporary_output",
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
ArtifactIntegrityPolicy,
|
||||
ArtifactInventoryOptions,
|
||||
ArtifactInventorySnapshot,
|
||||
ArtifactNativeMountPolicy,
|
||||
} from "./ArtifactInventory/types.js";
|
||||
|
||||
export { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js";
|
||||
@@ -19,7 +18,6 @@ export type {
|
||||
ArtifactIntegrityPolicy,
|
||||
ArtifactInventoryOptions,
|
||||
ArtifactInventorySnapshot,
|
||||
ArtifactNativeMountPolicy,
|
||||
} from "./ArtifactInventory/types.js";
|
||||
|
||||
/** Inventory one local artifact and return every graph collection inline. */
|
||||
@@ -27,7 +25,6 @@ export const inventoryArtifact = async (
|
||||
inputPath: string,
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly nativeMount?: ArtifactNativeMountPolicy;
|
||||
readonly integrity?: ArtifactIntegrityPolicy;
|
||||
} = {},
|
||||
): Promise<ArtifactInventoryResult> => {
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import type {
|
||||
ArtifactIntegrityPolicy,
|
||||
ArtifactNativeMountPolicy,
|
||||
} from "./types.js";
|
||||
import type { ArtifactIntegrityPolicy } from "./types.js";
|
||||
|
||||
/** Parsed caller intent for integrity mismatch handling. */
|
||||
export type ArtifactIntegrityIntent =
|
||||
| { readonly mode: "fail" }
|
||||
| { readonly mode: "record-and-continue" };
|
||||
|
||||
/** Resolve caller approval and operator policy before native reader selection. */
|
||||
export const resolveNativeMountPolicy = (
|
||||
approved: boolean,
|
||||
enabled: boolean,
|
||||
): ArtifactNativeMountPolicy => {
|
||||
if (!approved) return { status: "disabled" };
|
||||
if (!enabled)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Native DMG mounting is disabled by operator policy",
|
||||
);
|
||||
return { status: "approved" };
|
||||
};
|
||||
|
||||
/** Resolve parsed caller intent and operator policy before artifact scanning. */
|
||||
/** Preserve the explicit integrity admission choice without changing verification. */
|
||||
export const resolveArtifactIntegrityPolicy = (
|
||||
intent: ArtifactIntegrityIntent,
|
||||
enabled: boolean,
|
||||
): ArtifactIntegrityPolicy => {
|
||||
if (intent.mode === "fail") return intent;
|
||||
if (!enabled)
|
||||
throw new ArtifactReaderFailure(
|
||||
"policy",
|
||||
"Integrity continuation requires explicit approval and operator policy",
|
||||
);
|
||||
return intent;
|
||||
};
|
||||
): ArtifactIntegrityPolicy => intent;
|
||||
|
||||
@@ -5,12 +5,10 @@ import { MachOSliceArtifactReader } from "../../artifacts/MachOSliceArtifactRead
|
||||
import { NativeDmgArtifactReader } from "../../artifacts/NativeDmgArtifactReader.js";
|
||||
import { ZipArtifactReader } from "../../artifacts/ZipArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import type { ArtifactNativeMountPolicy } from "./types.js";
|
||||
|
||||
export const createReader = async (
|
||||
path: string,
|
||||
format: ArtifactNode["format"],
|
||||
nativeMount: ArtifactNativeMountPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ArtifactReader | undefined> => {
|
||||
switch (format) {
|
||||
@@ -29,7 +27,7 @@ export const createReader = async (
|
||||
? new MachOSliceArtifactReader(path)
|
||||
: undefined;
|
||||
case "dmg":
|
||||
if (nativeMount.status === "disabled") return undefined;
|
||||
if (process.platform !== "darwin") return undefined;
|
||||
return NativeDmgArtifactReader.create(path, signal);
|
||||
default:
|
||||
return undefined;
|
||||
@@ -43,7 +41,7 @@ export const inventoryLimitations = (
|
||||
if (reader !== undefined) return [];
|
||||
if (format === "dmg" || format === "pkg")
|
||||
return [
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires an approved native macOS adapter.`,
|
||||
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
|
||||
];
|
||||
if (format === "mach-o-universal" && process.platform !== "darwin")
|
||||
return ["Universal Mach-O slices require the native macOS lipo adapter."];
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
type PendingIntegrityContradiction,
|
||||
} from "./scanReader.js";
|
||||
import {
|
||||
NATIVE_MOUNT_DISABLED,
|
||||
STRICT_INTEGRITY_POLICY,
|
||||
type ArtifactInventoryOptions,
|
||||
type ArtifactInventorySnapshot,
|
||||
@@ -46,12 +45,7 @@ export const scanCanonicalArtifactInventory = async (
|
||||
const rootDigest = metadata.isDirectory()
|
||||
? null
|
||||
: await hashReadable(createReadStream(path), options.signal);
|
||||
const reader = await createReader(
|
||||
path,
|
||||
rootFormat,
|
||||
options.nativeMount ?? NATIVE_MOUNT_DISABLED,
|
||||
options.signal,
|
||||
);
|
||||
const reader = await createReader(path, rootFormat, options.signal);
|
||||
|
||||
try {
|
||||
const { nodes, occurrences, pendingContradictions } = await scanReader(
|
||||
|
||||
@@ -4,15 +4,6 @@ import type {
|
||||
IntegrityContradiction,
|
||||
} from "../../domain/artifactGraph.js";
|
||||
|
||||
/** Resolved native mount authority admitted to the artifact reader. */
|
||||
export type ArtifactNativeMountPolicy =
|
||||
| { readonly status: "disabled" }
|
||||
| { readonly status: "approved" };
|
||||
|
||||
export const NATIVE_MOUNT_DISABLED: ArtifactNativeMountPolicy = {
|
||||
status: "disabled",
|
||||
};
|
||||
|
||||
/** Resolved integrity behavior admitted to the artifact scanner. */
|
||||
export type ArtifactIntegrityPolicy =
|
||||
| { readonly mode: "fail" }
|
||||
@@ -25,7 +16,6 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
|
||||
/** Options shared by artifact inventory scans. */
|
||||
export interface ArtifactInventoryOptions {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
readonly nativeMount?: ArtifactNativeMountPolicy | undefined;
|
||||
readonly integrity?: ArtifactIntegrityPolicy | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveArtifactIntegrityPolicy,
|
||||
resolveNativeMountPolicy,
|
||||
} from "./ArtifactInventory/policy.js";
|
||||
import { resolveArtifactIntegrityPolicy } from "./ArtifactInventory/policy.js";
|
||||
|
||||
describe("artifact inventory policy resolution", () => {
|
||||
it("admits native mounting only when caller and operator both allow it", () => {
|
||||
expect(resolveNativeMountPolicy(false, true)).toEqual({
|
||||
status: "disabled",
|
||||
it("preserves the selected integrity behavior without disabling verification", () => {
|
||||
expect(resolveArtifactIntegrityPolicy({ mode: "fail" })).toEqual({
|
||||
mode: "fail",
|
||||
});
|
||||
expect(() => resolveNativeMountPolicy(true, false)).toThrow(
|
||||
"disabled by operator policy",
|
||||
);
|
||||
expect(resolveNativeMountPolicy(true, true)).toEqual({
|
||||
status: "approved",
|
||||
});
|
||||
});
|
||||
|
||||
it("admits parsed continuation intent only under operator policy", () => {
|
||||
expect(() =>
|
||||
resolveArtifactIntegrityPolicy({ mode: "record-and-continue" }, false),
|
||||
).toThrow("requires explicit approval and operator policy");
|
||||
expect(
|
||||
resolveArtifactIntegrityPolicy({ mode: "record-and-continue" }, true),
|
||||
resolveArtifactIntegrityPolicy({ mode: "record-and-continue" }),
|
||||
).toEqual({ mode: "record-and-continue" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
} from "../domain/analysisProfile.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
ProviderAdapterError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceSubjectTarget } from "../domain/evidence.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
@@ -33,7 +32,11 @@ import {
|
||||
resolveSessionOpen,
|
||||
type BinarySessionOpenOptions,
|
||||
} from "./BinarySessionOpen.js";
|
||||
import { prepareSessionExecution } from "./BinarySessionExecution.js";
|
||||
import {
|
||||
bindExecutionTarget,
|
||||
commitExecutionProfile,
|
||||
prepareSessionExecution,
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
@@ -211,8 +214,10 @@ export class BinarySession
|
||||
runId,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
if (options.snapshot === undefined) {
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
}
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
@@ -329,7 +334,22 @@ export class BinarySession
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive"
|
||||
)
|
||||
) {
|
||||
const evidence = createEvidence(
|
||||
profiled.value.subject ?? active.target,
|
||||
profiled.value.provider,
|
||||
{
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
result: profiled.value.result,
|
||||
analysisProfile: profile,
|
||||
rawResult: profiled.value.rawResult,
|
||||
limitations: profiled.value.limitations,
|
||||
locations: profiled.value.locations,
|
||||
},
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
profile,
|
||||
@@ -337,7 +357,7 @@ export class BinarySession
|
||||
parameters: arguments_,
|
||||
execution: profiled.value,
|
||||
});
|
||||
else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
} else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return profiled;
|
||||
@@ -495,64 +515,3 @@ export class BinarySession
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const commitExecutionProfile = (
|
||||
operation: AnalysisOperation,
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok || profile === undefined) return result;
|
||||
const provider = result.value.provider;
|
||||
if (
|
||||
provider.id !== profile.provider.id ||
|
||||
provider.name !== profile.provider.name ||
|
||||
provider.version !== profile.provider.version
|
||||
)
|
||||
return err(
|
||||
new ProviderAdapterError(profile.provider.id, `${operation}:profile`),
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
analysisProfile: structuredClone(profile),
|
||||
});
|
||||
};
|
||||
|
||||
const bindExecutionTarget = (
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
operation: AnalysisOperation,
|
||||
target: BinaryTarget,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok) return result;
|
||||
const subject: EvidenceSubjectTarget = {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format:
|
||||
target.format === "analysis-database"
|
||||
? "analysis-database"
|
||||
: target.format,
|
||||
...(target.architecture === undefined
|
||||
? {}
|
||||
: { architecture: target.architecture }),
|
||||
};
|
||||
if (
|
||||
result.value.subject !== null &&
|
||||
result.value.subject.sha256 !== target.sha256
|
||||
) {
|
||||
if (!isArtifactInventorySubject(operation, result.value.subject, target))
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
result.value.provider.id,
|
||||
`${operation}:subject`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok({ ...result.value, subject: result.value.subject ?? subject });
|
||||
};
|
||||
|
||||
const isArtifactInventorySubject = (
|
||||
operation: AnalysisOperation,
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
(operation === "inventory_artifact" || operation === "inspect_artifact") &&
|
||||
subject.path === (target.sourcePath ?? target.path);
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
NoBinaryOpenError,
|
||||
ProviderAdapterError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { EvidenceSubjectTarget } from "../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
@@ -88,3 +90,65 @@ export const prepareSessionExecution = (
|
||||
: undefined;
|
||||
return ok({ active, capability, profile, cacheable, cached });
|
||||
};
|
||||
|
||||
/** Attach the selected profile only when the provider identity agrees. */
|
||||
export const commitExecutionProfile = (
|
||||
operation: AnalysisOperation,
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok || profile === undefined) return result;
|
||||
const provider = result.value.provider;
|
||||
if (
|
||||
provider.id !== profile.provider.id ||
|
||||
provider.name !== profile.provider.name ||
|
||||
provider.version !== profile.provider.version
|
||||
)
|
||||
return err(
|
||||
new ProviderAdapterError(profile.provider.id, `${operation}:profile`),
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
analysisProfile: structuredClone(profile),
|
||||
});
|
||||
};
|
||||
|
||||
/** Bind an execution to the selected target, preserving valid artifact subjects. */
|
||||
export const bindExecutionTarget = (
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
operation: AnalysisOperation,
|
||||
target: BinaryTarget,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok) return result;
|
||||
const subject: EvidenceSubjectTarget = {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format:
|
||||
target.format === "analysis-database"
|
||||
? "analysis-database"
|
||||
: target.format,
|
||||
...(target.architecture === undefined
|
||||
? {}
|
||||
: { architecture: target.architecture }),
|
||||
};
|
||||
if (
|
||||
result.value.subject !== null &&
|
||||
result.value.subject.sha256 !== target.sha256 &&
|
||||
!isArtifactInventorySubject(operation, result.value.subject, target)
|
||||
)
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
result.value.provider.id,
|
||||
`${operation}:subject`,
|
||||
),
|
||||
);
|
||||
return ok({ ...result.value, subject: result.value.subject ?? subject });
|
||||
};
|
||||
|
||||
const isArtifactInventorySubject = (
|
||||
operation: AnalysisOperation,
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
(operation === "inventory_artifact" || operation === "inspect_artifact") &&
|
||||
subject.path === (target.sourcePath ?? target.path);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type EvidenceObservation,
|
||||
} from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { sanitizeBrowserUrl } from "../domain/browserObservation.js";
|
||||
import type {
|
||||
BrowserTargetList,
|
||||
InspectWebPageInput,
|
||||
@@ -63,7 +64,7 @@ type BrowserEvidenceOperation =
|
||||
| "capture_web_screenshot"
|
||||
| "compare_web_screenshots";
|
||||
|
||||
/** Create Evidence for a policy-scoped external browser observation. */
|
||||
/** Create Evidence for one request-scoped external browser observation. */
|
||||
export const createBrowserEvidence = (
|
||||
operation: BrowserEvidenceOperation,
|
||||
input: BrowserEvidenceInput,
|
||||
@@ -73,7 +74,7 @@ export const createBrowserEvidence = (
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: browserPredicate(operation),
|
||||
operation,
|
||||
parameters: browserParameters(input),
|
||||
parameters: browserParameters(input, result),
|
||||
result: jsonValueSchema.parse(result),
|
||||
confidence: "observed",
|
||||
authority: "external-service",
|
||||
@@ -91,6 +92,7 @@ export const createBrowserEvidence = (
|
||||
|
||||
const browserParameters = (
|
||||
input: BrowserEvidenceInput,
|
||||
result: BrowserEvidenceResult,
|
||||
): EvidenceObservation["parameters"] => {
|
||||
if (!("cdp_endpoint" in input)) {
|
||||
if (
|
||||
@@ -129,9 +131,15 @@ const browserParameters = (
|
||||
};
|
||||
throw new Error("Browser comparison input did not match a comparison type");
|
||||
}
|
||||
const observedOrigin = browserResultTargetOrigin(result);
|
||||
const scope = {
|
||||
cdp_endpoint: input.cdp_endpoint,
|
||||
allowed_origins: input.allowed_origins,
|
||||
allowed_origins:
|
||||
input.allowed_origins.length > 0
|
||||
? input.allowed_origins
|
||||
: "target_id" in input && observedOrigin !== undefined
|
||||
? [observedOrigin]
|
||||
: [],
|
||||
};
|
||||
if (!("target_id" in input)) return scope;
|
||||
return {
|
||||
@@ -159,6 +167,27 @@ const browserParameters = (
|
||||
};
|
||||
};
|
||||
|
||||
const browserResultTargetOrigin = (
|
||||
result: BrowserEvidenceResult,
|
||||
): string | undefined => {
|
||||
if (
|
||||
"inspection" in result &&
|
||||
typeof result.inspection === "object" &&
|
||||
result.inspection !== null &&
|
||||
"target" in result.inspection &&
|
||||
typeof result.inspection.target === "object" &&
|
||||
result.inspection.target !== null &&
|
||||
"origin" in result.inspection.target &&
|
||||
typeof result.inspection.target.origin === "string"
|
||||
)
|
||||
return result.inspection.target.origin;
|
||||
if (!("target" in result)) return undefined;
|
||||
if ("origin" in result.target) return result.target.origin;
|
||||
if ("initial_url" in result.target)
|
||||
return sanitizeBrowserUrl(result.target.initial_url).origin ?? undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isScreenshotComparison = (
|
||||
input: BrowserEvidenceInput,
|
||||
): input is CompareWebScreenshotsInput => "channel_threshold" in input;
|
||||
|
||||
@@ -2,47 +2,23 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { listBrowserTargetsInputSchema } from "../domain/browserObservation.js";
|
||||
import { listBrowserTargets } from "./BrowserObservationService.js";
|
||||
import { createPermissionAuthority } from "./PermissionAuthority.js";
|
||||
|
||||
describe("browser observation authorization", () => {
|
||||
it("requests loopback authority for an IPv6-only browser origin", async () => {
|
||||
describe("browser observation service prerequisites", () => {
|
||||
it("reports a missing provider without requiring a permission grant", async () => {
|
||||
const endpoint = "http://127.0.0.1:9222";
|
||||
const origin = "http://[::1]:3000";
|
||||
const ceiling = {
|
||||
capability: "browser_observe" as const,
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [endpoint, origin],
|
||||
network: "loopback" as const,
|
||||
mount: false,
|
||||
};
|
||||
const authority = await createPermissionAuthority(
|
||||
[ceiling],
|
||||
[
|
||||
{
|
||||
...ceiling,
|
||||
grant_id: "administrator:browser_observe",
|
||||
lifetime: "administrator",
|
||||
operation_identity: null,
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!authority.ok) throw authority.error;
|
||||
|
||||
const result = await listBrowserTargets(
|
||||
undefined,
|
||||
authority.value,
|
||||
listBrowserTargetsInputSchema.parse({
|
||||
cdp_endpoint: endpoint,
|
||||
allowed_origins: [origin],
|
||||
approved: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
isLiteralLoopbackHostname,
|
||||
type InspectWebPageInput,
|
||||
type ListBrowserTargetsInput,
|
||||
import type {
|
||||
InspectWebPageInput,
|
||||
ListBrowserTargetsInput,
|
||||
} from "../domain/browserObservation.js";
|
||||
import type { AnalyzeWebBundleInput } from "../domain/webBundleAnalysis.js";
|
||||
import type { ObserveWebSessionInput } from "../domain/browserSession.js";
|
||||
@@ -20,27 +18,15 @@ import {
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserObservationPort } from "./BrowserObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { createBrowserEvidence } from "./BrowserEvidence.js";
|
||||
|
||||
type ScopedBrowserInput = Pick<
|
||||
ListBrowserTargetsInput,
|
||||
"cdp_endpoint" | "allowed_origins"
|
||||
> & { readonly target_id?: string };
|
||||
|
||||
/** Authorize and execute one policy-scoped target discovery observation. */
|
||||
/** List browser targets within the endpoint and origin scope in the request. */
|
||||
export const listBrowserTargets = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListBrowserTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
browser,
|
||||
authority,
|
||||
input,
|
||||
"list_browser_targets",
|
||||
);
|
||||
const ready = requireBrowser(browser, "list_browser_targets");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
@@ -55,14 +41,13 @@ export const listBrowserTargets = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Authorize and execute one policy-scoped passive page inspection. */
|
||||
/** Inspect one page within the endpoint and origin scope in the request. */
|
||||
export const inspectWebPage = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: InspectWebPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(browser, authority, input, "inspect_web_page");
|
||||
const ready = requireBrowser(browser, "inspect_web_page");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
return result.ok
|
||||
@@ -77,14 +62,13 @@ export const inspectWebPage = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Capture approved sources and derive bounded static web-bundle evidence. */
|
||||
/** Capture requested sources and derive static web-bundle evidence. */
|
||||
export const analyzeWebBundle = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: AnalyzeWebBundleInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(browser, authority, input, "analyze_web_bundle");
|
||||
const ready = requireBrowser(browser, "analyze_web_bundle");
|
||||
if (!ready.ok) return ready;
|
||||
const analyzed = await ready.value.analyzeBundle(input, options);
|
||||
if (!analyzed.ok) return analyzed;
|
||||
@@ -101,11 +85,10 @@ export const analyzeWebBundle = async (
|
||||
/** Observe navigation caused by external user actions during one armed window. */
|
||||
export const observeWebSession = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ObserveWebSessionInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(browser, authority, input, "observe_web_session");
|
||||
const ready = requireBrowser(browser, "observe_web_session");
|
||||
if (!ready.ok) return ready;
|
||||
const observed = await ready.value.observeSession(input, options);
|
||||
return observed.ok
|
||||
@@ -123,16 +106,10 @@ export const observeWebSession = async (
|
||||
/** Discover WebMCP declarations without exposing an invocation surface. */
|
||||
export const discoverWebMcpTools = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: DiscoverWebMcpToolsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
browser,
|
||||
authority,
|
||||
input,
|
||||
"discover_webmcp_tools",
|
||||
);
|
||||
const ready = requireBrowser(browser, "discover_webmcp_tools");
|
||||
if (!ready.ok) return ready;
|
||||
const discovered = await ready.value.discoverWebMcpTools(input, options);
|
||||
return discovered.ok
|
||||
@@ -167,19 +144,13 @@ export const compareWebCaptureEvidence = async (
|
||||
: compared;
|
||||
};
|
||||
|
||||
/** Capture one explicitly approved visible-viewport screenshot. */
|
||||
/** Capture one visible-viewport screenshot for the requested target. */
|
||||
export const captureWebScreenshot = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: CaptureWebScreenshotInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
browser,
|
||||
authority,
|
||||
input,
|
||||
"capture_web_screenshot",
|
||||
);
|
||||
const ready = requireBrowser(browser, "capture_web_screenshot");
|
||||
if (!ready.ok) return ready;
|
||||
const captured = await ready.value.captureScreenshot(input, options);
|
||||
return captured.ok
|
||||
@@ -214,51 +185,6 @@ export const compareWebScreenshotEvidence = async (
|
||||
: compared;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ScopedBrowserInput,
|
||||
operation:
|
||||
| "list_browser_targets"
|
||||
| "inspect_web_page"
|
||||
| "analyze_web_bundle"
|
||||
| "observe_web_session"
|
||||
| "discover_webmcp_tools"
|
||||
| "capture_web_screenshot",
|
||||
): Promise<Result<BrowserObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-browser",
|
||||
operation,
|
||||
"browser observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "browser_observe",
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
origins: [input.cdp_endpoint, ...input.allowed_origins],
|
||||
network: browserNetworkScope(input.allowed_origins),
|
||||
mount: false,
|
||||
operation_identity: `${operation}:${input.target_id ?? input.cdp_endpoint}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return browser === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-browser",
|
||||
operation,
|
||||
"browser observation provider is not configured",
|
||||
),
|
||||
)
|
||||
: ok(browser);
|
||||
};
|
||||
|
||||
const requireBrowser = (
|
||||
browser: BrowserObservationPort | undefined,
|
||||
operation: string,
|
||||
@@ -268,17 +194,7 @@ const requireBrowser = (
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-browser",
|
||||
operation,
|
||||
"browser comparison provider is not configured",
|
||||
"browser observation provider is not configured",
|
||||
),
|
||||
)
|
||||
: ok(browser);
|
||||
|
||||
const browserNetworkScope = (
|
||||
allowedOrigins: readonly string[],
|
||||
): "loopback" | "external" =>
|
||||
allowedOrigins.every((origin) => {
|
||||
const hostname = new URL(origin).hostname;
|
||||
return isLiteralLoopbackHostname(hostname);
|
||||
})
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
||||
@@ -1,74 +1,23 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { BrowserScenarioCapturePort } from "./BrowserScenarioCapturePort.js";
|
||||
import { createBrowserScenarioEvidence } from "./BrowserScenarioEvidence.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
|
||||
import type { BrowserScenario } from "../domain/browserScenario.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-browser-scenario";
|
||||
|
||||
const networkScope = (origins: readonly string[]): "loopback" | "external" =>
|
||||
origins.every((origin) => isLiteralLoopbackHostname(new URL(origin).hostname))
|
||||
? "loopback"
|
||||
: "external";
|
||||
|
||||
const authorizeScenario = async (
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
): Promise<Result<true, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"browser scenario permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "browser_automate",
|
||||
roots: [],
|
||||
executables:
|
||||
scenario.browser.mode === "launch"
|
||||
? [scenario.browser.executable_path]
|
||||
: [],
|
||||
environment_names: scenario.secrets.map(
|
||||
({ environment_variable: name }) => name,
|
||||
),
|
||||
origins: [
|
||||
...(scenario.browser.mode === "connect"
|
||||
? [scenario.browser.cdp_endpoint]
|
||||
: []),
|
||||
...scenario.allowed_origins,
|
||||
],
|
||||
network: networkScope(scenario.allowed_origins),
|
||||
mount: false,
|
||||
operation_identity: `${OPERATION}:${digestJson(scenario)}`,
|
||||
},
|
||||
"write",
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return { ok: true, value: true };
|
||||
};
|
||||
|
||||
/** Authorize and execute one controlled browser scenario. */
|
||||
/** Execute one controlled browser scenario using its explicit request scope. */
|
||||
export const captureBrowserScenario = async (
|
||||
provider: BrowserScenarioCapturePort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
scenario: BrowserScenario,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const authorized = await authorizeScenario(authority, scenario);
|
||||
if (!authorized.ok) return authorized;
|
||||
if (provider === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
|
||||
@@ -15,8 +15,12 @@ const browserScenarioParameters = (
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
scenario_sha256: digestJson(scenario),
|
||||
browser_mode: scenario.browser.mode,
|
||||
...(scenario.browser.mode === "launch"
|
||||
? { browser_headless: scenario.browser.headless }
|
||||
: {}),
|
||||
start_url: sanitizeBrowserUrl(scenario.start_url.url),
|
||||
allowed_origins: scenario.allowed_origins,
|
||||
environment: scenario.environment,
|
||||
actions: scenario.actions.map(({ step_id, action }) => ({
|
||||
step_id,
|
||||
action,
|
||||
@@ -25,11 +29,9 @@ const browserScenarioParameters = (
|
||||
"timeout_ms" in item ? (item.timeout_ms ?? null) : null,
|
||||
),
|
||||
secret_declarations: scenario.secrets.map(
|
||||
({ secret_id, environment_variable, purpose, redaction }) => ({
|
||||
({ secret_id, environment_variable }) => ({
|
||||
secret_id,
|
||||
environment_variable,
|
||||
purpose,
|
||||
redaction,
|
||||
}),
|
||||
),
|
||||
capture: scenario.capture,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
|
||||
type ClientFeatureName = keyof ClientFeatureAvailability;
|
||||
|
||||
/** Empty MCP client feature declaration used when the caller supplies none. */
|
||||
export const NO_CLIENT_FEATURES: ClientFeatureAvailability = {
|
||||
elicitation_form: false,
|
||||
@@ -10,30 +8,12 @@ export const NO_CLIENT_FEATURES: ClientFeatureAvailability = {
|
||||
sampling: false,
|
||||
};
|
||||
|
||||
const CLIENT_FEATURE_REQUIREMENTS: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly required: readonly ClientFeatureName[];
|
||||
readonly optional: readonly ClientFeatureName[];
|
||||
}
|
||||
>
|
||||
> = {
|
||||
capture_process_scenario: {
|
||||
required: [],
|
||||
optional: ["elicitation_form"],
|
||||
},
|
||||
};
|
||||
|
||||
/** Project a tool's client requirements against the negotiated feature set. */
|
||||
export const clientRequirementsFor = (
|
||||
name: string,
|
||||
_name: string,
|
||||
clientFeatures: ClientFeatureAvailability,
|
||||
) => {
|
||||
const requirements = CLIENT_FEATURE_REQUIREMENTS[name] ?? {
|
||||
required: [],
|
||||
optional: [],
|
||||
};
|
||||
const requirements = { required: [], optional: [] } as const;
|
||||
return {
|
||||
required: [...requirements.required],
|
||||
optional: [...requirements.optional],
|
||||
|
||||
@@ -4,12 +4,11 @@ import { buildCapabilityInventory } from "./CapabilityInventory.js";
|
||||
|
||||
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
|
||||
processCaptureEnabled: true,
|
||||
controlledReplayEnabled: true,
|
||||
browserObservationEnabled: true,
|
||||
browserScenarioEnabled: true,
|
||||
electronObservationEnabled: true,
|
||||
v8InspectorObservationEnabled: true,
|
||||
javascriptReplayEnabled: true,
|
||||
managedRuntimeEnabled: true,
|
||||
};
|
||||
|
||||
const status = (
|
||||
@@ -141,25 +140,45 @@ describe("capability inventory: provider status", () => {
|
||||
else expect(availability.remediation).toContain(remediation);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["capture_process_scenario", { processCaptureEnabled: false }],
|
||||
["inspect_web_page", { browserObservationEnabled: false }],
|
||||
["capture_browser_scenario", { browserScenarioEnabled: false }],
|
||||
["inspect_electron_page", { electronObservationEnabled: false }],
|
||||
["observe_javascript_runtime", { v8InspectorObservationEnabled: false }],
|
||||
["run_controlled_replay", { javascriptReplayEnabled: false }],
|
||||
["plan_managed_runtime_correlation", { managedRuntimeEnabled: false }],
|
||||
] as const)("reports stable policy denial for %s", (name, override) => {
|
||||
const availability = entry(name, status(), {
|
||||
it("keeps configured workflows callable and reports only real host or provider limits", () => {
|
||||
const inventory = buildCapabilityInventory(status(), {
|
||||
...enabledPolicy,
|
||||
...override,
|
||||
processCaptureEnabled: false,
|
||||
browserObservationEnabled: false,
|
||||
browserScenarioEnabled: false,
|
||||
electronObservationEnabled: false,
|
||||
electronAutomationEnabled: false,
|
||||
v8InspectorObservationEnabled: false,
|
||||
});
|
||||
expect(availability).toMatchObject({
|
||||
const byName = new Map(inventory.map((item) => [item.name, item]));
|
||||
expect(byName.get("capture_process_scenario")).toMatchObject({
|
||||
available: false,
|
||||
reason: "policy_disabled",
|
||||
reason: "unsupported_host",
|
||||
});
|
||||
expect(byName.get("inspect_web_page")).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_missing",
|
||||
});
|
||||
expect(byName.get("run_controlled_replay")).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
});
|
||||
expect(byName.get("plan_managed_runtime_correlation")).toMatchObject({
|
||||
available: true,
|
||||
reason: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports controlled replay as unsupported on other host targets", () => {
|
||||
const replay = entry("run_controlled_replay", status(), {
|
||||
...enabledPolicy,
|
||||
controlledReplayEnabled: false,
|
||||
});
|
||||
expect(replay).toMatchObject({
|
||||
available: false,
|
||||
reason: "unsupported_host",
|
||||
remediation: "Run controlled replay on a Linux x86_64 host.",
|
||||
});
|
||||
expect(availability.remediation).toEqual(expect.any(String));
|
||||
expect(availability.remediation?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,8 +190,6 @@ describe("capability inventory: caller guidance", () => {
|
||||
browserObservationEnabled: false,
|
||||
electronObservationEnabled: false,
|
||||
v8InspectorObservationEnabled: false,
|
||||
javascriptReplayEnabled: false,
|
||||
managedRuntimeEnabled: false,
|
||||
});
|
||||
for (const availability of inventory)
|
||||
if (!availability.available) {
|
||||
@@ -182,7 +199,7 @@ describe("capability inventory: caller guidance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("projects negotiated client features into per-tool requirements", () => {
|
||||
it("does not require interactive elicitation for process capture", () => {
|
||||
const withoutElicitation = buildCapabilityInventory(
|
||||
status(),
|
||||
enabledPolicy,
|
||||
@@ -196,13 +213,13 @@ describe("capability inventory: caller guidance", () => {
|
||||
|
||||
expect(withoutElicitation?.client_requirements).toEqual({
|
||||
required: [],
|
||||
optional: ["elicitation_form"],
|
||||
optional: [],
|
||||
missing_required: [],
|
||||
missing_optional: ["elicitation_form"],
|
||||
missing_optional: [],
|
||||
});
|
||||
expect(withElicitation?.client_requirements).toEqual({
|
||||
required: [],
|
||||
optional: ["elicitation_form"],
|
||||
optional: [],
|
||||
missing_required: [],
|
||||
missing_optional: [],
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import { isSupportedControlledReplayHost } from "./ControlledReplayHostSupport.js";
|
||||
import {
|
||||
clientRequirementsFor,
|
||||
NO_CLIENT_FEATURES,
|
||||
@@ -28,13 +29,12 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
||||
type ProviderDescriptor = ProviderCapability;
|
||||
export type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly controlledReplayEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
readonly electronAutomationEnabled?: boolean;
|
||||
readonly v8InspectorObservationEnabled?: boolean;
|
||||
readonly javascriptReplayEnabled?: boolean;
|
||||
readonly managedRuntimeEnabled?: boolean;
|
||||
};
|
||||
|
||||
type AvailabilityFacts = {
|
||||
@@ -185,8 +185,8 @@ const availabilityFor = (context: AvailabilityContext): Availability => {
|
||||
return navigationContextAvailability(context.descriptors);
|
||||
const javascriptApplication = javascriptApplicationAvailability(context);
|
||||
if (javascriptApplication !== null) return javascriptApplication;
|
||||
const policyDecision = policyAvailability(context);
|
||||
if (policyDecision !== null) return policyDecision;
|
||||
const workflowAvailability = workflowAvailabilityFor(context);
|
||||
if (workflowAvailability !== null) return workflowAvailability;
|
||||
const targetDecision = targetAvailability(context);
|
||||
if (targetDecision !== null) return targetDecision;
|
||||
return providerAvailability(context);
|
||||
@@ -202,54 +202,44 @@ const javascriptApplicationAvailability = ({
|
||||
: null;
|
||||
};
|
||||
|
||||
const policyAvailability = ({
|
||||
const workflowAvailabilityFor = ({
|
||||
name,
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
const browser = browserPolicyAvailability(name, kind, policy);
|
||||
const browser = browserProviderAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
||||
return {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable or grant process_capture within the administrator ceiling.",
|
||||
};
|
||||
if (name === "run_controlled_replay" && !policy.javascriptReplayEnabled)
|
||||
return {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable javascript_replay with exact source roots and sandbox executables.",
|
||||
reason: "unsupported_host",
|
||||
remediation: "Run process capture on a supported Linux or macOS host.",
|
||||
};
|
||||
if (name === "run_controlled_replay")
|
||||
return { reason: "available", remediation: null };
|
||||
if (
|
||||
name === "plan_managed_runtime_correlation" &&
|
||||
!policy.managedRuntimeEnabled
|
||||
)
|
||||
return {
|
||||
reason: "policy_disabled",
|
||||
remediation:
|
||||
"Enable managed_runtime with exact artifact roots and a runtime executable.",
|
||||
};
|
||||
return (policy.controlledReplayEnabled ??
|
||||
isSupportedControlledReplayHost(process.platform, process.arch))
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "unsupported_host",
|
||||
remediation: "Run controlled replay on a Linux x86_64 host.",
|
||||
};
|
||||
if (name === "plan_managed_runtime_correlation")
|
||||
return { reason: "available", remediation: null };
|
||||
if (kind === "application") return { reason: "available", remediation: null };
|
||||
const electron = electronPolicyAvailability(name, kind, policy);
|
||||
const electron = electronProviderAvailability(name, kind, policy);
|
||||
if (electron !== null) return electron;
|
||||
if (kind === "runtime-provider")
|
||||
return policy.v8InspectorObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Enable V8 Inspector observation; pass the user's literal-loopback Inspector endpoint to the tool.",
|
||||
"Start REA with its JavaScript runtime provider available.",
|
||||
};
|
||||
if (kind === "session") return { reason: "available", remediation: null };
|
||||
return null;
|
||||
};
|
||||
|
||||
const electronPolicyAvailability = (
|
||||
const electronProviderAvailability = (
|
||||
name: string,
|
||||
kind: ToolKind,
|
||||
policy: AvailabilityPolicy,
|
||||
@@ -259,20 +249,20 @@ const electronPolicyAvailability = (
|
||||
return policy.electronAutomationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Enable active Electron automation and configure exact executable and application roots.",
|
||||
"Start REA with the Electron scenario provider available.",
|
||||
};
|
||||
return policy.electronObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Enable Electron observation; pass the user's literal-loopback CDP endpoint to the tool.",
|
||||
"Start REA with its Electron observation provider available.",
|
||||
};
|
||||
};
|
||||
|
||||
const browserPolicyAvailability = (
|
||||
const browserProviderAvailability = (
|
||||
name: string,
|
||||
kind: ToolKind,
|
||||
policy: AvailabilityPolicy,
|
||||
@@ -281,17 +271,17 @@ const browserPolicyAvailability = (
|
||||
return policy.browserScenarioEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Enable browser scenarios and configure exact executable roots or loopback CDP endpoints, page origins, and environment secret names.",
|
||||
"Start REA with its browser scenario provider available.",
|
||||
};
|
||||
if (kind !== "browser-provider") return null;
|
||||
return policy.browserObservationEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "policy_disabled",
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Enable browser observation and configure exact CDP endpoint and page origins.",
|
||||
"Start REA with its browser observation provider available.",
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
/** Canonicalize configured policy roots while ignoring stale missing entries. */
|
||||
export const canonicalizeConfiguredRoots = async (
|
||||
roots: readonly string[],
|
||||
): Promise<string[]> => {
|
||||
const canonical: string[] = [];
|
||||
for (const root of roots) {
|
||||
try {
|
||||
canonical.push(await realpath(root));
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissingPath(cause)) throw cause;
|
||||
}
|
||||
}
|
||||
return canonical;
|
||||
};
|
||||
|
||||
const isMissingPath = (cause: unknown): boolean =>
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSupportedControlledReplayHost } from "./ControlledReplayHostSupport.js";
|
||||
|
||||
describe("controlled replay host support", () => {
|
||||
it("admits only Linux x86_64", () => {
|
||||
expect(isSupportedControlledReplayHost("linux", "x64")).toBe(true);
|
||||
expect(isSupportedControlledReplayHost("linux", "arm64")).toBe(false);
|
||||
expect(isSupportedControlledReplayHost("darwin", "x64")).toBe(false);
|
||||
expect(isSupportedControlledReplayHost("win32", "x64")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Return whether this host has the platform boundary required by replay v1. */
|
||||
export const isSupportedControlledReplayHost = (
|
||||
platform: string,
|
||||
architecture: string,
|
||||
): boolean => platform === "linux" && architecture === "x64";
|
||||
@@ -1,6 +1,4 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import { artifactExtractionPermissionRequest } from "./ArtifactExtractionDestination.js";
|
||||
import { parseConfig, type AppConfig } from "../config.js";
|
||||
import { parseConfig } from "../config.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
import { createBinarySession } from "./runtime.js";
|
||||
@@ -29,8 +27,6 @@ import {
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.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";
|
||||
import {
|
||||
REA_WORKFLOW_PROVIDER,
|
||||
workflowAnalysisProfile,
|
||||
@@ -73,7 +69,6 @@ export const runDirectAnalysis = async (
|
||||
readonly logger?: Logger;
|
||||
readonly snapshotPath?: string | undefined;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
} = {},
|
||||
): Promise<JsonValue> =>
|
||||
@@ -85,9 +80,6 @@ export const runDirectAnalysis = async (
|
||||
...(options.providerId === undefined
|
||||
? {}
|
||||
: { providerId: options.providerId }),
|
||||
...(options.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: options.permissionAuthority }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -109,78 +101,6 @@ export const runProviderAnalysis = async (
|
||||
}),
|
||||
);
|
||||
|
||||
const authorizeAnalysis = async (
|
||||
authority: PermissionAuthority,
|
||||
tool:
|
||||
| NativeToolName
|
||||
| ArtifactAnalysisOperation
|
||||
| ManagedToolName
|
||||
| DirectAnalysisTool,
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
const requests = [];
|
||||
if (tool === "extract_artifact" && typeof arguments_.output_root === "string")
|
||||
requests.push({
|
||||
request: artifactExtractionPermissionRequest(),
|
||||
access: "write" as const,
|
||||
});
|
||||
for (const request of requests) {
|
||||
const result = await authority.authorize(request.request, request.access);
|
||||
if (!result.ok) return err(projectPermissionFailure(result.error));
|
||||
}
|
||||
if (
|
||||
["inventory_artifact", "inspect_artifact"].includes(tool) &&
|
||||
arguments_.native_mount_approved === true
|
||||
) {
|
||||
const result = await authority.authorize(
|
||||
{
|
||||
capability: "native_mount",
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: true,
|
||||
operation_identity: `${tool}:native_mount`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!result.ok) return err(projectPermissionFailure(result.error));
|
||||
}
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const permissionAuthorityFor = (
|
||||
config: AppConfig,
|
||||
supplied: PermissionAuthority | undefined,
|
||||
): ReturnType<typeof loadConfiguredPermissionAuthority> =>
|
||||
supplied === undefined
|
||||
? loadConfiguredPermissionAuthority(config)
|
||||
: Promise.resolve(ok(supplied));
|
||||
|
||||
const authorizeAnalysisRun = async (input: {
|
||||
readonly config: AppConfig;
|
||||
readonly suppliedAuthority: PermissionAuthority | undefined;
|
||||
readonly tool:
|
||||
| NativeToolName
|
||||
| ArtifactAnalysisOperation
|
||||
| ManagedToolName
|
||||
| DirectAnalysisTool;
|
||||
readonly arguments: Readonly<Record<string, JsonValue>>;
|
||||
}): Promise<Result<null, AnalysisError>> => {
|
||||
const authority = await permissionAuthorityFor(
|
||||
input.config,
|
||||
input.suppliedAuthority,
|
||||
);
|
||||
if (!authority.ok) return authority;
|
||||
const operation = await authorizeAnalysis(
|
||||
authority.value,
|
||||
input.tool,
|
||||
input.arguments,
|
||||
);
|
||||
if (!operation.ok) return operation;
|
||||
return ok(null);
|
||||
};
|
||||
|
||||
const runAnalysis = async (
|
||||
path: string,
|
||||
tool:
|
||||
@@ -193,7 +113,6 @@ const runAnalysis = async (
|
||||
readonly logger: Logger;
|
||||
readonly snapshotPath: string | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
readonly permissionAuthority?: PermissionAuthority;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
/**
|
||||
* Environment the configuration is read from. Defaults to the process
|
||||
@@ -208,13 +127,6 @@ const runAnalysis = async (
|
||||
const { logger, signal, snapshotPath } = options;
|
||||
const config = parseConfig(options.environment ?? process.env);
|
||||
if (!config.ok) return cliError(config.error);
|
||||
const authorization = await authorizeAnalysisRun({
|
||||
config: config.value,
|
||||
suppliedAuthority: options.permissionAuthority,
|
||||
tool,
|
||||
arguments: arguments_,
|
||||
});
|
||||
if (!authorization.ok) return cliError(authorization.error);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
try {
|
||||
const prepared = await prepareSnapshot({
|
||||
|
||||
@@ -3,6 +3,33 @@ import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
|
||||
import { createDoctorHostFixture as host } from "./Doctor.fixture.js";
|
||||
import { runDoctor } from "./Doctor.js";
|
||||
|
||||
it("reports unavailable replay without blocking installation or Hopper readiness", async () => {
|
||||
const replayCheck = {
|
||||
name: "javascript-replay",
|
||||
ok: false,
|
||||
classification: "unsupported_host" as const,
|
||||
detail: "Controlled replay requires Linux x86_64",
|
||||
};
|
||||
const doctorHost = host({
|
||||
javascriptReplayCheck: () => Promise.resolve(replayCheck),
|
||||
});
|
||||
for (const scope of [undefined, { providers: ["hopper"] }]) {
|
||||
const report = await runDoctor(undefined, doctorHost, scope);
|
||||
expect(report.healthy).toBe(true);
|
||||
expect(report.checks).toContainEqual(replayCheck);
|
||||
expect(report.informational_checks).toContainEqual(replayCheck);
|
||||
expect(report.scope_checks).not.toContainEqual(replayCheck);
|
||||
}
|
||||
const unhealthy = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
nodeVersion: "20.0.0",
|
||||
javascriptReplayCheck: () => Promise.resolve(replayCheck),
|
||||
}),
|
||||
);
|
||||
expect(unhealthy.healthy).toBe(false);
|
||||
});
|
||||
|
||||
describe("doctor", () => {
|
||||
it("returns exact recovery for every failed diagnostic", async () => {
|
||||
const result = await runDoctor(
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
collectDoctorDiagnostics,
|
||||
doctorHealthy,
|
||||
isOptionalDoctorCheck,
|
||||
} from "./DoctorDiagnostics.js";
|
||||
import {
|
||||
normalizeDoctorScope,
|
||||
@@ -187,9 +188,14 @@ export const runDoctor = async (
|
||||
healthy: scope.mode === "audit-wide" ? environmentHealthy : scoped.healthy,
|
||||
environment_healthy: environmentHealthy,
|
||||
scope,
|
||||
scope_checks: scope.mode === "audit-wide" ? checks : scoped.scopeChecks,
|
||||
scope_checks:
|
||||
scope.mode === "audit-wide"
|
||||
? checks.filter(({ name }) => !isOptionalDoctorCheck(name))
|
||||
: scoped.scopeChecks,
|
||||
informational_checks:
|
||||
scope.mode === "audit-wide" ? [] : scoped.informationalChecks,
|
||||
scope.mode === "audit-wide"
|
||||
? checks.filter(({ name }) => isOptionalDoctorCheck(name))
|
||||
: scoped.informationalChecks,
|
||||
...(hopperPath === undefined ? {} : { hopperPath }),
|
||||
...(providerInspections === undefined ? {} : { providerInspections }),
|
||||
checks,
|
||||
|
||||
@@ -70,16 +70,19 @@ export const doctorHealthy = (
|
||||
| undefined;
|
||||
},
|
||||
): boolean => {
|
||||
const requiredChecks = checks.filter(
|
||||
({ name }) => !isOptionalDoctorCheck(name),
|
||||
);
|
||||
if (providers.providerInspections === undefined)
|
||||
return checks.every(({ ok }) => ok);
|
||||
const providerChecks = checks.filter(
|
||||
return requiredChecks.every(({ ok }) => ok);
|
||||
const providerChecks = requiredChecks.filter(
|
||||
({ name }) =>
|
||||
name.startsWith("hopper") ||
|
||||
providers.providerInspections?.some((inspection) =>
|
||||
providerCheckName(inspection.id, name),
|
||||
) === true,
|
||||
);
|
||||
const coreHealthy = checks
|
||||
const coreHealthy = requiredChecks
|
||||
.filter((candidate) => !providerChecks.includes(candidate))
|
||||
.every(({ ok }) => ok);
|
||||
const configuredProvidersHealthy = providerChecks
|
||||
@@ -99,6 +102,10 @@ export const doctorHealthy = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Identify workflow diagnostics that do not gate core installation readiness. */
|
||||
export const isOptionalDoctorCheck = (name: string): boolean =>
|
||||
name === "javascript-replay";
|
||||
|
||||
const nodeCheck = (host: DoctorHost): DoctorCheck =>
|
||||
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
|
||||
remediation: "Install Node.js 22.19+ or 24.11+.",
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
DoctorProviderInspection,
|
||||
DoctorScope,
|
||||
} from "./Doctor.js";
|
||||
import { doctorHealthy } from "./DoctorDiagnostics.js";
|
||||
import { doctorHealthy, isOptionalDoctorCheck } from "./DoctorDiagnostics.js";
|
||||
|
||||
/** Normalized readiness boundary included in every doctor response. */
|
||||
export interface DoctorScopeReport {
|
||||
@@ -58,6 +58,7 @@ export const scopeDoctorChecks = (
|
||||
const coreChecks = input.checks.filter(
|
||||
({ name }) =>
|
||||
!isProviderCheck(name, input.providers.providerInspections) &&
|
||||
!isOptionalDoctorCheck(name) &&
|
||||
name !== "skill:identity" &&
|
||||
!name.startsWith("registration:"),
|
||||
);
|
||||
|
||||
@@ -8,9 +8,14 @@ import type {
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
type CanonicalElectronActiveObservationInput =
|
||||
ElectronActiveObservationInput & {
|
||||
readonly application_root: string;
|
||||
};
|
||||
|
||||
/** Create Evidence without retaining arbitrary runtime argument values. */
|
||||
export const createElectronActiveEvidence = (
|
||||
input: ElectronActiveObservationInput,
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
result: ElectronActiveObservationResult,
|
||||
provider: ProviderIdentity,
|
||||
): Evidence =>
|
||||
@@ -31,46 +36,18 @@ export const createElectronActiveEvidence = (
|
||||
});
|
||||
|
||||
const scenarioProjection = (
|
||||
input: ElectronActiveObservationInput,
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
executable_path: input.executable_path,
|
||||
application_path: input.application_path,
|
||||
application_root: input.application_root,
|
||||
args: redactArguments(input.args),
|
||||
args: [...input.args],
|
||||
actions: input.actions.map(({ step_id, kind }) => ({ step_id, kind })),
|
||||
});
|
||||
|
||||
const parameters = (
|
||||
input: ElectronActiveObservationInput,
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
...scenarioProjection(input),
|
||||
scenario_sha256: digestJson(scenarioProjection(input)),
|
||||
});
|
||||
|
||||
const redactArguments = (arguments_: readonly string[]): string[] => {
|
||||
let redactNext = false;
|
||||
return arguments_.map((argument) => {
|
||||
if (redactNext) {
|
||||
redactNext = false;
|
||||
return "<redacted>";
|
||||
}
|
||||
if (sensitiveFlag.test(argument)) {
|
||||
redactNext = true;
|
||||
return argument;
|
||||
}
|
||||
return redactArgument(argument);
|
||||
});
|
||||
};
|
||||
|
||||
const sensitiveFlag =
|
||||
/^--?(?:password|token|secret|api[-_]?key|authorization|cookie)$/iu;
|
||||
|
||||
const redactArgument = (argument: string): string => {
|
||||
if (
|
||||
/^--?(?:password|token|secret|api[-_]?key|authorization|cookie)=/iu.test(
|
||||
argument,
|
||||
)
|
||||
)
|
||||
return `${argument.slice(0, argument.indexOf("=") + 1)}<redacted>`;
|
||||
return argument;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import { dirname } from "node:path";
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
@@ -12,34 +10,28 @@ import {
|
||||
} from "../domain/errors.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { ElectronActiveObservationInput } from "../domain/electronActiveObservation.js";
|
||||
import { isPathContained } from "../domain/permissionPolicy.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
|
||||
|
||||
const OPERATION = "capture_electron_scenario" as const;
|
||||
const PROVIDER_ID = "rea-playwright-electron-active";
|
||||
type CanonicalElectronActiveObservationInput =
|
||||
ElectronActiveObservationInput & {
|
||||
readonly application_root: string;
|
||||
};
|
||||
|
||||
const canonicalizeInput = async (
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<ElectronActiveObservationInput, AnalysisError>> => {
|
||||
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
|
||||
try {
|
||||
const [executablePath, applicationPath, applicationRoot] =
|
||||
await Promise.all([
|
||||
realpath(input.executable_path),
|
||||
realpath(input.application_path),
|
||||
realpath(input.application_root),
|
||||
]);
|
||||
if (!isPathContained(applicationRoot, applicationPath))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["application_path"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"application_path must remain beneath application_root after symlink resolution",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const [executablePath, applicationPath] = await Promise.all([
|
||||
realpath(input.executable_path),
|
||||
realpath(input.application_path),
|
||||
]);
|
||||
const applicationRoot =
|
||||
input.application_root === undefined
|
||||
? dirname(applicationPath)
|
||||
: await realpath(input.application_root);
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -63,45 +55,14 @@ const canonicalizeInput = async (
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async (
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ElectronActiveObservationInput,
|
||||
): Promise<Result<ElectronActiveObservationInput, AnalysisError>> => {
|
||||
const canonical = await canonicalizeInput(input);
|
||||
if (!canonical.ok) return canonical;
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
PROVIDER_ID,
|
||||
OPERATION,
|
||||
"active Electron observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "electron_automate",
|
||||
roots: [canonical.value.application_root],
|
||||
executables: [canonical.value.executable_path],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
operation_identity: `${OPERATION}:${digestJson(canonical.value)}`,
|
||||
},
|
||||
"write",
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return canonical;
|
||||
};
|
||||
|
||||
/** Authorize and execute one bounded, provider-owned Electron experiment. */
|
||||
/** Canonicalize request paths and execute one provider-owned Electron run. */
|
||||
export const captureElectronScenario = async (
|
||||
provider: ElectronActiveObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ElectronActiveObservationInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const authorized = await authorize(authority, input);
|
||||
if (!authorized.ok) return authorized;
|
||||
const canonical = await canonicalizeInput(input);
|
||||
if (!canonical.ok) return canonical;
|
||||
if (provider === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
@@ -110,12 +71,12 @@ export const captureElectronScenario = async (
|
||||
"active Electron observation provider is not configured",
|
||||
),
|
||||
);
|
||||
const captured = await provider.capture(authorized.value, options);
|
||||
const captured = await provider.capture(canonical.value, options);
|
||||
return captured.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createElectronActiveEvidence(
|
||||
authorized.value,
|
||||
canonical.value,
|
||||
captured.value,
|
||||
provider.identity(),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type {
|
||||
InspectElectronPageInput,
|
||||
@@ -14,19 +12,13 @@ import {
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { createElectronEvidence } from "./ElectronEvidence.js";
|
||||
|
||||
/** Authorize and list Electron file pages exposed by the selected endpoint. */
|
||||
/** List local file pages exposed by the explicitly selected endpoint. */
|
||||
export const listElectronTargets = async (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListElectronTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"list_electron_targets",
|
||||
);
|
||||
const ready = requireProvider(provider, "list_electron_targets");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
@@ -41,19 +33,13 @@ export const listElectronTargets = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Authorize and inspect one root-confined Electron page. */
|
||||
/** Inspect one local file page from the explicitly selected endpoint. */
|
||||
export const inspectElectronPage = async (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: InspectElectronPageInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"inspect_electron_page",
|
||||
);
|
||||
const ready = requireProvider(provider, "inspect_electron_page");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.inspectPage(input, options);
|
||||
return result.ok
|
||||
@@ -68,34 +54,11 @@ export const inspectElectronPage = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
const requireProvider = (
|
||||
provider: ElectronObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListElectronTargetsInput | InspectElectronPageInput,
|
||||
operation: "list_electron_targets" | "inspect_electron_page",
|
||||
): Promise<Result<ElectronObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-electron",
|
||||
operation,
|
||||
"Electron observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "electron_observe",
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "loopback",
|
||||
mount: false,
|
||||
operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.cdp_endpoint}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return provider === undefined
|
||||
): Result<ElectronObservationPort, AnalysisError> =>
|
||||
provider === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-cdp-electron",
|
||||
@@ -104,4 +67,3 @@ const prepare = async (
|
||||
),
|
||||
)
|
||||
: ok(provider);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { lstat, open, readdir, readlink } from "node:fs/promises";
|
||||
@@ -98,17 +97,13 @@ export const snapshotRoots = async (
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const safeTarget = isPathWithinRoot(root, target)
|
||||
? relative(root, target) || "."
|
||||
: "<outside-declared-root>";
|
||||
if (safeTarget === "<outside-declared-root>") truncated = true;
|
||||
entries.push({
|
||||
path: `${rootAlias}:${relativePath}`,
|
||||
type: "symlink",
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256: null,
|
||||
symlink_target: safeTarget,
|
||||
symlink_target: target,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -148,7 +143,12 @@ export const snapshotRoots = async (
|
||||
for (const child of children.sort())
|
||||
await visit(root, rootAlias, join(path, child), depth + 1);
|
||||
};
|
||||
for (const [index, root] of scenario.filesystem_roots.entries())
|
||||
for (const [index, root] of scenario.filesystem_observation_paths.entries()) {
|
||||
if ((await lstatIfPresent(root)) === undefined) {
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
await visit(root, `root_${String(index)}`, root, 0);
|
||||
}
|
||||
return { files: entries, truncated };
|
||||
};
|
||||
|
||||
@@ -55,7 +55,9 @@ export class InstrumentedJavaScriptReplayHost implements JavaScriptReplayHost {
|
||||
return this.delegate.seccompDigest();
|
||||
}
|
||||
|
||||
probe(policy: Parameters<JavaScriptReplayHost["probe"]>[0]): Promise<void> {
|
||||
return this.delegate.probe(policy);
|
||||
probe(
|
||||
configuration: Parameters<JavaScriptReplayHost["probe"]>[0],
|
||||
): Promise<void> {
|
||||
return this.delegate.probe(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,8 @@ const assertApplicationSubject = (
|
||||
if (
|
||||
evidence.subject === null ||
|
||||
evidence.subject.digest.sha256 !== result.root_artifact_sha256 ||
|
||||
evidence.subject.format !== result.format
|
||||
evidence.subject.format !== result.format ||
|
||||
evidence.subject.local_path !== result.input_path
|
||||
)
|
||||
throw new TypeError(
|
||||
"JavaScript application Evidence subject does not match its result",
|
||||
|
||||
@@ -73,7 +73,7 @@ export const traceApplicationFeatureEvidenceValidated = (
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
return workflowFailure(operation, cause, input);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export const compareApplicationVersionsEvidenceValidated = (
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
return workflowFailure(operation, cause, input);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,7 +165,7 @@ export const compareSourceToBundleEvidenceValidated = (
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
return workflowFailure(operation, cause, input);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,19 +225,56 @@ export const compareJavaScriptExportShapesEvidenceValidated = (
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return workflowFailure(operation, cause);
|
||||
return workflowFailure(operation, cause, input);
|
||||
}
|
||||
};
|
||||
|
||||
const workflowFailure = (
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
input: unknown,
|
||||
): Result<never, AnalysisError> =>
|
||||
err(
|
||||
cause instanceof z.ZodError || cause instanceof TypeError
|
||||
? new AnalysisInputError(operation)
|
||||
: new AnalysisProtocolError(
|
||||
"JavaScript application workflow produced an invalid result",
|
||||
{ cause },
|
||||
cause instanceof z.ZodError
|
||||
? err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
undefined,
|
||||
projectInputIssues(cause.issues, input),
|
||||
),
|
||||
);
|
||||
)
|
||||
: cause instanceof TypeError &&
|
||||
SAFE_APPLICATION_INPUT_CONSTRAINTS.has(cause.message)
|
||||
? err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{ path: [], reason: "invalid_value", message: cause.message },
|
||||
]),
|
||||
)
|
||||
: err(
|
||||
new AnalysisProtocolError(
|
||||
"JavaScript application workflow produced an invalid result",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
|
||||
const SAFE_APPLICATION_INPUT_CONSTRAINTS = new Set([
|
||||
"Evidence semantic identifier does not match its record",
|
||||
"Application workflow requires authenticated analyze_javascript_application, reconcile_javascript_runtime, or project_managed_application_graph Evidence",
|
||||
"JavaScript application Evidence authority or confidence is invalid",
|
||||
"JavaScript application Evidence predicate does not match its result shape",
|
||||
"JavaScript application Evidence subject does not match its result",
|
||||
"Runtime reconciliation Evidence authority or confidence is invalid",
|
||||
"Runtime reconciliation result references Evidence outside its envelope",
|
||||
"Runtime reconciliation application layer is missing",
|
||||
"Managed application graph Evidence authority or confidence is invalid",
|
||||
"Managed application graph result references Evidence outside its envelope",
|
||||
"Native handoff Evidence requires an artifact subject",
|
||||
"Native handoff Evidence must be unique",
|
||||
...[
|
||||
"analyze_javascript_application",
|
||||
"reconcile_javascript_runtime",
|
||||
"project_managed_application_graph",
|
||||
].map(
|
||||
(operation) =>
|
||||
`Evidence does not match the supported ${operation} contract`,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { analyzeParsedJavaScriptStaticSource } from "../domain/javascriptStaticAnalysis.js";
|
||||
import { analyzeParsedJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
|
||||
import { parseJavaScriptSource } from "../domain/javascriptSourceParser.js";
|
||||
import { hasValidSourceMapContents } from "../domain/sourceMapContents.js";
|
||||
import type {
|
||||
JavaScriptSourceRange,
|
||||
JavaScriptSourcePoint,
|
||||
@@ -249,9 +250,12 @@ const collectSourceMapOriginals = (
|
||||
const names = map.sources;
|
||||
if (!Array.isArray(names))
|
||||
return invalidSourceMap(file, "Source map has no sources array.");
|
||||
const contents = Array.isArray(map.sourcesContent)
|
||||
? map.sourcesContent
|
||||
: [];
|
||||
const contents = map.sourcesContent;
|
||||
if (!hasValidSourceMapContents(names.length, contents))
|
||||
return invalidSourceMap(
|
||||
file,
|
||||
"Source map sourcesContent must contain one string or null entry per source.",
|
||||
);
|
||||
const root = typeof map.sourceRoot === "string" ? map.sourceRoot : "";
|
||||
for (const [index, raw] of names.entries()) {
|
||||
if (typeof raw !== "string")
|
||||
@@ -259,8 +263,8 @@ const collectSourceMapOriginals = (
|
||||
file,
|
||||
"Source map contains a non-string source name.",
|
||||
);
|
||||
const content =
|
||||
typeof contents[index] === "string" ? contents[index] : null;
|
||||
const rawContent = Array.isArray(contents) ? contents[index] : undefined;
|
||||
const content = typeof rawContent === "string" ? rawContent : null;
|
||||
sources.push({
|
||||
source: resolveSourceName(root, raw),
|
||||
content,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { ControlledReplayInput } from "../domain/javascriptReplay.js";
|
||||
import type { EnabledJavaScriptReplayPolicy } from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
/** Build the exact permission request for one controlled replay operation. */
|
||||
export const replayPermissionRequest = (
|
||||
input: ControlledReplayInput,
|
||||
policy: EnabledJavaScriptReplayPolicy,
|
||||
) => ({
|
||||
capability: "javascript_replay" as const,
|
||||
roots: [...input.left.modules, ...(input.right?.modules ?? [])].map(
|
||||
({ path }) => path,
|
||||
),
|
||||
executables: [
|
||||
policy.nodePath,
|
||||
policy.bubblewrapPath,
|
||||
policy.systemdRunPath,
|
||||
policy.systemctlPath,
|
||||
policy.shellPath,
|
||||
],
|
||||
environment_names: [],
|
||||
network: "none" as const,
|
||||
mount: true,
|
||||
operation_identity: `run_controlled_replay:${input.mode === "plan" ? "plan" : input.plan_digest}`,
|
||||
});
|
||||
@@ -2,15 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
prepareReplayPlan,
|
||||
type EnabledJavaScriptReplayPolicy,
|
||||
type JavaScriptReplayConfiguration,
|
||||
type JavaScriptReplayHost,
|
||||
} from "./JavaScriptReplayPlanning.js";
|
||||
import { controlledReplayInputSchema } from "../domain/javascriptReplay.js";
|
||||
|
||||
const root = "/approved";
|
||||
const policy: EnabledJavaScriptReplayPolicy = {
|
||||
status: "enabled",
|
||||
roots: [root],
|
||||
const root = "/replay-fixture";
|
||||
const configuration: JavaScriptReplayConfiguration = {
|
||||
nodePath: "/runtime/node",
|
||||
bubblewrapPath: "/runtime/bwrap",
|
||||
systemdRunPath: "/runtime/systemd-run",
|
||||
@@ -100,7 +98,7 @@ describe("controlled replay planning input bounds", () => {
|
||||
limits: { protocol_bytes: 1024 * 1024 },
|
||||
});
|
||||
|
||||
await expect(prepareReplayPlan(input, policy, host)).rejects.toThrow(
|
||||
await expect(prepareReplayPlan(input, configuration, host)).rejects.toThrow(
|
||||
"Replay plan input exceeds its protocol byte limit",
|
||||
);
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
|
||||
@@ -41,10 +41,8 @@ export interface ReplayRuntimeFileIdentity {
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/** Complete executable and source authority for enabled JavaScript replay. */
|
||||
export interface EnabledJavaScriptReplayPolicy {
|
||||
readonly status: "enabled";
|
||||
readonly roots: readonly [string, ...string[]];
|
||||
/** Executable paths required to establish isolated Linux JavaScript replay. */
|
||||
export interface JavaScriptReplayConfiguration {
|
||||
readonly nodePath: string;
|
||||
readonly bubblewrapPath: string;
|
||||
readonly systemdRunPath: string;
|
||||
@@ -52,11 +50,6 @@ export interface EnabledJavaScriptReplayPolicy {
|
||||
readonly shellPath: string;
|
||||
}
|
||||
|
||||
/** Parsed JavaScript replay configuration. */
|
||||
export type JavaScriptReplayPolicy =
|
||||
| { readonly status: "disabled" }
|
||||
| EnabledJavaScriptReplayPolicy;
|
||||
|
||||
export interface JavaScriptReplayHost {
|
||||
readonly readSource: (
|
||||
path: string,
|
||||
@@ -71,7 +64,9 @@ export interface JavaScriptReplayHost {
|
||||
nodePath: string,
|
||||
) => Promise<readonly ReplayRuntimeFileIdentity[]>;
|
||||
readonly seccompDigest: () => string;
|
||||
readonly probe: (policy: EnabledJavaScriptReplayPolicy) => Promise<void>;
|
||||
readonly probe: (
|
||||
configuration: JavaScriptReplayConfiguration,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PreparedReplayPlan {
|
||||
@@ -83,7 +78,7 @@ export interface PreparedReplayPlan {
|
||||
export interface JavaScriptReplayRunner {
|
||||
readonly execute: (
|
||||
prepared: PreparedReplayPlan,
|
||||
policy: EnabledJavaScriptReplayPolicy,
|
||||
configuration: JavaScriptReplayConfiguration,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<ReplayExecutionResult>;
|
||||
}
|
||||
@@ -101,7 +96,7 @@ const assertReplayManifestWithinProtocolLimit = (
|
||||
/** Read, normalize, and content-address every input to one replay experiment. */
|
||||
export const prepareReplayPlan = async (
|
||||
input: ControlledReplayInput,
|
||||
policy: EnabledJavaScriptReplayPolicy,
|
||||
configuration: JavaScriptReplayConfiguration,
|
||||
host: JavaScriptReplayHost,
|
||||
): Promise<PreparedReplayPlan> => {
|
||||
assertReplayManifestWithinProtocolLimit(input);
|
||||
@@ -115,7 +110,7 @@ export const prepareReplayPlan = async (
|
||||
).byteLength > input.limits.input_bytes
|
||||
)
|
||||
throw new RangeError("Replay case inputs exceed the aggregate byte limit");
|
||||
await host.probe(policy);
|
||||
await host.probe(configuration);
|
||||
const [
|
||||
runtime,
|
||||
bubblewrap,
|
||||
@@ -127,13 +122,13 @@ export const prepareReplayPlan = async (
|
||||
left,
|
||||
right,
|
||||
] = await Promise.all([
|
||||
host.identifyExecutable(policy.nodePath, ["--version"]),
|
||||
host.identifyExecutable(policy.bubblewrapPath, ["--version"]),
|
||||
host.identifyExecutable(policy.systemdRunPath, ["--version"]),
|
||||
host.identifyExecutable(policy.systemctlPath, ["--version"]),
|
||||
host.identifyExecutable(policy.shellPath, ["--version"]),
|
||||
host.identifyExecutable(configuration.nodePath, ["--version"]),
|
||||
host.identifyExecutable(configuration.bubblewrapPath, ["--version"]),
|
||||
host.identifyExecutable(configuration.systemdRunPath, ["--version"]),
|
||||
host.identifyExecutable(configuration.systemctlPath, ["--version"]),
|
||||
host.identifyExecutable(configuration.shellPath, ["--version"]),
|
||||
host.identifyWorker(),
|
||||
host.identifyRuntimeClosure(policy.nodePath),
|
||||
host.identifyRuntimeClosure(configuration.nodePath),
|
||||
prepareSide(input.left, input.limits.module_bytes, host),
|
||||
input.right === undefined
|
||||
? Promise.resolve(undefined)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import { z } from "zod";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
||||
@@ -23,14 +22,11 @@ import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { replayPermissionRequest } from "./JavaScriptReplayPermission.js";
|
||||
import {
|
||||
digestBytes,
|
||||
prepareReplayPlan,
|
||||
type EnabledJavaScriptReplayPolicy,
|
||||
type JavaScriptReplayConfiguration,
|
||||
type JavaScriptReplayHost,
|
||||
type JavaScriptReplayPolicy,
|
||||
type JavaScriptReplayRunner,
|
||||
} from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
@@ -39,10 +35,9 @@ const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
export interface JavaScriptReplayDependencies {
|
||||
readonly policy: () => JavaScriptReplayPolicy;
|
||||
readonly configuration: () => JavaScriptReplayConfiguration;
|
||||
readonly host: JavaScriptReplayHost;
|
||||
readonly runner: JavaScriptReplayRunner;
|
||||
readonly authority: PermissionAuthority | undefined;
|
||||
}
|
||||
|
||||
/** Plan or execute one content-bound extracted-module replay experiment. */
|
||||
@@ -69,17 +64,13 @@ export const runControlledReplayValidated = async (
|
||||
input: ControlledReplayInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||
const authorized = await authorizeReplay(
|
||||
dependencies,
|
||||
dependencies.policy(),
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
if (!authorized.ok) return authorized;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const configuration = dependencies.configuration();
|
||||
|
||||
const prepared = await prepareValidatedReplay(
|
||||
dependencies,
|
||||
authorized.value,
|
||||
configuration,
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
@@ -105,12 +96,12 @@ export const runControlledReplayValidated = async (
|
||||
),
|
||||
);
|
||||
|
||||
const exportContext = await authorizeReproducerExport(input);
|
||||
const exportContext = prepareReproducerExport(input);
|
||||
if (!exportContext.ok) return exportContext;
|
||||
|
||||
const executed = await executeValidatedReplay({
|
||||
runner: dependencies.runner,
|
||||
policy: authorized.value,
|
||||
configuration,
|
||||
prepared: prepared.value,
|
||||
exportContext: exportContext.value,
|
||||
options,
|
||||
@@ -137,42 +128,9 @@ export const runControlledReplayValidated = async (
|
||||
}
|
||||
};
|
||||
|
||||
const authorizeReplay = async (
|
||||
dependencies: JavaScriptReplayDependencies,
|
||||
policy: JavaScriptReplayPolicy,
|
||||
input: ControlledReplayInput,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<EnabledJavaScriptReplayPolicy, AnalysisError>> => {
|
||||
if (policy.status === "disabled")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-javascript-replay",
|
||||
OPERATION,
|
||||
"controlled replay is disabled; configure exact roots and executables before enabling it",
|
||||
),
|
||||
);
|
||||
if (dependencies.authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-javascript-replay",
|
||||
OPERATION,
|
||||
"JavaScript replay permission policy is not configured",
|
||||
),
|
||||
);
|
||||
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
|
||||
|
||||
const request = replayPermissionRequest(input, policy);
|
||||
const authorized =
|
||||
input.mode === "plan"
|
||||
? await dependencies.authority.explain(request, "read")
|
||||
: await dependencies.authority.authorize(request, "read");
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return ok(policy);
|
||||
};
|
||||
|
||||
const prepareValidatedReplay = async (
|
||||
dependencies: JavaScriptReplayDependencies,
|
||||
policy: EnabledJavaScriptReplayPolicy,
|
||||
configuration: JavaScriptReplayConfiguration,
|
||||
input: ControlledReplayInput,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
@@ -180,7 +138,7 @@ const prepareValidatedReplay = async (
|
||||
> => {
|
||||
if (isAborted(signal)) return err(new AnalysisCancelledError(OPERATION));
|
||||
try {
|
||||
return ok(await prepareReplayPlan(input, policy, dependencies.host));
|
||||
return ok(await prepareReplayPlan(input, configuration, dependencies.host));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
@@ -197,7 +155,7 @@ interface ReproducerExportContext {
|
||||
readonly includeSources: boolean;
|
||||
}
|
||||
|
||||
const authorizeReproducerExport = (
|
||||
const prepareReproducerExport = (
|
||||
input: ControlledReplayExecutionInput,
|
||||
): Result<ReproducerExportContext | undefined, AnalysisError> => {
|
||||
if (input.reproducer_export === undefined) return ok(undefined);
|
||||
@@ -209,7 +167,7 @@ const authorizeReproducerExport = (
|
||||
|
||||
interface ReplayExecutionContext {
|
||||
readonly runner: JavaScriptReplayRunner;
|
||||
readonly policy: EnabledJavaScriptReplayPolicy;
|
||||
readonly configuration: JavaScriptReplayConfiguration;
|
||||
readonly prepared: Awaited<ReturnType<typeof prepareReplayPlan>>;
|
||||
readonly exportContext: ReproducerExportContext | undefined;
|
||||
readonly options: ExecutionOptions;
|
||||
@@ -226,19 +184,19 @@ const executeValidatedReplay = async (
|
||||
AnalysisError
|
||||
>
|
||||
> => {
|
||||
const { runner, policy, prepared, exportContext, options } = context;
|
||||
const { runner, configuration, prepared, exportContext, options } = context;
|
||||
await options.progress?.report({
|
||||
phase: OPERATION,
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Admitting approved modules into the isolated replay worker",
|
||||
message: "Starting the isolated replay worker",
|
||||
});
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
|
||||
try {
|
||||
let executed = replayExecutionResultSchema.parse(
|
||||
await runner.execute(prepared, policy, options.signal),
|
||||
await runner.execute(prepared, configuration, options.signal),
|
||||
);
|
||||
await options.progress?.report({
|
||||
phase: OPERATION,
|
||||
@@ -302,7 +260,7 @@ const applyReproducerExport = async (
|
||||
...executed,
|
||||
limitations: [
|
||||
...executed.limitations,
|
||||
"Replay completed, but the separately approved reproducer export failed.",
|
||||
"Replay completed, but the requested reproducer export failed.",
|
||||
],
|
||||
reproducer: {
|
||||
state: "failed",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import {
|
||||
@@ -14,19 +12,13 @@ import type {
|
||||
} from "../domain/javascriptRuntimeObservation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Authorize and list attachable Node/Electron Inspector targets. */
|
||||
/** List targets exposed by the explicitly selected loopback Inspector. */
|
||||
export const listJavaScriptRuntimeTargets = async (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListJavaScriptRuntimeTargetsInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"list_javascript_runtime_targets",
|
||||
);
|
||||
const ready = requireProvider(provider, "list_javascript_runtime_targets");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.listTargets(input, options);
|
||||
return result.ok
|
||||
@@ -41,19 +33,13 @@ export const listJavaScriptRuntimeTargets = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
/** Authorize one bounded attach-only Inspector observation. */
|
||||
/** Observe one attach-only Inspector target selected by the request. */
|
||||
export const observeJavaScriptRuntime = async (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ObserveJavaScriptRuntimeInput,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const ready = await prepare(
|
||||
provider,
|
||||
authority,
|
||||
input,
|
||||
"observe_javascript_runtime",
|
||||
);
|
||||
const ready = requireProvider(provider, "observe_javascript_runtime");
|
||||
if (!ready.ok) return ready;
|
||||
const result = await ready.value.observe(input, options);
|
||||
return result.ok
|
||||
@@ -68,34 +54,11 @@ export const observeJavaScriptRuntime = async (
|
||||
: result;
|
||||
};
|
||||
|
||||
const prepare = async (
|
||||
const requireProvider = (
|
||||
provider: JavaScriptRuntimeObservationPort | undefined,
|
||||
authority: PermissionAuthority | undefined,
|
||||
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
|
||||
operation: "list_javascript_runtime_targets" | "observe_javascript_runtime",
|
||||
): Promise<Result<JavaScriptRuntimeObservationPort, AnalysisError>> => {
|
||||
if (authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-v8-inspector",
|
||||
operation,
|
||||
"V8 Inspector observation permission policy is not configured",
|
||||
),
|
||||
);
|
||||
const authorized = await authority.authorize(
|
||||
{
|
||||
capability: "v8_inspector_observe",
|
||||
roots: [],
|
||||
executables: [],
|
||||
environment_names: [],
|
||||
network: "loopback",
|
||||
mount: false,
|
||||
operation_identity: `${operation}:${"target_id" in input ? input.target_id : input.inspector_endpoint}`,
|
||||
},
|
||||
"read",
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
return provider === undefined
|
||||
): Result<JavaScriptRuntimeObservationPort, AnalysisError> =>
|
||||
provider === undefined
|
||||
? err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"rea-v8-inspector",
|
||||
@@ -104,4 +67,3 @@ const prepare = async (
|
||||
),
|
||||
)
|
||||
: ok(provider);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -32,19 +33,82 @@ export const reconcileJavaScriptRuntimeEvidence = (
|
||||
|
||||
/** Reconcile input already parsed by a trusted adapter boundary. */
|
||||
export const reconcileJavaScriptRuntimeEvidenceValidated = (
|
||||
input: z.output<typeof reconcileJavaScriptRuntimeInputSchema>,
|
||||
parsedInput: z.output<typeof reconcileJavaScriptRuntimeInputSchema>,
|
||||
): Result<Evidence, AnalysisError> => {
|
||||
try {
|
||||
const input = reconcileJavaScriptRuntimeInputSchema.parse(
|
||||
normalizeRuntimeMappingRoots(parsedInput),
|
||||
);
|
||||
const result = reconcileJavaScriptRuntime(input);
|
||||
return ok(createJavaScriptRuntimeReconciliationEvidence(input, result));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
projectInputIssues(cause.issues, parsedInput),
|
||||
),
|
||||
);
|
||||
if (
|
||||
cause instanceof TypeError &&
|
||||
SAFE_RECONCILIATION_INPUT_CONSTRAINTS.has(cause.message)
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{ path: [], reason: "invalid_value", message: cause.message },
|
||||
]),
|
||||
);
|
||||
return err(
|
||||
cause instanceof z.ZodError || cause instanceof TypeError
|
||||
? new AnalysisInputError(OPERATION)
|
||||
: new AnalysisProtocolError(
|
||||
"JavaScript runtime reconciliation produced an invalid result",
|
||||
{ cause },
|
||||
),
|
||||
new AnalysisProtocolError(
|
||||
"JavaScript runtime reconciliation produced an invalid result",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRuntimeMappingRoots = (
|
||||
input: z.output<typeof reconcileJavaScriptRuntimeInputSchema>,
|
||||
): z.output<typeof reconcileJavaScriptRuntimeInputSchema> => ({
|
||||
...input,
|
||||
static_layers: input.static_layers.map((layer) => ({
|
||||
...layer,
|
||||
runtime_mappings: layer.runtime_mappings.map((mapping) =>
|
||||
mapping.kind === "file-root"
|
||||
? {
|
||||
...mapping,
|
||||
root: isPortableAbsolutePath(mapping.root)
|
||||
? mapping.root
|
||||
: resolve(mapping.root),
|
||||
}
|
||||
: mapping,
|
||||
),
|
||||
})),
|
||||
});
|
||||
|
||||
const isPortableAbsolutePath = (path: string): boolean =>
|
||||
path.startsWith("/") ||
|
||||
/^[a-z]:[\\/]/iu.test(path) ||
|
||||
/^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
|
||||
|
||||
const SAFE_RECONCILIATION_INPUT_CONSTRAINTS = new Set([
|
||||
"Evidence semantic identifier does not match its record",
|
||||
"JavaScript application Evidence subject disagrees with its result",
|
||||
"Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence",
|
||||
"Runtime Evidence target or declared role disagrees with its result",
|
||||
"Runtime Evidence contains an invalid builtin location",
|
||||
"Runtime Evidence target disagrees with its captured result",
|
||||
"Browser Evidence target is outside its recorded origin scope",
|
||||
"Runtime Evidence contains source without source-capture selection",
|
||||
"Active Electron Evidence application path disagrees with its configured root",
|
||||
...[
|
||||
"inspect_web_page",
|
||||
"inspect_electron_page",
|
||||
"observe_javascript_runtime",
|
||||
"capture_electron_scenario",
|
||||
].map(
|
||||
(operation) =>
|
||||
`Evidence does not match the supported ${operation} contract`,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -179,7 +179,8 @@ const handleHttpRequest = async (
|
||||
recorder.scenario.limits.protocol_body_bytes,
|
||||
);
|
||||
const method = request.method ?? "GET";
|
||||
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
||||
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const path = `${requestUrl.pathname}${requestUrl.search}`;
|
||||
if (body === undefined) {
|
||||
recorder.truncated = true;
|
||||
response.statusCode = 413;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { z } from "zod";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisProtocolError,
|
||||
EvidenceIntegrityError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
@@ -41,16 +43,29 @@ export const compareManagedMembersEvidenceValidated = (
|
||||
}
|
||||
};
|
||||
|
||||
interface ManagedMemberComparisonPathDependencies {
|
||||
readonly resolveTarget: typeof parseBinaryTarget;
|
||||
readonly readBytes: (path: string) => Promise<Buffer>;
|
||||
}
|
||||
|
||||
const DEFAULT_PATH_DEPENDENCIES: ManagedMemberComparisonPathDependencies = {
|
||||
resolveTarget: parseBinaryTarget,
|
||||
readBytes: readFile,
|
||||
};
|
||||
|
||||
/** Inspect two local PE artifacts and return one derived comparison Evidence. */
|
||||
export const compareManagedMemberPaths = async (input: {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
}): Promise<Result<Evidence, AnalysisError>> => {
|
||||
export const compareManagedMemberPaths = async (
|
||||
input: {
|
||||
readonly leftPath: string;
|
||||
readonly rightPath: string;
|
||||
},
|
||||
dependencies: ManagedMemberComparisonPathDependencies = DEFAULT_PATH_DEPENDENCIES,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const operation = "compare_managed_members";
|
||||
try {
|
||||
const [leftTarget, rightTarget] = await Promise.all([
|
||||
parseBinaryTarget(input.leftPath),
|
||||
parseBinaryTarget(input.rightPath),
|
||||
dependencies.resolveTarget(input.leftPath),
|
||||
dependencies.resolveTarget(input.rightPath),
|
||||
]);
|
||||
if (!leftTarget.ok)
|
||||
return err(
|
||||
@@ -61,9 +76,21 @@ export const compareManagedMemberPaths = async (input: {
|
||||
new AnalysisInputError(operation, { cause: rightTarget.error }),
|
||||
);
|
||||
const [leftBytes, rightBytes] = await Promise.all([
|
||||
readFile(leftTarget.value.path),
|
||||
readFile(rightTarget.value.path),
|
||||
dependencies.readBytes(leftTarget.value.path),
|
||||
dependencies.readBytes(rightTarget.value.path),
|
||||
]);
|
||||
for (const [target, bytes] of [
|
||||
[leftTarget.value, leftBytes],
|
||||
[rightTarget.value, rightBytes],
|
||||
] as const) {
|
||||
const observedSha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (observedSha256 !== target.sha256)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${observedSha256} at ${target.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const leftInspection = inspectManagedMembersBytes(
|
||||
leftBytes,
|
||||
leftTarget.value,
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { projectPermissionFailure } from "./PermissionFailure.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisProtocolError,
|
||||
type AnalysisError,
|
||||
} from "../domain/errors.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../domain/evidence.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
managedRuntimeCorrelationInputSchema,
|
||||
@@ -17,33 +11,21 @@ import {
|
||||
type ManagedRuntimeCorrelationInput,
|
||||
} from "../domain/managedRuntimeCorrelation.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import { MANAGED_WORKFLOW_PROVIDER } from "./InvestigationProviders.js";
|
||||
import { digestJson } from "./JavaScriptReplayPlanning.js";
|
||||
|
||||
const OPERATION = "plan_managed_runtime_correlation" as const;
|
||||
|
||||
/** Parsed availability and authority scope for managed runtime correlation. */
|
||||
export type ManagedRuntimePolicy =
|
||||
| { readonly status: "disabled" }
|
||||
| {
|
||||
readonly status: "enabled";
|
||||
readonly roots: readonly [string, ...string[]];
|
||||
readonly executablePath: string;
|
||||
};
|
||||
|
||||
/** A managed-runtime policy that grants no runtime-correlation authority. */
|
||||
export const MANAGED_RUNTIME_DISABLED = {
|
||||
status: "disabled",
|
||||
} as const satisfies ManagedRuntimePolicy;
|
||||
|
||||
/** Live policy and authority dependencies for managed runtime correlation. */
|
||||
export interface ManagedRuntimeCorrelationDependencies {
|
||||
readonly policy: () => ManagedRuntimePolicy;
|
||||
readonly authority: PermissionAuthority | undefined;
|
||||
/** Configured local runtime executable used when preparing a correlation plan. */
|
||||
export interface ManagedRuntimeConfiguration {
|
||||
readonly executablePath: string | undefined;
|
||||
}
|
||||
|
||||
/** Create a separately authorized, non-executing managed runtime plan Evidence. */
|
||||
/** Live managed runtime configuration source, evaluated for each request. */
|
||||
export interface ManagedRuntimeCorrelationDependencies {
|
||||
readonly configuration: () => ManagedRuntimeConfiguration;
|
||||
}
|
||||
|
||||
/** Create a non-executing managed runtime correlation plan Evidence record. */
|
||||
export const planManagedRuntimeCorrelationEvidence = async (
|
||||
dependencies: ManagedRuntimeCorrelationDependencies,
|
||||
rawInput: unknown,
|
||||
@@ -62,50 +44,10 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
|
||||
dependencies: ManagedRuntimeCorrelationDependencies,
|
||||
input: ManagedRuntimeCorrelationInput,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
const policy = dependencies.policy();
|
||||
if (policy.status === "disabled")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
MANAGED_WORKFLOW_PROVIDER.id,
|
||||
OPERATION,
|
||||
"managed runtime correlation is disabled; configure exact roots and runtime executable before enabling it",
|
||||
),
|
||||
);
|
||||
if (dependencies.authority === undefined)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
MANAGED_WORKFLOW_PROVIDER.id,
|
||||
OPERATION,
|
||||
"managed runtime permission policy is not configured",
|
||||
),
|
||||
);
|
||||
let staticEvidence: Evidence, artifactPath: string;
|
||||
const executablePath =
|
||||
dependencies.configuration().executablePath ?? "dotnet";
|
||||
try {
|
||||
staticEvidence = parseEvidence(input.static_members);
|
||||
artifactPath = artifactPathFor(staticEvidence);
|
||||
} catch (cause: unknown) {
|
||||
return err(new AnalysisInputError(OPERATION, { cause }));
|
||||
}
|
||||
const authorized = await dependencies.authority.explain(
|
||||
{
|
||||
capability: "managed_runtime",
|
||||
roots: [artifactPath],
|
||||
executables: [policy.executablePath],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: operationIdentity(input, staticEvidence),
|
||||
},
|
||||
"read",
|
||||
{ remediation: "restart" },
|
||||
);
|
||||
if (!authorized.ok) return err(projectPermissionFailure(authorized.error));
|
||||
try {
|
||||
const result = planManagedRuntimeCorrelation(
|
||||
input,
|
||||
policy.executablePath,
|
||||
authorized.value.grant_id,
|
||||
);
|
||||
const result = planManagedRuntimeCorrelation(input, executablePath);
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
@@ -118,7 +60,7 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
|
||||
predicateType: "rea.managed-runtime-correlation-plan",
|
||||
operation: OPERATION,
|
||||
parameters: {
|
||||
static_members_evidence_id: staticEvidence.evidence_id,
|
||||
static_members_evidence_id: result.static_observation.evidence_id,
|
||||
method: jsonValueSchema.parse(input.method),
|
||||
requested_effect: input.requested_effect,
|
||||
host: jsonValueSchema.parse(input.host),
|
||||
@@ -151,34 +93,3 @@ export const planManagedRuntimeCorrelationEvidenceValidated = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const artifactPathFor = (evidence: Evidence): string => {
|
||||
const result = evidence.normalized_result;
|
||||
if (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"artifact" in result &&
|
||||
typeof result.artifact === "object" &&
|
||||
result.artifact !== null &&
|
||||
"path" in result.artifact &&
|
||||
typeof result.artifact.path === "string"
|
||||
)
|
||||
return result.artifact.path;
|
||||
throw new AnalysisInputError(OPERATION, {
|
||||
cause: new TypeError(
|
||||
"Managed member Evidence does not expose an artifact path",
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const operationIdentity = (
|
||||
input: ManagedRuntimeCorrelationInput,
|
||||
evidence: Evidence,
|
||||
): string =>
|
||||
`${OPERATION}:${digestJson({
|
||||
evidence_id: evidence.evidence_id,
|
||||
method: input.method,
|
||||
requested_effect: input.requested_effect,
|
||||
host: input.host,
|
||||
bounds: input.bounds,
|
||||
})}`;
|
||||
|
||||
@@ -65,7 +65,7 @@ export const prepareNodeCharacterization = async (
|
||||
: prepared;
|
||||
};
|
||||
|
||||
/** Execute only a freshly recomputed and separately approved characterization plan. */
|
||||
/** Execute only when the supplied plan hash matches freshly prepared content. */
|
||||
export const executeNodeCharacterization = async (
|
||||
dependencies: JavaScriptReplayDependencies,
|
||||
rawInput: unknown,
|
||||
@@ -82,10 +82,10 @@ export const executeNodeCharacterization = async (
|
||||
options,
|
||||
);
|
||||
if (!prepared.ok) return prepared;
|
||||
if (prepared.value.plan.plan_sha256 !== parsed.data.approved_plan_sha256)
|
||||
if (prepared.value.plan.plan_sha256 !== parsed.data.plan_sha256)
|
||||
return err(
|
||||
new ReplayPlanStaleError(
|
||||
parsed.data.approved_plan_sha256,
|
||||
parsed.data.plan_sha256,
|
||||
prepared.value.plan.plan_sha256,
|
||||
),
|
||||
);
|
||||
@@ -229,7 +229,7 @@ const createTransformationEvidence = (
|
||||
},
|
||||
],
|
||||
limitations: [
|
||||
"The transformation is deterministic derived evidence; execution authority is established only by a separately approved characterization run.",
|
||||
"The transformation is deterministic derived evidence; execution results are limited to the characterization plan whose content hash matched.",
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -266,7 +266,7 @@ const createCharacterizationEvidence = (
|
||||
authority: "controlled-replay",
|
||||
environment: replay.evidence.environment,
|
||||
limitations: [
|
||||
"This Evidence proves only the finite approved characterization plan and transformed controlled-replay authority, not unmodified shipped-runtime equivalence.",
|
||||
"This Evidence proves only the finite hash-matched characterization plan and transformed controlled-replay result, not unmodified shipped-runtime equivalence.",
|
||||
...replay.evidence.limitations,
|
||||
],
|
||||
evidenceLinks: [
|
||||
@@ -326,12 +326,6 @@ const createCharacterizationPlan = (
|
||||
identifiers: "deterministic",
|
||||
seed: replayPlan.determinism.random_seed,
|
||||
},
|
||||
authority: {
|
||||
preparation_approved: true,
|
||||
execution_approved: false,
|
||||
network: "none",
|
||||
provider_owned_process_only: true,
|
||||
},
|
||||
});
|
||||
|
||||
const digestPreparation = (
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
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,
|
||||
type PermissionRemediation,
|
||||
} 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";
|
||||
|
||||
interface PermissionEvaluationOptions {
|
||||
readonly remediation?: PermissionRemediation;
|
||||
}
|
||||
|
||||
interface CanonicalPermissionDecision {
|
||||
readonly decision: Extract<PermissionDecision, { readonly allowed: true }>;
|
||||
readonly owner: PermissionAuthority;
|
||||
readonly request: PermissionRequest;
|
||||
}
|
||||
|
||||
type AuthorizedPermission = PermissionDecision & {
|
||||
readonly allowed: true;
|
||||
readonly request: PermissionRequest;
|
||||
};
|
||||
|
||||
type PermissionAuthorizationResult = Promise<
|
||||
Result<AuthorizedPermission, PermissionPathError | PermissionRequiredError>
|
||||
>;
|
||||
|
||||
/** Stateful owner for permission evaluation, consumption, reload, and revoke. */
|
||||
export class PermissionAuthority {
|
||||
private policy: PermissionPolicy;
|
||||
private readonly configuredAuthority: PermissionAuthority | undefined;
|
||||
private readonly outsideCeilingRemediation: Exclude<
|
||||
PermissionRemediation,
|
||||
"elicit"
|
||||
>;
|
||||
|
||||
constructor(
|
||||
policy: PermissionPolicy,
|
||||
configuredAuthority?: PermissionAuthority,
|
||||
outsideCeilingRemediation: Exclude<
|
||||
PermissionRemediation,
|
||||
"elicit"
|
||||
> = "configure",
|
||||
) {
|
||||
this.policy = policy;
|
||||
this.configuredAuthority = configuredAuthority;
|
||||
this.outsideCeilingRemediation = outsideCeilingRemediation;
|
||||
}
|
||||
|
||||
/** Create a connection-owned overlay for once and session grants. */
|
||||
createConnectionAuthority(): PermissionAuthority {
|
||||
return new PermissionAuthority(
|
||||
createPermissionPolicy([]),
|
||||
this.configuredAuthority ?? this,
|
||||
"restart",
|
||||
);
|
||||
}
|
||||
|
||||
/** Canonicalize and authorize one exact operation before its side effect. */
|
||||
async authorize(
|
||||
request: PermissionRequest,
|
||||
access: "read" | "write",
|
||||
options: PermissionEvaluationOptions = {},
|
||||
): PermissionAuthorizationResult {
|
||||
const evaluated = await this.evaluateRequest(request, access, options);
|
||||
if (!evaluated.ok) return evaluated;
|
||||
const { decision, owner, request: canonicalRequest } = evaluated.value;
|
||||
owner.policy = consumePermission(owner.policy, decision);
|
||||
return ok({ ...decision, request: canonicalRequest });
|
||||
}
|
||||
|
||||
/** Dry-run the exact shared evaluation without consuming a once grant. */
|
||||
async explain(
|
||||
request: PermissionRequest,
|
||||
access: "read" | "write",
|
||||
options: PermissionEvaluationOptions = {},
|
||||
): PermissionAuthorizationResult {
|
||||
const evaluated = await this.evaluateRequest(request, access, options);
|
||||
return evaluated.ok
|
||||
? ok({
|
||||
...evaluated.value.decision,
|
||||
request: evaluated.value.request,
|
||||
})
|
||||
: evaluated;
|
||||
}
|
||||
|
||||
private async evaluateRequest(
|
||||
request: PermissionRequest,
|
||||
access: "read" | "write",
|
||||
options: PermissionEvaluationOptions,
|
||||
): Promise<
|
||||
Result<
|
||||
CanonicalPermissionDecision,
|
||||
PermissionPathError | PermissionRequiredError
|
||||
>
|
||||
> {
|
||||
const canonical = await canonicalizePermissionRequest(request, access);
|
||||
if (!canonical.ok) return canonical;
|
||||
const evaluated = this.evaluateCanonical(canonical.value, options);
|
||||
return evaluated.ok
|
||||
? ok({ ...evaluated.value, request: canonical.value })
|
||||
: evaluated;
|
||||
}
|
||||
|
||||
/** Add authority beneath the administrator ceiling. */
|
||||
grant(grant: PermissionGrant): ReturnType<typeof grantPermission> {
|
||||
const policy =
|
||||
this.configuredAuthority === undefined
|
||||
? this.policy
|
||||
: {
|
||||
...this.policy,
|
||||
ceilings: this.configuredAuthority.effectivePolicy().ceilings,
|
||||
};
|
||||
const result = grantPermission(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);
|
||||
}
|
||||
|
||||
private effectivePolicy(): PermissionPolicy {
|
||||
if (this.configuredAuthority === undefined) return this.policy;
|
||||
const configured = this.configuredAuthority.effectivePolicy();
|
||||
return {
|
||||
ceilings: configured.ceilings,
|
||||
grants: [...configured.grants, ...this.policy.grants],
|
||||
revoked_grant_ids: new Set([
|
||||
...configured.revoked_grant_ids,
|
||||
...this.policy.revoked_grant_ids,
|
||||
]),
|
||||
consumed_grant_ids: new Set([
|
||||
...configured.consumed_grant_ids,
|
||||
...this.policy.consumed_grant_ids,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
private evaluateCanonical(
|
||||
request: PermissionRequest,
|
||||
options: PermissionEvaluationOptions,
|
||||
): Result<
|
||||
{
|
||||
readonly decision: Extract<
|
||||
PermissionDecision,
|
||||
{ readonly allowed: true }
|
||||
>;
|
||||
readonly owner: PermissionAuthority;
|
||||
},
|
||||
PermissionRequiredError
|
||||
> {
|
||||
const configured = this.configuredAuthority;
|
||||
if (configured !== undefined) {
|
||||
const configuredDecision = evaluatePermission(
|
||||
configured.effectivePolicy(),
|
||||
request,
|
||||
);
|
||||
if (configuredDecision.allowed)
|
||||
return ok({ decision: configuredDecision, owner: configured });
|
||||
}
|
||||
const localPolicy =
|
||||
configured === undefined
|
||||
? this.policy
|
||||
: { ...this.policy, ceilings: configured.effectivePolicy().ceilings };
|
||||
const localDecision = evaluatePermission(localPolicy, request);
|
||||
if (localDecision.allowed)
|
||||
return ok({ decision: localDecision, owner: this });
|
||||
const effective = this.effectivePolicy();
|
||||
const denied = evaluatePermission(effective, request);
|
||||
if (denied.allowed) return ok({ decision: denied, owner: this });
|
||||
const outsideAdministratorCeiling =
|
||||
denied.reason === "outside_administrator_ceiling";
|
||||
const requestedRemediation = options.remediation ?? "configure";
|
||||
// Only a genuine ceiling violation can be fixed by widening configuration.
|
||||
// When the scope is inside the ceiling but no grant was ever issued, the
|
||||
// `missing` payload describes what a *grant* must cover, so pointing the
|
||||
// caller at the ceiling sends them to edit configuration that already
|
||||
// permits the operation. An explicitly requested remediation is honoured.
|
||||
const remediation = outsideAdministratorCeiling
|
||||
? requestedRemediation === "restart"
|
||||
? requestedRemediation
|
||||
: this.outsideCeilingRemediation
|
||||
: requestedRemediation === "configure"
|
||||
? "grant"
|
||||
: requestedRemediation;
|
||||
return err(
|
||||
new PermissionRequiredError({
|
||||
requested: request,
|
||||
missing: denied.missing,
|
||||
ceiling: ceilingFor(effective, request.capability),
|
||||
remediation,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Atomically replace optional persisted project grants. */
|
||||
async replaceProjectGrants(
|
||||
grants: readonly PermissionGrant[],
|
||||
): Promise<Result<null, PermissionPathError>> {
|
||||
return this.replaceGrants(grants, "project");
|
||||
}
|
||||
|
||||
/** Replace grants derived from the currently loaded administrator ceiling. */
|
||||
async replaceAdministratorGrants(
|
||||
grants: readonly PermissionGrant[],
|
||||
): Promise<Result<null, PermissionPathError>> {
|
||||
return this.replaceGrants(grants, "administrator");
|
||||
}
|
||||
|
||||
private async replaceGrants(
|
||||
grants: readonly PermissionGrant[],
|
||||
lifetime: "administrator" | "project",
|
||||
): Promise<Result<null, PermissionPathError>> {
|
||||
const canonical = await canonicalizeGrants(grants);
|
||||
if (!canonical.ok) return canonical;
|
||||
const replaced = addGrantsToPolicy(
|
||||
{
|
||||
...this.policy,
|
||||
grants: this.policy.grants.filter(
|
||||
(grant) => grant.lifetime !== lifetime,
|
||||
),
|
||||
},
|
||||
canonical.value,
|
||||
);
|
||||
if (!replaced.ok) return replaced;
|
||||
this.policy = replaced.value;
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
/** Atomically replace reloadable ceilings and persisted grants. */
|
||||
async replaceConfiguredPolicy(configuration: {
|
||||
readonly ceilings: readonly PermissionCeiling[];
|
||||
readonly administratorGrants: readonly PermissionGrant[];
|
||||
readonly projectGrants: readonly PermissionGrant[];
|
||||
}): Promise<Result<null, PermissionPathError>> {
|
||||
const ceilings = await canonicalizePermissionCeilings(
|
||||
configuration.ceilings,
|
||||
);
|
||||
if (!ceilings.ok) return ceilings;
|
||||
const grants = await canonicalizeGrants([
|
||||
...configuration.administratorGrants,
|
||||
...configuration.projectGrants,
|
||||
]);
|
||||
if (!grants.ok) return grants;
|
||||
const candidate: PermissionPolicy = {
|
||||
...reloadPermissionCeilings(this.policy, ceilings.value),
|
||||
grants: this.policy.grants.filter(
|
||||
({ lifetime }) =>
|
||||
lifetime !== "administrator" && lifetime !== "project",
|
||||
),
|
||||
};
|
||||
const replaced = addGrantsToPolicy(candidate, grants.value);
|
||||
if (!replaced.ok) return replaced;
|
||||
this.policy = replaced.value;
|
||||
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;
|
||||
const canonicalGrants = await canonicalizeGrants(grants);
|
||||
if (!canonicalGrants.ok) return canonicalGrants;
|
||||
const policy = addGrantsToPolicy(
|
||||
createPermissionPolicy(canonical.value),
|
||||
canonicalGrants.value,
|
||||
);
|
||||
return policy.ok ? ok(new PermissionAuthority(policy.value)) : policy;
|
||||
};
|
||||
|
||||
const canonicalizeGrants = async (
|
||||
grants: readonly PermissionGrant[],
|
||||
): Promise<Result<readonly PermissionGrant[], PermissionPathError>> => {
|
||||
const canonical: PermissionGrant[] = [];
|
||||
for (const grant of grants) {
|
||||
const canonicalGrant = await canonicalizeGrant(grant);
|
||||
if (!canonicalGrant.ok) return canonicalGrant;
|
||||
canonical.push(canonicalGrant.value);
|
||||
}
|
||||
return ok(canonical);
|
||||
};
|
||||
|
||||
const addGrantsToPolicy = (
|
||||
initial: PermissionPolicy,
|
||||
grants: readonly PermissionGrant[],
|
||||
): Result<PermissionPolicy, PermissionPathError> => {
|
||||
let policy = initial;
|
||||
for (const grant of grants) {
|
||||
const added = grantPermission(policy, grant);
|
||||
if (!added.ok)
|
||||
return err(
|
||||
new PermissionPathError(grant.grant_id, "io", { cause: added.error }),
|
||||
);
|
||||
policy = added.value;
|
||||
}
|
||||
return ok(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;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { PermissionAuthority } from "./PermissionAuthority.js";
|
||||
import type { PermissionRequiredError } from "../domain/errors.js";
|
||||
import {
|
||||
createPermissionPolicy,
|
||||
type PermissionRequest,
|
||||
type PermissionScope,
|
||||
} from "../domain/permissionPolicy.js";
|
||||
|
||||
// Ceiling paths must exist: the policy canonicalizes them with realpath.
|
||||
const ceilingScope: PermissionScope = {
|
||||
capability: "browser_automate",
|
||||
roots: [],
|
||||
executables: [process.execPath],
|
||||
origins: ["http://127.0.0.1:3000"],
|
||||
environment_names: [],
|
||||
network: "external",
|
||||
mount: false,
|
||||
};
|
||||
|
||||
/** A request the administrator ceiling already fully covers. */
|
||||
const coveredRequest = (
|
||||
overrides: Partial<PermissionScope> = {},
|
||||
): PermissionRequest => ({
|
||||
...ceilingScope,
|
||||
network: "loopback",
|
||||
operation_identity: "browser-scenario-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Narrow to the permission-denial error, failing the test on any other error. */
|
||||
const denied = async (
|
||||
authority: PermissionAuthority,
|
||||
request: PermissionRequest,
|
||||
remediation?: "elicit",
|
||||
): Promise<PermissionRequiredError> => {
|
||||
const result = await authority.authorize(
|
||||
request,
|
||||
"write",
|
||||
remediation === undefined ? {} : { remediation },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected a denial");
|
||||
if (result.error._tag !== "PermissionRequiredError")
|
||||
throw new Error(`expected a permission denial, got ${result.error._tag}`);
|
||||
return result.error;
|
||||
};
|
||||
|
||||
describe("permission denial remediation", () => {
|
||||
it("asks for a grant, not a wider ceiling, when the ceiling covers the scope", async () => {
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([ceilingScope], []),
|
||||
);
|
||||
// The ceiling contains this scope, so widening it cannot help.
|
||||
expect(await denied(authority, coveredRequest())).toMatchObject({
|
||||
remediation: "grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies an empty request as needing a grant, not a ceiling violation", async () => {
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([ceilingScope], []),
|
||||
);
|
||||
expect(
|
||||
await denied(authority, {
|
||||
capability: "browser_automate",
|
||||
roots: [],
|
||||
executables: [],
|
||||
origins: [],
|
||||
environment_names: [],
|
||||
network: "none",
|
||||
mount: false,
|
||||
operation_identity: "browser-scenario-1",
|
||||
}),
|
||||
).toMatchObject({ remediation: "grant" });
|
||||
});
|
||||
|
||||
it("still points at configuration for a genuine ceiling violation", async () => {
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([ceilingScope], []),
|
||||
);
|
||||
expect(
|
||||
await denied(
|
||||
authority,
|
||||
coveredRequest({ origins: ["https://not-configured.example"] }),
|
||||
),
|
||||
).toMatchObject({ remediation: "configure" });
|
||||
});
|
||||
|
||||
it("keeps an explicitly requested elicit remediation untouched", async () => {
|
||||
const authority = new PermissionAuthority(
|
||||
createPermissionPolicy([ceilingScope], []),
|
||||
);
|
||||
expect(await denied(authority, coveredRequest(), "elicit")).toMatchObject({
|
||||
remediation: "elicit",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import {
|
||||
AnalysisProtocolError,
|
||||
PermissionRequiredError,
|
||||
} from "../domain/errors.js";
|
||||
import type { PermissionPathError } from "./PermissionAuthority.js";
|
||||
|
||||
/** Preserve permission denials and project failed path resolution for callers. */
|
||||
export const projectPermissionFailure = (
|
||||
error: PermissionRequiredError | PermissionPathError,
|
||||
): PermissionRequiredError | AnalysisProtocolError =>
|
||||
error instanceof PermissionRequiredError
|
||||
? error
|
||||
: new AnalysisProtocolError(error.message, { cause: error });
|
||||
@@ -1,46 +0,0 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
|
||||
import type {
|
||||
EnabledProcessExecutionPolicy,
|
||||
ProcessScenario,
|
||||
} from "../domain/processCapture.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
import { canonicalizeConfiguredRoots } from "./ConfiguredRoots.js";
|
||||
|
||||
export const assertRealPathAuthority = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: EnabledProcessExecutionPolicy,
|
||||
): Promise<void> => {
|
||||
const executable = await realpath(scenario.executable);
|
||||
const executableRoots = await canonicalizeConfiguredRoots(
|
||||
policy.executableRoots,
|
||||
);
|
||||
if (!executableRoots.some((root) => isPathWithinRoot(root, executable)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved executable is outside approved roots",
|
||||
);
|
||||
const workingDirectory = await realpath(scenario.working_directory);
|
||||
const workingRoots = await canonicalizeConfiguredRoots(policy.workingRoots);
|
||||
if (!workingRoots.some((root) => isPathWithinRoot(root, workingDirectory)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved working directory is outside approved roots",
|
||||
);
|
||||
for (const root of scenario.filesystem_roots) {
|
||||
const resolvedRoot = await realpath(root);
|
||||
if (
|
||||
!workingRoots.some((approved) => isPathWithinRoot(approved, resolvedRoot))
|
||||
)
|
||||
throw new ProcessCaptureError(
|
||||
"resolved filesystem root is outside approved roots",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Reject cancellation before a process capture operation begins. */
|
||||
export const assertNotCancelled = (signal: AbortSignal | undefined): void => {
|
||||
if (signal?.aborted === true) throw processCaptureCancelled();
|
||||
};
|
||||
@@ -8,9 +8,26 @@ export type ProcessCaptureCapability =
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
/** Explain why process capture cannot claim owned-process cleanup on a host. */
|
||||
export const processCaptureOwnershipUnavailableReason = (
|
||||
platform: NodeJS.Platform,
|
||||
): string | undefined =>
|
||||
platform === "win32"
|
||||
? "Windows process-tree ownership and cleanup are unavailable; REA cannot verify that descendants have stopped."
|
||||
: undefined;
|
||||
|
||||
/** Probe the actual native PTY seam instead of inferring support from the OS name. */
|
||||
export const probeProcessCaptureCapability =
|
||||
async (): Promise<ProcessCaptureCapability> => {
|
||||
const ownershipReason = processCaptureOwnershipUnavailableReason(
|
||||
process.platform,
|
||||
);
|
||||
if (ownershipReason !== undefined)
|
||||
return {
|
||||
available: false,
|
||||
backend: "node-pty",
|
||||
reason: ownershipReason,
|
||||
};
|
||||
try {
|
||||
const { spawn } = await import("@lydell/node-pty");
|
||||
const terminal = spawn(
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { delimiter } from "node:path";
|
||||
|
||||
import type { ProcessScenario } from "../domain/processCapture.js";
|
||||
import type { LoopbackReplay } from "./LoopbackReplay.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
|
||||
interface ProcessCaptureEnvironmentOptions {
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly home: string;
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly shimReplay: CommandShimReplay;
|
||||
readonly runId: string;
|
||||
readonly hostEnvironment: Readonly<Record<string, string | undefined>>;
|
||||
}
|
||||
|
||||
/** Build the explicitly admitted environment for a captured process. */
|
||||
/** Build the inherited environment with scenario overrides and replay instrumentation. */
|
||||
export const makeProcessCaptureEnvironment = (
|
||||
options: ProcessCaptureEnvironmentOptions,
|
||||
): Record<string, string> => {
|
||||
const { scenario, home, replay, shimReplay, runId } = options;
|
||||
const { scenario, replay, shimReplay, runId, hostEnvironment } = options;
|
||||
const environment: Record<string, string> = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(hostEnvironment).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||
),
|
||||
),
|
||||
...scenario.environment,
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
TERM: scenario.environment.TERM ?? hostEnvironment.TERM ?? "xterm-256color",
|
||||
REA_PROCESS_RUN_ID: runId,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
environment.REA_SHIM_LEDGER_URL = shimReplay.url;
|
||||
// PATH is evidence-producing input. Inheriting the host PATH here would let
|
||||
// the authority and reconstruction probe different tools without recording
|
||||
// that difference. Scenarios must opt in through environment or
|
||||
// inherit_environment; deterministic shims always take precedence.
|
||||
// Shims are instrumentation; all remaining executable lookup uses host PATH.
|
||||
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
|
||||
.filter((part) => part.length > 0)
|
||||
.join(":");
|
||||
.join(delimiter);
|
||||
return environment;
|
||||
};
|
||||
|
||||
@@ -2,12 +2,8 @@ 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 userCategory?: "cancelled";
|
||||
readonly reason?: "capture_failed" | "cleanup_incomplete" | "cancelled";
|
||||
readonly cleanupResources?: readonly string[];
|
||||
}
|
||||
|
||||
@@ -16,10 +12,7 @@ export class ProcessCaptureError extends AnalysisError {
|
||||
readonly _tag = "ProcessCaptureError";
|
||||
|
||||
override readonly userMessage: string | undefined;
|
||||
override readonly userCategory:
|
||||
| "permission_required"
|
||||
| "cancelled"
|
||||
| undefined;
|
||||
override readonly userCategory: "cancelled" | undefined;
|
||||
readonly reason: NonNullable<ProcessCaptureErrorOptions["reason"]>;
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
@@ -30,11 +23,7 @@ export class ProcessCaptureError extends AnalysisError {
|
||||
this.userCategory = options?.userCategory;
|
||||
this.reason =
|
||||
options?.reason ??
|
||||
(options?.userCategory === "permission_required"
|
||||
? "permission_required"
|
||||
: options?.userCategory === "cancelled"
|
||||
? "cancelled"
|
||||
: "capture_failed");
|
||||
(options?.userCategory === "cancelled" ? "cancelled" : "capture_failed");
|
||||
this.cleanupIncomplete = this.reason === "cleanup_incomplete";
|
||||
this.cleanupResources = options?.cleanupResources ?? [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { IPty } from "@lydell/node-pty";
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
UnverifiedProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessPolicyDecision,
|
||||
ProcessSample,
|
||||
ProcessSettlement,
|
||||
ProcessScenario,
|
||||
@@ -20,7 +18,6 @@ import type {
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import {
|
||||
authorizeProcessScenario,
|
||||
digestProcessCommitment,
|
||||
processComparisonContract,
|
||||
processScenarioCommitment,
|
||||
@@ -38,15 +35,12 @@ import {
|
||||
ProcessCheckpoints,
|
||||
} from "./ProcessCheckpoints.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
assertNotCancelled,
|
||||
assertRealPathAuthority,
|
||||
} from "./ProcessCaptureAuthority.js";
|
||||
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
|
||||
import {
|
||||
normalizeProcessElapsedTime,
|
||||
normalizeProcessSamples,
|
||||
normalizeProcessText,
|
||||
redactProtocolEvents,
|
||||
normalizeProtocolEvents,
|
||||
} from "./ProcessNormalization.js";
|
||||
import type { CommandShimReplay } from "./CommandShimReplay.js";
|
||||
import { PROCESS_PROVIDER } from "./ProcessEvidence.js";
|
||||
@@ -110,60 +104,91 @@ export type PendingProcessCapture = Omit<
|
||||
|
||||
export const buildCaptureResult = (
|
||||
options: CaptureResultOptions,
|
||||
): PendingProcessCapture => ({
|
||||
manifest: options.manifest,
|
||||
normalization: options.scenario.normalization,
|
||||
frames: options.frames,
|
||||
rendered_frames: options.renderedFrames,
|
||||
interaction_events: options.interactions,
|
||||
exit: {
|
||||
code:
|
||||
options.exit.reason === "exited" && options.exit.exitCode >= 0
|
||||
? options.exit.exitCode
|
||||
: null,
|
||||
signal: options.exit.signal ?? null,
|
||||
reason: options.exit.reason,
|
||||
},
|
||||
settlement: options.settlement,
|
||||
process_samples: normalizeProcessSamples(
|
||||
options.samples,
|
||||
options.scenario,
|
||||
options.rootPid,
|
||||
),
|
||||
filesystem_checkpoints: options.checkpoints,
|
||||
shim_events: options.shimEvents,
|
||||
protocol_events: redactProtocolEvents(
|
||||
): PendingProcessCapture => {
|
||||
const hasSensitiveScriptedInput = options.scenario.events.some(
|
||||
(event) => event.type === "input" && event.sensitive,
|
||||
);
|
||||
const sensitiveInputUnknown =
|
||||
"Sensitive scripted input values are redacted from process capture Evidence.";
|
||||
const filesystemObservationUnknown =
|
||||
"No filesystem observation paths were selected; filesystem effects remain unknown.";
|
||||
const protocolEvents = normalizeProtocolEvents(
|
||||
options.replay.events,
|
||||
options.scenario,
|
||||
),
|
||||
replay_transitions: options.replay.transitions,
|
||||
event_journal: options.eventJournal,
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
filesystem_effects: classifyFilesystemEffects(
|
||||
options.before.files,
|
||||
options.after.files,
|
||||
),
|
||||
truncated: options.truncated,
|
||||
limitations: [
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
...(options.samplingPartial
|
||||
? ["Process-tree sampling ended with an incomplete observation."]
|
||||
: []),
|
||||
"Filesystem observations are before/after snapshots, not syscall traces.",
|
||||
"The harness does not enforce external network isolation.",
|
||||
],
|
||||
residual_unknowns: [
|
||||
{
|
||||
scope: "process",
|
||||
reason: "Process trees are sampled and may omit short-lived descendants.",
|
||||
);
|
||||
const hasFilesystemObservations =
|
||||
options.scenario.filesystem_observation_paths.length > 0;
|
||||
return {
|
||||
manifest: options.manifest,
|
||||
normalization: options.scenario.normalization,
|
||||
frames: options.frames,
|
||||
rendered_frames: options.renderedFrames,
|
||||
interaction_events: options.interactions,
|
||||
exit: {
|
||||
code:
|
||||
options.exit.reason === "exited" && options.exit.exitCode >= 0
|
||||
? options.exit.exitCode
|
||||
: null,
|
||||
signal: options.exit.signal ?? null,
|
||||
reason: options.exit.reason,
|
||||
},
|
||||
{
|
||||
scope: "network",
|
||||
reason: "External network isolation is not enforced by this adapter.",
|
||||
},
|
||||
],
|
||||
});
|
||||
settlement: options.settlement,
|
||||
process_samples: normalizeProcessSamples(
|
||||
options.samples,
|
||||
options.scenario,
|
||||
options.rootPid,
|
||||
),
|
||||
filesystem_checkpoints: options.checkpoints,
|
||||
shim_events: options.shimEvents,
|
||||
protocol_events: protocolEvents,
|
||||
replay_transitions: options.replay.transitions,
|
||||
event_journal: options.eventJournal,
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
filesystem_effects: classifyFilesystemEffects(
|
||||
options.before.files,
|
||||
options.after.files,
|
||||
),
|
||||
truncated: options.truncated,
|
||||
limitations: [
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
...(options.samplingPartial
|
||||
? ["Process-tree sampling ended with an incomplete observation."]
|
||||
: []),
|
||||
"Filesystem observations are before/after snapshots, not syscall traces.",
|
||||
"The harness does not enforce external network isolation.",
|
||||
"Inherited host environment variables are not recorded and may affect results.",
|
||||
...(!hasFilesystemObservations ? [filesystemObservationUnknown] : []),
|
||||
...(hasSensitiveScriptedInput ? [sensitiveInputUnknown] : []),
|
||||
],
|
||||
residual_unknowns: [
|
||||
{
|
||||
scope: "process",
|
||||
reason:
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
},
|
||||
{
|
||||
scope: "network",
|
||||
reason: "External network isolation is not enforced by this adapter.",
|
||||
},
|
||||
{
|
||||
scope: "environment",
|
||||
reason: "Inherited host environment variables are not recorded.",
|
||||
},
|
||||
...(!hasFilesystemObservations
|
||||
? [
|
||||
{
|
||||
scope: "filesystem" as const,
|
||||
reason: filesystemObservationUnknown,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasSensitiveScriptedInput
|
||||
? [{ scope: "interaction" as const, reason: sensitiveInputUnknown }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const hashFile = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
@@ -306,6 +331,19 @@ export const awaitTerminalExit = async ({
|
||||
timers.add(timeout);
|
||||
});
|
||||
|
||||
/** Host operations used when releasing one captured process run. */
|
||||
export interface ProcessCaptureCleanupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly cleanupProcessGroup: typeof cleanupOwnedProcessGroup;
|
||||
readonly removeTemporaryRoot: (path: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const processCaptureCleanupHost: ProcessCaptureCleanupHost = {
|
||||
platform: process.platform,
|
||||
cleanupProcessGroup: cleanupOwnedProcessGroup,
|
||||
removeTemporaryRoot: (path) => rm(path, { recursive: true, force: true }),
|
||||
};
|
||||
|
||||
export const releaseProcessResources = async (options: {
|
||||
readonly timers: ReadonlySet<ProcessTimer>;
|
||||
readonly replay: LoopbackReplay | undefined;
|
||||
@@ -316,7 +354,9 @@ export const releaseProcessResources = async (options: {
|
||||
readonly runId: string;
|
||||
readonly temporaryRoot: string;
|
||||
readonly capturedProcessGroupIds: readonly number[];
|
||||
readonly host?: ProcessCaptureCleanupHost;
|
||||
}): Promise<string | undefined> => {
|
||||
const host = options.host ?? processCaptureCleanupHost;
|
||||
for (const timer of options.timers) timer.cancel();
|
||||
let failure: string | undefined;
|
||||
try {
|
||||
@@ -339,9 +379,9 @@ export const releaseProcessResources = async (options: {
|
||||
} catch {
|
||||
failure ??= "filesystem checkpoint cleanup failed";
|
||||
}
|
||||
if (options.terminal !== undefined && process.platform !== "win32") {
|
||||
if (options.terminal !== undefined && host.platform !== "win32") {
|
||||
for (const processGroupId of new Set(options.capturedProcessGroupIds)) {
|
||||
const cleaned = await cleanupOwnedProcessGroup({
|
||||
const cleaned = await host.cleanupProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: processGroupId,
|
||||
processGroupId,
|
||||
@@ -350,7 +390,7 @@ export const releaseProcessResources = async (options: {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(options.temporaryRoot, { recursive: true, force: true });
|
||||
await host.removeTemporaryRoot(options.temporaryRoot);
|
||||
} catch {
|
||||
failure ??= "temporary process root cleanup failed";
|
||||
}
|
||||
@@ -424,70 +464,32 @@ export const resolveProcessResult = (
|
||||
|
||||
export const prepareProcessCapture = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal: AbortSignal | undefined,
|
||||
host: ProcessPreparationHost = systemProcessPreparationHost,
|
||||
): Promise<{
|
||||
readonly temporaryRoot: string;
|
||||
readonly runId: string;
|
||||
readonly home: string;
|
||||
readonly before: SnapshotResult;
|
||||
}> => {
|
||||
if (policy.status === "disabled")
|
||||
throw new ProcessCaptureError("process capture is disabled", {
|
||||
userCategory: "permission_required",
|
||||
userMessage: processPolicyMessage("process capture is disabled"),
|
||||
});
|
||||
const decision = authorizeProcessScenario(scenario, policy);
|
||||
if (!decision.allowed)
|
||||
throw new ProcessCaptureError(decision.reason, {
|
||||
userCategory: "permission_required",
|
||||
userMessage: processPolicyMessage(decision.reason),
|
||||
});
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
assertNotCancelled(signal);
|
||||
const before = await snapshotRoots(scenario, signal);
|
||||
const temporaryRoot = await host.createTemporaryRoot();
|
||||
try {
|
||||
const runId = randomUUID();
|
||||
const home = join(temporaryRoot, "home");
|
||||
await host.createHome(home);
|
||||
return { temporaryRoot, runId, home, before };
|
||||
return { temporaryRoot, runId, before };
|
||||
} catch (cause: unknown) {
|
||||
await host.cleanup(temporaryRoot);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
/** Filesystem seam for allocating and cleaning a capture's private home. */
|
||||
/** Filesystem seam for allocating and cleaning a capture's temporary root. */
|
||||
export interface ProcessPreparationHost {
|
||||
createTemporaryRoot(): Promise<string>;
|
||||
createHome(path: string): Promise<void>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
const systemProcessPreparationHost: ProcessPreparationHost = {
|
||||
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
|
||||
createHome: (path) => mkdir(path),
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
};
|
||||
|
||||
const processPolicyMessage = (
|
||||
reason: Exclude<ProcessPolicyDecision, { readonly allowed: true }>["reason"],
|
||||
): string => {
|
||||
if (reason === "process capture is disabled")
|
||||
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
|
||||
if (reason === "host network access is not approved by operator policy")
|
||||
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
|
||||
if (reason === "executable is outside approved roots")
|
||||
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
|
||||
if (reason === "working directory is outside approved roots")
|
||||
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (reason === "filesystem root is outside approved roots")
|
||||
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (
|
||||
reason === "scenario requests an environment variable not allowed by policy"
|
||||
)
|
||||
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
|
||||
return reason satisfies never;
|
||||
};
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
||||
import {
|
||||
buildCaptureResult,
|
||||
prepareProcessCapture,
|
||||
type ProcessPreparationHost,
|
||||
} from "./ProcessCaptureLifecycle.js";
|
||||
import { buildCaptureResult } from "./ProcessCaptureLifecycle.js";
|
||||
import { ProcessCheckpoints } from "./ProcessCheckpoints.js";
|
||||
import { normalizeProcessSamples } from "./ProcessNormalization.js";
|
||||
import { isInitializedPtyRoot, readLinuxChildren } from "./ProcessSampling.js";
|
||||
import { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
import {
|
||||
authorizeProcessScenario,
|
||||
compareProcessCaptures,
|
||||
parseProcessCapture,
|
||||
parseProcessScenario,
|
||||
@@ -127,32 +122,55 @@ it("preserves rendered observation order instead of timestamp sorting", () => {
|
||||
expect(result.rendered_frames).toEqual(renderedFrames);
|
||||
});
|
||||
|
||||
it("cleans the temporary root when capture home creation fails", async () => {
|
||||
const cleaned: string[] = [];
|
||||
const host: ProcessPreparationHost = {
|
||||
createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"),
|
||||
createHome: () => Promise.reject(new Error("mkdir failed")),
|
||||
cleanup: (path) => {
|
||||
cleaned.push(path);
|
||||
return Promise.resolve();
|
||||
it("marks redacted scripted input as an interaction unknown", () => {
|
||||
const capture = emptyCapture();
|
||||
const result = buildCaptureResult({
|
||||
frames: [],
|
||||
exit: { exitCode: 0, reason: "exited" },
|
||||
samples: [],
|
||||
replay: {
|
||||
httpUrl: "http://127.0.0.1",
|
||||
websocketUrl: "ws://127.0.0.1/ws",
|
||||
events: [],
|
||||
transitions: [],
|
||||
truncated: false,
|
||||
close: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
prepareProcessCapture(
|
||||
parseProcessScenario(base),
|
||||
before: { files: [], truncated: false },
|
||||
after: { files: [], truncated: false },
|
||||
truncated: false,
|
||||
scenario: parseProcessScenario({
|
||||
...base,
|
||||
events: [{ type: "input", at_ms: 0, data: "secret", sensitive: true }],
|
||||
}),
|
||||
rootPid: 1,
|
||||
samplingPartial: false,
|
||||
renderedFrames: [],
|
||||
interactions: [
|
||||
{
|
||||
status: "enabled",
|
||||
executableRoots: ["/bin"],
|
||||
workingRoots: ["/tmp"],
|
||||
allowedEnvironment: [],
|
||||
networkAccess: "external",
|
||||
sequence: 0,
|
||||
scheduled_at_ms: 0,
|
||||
dispatched_at_ms: 0,
|
||||
type: "input",
|
||||
data: "<redacted-input:6-bytes>",
|
||||
outcome: "dispatched",
|
||||
},
|
||||
undefined,
|
||||
host,
|
||||
),
|
||||
).rejects.toThrow("mkdir failed");
|
||||
expect(cleaned).toEqual(["/tmp/rea-process-fixture"]);
|
||||
],
|
||||
checkpoints: capture.filesystem_checkpoints,
|
||||
shimEvents: [],
|
||||
settlement: {
|
||||
state: capture.settlement.state,
|
||||
elapsed_ms: capture.settlement.elapsed_ms,
|
||||
},
|
||||
manifest: capture.manifest,
|
||||
eventJournal: [],
|
||||
});
|
||||
|
||||
expect(result.residual_unknowns).toContainEqual({
|
||||
scope: "interaction",
|
||||
reason:
|
||||
"Sensitive scripted input values are redacted from process capture Evidence.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes every sampled process identifier in command text", () => {
|
||||
@@ -275,7 +293,7 @@ it("cancels filesystem snapshots before traversing declared roots", async () =>
|
||||
snapshotRoots(
|
||||
parseProcessScenario({
|
||||
...base,
|
||||
filesystem_roots: ["/tmp"],
|
||||
filesystem_observation_paths: ["/tmp"],
|
||||
}),
|
||||
controller.signal,
|
||||
),
|
||||
@@ -293,12 +311,12 @@ it("parses bounded scenarios and rejects unordered events", () => {
|
||||
],
|
||||
}),
|
||||
).toThrow(/ordered/);
|
||||
expect(() =>
|
||||
expect(
|
||||
parseProcessScenario({
|
||||
...base,
|
||||
environment: { HOME: "/unsafe" },
|
||||
}),
|
||||
).toThrow(/reserved/);
|
||||
environment: { HOME: "/caller-selected-home" },
|
||||
}).environment.HOME,
|
||||
).toBe("/caller-selected-home");
|
||||
expect(() =>
|
||||
parseProcessScenario({
|
||||
...base,
|
||||
@@ -306,37 +324,3 @@ it("parses bounded scenarios and rejects unordered events", () => {
|
||||
}),
|
||||
).toThrow(/after the scenario timeout/);
|
||||
});
|
||||
|
||||
it("requires explicit operator approval for host network access", () => {
|
||||
expect(
|
||||
authorizeProcessScenario(parseProcessScenario(base), {
|
||||
status: "enabled",
|
||||
executableRoots: ["/bin"],
|
||||
workingRoots: ["/tmp"],
|
||||
allowedEnvironment: [],
|
||||
networkAccess: "none",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: "host network access is not approved by operator policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses paths and environment outside operator policy", () => {
|
||||
const scenario = parseProcessScenario({
|
||||
...base,
|
||||
environment: { TOKEN: "secret" },
|
||||
});
|
||||
expect(
|
||||
authorizeProcessScenario(scenario, {
|
||||
status: "enabled",
|
||||
executableRoots: ["/bin"],
|
||||
workingRoots: ["/tmp"],
|
||||
allowedEnvironment: [],
|
||||
networkAccess: "external",
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: "scenario requests an environment variable not allowed by policy",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { processCapturePermissionRequest } from "./ProcessCapturePermission.js";
|
||||
import { parseProcessScenario } from "../domain/processCapture.js";
|
||||
|
||||
describe("process capture permission request", () => {
|
||||
it("projects the complete adapter-neutral authority scope", () => {
|
||||
const scenario = parseProcessScenario({
|
||||
executable: "/bin/tool",
|
||||
arguments: ["--probe"],
|
||||
working_directory: "/workspace",
|
||||
environment: { FIXTURE_TOKEN: "secret" },
|
||||
inherit_environment: ["PATH"],
|
||||
filesystem_roots: ["/workspace/output"],
|
||||
network_access: "host",
|
||||
events: [],
|
||||
});
|
||||
|
||||
expect(processCapturePermissionRequest(scenario)).toEqual({
|
||||
capability: "process_capture",
|
||||
roots: ["/workspace", "/workspace/output"],
|
||||
executables: ["/bin/tool"],
|
||||
environment_names: ["FIXTURE_TOKEN", "PATH"],
|
||||
network: "external",
|
||||
mount: false,
|
||||
operation_identity: "capture_process_scenario:/bin/tool",
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user