Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a74b11e99a | ||
|
|
eb85e932ac | ||
|
|
35a955ac15 | ||
|
|
761a898926 | ||
|
|
3334db2bde | ||
|
|
97359fe189 | ||
|
|
09a0c363d9 | ||
|
|
ac392da332 | ||
|
|
467f68412e | ||
|
|
22f4dc101a | ||
|
|
be011ea3ba | ||
|
|
40a33d3d3b | ||
|
|
464115f2ec | ||
|
|
883db73b3f | ||
|
|
5aafaa00ec | ||
|
|
92b373e660 | ||
|
|
69af884666 |
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
: {
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user