diff --git a/docs/cli.md b/docs/cli.md index dcbd2cbb83..f0cb9d5f6b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,6 +30,13 @@ jq -c '{source: {kind: "inline", evidence: .}, view: {kind: "summary"}}' app-evi rea inspect-analysis-view app-view.json ``` +The request can also name the saved file instead of embedding it: +`{"source": {"kind": "evidence-file", "path": "app-evidence.json"}, "view": {"kind": "summary"}}`. +The CLI resolves a relative path against the working directory; MCP clients +pass an absolute path. REA reads and authenticates the file, then projects it +like retained Evidence, so a large file is bounded by the file reader rather +than charged as inline input. + For example, a 334 MB Obsidian application Evidence file yields a summary view of about 10 KB. Replace the view with `{"kind": "page", "collection": "modules", "offset": 0, "limit": 32}` for a diff --git a/docs/specs/agent-environments/rich-captures/plan.md b/docs/specs/agent-environments/rich-captures/plan.md new file mode 100644 index 0000000000..de1642f2b2 --- /dev/null +++ b/docs/specs/agent-environments/rich-captures/plan.md @@ -0,0 +1,18 @@ +# Plan + +1. Add the `evidence-file` source to `analysisViewSourceSchema` + (`src/domain/analysisView/analysisView.ts`). +2. Add `inspectAnalysisViewWithFiles` / `inspectAnalysisViewValidatedWithFiles` + to `AnalysisViewService`: read with `readJsonFile`, authenticate with + `parseEvidence`, then project through the retained path with a lookup that + returns the file's Evidence. The synchronous path refuses an unread file + source. +3. Route the CLI command (with `source.path` resolved against the working + directory) and the MCP tool through the file-aware entry point. +4. Add `sibling-pair-variance` to `TEMPLATE_RULES`, with its merge pass after + `sibling-variance`, its parameter names and its segment-limit limitation. +5. Document both in the tool contract, `docs/cli.md` and + `docs/web-network-captures.md`. +6. Tests: service (file equals inline; a capture refused inline is accepted + from a file; refusals), CLI and MCP boundaries, and the pair rule's merges + and non-merges. diff --git a/docs/specs/agent-environments/rich-captures/spec.md b/docs/specs/agent-environments/rich-captures/spec.md new file mode 100644 index 0000000000..9dfa9d98df --- /dev/null +++ b/docs/specs/agent-environments/rich-captures/spec.md @@ -0,0 +1,47 @@ +# Rich captures for the environment manifest + +Issue: https://github.com/morluto/rea/issues/4933 + +## Outcome + +A capture large enough to hold a rich application state can be projected into +an environment manifest whose tool list stays within what model providers +accept. Two changes: + +- `inspect_analysis_view` accepts a third source, + `{"kind": "evidence-file", "path": ""}`. REA reads one + completed Evidence JSON file, authenticates it, and projects it like retained + Evidence, so its strings are bounded by the file reader instead of being + charged as inline input. +- The environment-manifest view gains an opt-in template rule, + `sibling-pair-variance`, that templates two adjacent literal segments after a + plural literal, such as `/repos/{owner}/{repo}/…`. + +## Requirements + +- An `evidence-file` path must be absolute and free of NUL bytes; the CLI + resolves a relative path against the working directory before validation, + and MCP clients pass an absolute path. +- The file is read through the shared JSON file reader (regular file, heap and + string-length guards) and parsed as complete Evidence. A missing or unreadable + file is an `EvidenceFileError`; JSON that is not complete Evidence is invalid + input reported under `source.path`. +- A file source projects exactly what the same Evidence projects inline or + retained, with the same parent Evidence ID, view parameters and result. +- The synchronous projection API refuses an unread `evidence-file` source with + a typed capability error; the CLI and MCP adapters read files first. +- `sibling-pair-variance` applies only when listed, after `sibling-variance`. It + compares groups equal in method, origin and segment count except at two + adjacent literal positions at index 2 or later that follow a plural literal, + and merges a bucket only when it holds at least three distinct pairs and both + positions vary. Stop words and `v1`-style versions never qualify, and routes + of more than 32 segments are not compared, with a limitation saying so. +- Its parameters are named from the plural literal, singular plus `_namespace` + and then `_name`, and labelled `sibling-pair-variance`. + +## Non-goals + +- No change to the 96 MiB projection budget or to inline admission. +- No change to `sibling-variance`; pairs are a separate rule so every inferred + parameter keeps naming the rule that produced it. +- No inference of semantic names (`owner`, `repo`) beyond the plural literal. diff --git a/docs/specs/agent-environments/rich-captures/tasks.md b/docs/specs/agent-environments/rich-captures/tasks.md new file mode 100644 index 0000000000..bb0d1ec6bf --- /dev/null +++ b/docs/specs/agent-environments/rich-captures/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Add the `evidence-file` source and the file-aware service entry point. +- [x] Route the CLI command and the MCP tool through it. +- [x] Add the `sibling-pair-variance` rule, its names and its limitation. +- [x] Document both; extend the tool contract text. +- [x] Add service, CLI, MCP and rule tests. +- [ ] Run static, formatting, duplication, documentation and test gates. +- [ ] Rebuild a rich capture with both changes and record the results. diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 5a5283c672..a8e70c256b 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -300,7 +300,10 @@ exclusions and deduplicated limitations. It scans path spans without splitting literal paths into arrays. Before schema parsing, it reserves the capture clone and validation scratch by walking the parent without cloning it. Inline requests borrow the Evidence value through CLI/MCP envelope validation; the application -admits the entire portable envelope before authenticated parsing or hashing. +admits the entire portable envelope before authenticated parsing or hashing. An +`evidence-file` source is read and authenticated first and then admitted like +retained Evidence, so its strings are not charged as inline envelope text: a +capture too large to project inline can be projected from its file. The envelope clone, hash scratch and capture clone stay charged during grouping. Temporary URL/path/MIME/query work and retained state must fit together within a 96 MiB projection representation budget. A whole group or @@ -367,11 +370,12 @@ template and `params` carry the four format rules with names `id`, `uuid`, only when listed, each at most once (a repeated rule is an `invalid_request`), in this order: -| Rule | Applies to | Parameter | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| `after-uid-literal` | The literal segment right after a literal `uid`, unless it is a stop word | `uid` | -| `opaque-token` | A lowercase alphanumeric segment of twelve or more characters mixing letters and digits that is not a `v1`-style version | `uid` | -| `sibling-variance` | Groups equal in method, origin and segment count that differ in one literal at index 2 or later; the siblings whose literal is identifier-like (a digit or `:`, or three or more hyphenated names under a plural literal) merge, never a stop word, and the other literals stay their own tools | singular previous literal plus `_id`, else `id` | +| Rule | Applies to | Parameter | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `after-uid-literal` | The literal segment right after a literal `uid`, unless it is a stop word | `uid` | +| `opaque-token` | A lowercase alphanumeric segment of twelve or more characters mixing letters and digits that is not a `v1`-style version | `uid` | +| `sibling-variance` | Groups equal in method, origin and segment count that differ in one literal at index 2 or later; the siblings whose literal is identifier-like (a digit or `:`, or three or more hyphenated names under a plural literal) merge, never a stop word, and the other literals stay their own tools | singular previous literal plus `_id`, else `id` | +| `sibling-pair-variance` | Groups equal in method, origin and segment count that differ in two adjacent literals right after a plural literal at index 2 or later, with at least three distinct pairs and both positions varying (`/repos/{owner}/{repo}/issues`); stop words and `v1`-style versions never qualify | singular plural literal plus `_namespace`, then `_name` | With `sibling-variance` selected, literal siblings within its comparison scope fold into existing opt-in parameters as observed values unless they are stop words. @@ -388,7 +392,7 @@ cut to their first label, `_by_` appended when parameters exist, sanitized to `[a-z0-9_]`, at most 60 characters and made unique with numeric suffixes: `get_dashboards_by_uid`, `get_plugins_settings_by_plugin_id`. -`sibling-variance` compares routes of at most 32 segments; longer routes keep +`sibling-variance` and `sibling-pair-variance` compare routes of at most 32 segments; longer routes keep their grouped templates and the manifest says so in a limitation. `body_shape` is derived for `POST`, `PUT` and `PATCH` from the tool's earliest record when it carries an `application/json` or `application/*+json` request body: top-level @@ -403,11 +407,14 @@ typed unsupported errors as the api-surface view, including the canonical token `sensitive_values_unretained`. The manifest accumulator shares the api-surface admission model: the capture -clone, the inline envelope, record pointers, groups, every route part, observed +clone, the inline envelope (for an inline source), record pointers, groups, every route part, observed values, query names and body-shape keys are admitted before allocation, and each recorded body is charged as temporary bytes before it is parsed. Retained state and temporary work must fit together within the 96 MiB projection representation budget; exceeding it returns `resource_constraint` without a partial manifest. +Name a large capture with `{"kind": "evidence-file", "path": "/abs/capture.json"}` +rather than inline: response bodies the manifest never reads are then charged +once per value in the capture clone, not per character. ## Resource and lifecycle boundaries diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index 3d9db3ddab..fa010f3149 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -1,3 +1,6 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { analysisViewCaptureFixture, analysisViewCaptureEvidence, @@ -19,6 +22,7 @@ import { import { inspectAnalysisView, inspectAnalysisViewValidated, + inspectAnalysisViewWithFiles, summarizeRetainedAnalysis, } from "./AnalysisViewService.js"; import type { Evidence } from "../../domain/evidence.js"; @@ -27,6 +31,7 @@ import { err, ok } from "../../domain/result.js"; import { EvidenceLedger } from "../investigation/EvidenceLedger.js"; import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js"; import { isImmutableJsonSnapshot } from "../../domain/immutableJson.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; it.each(["layout", "javascript"])( "carries the retained %s source snapshot into repeatable shared views", @@ -860,3 +865,104 @@ it("admits raw_result and parameters before authenticating a portable capture", }); } }); + +describe("inspectAnalysisViewWithFiles", () => { + const view = { kind: "environment-manifest" } as const; + const writeEvidenceFile = async (value: unknown): Promise => { + const root = await createTestTempDirectory("rea-analysis-view-file-"); + const path = join(root, "capture.json"); + await writeFile(path, JSON.stringify(value)); + return path; + }; + + it("projects an Evidence file exactly as the same Evidence inline", async () => { + const parent = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + const fromFile = await inspectAnalysisViewWithFiles({ + source: { kind: "evidence-file", path: await writeEvidenceFile(parent) }, + view, + }); + const inline = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view, + }); + if (!fromFile.ok) throw fromFile.error; + if (!inline.ok) throw inline.error; + expect(fromFile.value.normalized_result).toEqual( + inline.value.normalized_result, + ); + expect(fromFile.value.parameters).toEqual(inline.value.parameters); + }); + + it("accepts from a file a capture whose response text exceeds the inline budget", async () => { + const capture = environmentManifestCaptureFixture(); + const reported = capture.records[0]!.reported as { + response: { content: { text: string } }; + }; + // Inline admission charges about 14 bytes per character; this body alone overruns it. + reported.response.content.text = "a".repeat( + Math.ceil(WEB_NETWORK_CAPTURE_LIMITS.outputBytes / 14) + 1024, + ); + const parent = analysisViewCaptureEvidence(capture); + expect( + inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisResourceConstraintError", resource: "memory" }, + }); + const fromFile = await inspectAnalysisViewWithFiles({ + source: { kind: "evidence-file", path: await writeEvidenceFile(parent) }, + view, + }); + if (!fromFile.ok) throw fromFile.error; + expect(fromFile.value.parameters).toMatchObject({ + parent_evidence_id: parent.evidence_id, + }); + }); + + it("refuses a relative path, a missing file and JSON that is not Evidence", async () => { + expect( + await inspectAnalysisViewWithFiles({ + source: { kind: "evidence-file", path: "capture.json" }, + view, + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); + const missing = join( + await createTestTempDirectory("rea-analysis-view-missing-"), + "absent.json", + ); + expect( + await inspectAnalysisViewWithFiles({ + source: { kind: "evidence-file", path: missing }, + view, + }), + ).toMatchObject({ ok: false, error: { _tag: "EvidenceFileError" } }); + const notEvidence = await inspectAnalysisViewWithFiles({ + source: { + kind: "evidence-file", + path: await writeEvidenceFile({ hello: 1 }), + }, + view, + }); + expect(notEvidence).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + }); + + it("leaves file sources to the file-aware entry point", () => { + expect( + inspectAnalysisView({ + source: { kind: "evidence-file", path: "/tmp/capture.json" }, + view, + }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + }); +}); diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 7ea9e0f645..74ddd48536 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -29,6 +29,7 @@ import { err, ok, type Result } from "../../domain/result.js"; import { WebNetworkApiSurfaceAccumulator } from "../../domain/analysisView/WebNetworkApiSurfaceAccumulator.js"; import { EnvironmentManifestAccumulator } from "../../domain/analysisView/EnvironmentManifestAccumulator.js"; import { admitAnalysisViewData } from "../../domain/analysisView/analysisViewDataAdmission.js"; +import { readJsonFile } from "../JsonFiles.js"; /** Authenticate a selected-view request and project already completed Evidence. */ export const inspectAnalysisView = ( @@ -48,6 +49,48 @@ export const inspectAnalysisView = ( return inspectAnalysisViewValidated(parsed.data, lookup); }; +/** Validate a request whose source may be an Evidence file, then project it. */ +export const inspectAnalysisViewWithFiles = async ( + rawInput: unknown, + lookup?: EvidenceLookup, +): Promise> => { + const parsed = inspectAnalysisViewInputSchema.safeParse(rawInput); + if (!parsed.success) + return err( + analysisInputErrorFromIssues( + INSPECT_ANALYSIS_VIEW_OPERATION, + parsed.error.issues, + rawInput, + { cause: parsed.error }, + ), + ); + return inspectAnalysisViewValidatedWithFiles(parsed.data, lookup); +}; + +/** + * Read and authenticate an Evidence-file source, then project it as retained + * Evidence: its strings are bounded by the file reader rather than charged as + * inline input. Other sources project unchanged. + */ +export const inspectAnalysisViewValidatedWithFiles = async ( + input: InspectAnalysisViewInput, + lookup?: EvidenceLookup, +): Promise> => { + if (input.source.kind !== "evidence-file") + return inspectAnalysisViewValidated(input, lookup); + const loaded = await readJsonFile(input.source.path); + if (!loaded.ok) return loaded; + const parent = authenticateEvidence(loaded.value, ["source", "path"], { + source: input.source, + }); + if (!parent.ok) return parent; + const id = parent.value.evidence_id; + return inspectAnalysisViewValidated( + { ...input, source: { kind: "retained-evidence", evidence_id: id } }, + (evidenceId) => (evidenceId === id ? parent.value : lookup?.(evidenceId)), + ); +}; + /** Authenticate borrowed inline Evidence after adapter envelope validation. */ export const inspectAnalysisViewValidated = ( input: InspectAnalysisViewInput, @@ -182,8 +225,28 @@ const resolveAnalysisViewSource = ( ): Result => { if (source.kind === "retained-evidence") return resolveEvidenceInput(source, lookup); + // Files are read by inspectAnalysisViewValidatedWithFiles, which the CLI and MCP adapters call. + if (source.kind === "evidence-file") + return err( + new AnalysisCapabilityUnavailableError( + ANALYSIS_VIEW_PROVIDER.id, + INSPECT_ANALYSIS_VIEW_OPERATION, + "evidence-file sources are read before projection", + ), + ); + return authenticateEvidence(source.evidence, ["source", "evidence"], { + source, + }); +}; + +/** Parse complete Evidence, reporting schema issues under the given request path. */ +const authenticateEvidence = ( + value: unknown, + path: readonly (string | number)[], + input: unknown, +): Result => { try { - return ok(parseEvidence(source.evidence)); + return ok(parseEvidence(value)); } catch (cause: unknown) { if (cause instanceof z.ZodError) return err( @@ -191,9 +254,9 @@ const resolveAnalysisViewSource = ( INSPECT_ANALYSIS_VIEW_OPERATION, cause.issues.map((issue) => ({ ...issue, - path: ["source", "evidence", ...issue.path], + path: [...path, ...issue.path], })), - { source }, + input, { cause }, ), ); diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index aaaca23233..3335012a02 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -1,6 +1,6 @@ import { z } from "incur"; -import { inspectAnalysisViewValidated } from "../application/analysisView/AnalysisViewService.js"; +import { inspectAnalysisViewValidatedWithFiles } from "../application/analysisView/AnalysisViewService.js"; import { readEvidenceBundle } from "../application/EvidenceBundleFiles.js"; import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js"; import { @@ -8,7 +8,7 @@ import { importEvidenceBundleInputSchema, } from "../contracts/sessionToolSchemas.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { parseCliJsonInput } from "../cliJsonInput.js"; +import { parseCliJsonInput, resolveCliJsonPaths } from "../cliJsonInput.js"; import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; @@ -82,7 +82,9 @@ export const registerAnalysisViewCommands = ( CLI_COMMANDS.inspectAnalysisView, ); if (!input.ok) return input.error; - const parsed = inspectAnalysisViewInputSchema.safeParse(input.value); + // An evidence-file path is resolved against the operator's working directory. + const value = resolveCliJsonPaths(input.value, [["source", "path"]]); + const parsed = inspectAnalysisViewInputSchema.safeParse(value); if (!parsed.success) return { error: "Application workflow failed", @@ -90,11 +92,11 @@ export const registerAnalysisViewCommands = ( analysisInputErrorFromIssues( CLI_COMMANDS.inspectAnalysisView, parsed.error.issues, - input.value, + value, ), ), }; - const result = inspectAnalysisViewValidated(parsed.data); + const result = await inspectAnalysisViewValidatedWithFiles(parsed.data); return result.ok ? jsonValueSchema.parse(result.value) : { diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index c2700c0cab..63309e9b1b 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Inspect selected views of completed 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 or inspect_web_network_capture Evidence without repeating analysis. 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Inspect selected views of completed 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 or inspect_web_network_capture Evidence without repeating analysis. 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. 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. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions; occurrence logical_paths matches exact recorded strings (omitted means all available, [] means none). source_complete distinguishes complete inventories from partial source records; page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/domain/analysisView/EnvironmentManifestAccumulator.ts b/src/domain/analysisView/EnvironmentManifestAccumulator.ts index 12a99db505..f4bdea7647 100644 --- a/src/domain/analysisView/EnvironmentManifestAccumulator.ts +++ b/src/domain/analysisView/EnvironmentManifestAccumulator.ts @@ -438,6 +438,22 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula }); } + /** One limitation per selected sibling rule when a route is too long for it to compare. */ + #segmentLimitLimitations(toolGroups: readonly ToolGroup[]): string[] { + if ( + !toolGroups.some( + (group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT, + ) + ) + return []; + return (["sibling-variance", "sibling-pair-variance"] as const) + .filter((rule) => this.#rules.has(rule)) + .map( + (rule) => + `Routes with more than ${String(SIBLING_VARIANCE_SEGMENT_LIMIT)} segments are not compared by ${rule}; they keep their grouped templates.`, + ); + } + /** Project the complete manifest; tools, records and exclusions are sorted deterministically. */ finish(): Result { const primary = this.#primaryOrigin(); @@ -493,14 +509,7 @@ export class EnvironmentManifestAccumulator extends WebNetworkProjectionAccumula "A top-level __proto__ key in a recorded JSON body is omitted from body_shape; it cannot be an own property of the published object.", ] : []), - ...(this.#rules.has("sibling-variance") && - toolGroups.some( - (group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT, - ) - ? [ - `Routes with more than ${String(SIBLING_VARIANCE_SEGMENT_LIMIT)} segments are not compared by sibling-variance; they keep their grouped templates.`, - ] - : []), + ...this.#segmentLimitLimitations(toolGroups), ...(this.#unrepresented > 0 ? [ `${String(this.#unrepresented)} retained records without a usable request line are not represented in the manifest.`, diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 38002281e1..aba36fd9b0 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -1,3 +1,6 @@ +import { isAbsolute } from "node:path"; + +import { localPathStringSchema } from "../localPath.js"; import { z } from "zod"; import { javascriptModuleReferencesRequestSchema, @@ -254,7 +257,7 @@ const inlineEvidenceInputSchema = z.unknown().nonoptional(); inlineEvidenceInputSchema._zod.processJSONSchema = evidenceSchema._zod.processJSONSchema; -/** Unauthenticated inline input or an exact same-session retained reference. */ +/** Unauthenticated inline input, an exact same-session retained reference, or one local Evidence file. */ export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("retained-evidence"), @@ -264,6 +267,17 @@ export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ kind: z.literal("inline"), evidence: inlineEvidenceInputSchema, }), + z.strictObject({ + kind: z.literal("evidence-file"), + path: localPathStringSchema + .refine(isAbsolute, { + message: + "path must be an absolute local filesystem path (for example /tmp/capture.json or C:\\rea\\capture.json)", + }) + .describe( + "Absolute local path of one completed Evidence JSON file. REA reads and authenticates it, then projects it like retained Evidence, so large captures are not charged as inline input.", + ), + }), ]); /** Public inspect_analysis_view input. */ diff --git a/src/domain/analysisView/environmentManifestRules.ts b/src/domain/analysisView/environmentManifestRules.ts index 18b2d65518..96fe127567 100644 --- a/src/domain/analysisView/environmentManifestRules.ts +++ b/src/domain/analysisView/environmentManifestRules.ts @@ -9,6 +9,7 @@ export const TEMPLATE_RULES = [ "after-uid-literal", "opaque-token", "sibling-variance", + "sibling-pair-variance", ] as const; export type TemplateRule = (typeof TEMPLATE_RULES)[number]; export type ManifestRule = RouteRule | TemplateRule; @@ -287,6 +288,98 @@ const mergeSiblingVariance = (groups: readonly ToolGroup[]): ToolGroup[] => { return [...current, ...beyondLimit]; }; +/** Fewest distinct literal pairs that make two adjacent positions one templated pair. */ +export const SIBLING_PAIR_MINIMUM = 3; + +/** Both positions are literals that are neither stop words nor versions, right after a plural literal. */ +const pairCandidate = (group: ToolGroup, position: number): boolean => { + const previous = group.parts[position - 1]; + const first = group.parts[position]; + const second = group.parts[position + 1]; + return ( + previous?.kind === "literal" && + previous.value.endsWith("s") && + !STOP_WORDS.has(previous.value) && + first?.kind === "literal" && + second?.kind === "literal" && + [first.value, second.value].every( + (value) => !STOP_WORDS.has(value) && !VERSION_TOKEN.test(value), + ) + ); +}; + +/** Merge one bucket when its pairs are distinct enough and both positions vary; otherwise keep its tools. */ +const mergePairBucket = ( + bucket: readonly ToolGroup[], + position: number, +): ToolGroup[] => { + const pairs = new Set( + bucket.map((group) => + compositeKey([ + literalAt(group, position), + literalAt(group, position + 1), + ]), + ), + ); + const firsts = new Set(bucket.map((group) => literalAt(group, position))); + const seconds = new Set( + bucket.map((group) => literalAt(group, position + 1)), + ); + if (pairs.size < SIBLING_PAIR_MINIMUM || firsts.size < 2 || seconds.size < 2) + return [...bucket]; + const first = earliest(bucket); + const shape = withParts( + first, + first.parts.map((part, index) => + index === position || index === position + 1 + ? param("sibling-pair-variance", [literalAt(first, index)]) + : part, + ), + ); + return [ + mergeGroups( + bucket.map((group) => (group === first ? shape : group)), + shape, + ), + ]; +}; + +/** Merge groups that differ in two adjacent literals after a plural literal, such as `/repos/{owner}/{repo}/…`. */ +const mergeSiblingPairs = (groups: readonly ToolGroup[]): ToolGroup[] => { + const beyondLimit = groups.filter( + (group) => group.parts.length > SIBLING_VARIANCE_SEGMENT_LIMIT, + ); + let current = groups.filter( + (group) => group.parts.length <= SIBLING_VARIANCE_SEGMENT_LIMIT, + ); + const longest = Math.max(0, ...current.map((group) => group.parts.length)); + for (let position = 2; position + 1 < longest; position += 1) { + const buckets = new Map(); + const output: ToolGroup[] = []; + for (const group of current) { + if (!pairCandidate(group, position)) { + output.push(group); + continue; + } + const key = compositeKey([ + group.method, + group.origin, + group.parts.length, + ...group.parts.map((part, index) => + index === position || index === position + 1 ? null : partKey(part), + ), + ]); + const bucket = buckets.get(key); + if (bucket === undefined) buckets.set(key, [group]); + else bucket.push(group); + } + for (const bucket of buckets.values()) + output.push(...mergePairBucket(bucket, position)); + current = output; + } + return [...current, ...beyondLimit]; +}; + /** Apply the selected opt-in rules in fixed order; every merge is an inference. */ export const applyTemplateRules = ( groups: readonly ToolGroup[], @@ -303,6 +396,7 @@ export const applyTemplateRules = ( ); current = rebucket(current); if (rules.has("sibling-variance")) current = mergeSiblingVariance(current); + if (rules.has("sibling-pair-variance")) current = mergeSiblingPairs(current); return current; }; @@ -343,6 +437,15 @@ const paramBaseName = (parts: readonly RoutePart[], index: number): string => { : ""; return stem.length > 0 ? `${stem}_id` : "id"; } + case "sibling-pair-variance": { + // The pair's first position follows the plural literal; the second follows the first. + const second = parts[index - 1]?.kind === "param"; + const plural = parts[second ? index - 2 : index - 1]; + const stem = + plural?.kind === "literal" ? sanitizeToken(singular(plural.value)) : ""; + const suffix = second ? "name" : "namespace"; + return stem.length > 0 ? `${stem}_${suffix}` : suffix; + } } }; diff --git a/src/domain/analysisView/environmentManifestView.test.ts b/src/domain/analysisView/environmentManifestView.test.ts index 2c2367f0ba..b41ee8779c 100644 --- a/src/domain/analysisView/environmentManifestView.test.ts +++ b/src/domain/analysisView/environmentManifestView.test.ts @@ -540,6 +540,84 @@ it("merges identifier-like siblings at index two or later and never folds stop w ).toEqual(["blue-two", "green-three", "red-one"]); }); +it("templates two adjacent literals after a plural literal as a pair when three pairs vary in both positions", () => { + const projected = manifest( + ["sibling-pair-variance"], + syntheticCapture([ + [ + "GET", + "https://g.test/api/v1/repos/lighthouse-rota/lamp-room-log/issues", + ], + ["GET", "https://g.test/api/v1/repos/granite-ledger/ledger-core/issues"], + [ + "GET", + "https://g.test/api/v1/repos/blue-heron-kitchen/recipe-book/issues", + ], + [ + "GET", + "https://g.test/api/v1/repos/lighthouse-rota/tide-tables/issues/4", + ], + [ + "GET", + "https://g.test/api/v1/repos/granite-ledger/audit-trail/issues/9", + ], + [ + "GET", + "https://g.test/api/v1/repos/saltmarsh-depot/crate-tracker/issues/2", + ], + ["GET", "https://g.test/api/v1/teams/one/alpha/members"], + ["GET", "https://g.test/api/v1/teams/two/beta/members"], + ["GET", "https://g.test/api/v1/orgs/acme/x1/hooks"], + ["GET", "https://g.test/api/v1/orgs/acme/x2/hooks"], + ["GET", "https://g.test/api/v1/orgs/acme/x3/hooks"], + ]), + ); + expect(projected.tools.map((tool) => tool.path_template)).toEqual([ + "/api/v1/orgs/acme/x1/hooks", + "/api/v1/orgs/acme/x2/hooks", + "/api/v1/orgs/acme/x3/hooks", + "/api/v1/repos/{repo_namespace}/{repo_name}/issues", + "/api/v1/repos/{repo_namespace}/{repo_name}/issues/{numeric}", + "/api/v1/teams/one/alpha/members", + "/api/v1/teams/two/beta/members", + ]); + expect( + projected.tools.find( + (tool) => + tool.path_template === + "/api/v1/repos/{repo_namespace}/{repo_name}/issues", + ), + ).toMatchObject({ + name: "get_repos_issues_by_repo_namespace_repo_name", + params: [ + { + name: "repo_namespace", + rule: "sibling-pair-variance", + observed_values: [ + "blue-heron-kitchen", + "granite-ledger", + "lighthouse-rota", + ], + }, + { + name: "repo_name", + rule: "sibling-pair-variance", + observed_values: ["lamp-room-log", "ledger-core", "recipe-book"], + }, + ], + record_ordinals: [0, 1, 2], + }); + expect( + projected.tools + .find((tool) => tool.path_template.endsWith("/issues/{numeric}")) + ?.params.map((param) => [param.name, param.rule]), + ).toEqual([ + ["repo_namespace", "sibling-pair-variance"], + ["repo_name", "sibling-pair-variance"], + ["id", "numeric"], + ]); +}); + it("never folds a literal sibling into a format-rule parameter", () => { const projected = manifest( ALL_RULES, diff --git a/src/domain/analysisView/environmentManifestView.ts b/src/domain/analysisView/environmentManifestView.ts index 226c72fb3a..376c3a41f5 100644 --- a/src/domain/analysisView/environmentManifestView.ts +++ b/src/domain/analysisView/environmentManifestView.ts @@ -23,7 +23,7 @@ export const environmentManifestViewRequestSchema = z.strictObject({ .meta({ uniqueItems: true }) .default([]) .describe( - "Opt-in template rules applied after the four format rules, each listed at most once: after-uid-literal templates the segment following a literal uid, opaque-token templates long mixed alphanumeric segments, sibling-variance merges groups that differ in one identifier-like segment. Literal sibling folding into existing opt-in parameters also requires sibling-variance. Merged parameters are inferences.", + "Opt-in template rules applied after the four format rules, each listed at most once: after-uid-literal templates the segment following a literal uid, opaque-token templates long mixed alphanumeric segments, sibling-variance merges groups that differ in one identifier-like segment, sibling-pair-variance merges groups that differ in two adjacent literals after a plural literal when at least three distinct pairs vary in both. Literal sibling folding into existing opt-in parameters also requires sibling-variance. Merged parameters are inferences.", ), include_query_parameter_names: z .boolean() diff --git a/src/server/registerAnalysisViewTool.ts b/src/server/registerAnalysisViewTool.ts index d427c0cb42..8feb6e8909 100644 --- a/src/server/registerAnalysisViewTool.ts +++ b/src/server/registerAnalysisViewTool.ts @@ -1,6 +1,6 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; -import { inspectAnalysisView } from "../application/analysisView/AnalysisViewService.js"; +import { inspectAnalysisViewWithFiles } from "../application/analysisView/AnalysisViewService.js"; import type { EvidenceLookup } from "../application/EvidenceInputResolver.js"; import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; import { toolContract } from "../contracts/toolContracts.js"; @@ -24,7 +24,7 @@ export const registerAnalysisViewTool = ( const registration = toolRegistrationOptions(contract); const handler = createEvidenceToolHandler( server, - (input) => Promise.resolve(inspectAnalysisView(input, evidenceById)), + (input) => inspectAnalysisViewWithFiles(input, evidenceById), logger, recordEvidence, withAdmittedAnalysis, diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index a8f8a83266..ca37b4f6af 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -547,6 +547,49 @@ cliTest( }, ); +cliTest( + "projects an environment manifest from an Evidence file through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-file-cli-"); + const capturePath = join(root, "capture.json"); + const parent = analysisViewCaptureEvidence( + environmentManifestCaptureFixture(), + ); + await writeFile(capturePath, JSON.stringify(parent)); + const requestPath = join(root, "view.json"); + const view = { + kind: "environment-manifest", + template_rules: ["opaque-token", "sibling-pair-variance"], + } as const; + await writeFile( + requestPath, + JSON.stringify({ + source: { kind: "evidence-file", path: capturePath }, + view, + }), + ); + const result = await cli.run({ + arguments: ["inspect-analysis-view", requestPath, "--json"], + }); + expect(result.exitCode, result.stdout).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.parameters).toMatchObject({ + parent_evidence_id: parent.evidence_id, + }); + // The file source projects exactly what the same Evidence projects inline. + const inline = inspectAnalysisView({ + source: { kind: "inline", evidence: parent }, + view, + }); + if (!inline.ok) throw inline.error; + expect(evidence.normalized_result).toEqual(inline.value.normalized_result); + expect(evidence.normalized_result).toMatchObject({ + kind: "environment-manifest", + origin: "http://grafana.example", + }); + }, +); + cliTest( "projects an environment manifest through the compiled CLI without recorded values", async ({ cli }) => { diff --git a/tests/boundary/mcp/analysisViewMcp.test.ts b/tests/boundary/mcp/analysisViewMcp.test.ts index 4846f1bd21..379df38e10 100644 --- a/tests/boundary/mcp/analysisViewMcp.test.ts +++ b/tests/boundary/mcp/analysisViewMcp.test.ts @@ -1167,7 +1167,7 @@ it.each(PUBLISHED_EXAMPLES)( }, ); -it("projects an environment manifest identically from retained and portable MCP Evidence", async () => { +it("projects an environment manifest identically from retained, portable and file MCP Evidence", async () => { const { client, session } = await connect(); const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas( client, @@ -1176,6 +1176,11 @@ it("projects an environment manifest identically from retained and portable MCP const capture = environmentManifestCaptureFixture(); const parent = analysisViewCaptureEvidence(capture); expect(session.recordEvidence(parent).ok).toBe(true); + const evidencePath = join( + await createTestTempDirectory("rea-evidence-file-mcp-"), + "capture.json", + ); + await writeFile(evidencePath, JSON.stringify(parent)); const view = { kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], @@ -1184,6 +1189,7 @@ it("projects an environment manifest identically from retained and portable MCP for (const source of [ { kind: "inline", evidence: parent }, { kind: "retained-evidence", evidence_id: parent.evidence_id }, + { kind: "evidence-file", path: evidencePath }, ]) { expect(ajv.validate(inputSchema, { source, view })).toBe(true); expect( @@ -1224,6 +1230,16 @@ it("projects an environment manifest identically from retained and portable MCP outputs.push(evidence.normalized_result); } expect(outputs[0]).toEqual(outputs[1]); + expect(outputs[2]).toEqual(outputs[0]); + // JSON Schema cannot express "absolute"; the tool refuses a relative path when called. + const relative = await client.callTool({ + name: "inspect_analysis_view", + arguments: { + source: { kind: "evidence-file", path: "capture.json" }, + view, + }, + }); + expect(parseMcpToolError(relative).error.code).toBe("invalid_request"); const hidden = createEvidence( { path: capture.artifact.path,