Compare commits

...
Author SHA1 Message Date
morluto a74b11e99a chore: sync branch with latest main 2026-10-08 23:52:20 +08:00
morluto eb85e932ac docs(artifacts): clarify extraction materialization evidence 2026-10-08 23:48:06 +08:00
morluto 35a955ac15 fix(artifacts): correlate unpacked integrity with materialized companions 2026-10-08 23:46:05 +08:00
morluto 761a898926 chore: merge main and reconcile CI projections 2026-10-08 23:45:07 +08:00
morluto 3334db2bde fix(ci): restore generated projections in test shards 2026-10-08 23:31:52 +08:00
morluto 97359fe189 chore: synchronize integrity policy branch with latest main guidance 2026-10-08 23:16:56 +08:00
morluto 09a0c363d9 fix(artifacts): preserve integrity evidence across workflows and integrate main 2026-10-08 23:16:16 +08:00
ac392da332 fix(artifacts): keep integrity policy and extraction claims aligned (#1083)
* fix(artifacts): keep integrity policy and extraction claims aligned

rea analyze routed a JavaScript application without the integrity policy,
so the remediation flag it recommends was rejected on that command. The
routed command now accepts --integrity-policy and forwards it.

Extraction copied every inventory contradiction into the result and said
those files were written. Only materialized occurrences stay in that list;
nested contradictions are named as unwritten.

* test(artifacts): type the nested extraction fixture as a directory path

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-08 22:41:35 +08:00
Kaoru0822 467f68412e Merge remote-tracking branch 'origin/main' into feat/artifact-integrity-policy 2026-10-07 14:50:36 +00:00
Kaoru0822andClaude Opus 5.5 22f4dc101a Merge origin/main into feat/artifact-integrity-policy
Keep main's skill metadata version (27) and regenerate the package, tool
catalog and product metadata for the merged tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:18:28 +00:00
Kaoru0822andClaude Opus 5.5 be011ea3ba Merge origin/main into feat/artifact-integrity-policy
Keep both new MCP extraction tests, use main's relocated Apple domain
imports, and regenerate the catalog metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:41:30 +00:00
morluto 40a33d3d3b chore: merge main and regenerate combined catalog metadata 2026-10-07 18:44:31 +08:00
morluto 464115f2ec chore: merge main and retain artifact integrity evidence 2026-10-07 18:02:05 +08:00
morluto 883db73b3f chore: merge main and resolve PR #902 conflicts 2026-10-07 17:28:56 +08:00
Kaoru0822andClaude Opus 5.5 5aafaa00ec fix(artifacts): carry recorded integrity contradictions into application workflows
A recorded contradiction appeared only in the analyze_javascript_application
response. Version and source comparisons, feature traces, and export-shape
comparisons read the application graph, which still claimed complete
coverage, so they could report complete-within-inputs results over
untrusted bytes. The graph is now partial when contradictions were
recorded, and its limitations, inherited by the result and the semantic
graph, name every contradicted path.

The mismatch remediation names both spellings: integrity_policy in MCP
and --integrity-policy on the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:50:41 +00:00
Kaoru0822 92b373e660 Merge remote-tracking branch 'origin/main' into feat/artifact-integrity-policy
# Conflicts:
#	docs/product-catalog.json
#	docs/verification/managed-conformance-manifest.json
#	skills/reverse-engineer-anything/SKILL.md
#	src/generatedMcpToolCatalog.ts
2026-10-07 08:37:04 +00:00
Kaoru0822andClaude Opus 5.5 69af884666 feat(artifacts): let extraction and JavaScript analysis record integrity mismatches
macOS code signing rewrites unpacked native modules after electron-builder
records their ASAR integrity, so the declared and observed hashes of
those .node files differ in most signed Electron apps. Claude, ChatGPT,
Notion, Obsidian, and Tabby all failed inspect_artifact,
extract_artifact, and analyze_javascript_application by default. Only
inspect_artifact offered integrity_policy, so extraction and JavaScript
analysis of these apps was impossible.

extract_artifact and analyze_javascript_application now accept the same
integrity_policy (default fail). With record-and-continue, extraction
writes the observed bytes, and JavaScript analysis reads contradicted
files as observed. Contradicted nested archives stay unexpanded. Both
results return the recorded integrity_contradictions and a limitation.
The mismatch remediation now names this option instead of only advising a
fresh copy, which cannot help for a signed installation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:28:44 +00:00
27 changed files with 530 additions and 75 deletions
+2 -6
View File
@@ -102,12 +102,8 @@ jobs:
with:
name: rea-dist
path: dist
- name: Restore generated outputs from downloaded build
run: |
node scripts/generate-mcp-tool-catalog.mjs
node scripts/generate-skill-metadata.mjs
node scripts/generate-product-catalog.mjs
node scripts/generate-completion-ledger.mjs
- name: Generate projections from downloaded build
run: npm run build:projections
- run: npm run test:ci:shard:run -- --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
+12 -4
View File
@@ -276,11 +276,19 @@ and returns its result and Evidence inline.
## Integrity record-and-continue
Artifact integrity fails closed by default. A request can explicitly select
Artifact integrity fails closed by default. `inspect_artifact`,
`extract_artifact`, and `analyze_javascript_application` accept
`integrity_policy=record-and-continue` when the investigation needs verified
siblings to continue after a mismatch.
siblings to continue after a mismatch. A common expected mismatch is an
unpacked native module that macOS code signing rewrote after the ASAR header
recorded its integrity.
Contradictory bytes are quarantined from nested expansion and recorded with
declared and observed hashes, trust, provenance, path, and unpacked state.
Verified siblings continue. Comparisons classify the result as a contradiction
and reconstruction cannot treat it as unchanged.
Verified siblings continue. Extraction writes the observed bytes and returns the
contradictions with its result; JavaScript analysis reads contradicted files as
observed and lists them in `integrity_contradictions`. Comparisons classify the
result as a contradiction and reconstruction cannot treat it as unchanged.
Application comparisons and feature traces retain the source integrity warnings
and report incomplete coverage. Extraction retains nested contradiction records
without claiming that the nested members were materialized separately.
+2 -1
View File
@@ -87,7 +87,8 @@
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
"prebuild": "npm run deps:check && npm run metadata:check",
"build": "node scripts/run-exclusive.mjs build npm run build:unlocked",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"build:unlocked": "node scripts/clean-build-output.mjs && tsc -p tsconfig.build.json && npm run build:projections",
"build:projections": "node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs",
"precheck": "npm run deps:check",
"check": "turbo run typecheck lint format:check knip metadata:check",
"precheck:fast": "npm run deps:check",
@@ -51,4 +51,5 @@ const parameters = (
input: AnalyzeJavaScriptApplicationInput,
): EvidenceObservation["parameters"] => ({
format: input.format,
integrity_policy: input.integrity_policy,
});
@@ -1,12 +1,18 @@
import { writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createPackageWithOptions } from "@electron/asar";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
import {
compareApplicationVersionsEvidence,
compareJavaScriptExportShapesEvidence,
traceApplicationFeatureEvidence,
} from "./JavaScriptApplicationWorkflowService.js";
describe("JavaScript application failure diagnostics", () => {
it("identifies the rejected result field in caller-visible diagnostics", async () => {
@@ -155,3 +161,107 @@ describe("JavaScript application failure diagnostics", () => {
},
);
});
const CONTRADICTION_LIMITATION =
"Observed bytes of 1 artifact file(s) contradict declared integrity and are untrusted: addon.node.";
describe("JavaScript application artifact integrity", () => {
it("analyzes a signed-after-packaging native module only when the mismatch is recorded", async () => {
const root = await createTestTempDirectory("rea-js-integrity-");
const source = join(root, "source");
await mkdir(source);
await writeFile(
join(source, "package.json"),
'{"name":"signed","main":"main.js"}',
);
await writeFile(
join(source, "main.js"),
"require('./addon.node'); export function value() { return {status: 'ready'}; }\n",
);
await writeFile(join(source, "addon.node"), "packed native bytes");
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "*.node" });
// macOS code signing rewrites unpacked binaries after the archive header
// has recorded their integrity.
await writeFile(
join(`${archive}.unpacked`, "addon.node"),
"signed native bytes",
);
const strict = await analyzeJavaScriptApplication({ input_path: archive });
expect(strict.ok).toBe(false);
if (strict.ok) throw new Error("Expected an integrity failure");
expect(projectAnalysisError(strict.error)).toMatchObject({
code: "artifact_integrity_mismatch",
details: { logical_path: "addon.node", unpacked: true },
});
const recorded = await analyzeJavaScriptApplication({
input_path: archive,
integrity_policy: "record-and-continue",
});
expect(recorded.ok).toBe(true);
if (!recorded.ok) throw new Error("Expected recorded analysis");
expect(recorded.value.parameters).toEqual({
format: "auto",
integrity_policy: "record-and-continue",
});
expect(recorded.value.normalized_result).toMatchObject({
format: "asar",
integrity_contradictions: [
{
logical_path: "addon.node",
unpacked: true,
trust: "observed-untrusted",
},
],
graph: { coverage: { status: "partial" } },
limitations: expect.arrayContaining([CONTRADICTION_LIMITATION]),
semantic_graph: {
limitations: expect.arrayContaining([CONTRADICTION_LIMITATION]),
},
});
// Workflows built on the recorded graph inherit its partial coverage.
const clean = await analyzeJavaScriptApplication({
input_path: source,
format: "directory",
});
if (!clean.ok) throw new Error("Expected clean analysis");
const comparison = compareApplicationVersionsEvidence({
left: clean.value,
right: recorded.value,
});
expect(comparison.ok).toBe(true);
if (!comparison.ok) throw new Error("Expected comparison");
expect(comparison.value.normalized_result).toMatchObject({
coverage: { status: "partial", right_graph_status: "partial" },
limitations: expect.arrayContaining([CONTRADICTION_LIMITATION]),
});
const shapes = compareJavaScriptExportShapesEvidence({
left: clean.value,
right: recorded.value,
left_module_path: "main.js",
left_export_name: "value",
right_module_path: "main.js",
right_export_name: "value",
});
expect(shapes.ok).toBe(true);
if (!shapes.ok) throw shapes.error;
expect(shapes.value.normalized_result).toMatchObject({
coverage: { status: "partial" },
limitations: expect.arrayContaining([CONTRADICTION_LIMITATION]),
});
const trace = traceApplicationFeatureEvidence({
application: recorded.value,
seed: { kind: "string", value: "nonexistent-feature" },
direction: "outgoing",
});
expect(trace.ok).toBe(true);
if (!trace.ok) throw trace.error;
expect(trace.value.normalized_result).toMatchObject({
graph: null,
limitations: expect.arrayContaining([CONTRADICTION_LIMITATION]),
});
});
});
@@ -55,6 +55,7 @@ export const analyzeJavaScriptApplicationValidated = async (
{
input_path: input.input_path,
format: input.format,
integrity_policy: input.integrity_policy,
},
options.signal,
options.progress,
@@ -92,6 +92,7 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
javascript !== null && javascript.parse_status === "partial",
);
const unknownGap =
context.snapshot.integrity_contradictions.length > 0 ||
context.analysis.parse_failures > 0 ||
context.fileSet.invalid_utf8_files > 0 ||
sourceMapPolicyGap ||
@@ -103,6 +104,20 @@ const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
return completeApplicationCoverage();
};
/**
* Disclose recorded integrity contradictions wherever the application graph
* is consumed; the graph is also partial, so comparisons and traces built on
* it do not claim completeness.
*/
export const integrityContradictionLimitations = (
snapshot: Pick<ArtifactInventorySnapshot, "integrity_contradictions">,
): string[] =>
snapshot.integrity_contradictions.length === 0
? []
: [
`Observed bytes of ${String(snapshot.integrity_contradictions.length)} artifact file(s) contradict declared integrity and are untrusted: ${snapshot.integrity_contradictions.map(({ logical_path: path }) => path).join(", ")}.`,
];
const graphLimitations = (
context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable",
@@ -124,6 +139,7 @@ const graphLimitations = (
);
return [
...context.analysis.limitations,
...integrityContradictionLimitations(context.snapshot),
"CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.",
"Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.",
"Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.",
@@ -5,9 +5,13 @@ import { createJavaScriptArtifactReader as createReader } from "../../artifacts/
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import type { IntegrityContradiction } from "../../domain/artifactGraph.js";
import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
import {
buildJavaScriptArtifactGraph,
integrityContradictionLimitations,
} from "./JavaScriptArtifactGraphBuilder.js";
import {
javascriptArtifactReconstructionInputSchema,
type JavaScriptArtifactReconstructionInput,
@@ -27,6 +31,7 @@ export interface JavaScriptArtifactReconstructionResult {
readonly graph: JavaScriptApplicationGraph;
readonly semantic_graph: JavaScriptSemanticGraph;
readonly electron_summary: ElectronBoundarySummary;
readonly integrity_contradictions: readonly IntegrityContradiction[];
readonly statistics: {
readonly relevant_files: number;
readonly nested_asar_containers: number;
@@ -55,7 +60,10 @@ export const reconstructJavaScriptArtifact = async (
abortIfNeeded(signal);
const path = await realpath(input.input_path);
const format = await resolveFormat(path, input);
const snapshot = await scanCanonicalArtifactInventory(path, { signal });
const snapshot = await scanCanonicalArtifactInventory(path, {
signal,
integrity: { mode: input.integrity_policy },
});
if (snapshot.manifest.root_format !== format)
throw new ArtifactReaderFailure(
"format",
@@ -101,6 +109,7 @@ export const reconstructJavaScriptArtifact = async (
snapshot.manifest.root_sha256,
graph,
analysis,
integrityContradictionLimitations(snapshot),
);
return {
input_path: path,
@@ -111,6 +120,7 @@ export const reconstructJavaScriptArtifact = async (
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(analysis),
integrity_contradictions: snapshot.integrity_contradictions,
statistics: {
relevant_files: files.files.length,
nested_asar_containers: files.containers.length,
@@ -1,9 +1,12 @@
import { z } from "zod";
import { artifactIntegrityPolicySchema } from "../../domain/artifactGraph.js";
/** Local ASAR/directory reconstruction request. */
export const javascriptArtifactReconstructionInputSchema = z.strictObject({
input_path: z.string().min(1),
format: z.enum(["auto", "asar", "directory"]).default("auto"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Parsed local reconstruction request. */
@@ -59,6 +59,8 @@ interface BuilderInput {
"graph_id" | "nodes"
>;
readonly analysis: JavaScriptArtifactAnalysis;
/** Source facts the semantic graph inherits, such as integrity contradictions. */
readonly sourceLimitations?: readonly string[];
}
interface FileContext extends SemanticFlowProjectionContext {
@@ -111,13 +113,19 @@ export const buildJavaScriptSemanticGraph = ({
rootArtifactSha256,
applicationGraph,
analysis,
sourceLimitations = [],
}: BuilderInput): JavaScriptSemanticGraph => {
const projection = createJavaScriptSemanticGraphProjection();
for (const analyzed of analysis.files) {
if (analyzed.semantic !== null)
projection.projectFile(analyzed.file, analyzed.semantic.ir);
}
return projection.finish(rootArtifactSha256, applicationGraph, analysis);
return projection.finish(
rootArtifactSha256,
applicationGraph,
analysis,
sourceLimitations,
);
};
/** File-local semantic projection that does not retain consumed source IR. */
@@ -130,6 +138,7 @@ export interface JavaScriptSemanticGraphProjection {
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
analysis: Pick<JavaScriptArtifactAnalysis, "truncated_scopes">,
sourceLimitations?: readonly string[],
) => JavaScriptSemanticGraph;
}
@@ -163,7 +172,12 @@ export const createJavaScriptSemanticGraphProjection =
};
return {
projectFile: projectSource,
finish: (rootArtifactSha256, applicationGraph, analysis) => {
finish: (
rootArtifactSha256,
applicationGraph,
analysis,
sourceLimitations = [],
) => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const unknowns = [...state.unknowns.values()];
@@ -206,6 +220,7 @@ export const createJavaScriptSemanticGraphProjection =
})),
},
limitations: [
...sourceLimitations,
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
+3
View File
@@ -171,6 +171,9 @@ class ArtifactClient implements AnalysisClient {
inputPath: this.target.sourcePath ?? this.target.path,
inputFormat: this.target.format,
outputRoot: parsed.output_root,
integrity: resolveArtifactIntegrityPolicy({
mode: parsed.integrity_policy,
}),
},
options?.signal,
);
+58 -35
View File
@@ -21,15 +21,19 @@ import {
type ArtifactGraphManifest,
type ArtifactNode,
type ArtifactOccurrence,
type IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import { scanArtifactInventory } from "../inventory/ArtifactInventory.js";
import type { ArtifactIntegrityPolicy } from "../inventory/types.js";
/** Local extraction input with the output root chosen by the adapter. */
export interface ArtifactExtractionInput {
readonly inputPath: string;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
/** Integrity mismatches fail unless the caller records and continues. */
readonly integrity?: ArtifactIntegrityPolicy;
}
/** Extract every regular inventory occurrence into an exclusively owned absent root. */
@@ -40,29 +44,25 @@ export const extractArtifact = async (
const sourcePath = await realpath(input.inputPath);
const snapshot = await scanArtifactInventory(sourcePath, {
signal,
integrity: input.integrity,
});
const selectedOccurrences = snapshot.occurrences.filter(
(occurrence) =>
(occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") &&
occurrence.logical_path !== ".",
);
const selectedIds = new Set(
selectedOccurrences.map(({ occurrence_id: id }) => id),
const occurrences = new Map(
snapshot.occurrences.map((occurrence) => [
occurrence.occurrence_id,
occurrence,
]),
);
const occurrences = new Map<string, ArtifactOccurrence>();
const neededNodes = new Set<string>();
collectOccurrences(
snapshot.occurrences,
selectedIds,
occurrences,
neededNodes,
);
const nodes = new Map<string, ArtifactNode>();
collectNodes(snapshot.nodes, neededNodes, nodes);
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
const inventory: LoadedInventory = {
manifest: snapshot.manifest,
occurrences,
nodes,
integrityContradictions: snapshot.integrity_contradictions,
};
const selected = selectedOccurrences.map((occurrence) => {
if (
@@ -187,6 +187,39 @@ const createExtractionResult = (
selected: readonly SelectedOccurrence[],
extracted: readonly ExtractedOccurrence[],
): ArtifactExtractionResult => {
const materializedIds = new Set(
selected.map(({ occurrence }) => occurrence.occurrence_id),
);
const materializedPaths = new Map(
selected.map(({ occurrence }) => [occurrence.logical_path, occurrence]),
);
const wasMaterialized = (contradiction: IntegrityContradiction): boolean => {
if (materializedIds.has(contradiction.occurrence_id)) return true;
if (!contradiction.unpacked) return false;
// An unpacked member is read beside its enclosing ASAR, not from ASAR bytes.
const occurrence = inventory.occurrences.get(contradiction.occurrence_id);
let parentId = occurrence?.parent_occurrence_id;
while (parentId !== null && parentId !== undefined) {
const parent = inventory.occurrences.get(parentId);
if (parent === undefined) return false;
if (
parent.artifact_id !== null &&
inventory.nodes.get(parent.artifact_id)?.format === "asar"
) {
const companion = materializedPaths.get(
`${parent.logical_path}.unpacked${contradiction.logical_path.slice(parent.logical_path.length)}`,
);
return companion?.artifact_id === occurrence?.artifact_id;
}
parentId = parent.parent_occurrence_id;
}
return false;
};
const writtenContradictions =
inventory.integrityContradictions.filter(wasMaterialized);
const nestedContradictions = inventory.integrityContradictions.filter(
(contradiction) => !wasMaterialized(contradiction),
);
const extractionSemantic = {
source_manifest_id: inventory.manifest.manifest_id,
selected_occurrence_ids: selected
@@ -206,8 +239,19 @@ const createExtractionResult = (
containment_verified: true,
cleanup: { attempted: false, verified: true, residual_paths: [] },
provenance: [],
integrity_contradictions: inventory.integrityContradictions,
limitations: [
"All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file.",
"All regular files in the active artifact were materialized. The artifacts list identifies written files; nested inventory observations may refer to additional logical paths.",
...(writtenContradictions.length === 0
? []
: [
`${String(writtenContradictions.length)} extracted file(s) contradict declared integrity; their observed bytes were written and are untrusted.`,
]),
...(nestedContradictions.length === 0
? []
: [
`${String(nestedContradictions.length)} nested integrity contradiction(s) were not written as their own files (${nestedContradictions.map(({ logical_path }) => logical_path).join(", ")}); their records describe inventory observations, not materialized files.`,
]),
],
});
};
@@ -216,30 +260,9 @@ interface LoadedInventory {
readonly manifest: ArtifactGraphManifest;
readonly occurrences: ReadonlyMap<string, ArtifactOccurrence>;
readonly nodes: ReadonlyMap<string, ArtifactNode>;
readonly integrityContradictions: readonly IntegrityContradiction[];
}
const collectOccurrences = (
items: readonly ArtifactOccurrence[],
selected: ReadonlySet<string>,
output: Map<string, ArtifactOccurrence>,
neededNodes: Set<string>,
): void => {
for (const item of items) {
if (!selected.has(item.occurrence_id)) continue;
output.set(item.occurrence_id, item);
if (item.artifact_id !== null) neededNodes.add(item.artifact_id);
}
};
const collectNodes = (
items: readonly ArtifactNode[],
selected: ReadonlySet<string>,
output: Map<string, ArtifactNode>,
): void => {
for (const item of items)
if (selected.has(item.artifact_id)) output.set(item.artifact_id, item);
};
const createReader = async (
path: string,
format: BinaryTarget["format"],
+11 -9
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { artifactIntegrityPolicySchema } from "../domain/artifactGraph.js";
import { runProviderAnalysis } from "../composition/directAnalysis.js";
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
@@ -193,14 +194,19 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z.object({}),
run: ({ args }) =>
options: z.object({
integrityPolicy: artifactIntegrityPolicySchema.describe(
"Behavior when declared artifact integrity does not match",
),
}),
run: ({ args, options }) =>
logCliCommand(logger, "extract-artifact", () =>
runProviderAnalysis(
args.path,
"extract_artifact",
{
output_root: createArtifactExtractionDestination(),
integrity_policy: options.integrityPolicy,
},
logger,
),
@@ -216,14 +222,10 @@ const registerInspectionCommand = (cli: CliInstance, logger: Logger): void => {
path: z.string().describe("Application or package path"),
}),
options: z.object({
integrityPolicy: z
.enum(["fail", "record-and-continue"])
.default("fail")
.describe("Behavior when declared artifact integrity does not match"),
integrityPolicy: artifactIntegrityPolicySchema.describe(
"Behavior when declared artifact integrity does not match",
),
}),
alias: {
integrityPolicy: "integrity-policy",
},
run: ({ args, options }) =>
logCliCommand(logger, "inspect-artifact", () =>
runProviderAnalysis(
+11 -2
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { artifactIntegrityPolicySchema } from "../domain/artifactGraph.js";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
@@ -51,7 +52,11 @@ const registerOverviewCommands = (
args: z.object({
path: z.string().describe("App, program, or analysis database path"),
}),
options: overviewOptions,
options: overviewOptions.extend({
integrityPolicy: artifactIntegrityPolicySchema.describe(
"Behavior when declared JavaScript artifact integrity does not match",
),
}),
run: ({ args, options, format }) =>
logCliCommand(logger, "analyze", () =>
runRoutedOverview(
@@ -123,6 +128,7 @@ const runRoutedOverview = async (
readonly snapshot?: string | undefined;
readonly "target-format"?: "dos-com" | undefined;
readonly provider?: string | undefined;
readonly integrityPolicy?: "fail" | "record-and-continue" | undefined;
},
logger: Logger,
output?: CliCommandOutput,
@@ -134,7 +140,10 @@ const runRoutedOverview = async (
(await isJavaScriptApplicationPath(path))
)
return runCliJavaScriptApplicationAnalysis(
{ input_path: resolve(path) },
{
input_path: resolve(path),
integrity_policy: options.integrityPolicy ?? "fail",
},
output,
);
return runDirectAnalysis(
+5 -1
View File
@@ -186,7 +186,11 @@ const registerJavaScriptApplicationCommand = (
run: ({ args, options, format }) =>
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
runCliJavaScriptApplicationAnalysis(
{ input_path: args.path, format: options.artifactFormat },
{
input_path: args.path,
format: options.artifactFormat,
integrity_policy: options.integrityPolicy,
},
resultOutput === undefined
? undefined
: {
+4
View File
@@ -1,4 +1,5 @@
import { z } from "incur";
import { artifactIntegrityPolicySchema } from "./domain/artifactGraph.js";
/** Accept an agent-selected browser observation duration without an artificial ceiling. */
export const observationDuration = (fallback: number, minimum = 0) =>
@@ -63,4 +64,7 @@ export const javascriptApplicationOptions = z.object({
.enum(["auto", "asar", "directory"])
.default("auto")
.describe("Application artifact format"),
integrityPolicy: artifactIntegrityPolicySchema.describe(
"Behavior when declared artifact integrity does not match",
),
});
+7 -3
View File
@@ -8,17 +8,21 @@ import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js";
import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
import { artifactIntegrityPolicySchema } from "../domain/artifactGraph.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
integrity_policy: z.enum(["fail", "record-and-continue"]).default("fail"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Extraction needs no selector: it materializes every regular child file. */
export const artifactExtractionInputSchema = z.strictObject({});
export const artifactExtractionInputSchema = z.strictObject({
integrity_policy: artifactIntegrityPolicySchema,
});
/** Provider input containing the destination chosen by the local adapter. */
export const artifactExtractionExecutionSchema = z.strictObject({
output_root: z.string().min(1),
integrity_policy: artifactIntegrityPolicySchema,
});
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
@@ -68,7 +72,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"extract_artifact",
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
"Extract all regular files from the active archive or application package into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and reports each mismatch as an untrusted integrity contradiction.",
artifactExtractionInputSchema,
),
artifact(
@@ -79,7 +79,7 @@ export const ELECTRON_TOOL_CONTRACTS = [
name: "analyze_javascript_application",
...toolContractMetadata("analyze_javascript_application"),
description:
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns all recovered graph nodes, edges, semantic relations, limitations, and coverage.",
"Reconstruct one local ASAR or extracted JavaScript application as an inline application graph without executing it. Returns all recovered graph nodes, edges, semantic relations, limitations, and coverage. Integrity mismatches, such as unpacked native modules signed after packaging, fail by default; record-and-continue analyzes the observed bytes and reports each mismatch as an untrusted integrity contradiction.",
kind: "electron-provider",
inputSchema: analyzeJavaScriptApplicationInputSchema,
outputSchema: applicationOutputSchema,
+14 -1
View File
@@ -157,7 +157,11 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => {
const standardMessage = standardErrorMessage(error._tag);
if (standardMessage !== undefined) return standardMessage;
if (error instanceof ArtifactOperationError)
return artifactMessage(error.reason);
return error.reason === "integrity" &&
error.artifactDetails !== undefined &&
INTEGRITY_POLICY_OPERATIONS.has(error.operation)
? "Artifact bytes contradict their declared integrity. If that is expected, such as an unpacked native module signed after packaging, rerun with the record-and-continue integrity policy (integrity_policy in MCP, --integrity-policy on the CLI) to keep the mismatch as an untrusted contradiction; otherwise get a fresh copy."
: artifactMessage(error.reason);
if (error instanceof EvidenceReferenceError)
return error.reason === "missing"
? `Evidence ${error.evidenceId} is not retained in this session. Supply complete inline Evidence, re-run its producer, or import its bundle before using this reference.`
@@ -245,6 +249,15 @@ const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
"HopperStartError",
]);
/** Operations whose callers can record integrity mismatches and continue. */
const INTEGRITY_POLICY_OPERATIONS: ReadonlySet<
ArtifactOperationError["operation"]
> = new Set([
"inspect_artifact",
"extract_artifact",
"analyze_javascript_application",
]);
const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
if (reason === "cancelled")
return "Artifact operation was cancelled. Start it again when ready.";
+7 -1
View File
@@ -122,8 +122,13 @@ const artifactOccurrenceSchema = z.object({
limitations: z.array(z.string()),
});
/** Behavior when observed bytes contradict declared artifact integrity. */
export const artifactIntegrityPolicySchema = z
.enum(["fail", "record-and-continue"])
.default("fail");
/** Observed bytes that contradict a container's declared integrity identity. */
const integrityContradictionSchema = z.object({
export const integrityContradictionSchema = z.object({
contradiction_id: contradictionIdSchema,
occurrence_id: occurrenceIdSchema,
parent_artifact_id: artifactIdSchema,
@@ -211,6 +216,7 @@ export const artifactExtractionResultSchema = z.object({
residual_paths: z.array(boundedRelativePathSchema),
}),
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
limitations: z.array(z.string()),
});
@@ -1,5 +1,9 @@
import { z } from "zod";
import {
artifactIntegrityPolicySchema,
integrityContradictionSchema,
} from "../artifactGraph.js";
import { javascriptApplicationGraphSchema } from "./javascriptApplicationGraph.js";
import { javaScriptSemanticGraphSchema } from "./javascriptSemanticGraph.js";
import { digestSchema } from "../digests.js";
@@ -11,6 +15,7 @@ const countSchema = z.number().int().min(0);
export const analyzeJavaScriptApplicationInputSchema = z.strictObject({
input_path: z.string().min(1),
format: z.enum(["auto", "asar", "directory"]).default("auto"),
integrity_policy: artifactIntegrityPolicySchema,
});
/** Compact counts for the high-level Electron architecture/security surface. */
@@ -62,6 +67,7 @@ export const javascriptApplicationAnalysisResultSchema = z
graph: javascriptApplicationGraphSchema,
summary: electronBoundarySummarySchema,
statistics: reconstructionStatisticsSchema,
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
limitations: z.array(z.string().min(1)),
semantic_graph: javaScriptSemanticGraphSchema,
})
@@ -149,6 +149,8 @@ const comparisonLimitations = (
input: ApplicationVersionComparisonProjectionInput,
): string[] =>
uniqueSorted([
...input.left.graph.limitations,
...input.right.graph.limitations,
"Application comparison never uses bundler module ordinals as persistent identity and performs no fuzzy pairing.",
"Exact digest equality proves byte identity only; source-map, structural-fingerprint, and semantic-key matches retain inferred confidence.",
"Minified names and chunk locations are not assumed stable across versions.",
@@ -66,7 +66,11 @@ export const compareJavaScriptExportShapes = (
allChanges,
omittedChanges,
});
const limitations = comparisonLimitations(left, right, coverage.status);
const limitations = uniqueSorted([
...input.left.graph.limitations,
...input.right.graph.limitations,
...comparisonLimitations(left, right, coverage.status),
]);
const semantic = {
left: left.selection,
right: right.selection,
@@ -119,6 +119,7 @@ const noMatchResult = (
evidence_links: [input.sourceEvidenceId],
limitations: uniqueSorted([
"No graph entity matched the literal seed; this is not evidence that the feature is absent.",
...input.graph.limitations,
...sourceCoverageLimitations(input.graph),
]),
};
@@ -239,6 +240,7 @@ const traceCoverageStatus = (
const traceLimitations = (graph: JavaScriptApplicationGraph): string[] =>
uniqueSorted([
...graph.limitations,
"A trace reports graph relationships, not proof that code executed or that a feature is reachable in every state.",
"Static, native, passive-runtime, inferred, and unknown facts retain their original authority in the returned graph.",
"Native handoffs never open a binary or invoke a provider automatically; snapshot reuse remains provider/profile/target exact.",
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createPackageWithOptions } from "@electron/asar";
import { Filter } from "incur";
import { expect } from "vitest";
import { z } from "zod";
@@ -116,3 +117,42 @@ cliTest(
expect(output.json).toEqual({});
},
);
for (const command of ["analyze", "analyze-javascript-application"]) {
cliTest(
`${command} forwards the integrity policy while preserving JSON output`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-js-cli-integrity-");
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "main.js"), "require('./addon.node');\n");
await writeFile(join(source, "addon.node"), "original");
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "*.node" });
await writeFile(join(`${archive}.unpacked`, "addon.node"), "changed");
const strict = await cli.run({ arguments: [command, archive, "--json"] });
expect(strict.json).toMatchObject({
code: "artifact_integrity_mismatch",
});
const recorded = await cli.run({
arguments: [
command,
archive,
"--integrity-policy",
"record-and-continue",
"--json",
],
});
expect(recorded.exitCode).toBe(0);
expect(recorded.json).toMatchObject({
parameters: { integrity_policy: "record-and-continue" },
normalized_result: {
integrity_contradictions: [
{ logical_path: "addon.node", trust: "observed-untrusted" },
],
graph: { coverage: { status: "partial" } },
},
});
},
);
}
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createPackage, createPackageWithOptions } from "@electron/asar";
import { expect, it } from "vitest";
import { extractArtifact } from "../../../src/artifacts/extraction/ArtifactExtraction.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it("does not claim unmaterialized nested integrity contradictions were extracted", async () => {
const root = await createTestTempDirectory("rea-nested-integrity-extract-");
const source = join(root, "source");
const original = "console.log('ok');\n";
const changed = "console.log('no');\n";
await mkdir(source);
await writeFile(join(source, "main.js"), original);
const app = join(root, "app");
await mkdir(app);
const archive = join(app, "app.asar");
await createPackage(source, archive);
const bytes = await readFile(archive);
const contentOffset = bytes.indexOf(original);
expect(contentOffset).toBeGreaterThanOrEqual(0);
bytes.write(changed, contentOffset, "utf8");
await writeFile(archive, bytes);
const result = await extractArtifact({
inputPath: app,
// A directory path is read as a directory before this format is consulted.
inputFormat: "zip",
outputRoot: join(root, "out"),
integrity: { mode: "record-and-continue" },
});
expect(result.artifacts.map(({ relative_path }) => relative_path)).toEqual([
"app.asar",
]);
expect(result.integrity_contradictions).toEqual([
expect.objectContaining({
logical_path: "app.asar/main.js",
declared_sha256: createHash("sha256").update(original).digest("hex"),
observed_sha256: createHash("sha256").update(changed).digest("hex"),
trust: "observed-untrusted",
}),
]);
expect(result.limitations.join("\n")).toContain(
"were not written as their own files",
);
expect(result.limitations.join("\n")).toContain("main.js");
expect(result.limitations.join("\n")).not.toContain(
"extracted file(s) contradict",
);
});
it.each(["addon.node", "lib/addon.node"])(
"recognizes unpacked companion %s materialized under its filesystem path",
async (member) => {
const root = await createTestTempDirectory(
"rea-unpacked-integrity-extract-",
);
const source = join(root, "source");
const app = join(root, "app");
await mkdir(source);
await mkdir(app);
const original = "unsigned addon bytes";
const observed = "signed addon bytes";
await mkdir(join(source, "lib"));
await writeFile(join(source, member), original);
const archive = join(app, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "**/*.node" });
await writeFile(join(`${archive}.unpacked`, member), observed);
const output = join(root, "out");
const result = await extractArtifact({
inputPath: app,
inputFormat: "zip",
outputRoot: output,
integrity: { mode: "record-and-continue" },
});
expect(
await readFile(join(output, `app.asar.unpacked/${member}`), "utf8"),
).toBe(observed);
expect(result.integrity_contradictions).toEqual([
expect.objectContaining({
logical_path: `app.asar/${member}`,
unpacked: true,
}),
]);
expect(result.limitations.join("\n")).toContain(
"1 extracted file(s) contradict declared integrity",
);
expect(result.limitations.join("\n")).not.toContain(
"were not written as their own files",
);
},
);
+80 -4
View File
@@ -66,8 +66,7 @@ it.each([
error: {
code: "artifact_integrity_mismatch",
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
message: INTEGRITY_REMEDIATION,
details: {
logical_path: "main.js",
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
@@ -76,8 +75,7 @@ it.each([
},
retryable: false,
remediation: {
action:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
action: INTEGRITY_REMEDIATION,
},
},
});
@@ -92,6 +90,9 @@ it.each([
20_000,
);
const INTEGRITY_REMEDIATION =
"Artifact bytes contradict their declared integrity. If that is expected, such as an unpacked native module signed after packaging, rerun with the record-and-continue integrity policy (integrity_policy in MCP, --integrity-policy on the CLI) to keep the mismatch as an untrusted contradiction; otherwise get a fresh copy.";
const artifactInventoryResultSchema = z.object({
occurrences: z.array(
z.object({ logical_path: z.string(), hash_status: z.string() }),
@@ -148,6 +149,81 @@ it("extracts an active archive through MCP when requested", async () => {
}
});
it("extracts an unpacked integrity mismatch only when the caller records it", async () => {
const root = await createTestTempDirectory("rea-asar-extract-continue-mcp-");
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "main.js"), "console.log('ok');\n");
await writeFile(join(source, "kept.txt"), "kept\n");
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, { unpack: "*.js" });
// Code signing after packaging changes unpacked bytes in the same way.
await writeFile(
join(`${archive}.unpacked`, "main.js"),
"console.log('no');\n",
);
const session = createTestBinarySession(new ArtifactProvider());
const server = createServer(session, session);
const client = new Client({ name: "asar-extract-continue", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
let outputRoot: string | undefined;
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
const strict = await client.callTool({
name: "extract_artifact",
arguments: {},
});
expect(strict.isError).toBe(true);
expect(strict.structuredContent).toMatchObject({
error: {
code: "artifact_integrity_mismatch",
message: INTEGRITY_REMEDIATION,
},
});
const result = await client.callTool({
name: "extract_artifact",
arguments: { integrity_policy: "record-and-continue" },
});
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
true,
);
const extraction = z
.object({
output_root: z.string(),
integrity_contradictions:
artifactInventoryResultSchema.shape.integrity_contradictions,
limitations: z.array(z.string()),
})
.parse(compactResult(result.structuredContent).result);
outputRoot = extraction.output_root;
expect(await readFile(join(outputRoot, "main.js"), "utf8")).toBe(
"console.log('no');\n",
);
expect(await readFile(join(outputRoot, "kept.txt"), "utf8")).toBe("kept\n");
expect(extraction.integrity_contradictions).toEqual([
expect.objectContaining({
logical_path: "main.js",
trust: "observed-untrusted",
}),
]);
expect(extraction.limitations).toContain(
"1 extracted file(s) contradict declared integrity; their observed bytes were written and are untrusted.",
);
} finally {
await Promise.allSettled([client.close(), server.close(), session.close()]);
if (outputRoot !== undefined)
await rm(outputRoot, { recursive: true, force: true });
}
});
it("extracts a macOS app bundle through MCP", async () => {
const root = await createTestTempDirectory("rea-artifact-extract-app-mcp-");
const contents = join(root, "Fixture.app", "Contents");