Compare commits

...
58 changed files with 2311 additions and 186 deletions

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.3.0"
".": "2.4.0"
}
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [2.4.0](https://github.com/morluto/rea/compare/rea-agents-2.3.0...rea-agents-2.4.0) (2026-07-23)
### Features
* **artifacts:** support MSIX and AppX packages ([6e6cce1](https://github.com/morluto/rea/commit/6e6cce1bf3cbd9c48799bee9969c53d66c42162e))
* expand reactive capture and package analysis ([9da1794](https://github.com/morluto/rea/commit/9da179425e83df9f255597437320c1ab6ff5c5c2))
* **process:** add reactive capture scenarios ([69761e9](https://github.com/morluto/rea/commit/69761e95b63ec7617aa161f7a1b4e3c8847a6175))
### Bug Fixes
* **capabilities:** expose typed availability codes ([b906def](https://github.com/morluto/rea/commit/b906defbff28885a1d39f78da7c2b7170a7108c6))
## [2.3.0](https://github.com/morluto/rea/compare/rea-agents-2.2.0...rea-agents-2.3.0) (2026-07-23)
+2 -2
View File
@@ -376,7 +376,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA, and ASAR inventory; explicitly selected extraction into an absent owned tree |
| Artifact graph | 2 | deterministic directory, ZIP/APK/IPA/MSIX/AppX, and ASAR inventory; explicitly selected extraction into an absent owned tree |
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
| Browser observation | 8 | exact-origin CDP capture, bundle and source-map analysis, WebMCP discovery, session timelines, capture diff, and visual evidence |
| Electron analysis | 4 | passive root-confined observation, bounded static application mapping, and evidence-backed static/runtime reconciliation |
@@ -479,7 +479,7 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@2.3.0", "mcp"]
"args": ["-y", "rea-agents@2.4.0", "mcp"]
}
}
}
+1 -1
View File
@@ -182,7 +182,7 @@ rea uninstall --purge-data
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@2.3.0", "mcp"]
"args": ["-y", "rea-agents@2.4.0", "mcp"]
}
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@2.3.0", "mcp"]
"args": ["-y", "rea-agents@2.4.0", "mcp"]
}
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@2.3.0", "mcp"]
"args": ["-y", "rea-agents@2.4.0", "mcp"]
}
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ Setup 会检测 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Win
"mcpServers": {
"rea": {
"command": "npx",
"args": ["-y", "rea-agents@2.3.0", "mcp"]
"args": ["-y", "rea-agents@2.4.0", "mcp"]
}
}
}
+31
View File
@@ -115,6 +115,37 @@ 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.
## Reactive process scenarios
Set `reactive` to a Process Reactive Scenario v1 declaration when interaction
must follow observed output instead of guessed delays. The graph declares an
initial state, absolute scenario and state deadlines, explicitly prioritized
transitions, bounded uses and visits, trigger predicates, ordered actions, and
either a next state or `passed` outcome.
The admitted runtime slice matches decoded UTF-8 terminal literals and exact
`terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or
`shim` journal events. Process events are sampled observations, not an
event-complete lifecycle. Filesystem events identify named checkpoints; shim
events identify a command and route. Live predicates receive the same normalized
and redacted payload later persisted in Evidence. Predicates may compose with
bounded `all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at
scenario start, state entry, a prior checkpoint, or an exact event ID. Actions
can write PTY input, resize it, signal the root process, or capture a named
filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition`
event triggers and non-root signal selectors fail during preflight before the
target launches.
Reactive results are recorded in `reactive_run`, including the terminal outcome,
active state, matched event IDs, action evidence IDs, and deterministic
transition journal. Terminal controls also commit the last admitted observation
order, so later output cannot be moved ahead of a timeout, cancellation, or
target loss during validation. If the process exits before the graph finishes,
the outcome is `target_lost`; state/scenario deadlines remain distinct
`predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain
available for fixed schedules, but they are not silently translated into the
reactive graph.
## Checkpoints and deterministic shims
Checkpoint triggers may use an elapsed time, a terminal literal and occurrence
+3 -3
View File
@@ -2,7 +2,7 @@
"catalog_schema_version": 1,
"package": {
"name": "rea-agents",
"version": "2.3.0",
"version": "2.4.0",
"sdk": {
"server": "2.0.0-beta.4",
"client": "2.0.0-beta.4",
@@ -594,10 +594,10 @@
"mcp_resource_templates": 9
},
"digests": {
"tools_sha256": "501d405efbf8fc3537d13791f87ac81f3b97f3f328b62710ca0885de7213a62f",
"tools_sha256": "15544cf838c730784a082d0ff9e0feccbe35acd1675d7dfbe99d63296b72b3e3",
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
"resources_sha256": "94bebbab1a1b3cf47644c38d3de6469de568caa7bd7b842f529ead8ad320a912",
"combined_sha256": "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
"combined_sha256": "aa413204625f3aeb62732d0f732b9b8b24832eea4520214b1e6bd204e599bcd6"
}
}
}
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"manifest_id": "ecm_e04a25d07a540df3bec2fd5ba6d8a158e1f582dc94fc59225c7ba329cbabbbf6",
"manifest_id": "ecm_e2ed4ec5ff3bf3b3c25678a0c1e69971eb03d0fe6bbaebb1bf26a13b0151b17b",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -14,7 +14,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "a8ffb68f043fc9dfd0eb401caa6e548adce991d1e228b4539333cf90be17a755"
"sha256": "f330e6b677d1d68cdf369d852e61c390eb3369503fead732be560e2a1f0fbdc7"
}
],
"claims": [
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rea-agents",
"version": "2.3.0",
"version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rea-agents",
"version": "2.3.0",
"version": "2.4.0",
"license": "MIT",
"dependencies": {
"@babel/parser": "8.0.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "rea-agents",
"version": "2.3.0",
"version": "2.4.0",
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
"license": "MIT",
"repository": {
+3 -2
View File
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an
metadata:
version: "23"
tool_count: 98
catalog_digest: "71de8a7174176823fd82c2666130233b5a1b4a1613583270d268774775b410bc"
catalog_digest: "aa413204625f3aeb62732d0f732b9b8b24832eea4520214b1e6bd204e599bcd6"
---
# REA
@@ -22,7 +22,8 @@ Choose the first tool from the target the user supplied. Do not call
- ASAR or extracted JavaScript/Electron tree:
`analyze_javascript_application`.
- Archive, application package, ZIP/APK/IPA, or DMG: `inventory_artifact`.
- Archive, application package, ZIP/APK/IPA/MSIX/AppX, or DMG:
`inventory_artifact`.
- Managed PE/CLI assembly: `inspect_managed_artifact`.
- User-owned browser page already open: `list_browser_targets`.
- User-owned Electron runtime already open: `list_electron_targets`.
@@ -24,8 +24,8 @@ parser and must not become an implicit setup dependency.
## Packages and extraction
Call `inventory_artifact` before extraction for application bundles, archives,
ZIP/APK/IPA, ASAR, or DMG inputs. Continue deterministic occurrence pages from
the returned offset only when needed. Cite graph manifest IDs.
ZIP/APK/IPA/MSIX/AppX, ASAR, or DMG inputs. Continue deterministic occurrence
pages from the returned offset only when needed. Cite graph manifest IDs.
`extract_artifact` requires explicit approval, an absent absolute output root,
and selected occurrence IDs. Never extract every entry implicitly. Symlinks and
+10 -2
View File
@@ -127,8 +127,16 @@ interface CapabilityEffects {
}
type CapabilityAvailability =
| { readonly available: true; readonly reason: null }
| { readonly available: false; readonly reason: string };
| {
readonly available: true;
readonly reason: null;
readonly availabilityCode?: null;
}
| {
readonly available: false;
readonly reason: string;
readonly availabilityCode?: ProviderRejectionCode;
};
interface CapabilityLimits {
readonly maxResults: number | null;
+7 -1
View File
@@ -269,7 +269,13 @@ const createReader = async (
if ((await lstat(path)).isDirectory())
return new DirectoryArtifactReader(path);
if (format === "asar") return new AsarArtifactReader(path);
if (format === "ipa" || format === "apk" || format === "zip")
if (
format === "ipa" ||
format === "apk" ||
format === "msix" ||
format === "appx" ||
format === "zip"
)
return new ZipArtifactReader(path, format);
if (format === "mach-o") return new MachOSliceArtifactReader(path);
throw new ArtifactReaderFailure(
+5 -18
View File
@@ -1,5 +1,4 @@
import { createHash } from "node:crypto";
import { extname } from "node:path";
import canonicalize from "canonicalize";
@@ -13,6 +12,7 @@ import type {
ArtifactNode,
ArtifactOccurrence,
} from "../domain/artifactGraph.js";
import { zipPackageFormatForPath } from "../domain/zipPackageFormat.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
export interface MutableOccurrence {
@@ -130,7 +130,9 @@ export const createRootNode = (input: {
size: input.digest?.bytes ?? 0,
kind:
input.directory ||
["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(input.format)
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(
input.format,
)
? "container"
: classifyArtifactPath(input.path).kind,
format: input.format,
@@ -264,7 +266,7 @@ export const classifyArtifactPath = (
if (/\.(?:m?js|cjs)$/u.test(lower))
return { kind: "javascript", format: "javascript-bundle" };
if (lower.endsWith(".asar")) return { kind: "container", format: "asar" };
const archiveFormat = archiveFormatForExtension(extname(lower));
const archiveFormat = zipPackageFormatForPath(lower);
if (archiveFormat !== undefined)
return { kind: "container", format: archiveFormat };
if (/\.framework(?:\/|$)/u.test(lower))
@@ -351,19 +353,4 @@ const relationFor = (path: string): ArtifactEdge["relation"] => {
return "contains";
};
const archiveFormatForExtension = (
extension: string,
): "zip" | "ipa" | "apk" | undefined => {
switch (extension) {
case ".zip":
return "zip";
case ".ipa":
return "ipa";
case ".apk":
return "apk";
default:
return undefined;
}
};
const depth = (path: string): number => path.split("/").length;
@@ -2,12 +2,15 @@ import { open } from "node:fs/promises";
import { classifyArtifactPath } from "../ArtifactGraphConstruction.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import { zipPackageFormatForPath } from "../../domain/zipPackageFormat.js";
const classifyContainerExtension = (
path: string,
): ArtifactNode["format"] | undefined => {
const lower = path.toLowerCase();
for (const format of ["asar", "ipa", "apk", "zip", "dmg", "pkg"] as const)
const zipPackage = zipPackageFormatForPath(lower);
if (zipPackage !== undefined) return zipPackage;
for (const format of ["asar", "dmg", "pkg"] as const)
if (lower.endsWith(`.${format}`)) return format;
return undefined;
};
@@ -22,6 +22,8 @@ export const createReader = async (
case "zip":
case "ipa":
case "apk":
case "msix":
case "appx":
return new ZipArtifactReader(path, format);
case "asar":
return new AsarArtifactReader(path);
+1
View File
@@ -147,6 +147,7 @@ const capabilityStatus = (
operation: descriptor.operation,
available: descriptor.available,
reason: descriptor.reason,
availability_code: descriptor.availabilityCode ?? null,
input_contract_version: descriptor.inputContractVersion,
output_contract_version: descriptor.outputContractVersion,
pagination: descriptor.pagination,
+14 -8
View File
@@ -12,6 +12,7 @@ const statusSchema = z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
availability_code: z.string().nullable().optional(),
}),
),
});
@@ -29,6 +30,7 @@ type ToolKind = (typeof TOOL_CONTRACTS)[number]["kind"];
type ProviderDescriptor = {
readonly available: boolean;
readonly reason: string | null;
readonly availability_code?: string | null;
};
type AvailabilityPolicy = {
readonly processCaptureEnabled: boolean;
@@ -86,8 +88,15 @@ export const buildCapabilityInventory = (
policy: AvailabilityPolicy,
) => {
const status = statusSchema.parse(sessionStatus);
const descriptors = new Map(
status.capabilities.map((descriptor) => [descriptor.operation, descriptor]),
const descriptors = new Map<string, ProviderDescriptor>(
status.capabilities.map((descriptor) => [
descriptor.operation,
{
available: descriptor.available,
reason: descriptor.reason,
availability_code: descriptor.availability_code ?? null,
},
]),
);
return TOOL_CONTRACTS.map((contract) => {
const availability = availabilityFor({
@@ -237,7 +246,7 @@ const providerAvailability = ({
};
if (
!descriptor.available &&
descriptor.reason?.toLowerCase().includes("require macos") === true
descriptor.availability_code === "unsupported_host"
)
return {
reason: "unsupported_host",
@@ -254,10 +263,7 @@ const providerAvailability = ({
const composedAvailability = (
name: string,
descriptors: ReadonlyMap<
string,
{ readonly available: boolean; readonly reason: string | null }
>,
descriptors: ReadonlyMap<string, ProviderDescriptor>,
): {
readonly reason: ToolAvailabilityReason;
readonly remediation: string | null;
@@ -280,7 +286,7 @@ const composedAvailability = (
if (unavailable !== undefined)
return {
reason:
unavailable.reason?.toLowerCase().includes("require macos") === true
unavailable.availability_code === "unsupported_host"
? "unsupported_host"
: "provider_unavailable",
remediation:
@@ -0,0 +1,39 @@
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;
}
/** Build the explicitly admitted environment for a captured process. */
export const makeProcessCaptureEnvironment = (
options: ProcessCaptureEnvironmentOptions,
): Record<string, string> => {
const { scenario, home, replay, shimReplay, runId } = options;
const environment: Record<string, string> = {
...scenario.environment,
HOME: home,
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.
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
.filter((part) => part.length > 0)
.join(":");
return environment;
};
@@ -71,6 +71,7 @@ interface CaptureResultOptions {
};
readonly samples: readonly ProcessSample[];
readonly replay: LoopbackReplay;
readonly reactiveRun: ProcessCapture["reactive_run"];
readonly before: SnapshotResult;
readonly after: SnapshotResult;
readonly truncated: boolean;
@@ -116,6 +117,7 @@ export const buildCaptureResult = (
options.scenario,
),
replay_transitions: options.replay.transitions,
reactive_run: options.reactiveRun,
event_journal: options.eventJournal,
files_before: options.before.files,
files_after: options.after.files,
+6
View File
@@ -106,6 +106,12 @@ export class ProcessCheckpoints {
this.#scheduleCapture(name, this.#signal);
}
/** Read one committed checkpoint for synchronous journal projection. */
at(index: number): FilesystemCheckpoint | undefined {
const checkpoint = this.#captures[index];
return checkpoint === undefined ? undefined : structuredClone(checkpoint);
}
#scheduleCapture(name: string, signal: AbortSignal | undefined): void {
if (this.#captured.has(name)) return;
this.#captured.add(name);
+70 -56
View File
@@ -15,7 +15,6 @@ import {
ProcessCaptureError,
processCaptureCancelled,
} from "./ProcessCaptureError.js";
export { ProcessCaptureError } from "./ProcessCaptureError.js";
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
import { startProcessSampler } from "./ProcessSampling.js";
@@ -26,7 +25,10 @@ import {
startCommandShimReplay,
type CommandShimReplay,
} from "./CommandShimReplay.js";
import { normalizeProcessText } from "./ProcessNormalization.js";
import {
normalizeProcessShimEvent,
normalizeProcessText,
} from "./ProcessNormalization.js";
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
import {
awaitTerminalExit,
@@ -42,45 +44,17 @@ import { assertNotCancelled } from "./ProcessCaptureAuthority.js";
import {
createProcessCaptureJournal,
scheduleScenarioInteractions,
type ProcessCaptureJournal,
} from "./ProcessCaptureJournal.js";
import {
assertSupportedReactiveScenario,
projectProcessReactiveRun,
startProcessReactiveHarness,
type ProcessReactiveHarness,
} from "./ProcessReactiveHarness.js";
import { makeProcessCaptureEnvironment } from "./ProcessCaptureEnvironment.js";
export { probeProcessCaptureCapability } from "./ProcessCaptureCapability.js";
interface EnvironmentOptions {
readonly scenario: ProcessScenario;
readonly home: string;
readonly replay: LoopbackReplay;
readonly shimReplay: CommandShimReplay;
readonly runId: string;
}
const makeEnvironment = (
options: EnvironmentOptions,
): Record<string, string> => {
const { scenario, home, replay, shimReplay, runId } = options;
const environment: Record<string, string> = {
...scenario.environment,
HOME: home,
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.
environment.PATH = [shimReplay.binPath, environment.PATH ?? ""]
.filter((part) => part.length > 0)
.join(":");
return environment;
};
interface StartedCaptureRuntime {
readonly replay: LoopbackReplay;
readonly renderer: TerminalRenderer;
@@ -92,6 +66,7 @@ interface StartedCaptureRuntime {
readonly lastOutput: () => number;
readonly framesTruncated: () => boolean;
readonly stopSampler: () => Promise<{ readonly partial: boolean }>;
readonly reactive: ProcessReactiveHarness | undefined;
}
const cleanupFailedStartup = async (options: {
@@ -136,7 +111,7 @@ const createTerminalRenderer = (
recordEvent,
});
const startCaptureRuntime = async (options: {
interface StartCaptureRuntimeOptions {
readonly scenario: ProcessScenario;
readonly home: string;
readonly temporaryRoot: string;
@@ -148,14 +123,29 @@ const startCaptureRuntime = async (options: {
readonly timers: Set<NodeJS.Timeout>;
readonly dispatchedEventIndexes: Set<number>;
readonly recordEvent: RecordProcessCaptureEvent;
readonly journal: ProcessCaptureJournal;
readonly signal?: AbortSignal;
}): Promise<StartedCaptureRuntime> => {
}
const reactiveCapture = (
options: StartCaptureRuntimeOptions,
protocolEvents: () => LoopbackReplay["events"],
): Parameters<typeof startProcessReactiveHarness>[0]["capture"] => ({
...options,
processSamples: options.samples,
protocolEvents,
});
const startCaptureRuntime = async (
options: StartCaptureRuntimeOptions,
): Promise<StartedCaptureRuntime> => {
const { scenario } = options;
let replay: LoopbackReplay | undefined;
let renderer: TerminalRenderer | undefined;
let checkpoints: ProcessCheckpoints | undefined;
let shimReplay: CommandShimReplay | undefined;
let terminal: IPty | undefined;
let reactive: ProcessReactiveHarness | undefined;
try {
const { spawn } = await import("@lydell/node-pty");
replay = await startLoopbackReplay(scenario, options.recordEvent);
@@ -180,7 +170,7 @@ const startCaptureRuntime = async (options: {
);
terminal = spawn(scenario.executable, [...scenario.arguments], {
cwd: scenario.working_directory,
env: makeEnvironment({ ...options, replay, shimReplay }),
env: makeProcessCaptureEnvironment({ ...options, replay, shimReplay }),
cols: scenario.terminal.columns,
rows: scenario.terminal.rows,
name: "xterm-256color",
@@ -193,6 +183,15 @@ const startCaptureRuntime = async (options: {
renderer,
checkpoints,
});
reactive = startProcessReactiveHarness({
capture: reactiveCapture(options, () => replay?.events ?? []),
scenario,
terminal: () => terminal,
renderer,
checkpoints,
shimReplay,
started,
});
scheduleScenarioInteractions({
...options,
getTerminal: () => terminal,
@@ -218,8 +217,11 @@ const startCaptureRuntime = async (options: {
lastOutput: () => lastOutput,
framesTruncated,
stopSampler,
reactive,
};
} catch (cause: unknown) {
reactive?.unsubscribe();
await reactive?.coordinator.close();
return cleanupFailedStartup({
cause,
timers: options.timers,
@@ -239,23 +241,14 @@ const normalizedShimEvents = (
scenario: ProcessScenario,
temporaryRoot: string,
): ProcessCapture["shim_events"] =>
runtime.shimReplay.events.map((event) => ({
...event,
arguments: event.arguments.map((argument) =>
normalizeProcessText(
argument,
scenario,
temporaryRoot,
runtime.terminal.pid,
),
),
working_directory: normalizeProcessText(
event.working_directory,
runtime.shimReplay.events.map((event) =>
normalizeProcessShimEvent(
event,
scenario,
temporaryRoot,
runtime.terminal.pid,
),
}));
);
const finishProcessRun = async (options: {
readonly runtime: StartedCaptureRuntime | undefined;
@@ -267,6 +260,7 @@ const finishProcessRun = async (options: {
readonly capture: ProcessCapture | undefined;
readonly executionFailure: unknown;
}): Promise<ProcessCapture> => {
options.runtime?.reactive?.unsubscribe();
await options.stopSampler();
const cleanupFailure = await releaseProcessResources({
timers: options.timers,
@@ -285,10 +279,16 @@ const finishProcessRun = async (options: {
options.samples,
),
});
if (cleanupFailure !== undefined)
await options.runtime?.reactive?.coordinator.submit({
kind: "cleanup_failed",
});
await options.runtime?.reactive?.coordinator.close();
let { capture, executionFailure } = options;
if (capture !== undefined) {
capture = {
...capture,
reactive_run: projectProcessReactiveRun(options.runtime?.reactive),
settlement: {
...capture.settlement,
cleanup_outcome:
@@ -326,7 +326,17 @@ const completeCapture = async (options: {
const { runtime, scenario } = options;
runtime.checkpoints.trigger("root_exit");
const { reason } = options.exit;
if (reason === "cancelled") throw processCaptureCancelled();
if (reason === "cancelled") {
if (runtime.reactive !== undefined) {
await runtime.reactive.coordinator.submit({ kind: "cancelled" });
runtime.reactive.unsubscribe();
}
throw processCaptureCancelled();
}
if (runtime.reactive !== undefined) {
await runtime.reactive.coordinator.submit({ kind: "target_lost" });
runtime.reactive.unsubscribe();
}
const settlement = await observeSettlement(
options.runId,
selectCapturedProcessGroupIds(runtime.terminal.pid, options.samples),
@@ -359,6 +369,7 @@ const completeCapture = async (options: {
exit: { ...options.exit, reason },
samples: options.samples,
replay: runtime.replay,
reactiveRun: projectProcessReactiveRun(runtime.reactive),
before: options.before,
after,
truncated,
@@ -381,6 +392,7 @@ const runProcessScenario = async (
policy: ProcessExecutionPolicy,
signal?: AbortSignal,
): Promise<ProcessCapture> => {
assertSupportedReactiveScenario(scenario);
const { temporaryRoot, runId, home, before } = await prepareProcessCapture(
scenario,
policy,
@@ -388,7 +400,8 @@ const runProcessScenario = async (
);
const frames: TerminalFrame[] = [];
const samples: ProcessSample[] = [];
const { entries: eventJournal, recordEvent } = createProcessCaptureJournal();
const journal = createProcessCaptureJournal();
const { entries: eventJournal, recordEvent } = journal;
let runtime: StartedCaptureRuntime | undefined;
const timers = new Set<NodeJS.Timeout>();
let capture: ProcessCapture | undefined;
@@ -410,6 +423,7 @@ const runProcessScenario = async (
timers,
dispatchedEventIndexes,
recordEvent,
journal,
...(signal === undefined ? {} : { signal }),
});
stopSampler = runtime.stopSampler;
+20
View File
@@ -2,6 +2,7 @@ import type {
ProcessCapture,
ProcessSample,
ProcessScenario,
ShimEvent,
} from "../domain/processCapture.js";
/** Bucket one elapsed process-capture timestamp under scenario normalization. */
@@ -91,6 +92,25 @@ export const normalizeProcessSamples = (
}));
};
/** Normalize one shim observation identically for live matching and capture. */
export const normalizeProcessShimEvent = (
event: ShimEvent,
scenario: ProcessScenario,
temporaryRoot: string,
rootPid: number,
): ShimEvent => ({
...event,
arguments: event.arguments.map((argument) =>
normalizeProcessText(argument, scenario, temporaryRoot, rootPid),
),
working_directory: normalizeProcessText(
event.working_directory,
scenario,
temporaryRoot,
rootPid,
),
});
const normalizePidTokens = (
value: string,
identifiers: readonly number[],
+25 -5
View File
@@ -51,7 +51,7 @@ export class ProcessReactiveCoordinator {
readonly #now: () => number;
readonly #scenarioDeadlineAt: number;
readonly #onDecision:
| ((decision: ProcessReactiveDecision) => void)
| ((decision: ProcessReactiveDecision, input: ProcessReactiveInput) => void)
| undefined;
#snapshot: ProcessReactiveSnapshot;
readonly #pendingInputs: ProcessReactiveInput[] = [];
@@ -70,7 +70,10 @@ export class ProcessReactiveCoordinator {
readonly executor: ProcessReactiveExecutor;
readonly timerHost?: ProcessReactiveTimerHost;
readonly now?: () => number;
readonly onDecision?: (decision: ProcessReactiveDecision) => void;
readonly onDecision?: (
decision: ProcessReactiveDecision,
input: ProcessReactiveInput,
) => void;
}) {
this.#scenario = options.scenario;
this.#executor = options.executor;
@@ -206,7 +209,7 @@ export class ProcessReactiveCoordinator {
);
}
this.#snapshot = decision.snapshot;
this.#onDecision?.(decision);
this.#onDecision?.(decision, input);
if (this.#snapshot.status === "finished") this.#clearTimers();
else if (
this.#accepting &&
@@ -216,18 +219,35 @@ export class ProcessReactiveCoordinator {
)
this.#scheduleStateDeadline();
for (const result of effectResults)
if (result.status === "succeeded")
if (
result.status === "succeeded" &&
!this.#hasObservation(result.observation.event_id)
)
this.#enqueueOwned({
kind: "observation",
observation: result.observation,
});
}
#hasObservation(eventId: string): boolean {
return (
this.#snapshot.observations.some(
(observation) => observation.event_id === eventId,
) ||
this.#pendingInputs.some(
(input) =>
input.kind === "observation" &&
input.observation.event_id === eventId,
)
);
}
#interruptsEffects(input: ProcessReactiveInput): boolean {
if (
input.kind === "cancelled" ||
input.kind === "cleanup_failed" ||
input.kind === "scenario_deadline"
input.kind === "scenario_deadline" ||
input.kind === "target_lost"
)
return true;
return (
+9 -3
View File
@@ -184,9 +184,15 @@ const triggerSources = (
): readonly string[] => {
if (trigger.kind === "terminal_text") return [];
if (trigger.kind === "event")
return ["terminal_raw", "interaction", "filesystem"].includes(
trigger.source,
)
return [
"terminal_raw",
"interaction",
"process",
"filesystem",
"http",
"websocket",
"shim",
].includes(trigger.source)
? []
: [trigger.source];
return trigger.kind === "repeat"
+159
View File
@@ -0,0 +1,159 @@
import type { IPty } from "@lydell/node-pty";
import type {
InteractionEvent,
ProcessCapture,
ProcessReactiveControlRecord,
ProcessSample,
ProcessScenario,
ProtocolEvent,
TerminalFrame,
} from "../domain/processCapture.js";
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
import { ProcessCaptureError } from "./ProcessCaptureError.js";
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
import type { ProcessCheckpoints } from "./ProcessCheckpoints.js";
import type { CommandShimReplay } from "./CommandShimReplay.js";
import { ProcessReactiveCoordinator } from "./ProcessReactiveCoordinator.js";
import {
executeProcessReactiveEffects,
unsupportedProcessReactiveFeatures,
} from "./ProcessReactiveEffects.js";
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
import {
normalizeProcessSamples,
normalizeProcessShimEvent,
redactProtocolEvents,
} from "./ProcessNormalization.js";
import type { TerminalRenderer } from "./TerminalRenderer.js";
/** Coordinator and subscription owned by one live process capture. */
export interface ProcessReactiveHarness {
readonly coordinator: ProcessReactiveCoordinator;
readonly controls: readonly ProcessReactiveControlRecord[];
readonly unsubscribe: () => void;
}
const controlKind = (
input: ProcessReactiveInput,
): ProcessReactiveControlRecord["kind"] | undefined =>
input.kind === "observation" ? undefined : input.kind;
/** Reject reactive declarations the current process adapter cannot execute. */
export const assertSupportedReactiveScenario = (
scenario: ProcessScenario,
): void => {
if (scenario.reactive === null) return;
const unsupported = unsupportedProcessReactiveFeatures(scenario.reactive);
if (unsupported.length === 0) return;
throw new ProcessCaptureError(
`reactive scenario requests unsupported features: ${unsupported.join(", ")}`,
);
};
/**
* Attach the serialized reactive runtime once the target PID is available.
*
* Subscription replays journal entries admitted during launcher startup before
* observing new records, so PID-dependent normalization cannot drop them.
*/
export const startProcessReactiveHarness = (options: {
readonly scenario: ProcessScenario;
readonly terminal: () => IPty | undefined;
readonly renderer: TerminalRenderer;
readonly checkpoints: ProcessCheckpoints;
readonly shimReplay: CommandShimReplay;
readonly started: number;
readonly capture: {
readonly interactions: InteractionEvent[];
readonly journal: ProcessCaptureJournal;
readonly frames: readonly TerminalFrame[];
readonly processSamples: readonly ProcessSample[];
readonly protocolEvents: () => readonly ProtocolEvent[];
readonly temporaryRoot: string;
};
}): ProcessReactiveHarness | undefined => {
if (options.scenario.reactive === null) return undefined;
const controls: ProcessReactiveControlRecord[] = [];
const coordinator = new ProcessReactiveCoordinator({
scenario: options.scenario.reactive,
executor: {
execute: (actions, signal) =>
executeProcessReactiveEffects(
{
terminal: options.terminal,
renderer: options.renderer,
checkpoints: options.checkpoints,
interactions: options.capture.interactions,
journal: options.capture.journal,
startedAtMs: options.started,
now: Date.now,
},
actions,
signal,
),
},
onDecision: (decision, input) => {
const kind = controlKind(input);
if (kind === undefined || decision.kind !== "finished") return;
controls.push({
sequence: controls.length,
kind,
after_capture_order:
decision.snapshot.observations.at(-1)?.capture_order ?? -1,
});
},
});
return {
coordinator,
controls,
unsubscribe: subscribeProcessReactiveObservations({
journal: options.capture.journal,
frames: options.capture.frames,
interactions: options.capture.interactions,
checkpointAt: (index) => options.checkpoints.at(index),
processSampleAt: (index) => {
const rootPid = options.terminal()?.pid;
return rootPid === undefined
? undefined
: normalizeProcessSamples(
options.capture.processSamples,
options.scenario,
rootPid,
)[index];
},
protocolEventAt: (index) =>
redactProtocolEvents(
options.capture.protocolEvents().slice(index, index + 1),
options.scenario,
)[0],
shimEventAt: (index) => {
const event = options.shimReplay.events[index];
const rootPid = options.terminal()?.pid;
return event === undefined || rootPid === undefined
? undefined
: normalizeProcessShimEvent(
event,
options.scenario,
options.capture.temporaryRoot,
rootPid,
);
},
sink: coordinator,
}),
};
};
/** Project internal reducer state into the stable Process Capture shape. */
export const projectProcessReactiveRun = (
harness: ProcessReactiveHarness | undefined,
): ProcessCapture["reactive_run"] =>
harness === undefined
? null
: {
status: harness.coordinator.snapshot.status,
outcome: harness.coordinator.snapshot.outcome,
active_state: harness.coordinator.snapshot.active_state,
transitions: harness.coordinator.snapshot.transitions,
controls: harness.controls,
};
+145 -16
View File
@@ -1,8 +1,17 @@
import type {
InteractionEvent,
FilesystemCheckpoint,
ProcessCaptureEventJournalEntry,
ProcessSample,
ProtocolEvent,
ShimEvent,
TerminalFrame,
} from "../domain/processCapture.js";
import { createProcessObservation } from "../domain/processObservation.js";
import {
createProcessObservation,
processObservationSubject,
type ProcessObservation,
} from "../domain/processObservation.js";
import type { ProcessReactiveInput } from "../domain/processReactiveRuntime.js";
import type { ProcessCaptureJournal } from "./ProcessCaptureJournal.js";
@@ -11,25 +20,145 @@ export interface ProcessReactiveObservationSink {
enqueue(input: ProcessReactiveInput): void;
}
/** Subscribe a reactive coordinator to normalized raw PTY journal records. */
export const subscribeProcessReactiveTerminalObservations = (options: {
interface ProcessReactiveObservationOptions {
readonly journal: ProcessCaptureJournal;
readonly frames: readonly TerminalFrame[];
readonly interactions: readonly InteractionEvent[];
readonly checkpointAt: (index: number) => FilesystemCheckpoint | undefined;
readonly processSampleAt: (index: number) => ProcessSample | undefined;
readonly protocolEventAt: (index: number) => ProtocolEvent | undefined;
readonly shimEventAt: (index: number) => ShimEvent | undefined;
readonly sink: ProcessReactiveObservationSink;
}): (() => void) =>
options.journal.subscribe((entry: ProcessCaptureEventJournalEntry) => {
if (entry.collection !== "frames") return;
const frame = options.frames[entry.index];
if (frame === undefined) return;
options.sink.enqueue({
kind: "observation",
observation: createProcessObservation({
}
const terminalObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.frames[entry.index];
return value === undefined
? undefined
: createProcessObservation({
source: "terminal_raw",
source_sequence: frame.sequence,
captured_at_ms: frame.at_ms,
source_sequence: value.sequence,
captured_at_ms: value.at_ms,
subject_id: null,
location: entry,
payload: frame,
}),
});
payload: value,
});
};
const interactionObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.interactions[entry.index];
return value === undefined
? undefined
: createProcessObservation({
source: "interaction",
source_sequence: value.sequence,
captured_at_ms: value.dispatched_at_ms,
subject_id: null,
location: entry,
payload: value,
});
};
const filesystemObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.checkpointAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: "filesystem",
source_sequence: entry.index,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject("filesystem", value),
location: entry,
payload: value,
});
};
const processObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.processSampleAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: "process",
source_sequence: entry.index,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject("process", value),
location: entry,
payload: value,
});
};
const protocolObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const value = options.protocolEventAt(entry.index);
return value === undefined
? undefined
: createProcessObservation({
source: value.protocol,
source_sequence: value.sequence,
captured_at_ms: value.at_ms,
subject_id: processObservationSubject(value.protocol, value),
location: entry,
payload: value,
});
};
const shimObservation = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
const normalized = options.shimEventAt(entry.index);
if (normalized === undefined) return undefined;
return createProcessObservation({
source: "shim",
source_sequence: normalized.sequence,
captured_at_ms: normalized.at_ms,
subject_id: processObservationSubject("shim", normalized),
location: entry,
payload: normalized,
});
};
const observationFor = (
options: ProcessReactiveObservationOptions,
entry: ProcessCaptureEventJournalEntry,
): ProcessObservation | undefined => {
if (entry.collection === "frames") return terminalObservation(options, entry);
if (entry.collection === "interaction_events")
return interactionObservation(options, entry);
if (entry.collection === "filesystem_checkpoints")
return filesystemObservation(options, entry);
if (entry.collection === "process_samples")
return processObservation(options, entry);
if (entry.collection === "protocol_events")
return protocolObservation(options, entry);
if (entry.collection === "shim_events")
return shimObservation(options, entry);
return undefined;
};
/** Subscribe the reactive coordinator to admitted normalized journal records. */
export const subscribeProcessReactiveObservations = (
options: ProcessReactiveObservationOptions,
): (() => void) => {
const enqueue = (entry: ProcessCaptureEventJournalEntry): void => {
const observation = observationFor(options, entry);
if (observation !== undefined)
options.sink.enqueue({ kind: "observation", observation });
};
for (const entry of options.journal.entries) enqueue(entry);
return options.journal.subscribe(enqueue);
};
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Readable } from "node:stream";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
export interface ArtifactEntry {
@@ -23,7 +24,7 @@ export interface ArtifactEntry {
/** Read-only adapter over one directory, archive, or virtual container. */
export interface ArtifactReader {
readonly format: "directory" | "zip" | "ipa" | "apk" | "asar" | "file";
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
provenance(): readonly ArtifactCommand[];
+3 -2
View File
@@ -9,6 +9,7 @@ import {
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
class NodeFileReader extends Reader<string> {
#handle: FileHandle | undefined;
@@ -41,12 +42,12 @@ class NodeFileReader extends Reader<string> {
/** Lazy Zip64-capable reader with CRC and overlap verification on every read. */
export class ZipArtifactReader implements ArtifactReader {
readonly format: "zip" | "ipa" | "apk";
readonly format: ZipPackageFormat;
readonly #source: NodeFileReader;
readonly #reader: ZipReader<string>;
readonly #entries = new Map<string, Entry>();
constructor(path: string, format: "zip" | "ipa" | "apk") {
constructor(path: string, format: ZipPackageFormat) {
this.format = format;
this.#source = new NodeFileReader(path);
this.#reader = new ZipReader(this.#source, {
@@ -45,6 +45,8 @@ export const targetFormatSchema = z.enum([
"zip",
"ipa",
"apk",
"msix",
"appx",
"asar",
"dmg",
"pkg",
@@ -64,6 +66,7 @@ const providerCapability = z.object({
operation: z.string(),
available: z.boolean(),
reason: z.string().nullable(),
availability_code: z.enum(PROVIDER_REJECTION_CODES).nullable(),
input_contract_version: z.number().int().min(1),
output_contract_version: z.number().int().min(1),
pagination: z.enum(["none", "offset", "cursor"]),
+2
View File
@@ -37,6 +37,8 @@ const formatSchema = z.enum([
"zip",
"ipa",
"apk",
"msix",
"appx",
"asar",
"dmg",
"pkg",
+2
View File
@@ -52,6 +52,8 @@ const artifactFormatSchema = z.enum([
"analysis-database",
"ipa",
"apk",
"msix",
"appx",
"plist",
"entitlements",
"directory",
+5 -6
View File
@@ -14,6 +14,7 @@ import { promisify } from "node:util";
import { BinaryTargetError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
import { zipPackageFormatForPath } from "./zipPackageFormat.js";
const execFileAsync = promisify(execFile);
@@ -36,6 +37,8 @@ export interface BinaryTarget {
| "zip"
| "ipa"
| "apk"
| "msix"
| "appx"
| "asar"
| "dmg"
| "pkg"
@@ -131,11 +134,7 @@ const detectArtifactFormat = async (
[0x03, 0x05, 0x07].includes(magic[2] ?? -1) &&
[0x04, 0x06, 0x08].includes(magic[3] ?? -1)
) {
return lower.endsWith(".ipa")
? "ipa"
: lower.endsWith(".apk")
? "apk"
: "zip";
return zipPackageFormatForPath(lower) ?? "zip";
}
const named = namedArtifactFormat(lower);
if (named !== undefined) return named;
@@ -167,7 +166,7 @@ const namedArtifactFormat = (
};
const isArchiveFormat = (format: BinaryTarget["format"]): boolean =>
["zip", "ipa", "apk", "asar", "dmg", "pkg"].includes(format);
["zip", "ipa", "apk", "msix", "appx", "asar", "dmg", "pkg"].includes(format);
const sha256Handle = async (handle: FileHandle): Promise<string> => {
const hash = createHash("sha256");
+2
View File
@@ -29,6 +29,8 @@ const subjectSchema = z.object({
"zip",
"ipa",
"apk",
"msix",
"appx",
"asar",
"dmg",
"pkg",
+93
View File
@@ -5,6 +5,11 @@ import {
LEGACY_PROCESS_CAPTURE_MESSAGE,
normalizationSchema,
} from "./processScenario.js";
import type {
ProcessReactiveOutcome,
ProcessReactiveTransitionRecord,
} from "./processReactiveRuntime.js";
import { PROCESS_REACTIVE_LIMITS } from "./processReactiveScenario.js";
import type { ReplayTransitionRecord } from "./replayMachineRuntime.js";
export * from "./processScenario.js";
@@ -133,6 +138,18 @@ export interface ProcessCaptureEventJournalEntry {
readonly index: number;
}
/** Ordered control input that terminated or superseded a reactive run. */
export interface ProcessReactiveControlRecord {
readonly sequence: number;
readonly kind:
| "state_deadline"
| "scenario_deadline"
| "target_lost"
| "cancelled"
| "cleanup_failed";
readonly after_capture_order: number;
}
/** Shared observation callback used by every process-capture producer. */
export type RecordProcessCaptureEvent = (
collection: ProcessCaptureEventCollection,
@@ -185,6 +202,13 @@ export interface ProcessCapture {
readonly shim_events: readonly ShimEvent[];
readonly protocol_events: readonly ProtocolEvent[];
readonly replay_transitions: readonly ReplayTransitionRecord[];
readonly reactive_run: {
readonly status: "running" | "finished";
readonly outcome: ProcessReactiveOutcome | null;
readonly active_state: string;
readonly transitions: readonly ProcessReactiveTransitionRecord[];
readonly controls: readonly ProcessReactiveControlRecord[];
} | null;
/**
* Global observation order across independently recorded collections.
*
@@ -363,6 +387,75 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
}),
)
.default([]),
reactive_run: z
.object({
status: z.enum(["running", "finished"]),
outcome: z
.enum([
"passed",
"predicate_timeout",
"scenario_deadline",
"ambiguous_match",
"action_rejected",
"target_lost",
"capture_incomplete",
"cancelled",
"cleanup_failed",
])
.nullable(),
active_state: z.string().min(1).max(64),
controls: z
.array(
z.object({
sequence: z.number().int().nonnegative(),
kind: z.enum([
"state_deadline",
"scenario_deadline",
"target_lost",
"cancelled",
"cleanup_failed",
]),
after_capture_order: z.number().int().min(-1),
}),
)
.default([]),
transitions: z
.array(
z.object({
sequence: z.number().int().nonnegative(),
transition_id: z.string().min(1).max(64),
state_before: z.string().min(1).max(64),
state_after: z.string().min(1).max(64).nullable(),
outcome: z
.enum([
"passed",
"predicate_timeout",
"scenario_deadline",
"ambiguous_match",
"action_rejected",
"target_lost",
"capture_incomplete",
"cancelled",
"cleanup_failed",
])
.nullable(),
trigger_event_ids: z
.array(z.string().min(1))
.max(PROCESS_REACTIVE_LIMITS.predicates),
action_event_ids: z
.array(z.string().min(1))
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
action_types: z
.array(
z.enum(["send_input", "resize", "send_signal", "checkpoint"]),
)
.max(PROCESS_REACTIVE_LIMITS.actionsPerTransition),
}),
)
.max(PROCESS_REACTIVE_LIMITS.transitions),
})
.nullable()
.default(null),
event_journal: z
.array(
z.object({
@@ -0,0 +1,273 @@
import canonicalize from "canonicalize";
import type { ProcessCapture } from "./processCapture.js";
import {
projectProcessObservation,
type ProcessObservation,
} from "./processObservation.js";
import {
commitProcessReactiveProposal,
createProcessReactiveSnapshot,
reduceProcessReactiveScenario,
type ProcessReactiveDecision,
type ProcessReactiveInput,
type ProcessReactiveSnapshot,
} from "./processReactiveRuntime.js";
import {
processReactiveScenarioSchema,
type ProcessReactiveScenario,
} from "./processReactiveScenario.js";
type RequireInvariant = (
condition: boolean,
path: string,
message: string,
) => void;
type ReactiveRun = NonNullable<ProcessCapture["reactive_run"]>;
type ReactiveTransition = ReactiveRun["transitions"][number];
const admittedCollection = (
collection: NonNullable<
ProcessCapture["event_journal"]
>[number]["collection"],
): boolean =>
[
"frames",
"interaction_events",
"process_samples",
"filesystem_checkpoints",
"shim_events",
"protocol_events",
].includes(collection);
const reactiveObservations = (
capture: ProcessCapture,
): readonly ProcessObservation[] =>
(capture.event_journal ?? []).flatMap((location) => {
if (!admittedCollection(location.collection)) return [];
const observation = projectProcessObservation(capture, location);
return observation === null ? [] : [observation];
});
const validateTransitionDeclaration = (
transition: ReactiveTransition,
next: ReactiveTransition | undefined,
context: {
readonly index: number;
readonly scenario: ProcessReactiveScenario;
readonly require: RequireInvariant;
},
): void => {
const { index, scenario, require } = context;
const path = `reactive_run.transitions[${String(index)}]`;
const declared = scenario.states
.find(({ id }) => id === transition.state_before)
?.on.find(({ id }) => id === transition.transition_id);
require(declared !==
undefined, path, "reactive transition is not declared by its recorded source state");
if (declared !== undefined) {
const stateAfter =
declared.target.kind === "goto" ? declared.target.state : null;
const outcome =
declared.target.kind === "finish" ? declared.target.outcome : null;
require(transition.state_after === stateAfter &&
transition.outcome ===
outcome, path, "reactive transition target or outcome differs from the committed graph");
require(canonicalize(transition.action_types) ===
canonicalize(
declared.actions.map(({ type }) => type),
), `${path}.action_types`, "reactive transition actions differ from the committed graph");
}
require(transition.sequence ===
index, `${path}.sequence`, "reactive transition sequence must be contiguous from zero");
if (next !== undefined)
require(transition.state_after ===
next.state_before, `reactive_run.transitions[${String(index + 1)}].state_before`, "reactive transition states must be contiguous");
};
const validateTransitionDeclarations = (
run: ReactiveRun,
scenario: ProcessReactiveScenario,
require: RequireInvariant,
): void => {
require(run.transitions[0]?.state_before === scenario.initial_state ||
run.transitions.length ===
0, "reactive_run.transitions[0].state_before", "reactive transition journal must start at the declared initial state");
for (const [index, transition] of run.transitions.entries())
validateTransitionDeclaration(transition, run.transitions[index + 1], {
index,
scenario,
require,
});
};
const commitRecordedEffects = (options: {
readonly scenario: ProcessReactiveScenario;
readonly proposal: Extract<
ProcessReactiveDecision,
{ readonly kind: "proposal" }
>;
readonly expected: ReactiveTransition | undefined;
readonly observations: ReadonlyMap<string, ProcessObservation>;
readonly recordedFailure: "action_rejected" | "target_lost" | undefined;
}): ProcessReactiveDecision =>
commitProcessReactiveProposal(
options.scenario,
options.proposal,
options.recordedFailure === undefined
? options.proposal.effects.map((_, index) => {
const eventId = options.expected?.action_event_ids[index];
const observation =
eventId === undefined
? undefined
: options.observations.get(eventId);
return observation === undefined
? { status: "rejected" as const }
: { status: "succeeded" as const, observation };
})
: [
options.recordedFailure === "target_lost"
? { status: "target_lost" as const }
: { status: "rejected" as const },
],
{ committedSensitiveInputs: true },
);
const replayTransitions = (
scenario: ProcessReactiveScenario,
run: ReactiveRun,
observations: readonly ProcessObservation[],
require: RequireInvariant,
): ProcessReactiveSnapshot => {
const observationsById = new Map(
observations.map((observation) => [observation.event_id, observation]),
);
let snapshot = createProcessReactiveSnapshot(scenario);
let transitionIndex = 0;
const advance = (input: ProcessReactiveInput): void => {
let decision = reduceProcessReactiveScenario(scenario, snapshot, input);
if (decision.kind === "proposal") {
const proposal = decision;
decision = commitRecordedEffects({
scenario,
proposal: decision,
expected: run.transitions[transitionIndex],
observations: observationsById,
recordedFailure:
run.transitions[transitionIndex] === undefined &&
decision.effects.length > 0 &&
(run.outcome === "action_rejected" || run.outcome === "target_lost")
? run.outcome
: undefined,
});
require(decision.kind !== "finished" ||
decision.outcome !== "action_rejected" ||
run.outcome ===
"action_rejected", `reactive_run.transitions[${String(transitionIndex)}].action_event_ids`, `recorded action observations do not match proposed effects: ${canonicalize(
{
effects: proposal.effects,
observations: run.transitions[transitionIndex]?.action_event_ids.map(
(eventId) => observationsById.get(eventId),
),
},
)}`);
}
if (decision.kind === "transition") {
const expected = run.transitions[transitionIndex];
require(expected !== undefined &&
canonicalize(decision.record) ===
canonicalize(
expected,
), `reactive_run.transitions[${String(transitionIndex)}]`, "reactive transition differs from deterministic journal replay");
transitionIndex += 1;
}
snapshot = decision.snapshot;
};
const advanceControl = (
control: ReactiveRun["controls"][number],
index: number,
): void => {
require(snapshot.status !== "finished" ||
control.kind ===
"cleanup_failed", `reactive_run.controls[${String(index)}]`, "reactive control cannot follow a finished run");
advance(
control.kind === "state_deadline"
? {
kind: control.kind,
state_id: snapshot.active_state,
state_entry_capture_order: snapshot.state_entry_capture_order,
}
: { kind: control.kind },
);
};
let controlIndex = 0;
for (const observation of observations) {
while (
(run.controls[controlIndex]?.after_capture_order ??
Number.MAX_SAFE_INTEGER) < observation.capture_order
) {
const control = run.controls[controlIndex]!;
advanceControl(control, controlIndex);
controlIndex += 1;
}
advance({ kind: "observation", observation });
}
for (; controlIndex < run.controls.length; controlIndex += 1) {
const control = run.controls[controlIndex]!;
advanceControl(control, controlIndex);
}
require(transitionIndex ===
run.transitions
.length, "reactive_run.transitions", `reactive transition journal is not reproduced by deterministic replay (replayed ${String(transitionIndex)} of ${String(run.transitions.length)}; outcome ${snapshot.outcome})`);
return snapshot;
};
const validateControls = (
run: ReactiveRun,
observations: readonly ProcessObservation[],
require: RequireInvariant,
): void => {
const admittedOrders = new Set([
-1,
...observations.map(({ capture_order }) => capture_order),
]);
for (const [index, control] of run.controls.entries()) {
require(control.sequence ===
index, `reactive_run.controls[${String(index)}].sequence`, "reactive control sequence must be contiguous");
require(admittedOrders.has(
control.after_capture_order,
), `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoff must identify the last admitted observation");
require(index === 0 ||
control.after_capture_order >=
run.controls[index - 1]!
.after_capture_order, `reactive_run.controls[${String(index)}].after_capture_order`, "reactive control cutoffs must be ordered");
}
};
/** Validate one capture's reactive journal by replaying the production reducer. */
export const validateProcessCaptureReactiveRun = (
capture: ProcessCapture,
require: RequireInvariant,
): void => {
const declared = capture.manifest.scenario["reactive"];
const run = capture.reactive_run;
const declaredReactive = declared !== undefined && declared !== null;
require(declaredReactive ===
(run !==
null), "reactive_run", "reactive result presence must match the committed scenario");
if (run === null) return;
const parsed = processReactiveScenarioSchema.safeParse(declared);
require(parsed.success, "manifest.scenario.reactive", "committed reactive scenario does not satisfy its declared schema");
if (!parsed.success) return;
require(run.status === "finished" &&
run.outcome !==
null, "reactive_run.outcome", "completed capture requires a finished reactive outcome");
validateTransitionDeclarations(run, parsed.data, require);
const observations = reactiveObservations(capture);
validateControls(run, observations, require);
const snapshot = replayTransitions(parsed.data, run, observations, require);
require(snapshot.outcome ===
run.outcome, "reactive_run.outcome", "reactive outcome differs from deterministic journal replay");
require(snapshot.active_state ===
run.active_state, "reactive_run.active_state", "reactive active state differs from deterministic journal replay");
};
+2
View File
@@ -1,4 +1,5 @@
import type { ProcessCapture } from "./processCapture.js";
import { validateProcessCaptureReactiveRun } from "./processCaptureReactiveValidation.js";
import { replayMachineSchema, type ReplayMachine } from "./replayMachine.js";
import { digestProcessCommitment } from "./processScenario.js";
@@ -314,5 +315,6 @@ export const validateProcessCapture = (
validateLifecycle(capture, require);
validateShimEvents(capture, require);
validateReplayEvents(capture, require);
validateProcessCaptureReactiveRun(capture, require);
return issues;
};
+20 -7
View File
@@ -109,16 +109,29 @@ const atMilliseconds = (value: unknown): number | null => {
return null;
};
const subjectFor = (
/** Derive the stable capture-local identity for one normalized observation. */
export const processObservationSubject = (
source: ProcessObservationSource,
value: JsonValue,
value: unknown,
): string | null => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
if (source === "process" && typeof value["pid"] === "number")
return `process:${String(value["pid"])}`;
if (source === "filesystem" && typeof value["name"] === "string")
return `checkpoint:${value["name"]}`;
if (source === "process" && "pid" in value && typeof value.pid === "number")
return `process:${String(value.pid)}`;
if (
source === "filesystem" &&
"name" in value &&
typeof value.name === "string"
)
return `checkpoint:${value.name}`;
if (
source === "shim" &&
"command" in value &&
typeof value.command === "string"
)
return "route_index" in value && typeof value.route_index === "number"
? `shim:${value.command}:route:${String(value.route_index)}`
: `shim:${value.command}:unmatched`;
return null;
};
@@ -188,7 +201,7 @@ const projectedRecord = (
return {
source: projected.source,
captured_at_ms: atMilliseconds(payload),
subject_id: subjectFor(projected.source, payload),
subject_id: processObservationSubject(projected.source, payload),
payload,
};
};
+33 -16
View File
@@ -65,6 +65,7 @@ export type ProcessReactiveInput =
readonly state_entry_capture_order: number;
}
| { readonly kind: "scenario_deadline" }
| { readonly kind: "target_lost" }
| { readonly kind: "cancelled" }
| { readonly kind: "cleanup_failed" };
@@ -123,6 +124,33 @@ export type ProcessReactiveDecision =
const counterValue = (counters: readonly Counter[], id: string): number =>
counters.find((counter) => counter.id === id)?.count ?? 0;
type ObservationAdmission =
| { readonly kind: "new"; readonly snapshot: ProcessReactiveSnapshot }
| { readonly kind: "invalid" };
const observeInput = (
snapshot: ProcessReactiveSnapshot,
observation: ProcessObservation,
): ObservationAdmission => {
const duplicate = snapshot.observations.find(
({ event_id }) => event_id === observation.event_id,
);
if (duplicate !== undefined) return { kind: "invalid" };
const prior = snapshot.observations.at(-1);
if (
(prior !== undefined && observation.capture_order <= prior.capture_order) ||
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
)
return { kind: "invalid" };
return {
kind: "new",
snapshot: {
...snapshot,
observations: [...snapshot.observations, observation],
},
};
};
/** Create the initial immutable reducer state for one validated scenario. */
export const createProcessReactiveSnapshot = (
scenario: ProcessReactiveScenario,
@@ -166,6 +194,8 @@ const reduceControlInput = (
: { kind: "waiting", snapshot, evaluations: [] };
if (input.kind === "scenario_deadline")
return finishedDecision(snapshot, "scenario_deadline");
if (input.kind === "target_lost")
return finishedDecision(snapshot, "target_lost");
return finishedDecision(snapshot, "cancelled");
};
@@ -179,23 +209,10 @@ export const reduceProcessReactiveScenario = (
if (controlDecision !== null) return controlDecision;
if (input.kind !== "observation")
return finishedDecision(snapshot, "capture_incomplete");
const prior = snapshot.observations.at(-1);
if (
(prior !== undefined &&
input.observation.capture_order <= prior.capture_order) ||
snapshot.observations.some(
({ event_id }) => event_id === input.observation.event_id,
)
)
const admitted = observeInput(snapshot, input.observation);
if (admitted.kind === "invalid")
return finishedDecision(snapshot, "capture_incomplete");
if (
snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations
)
return finishedDecision(snapshot, "capture_incomplete");
const observed = {
...snapshot,
observations: [...snapshot.observations, input.observation],
};
const observed = admitted.snapshot;
const state = scenario.states.find(({ id }) => id === observed.active_state);
if (state === undefined) return finishedDecision(observed, "target_lost");
const budget = { remaining: PROCESS_REACTIVE_LIMITS.evaluationWork };
+22 -4
View File
@@ -124,6 +124,7 @@ const applyProcessReactiveTransition = (input: {
const effectObservationMatchesAction = (
action: ProcessReactiveScenario["states"][number]["on"][number]["actions"][number],
observation: ProcessObservation,
committedSensitiveInputs: boolean,
): boolean => {
const payload =
typeof observation.payload === "object" &&
@@ -153,9 +154,11 @@ const effectObservationMatchesAction = (
return (
payload["type"] === "input" &&
payload["data"] ===
(action.sensitive
? `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`
: action.data)
expectedInputEvidence(
action.data,
action.sensitive,
committedSensitiveInputs,
)
);
if (action.type === "resize")
return (
@@ -165,6 +168,16 @@ const effectObservationMatchesAction = (
return payload["type"] === "signal" && payload["data"] === action.signal;
};
const expectedInputEvidence = (
data: string,
sensitive: boolean,
committedSensitiveInput: boolean,
): string =>
!sensitive ||
(committedSensitiveInput && /^<redacted-input:\d+-bytes>$/.test(data))
? data
: `<redacted-input:${String(Buffer.byteLength(data))}-bytes>`;
const failedProposal = (
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
outcome: "action_rejected" | "target_lost",
@@ -180,6 +193,7 @@ export const commitProcessReactiveProposal = (
scenario: ProcessReactiveScenario,
proposed: Extract<ProcessReactiveDecision, { readonly kind: "proposal" }>,
results: readonly ProcessReactiveEffectResult[],
options: { readonly committedSensitiveInputs?: boolean } = {},
): ProcessReactiveDecision => {
if (results.some(({ status }) => status === "target_lost"))
return failedProposal(proposed, "target_lost");
@@ -227,7 +241,11 @@ export const commitProcessReactiveProposal = (
const effectObservation = result.observation;
if (
action === undefined ||
!effectObservationMatchesAction(action, effectObservation) ||
!effectObservationMatchesAction(
action,
effectObservation,
options.committedSensitiveInputs ?? false,
) ||
!Number.isSafeInteger(effectObservation.capture_order) ||
effectObservation.capture_order <= previousOrder ||
knownEventIds.has(effectObservation.event_id)
+30
View File
@@ -2,6 +2,10 @@ import { createHash } from "node:crypto";
import { resolve } from "node:path";
import canonicalize from "canonicalize";
import { z } from "zod";
import {
processReactiveScenarioSchema,
type ProcessReactiveScenario,
} from "./processReactiveScenario.js";
import { replayMachineSchema } from "./replayMachine.js";
const positiveBudget = z.number().int().positive();
@@ -51,6 +55,29 @@ export const digestProcessCommitment = (value: unknown): string => {
return createHash("sha256").update(serialized).digest("hex");
};
const reactiveScenarioCommitment = (
scenario: ProcessReactiveScenario | null,
): Readonly<Record<string, unknown>> | null =>
scenario === null
? null
: {
...scenario,
states: scenario.states.map((state) => ({
...state,
on: state.on.map((transition) => ({
...transition,
actions: transition.actions.map((action) =>
action.type === "send_input" && action.sensitive
? {
...action,
data: `<redacted-input:${String(Buffer.byteLength(action.data))}-bytes>`,
}
: action,
),
})),
})),
};
/** Build the secret-safe scenario projection committed by Process Capture v4. */
export const processScenarioCommitment = (
scenario: ProcessScenario,
@@ -63,6 +90,7 @@ export const processScenarioCommitment = (
scenario.secret_aliases.includes(name) ? "<redacted-secret>" : value,
]),
),
reactive: reactiveScenarioCommitment(scenario.reactive),
unknown_registry_approved: scenario.unknown_registry_approved === true,
executable_sha256: executableSha256 ?? null,
});
@@ -86,6 +114,7 @@ export const processComparisonContract = (
normalization: scenario.normalization,
command_shims: scenario.command_shims,
replay: scenario.replay,
reactive: reactiveScenarioCommitment(scenario.reactive),
});
/**
@@ -292,6 +321,7 @@ export const processScenarioSchema = z
websocket_messages: [],
websocket_connections: [],
}),
reactive: processReactiveScenarioSchema.nullable().default(null),
})
.strict()
.superRefine((scenario, context) => {
+20
View File
@@ -0,0 +1,20 @@
/** ZIP-backed application package families that share the hardened archive reader. */
export type ZipPackageFormat = "zip" | "ipa" | "apk" | "msix" | "appx";
/**
* Classify a ZIP-backed package by its complete lower-cased path suffix.
*
* This is deliberately extension-only. Callers must separately verify ZIP
* magic before trusting the classification for a root input.
*/
export const zipPackageFormatForPath = (
path: string,
): ZipPackageFormat | undefined => {
const lower = path.toLowerCase();
if (lower.endsWith(".zip")) return "zip";
if (lower.endsWith(".ipa")) return "ipa";
if (lower.endsWith(".apk") || lower.endsWith(".aab")) return "apk";
if (lower.endsWith(".msix") || lower.endsWith(".msixbundle")) return "msix";
if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx";
return undefined;
};
+1 -1
View File
@@ -1,7 +1,7 @@
/** Generated from package.json and package-lock.json; do not edit. */
export const PACKAGE_METADATA = {
name: "rea-agents",
version: "2.3.0", // x-release-please-version
version: "2.4.0", // x-release-please-version
serverSdkVersion: "2.0.0-beta.4",
clientSdkVersion: "2.0.0-beta.4",
coreSdkVersion: "2.0.0-beta.4",
+1
View File
@@ -75,6 +75,7 @@ export class NativeMacOSProvider implements AnalysisProvider {
? ({ available: true, reason: null } as const)
: ({
available: false,
availabilityCode: "unsupported_host",
reason: "Native macOS utilities require macOS.",
} as const);
return Object.freeze({
+65
View File
@@ -179,6 +179,71 @@ describe("artifact graph provider", () => {
});
});
it.each([
["fixture.msix", "msix"],
["fixture.appxbundle", "appx"],
] as const)(
"reuses the hardened ZIP reader for %s package inventory",
async (name, format) => {
const root = await createTestTempDirectory("rea-windows-package-");
const path = join(root, name);
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add(
"Assets/main.js",
new TextReader("export default 'windows';"),
);
await writer.add(
"VFS/ProgramFilesX64/App/addon.node",
new TextReader("native"),
);
await writeFile(path, await writer.close());
const parsed = await parseBinaryTarget(path);
expect(parsed.ok && parsed.value).toMatchObject({
kind: "archive",
format,
});
if (!parsed.ok) return;
const result = await inventory(parsed.value);
expect(result.manifest.root_format).toBe(format);
expect(result.nodes.items.map(({ kind }) => kind)).toEqual(
expect.arrayContaining(["javascript", "native-addon"]),
);
const selected = result.occurrences.items.find(
({ logical_path: logicalPath }) => logicalPath === "Assets/main.js",
);
expect(selected).toBeDefined();
if (selected === undefined) return;
const output = join(root, "output");
const extracted = await new ArtifactProvider()
.createClient(parsed.value)
.execute(
"extract_artifact",
artifactExtractionInputSchema.parse({
approved: true,
output_root: output,
occurrence_ids: [selected.occurrence_id],
}),
);
expect(extracted.ok).toBe(true);
expect(await readFile(join(output, "Assets", "main.js"), "utf8")).toBe(
"export default 'windows';",
);
expect(
parseEvidence(
await runProviderAnalysis(path, "inventory_artifact", {
node_limit: 10,
occurrence_limit: 10,
edge_limit: 10,
}),
),
).toMatchObject({
operation: "inventory_artifact",
subject: { format },
});
},
);
it("rejects unsafe, colliding, and over-ratio entries", async () => {
expect(() => normalizeArtifactPath("../escape", limits())).toThrow(
/normalized/u,
+19 -2
View File
@@ -95,18 +95,35 @@ describe("executable header parsing", () => {
});
describe("binary target I/O", () => {
it("classifies ZIP profiles and text artifacts without Hopper", async () => {
it("classifies ZIP package families and text artifacts without Hopper", async () => {
const directory = await createTestTempDirectory("rea-artifact-target-");
const zip = join(directory, "fixture.apk");
const msix = join(directory, "fixture.msixbundle");
const appx = join(directory, "fixture.appx");
const script = join(directory, "bundle.js");
await writeFile(zip, Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]));
const emptyZip = Buffer.from([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0]);
await Promise.all([
writeFile(zip, emptyZip),
writeFile(msix, emptyZip),
writeFile(appx, emptyZip),
]);
await writeFile(script, "export default 1;\n");
const archive = await parseBinaryTarget(zip);
const msixArchive = await parseBinaryTarget(msix);
const appxArchive = await parseBinaryTarget(appx);
const javascript = await parseBinaryTarget(script);
expect(archive.ok && archive.value).toMatchObject({
kind: "archive",
format: "apk",
});
expect(msixArchive.ok && msixArchive.value).toMatchObject({
kind: "archive",
format: "msix",
});
expect(appxArchive.ok && appxArchive.value).toMatchObject({
kind: "archive",
format: "appx",
});
expect(javascript.ok && javascript.value).toMatchObject({
kind: "artifact",
format: "javascript",
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { buildCapabilityInventory } from "../src/application/CapabilityInventory.js";
const enabledPolicy: Parameters<typeof buildCapabilityInventory>[1] = {
processCaptureEnabled: true,
evidenceFileRoots: 1,
investigationInputRoots: 1,
browserObservationEnabled: true,
electronObservationEnabled: true,
javascriptReplayEnabled: true,
managedRuntimeEnabled: true,
};
const status = (
options: {
readonly open?: boolean;
readonly kind?: "executable" | "database" | "archive" | "artifact";
readonly capabilities?: readonly {
readonly operation: string;
readonly available: boolean;
readonly reason: string | null;
readonly availability_code?: string;
}[];
} = {},
) => ({
open: options.open ?? false,
...(options.kind === undefined ? {} : { kind: options.kind }),
capabilities: [...(options.capabilities ?? [])],
});
const entry = (
name: string,
sessionStatus: ReturnType<typeof status>,
policy = enabledPolicy,
) => {
const found = buildCapabilityInventory(sessionStatus, policy).find(
(candidate) => candidate.name === name,
);
if (found === undefined) throw new Error(`missing capability ${name}`);
return found;
};
describe("capability inventory", () => {
it.each([
{
label: "requires a target",
name: "current_address",
sessionStatus: status(),
reason: "target_required",
remediation: "open_binary",
},
{
label: "rejects an incompatible target family",
name: "current_address",
sessionStatus: status({ open: true, kind: "archive" }),
reason: "target_unsupported",
remediation: "native executable",
},
{
label: "reports a missing provider operation",
name: "current_address",
sessionStatus: status({ open: true, kind: "executable" }),
reason: "provider_missing",
remediation: "provider",
},
{
label: "distinguishes an unsupported host",
name: "current_address",
sessionStatus: status({
open: true,
kind: "executable",
capabilities: [
{
operation: "current_address",
available: false,
availability_code: "unsupported_host",
reason: "Operation requires macOS",
},
],
}),
reason: "unsupported_host",
remediation: "macOS",
},
{
label: "retains a provider failure",
name: "current_address",
sessionStatus: status({
open: true,
kind: "executable",
capabilities: [
{
operation: "current_address",
available: false,
reason: "Provider session is unhealthy",
},
],
}),
reason: "provider_unavailable",
remediation: "unhealthy",
},
{
label: "reports an available provider operation",
name: "current_address",
sessionStatus: status({
open: true,
kind: "executable",
capabilities: [
{
operation: "current_address",
available: true,
reason: null,
},
],
}),
reason: "available",
remediation: null,
},
])("$label", ({ name, sessionStatus, reason, remediation }) => {
const availability = entry(name, sessionStatus);
expect(availability).toMatchObject({
reason,
available: reason === "available",
});
if (remediation === null) expect(availability.remediation).toBeNull();
else expect(availability.remediation).toContain(remediation);
});
it.each([
["capture_process_scenario", { processCaptureEnabled: false }],
["inspect_web_page", { browserObservationEnabled: false }],
["inspect_electron_page", { electronObservationEnabled: 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(), {
...enabledPolicy,
...override,
});
expect(availability).toMatchObject({
available: false,
reason: "policy_disabled",
});
expect(availability.remediation).toEqual(expect.any(String));
expect(availability.remediation?.length).toBeGreaterThan(0);
});
it("gives every unavailable public tool an actionable remediation", () => {
const inventory = buildCapabilityInventory(status(), {
...enabledPolicy,
processCaptureEnabled: false,
evidenceFileRoots: 0,
investigationInputRoots: 0,
browserObservationEnabled: false,
electronObservationEnabled: false,
javascriptReplayEnabled: false,
managedRuntimeEnabled: false,
});
for (const availability of inventory)
if (!availability.available) {
expect(availability.reason).not.toBe("available");
expect(availability.remediation).toEqual(expect.any(String));
expect(availability.remediation?.length).toBeGreaterThan(0);
}
});
});
+2 -13
View File
@@ -85,19 +85,8 @@ describe("tool contract surface", () => {
});
});
it("advertises complete typed schemas and annotations for all analysis tools", () => {
const contracts = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...NATIVE_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
...BROWSER_TOOL_CONTRACTS,
...ELECTRON_TOOL_CONTRACTS,
...APPLICATION_TOOL_CONTRACTS,
];
expect(contracts).toHaveLength(79);
it("advertises complete typed schemas and annotations for every public tool", () => {
const contracts = TOOL_CONTRACTS;
for (const contract of contracts) {
const inputSchema = contractJsonSchema(contract.inputSchema);
const outputSchema = contractJsonSchema(contract.outputSchema);
+26
View File
@@ -0,0 +1,26 @@
import { spawn, spawnSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { WebSocket } from "ws";
process.stdout.write("Ready\n");
const worker = spawn(
process.execPath,
["-e", 'process.title = "rea-reactive-worker"; setTimeout(() => {}, 750);'],
{ stdio: "ignore" },
);
process.stdout.write("Collecting\n");
spawnSync(process.argv[2], ["probe"], { stdio: "ignore" });
await writeFile("reactive-result.txt", "created");
await fetch(`${process.env.REA_REPLAY_HTTP_URL}/reactive`);
await new Promise((resolve, reject) => {
const socket = new WebSocket(process.env.REA_REPLAY_WEBSOCKET_URL);
socket.once("open", () => socket.send("reactive-client"));
socket.once("message", () => socket.close());
socket.once("close", resolve);
socket.once("error", reject);
});
await new Promise((resolve, reject) => {
worker.once("exit", resolve);
worker.once("error", reject);
});
+766 -2
View File
@@ -103,6 +103,7 @@ const emptyCapture = (): ProcessCapture => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
event_journal: [],
files_before: [],
files_after: [],
@@ -187,6 +188,7 @@ describe("process capture domain", () => {
];
const result = buildCaptureResult({
frames: [],
reactiveRun: null,
exit: { exitCode: 0, reason: "exited" },
samples: [],
replay: {
@@ -452,6 +454,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@@ -696,6 +699,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@@ -756,6 +760,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@@ -800,6 +805,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@@ -873,6 +879,7 @@ describe("process capture domain", () => {
shim_events: [],
protocol_events: [],
replay_transitions: [],
reactive_run: null,
files_before: [],
files_after: [],
filesystem_effects: [],
@@ -1507,6 +1514,407 @@ describe("process capture adapter", () => {
}
});
it("drives a process from terminal observations and retains the reactive transition journal", async () => {
const root = await createTestTempDirectory("rea-reactive-harness-test-");
const script = join(root, "reactive.mjs");
await writeFile(
script,
[
'import { createInterface } from "node:readline";',
"const input = createInterface({ input: process.stdin, terminal: false });",
'process.stdout.write("Ready\\n");',
'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });',
].join("\n"),
);
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 5_000,
states: [
{
id: "waiting",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "answer",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [
{ type: "send_input", data: "accepted\n", sensitive: true },
],
target: { kind: "goto", state: "finishing" },
},
],
},
{
id: "finishing",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "complete",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Done",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "state_entry" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
active_state: "finishing",
});
expect(
result.value.reactive_run?.transitions.map(
({ transition_id }) => transition_id,
),
).toEqual(["answer", "complete"]);
expect(result.value.interaction_events).toContainEqual(
expect.objectContaining({
type: "input",
data: "<redacted-input:9-bytes>",
outcome: "dispatched",
}),
);
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"Done",
);
expect(result.value.manifest.comparison_contract).toHaveProperty(
"reactive",
);
expect(
JSON.stringify(result.value.manifest.comparison_contract),
).not.toContain("accepted");
expect(JSON.stringify(result.value.manifest.scenario)).not.toContain(
"accepted",
);
const reactiveRun = result.value.reactive_run;
if (reactiveRun === null) throw new Error("reactive result missing");
const lastReactiveOrder = result.value.event_journal
?.filter(({ collection }) =>
[
"frames",
"interaction_events",
"filesystem_checkpoints",
"shim_events",
].includes(collection),
)
.at(-1)?.capture_order;
if (lastReactiveOrder === undefined)
throw new Error("reactive observation order missing");
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
controls: [
...reactiveRun.controls,
{
sequence: reactiveRun.controls.length,
kind: "target_lost",
after_capture_order: lastReactiveOrder,
},
],
},
}),
).toContainEqual(
expect.objectContaining({
path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`,
message: "reactive control cannot follow a finished run",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? { ...transition, transition_id: "fabricated" }
: transition,
),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0]",
message:
"reactive transition is not declared by its recorded source state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
status: "finished",
outcome: "passed",
active_state: "waiting",
transitions: [],
controls: [],
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
const suffix = reactiveRun.transitions
.slice(1)
.map((transition, index) => ({
...transition,
sequence: index,
}));
expect(
validateProcessCapture({
...result.value,
reactive_run: { ...reactiveRun, transitions: suffix },
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.transitions[0].state_before",
message:
"reactive transition journal must start at the declared initial state",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: reactiveRun.transitions.map((transition, index) =>
index === 0
? {
...transition,
trigger_event_ids: [],
action_event_ids: [],
}
: transition,
),
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "reactive_run.transitions",
message: expect.stringContaining(
"reactive transition journal is not reproduced",
),
}),
expect.objectContaining({
path: "reactive_run.transitions[0].action_event_ids",
message: expect.stringContaining(
"recorded action observations do not match proposed effects",
),
}),
]),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
status: "running",
outcome: null,
},
}),
).toContainEqual(
expect.objectContaining({
message: "completed capture requires a finished reactive outcome",
}),
);
expect(
validateProcessCapture({
...result.value,
reactive_run: {
...reactiveRun,
transitions: [
reactiveRun.transitions[0]!,
{ ...reactiveRun.transitions[0]!, sequence: 1 },
],
},
}),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: "reactive transition states must be contiguous",
}),
expect.objectContaining({
message:
"reactive transition differs from deterministic journal replay",
}),
]),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("records target loss before post-exit settlement can win the deadline race", async () => {
const root = await createTestTempDirectory(
"rea-reactive-target-loss-test-",
);
const script = join(root, "exit.mjs");
await writeFile(script, 'process.stdout.write("exiting\\n");\n');
const scenario = parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script],
working_directory: root,
settle_ms: 500,
reactive: {
version: 1,
initial_state: "waiting",
deadline_ms: 2_000,
states: [
{
id: "waiting",
max_visits: 1,
deadline_ms: 300,
on: [
{
id: "unreachable",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "never-produced",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
});
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const result = await captureProcessScenario(scenario, {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "target_lost",
active_state: "waiting",
transitions: [],
controls: [
expect.objectContaining({
kind: "target_lost",
}),
],
});
const frame = result.value.frames.at(-1);
const journal = result.value.event_journal;
const reactiveRun = result.value.reactive_run;
if (frame === undefined || journal === undefined || reactiveRun === null)
throw new Error("ordered reactive evidence missing");
const laterCaptureOrder = journal.length;
const withLaterMatch = {
...result.value,
frames: [
...result.value.frames,
{
...frame,
sequence: result.value.frames.length,
at_ms: frame.at_ms + 1,
data: "never-produced",
},
],
event_journal: [
...journal,
{
capture_order: laterCaptureOrder,
collection: "frames" as const,
index: result.value.frames.length,
},
],
};
expect(
validateProcessCapture(withLaterMatch).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
expect(
validateProcessCapture({
...withLaterMatch,
reactive_run: {
...reactiveRun,
controls: reactiveRun.controls.map((control) => ({
...control,
after_capture_order: laterCaptureOrder,
})),
},
}),
).toContainEqual(
expect.objectContaining({
path: "reactive_run.outcome",
message: "reactive outcome differs from deterministic journal replay",
}),
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("renders terminal state, records shim invocations, and captures literal checkpoints", async () => {
const root = await createTestTempDirectory("rea-v3-test-");
const script = join(root, "scenario.mjs");
@@ -1529,7 +1937,6 @@ describe("process capture adapter", () => {
executable: process.execPath,
arguments: [script],
working_directory: root,
filesystem_roots: [root],
checkpoints: [
{
name: "probe_seen",
@@ -1562,6 +1969,75 @@ describe("process capture adapter", () => {
],
},
],
reactive: {
version: 1,
initial_state: "waiting_for_shim",
deadline_ms: 5_000,
states: [
{
id: "waiting_for_shim",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "record_shim",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "shim",
exact: {
command: "codex",
route_index: 0,
arguments: ["--version"],
working_directory: "<working-directory>",
outcome: "matched",
},
ignore_fields: ["sequence", "at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [
{ type: "checkpoint", name: "reactive_shim_seen" },
],
target: {
kind: "goto",
state: "waiting_for_checkpoint",
},
},
],
},
{
id: "waiting_for_checkpoint",
max_visits: 1,
deadline_ms: 2_000,
on: [
{
id: "finish_checkpoint",
priority: 0,
max_uses: 1,
when: {
kind: "event",
source: "filesystem",
exact: {
name: "reactive_shim_seen",
files: [],
effects: [],
truncated: false,
},
ignore_fields: ["at_ms"],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
@@ -1591,12 +2067,300 @@ describe("process capture adapter", () => {
]);
expect(
result.value.filesystem_checkpoints.map(({ name }) => name),
).toEqual(["before", "probe_seen", "after_settlement"]);
).toEqual(
expect.arrayContaining([
"before",
"reactive_shim_seen",
"probe_seen",
"after_settlement",
]),
);
expect(result.value.reactive_run).toMatchObject({
status: "finished",
outcome: "passed",
transitions: [
expect.objectContaining({
transition_id: "record_shim",
trigger_event_ids: ["obs.shim_events.0"],
action_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
expect.objectContaining({
transition_id: "finish_checkpoint",
trigger_event_ids: [
expect.stringMatching(/^obs\.filesystem_checkpoints\./u),
],
}),
],
});
} finally {
await rm(root, { recursive: true, force: true });
}
}, 20_000);
it("runs the committed multi-source reactive fixture deterministically", async () => {
const root = await createTestTempDirectory("rea-reactive-e2e-");
const script = fileURLToPath(
new URL("./fixtures/processReactiveScenario.mjs", import.meta.url),
);
const event = (
source: "process" | "filesystem" | "http" | "websocket" | "shim",
exact: Record<string, unknown>,
ignoreFields: readonly string[],
) => ({
kind: "event" as const,
source,
exact,
ignore_fields: ignoreFields,
since: { kind: "scenario_start" as const },
consume: true,
cardinality: { min: 1, max: 1 },
});
const run = () =>
captureProcessScenario(
parseProcessScenario({
approved: true,
executable: process.execPath,
arguments: [script, "codex"],
working_directory: root,
replay: {
http: [
{
method: "GET",
path: "/reactive",
status: 200,
body: "ok",
},
],
websocket_messages: ["reactive-server"],
},
command_shims: [
{
name: "codex",
routes: [
{
arguments: ["probe"],
outputs: [],
termination: { type: "exit", code: 0 },
},
],
},
],
reactive: {
version: 1,
initial_state: "ready",
deadline_ms: 8_000,
states: [
{
id: "ready",
max_visits: 1,
deadline_ms: 3_000,
on: [
{
id: "checkpoint_ready",
priority: 0,
max_uses: 1,
when: {
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Ready",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: true,
},
actions: [{ type: "checkpoint", name: "ready_snapshot" }],
target: { kind: "goto", state: "collecting" },
},
],
},
{
id: "collecting",
max_visits: 1,
deadline_ms: 5_000,
on: [
{
id: "finish_all_sources",
priority: 0,
max_uses: 1,
when: {
kind: "all",
triggers: [
{
kind: "any",
triggers: [
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "Collecting",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
{
kind: "terminal_text",
view: "decoded",
encoding: "utf8",
literal: "unreachable-alternative",
case_sensitive: true,
control_sequences: "include",
occurrence: 1,
since: { kind: "scenario_start" },
consume: false,
},
],
},
event(
"process",
{
pid: 3,
parent_pid: 1,
command: "rea-reactive-worker",
process_group_id: 1,
session_id: 1,
},
["at_ms"],
),
event(
"filesystem",
{
name: "ready_snapshot",
files: [],
effects: [],
truncated: false,
},
["at_ms"],
),
event(
"shim",
{
command: "codex",
route_index: 0,
arguments: ["probe"],
working_directory: "<working-directory>",
outcome: "matched",
},
["sequence", "at_ms"],
),
{
kind: "sequence",
triggers: [
event(
"http",
{
protocol: "http",
direction: "request",
method: "GET",
path: "/reactive",
data: "",
outcome: "matched",
},
["sequence", "at_ms"],
),
event(
"websocket",
{
protocol: "websocket",
direction: "received",
method: null,
path: "/ws",
data: "reactive-client",
outcome: "matched",
},
["sequence", "at_ms"],
),
],
},
],
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
},
}),
{
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
);
try {
const capability = await probeProcessCaptureCapability();
if (!capability.available) return;
const first = await run();
const second = await run();
if (!first.ok) throw first.error;
if (!second.ok) throw second.error;
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
const trace = (capture: ProcessCapture) =>
capture.reactive_run?.transitions.map((transition) => ({
transition_id: transition.transition_id,
from: transition.state_before,
to: transition.state_after,
outcome: transition.outcome,
trigger_collections: transition.trigger_event_ids.map(
(id) => id.split(".")[1],
),
}));
expect(
first.value.reactive_run?.outcome,
JSON.stringify({
run: first.value.reactive_run,
processes: first.value.process_samples,
checkpoints: first.value.filesystem_checkpoints,
shims: first.value.shim_events,
protocols: first.value.protocol_events,
}),
).toBe("passed");
expect(trace(first.value)).toEqual(trace(second.value));
expect(trace(first.value)).toEqual([
expect.objectContaining({ transition_id: "checkpoint_ready" }),
expect.objectContaining({
transition_id: "finish_all_sources",
trigger_collections: expect.arrayContaining([
"frames",
"process_samples",
"filesystem_checkpoints",
"shim_events",
"protocol_events",
]),
}),
]);
for (const outcome of ["action_rejected", "target_lost"] as const) {
const failedActionCapture = {
...first.value,
reactive_run: {
status: "finished" as const,
outcome,
active_state: "ready",
transitions: [],
controls: [],
},
};
expect(
validateProcessCapture(failedActionCapture).filter(({ path }) =>
path.startsWith("reactive_run"),
),
).toEqual([]);
}
expect(first.value.truncated).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
it("does not follow or disclose symlink targets outside declared roots", async () => {
const root = await createTestTempDirectory("rea-symlink-test-");
await symlink("/etc/passwd", join(root, "escape"));
+126 -1
View File
@@ -3,7 +3,8 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { mkdir, rm } from "node:fs/promises";
import { dirname } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
@@ -106,6 +107,130 @@ describe("process-capture MCP elicitation", () => {
}
});
it.each(["tampered", "expired"] as const)(
"rejects %s signed state through the modern MCP client and server",
async (failure) => {
const startedAt = Date.parse("2026-07-23T00:00:00.000Z");
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(startedAt);
const root = await createTestTempDirectory("rea-elicit-state-");
const authority = new PermissionAuthority(
createPermissionPolicy([
{
capability: "process_capture",
roots: [root],
executables: [process.execPath],
environment_names: [],
network: "external",
mount: false,
},
]),
);
const session = new BinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(),
}));
const client = new Client(
{ name: "process-elicit-state", version: "1" },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: {
mode: {
pin: PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions[0],
},
},
inputRequired: { autoFulfill: false, maxRounds: 3 },
},
);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const server = serveStdio(
() =>
createServer(session, session, {
logger: silentLogger,
permissionAuthority: authority,
processPolicy: {
enabled: true,
executableRoots: [dirname(process.execPath)],
workingRoots: [root],
allowedEnvironment: [],
allowExternalNetwork: true,
},
}),
{ transport: serverTransport, legacy: "reject" },
);
const call = {
name: "capture_process_scenario",
arguments: {
approved: true,
executable: process.execPath,
arguments: ["-e", "process.exit(0)"],
working_directory: root,
},
};
try {
await client.connect(clientTransport);
const required = await client.callTool(call, {
allowInputRequired: true,
});
expect(isInputRequiredResult(required)).toBe(true);
if (!isInputRequiredResult(required)) return;
const requestState = required.requestState;
expect(requestState).toEqual(expect.any(String));
if (requestState === undefined) return;
if (failure === "expired")
vi.setSystemTime(
startedAt +
(PROCESS_CAPTURE_ELICITATION_POLICY.stateTtlSeconds + 1) * 1_000,
);
const echoedState =
failure === "tampered"
? `${requestState.slice(0, -1)}${requestState.endsWith("A") ? "B" : "A"}`
: requestState;
const retryMethod: string = "tools/call";
await expect(
client.request(
{
method: retryMethod,
params: {
...call,
requestState: echoedState,
inputResponses: {
process_capture_grant: {
action: "accept",
content: { lifetime: "session" },
},
},
},
},
z.unknown(),
),
).rejects.toMatchObject({
code: -32_602,
data: { reason: "invalid_request_state" },
});
expect(
await authority.authorize(
{
capability: "process_capture",
roots: [root],
executables: [process.execPath],
environment_names: [],
network: "external",
mount: false,
operation_identity: `capture_process_scenario:${process.execPath}`,
},
"read",
),
).toMatchObject({ ok: false });
} finally {
await Promise.allSettled([client.close(), server.close()]);
await rm(root, { recursive: true, force: true });
vi.useRealTimers();
}
},
);
it("grants and reuses a signed session scope on the modern protocol", async () => {
const authority = ceilingOnlyAuthority();
let state: ProcessCaptureElicitationState | undefined;
+4
View File
@@ -122,6 +122,10 @@ describe("process observation vocabulary", () => {
captured_at_ms: 4,
subject_id: "process:2",
});
expect(observations[7]).toMatchObject({
event_id: "obs.shim_events.0",
subject_id: "shim:helper:route:0",
});
expect(observations[3]?.captured_at_ms).toBeNull();
expect(observations[4]?.captured_at_ms).toBeNull();
});
+7 -2
View File
@@ -9,7 +9,7 @@ import {
unsupportedProcessReactiveFeatures,
} from "../src/application/ProcessReactiveEffects.js";
import { createProcessCaptureJournal } from "../src/application/ProcessCaptureJournal.js";
import { subscribeProcessReactiveTerminalObservations } from "../src/application/ProcessReactiveObservations.js";
import { subscribeProcessReactiveObservations } from "../src/application/ProcessReactiveObservations.js";
import type {
FilesystemCheckpoint,
InteractionEvent,
@@ -149,9 +149,14 @@ const createHarness = (scenario: ProcessReactiveScenario) => {
},
timerHost: timers.host,
});
const unsubscribe = subscribeProcessReactiveTerminalObservations({
const unsubscribe = subscribeProcessReactiveObservations({
journal,
frames,
interactions,
checkpointAt: (index) => checkpoints[index],
processSampleAt: () => undefined,
protocolEventAt: () => undefined,
shimEventAt: () => undefined,
sink: coordinator,
});
return {
+32
View File
@@ -194,6 +194,27 @@ describe("process reactive runtime", () => {
});
});
it("treats redaction-shaped live input as ordinary sensitive data", () => {
const scenario = scenarioWith([
finish("ready", terminalTrigger(), 100, [
{
type: "send_input",
data: "<redacted-input:1-bytes>",
sensitive: true,
},
]),
]);
const decision = offer(
scenario,
createProcessReactiveSnapshot(scenario),
observation("terminal_raw", 0, { data: "Ready" }),
);
expect(decision).toMatchObject({
kind: "transition",
record: { outcome: "passed" },
});
});
it("reports only the minimal terminal chunk span containing the occurrence", () => {
const scenario = scenarioWith([finish("ready", terminalTrigger())]);
let snapshot = createProcessReactiveSnapshot(scenario);
@@ -638,5 +659,16 @@ describe("process reactive runtime", () => {
kind: "finished",
outcome: "capture_incomplete",
});
const duplicate = observation("shim", 1, { name: "later" });
expect(offer(scenario, first.snapshot, duplicate)).toMatchObject({
kind: "finished",
outcome: "capture_incomplete",
});
expect(
offer(scenario, first.snapshot, {
...duplicate,
payload: { name: "changed" },
}),
).toMatchObject({ kind: "finished", outcome: "capture_incomplete" });
});
});
+1
View File
@@ -115,6 +115,7 @@ describe("server and catalog identity", () => {
{
operation: "inspect_macho",
available: false,
availability_code: "unsupported_host",
reason: "Native macOS utilities require macOS.",
},
],