Compare commits

..
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c6cda443c1 docs(javascript): state that duplicate unmapped content stays ambiguous
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 03:33:36 +00:00
Kaoru0822andClaude Opus 5.5 e308f35c43 fix(javascript): keep unmapped captured runtime scripts unknown
A captured script whose bytes matched no static digest was reported as
high-confidence unmatched even when no automatic or caller-declared
mapping applied to its location in any static layer. The matcher now
computes mapping presence once while locating candidates and shares one
no-candidate classification between the captured-source and source-free
branches, so an unmapped location stays unknown with
no-authorized-location-mapping and is not counted as unmatched.

Fixes #6061.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:20:21 +00:00
morluto 719d56ebfe fix(evidence): preserve provider execution provenance across workflows 2026-10-22 10:02:31 +08:00
morluto 87c8086fc1 refactor(managed): reuse canonical JSON digest owner 2026-10-22 09:52:58 +08:00
morluto eab4e47916 refactor(ghidra): share session startup observation checks 2026-10-22 09:52:49 +08:00
morluto db87917daa refactor(native): use canonical deadline defaults 2026-10-22 09:52:40 +08:00
morluto 183b4d4e7e refactor(managed): share metadata token schema 2026-10-22 09:33:58 +08:00
morluto 00819258a7 refactor(browser): separate bundle state from AST inspection 2026-10-22 09:33:58 +08:00
54 changed files with 971 additions and 1535 deletions

No files matched your search

@@ -38,8 +38,6 @@ Page-declared WebMCP tools are untrusted inventory and are never invoked by REA.
browser and profile. Check `browser.cleanup` for the outcome.
Request only the DOM, accessibility, or network details needed for the question.
The default final capture contains the URL; step outcomes are reported separately.
For multi-step flows with trees or events, select `detail: "summary"` and page
exact steps or events with `inspect_analysis_view` kind `browser-scenario`.
Add a wait action for the expected page state before capturing it. Report that
REA performed the declared actions.
-40
View File
@@ -293,46 +293,6 @@ external browser without closing it. The request itself defines the operation;
there is no additional REA grant step. The browser and host still enforce their
own access and process rules.
## Summary delivery
Multi-step scenarios that select accessibility trees, DOM or network events can
produce results far larger than an MCP client accepts. Select
`"detail": "summary"` (CLI: `--detail summary --evidence_output <bundle>`) to
retain the complete Evidence and receive a compact view instead:
- one row per step with its status, error, elapsed time and `after_url`;
`before_url` appears only when it differs from the previous step's
`after_url`, and sections are listed as captured, missing or truncated
(`not_requested` sections are omitted);
- `final_accessibility`: the accessibility tree of the first failed or
cancelled step, or of the last step, falling back to the nearest earlier step
that captured one (`step_index` is `null` when none did);
- retained event counts by kind, with the budget omissions, and network
transaction counts by origin, resource type and final status class.
Page exact records from `normalized_result.parent_evidence_id` with
`inspect_analysis_view`:
```json
{
"source": { "kind": "retained-evidence", "evidence_id": "ev_..." },
"view": {
"kind": "browser-scenario",
"collection": "steps",
"step_indices": [3],
"artifacts": ["dom"]
}
}
```
Steps return only the captured sections named in `artifacts` and list the
others with their state. Events (`"collection": "events"`) accept
`step_indices`, `event_kinds` and `transaction_ids` and keep each event's
original `item_index`. An omitted filter selects all records; `[]` selects
none. Every page repeats the capture's completeness and dropped-event count:
exhausting the pages does not make a truncated capture complete. The default
`"detail": "complete"` keeps returning the whole capture inline.
## Network evidence
`capture.events: ["network"]` retains request, response, completion, failure,
+6 -1
View File
@@ -106,7 +106,12 @@ nodes. REA evaluates compatible static candidates conservatively:
that disagreement. If a mapped candidate's digest is unavailable and no
mapped content match is known, the comparison remains `unknown`.
6. Missing authorization mappings and incomplete or truncated input coverage
remain unknown or truncated rather than becoming absence claims.
remain unknown or truncated rather than becoming absence claims. A runtime
location with no automatic artifact-root or caller-declared mapping into
any layer, and no content match, is `unknown` with
`no-authorized-location-mapping`, with or without captured bytes; several
content matches remain `ambiguous` under rule 4. Only a mapped location can
be `unmatched` with `no-static-content-match`.
Every successful cross-layer `observed_as` edge has
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
@@ -1,272 +0,0 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
SCENARIO_ORIGIN,
scenarioCaptureEvidence,
scenarioCaptureFixture,
} from "../../../tests/fixtures/browserScenarioCapture.js";
import { inspectAnalysisView } from "./AnalysisViewService.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { sanitizeBrowserUrl } from "../../domain/browserObservation.js";
import type { BrowserScenarioCapture } from "../../domain/browserScenarioCapture.js";
import type { AnalysisViewRequest } from "../../domain/analysisView/analysisView.js";
const tree = (text: string) => ({
state: "captured" as const,
value: {
sha256: createHash("sha256").update(text).digest("hex"),
bytes: Buffer.byteLength(text),
text,
},
});
/** Three steps: navigate to /quiz, capture its tree, then fail on a missing control. */
const quizCapture = (
thirdStep: "failed" | "completed" = "failed",
): BrowserScenarioCapture => {
const base = scenarioCaptureFixture();
const [initial, browse] = base.steps;
if (initial === undefined || browse === undefined)
throw new Error("Fixture lacks steps");
const quiz = sanitizeBrowserUrl(`${SCENARIO_ORIGIN}/quiz`);
return {
...base,
scenario: { ...base.scenario, action_count: 2 },
steps: [
{
...initial,
artifacts: {
...initial.artifacts,
url: { state: "captured", value: initial.after_url },
},
},
{
...browse,
after_url: quiz,
artifacts: {
...browse.artifacts,
accessibility: tree('- heading "Question 1"'),
screenshot: { state: "missing", reason: "page closed" },
},
},
{
...browse,
step_index: 2,
step_id: "answer",
action: "click",
before_url: quiz,
after_url: quiz,
...(thirdStep === "failed"
? { status: "failed" as const, error: "locator not found" }
: { status: "completed" as const, error: null }),
},
],
};
};
const view = (
capture: BrowserScenarioCapture,
request: AnalysisViewRequest,
) => {
const projected = inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence(capture) },
view: request,
});
if (!projected.ok) throw projected.error;
return projected.value.normalized_result as Record<string, unknown>;
};
describe("browser scenario analysis views", () => {
it("summarizes steps compactly and focuses the first failing step's nearest captured tree", () => {
const summary = view(quizCapture(), { kind: "summary" });
expect(summary).toMatchObject({
kind: "summary",
parent_operation: "capture_browser_scenario",
artifact: { path: "", sha256: null },
coverage: { status: "complete-within-view", exhausted: true },
});
const { steps, final_accessibility, network, events } =
summary.summary as Record<string, unknown> & {
steps: Record<string, unknown>[];
};
// before_url repeats only across a discontinuity; not_requested sections are omitted.
expect(steps[0]).toHaveProperty("before_url");
expect(steps[1]).not.toHaveProperty("before_url");
expect(steps[2]).not.toHaveProperty("before_url");
expect(steps[0]?.sections).toEqual({
captured: ["url"],
missing: [],
truncated: [],
});
expect(steps[1]?.sections).toEqual({
captured: ["accessibility"],
missing: ["screenshot"],
truncated: [],
});
expect(steps[2]).toMatchObject({
status: "failed",
error: "locator not found",
after_url: { url: `${SCENARIO_ORIGIN}/quiz` },
});
expect(final_accessibility).toEqual({
focus_step_index: 2,
focus: "first-unsuccessful-step",
step_index: 1,
accessibility: tree('- heading "Question 1"'),
});
expect(network).toEqual({
transactions: 8,
by_origin: [{ origin: SCENARIO_ORIGIN, transactions: 8 }],
by_resource_type: [
{ resource_type: "document", transactions: 1 },
{ resource_type: "fetch", transactions: 7 },
],
by_status_class: [
{ status_class: "2xx", transactions: 7 },
{ status_class: "failed", transactions: 1 },
],
});
expect(events).toMatchObject({ retained: 24, dropped: 0 });
});
it("reports the last step's own state when no step captured a tree", () => {
const capture = scenarioCaptureFixture();
expect(view(capture, { kind: "summary" })).toMatchObject({
summary: {
final_accessibility: {
focus_step_index: 1,
focus: "last-step",
step_index: null,
accessibility: { state: "not_requested" },
},
},
});
});
});
describe("browser scenario analysis view pages", () => {
it("pages exact steps with only the selected captured sections", () => {
const page = view(quizCapture("completed"), {
kind: "browser-scenario",
collection: "steps",
step_indices: [1, 2],
artifacts: ["accessibility"],
offset: 0,
limit: 1,
});
expect(page).toMatchObject({
kind: "browser-scenario",
source_completeness: { status: "complete" },
source_events: { retained: 24, dropped: 0 },
coverage: { status: "page", examined: 1, total: 2, next_offset: 1 },
items: {
collection: "steps",
rows: [
{
step_index: 1,
artifacts: { accessibility: tree('- heading "Question 1"') },
omitted_artifacts: [{ section: "screenshot", state: "missing" }],
},
],
},
});
});
it("filters events by step, kind and transaction, keeping original item indices", () => {
const capture = scenarioCaptureFixture();
const responses = view(capture, {
kind: "browser-scenario",
collection: "events",
event_kinds: ["response", "request-failed"],
transaction_ids: ["request-2", "request-8"],
artifacts: [],
offset: 0,
limit: 64,
}) as { items: { rows: { item_index: number; event: unknown }[] } };
expect(
responses.items.rows.map(({ item_index, event }) => ({
item_index,
event,
})),
).toEqual(
capture.events.items.flatMap((event, item_index) =>
(event.kind === "response" || event.kind === "request-failed") &&
["request-2", "request-8"].includes(event.transaction_id)
? [{ item_index, event }]
: [],
),
);
expect(responses.items.rows).toHaveLength(2);
for (const empty of [
{ step_indices: [] },
{ event_kinds: [] },
{ transaction_ids: [] },
{ step_indices: [0] },
])
expect(
view(capture, {
kind: "browser-scenario",
collection: "events",
artifacts: [],
offset: 0,
limit: 64,
...empty,
}),
).toMatchObject({
items: { rows: [] },
coverage: { status: "empty", total: 0, exhausted: true },
});
});
it("states dropped events and refuses views of other parents", () => {
const base = scenarioCaptureFixture();
const dropped = {
...base,
events: {
...base.events,
dropped: 3,
omissions: {
...base.events.omissions,
by_kind: [
{
kind: "console" as const,
count: 3,
reasons: ["max-events" as const],
},
],
},
},
completeness: {
status: "truncated" as const,
equality_eligible: false as const,
missing_sections: [],
truncated_sections: ["events" as const],
},
};
const summary = view(dropped, { kind: "summary" });
expect(summary.unknowns).toContain(
"3 events were dropped by capture budgets; counts and pages cover retained events only.",
);
expect(summary).toMatchObject({
summary: {
events: {
dropped: 3,
omissions: {
by_kind: [{ kind: "console", count: 3 }],
network_transactions: 0,
},
},
},
});
const refused = inspectAnalysisView({
source: { kind: "inline", evidence: scenarioCaptureEvidence() },
view: { kind: "go", collection: "dependencies" },
});
if (refused.ok) throw new Error("Expected an unsupported view refusal");
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "invalid_request",
});
});
});
@@ -0,0 +1,102 @@
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import { PacketCaptureService } from "./PacketCaptureService.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { inspectPacketCaptureInputSchema } from "../../domain/captures/packetCapture.js";
import { ok } from "../../domain/result.js";
it("retains provider metadata in a late-cancellation observation", async () => {
const input = inspectPacketCaptureInputSchema.parse({
path: process.platform === "win32" ? "C:\\capture.pcap" : "/capture.pcap",
fields: ["ip.addr"],
});
const provider = {
id: "fixture-capture",
name: "Fixture capture provider",
version: "1",
} as const;
const subject = {
path: input.path,
sha256: "a".repeat(64),
format: "file",
} as const;
const report = {
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
selection: input,
packets: [],
output_window: {
matching_packets: 0,
returned_packets: 0,
has_more: false,
},
read_scope: "to-eof",
diagnostics: "",
tool: {
configured_path: "/fixture/tshark",
path: "/fixture/tshark",
sha256: "b".repeat(64),
version_banner: "TShark fixture",
version_diagnostics: "",
arguments: [],
},
configuration: "private-empty-personal-profile",
resource_limiter: {
configured_path: "/fixture/prlimit",
path: "/fixture/prlimit",
sha256: "c".repeat(64),
arguments: [],
},
limits: {
input_bytes: 1,
output_bytes: 1,
command_timeout_ms: 1,
address_space_bytes: 0,
file_bytes: 0,
cpu_seconds: 0,
},
hardware_acquisition: "not-performed",
} as const;
const rawResult = { producer: "fixture", bytes: 12 };
const profile = createAnalysisProfile(provider, { filter: null });
const relationshipCoverage = {
status: "unknown",
reason: "Packet relationships are not reported.",
} as const;
const execution = createAnalysisExecution(report, provider, {
subject,
rawResult,
analysisProfile: profile,
relationshipCoverage,
locations: [{ kind: "artifact-path", path: input.path }],
});
const controller = new AbortController();
const reason = Object.freeze({ requested: "cancel after capture result" });
const service = new PacketCaptureService({
inspect: async (_input, options?: ExecutionOptions) => {
controller.abort(reason);
expect(options?.signal).toBe(controller.signal);
return ok(execution);
},
inspectFields: async () => {
throw new Error("Unexpected field discovery call");
},
close: async () => {},
});
const result = await service.inspect(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_packet_capture",
raw_result: rawResult,
analysis_profile: profile,
relationship_coverage: relationshipCoverage,
});
});
@@ -3,24 +3,25 @@ import {
packetFieldsCatalogSchema,
type InspectPacketFieldsInput,
} from "../../domain/captures/packetFields.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type {
AnalysisExecution,
ExecutionOptions,
import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
inspectPacketCaptureInputSchema,
packetCaptureSchema,
type InspectPacketCaptureInput,
} from "../../domain/captures/packetCapture.js";
import { err, ok, type Result } from "../../domain/result.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
export interface PacketCapturePort {
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
@@ -74,7 +75,15 @@ export class PacketCaptureService {
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
input.data,
execution,
"observed",
),
options?.signal,
);
}
async inspectFields(
raw: unknown,
@@ -108,33 +117,14 @@ export class PacketCaptureService {
"Field discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
input.data,
execution,
"observed",
),
options?.signal,
);
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: evidence,
}),
)
: ok(evidence);
}
}
@@ -76,14 +76,15 @@ describe("environment manifests from capture_browser_scenario parents", () => {
kind: "inline",
evidence: scenarioCaptureEvidence(scenarioCaptureFixture()),
},
view: { kind: "dart-aot", collection: "package_uris" },
view: { kind: "summary" },
});
if (refused.ok) throw new Error("Expected the unsupported view refusal");
const projected = projectAnalysisError(refused.error);
expect(projected).toMatchObject({ code: "invalid_request" });
expect(JSON.stringify(projected)).toContain(
"supports the summary, browser-scenario, environment-manifest and environment-state views",
);
expect(projectAnalysisError(refused.error)).toMatchObject({
code: "unsupported_target",
message: expect.stringContaining(
"supports only the environment-manifest and environment-state views",
),
});
});
it("serves and exports a scenario-built manifest from the same capture", () => {
@@ -8,6 +8,7 @@ import {
} from "../../domain/firmware/firmwareAnalysis.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
const fixture = (
operation: FirmwareOperation,
@@ -68,15 +69,35 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)(
"retains %s Evidence after late cancellation and preserves provider failure precedence",
async (operation) => {
const { input, execution } = fixture(operation);
const profile = createAnalysisProfile(
{ ...execution.provider, version: "1" },
{
signature_database: "caller-selected snapshot",
},
);
const coverage = {
status: "unknown",
reason: "No call graph examined",
} as const;
const observation = {
...execution,
analysisProfile: profile,
relationshipCoverage: coverage,
};
const control = await new FirmwareAnalysisService({
execute: async () => ok(execution),
execute: async () => ok(observation),
}).execute(operation, input);
if (!control.ok) throw control.error;
expect(control.value).toMatchObject({
raw_result: { source: "controlled producer" },
analysis_profile: profile,
relationship_coverage: coverage,
});
const controller = new AbortController();
const result = await new FirmwareAnalysisService({
execute: async () => {
controller.abort();
return ok(execution);
return ok(observation);
},
}).execute(operation, input, { signal: controller.signal });
expect(result).toMatchObject({
@@ -1,4 +1,7 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
createAnalysisExecutionEvidence,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
import {
firmwareRequestSchema,
@@ -12,8 +15,7 @@ import {
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import type { Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
@@ -71,15 +73,12 @@ export class FirmwareAnalysisService {
),
);
return completeProviderEvidence(
createEvidence(execution.subject, execution.provider, {
createAnalysisExecutionEvidence(
operation,
parameters: jsonObjectSchema.parse(parsed.data.input),
result: execution.result,
rawResult: execution.rawResult,
locations: execution.locations,
limitations: execution.limitations,
confidence: "observed",
}),
parsed.data.input,
execution,
"observed",
),
options?.signal,
);
}
@@ -0,0 +1,96 @@
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import { SignalRecordingService } from "./SignalRecordingService.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { decodeSignalRecordingInputSchema } from "../../domain/signals/signalRecording.js";
import { ok } from "../../domain/result.js";
it("retains provider metadata in a late-cancellation observation", async () => {
const input = decodeSignalRecordingInputSchema.parse({
path: process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd",
decoders: "uart",
});
const provider = {
id: "fixture-signal",
name: "Fixture signal provider",
version: "1",
} as const;
const subject = {
path: input.path,
sha256: "a".repeat(64),
format: "file",
} as const;
const report = {
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
selection: input,
annotations_text: "10-20 uart-1: 42\n",
output_window: { total_lines: 1, returned_lines: 1, has_more: false },
diagnostics: "",
tool: {
configured_path: "/fixture/sigrok-cli",
path: "/fixture/sigrok-cli",
sha256: "b".repeat(64),
version_banner: "sigrok-cli fixture",
version_diagnostics: "",
arguments: [],
},
resource_limiter: {
configured_path: "/fixture/prlimit",
path: "/fixture/prlimit",
sha256: "c".repeat(64),
arguments: [],
},
limits: {
input_bytes: 1,
output_bytes: 1,
command_timeout_ms: 1,
address_space_bytes: 0,
file_bytes: 0,
cpu_seconds: 0,
},
hardware_acquisition: "not-performed",
} as const;
const rawResult = { producer: "fixture", bytes: 12 };
const profile = createAnalysisProfile(provider, { decoder: "uart" });
const relationshipCoverage = {
status: "unknown",
reason: "Recording relationships are not reported.",
} as const;
const execution = createAnalysisExecution(report, provider, {
subject,
rawResult,
analysisProfile: profile,
relationshipCoverage,
locations: [{ kind: "artifact-path", path: input.path }],
});
const controller = new AbortController();
const reason = Object.freeze({ requested: "cancel after recording result" });
const service = new SignalRecordingService({
decode: async (_input, options?: ExecutionOptions) => {
controller.abort(reason);
expect(options?.signal).toBe(controller.signal);
return ok(execution);
},
inspectDecoders: async () => {
throw new Error("Unexpected decoder discovery call");
},
close: async () => {},
});
const result = await service.decode(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
operation: "decode_signal_recording",
raw_result: rawResult,
analysis_profile: profile,
relationship_coverage: relationshipCoverage,
});
});
@@ -3,24 +3,25 @@ import {
signalDecodersSchema,
type InspectSignalDecodersInput,
} from "../../domain/signals/signalDecoders.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type {
AnalysisExecution,
ExecutionOptions,
import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
decodeSignalRecordingInputSchema,
signalRecordingSchema,
type DecodeSignalRecordingInput,
} from "../../domain/signals/signalRecording.js";
import { err, ok, type Result } from "../../domain/result.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
export interface SignalRecordingPort {
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
@@ -74,7 +75,15 @@ export class SignalRecordingService {
"Decoder returned malformed data or changed the selected recording identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
input.data,
execution,
"observed",
),
options?.signal,
);
}
async inspectDecoders(
raw: unknown,
@@ -108,33 +117,14 @@ export class SignalRecordingService {
"Decoder discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
input.data,
execution,
"observed",
),
options?.signal,
);
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: evidence,
}),
)
: ok(evidence);
}
}
@@ -5,6 +5,7 @@ import {
wasmArtifactExecution,
} from "../../../tests/fixtures/wasm/artifact.js";
import { ok } from "../../domain/result.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
it.each([
{ path: "relative" },
{ path: "/module", glue_paths: ["relative"] },
@@ -119,30 +120,54 @@ it("cancels before provider work and delivers observed Evidence on success", asy
});
});
it("retains completed Evidence when cancellation arrives after provider return", async () => {
const value = wasmArtifactFixture();
const controller = new AbortController();
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async () => {
controller.abort();
return ok(wasmArtifactExecution(value));
},
});
it.each([false, true])(
"retains provider provenance after completion (late cancellation: %s)",
async (cancel) => {
const value = wasmArtifactFixture();
const execution = wasmArtifactExecution(value);
const profile = createAnalysisProfile(
{ ...execution.provider, version: "1" },
{
features: "selected",
},
);
const coverage = {
status: "unknown",
reason: "No call graph examined",
} as const;
const controller = new AbortController();
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async () => {
if (cancel) controller.abort();
return ok({
...execution,
rawResult: { stdout: "retained command output" },
analysisProfile: profile,
relationshipCoverage: coverage,
});
},
});
expect(
await service.inspect(
const result = await service.inspect(
{ path: value.artifact.path },
{ signal: controller.signal },
),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
partialObservation: { normalized_result: value },
},
});
});
);
if (cancel)
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
else expect(result.ok).toBe(true);
const evidence = result.ok ? result.value : result.error.partialObservation;
expect(evidence).toMatchObject({
normalized_result: value,
raw_result: { stdout: "retained command output" },
analysis_profile: profile,
relationship_coverage: coverage,
});
},
);
it("preserves malformed-output failure when cancellation arrives with it", async () => {
const value = wasmArtifactFixture();
+11 -13
View File
@@ -1,11 +1,14 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
createAnalysisExecutionEvidence,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { WasmArtifactPort } from "./WasmArtifactPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
@@ -69,17 +72,12 @@ export class WasmArtifactService {
),
);
return completeProviderEvidence(
createEvidence(execution.subject, execution.provider, {
operation: OPERATION,
parameters: input.data,
result: report.data,
...(execution.rawResult === null
? {}
: { rawResult: execution.rawResult }),
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
}),
createAnalysisExecutionEvidence(
OPERATION,
input.data,
{ ...execution, result: report.data },
"observed",
),
options?.signal,
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerState.js";
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
+1 -1
View File
@@ -3,7 +3,7 @@ import { access } from "node:fs/promises";
import { expect, it } from "vitest";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
+1 -1
View File
@@ -31,7 +31,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
import { err, ok, type Result } from "../domain/result.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
@@ -18,7 +18,7 @@ vi.mock("node:v8", async (importOriginal) => {
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
it("refuses before spawning or reading source when no host result capacity remains", async () => {
@@ -47,7 +47,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
});
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline";
import { expect, it } from "vitest";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleWorkerMessageSchema,
type WebBundleWorkerMessage,
+1 -1
View File
@@ -7,7 +7,7 @@ import { withArtifactFileHandle } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { jsonParts } from "../domain/jsonSerialization.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
+2 -23
View File
@@ -3,11 +3,6 @@ import { z } from "zod";
import { captureBrowserScenario } from "./application/BrowserScenarioCaptureService.js";
import { withCommandCancellation } from "./cli/commandCancellation.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
publishCliAnalysisEvidence,
} from "./cli/analysisEvidenceDelivery.js";
import { CLI_COMMANDS } from "./cliCommandNames.js";
import { parseCliJsonInput, resolveCliJsonPaths } from "./cliJsonInput.js";
import { logCliCommand } from "./cliLogging.js";
@@ -21,7 +16,6 @@ import type { JsonValue } from "./domain/jsonValue.js";
import type { Logger } from "pino";
const OPERATION = "capture_browser_scenario";
const PROVIDER_ID = "rea-playwright-browser-scenario";
/** Register the one-shot controlled browser scenario command. */
export const registerBrowserScenarioCommands = (
@@ -42,17 +36,9 @@ export const registerBrowserScenarioCommands = (
.string()
.describe("Inline browser scenario JSON or JSON file path"),
}),
options: z.object(analysisEvidenceDeliveryOptions),
run: ({ args, options: delivery }) =>
run: ({ args }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.captureBrowserScenario, async () => {
const deliveryError = cliEvidenceDeliveryInputError({
detail: delivery.detail,
evidenceOutput: delivery.evidence_output,
operation: OPERATION,
});
// Refuse undeliverable summaries before launching a browser.
if (deliveryError !== undefined) return cliError(deliveryError);
const input = await parseCliJsonInput(args.inputJson, OPERATION);
if (!input.ok) return input.error;
const scenario = browserScenarioSchema.safeParse(
@@ -82,6 +68,7 @@ export const registerBrowserScenarioCommands = (
result = await captureBrowserScenario(provider, scenario.data, {
signal,
});
return result.ok ? result.value : cliError(result.error);
} finally {
try {
await provider?.close?.();
@@ -110,14 +97,6 @@ export const registerBrowserScenarioCommands = (
});
}
}
if (!result.ok) return cliError(result.error);
return publishCliAnalysisEvidence(result.value, {
providerId: PROVIDER_ID,
operation: OPERATION,
signal,
detail: delivery.detail,
evidenceOutput: delivery.evidence_output,
});
}),
),
});
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects, inspect_web_network_capture or capture_browser_scenario Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. capture_browser_scenario parents also support view {kind: "summary"} (step outcomes, focus accessibility tree, event and network counts) and view {kind: "browser-scenario", collection: "steps" | "events", offset: 0, limit: 64}: stLine truncated
'Inspect selected views of completed Frida session status, GDB/objdump command observations, Go build metadata, Rust embedded dependencies, binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata, inspect_model_artifact, inspect_gpu_code_objects or inspect_web_network_capture Evidence without repeating analysis. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete EvidenceLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+4 -13
View File
@@ -1,10 +1,6 @@
import type { ToolContract } from "./toolContractTypes.js";
import { toolContractMetadata } from "./toolEffects.js";
import {
analysisDetailOutputSchemaOf,
analysisDetailSchema,
} from "./analysisDetail.js";
import { browserScenarioSummaryViewResultSchema } from "../domain/analysisView/analysisView.js";
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
import { browserScenarioInputSchema } from "../domain/browserScenario.js";
import { browserScenarioCaptureSchema } from "../domain/browserScenarioCapture.js";
import type { JsonValue } from "../domain/jsonValue.js";
@@ -30,15 +26,10 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
name: "capture_browser_scenario",
...toolContractMetadata("capture_browser_scenario"),
description:
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Default detail complete returns the capture inline. Detail summary retains it and returns per-step status, errors and URLs, the final or first failing step's accessibility tree, and event and network counts; page exact steps (with selected artifacts) or events through inspect_analysis_view kind browser-scenario.",
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
kind: "browser-provider",
inputSchema: browserScenarioInputSchema.extend({
detail: analysisDetailSchema,
}),
outputSchema: analysisDetailOutputSchemaOf(
browserScenarioCaptureSchema,
browserScenarioSummaryViewResultSchema,
),
inputSchema: browserScenarioInputSchema,
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
examples: [
{ title: "Capture a deterministic browser scenario", input: example },
],
+3 -26
View File
@@ -167,13 +167,6 @@ import {
interfaceBuilderViewRequestSchema,
projectInterfaceBuilderView,
} from "./interfaceBuilderView.js";
import {
browserScenarioSummarySchema,
browserScenarioViewContextSchema,
browserScenarioViewItemsSchema,
browserScenarioViewRequestSchema,
projectBrowserScenarioView,
} from "./browserScenarioView.js";
import {
projectWebNetworkCaptureView,
captureViewRequestSchema,
@@ -329,7 +322,6 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
environmentManifestViewRequestSchema,
environmentStateViewRequestSchema,
captureViewRequestSchema,
browserScenarioViewRequestSchema,
protobufViewRequestSchema,
qlogViewRequestSchema,
pcapViewRequestSchema,
@@ -612,13 +604,6 @@ export const webNetworkCaptureSummaryViewResultSchema = z.strictObject({
summary: captureSummarySchema,
});
/** Step outcomes, focus accessibility tree and event counts for selected scenario delivery. */
export const browserScenarioSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("capture_browser_scenario"),
summary: browserScenarioSummarySchema,
});
/** Exact package declarations with retained decoded manifest text. */
export const androidPackageSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
@@ -769,7 +754,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
managedArtifactSummaryViewResultSchema,
managedMemberSummaryViewResultSchema,
webNetworkCaptureSummaryViewResultSchema,
browserScenarioSummaryViewResultSchema,
androidPackageSummaryViewResultSchema,
pcapCaptureSummaryViewResultSchema,
dartAotSummaryViewResultSchema,
@@ -804,14 +788,6 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
...viewResultBase,
parent_operation: z.literal("inspect_web_network_capture"),
}),
z.strictObject({
kind: z.literal("browser-scenario"),
view: browserScenarioViewRequestSchema,
items: browserScenarioViewItemsSchema,
...viewResultBase,
...browserScenarioViewContextSchema.shape,
parent_operation: z.literal("capture_browser_scenario"),
}),
z.strictObject({
kind: z.literal("qlog"),
view: qlogViewRequestSchema,
@@ -1028,7 +1004,9 @@ const unsupportedParent = (
new AnalysisUnsupportedTargetError(
INSPECT_ANALYSIS_VIEW_OPERATION,
parentArtifactPath(parent),
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, web network capture or browser scenario Evidence.`,
parent.operation === "capture_browser_scenario"
? `Parent Evidence operation "capture_browser_scenario" supports only the environment-manifest and environment-state views.`
: `Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function/inventory, artifact inventory/inspection, WASM, Mach-O, Interface Builder, EVM, managed artifact or member, Android package, Dart AOT, IL2CPP metadata, or web network capture Evidence.`,
);
const parentArtifactPath = (parent: AnalysisViewParent): string => {
@@ -1070,7 +1048,6 @@ const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
["inspect_managed_artifact", projectManagedArtifactView],
["inspect_managed_members", projectManagedMemberView],
["inspect_android_package", projectAndroidPackageView],
["capture_browser_scenario", projectBrowserScenarioView],
["inspect_dart_aot", projectDartAotView],
["inspect_il2cpp_metadata", projectIl2cppMetadataView],
["inspect_model_artifact", projectModelArtifactView],
@@ -1,502 +0,0 @@
import { z } from "zod";
import type { AnalysisError } from "../analysisErrorBase.js";
import { sanitizedBrowserUrlSchema } from "../browserObservation.js";
import {
browserScenarioCaptureSchema,
type BrowserScenarioCapture,
} from "../browserScenarioCapture.js";
import {
browserScenarioCompletenessSchema,
browserScenarioEventKindSchema,
browserScenarioEventSchema,
browserStepArtifactsSchema,
type BrowserScenarioStep,
} from "../browserScenarioCaptureValues.js";
import { compareAsciiStrings } from "../canonicalOrdering.js";
import { err, ok, type Result } from "../result.js";
import { analysisViewInputError } from "./analysisViewErrors.js";
import {
completeWithinViewCoverage,
pageViewCoverage,
} from "./analysisViewCoverage.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
import type {
AnalysisViewCoverage,
AnalysisViewParent,
AnalysisViewRequest,
UnsignedAnalysisView,
} from "./analysisView.js";
const OPERATION = "capture_browser_scenario" as const;
const ARTIFACT_SECTIONS = browserStepArtifactsSchema.keyof().options;
const artifactSectionSchema = browserStepArtifactsSchema.keyof();
type ArtifactSection = z.infer<typeof artifactSectionSchema>;
type Step = BrowserScenarioStep;
const countSchema = z.number().int().nonnegative();
const statusClassSchema = z.enum([
"1xx",
"2xx",
"3xx",
"4xx",
"5xx",
"failed",
"unknown",
]);
type StatusClass = z.infer<typeof statusClassSchema>;
/** Section names by recorded state; not_requested sections are omitted. */
const stepSectionsSchema = z.strictObject({
captured: z.array(artifactSectionSchema),
missing: z.array(artifactSectionSchema),
truncated: z.array(artifactSectionSchema),
});
const summaryStepSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema
.exactOptional()
.describe(
"Present only when it differs from the previous step's after_url (always for step 0).",
),
after_url: sanitizedBrowserUrlSchema,
events: z.strictObject({
sequence_start: z.number().int().min(1),
sequence_end: countSchema,
}),
sections: stepSectionsSchema,
completeness: z.enum(["complete", "incomplete", "truncated"]),
});
const focusAccessibilitySchema = z.strictObject({
focus_step_index: countSchema,
focus: z.enum(["first-unsuccessful-step", "last-step"]),
/** The step whose tree is returned: the focus step, or the nearest earlier step that captured one. */
step_index: countSchema.nullable(),
accessibility: browserStepArtifactsSchema.shape.accessibility,
});
/** Compact capture overview; complete records stay in the retained parent. */
export const browserScenarioSummarySchema = z.strictObject({
browser: browserScenarioCaptureSchema.shape.browser,
scenario: browserScenarioCaptureSchema.shape.scenario,
duration_ms: countSchema,
completeness: browserScenarioCompletenessSchema,
steps: z.array(summaryStepSchema),
final_accessibility: focusAccessibilitySchema,
events: z.strictObject({
retained: countSchema,
dropped: countSchema,
by_kind: z.array(
z.strictObject({
kind: browserScenarioEventKindSchema,
count: countSchema,
}),
),
omissions: z.strictObject({
by_kind:
browserScenarioCaptureSchema.shape.events.shape.omissions.shape.by_kind,
network_transactions: countSchema,
network_transaction_range:
browserScenarioCaptureSchema.shape.events.shape.omissions.shape
.network_transaction_range,
}),
}),
network: z.strictObject({
transactions: countSchema,
by_origin: z.array(
z.strictObject({
origin: z.string().nullable(),
transactions: countSchema,
}),
),
by_resource_type: z.array(
z.strictObject({
resource_type: z.string().min(1),
transactions: countSchema,
}),
),
by_status_class: z
.array(
z.strictObject({
status_class: statusClassSchema,
transactions: countSchema,
}),
)
.describe(
"Final retained outcome per transaction: the first response status class, failed, or unknown when neither was retained.",
),
}),
limitation_count: countSchema,
});
const indicesSchema = z.array(countSchema);
/** Pages of exact retained steps or events, in original order. */
export const browserScenarioViewRequestSchema = z.strictObject({
kind: z.literal("browser-scenario"),
collection: z.enum(["steps", "events"]),
step_indices: indicesSchema
.optional()
.describe("Exact step indices; omitted selects all, [] selects none."),
artifacts: z
.array(artifactSectionSchema)
.default([])
.describe(
"Steps only: captured sections returned in full. Other recorded sections are listed in omitted_artifacts with their state.",
),
event_kinds: z
.array(browserScenarioEventKindSchema)
.optional()
.describe(
"Events only: exact kinds; omitted selects all, [] selects none.",
),
transaction_ids: z
.array(z.string().min(1))
.optional()
.describe(
"Events only: exact network transaction IDs; omitted selects all, [] selects none. Events without a transaction never match a transaction selection.",
),
offset: countSchema.default(0),
limit: z.number().int().positive().default(64),
});
export type BrowserScenarioViewRequest = z.output<
typeof browserScenarioViewRequestSchema
>;
/** One exact step whose unselected captured sections are named rather than returned. */
const stepRowSchema = z.strictObject({
step_index: countSchema,
step_id: z.string().min(1),
action: z.string().min(1),
status: z.enum(["completed", "failed", "cancelled"]),
error: z.string().nullable(),
elapsed_ms: countSchema,
before_url: sanitizedBrowserUrlSchema,
after_url: sanitizedBrowserUrlSchema,
event_sequence_start: z.number().int().min(1),
event_sequence_end: countSchema,
completeness: browserScenarioCompletenessSchema,
artifacts: browserStepArtifactsSchema.partial(),
omitted_artifacts: z.array(
z.strictObject({
section: artifactSectionSchema,
state: z.enum(["captured", "missing", "truncated"]),
}),
),
});
const eventRowSchema = z.strictObject({
item_index: countSchema,
event: browserScenarioEventSchema,
});
export const browserScenarioViewItemsSchema = z.discriminatedUnion(
"collection",
[
z.strictObject({
collection: z.literal("steps"),
rows: z.array(stepRowSchema),
}),
z.strictObject({
collection: z.literal("events"),
rows: z.array(eventRowSchema),
}),
],
);
/** Capture-wide facts every page repeats so exhaustion is not read as completeness. */
export const browserScenarioViewContextSchema = z.strictObject({
source_completeness: browserScenarioCompletenessSchema,
source_events: z.strictObject({
retained: countSchema,
dropped: countSchema,
}),
});
const unsupportedView = (): AnalysisError =>
analysisViewInputError([
{
path: ["view"],
reason: "invalid_value",
message:
'Parent Evidence operation "capture_browser_scenario" supports the summary, browser-scenario, environment-manifest and environment-state views.',
},
]);
const sameUrl = (
left: Step["after_url"] | undefined,
right: Step["before_url"],
): boolean =>
left !== undefined &&
left.url === right.url &&
left.redacted === right.redacted;
const stepSections = (step: Step): z.infer<typeof stepSectionsSchema> => {
const sections: z.infer<typeof stepSectionsSchema> = {
captured: [],
missing: [],
truncated: [],
};
for (const section of ARTIFACT_SECTIONS) {
const { state } = step.artifacts[section];
if (state !== "not_requested") sections[state].push(section);
}
return sections;
};
const summaryStep = (
step: Step,
previous: Step | undefined,
): z.infer<typeof summaryStepSchema> => ({
step_index: step.step_index,
step_id: step.step_id,
action: step.action,
status: step.status,
error: step.error,
elapsed_ms: step.elapsed_ms,
...(sameUrl(previous?.after_url, step.before_url)
? {}
: { before_url: step.before_url }),
after_url: step.after_url,
events: {
sequence_start: step.event_sequence_start,
sequence_end: step.event_sequence_end,
},
sections: stepSections(step),
completeness: step.completeness.status,
});
/** The first unsuccessful step explains a failure; otherwise the final screen does. */
const focusAccessibility = (
steps: readonly Step[],
): z.infer<typeof focusAccessibilitySchema> => {
const unsuccessful = steps.findIndex(({ status }) => status !== "completed");
const focusIndex = unsuccessful === -1 ? steps.length - 1 : unsuccessful;
const focus = steps[focusIndex];
if (focus === undefined) throw new Error("Capture has no steps");
const kind =
unsuccessful === -1 ? ("last-step" as const) : "first-unsuccessful-step";
for (let index = focusIndex; index >= 0; index -= 1) {
const accessibility = steps[index]?.artifacts.accessibility;
if (accessibility?.state === "captured")
return {
focus_step_index: focusIndex,
focus: kind,
step_index: index,
accessibility,
};
}
return {
focus_step_index: focusIndex,
focus: kind,
step_index: null,
accessibility: focus.artifacts.accessibility,
};
};
/** Counts in canonical key order; a null origin sorts first. */
const sortedCounts = <Key extends string | null>(
counts: ReadonlyMap<Key, number>,
): [Key, number][] =>
[...counts].sort(([left], [right]) =>
left === null ? -1 : right === null ? 1 : compareAsciiStrings(left, right),
);
const increment = <Key>(counts: Map<Key, number>, key: Key): void => {
counts.set(key, (counts.get(key) ?? 0) + 1);
};
/** Count retained network transactions once each, from their first retained request event. */
const networkCounts = (
capture: BrowserScenarioCapture,
): z.infer<typeof browserScenarioSummarySchema>["network"] => {
const origins = new Map<string | null, number>();
const resourceTypes = new Map<string, number>();
const outcomes = new Map<string, StatusClass>();
for (const event of capture.events.items) {
if (event.kind === "request") {
if (outcomes.has(event.transaction_id)) continue;
outcomes.set(event.transaction_id, "unknown");
increment(origins, event.url.origin);
increment(resourceTypes, event.resource_type);
} else if (event.kind === "response") {
if (outcomes.get(event.transaction_id) === "unknown")
outcomes.set(
event.transaction_id,
`${String(Math.floor(event.status / 100))}xx` as StatusClass,
);
} else if (event.kind === "request-failed") {
if (outcomes.get(event.transaction_id) === "unknown")
outcomes.set(event.transaction_id, "failed");
}
}
const statusClasses = new Map<StatusClass, number>();
for (const outcome of outcomes.values()) increment(statusClasses, outcome);
return {
transactions: outcomes.size,
by_origin: sortedCounts(origins).map(([origin, transactions]) => ({
origin,
transactions,
})),
by_resource_type: sortedCounts(resourceTypes).map(
([resource_type, transactions]) => ({ resource_type, transactions }),
),
by_status_class: sortedCounts(statusClasses).map(
([status_class, transactions]) => ({ status_class, transactions }),
),
};
};
const eventCounts = (
capture: BrowserScenarioCapture,
): z.infer<typeof browserScenarioSummarySchema>["events"] => {
const kinds = new Map<
BrowserScenarioCapture["events"]["items"][number]["kind"],
number
>();
for (const event of capture.events.items) increment(kinds, event.kind);
const { omissions } = capture.events;
return {
retained: capture.events.retained,
dropped: capture.events.dropped,
by_kind: sortedCounts(kinds).map(([kind, count]) => ({ kind, count })),
omissions: {
by_kind: omissions.by_kind,
network_transactions: omissions.network_transactions.length,
network_transaction_range: omissions.network_transaction_range,
},
};
};
const selected = <Value>(
filter: readonly Value[] | undefined,
): ((value: Value) => boolean) => {
if (filter === undefined) return () => true;
const values = new Set(filter);
return (value) => values.has(value);
};
const stepRow = (
{ artifacts: recorded, ...step }: Step,
selection: ReadonlySet<ArtifactSection>,
): z.infer<typeof stepRowSchema> => {
const artifacts: z.infer<typeof stepRowSchema>["artifacts"] = {};
const omitted: z.infer<typeof stepRowSchema>["omitted_artifacts"] = [];
for (const section of ARTIFACT_SECTIONS) {
const artifact = recorded[section];
if (selection.has(section))
Object.assign(artifacts, { [section]: artifact });
else if (artifact.state !== "not_requested")
omitted.push({ section, state: artifact.state });
}
return { ...step, artifacts, omitted_artifacts: omitted };
};
/** One stable page of matching steps or events, in original order. */
const pageRecords = (
capture: BrowserScenarioCapture,
view: BrowserScenarioViewRequest,
): {
readonly items: z.infer<typeof browserScenarioViewItemsSchema>;
readonly coverage: AnalysisViewCoverage;
} => {
const stepSelected = selected(view.step_indices);
const page = <Row>(rows: readonly Row[]) => {
const returned = rows.slice(view.offset, view.offset + view.limit);
return {
rows: returned,
coverage: pageViewCoverage(view.offset, returned.length, rows.length),
};
};
if (view.collection === "steps") {
const artifacts = new Set(view.artifacts);
const { rows, coverage } = page(
capture.steps.filter(({ step_index }) => stepSelected(step_index)),
);
return {
items: {
collection: "steps",
rows: rows.map((step) => stepRow(step, artifacts)),
},
coverage,
};
}
const kindSelected = selected(view.event_kinds);
const transactionSelected = selected(view.transaction_ids);
const { rows, coverage } = page(
capture.events.items.flatMap((event, item_index) =>
stepSelected(event.step_index) &&
kindSelected(event.kind) &&
(view.transaction_ids === undefined ||
("transaction_id" in event &&
transactionSelected(event.transaction_id)))
? [{ item_index, event }]
: [],
),
);
return { items: { collection: "events", rows }, coverage };
};
/** Project retained scenario Evidence without relaunching the browser or replaying actions. */
export const projectBrowserScenarioView = (
parent: AnalysisViewParent,
view: AnalysisViewRequest,
): Result<UnsignedAnalysisView, AnalysisError> => {
if (view.kind !== "summary" && view.kind !== "browser-scenario")
return err(unsupportedView());
const parsed = parseAnalysisViewResult(browserScenarioCaptureSchema, parent);
if (!parsed.ok) return parsed;
const capture = parsed.value;
const shared = {
parent_evidence_id: parent.evidenceId,
parent_operation: OPERATION,
parent_digest: parent.evidenceId.slice(3),
artifact: { path: "", sha256: null },
limitations: [...parent.limitations],
unknowns: [
"Step states record one controlled replay; they do not establish the application's current state.",
...(capture.events.dropped > 0
? [
`${String(capture.events.dropped)} events were dropped by capture budgets; counts and pages cover retained events only.`,
]
: []),
],
};
if (view.kind === "summary")
return ok({
kind: "summary",
view,
...shared,
summary: {
browser: capture.browser,
scenario: capture.scenario,
duration_ms: capture.duration_ms,
completeness: capture.completeness,
steps: capture.steps.map((step, index) =>
summaryStep(step, capture.steps[index - 1]),
),
final_accessibility: focusAccessibility(capture.steps),
events: eventCounts(capture),
network: networkCounts(capture),
limitation_count: capture.limitations.length,
},
coverage: completeWithinViewCoverage(1, 1),
});
return ok({
kind: "browser-scenario",
view,
...shared,
source_completeness: capture.completeness,
source_events: {
retained: capture.events.retained,
dropped: capture.events.dropped,
},
...pageRecords(capture, view),
});
};
@@ -77,14 +77,13 @@ const reconcileEntity = (
reason: "runtime-location-unresolved",
candidates: [],
});
const locations = candidatesWithLocation(entity, index, layers);
const { locations, mapped } = locateEntity(entity, index, layers);
if (entity.sourceSha256 !== null)
return reconcileCapturedSource(
entity,
entity.sourceSha256,
return reconcileCapturedSource(entity, entity.sourceSha256, {
locations,
mapped,
index,
);
});
const location = locations.at(0);
if (locations.length === 1 && location !== undefined) {
@@ -103,20 +102,40 @@ const reconcileEntity = (
reason: "ambiguous-static-candidates",
candidates: locations.map(({ candidate }) => candidate),
});
const mapped = layers.some(
(layer) => mappedRuntimePaths(layer, entity).length > 0,
);
return unresolved(entity, {
status: mapped ? "unmatched" : "unknown",
basis: "none",
confidence: "unknown",
reason: mapped
? "no-static-content-match"
: "no-authorized-location-mapping",
candidates: [],
});
return withoutStaticCandidate(entity, mapped, "unknown");
};
/**
* Classify an entity with no qualifying static candidate. Absence is claimed
* only beneath an automatic or caller-declared mapping; without one, the
* runtime location has no authorized static counterpart and stays unknown.
* Captured bytes compared against every static digest support a high-confidence
* absence; a location-only absence does not.
*/
const withoutStaticCandidate = (
entity: RuntimeReconciliationEntity,
mapped: boolean,
absenceConfidence: JavaScriptRuntimeReconciliationItem["confidence"],
): EvaluatedMatch =>
unresolved(
entity,
mapped
? {
status: "unmatched",
basis: "none",
confidence: absenceConfidence,
reason: "no-static-content-match",
candidates: [],
}
: {
status: "unknown",
basis: "none",
confidence: "unknown",
reason: "no-authorized-location-mapping",
candidates: [],
},
);
interface LocatedCandidate {
readonly candidate: StaticRuntimeCandidate;
readonly mapping: MappedRuntimePath;
@@ -125,8 +144,15 @@ interface LocatedCandidate {
const reconcileCapturedSource = (
entity: RuntimeReconciliationEntity,
sourceSha256: string,
locations: readonly LocatedCandidate[],
index: CandidateIndex,
{
locations,
mapped,
index,
}: {
readonly locations: readonly LocatedCandidate[];
readonly mapped: boolean;
readonly index: CandidateIndex;
},
): EvaluatedMatch => {
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
// repair a disagreement or establish the content of a candidate without a digest.
@@ -176,23 +202,19 @@ const reconcileCapturedSource = (
candidates: locations.map(({ candidate: located }) => located),
});
}
return unresolved(entity, {
status: "unmatched",
basis: "none",
confidence: "high",
reason: "no-static-content-match",
candidates: [],
});
return withoutStaticCandidate(entity, mapped, "high");
};
const candidatesWithLocation = (
const locateEntity = (
entity: RuntimeReconciliationEntity,
index: CandidateIndex,
layers: readonly ParsedStaticLayer[],
): LocatedCandidate[] => {
): { readonly locations: LocatedCandidate[]; readonly mapped: boolean } => {
const located = new Map<string, LocatedCandidate>();
let mapped = false;
for (const layer of layers)
for (const mapping of mappedRuntimePaths(layer, entity))
for (const mapping of mappedRuntimePaths(layer, entity)) {
mapped = true;
for (const candidate of [
...(index.byLayerCategoryPath.get(
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
@@ -215,12 +237,16 @@ const candidatesWithLocation = (
const key = candidateKey(candidate);
if (!located.has(key)) located.set(key, { candidate, mapping });
}
return [...located.values()].sort((left, right) =>
compareUnicodeCodePoints(
candidateKey(left.candidate),
candidateKey(right.candidate),
}
return {
locations: [...located.values()].sort((left, right) =>
compareUnicodeCodePoints(
candidateKey(left.candidate),
candidateKey(right.candidate),
),
),
);
mapped,
};
};
const indexCandidates = (
+5 -12
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { evidenceSchema, type Evidence } from "../evidence.js";
@@ -20,7 +17,7 @@ import {
type ManagedMemberInspection,
type ManagedNativeBoundaryInspection,
} from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
assessManagedGraphOmissions,
managedGraphEvidenceCoverage,
@@ -120,13 +117,6 @@ export interface GraphBuildState {
readonly fieldNodes: Map<string, ApplicationNode>;
}
const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed application graph canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
/** Project authenticated managed Evidence records into the application graph. */
export const projectManagedApplicationGraph = (
input: ProjectManagedApplicationGraphInput,
@@ -194,7 +184,10 @@ export const projectManagedApplicationGraph = (
};
return managedApplicationGraphResultSchema.parse({
...withoutId,
projection_id: `magp_${sha256(withoutId)}`,
projection_id: `magp_${digestCanonicalValue(
withoutId,
"Managed application graph",
)}`,
});
};
+33 -32
View File
@@ -1,7 +1,8 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
/** Build-local ECMA-335 metadata token emitted as an eight-digit lowercase hex value. */
export const managedMetadataTokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const offsetSchema = z.number().int().min(0);
/** A 16-byte CLI metadata GUID without RFC 4122 version/variant restrictions. */
@@ -26,7 +27,7 @@ const assemblyIdentitySchema = z.object({
flags: z.number().int().min(0).max(0xffff_ffff),
hash_algorithm: z.number().int().min(0).max(0xffff_ffff),
public_key: publicKeyIdentitySchema,
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -36,7 +37,7 @@ const moduleIdentitySchema = z.object({
mvid: cliMetadataGuidSchema.nullable(),
enc_id: cliMetadataGuidSchema.nullable(),
enc_base_id: cliMetadataGuidSchema.nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -70,7 +71,7 @@ const assemblyReferenceSchema = z.object({
public_key_or_token: publicKeyIdentitySchema,
hash_value_sha256: digestSchema.nullable(),
hash_value_length: z.number().int().min(0),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -78,23 +79,23 @@ const managedResourceSchema = z.object({
name: z.string(),
flags: z.number().int().min(0).max(0xffff_ffff),
visibility: z.enum(["public", "private", "unknown"]),
implementation_token: tokenSchema.nullable(),
implementation_token: managedMetadataTokenSchema.nullable(),
embedded: z.boolean(),
declared_offset: z.number().int().min(0).max(0xffff_ffff),
data_length: z.number().int().min(0).nullable(),
data_sha256: digestSchema.nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
const customAttributeSchema = z.object({
parent_token: tokenSchema,
constructor_token: tokenSchema.nullable(),
parent_token: managedMetadataTokenSchema,
constructor_token: managedMetadataTokenSchema.nullable(),
type_name: z.string().nullable(),
value_length: z.number().int().min(0),
value_sha256: digestSchema,
decoded_fixed_string: z.string().nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -120,7 +121,7 @@ const managedSignatureSchema = z.object({
});
const managedTypeSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
namespace: z.string(),
name: z.string(),
@@ -132,8 +133,8 @@ const managedTypeSchema = z.object({
"Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.",
),
flags: z.number().int().min(0).max(0xffff_ffff),
extends_token: tokenSchema.nullable(),
enclosing_type_token: tokenSchema
extends_token: managedMetadataTokenSchema.nullable(),
enclosing_type_token: managedMetadataTokenSchema
.nullable()
.default(null)
.describe(
@@ -153,20 +154,20 @@ const managedTypeSchema = z.object({
/** One ExportedType row: a type this assembly exports from another module or forwards. */
const managedExportedTypeSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
namespace: z.string(),
name: z.string(),
flags: z.number().int().min(0).max(0xffff_ffff),
forwarder: z.boolean(),
type_def_id: z.number().int().min(0).max(0xffff_ffff),
implementation_token: tokenSchema.nullable(),
implementation_token: managedMetadataTokenSchema.nullable(),
});
const managedFieldSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
declaring_type_token: tokenSchema.nullable(),
declaring_type_token: managedMetadataTokenSchema.nullable(),
declaring_type: z.string().nullable(),
name: z.string(),
flags: z.number().int().min(0).max(0xffff),
@@ -179,7 +180,7 @@ const managedExceptionRegionSchema = z.object({
try_length: z.number().int().min(0),
handler_offset: z.number().int().min(0),
handler_length: z.number().int().min(0),
class_token: tokenSchema.nullable(),
class_token: managedMetadataTokenSchema.nullable(),
filter_offset: z.number().int().min(0).nullable(),
});
@@ -210,7 +211,7 @@ const managedMethodBodySchema = z.object({
file_offset: offsetSchema.nullable(),
max_stack: z.number().int().min(0).nullable(),
init_locals: z.boolean().nullable(),
local_var_sig_token: tokenSchema.nullable(),
local_var_sig_token: managedMetadataTokenSchema.nullable(),
il_size: z.number().int().min(0),
il_sha256: digestSchema
.nullable()
@@ -232,9 +233,9 @@ const managedMethodBodySchema = z.object({
});
const managedMethodSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
declaring_type_token: tokenSchema.nullable(),
declaring_type_token: managedMetadataTokenSchema.nullable(),
declaring_type: z.string().nullable(),
name: z.string(),
rva: z.number().int().min(0).max(0xffff_ffff),
@@ -250,9 +251,9 @@ const managedMethodSchema = z.object({
const declaringTypeNameSchema = z.string().nullable().default(null);
const managedMemberReferenceSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
parent_token: tokenSchema.nullable(),
parent_token: managedMetadataTokenSchema.nullable(),
parent_name: declaringTypeNameSchema.describe(
"Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown",
),
@@ -261,7 +262,7 @@ const managedMemberReferenceSchema = z.object({
});
const managedUserStringSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
offset: offsetSchema.describe("File offset of the #US item"),
value: z
.string()
@@ -271,21 +272,21 @@ const managedUserStringSchema = z.object({
});
const managedModuleReferenceSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
name: z.string(),
});
const managedNativeImportSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
mapping_flags: z.number().int().min(0).max(0xffff),
mapping_flags_hex: z.string().regex(/^0x[0-9a-f]{4}$/u),
member_token: tokenSchema.nullable(),
member_token: managedMetadataTokenSchema.nullable(),
member_kind: z.enum(["method", "field", "unknown"]),
member_name: z.string().nullable(),
import_name: z.string(),
import_scope_token: tokenSchema.nullable(),
import_scope_token: managedMetadataTokenSchema.nullable(),
import_scope_name: z.string().nullable(),
no_mangle: z.boolean(),
char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]),
@@ -310,7 +311,7 @@ const managedNativeImportSchema = z.object({
});
const managedNativeImplementationSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
name: z.string(),
rva: z.number().int().min(0).max(0xffff_ffff),
@@ -335,10 +336,10 @@ const managedNativeImplementationSchema = z.object({
});
const managedCallEdgeSchema = z.object({
caller_token: tokenSchema,
caller_token: managedMetadataTokenSchema,
caller: z.string().nullable(),
opcode: z.string(),
target_token: tokenSchema,
target_token: managedMetadataTokenSchema,
target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]),
target_name: z
.string()
@@ -350,10 +351,10 @@ const managedCallEdgeSchema = z.object({
});
const managedFieldAccessSchema = z.object({
method_token: tokenSchema,
method_token: managedMetadataTokenSchema,
method: z.string().nullable(),
opcode: z.string(),
field_token: tokenSchema,
field_token: managedMetadataTokenSchema,
field_name: z.string().nullable(),
field_declaring_type: declaringTypeNameSchema.describe(
"Declaring type of the accessed field; null when unknown",
+14 -10
View File
@@ -1,9 +1,11 @@
import { z } from "zod";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { emptyArraySchema } from "../emptyArraySchema.js";
import { evidenceSchema } from "../evidence.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
type ManagedMemberInspection,
} from "./managedArtifact.js";
import {
@@ -17,13 +19,12 @@ import {
buildFieldItems,
buildMethodItems,
} from "./managedMemberComparisonItems.js";
import { keyMembers, sha256 } from "./managedMemberComparisonMatch.js";
import { keyMembers } from "./managedMemberComparisonMatch.js";
import { hasSignatureResolution } from "./managedMemberSignatureIdentity.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const boundedTextSchema = z.string().min(1);
/** Two authenticated managed member observations. */
@@ -47,7 +48,7 @@ const concreteMatchBasisSchema = z.enum([
"structural-method-shape",
"field-signature",
]);
const candidateTokensSchema = z.array(tokenSchema).min(1);
const candidateTokensSchema = z.array(managedMetadataTokenSchema).min(1);
const matchedComparisonSchema = z.strictObject({
status: z.literal("matched"),
basis: concreteMatchBasisSchema,
@@ -70,7 +71,7 @@ const ambiguousComparisonSchema = z.strictObject({
candidate_right_tokens: candidateTokensSchema,
});
const memberIdentitySchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
declaring_type: z.string().nullable(),
name: z.string(),
signature_sha256: digestSchema,
@@ -247,12 +248,15 @@ export const compareManagedMembers = (
});
const limitations = comparisonLimitations(left.result, right.result);
const result = {
comparison_id: `mmc_${sha256({
left: left.evidenceId,
right: right.evidenceId,
methods: [...methodItems],
fields: [...fieldItems],
})}`,
comparison_id: `mmc_${digestCanonicalValue(
{
left: left.evidenceId,
right: right.evidenceId,
methods: [...methodItems],
fields: [...fieldItems],
},
"Managed comparison",
)}`,
algorithm: {
name: "rea-managed-member-comparison" as const,
token_identity: "build-local" as const,
@@ -1,11 +1,11 @@
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { ManagedMemberInspection } from "./managedArtifact.js";
import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js";
import {
type ManagedFieldMatches,
type ManagedMethodMatches,
sha256,
import type {
ManagedFieldMatches,
ManagedMethodMatches,
} from "./managedMemberComparisonMatch.js";
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
@@ -180,7 +180,7 @@ const methodOnlyItem = (
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`,
item_id: `mmc_method_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
status: limitations.length === 0 ? "removed" : "unknown",
left: methodIdentity(member.left),
right: null,
@@ -203,7 +203,7 @@ const methodOnlyItem = (
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`,
item_id: `mmc_method_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: methodIdentity(member.right),
@@ -377,7 +377,7 @@ const fieldOnlyItem = (
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`,
item_id: `mmc_field_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
status: limitations.length === 0 ? "removed" : "unknown",
left: fieldIdentity(member.left),
right: null,
@@ -399,7 +399,7 @@ const fieldOnlyItem = (
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`,
item_id: `mmc_field_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: fieldIdentity(member.right),
@@ -448,11 +448,14 @@ export const buildMethodItems = (
: observedDimensions;
const observedChange = observedDimensions.length > 0;
items.push({
item_id: `mmc_method_${sha256({
left: pair.left.item.token,
right: pair.right.item.token,
basis: pair.basis,
})}`,
item_id: `mmc_method_${digestCanonicalValue(
{
left: pair.left.item.token,
right: pair.right.item.token,
basis: pair.basis,
},
"Managed comparison",
)}`,
status: observedChange
? "changed"
: bodyUnknown || signatureLimitations.length > 0
@@ -481,11 +484,14 @@ export const buildMethodItems = (
}
for (const ambiguous of matches.ambiguous) {
items.push({
item_id: `mmc_method_${sha256({
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
basis: ambiguous.basis,
})}`,
item_id: `mmc_method_${digestCanonicalValue(
{
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
basis: ambiguous.basis,
},
"Managed comparison",
)}`,
status: "unknown",
left: null,
right: null,
@@ -551,10 +557,13 @@ export const buildFieldItems = (
signatureIdentity(pair.right.item.signature) ||
pair.left.item.flags !== pair.right.item.flags;
items.push({
item_id: `mmc_field_${sha256({
left: pair.left.item.token,
right: pair.right.item.token,
})}`,
item_id: `mmc_field_${digestCanonicalValue(
{
left: pair.left.item.token,
right: pair.right.item.token,
},
"Managed comparison",
)}`,
status: changed
? "changed"
: signatureLimitations.length > 0
@@ -575,10 +584,13 @@ export const buildFieldItems = (
}
for (const ambiguous of matches.ambiguous)
items.push({
item_id: `mmc_field_${sha256({
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
})}`,
item_id: `mmc_field_${digestCanonicalValue(
{
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
},
"Managed comparison",
)}`,
status: "unknown",
left: null,
right: null,
@@ -1,7 +1,4 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
managedMemberInspectionSchema,
type ManagedMemberInspection,
@@ -15,15 +12,6 @@ import {
comparableSides,
signatureIdentity,
} from "./managedMemberSignatureIdentity.js";
import type { JsonValue } from "../jsonValue.js";
export const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed comparison canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
type Method = ManagedMemberInspection["methods"][number];
type Field = ManagedMemberInspection["fields"][number];
@@ -69,49 +57,58 @@ const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed<Method> => ({
item.body.status === "present" &&
item.body.il_sha256 !== null &&
item.body.normalized_il_sha256 !== null
? sha256([
"method-exact",
signatureIdentity(item.signature),
item.body.il_sha256,
item.body.normalized_il_sha256,
])
? digestCanonicalValue(
[
"method-exact",
signatureIdentity(item.signature),
item.body.il_sha256,
item.body.normalized_il_sha256,
],
"Managed comparison",
)
: null,
// The blob, with type operands named, is the exact signature; decoding
// only gates the shape keys.
signatureKey:
declared(item) === null
? null
: sha256([
"method-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
: digestCanonicalValue(
[
"method-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
],
"Managed comparison",
),
structuralKey:
item.signature.parse_status === "decoded" && item.body.status === "present"
? sha256([
"method-structural",
// Decoded type strings carry build-local rows; the identity names them.
signatureIdentity(item.signature),
item.body.status,
item.body.header_format,
item.body.il_size,
item.body.max_stack,
item.body.init_locals,
item.body.opcode_counts,
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
opcode,
operand_kind,
operand_kind === "constant" ? operand : null,
]),
item.body.exception_regions.map((region) => [
region.flags,
region.try_length,
region.handler_length,
region.class_token === null ? null : "type-token",
region.filter_offset === null ? null : "filter",
]),
])
? digestCanonicalValue(
[
"method-structural",
// Decoded type strings carry build-local rows; the identity names them.
signatureIdentity(item.signature),
item.body.status,
item.body.header_format,
item.body.il_size,
item.body.max_stack,
item.body.init_locals,
item.body.opcode_counts,
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
opcode,
operand_kind,
operand_kind === "constant" ? operand : null,
]),
item.body.exception_regions.map((region) => [
region.flags,
region.try_length,
region.handler_length,
region.class_token === null ? null : "type-token",
region.filter_offset === null ? null : "filter",
]),
],
"Managed comparison",
)
: null,
});
@@ -122,25 +119,34 @@ const keyField = (item: Field, declared: DeclaredNameOf): Keyed<Field> => ({
signatureId: signatureIdentity(item.signature),
exactKey:
item.signature.parse_status === "decoded"
? sha256(["field-exact", signatureIdentity(item.signature)])
? digestCanonicalValue(
["field-exact", signatureIdentity(item.signature)],
"Managed comparison",
)
: null,
// Only an undecoded field reaches this round: a decoded field with the
// same signature already met its counterpart in the exact round.
signatureKey:
declared(item) === null
? null
: sha256([
"field-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
structuralKey: sha256([
"field-structural",
item.signature.kind,
item.signature.field_type,
item.flags,
]),
: digestCanonicalValue(
[
"field-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
],
"Managed comparison",
),
structuralKey: digestCanonicalValue(
[
"field-structural",
item.signature.kind,
item.signature.field_type,
item.flags,
],
"Managed comparison",
),
});
const matchMethods = (
@@ -237,7 +243,8 @@ export const identityOf = ({
}: {
readonly declaring_type: DeclaredName;
readonly name: string;
}): string => sha256([declaring_type, name]);
}): string =>
digestCanonicalValue([declaring_type, name], "Managed comparison");
/** Narrow a key to members of one declared type and name. */
const declaredAs =
@@ -246,7 +253,7 @@ const declaredAs =
const value = key(item);
return value === null || item.identityUnavailable
? null
: sha256([item.identity, value]);
: digestCanonicalValue([item.identity, value], "Managed comparison");
};
const matchByKeys = <Item extends Declared>({
@@ -1,14 +1,10 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { Evidence } from "../evidence.js";
import {
managedNativeBoundaryInspectionSchema,
type ManagedNativeBoundaryInspection,
} from "./managedArtifact.js";
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
import type { JsonValue } from "../jsonValue.js";
import {
managedNativeVerificationResultSchema,
type ManagedNativeVerificationInput,
@@ -20,13 +16,6 @@ import {
verifyPinvoke,
} from "./managedNativeVerificationMatch.js";
const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed/native verification canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
const parseManagedBoundary = (
input: ManagedNativeVerificationInput,
): {
@@ -65,6 +54,9 @@ export const verifyManagedNativeBoundaries = (
});
return managedNativeVerificationResultSchema.parse({
...withoutId,
verification_id: `mnv_${sha256(withoutId)}`,
verification_id: `mnv_${digestCanonicalValue(
withoutId,
"Managed/native verification",
)}`,
});
};
@@ -1,13 +1,10 @@
import { createHash } from "node:crypto";
import { basename } from "node:path";
import canonicalize from "canonicalize";
import { parseEvidence } from "../evidence.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { functionDossierSchema } from "../hopperValues.js";
import { inspectMachoSchema } from "../native/nativeInspection.js";
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import {
pinvokeVerificationSchema,
type ManagedNativeVerificationInput,
@@ -16,13 +13,6 @@ import {
type PinvokeVerification,
} from "./managedNativeVerificationSchemas.js";
const digest = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed/native verification canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
type PinvokeImport = ManagedNativeBoundaryInspection["pinvoke_imports"][number];
type VerifiedPinvoke = {
readonly verification: PinvokeVerification;
@@ -326,13 +316,16 @@ export const verifyPinvoke = ({
const limitations = pinvokeLimitations(selection.match, item);
return {
verification: pinvokeVerificationSchema.parse({
item_id: `mnv_pinvoke_${digest({
managedEvidenceId,
token: item.token,
importName: item.import_name,
importScope: item.import_scope_name,
candidates: selection.match.candidates,
})}`,
item_id: `mnv_pinvoke_${digestCanonicalValue(
{
managedEvidenceId,
token: item.token,
importName: item.import_name,
importScope: item.import_scope_name,
candidates: selection.match.candidates,
},
"Managed/native verification",
)}`,
managed,
...selection.match,
evidence_links: [
@@ -2,12 +2,14 @@ import { z } from "zod";
import { emptyArraySchema } from "../emptyArraySchema.js";
import { evidenceSchema } from "../evidence.js";
import { cliMetadataGuidSchema } from "./managedArtifact.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
} from "./managedArtifact.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const textSchema = z.string().min(1);
const nativeSymbolSchema = z.strictObject({
@@ -25,8 +27,8 @@ export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
const pinvokeVerificationContextShape = {
item_id: prefixedDigestSchema("mnv_pinvoke"),
managed: z.strictObject({
token: tokenSchema,
member_token: tokenSchema.nullable(),
token: managedMetadataTokenSchema,
member_token: managedMetadataTokenSchema.nullable(),
member_name: z.string().nullable(),
import_name: z.string().min(1),
import_scope_name: z.string().nullable(),
+12 -14
View File
@@ -1,20 +1,19 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { evidenceSchema, type Evidence } from "../evidence.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
managedMemberInspectionSchema,
type ManagedMemberInspection,
} from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { digestSchema, prefixedDigestSchema } from "../digests.js";
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const textSchema = z.string().min(1);
const decompilerSchema = z.strictObject({
@@ -26,7 +25,7 @@ const decompilerSchema = z.strictObject({
});
const managedReconstructionMethodInputSchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
signature_sha256: digestSchema,
normalized_il_sha256: digestSchema.nullable(),
reconstruction: z.strictObject({
@@ -52,7 +51,7 @@ export const managedReconstructionImportInputSchema = z.strictObject({
});
const importedMethodSchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
declaring_type: z.string().nullable(),
name: z.string(),
signature_sha256: digestSchema,
@@ -122,13 +121,6 @@ type StaticObservation =
const sha256Text = (value: string): string =>
createHash("sha256").update(value).digest("hex");
const sha256Json = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed reconstruction canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
const countLines = (text: string): number =>
text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length;
@@ -227,7 +219,10 @@ const buildDecompilerRecord = (
decompiler: ManagedReconstructionImportInput["decompiler"],
): ManagedReconstructionImportResult["decompiler"] => ({
...decompiler,
options_sha256: sha256Json(decompiler.options),
options_sha256: digestCanonicalValue(
decompiler.options,
"Managed reconstruction",
),
});
const RECONSTRUCTION_LIMITATIONS = [
@@ -282,6 +277,9 @@ export const importManagedReconstruction = (
);
return managedReconstructionImportResultSchema.parse({
...withoutId,
reconstruction_id: `mre_${sha256Json(withoutId)}`,
reconstruction_id: `mre_${digestCanonicalValue(
withoutId,
"Managed reconstruction",
)}`,
});
};
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./webBundleAnalyzer.js";
import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js";
import { webBundleAnalysisSchema } from "./webBundleAnalysis.js";
import { isIncludedScript } from "./webBundleAnalyzerInspection.js";
import { isIncludedScript } from "./webBundleAnalyzerState.js";
import { webBundleScriptAnalysisSchema } from "./webBundleScriptAnalysis.js";
import { createWebTextArtifact } from "./webContentArtifact.js";
+4 -4
View File
@@ -1,10 +1,10 @@
import type { WebPageInspection } from "./browserObservationSchemas.js";
import { analyzeScript } from "./webBundleAnalyzerInspection.js";
import {
analyzeScript,
emptyAccumulator,
createWebBundleAnalysisAccumulator,
isIncludedScript,
type IncludedScript,
} from "./webBundleAnalyzerInspection.js";
} from "./webBundleAnalyzerState.js";
import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import {
@@ -16,7 +16,7 @@ import {
export const analyzeCapturedWebScript = (
script: IncludedScript,
): WebBundleScriptAnalysis => {
const accumulator = emptyAccumulator();
const accumulator = createWebBundleAnalysisAccumulator();
analyzeScript(script, accumulator);
const failed = accumulator.failedScripts.length > 0;
const recovered = accumulator.recoveredScripts.length > 0;
+4 -39
View File
@@ -1,8 +1,11 @@
import type {
AnalysisAccumulator,
IncludedScript,
} from "./webBundleAnalyzerState.js";
import * as t from "@babel/types";
import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
@@ -37,26 +40,6 @@ type ChunkEdge = BundleObservations["chunks"]["edges"][number];
type Finding = BundleObservations["routes"][number];
type WebMcpDeclaration = BundleObservations["webmcp_declarations"][number];
type Inference = WebBundleAnalysis["inferences"][number];
type BrowserScript = WebPageInspection["scripts"]["items"][number];
type IncludedSource = Extract<BrowserScript["source"], { included: true }>;
export type IncludedScript = Omit<BrowserScript, "source"> & {
readonly source: IncludedSource;
};
export interface AnalysisAccumulator {
readonly edges: ChunkEdge[];
readonly routes: Finding[];
readonly endpoints: Finding[];
readonly webMcp: WebMcpDeclaration[];
readonly inferences: Inference[];
readonly seen: Set<string>;
visitedNodes: number;
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
readonly widenedScripts: string[];
}
export const analyzeScript = (
script: IncludedScript,
accumulator: AnalysisAccumulator,
@@ -501,24 +484,6 @@ const basis = (script: IncludedScript, detector: string) => ({
detector,
});
export const emptyAccumulator = (): AnalysisAccumulator => ({
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
});
export const isIncludedScript = (
script: BrowserScript,
): script is IncludedScript => script.source.included;
const routeCallNames = [
"route",
"addRoute",
+8 -20
View File
@@ -4,10 +4,12 @@ import {
webBundleAnalysisSchema,
type WebBundleAnalysis,
} from "./webBundleAnalysis.js";
import type {
AnalysisAccumulator,
IncludedScript,
} from "./webBundleAnalyzerInspection.js";
import {
createWebBundleAnalysisAccumulator,
isIncludedScript,
type AnalysisAccumulator,
type IncludedScript,
} from "./webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
@@ -29,9 +31,7 @@ export const buildCapturedWebBundleAnalysis = (
);
byKey.set(result.script_key, result);
}
const sourceScripts = inspection.scripts.items.filter(
(script): script is IncludedScript => script.source.included,
);
const sourceScripts = inspection.scripts.items.filter(isIncludedScript);
const expectedKeys = new Set(
sourceScripts.map(({ script_key }) => script_key),
);
@@ -145,19 +145,7 @@ const mergeScriptResults = (
{ readonly status: "analyzed" }
>[],
): AnalysisAccumulator => {
const accumulator: AnalysisAccumulator = {
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
};
const accumulator = createWebBundleAnalysisAccumulator();
for (const result of results) {
accumulator.visitedNodes += result.visited_ast_nodes;
if (result.parse_status === "failed") {
+44
View File
@@ -0,0 +1,44 @@
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
type BundleObservations = WebBundleAnalysis["observations"];
type BrowserScript = WebPageInspection["scripts"]["items"][number];
type IncludedSource = Extract<BrowserScript["source"], { included: true }>;
/** Captured script whose source bytes are available for inert analysis. */
export type IncludedScript = Omit<BrowserScript, "source"> & {
readonly source: IncludedSource;
};
/** Mutable facts shared by per-script inspection and bundle result assembly. */
export interface AnalysisAccumulator {
readonly edges: BundleObservations["chunks"]["edges"];
readonly routes: BundleObservations["routes"];
readonly endpoints: BundleObservations["endpoints"];
readonly webMcp: BundleObservations["webmcp_declarations"];
readonly inferences: WebBundleAnalysis["inferences"];
readonly seen: Set<string>;
visitedNodes: number;
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
readonly widenedScripts: string[];
}
export const createWebBundleAnalysisAccumulator = (): AnalysisAccumulator => ({
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
});
export const isIncludedScript = (
script: BrowserScript,
): script is IncludedScript => script.source.included;
+23 -26
View File
@@ -9,6 +9,7 @@ import {
type AnalysisClient,
type AnalysisClientContext,
type AnalysisOperation,
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config/types.js";
import {
@@ -478,6 +479,24 @@ export const createGhidraProviderClient = (input: {
operation,
);
};
const startSession = async (
operation: AnalysisOperation,
options?: ExecutionOptions,
): Promise<Result<GhidraSessionInfo, AnalysisError>> => {
const started = await client.start(options?.signal, options?.progress);
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
providerStarted = true;
const failed = await checkSessionObservations(operation, started.value);
return failed === undefined ? started : err(failed);
};
const releaseLimitation = unverifiedGhidraBuildLimitation(
prerequisites.value.providerVersion,
);
@@ -603,19 +622,8 @@ export const createGhidraProviderClient = (input: {
),
);
if (operation === "health") {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
const started = await startSession(operation, options);
if (!started.ok) return started;
return ok(
createAnalysisExecution(started.value, committedProfile.provider, {
analysisProfile: committedProfile,
@@ -633,19 +641,8 @@ export const createGhidraProviderClient = (input: {
seeds !== undefined ||
softFloat
) {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
const started = await startSession(operation, options);
if (!started.ok) return started;
}
const called = await client.callTool(operation, input.value, {
...(options?.signal === undefined ? {} : { signal: options.signal }),
+3 -8
View File
@@ -19,11 +19,6 @@ import type {
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js";
/**
* Omitted `native_command_timeout_ms`. Distinct from objdump/rizin
* `command_timeout_ms`, which is a separate 120000 ms one-shot budget.
*/
export const NATIVE_COMMAND_TIMEOUT_MS = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS;
/** Retain complete command output up to a bounded aggregate size. */
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
/**
@@ -131,7 +126,7 @@ export interface NativeCommandFailureCapture {
export interface NativeCommandOptions {
readonly signal?: AbortSignal;
readonly acceptNonZero?: boolean;
/** Selected wall clock; omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
/** Selected wall clock; omitted uses `DEFAULT_NATIVE_COMMAND_TIMEOUT_MS`. */
readonly timeoutMs?: number;
}
@@ -155,7 +150,7 @@ export type NativeToolResolver = (
tool: string,
signal?: AbortSignal,
launcher?: NativeCommandLauncher,
/** Selected command deadline. Omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
/** Selected command deadline. Omitted uses `DEFAULT_NATIVE_COMMAND_TIMEOUT_MS`. */
timeoutMs?: number,
) => Promise<Result<ResolvedTool, NativeCommandFailure>>;
@@ -533,7 +528,7 @@ const captureProcess = async (
...(options.signal === undefined ? {} : { signal: options.signal }),
},
{
timeoutMs: options.timeoutMs ?? NATIVE_COMMAND_TIMEOUT_MS,
timeoutMs: options.timeoutMs ?? DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
diagnosticBytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
acceptNonZeroExit: options.acceptNonZero === true,
},
+3 -5
View File
@@ -14,10 +14,8 @@ import type {
NativeCommandOptions,
NativeCommandRunner,
} from "./CommandRunner.js";
import {
NATIVE_COMMAND_TIMEOUT_MS,
XcrunCommandRunner,
} from "./CommandRunner.js";
import { XcrunCommandRunner } from "./CommandRunner.js";
import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js";
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
vi.mock("../process/OwnedCommand.js", async (original) => ({
@@ -59,7 +57,7 @@ describe("native_command_timeout_ms", () => {
const result = await execute(runner, {});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(runner.timeouts.every((timeout) => timeout === 60_000)).toBe(true);
expect(NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
expect(DEFAULT_NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
expect(runner.timeouts.length).toBeGreaterThan(0);
});
+5 -5
View File
@@ -59,6 +59,7 @@ import {
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
DEFAULT_NATIVE_UI_DEADLINE_MS,
MAX_NATIVE_TIMER_MS,
fixedNativeAllowance,
@@ -95,7 +96,6 @@ import {
} from "../domain/native/nativeInspection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
NATIVE_COMMAND_TIMEOUT_MS,
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
NativeCommandFailure,
@@ -603,7 +603,7 @@ class NativeMacOSClient implements AnalysisClient {
): Promise<Result<NativeObservation, AnalysisError>> {
const timeout = readNativeDeadline(
parameters.native_command_timeout_ms,
NATIVE_COMMAND_TIMEOUT_MS,
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
);
if (!timeout.ok)
return Promise.resolve(
@@ -1295,7 +1295,7 @@ const isNativeOperation = (
const translateCommandFailure = (
operation: NativeToolName,
failure: NativeCommandFailure,
timeoutMs = NATIVE_COMMAND_TIMEOUT_MS,
timeoutMs = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
): AnalysisError => {
const cleanupOwners = [
...failure.cleanupFailures,
@@ -1367,7 +1367,7 @@ const translateCommandFailure = (
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
@@ -1429,7 +1429,7 @@ const translateCommandFailure = (
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, it, vi } from "vitest";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { createNativeUiHelperRuntime } from "./NativeUiHelperRuntime.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
import { DEFAULT_NATIVE_UI_DEADLINE_MS } from "../domain/native/nativeDeadlines.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
@@ -37,7 +37,7 @@ it.each([250_000, undefined])(
} finally {
await runtime.close();
}
const expected = deadlineMs ?? NATIVE_UI_DEADLINE_MS;
const expected = deadlineMs ?? DEFAULT_NATIVE_UI_DEADLINE_MS;
const calls = vi.mocked(execFileOutput).mock.calls;
expect(calls).toHaveLength(2);
expect(calls[0]?.[0]).toBe("/usr/bin/xcrun");
+2 -2
View File
@@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url";
import { SWIFTC_INTERRUPT } from "../process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { safeParseJson } from "../domain/safeJson.js";
import { DEFAULT_NATIVE_UI_DEADLINE_MS } from "../domain/native/nativeDeadlines.js";
import type { NativeUiHelper } from "./NativeUiObservation.js";
import { NATIVE_UI_HELPER_MAX_BUFFER } from "./NativeUiOutputBudget.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
export interface NativeUiHelperRuntime {
readonly invoke: NativeUiHelper;
@@ -25,7 +25,7 @@ export interface NativeUiHelperRuntime {
/** Lazily compile one owned helper per observation/scenario and remove its compiler cache. */
export const createNativeUiHelperRuntime = (
environment: Readonly<NodeJS.ProcessEnv>,
deadlineMs = NATIVE_UI_DEADLINE_MS,
deadlineMs = DEFAULT_NATIVE_UI_DEADLINE_MS,
): NativeUiHelperRuntime => {
const selectedEnvironment = snapshotEnvironment(environment);
const deadlineSignal = AbortSignal.timeout(deadlineMs);
+2 -5
View File
@@ -8,9 +8,6 @@ import {
nativeDeadlineRecovery,
} from "../domain/native/nativeDeadlines.js";
/** Omitted `native_ui_deadline_ms`. */
export const NATIVE_UI_DEADLINE_MS = DEFAULT_NATIVE_UI_DEADLINE_MS;
type Interruption =
| { readonly kind: "caller"; readonly reason: unknown }
| { readonly kind: "deadline"; readonly reason: unknown };
@@ -38,7 +35,7 @@ export class NativeUiInterruptionControl {
constructor(
caller: AbortSignal | undefined,
deadline: AbortSignal,
deadlineMs = NATIVE_UI_DEADLINE_MS,
deadlineMs = DEFAULT_NATIVE_UI_DEADLINE_MS,
) {
this.#caller = caller;
this.#deadline = deadline;
@@ -86,7 +83,7 @@ export class NativeUiInterruptionControl {
timeoutMs: this.#deadlineMs,
setting: "native_ui_deadline_ms",
cliFlag: "--native-ui-deadline-ms",
defaultMs: NATIVE_UI_DEADLINE_MS,
defaultMs: DEFAULT_NATIVE_UI_DEADLINE_MS,
phase: "native UI observation deadline",
detail: " A partial observation may already be retained.",
unchanged:
+2 -5
View File
@@ -34,10 +34,7 @@ import {
NATIVE_UI_OUTPUT_BUDGET_BYTES,
NATIVE_UI_OUTPUT_WEIGHT,
} from "./NativeUiOutputBudget.js";
import {
NATIVE_UI_DEADLINE_MS,
NativeUiInterruptionControl,
} from "./NativeUiInterruptionControl.js";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
const helper = fileURLToPath(
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
@@ -79,7 +76,7 @@ export const observeNativeUi = async (
new NativeUiInterruptionControl(
options.signal,
runtime.signal,
runtime.deadlineMs ?? NATIVE_UI_DEADLINE_MS,
runtime.deadlineMs ?? DEFAULT_NATIVE_UI_DEADLINE_MS,
);
const ownsInterruption = options.interruption === undefined;
let result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>;
-1
View File
@@ -990,7 +990,6 @@ const registerObservationTools = ({
});
registerBrowserScenarioTool(server, {
...common,
evidenceById,
provider: options.browserScenarioCapture,
loadFailure: options.optionalProviderLoadFailures?.browserScenarioCapture,
});
+9 -40
View File
@@ -5,12 +5,6 @@ import {
type OptionalProviderLoadFailure,
} from "../application/OptionalObservationProviders.js";
import { err } from "../domain/result.js";
import type { EvidenceLookup } from "../application/EvidenceInputResolver.js";
import {
requireAnalysisViewRetention,
summarizeRetainedAnalysis,
} from "../application/analysisView/AnalysisViewService.js";
import { analysisDetailSchema } from "../contracts/analysisDetail.js";
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
import { captureBrowserScenario } from "../application/BrowserScenarioCaptureService.js";
@@ -29,7 +23,6 @@ interface BrowserScenarioToolRegistration {
readonly loadFailure?: OptionalProviderLoadFailure | undefined;
readonly provider: BrowserScenarioCapturePort | undefined;
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
readonly evidenceById?: EvidenceLookup | undefined;
readonly withAdmittedAnalysis?: WithAdmittedAnalysis;
}
@@ -43,29 +36,20 @@ export const registerBrowserScenarioTool = (
contract.name,
toolRegistrationOptions(contract),
async (input, context) => {
// Delivery detail is not part of the scenario or its recorded parameters.
const { detail: requestedDetail, ...scenarioInput } = input;
const parsedScenario = browserScenarioSchema.safeParse(scenarioInput);
const parsedDetail = analysisDetailSchema.safeParse(requestedDetail);
if (!parsedScenario.success || !parsedDetail.success) {
const issues = [
...(parsedScenario.error?.issues ?? []),
...(parsedDetail.error?.issues.map((issue) => ({
...issue,
path: ["detail", ...issue.path],
})) ?? []),
];
const parsedScenario = browserScenarioSchema.safeParse(input);
if (!parsedScenario.success)
return server.delivery.toCallToolResult(
err(
analysisInputErrorFromIssues(contract.name, issues, input, {
cause: parsedScenario.error ?? parsedDetail.error,
}),
analysisInputErrorFromIssues(
contract.name,
parsedScenario.error.issues,
input,
{ cause: parsedScenario.error },
),
),
contract,
);
}
const scenario = parsedScenario.data;
const detail = parsedDetail.data;
if (options.loadFailure !== undefined)
return server.delivery.toCallToolResult(
err(optionalProviderUnavailable(options.loadFailure, contract.name)),
@@ -77,19 +61,6 @@ export const registerBrowserScenarioTool = (
contract.name,
context.mcpReq.signal,
async () => {
const retention = {
recordEvidence: options.recordEvidence,
evidenceById: options.evidenceById,
};
// Refuse summary delivery before launching a browser that could not retain its Evidence.
if (detail === "summary") {
const ready = requireAnalysisViewRetention(
contract.name,
retention,
);
if (!ready.ok)
return server.delivery.toCallToolResult(ready, contract);
}
const result = await logToolExecution(
options.logger,
contract.name,
@@ -100,9 +71,7 @@ export const registerBrowserScenarioTool = (
);
return deliverEvidenceResult(
server,
detail === "summary" && result.ok
? summarizeRetainedAnalysis(result.value, retention)
: result,
result,
contract,
options.recordEvidence,
);
@@ -0,0 +1,40 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, it } from "vitest";
it("loads the isolated coordinator without loading its worker's JavaScript parser", async () => {
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--input-type=module",
"-e",
`
import { registerHooks } from "node:module";
const parser = import.meta.resolve("@babel/parser");
let parserLoaded = false;
const hook = registerHooks({
load(url, context, nextLoad) {
if (url === parser) parserLoaded = true;
return nextLoad(url, context);
},
});
const { IsolatedWebBundleAnalyzer } = await import(process.argv[1]);
hook.deregister();
process.stdout.write(JSON.stringify({
coordinatorLoaded: typeof IsolatedWebBundleAnalyzer === "function",
parserLoaded,
}));
`,
new URL(
"../../../dist/browser/IsolatedWebBundleAnalyzer.js",
import.meta.url,
).href,
],
{ timeout: 10_000 },
);
expect(JSON.parse(stdout)).toEqual({
coordinatorLoaded: true,
parserLoaded: false,
});
});
@@ -37,38 +37,6 @@ describe("scenario CLI schema diagnostics", () => {
});
});
cliTest(
"refuses summary delivery without a complete Evidence bundle before launching",
async ({ cli }) => {
const scenario = {
browser: { mode: "launch", executable_path: "/nonexistent/chromium" },
start_url: { url: "https://app.example.test/", query: [] },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
};
const result = await cli.run({
arguments: [
"capture-browser-scenario",
JSON.stringify(scenario),
"--detail",
"summary",
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(result.exitCode).not.toBe(0);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
operation: "capture_browser_scenario",
issues: [{ path: ["evidence_output"], reason: "invalid_value" }],
},
});
},
);
cliTest("reports Electron scenario schema paths", async ({ cli }) => {
const result = await cli.run({
arguments: ["capture-electron-scenario", "{}", "--json"],
@@ -229,3 +229,204 @@ it("keeps unavailable mapped digests unknown instead of borrowing unrelated matc
reason: "static-or-runtime-coverage-incomplete",
});
});
const UNRELATED = "export const unrelated = 17;\n";
type LayerSetup = {
readonly root: string;
readonly mapping: "none" | "explicit";
};
/** Reconcile one captured script, located at absent.js under live, against layers. */
const reconcileCapturedScript = async (input: {
readonly layers: readonly LayerSetup[];
readonly live: string;
readonly source: string;
readonly includeWorker?: boolean;
}) => {
const result = reconcileJavaScriptRuntime(
parseRuntimeReconciliationInput(
reconcileJavaScriptRuntimeInputSchema.parse({
static_layers: await Promise.all(
input.layers.map(async ({ root, mapping }, index) => ({
role: (["application", "cache", "assets"] as const)[index],
analysis: await analyzeFixture(root),
runtime_mappings:
mapping === "explicit"
? [{ kind: "file-root", root: input.live, artifact_prefix: "" }]
: [],
})),
),
runtime_observations: [
electronRuntimeEvidence(input.live, input.source, {
scriptFile: "absent.js",
includeWorker: input.includeWorker ?? false,
}),
],
}),
),
);
const item = (kind: string) =>
result.reconciliations.find(({ entity_kind }) => entity_kind === kind);
return { result, script: item("script"), worker: item("worker") };
};
it("keeps an unmapped captured script without a content match unknown", async () => {
const root = await applicationFixture();
const live = await createTestTempDirectory("rea-runtime-unmapped-");
const { result, script, worker } = await reconcileCapturedScript({
layers: [{ root, mapping: "none" }],
live,
source: UNRELATED,
includeWorker: true,
});
const unknown = {
status: "unknown",
basis: "none",
confidence: "unknown",
reason: "no-authorized-location-mapping",
static_layer_id: null,
static_node_id: null,
candidate_static_count: 0,
};
expect(script).toMatchObject(unknown);
// A source-free worker at an unmapped location shares the same rule.
expect(worker).toMatchObject(unknown);
expect(result.summary).toMatchObject({ matched: 0, unmatched: 0 });
expect(
result.graph.edges.some(({ relation }) => relation === "observed_as"),
).toBe(false);
});
it.each(["explicit", "implicit-root"] as const)(
"reports a mapped captured script without a content match as unmatched (%s)",
async (mapping) => {
const root = await applicationFixture();
const live =
mapping === "explicit"
? await createTestTempDirectory("rea-runtime-mapped-absent-")
: root;
const { result, script } = await reconcileCapturedScript({
layers: [{ root, mapping: mapping === "explicit" ? "explicit" : "none" }],
live,
source: UNRELATED,
});
expect(script).toMatchObject({
status: "unmatched",
basis: "none",
confidence: "high",
reason: "no-static-content-match",
candidate_static_count: 0,
});
expect(result.summary.unmatched).toBe(1);
},
);
it.each([
["only the second", ["none", "explicit"], "unmatched"],
["only the first", ["explicit", "none"], "unmatched"],
["neither", ["none", "none"], "unknown"],
] as const)(
"classifies an unmatched captured script when %s of two layers maps it",
async (_label, mappings, status) => {
const live = await createTestTempDirectory("rea-runtime-layer-mapping-");
const { script } = await reconcileCapturedScript({
layers: await Promise.all(
mappings.map(async (mapping) => ({
root: await applicationFixture(),
mapping,
})),
),
live,
source: UNRELATED,
});
expect(script).toMatchObject({
status,
reason:
status === "unknown"
? "no-authorized-location-mapping"
: "no-static-content-match",
});
},
);
it.each([
[1, "matched", "unique-content-match"],
[2, "ambiguous", "ambiguous-static-candidates"],
] as const)(
"keeps unmapped content-only correspondence with %i digest candidates",
async (copies, status, reason) => {
const live = await createTestTempDirectory("rea-runtime-unmapped-content-");
const { result, script } = await reconcileCapturedScript({
layers: await Promise.all(
Array.from({ length: copies }, async () => {
const root = await applicationFixture();
await writeFile(join(root, "other.js"), CAPTURED);
return { root, mapping: "none" as const };
}),
),
live,
source: CAPTURED,
});
expect(script).toMatchObject({
status,
basis: copies === 1 ? "content-sha256" : "none",
reason,
candidate_static_count: copies,
});
expect(result.summary.unmatched).toBe(0);
},
);
it.each([
["https://example.test/assets/absent.js", "unmatched"],
["https://other.test/assets/absent.js", "unknown"],
] as const)(
"applies URL-prefix mapping presence to captured script %s",
async (url, status) => {
const root = await applicationFixture();
const parsed = parseRuntimeReconciliationInput(
reconcileJavaScriptRuntimeInputSchema.parse({
static_layers: [
{
role: "application",
analysis: await analyzeFixture(root),
runtime_mappings: [
{
kind: "url-prefix",
prefix: "https://example.test/assets/",
artifact_prefix: "",
},
],
},
],
runtime_observations: [
electronRuntimeEvidence(root, UNRELATED, { includeWorker: false }),
],
}),
);
// Model a URL-located capture at the matcher boundary; browser and
// Inspector projections supply URL locations to the same classifier.
const entities = projectRuntimeCaptures(parsed.captures).entities.map(
(entity) =>
entity.kind === "script"
? { ...entity, location: { kind: "url" as const, value: url } }
: entity,
);
const result = reconcileRuntimeEntities({
entities,
candidates: collectStaticRuntimeCandidates(parsed.layers),
layers: parsed.layers,
});
expect(
result.items.find(({ entity_kind }) => entity_kind === "script"),
).toMatchObject({
status,
confidence: status === "unknown" ? "unknown" : "high",
reason:
status === "unknown"
? "no-authorized-location-mapping"
: "no-static-content-match",
});
},
);
@@ -367,111 +367,3 @@ test("records a completed late-close cancellation as server Evidence", async ()
await Promise.all([client.close(), server.close(), session.close()]);
}
});
test("delivers a retained summary and pages its exact steps and events", async () => {
const provider = new FakeBrowserScenarioProvider();
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(observed(null)),
close: () => Promise.resolve(ok(null)),
}));
const server = createServer(
{ kind: "session", session },
{
browserScenarioCapture: provider,
availabilityPolicy: () => ({
processCaptureEnabled: false,
investigationInputRoots: 0,
}),
},
);
const client = new Client({ name: "scenario-summary", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const tools = (await client.listTools()).tools;
const advertised = tools.find(
({ name }) => name === "capture_browser_scenario",
);
if (advertised?.outputSchema === undefined)
throw new Error("Missing advertised scenario output schema");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const validateInput = ajv.compile(advertised.inputSchema);
const validateOutput = ajv.compile(
z.record(z.string(), z.unknown()).parse(advertised.outputSchema),
);
const request = { ...minimalScenario(), detail: "summary" };
expect(validateInput(request), JSON.stringify(validateInput.errors)).toBe(
true,
);
const response = await client.callTool({
name: "capture_browser_scenario",
arguments: request,
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(
validateOutput(response.structuredContent),
JSON.stringify(validateOutput.errors),
).toBe(true);
const summary = z
.object({
normalized_result: z.object({
kind: z.literal("summary"),
parent_evidence_id: z.string(),
summary: z.object({
steps: z.array(z.object({ step_id: z.string() })),
}),
}),
})
.parse(response.structuredContent).normalized_result;
expect(summary.summary.steps.map(({ step_id }) => step_id)).toEqual([
"scenario_start",
"settle",
]);
// Delivery detail is neither sent to the provider nor recorded as a scenario parameter.
expect(provider.scenarios[0]).not.toHaveProperty("detail");
const parent = session.evidenceById(summary.parent_evidence_id);
expect(parent?.operation).toBe("capture_browser_scenario");
expect(parent?.parameters).not.toHaveProperty("detail");
const page = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: {
kind: "retained-evidence",
evidence_id: summary.parent_evidence_id,
},
view: {
kind: "browser-scenario",
collection: "steps",
step_indices: [1],
},
},
});
expect(page.isError, JSON.stringify(page)).not.toBe(true);
expect(page.structuredContent).toMatchObject({
normalized_result: {
kind: "browser-scenario",
parent_evidence_id: summary.parent_evidence_id,
items: {
collection: "steps",
rows: [{ step_index: 1, step_id: "settle", artifacts: {} }],
},
coverage: { total: 1, exhausted: true },
},
});
const invalid = await client.callTool({
name: "capture_browser_scenario",
arguments: { ...minimalScenario(), detail: "brief" },
});
expect(parseMcpToolError(invalid)).toMatchObject({
error: { code: "invalid_request" },
});
expect(provider.scenarios).toHaveLength(1);
} finally {
await Promise.all([client.close(), server.close(), session.close()]);
}
});