fix: resolve triaged correctness issues

This commit is contained in:
N0zoM1z0 2026-07-14 14:39:02 +08:00
parent 998697155d
commit e8ed3073ac
47 changed files with 992 additions and 197 deletions

View File

@ -95,11 +95,11 @@ export class AnalysisSnapshotCache {
this.#target.architecture !== snapshot.target.architecture)
)
this.#entries.clear();
this.#target = snapshot.target;
this.#target = structuredClone(snapshot.target);
let imported = 0;
for (const entry of snapshot.entries) {
if (!this.#entries.has(entry.query_id)) imported += 1;
this.#entries.set(entry.query_id, entry);
this.#entries.set(entry.query_id, structuredClone(entry));
}
return imported;
}
@ -114,7 +114,7 @@ export class AnalysisSnapshotCache {
snapshot_version: 1,
target: snapshotTarget(target),
entries: this.entries(),
evidence_bundle: evidenceBundle,
evidence_bundle: structuredClone(evidenceBundle),
});
}
@ -138,9 +138,9 @@ export class AnalysisSnapshotCache {
/** Return canonical entries for persistence. */
entries(): AnalysisSnapshotEntry[] {
return [...this.#entries.values()].sort((left, right) =>
left.query_id.localeCompare(right.query_id),
);
return [...this.#entries.values()]
.sort((left, right) => left.query_id.localeCompare(right.query_id))
.map((entry) => structuredClone(entry));
}
/** Replay an exact provider-specific query, marking its cached provenance. */
@ -159,7 +159,7 @@ export class AnalysisSnapshotCache {
const cached = this.#entries.get(queryId);
if (cached === undefined) return undefined;
const subject = cached.execution.subject;
return {
return structuredClone({
result: cached.execution.result,
rawResult: cached.execution.raw_result,
provider: cached.execution.provider,
@ -183,7 +183,7 @@ export class AnalysisSnapshotCache {
format: subject.format,
architecture: subject.architecture,
},
};
});
}
/** Record one successful immutable call unless the cache is full. */

View File

@ -30,6 +30,17 @@ export interface MutableOccurrence {
limitations: string[];
}
/** Index the first occurrence for each canonical logical path. */
export const indexOccurrencesByPath = (
occurrences: readonly MutableOccurrence[],
): ReadonlyMap<string, MutableOccurrence> => {
const byPath = new Map<string, MutableOccurrence>();
for (const occurrence of occurrences)
if (!byPath.has(occurrence.logical_path))
byPath.set(occurrence.logical_path, occurrence);
return byPath;
};
export const createOccurrence = (
entry: ArtifactEntry,
path: string,
@ -196,6 +207,7 @@ export const createArtifactEdges = (
producer?: ArtifactCommand,
): ArtifactEdge[] => {
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
const byPath = indexOccurrencesByPath(occurrences);
const edges: ArtifactEdge[] = [];
for (const occurrence of occurrences) {
if (
@ -204,10 +216,7 @@ export const createArtifactEdges = (
)
continue;
const mappedSource = occurrence.logical_path.endsWith(".map")
? occurrences.find(
({ logical_path: path }) =>
path === occurrence.logical_path.slice(0, -".map".length),
)
? byPath.get(occurrence.logical_path.slice(0, -".map".length))
: undefined;
const parentArtifactId =
mappedSource?.artifact_id ??

View File

@ -33,6 +33,7 @@ import {
createOccurrence,
createRootNode,
digestCanonical,
indexOccurrencesByPath,
materializeDirectoryNodes,
nearestParent,
pageOf,
@ -180,11 +181,10 @@ export const scanCanonicalArtifactInventory = async (
const occurrenceById = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
const occurrenceByPath = indexOccurrencesByPath(occurrences);
const integrityContradictions = pendingContradictions.map(
(contradiction): IntegrityContradiction => {
const occurrence = occurrences.find(
({ logical_path: path }) => path === contradiction.logicalPath,
);
const occurrence = occurrenceByPath.get(contradiction.logicalPath);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"integrity",

View File

@ -113,7 +113,7 @@ export class BinarySession implements BinarySessionPort {
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
if (operation !== undefined) {
const exact = this.#capabilities?.get(operation)?.provider;
if (exact !== undefined) return exact;
if (exact !== undefined) return structuredClone(exact);
if (enhancedToolNameSchema.safeParse(operation).success) {
const providers = new Map<string, ProviderIdentity>();
for (const descriptor of this.#capabilities?.values() ?? [])
@ -124,11 +124,11 @@ export class BinarySession implements BinarySessionPort {
providers.set(descriptor.provider.id, descriptor.provider);
if (providers.size === 1) {
const provider = providers.values().next().value;
if (provider !== undefined) return provider;
if (provider !== undefined) return structuredClone(provider);
}
}
}
return this.#providerIdentity;
return structuredClone(this.#providerIdentity);
}
/** Observe runtime provider-health changes that affect discovery metadata. */
@ -223,18 +223,21 @@ export class BinarySession implements BinarySessionPort {
workspaceId: string,
revision: number,
): InvestigationWorkspace | undefined {
return this.#investigationWorkspaces.get(
const workspace = this.#investigationWorkspaces.get(
`${workspaceId}:${String(revision)}`,
);
return workspace === undefined ? undefined : structuredClone(workspace);
}
/** List retained workspace revisions in canonical identity order. */
investigationWorkspaces(): readonly InvestigationWorkspace[] {
return [...this.#investigationWorkspaces.values()].sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
);
return [...this.#investigationWorkspaces.values()]
.sort(
(left, right) =>
left.workspace_id.localeCompare(right.workspace_id) ||
left.revision - right.revision,
)
.map((workspace) => structuredClone(workspace));
}
/** Create an approved residual unknown and immutable mutation evidence. */
@ -468,7 +471,9 @@ export class BinarySession implements BinarySessionPort {
/** Return the immutable artifact identity captured before Hopper launched. */
activeTarget(): BinaryTarget | undefined {
return this.#active?.target;
return this.#active === undefined
? undefined
: structuredClone(this.#active.target);
}
/**

View File

@ -122,9 +122,11 @@ export class EvidenceLedger {
/** Export records in deterministic, semantically irrelevant ID order. */
export(): EvidenceBundle {
return createEvidenceBundle(
[...this.#records.values()],
[...this.#unknownRevisions.values()],
return structuredClone(
createEvidenceBundle(
[...this.#records.values()],
[...this.#unknownRevisions.values()],
),
);
}
@ -232,7 +234,7 @@ export class EvidenceLedger {
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(unknown);
return ok(structuredClone(unknown));
}
/** Apply one approved full-state update using optimistic revision matching. */
@ -277,7 +279,8 @@ export class EvidenceLedger {
unknown.severity === filters.severity) &&
(filters.domain === undefined || unknown.domain === filters.domain),
)
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id));
.sort((left, right) => left.unknown_id.localeCompare(right.unknown_id))
.map((unknown) => structuredClone(unknown));
}
/** Return current resolution validity; imported invalid states are rejected. */
@ -295,7 +298,7 @@ export class EvidenceLedger {
return ok({
valid: unknown.status === "resolved",
truthVerified: unknown.resolution?.disposition === "verified",
unknown,
unknown: structuredClone(unknown),
});
}
@ -331,7 +334,7 @@ export class EvidenceLedger {
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
if (!checked.ok) return checked;
this.#commit(pendingRecords, pendingUnknowns, checked.value);
return ok(unknown);
return ok(structuredClone(unknown));
}
#validateCandidate(

View File

@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { IPty } from "node-pty";
@ -401,6 +401,7 @@ export const prepareProcessCapture = async (
scenario: ProcessScenario,
policy: ProcessExecutionPolicy,
signal: AbortSignal | undefined,
host: ProcessPreparationHost = systemProcessPreparationHost,
): Promise<{
readonly temporaryRoot: string;
readonly runId: string;
@ -416,11 +417,29 @@ export const prepareProcessCapture = async (
await assertRealPathAuthority(scenario, policy);
assertNotCancelled(signal);
const before = await snapshotRoots(scenario, signal);
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
const runId = randomUUID();
const home = join(temporaryRoot, "home");
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
return { temporaryRoot, runId, home, before };
const temporaryRoot = await host.createTemporaryRoot();
try {
const runId = randomUUID();
const home = join(temporaryRoot, "home");
await host.createHome(home);
return { temporaryRoot, runId, home, before };
} catch (cause: unknown) {
await host.cleanup(temporaryRoot);
throw cause;
}
};
/** Filesystem seam for allocating and cleaning a capture's private home. */
export interface ProcessPreparationHost {
createTemporaryRoot(): Promise<string>;
createHome(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
}
const systemProcessPreparationHost: ProcessPreparationHost = {
createTemporaryRoot: () => mkdtemp(join(tmpdir(), "rea-process-")),
createHome: (path) => mkdir(path),
cleanup: (path) => rm(path, { recursive: true, force: true }),
};
const processPolicyMessage = (

View File

@ -126,7 +126,7 @@ export class ProcessCheckpoints {
effects: classifyFilesystemEffects(previous, after.files),
truncated: after.truncated,
});
return this.#captures;
return structuredClone(this.#captures);
}
/** Cancel scheduled captures and await any snapshot already in progress. */

View File

@ -112,7 +112,12 @@ export const compareProcessEvidenceFiles = async (
};
const parseCaptureEvidence = (input: unknown) => {
const evidence = parseEvidence(input);
let evidence;
try {
evidence = parseEvidence(input);
} catch {
throw invalidCaptureEvidence();
}
if (
evidence.operation !== "capture_process_scenario" ||
evidence.predicate_type !== "rea.process-capture/v4" ||
@ -129,12 +134,22 @@ const parseCaptureEvidence = (input: unknown) => {
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
);
}
return {
id: evidence.evidence_id,
capture: parseProcessCapture(evidence.normalized_result),
};
try {
return {
id: evidence.evidence_id,
capture: parseProcessCapture(evidence.normalized_result),
};
} catch {
throw invalidCaptureEvidence();
}
};
const invalidCaptureEvidence = (): ProcessCliFailure =>
new ProcessCliFailure(
"invalid_input",
"Capture evidence is malformed. Create new capture evidence, then try again.",
);
const readJson = async (path: string): Promise<unknown> => {
let bytes;
try {

View File

@ -31,7 +31,7 @@ export const normalizeProcessText = (
);
}
if (scenario.normalization.pids)
normalized = normalized.replaceAll(String(pid), "<pid>");
normalized = normalizePidTokens(normalized, [pid]);
if (scenario.normalization.ports)
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
for (const pattern of scenario.normalization.patterns)
@ -57,6 +57,17 @@ export const normalizeProcessSamples = (
for (const identifier of identifiers)
if (identifier > 0 && !mapping.has(identifier))
mapping.set(identifier, mapping.size + 1);
const normalizeCommand = (command: string): string => {
const normalized = normalizeProcessText(
command,
scenario,
"<no-temporary-root>",
rootPid,
);
return scenario.normalization.pids
? normalizePidTokens(normalized, identifiers)
: normalized;
};
return samples.map((sample) => ({
at_ms:
Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
@ -69,15 +80,25 @@ export const normalizeProcessSamples = (
: (mapping.get(sample.process_group_id) ?? 0),
session_id:
sample.session_id === null ? null : (mapping.get(sample.session_id) ?? 0),
command: normalizeProcessText(
sample.command,
scenario,
"<no-temporary-root>",
rootPid,
),
command: normalizeCommand(sample.command),
}));
};
const normalizePidTokens = (
value: string,
identifiers: readonly number[],
): string => {
const tokens = [...new Set(identifiers)]
.filter((identifier) => Number.isSafeInteger(identifier) && identifier > 0)
.sort((left, right) => right - left)
.map(String);
if (tokens.length === 0) return value;
return value.replace(
new RegExp(`(?<!\\d)(?:${tokens.join("|")})(?!\\d)`, "gu"),
"<pid>",
);
};
export const redactProtocolEvents = (
events: readonly ProcessCapture["protocol_events"][number][],
scenario: ProcessScenario,

View File

@ -40,11 +40,14 @@ export type ProcessGroupObservation =
| { readonly state: "alive" }
| { readonly state: "unverifiable"; readonly reason: string };
const parseEnvironment = (value: string): Readonly<Record<string, string>> =>
/** Parse the NUL-delimited Linux process environment without nameless keys. */
export const parseProcessEnvironment = (
value: string,
): Readonly<Record<string, string>> =>
Object.fromEntries(
value
.split("\0")
.filter((entry) => entry.includes("="))
.filter((entry) => entry.indexOf("=") > 0)
.map((entry) => {
const separator = entry.indexOf("=");
return [entry.slice(0, separator), entry.slice(separator + 1)];
@ -72,7 +75,9 @@ const systemHost: ProcessOwnershipHost = {
},
async environment(pid) {
if (process.platform === "linux") {
return parseEnvironment(await readFile(`/proc/${pid}/environ`, "utf8"));
return parseProcessEnvironment(
await readFile(`/proc/${pid}/environ`, "utf8"),
);
}
const { stdout } = await execFileAsync("ps", ["eww", "-p", String(pid)]);
const environment: Record<string, string> = {};

View File

@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import { promisify } from "node:util";
import type { ProcessSample } from "../domain/processCapture.js";
@ -104,25 +104,79 @@ const readProcessCommand = async (
}
};
const readLinuxChildren = async (
/** Procfs reads used to enumerate children created by every process thread. */
export interface LinuxChildrenHost {
taskIds(
pid: number,
signal: AbortSignal,
): Promise<readonly number[] | undefined>;
children(
pid: number,
taskId: number,
signal: AbortSignal,
): Promise<string | undefined>;
}
const systemLinuxChildrenHost: LinuxChildrenHost = {
async taskIds(pid, signal) {
try {
signal.throwIfAborted();
const entries = await readdir(`/proc/${String(pid)}/task`);
signal.throwIfAborted();
return entries
.filter((entry) => /^\d+$/.test(entry))
.map(Number)
.filter((taskId) => Number.isSafeInteger(taskId) && taskId > 0)
.sort((left, right) => left - right);
} catch (cause: unknown) {
return handleProcReadFailure(cause);
}
},
async children(pid, taskId, signal) {
try {
return await readFile(
`/proc/${String(pid)}/task/${String(taskId)}/children`,
{ encoding: "utf8", signal },
);
} catch (cause: unknown) {
return handleProcReadFailure(cause);
}
},
};
/** Read the deduplicated children created by every thread in a Linux process. */
export const readLinuxChildren = async (
pid: number,
signal: AbortSignal,
host: LinuxChildrenHost = systemLinuxChildrenHost,
): Promise<readonly number[] | undefined> => {
let text: string;
try {
text = await readFile(`/proc/${String(pid)}/task/${String(pid)}/children`, {
encoding: "utf8",
signal,
});
} catch (cause: unknown) {
return handleProcReadFailure(cause);
const taskIds = await host.taskIds(pid, signal);
if (taskIds === undefined) return undefined;
const children = new Set<number>();
let successfulReads = 0;
for (
let offset = 0;
offset < taskIds.length;
offset += PROC_READ_CONCURRENCY
) {
const texts = await Promise.all(
taskIds
.slice(offset, offset + PROC_READ_CONCURRENCY)
.map((taskId) => host.children(pid, taskId, signal)),
);
for (const text of texts) {
if (text === undefined) continue;
successfulReads += 1;
for (const value of text.trim().split(/\s+/)) {
if (!/^\d+$/.test(value)) continue;
const child = Number(value);
if (Number.isSafeInteger(child) && child > 0) children.add(child);
}
}
}
return text
.trim()
.split(/\s+/)
.filter((value) => /^\d+$/.test(value))
.map(Number)
.filter((child) => Number.isSafeInteger(child) && child > 0);
return successfulReads === 0
? undefined
: [...children].sort((left, right) => left - right);
};
const inspectProcess = async (

View File

@ -1,6 +1,16 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, realpath, stat } from "node:fs/promises";
import {
lstat,
mkdir,
open,
readFile,
realpath,
stat,
unlink,
type FileHandle,
} from "node:fs/promises";
import { dirname } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
@ -49,7 +59,12 @@ export class ProjectPermissionStoreError extends Error {
readonly _tag = "ProjectPermissionStoreError" as const;
constructor(
readonly reason: "project_not_found" | "not_owner_only" | "invalid" | "io",
readonly reason:
| "project_not_found"
| "not_owner_only"
| "invalid"
| "locked"
| "io",
options?: ErrorOptions,
) {
super(`Project permission store failed: ${reason}`, options);
@ -143,5 +158,114 @@ export const writeProjectPermissionStore = async (
}
};
const isNotFound = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
/** Revoke one grant while serializing the complete read-modify-write cycle. */
export const revokeProjectPermissionGrant = async (
path: string,
projectRoot: string,
grantId: string,
): Promise<Result<boolean, ProjectPermissionStoreError>> => {
let lock: PermissionStoreLock | undefined;
try {
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
const acquired = await acquirePermissionStoreLock(path);
if (!acquired.ok) return acquired;
lock = acquired.value;
const current = await readProjectPermissionStore(path, projectRoot);
if (!current.ok) return current;
const grants = current.value?.grants ?? [];
const retained = grants.filter(({ grant_id }) => grant_id !== grantId);
if (retained.length === grants.length) return ok(false);
const written = await writeProjectPermissionStore(
path,
projectRoot,
retained,
);
return written.ok ? ok(true) : written;
} catch (cause: unknown) {
return err(new ProjectPermissionStoreError("io", { cause }));
} finally {
if (lock !== undefined) await releasePermissionStoreLock(lock);
}
};
interface PermissionStoreLock {
readonly path: string;
readonly handle: FileHandle;
}
const acquirePermissionStoreLock = async (
destination: string,
): Promise<Result<PermissionStoreLock, ProjectPermissionStoreError>> => {
const path = `${destination}.lock`;
for (let attempt = 0; attempt < 100; attempt += 1) {
try {
return ok(await createPermissionStoreLock(path));
} catch (cause: unknown) {
if (!isAlreadyExists(cause))
return err(new ProjectPermissionStoreError("io", { cause }));
if (await removeStalePermissionStoreLock(path)) continue;
if (attempt === 99)
return err(new ProjectPermissionStoreError("locked", { cause }));
await delay(10);
}
}
return err(new ProjectPermissionStoreError("locked"));
};
const createPermissionStoreLock = async (
path: string,
): Promise<PermissionStoreLock> => {
const handle = await open(path, "wx", 0o600);
try {
await handle.writeFile(`${String(process.pid)}\n`, "utf8");
await handle.sync();
return { path, handle };
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
await unlink(path).catch(() => undefined);
throw cause;
}
};
const removeStalePermissionStoreLock = async (
path: string,
): Promise<boolean> => {
try {
const metadata = await lstat(path);
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 32)
return false;
const encoded = (await readFile(path, "utf8")).trim();
if (!/^[1-9][0-9]{0,9}$/u.test(encoded)) return false;
if (processIsAlive(Number(encoded))) return false;
await unlink(path);
return true;
} catch {
return false;
}
};
const processIsAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (cause: unknown) {
return errorCode(cause) !== "ESRCH";
}
};
const releasePermissionStoreLock = async (
lock: PermissionStoreLock,
): Promise<void> => {
await lock.handle.close().catch(() => undefined);
await unlink(lock.path).catch(() => undefined);
};
const isNotFound = (cause: unknown): boolean => errorCode(cause) === "ENOENT";
const isAlreadyExists = (cause: unknown): boolean =>
errorCode(cause) === "EEXIST";
const errorCode = (cause: unknown): unknown =>
typeof cause === "object" && cause !== null && "code" in cause
? cause.code
: undefined;

View File

@ -69,7 +69,7 @@ export class TerminalRenderer {
/** Await all queued parsing and return immutable rendered observations. */
async frames(): Promise<readonly RenderedTerminalFrame[]> {
await this.#pending;
return this.#frames;
return structuredClone(this.#frames);
}
/** Whether a rendered observation exceeded its independent capture budget. */

View File

@ -94,7 +94,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
provenance(): readonly ArtifactCommand[] {
return this.#command === undefined ? [] : [this.#command];
return this.#command === undefined ? [] : [structuredClone(this.#command)];
}
close(): Promise<void> {

View File

@ -103,7 +103,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
}
provenance(): readonly ArtifactCommand[] {
return this.#provenance;
return structuredClone(this.#provenance);
}
async close(): Promise<void> {

View File

@ -107,7 +107,7 @@ export class SafeOutputTree {
}
get cleanup(): SafeOutputCleanup {
return this.#cleanup;
return structuredClone(this.#cleanup);
}
/** Stream one selected regular file with exact byte and digest verification. */
@ -205,7 +205,7 @@ export class SafeOutputTree {
/** Remove only this operation's unsealed tree and verify absence. */
async rollback(): Promise<SafeOutputCleanup> {
if (this.#published) return this.#cleanup;
if (this.#published) return structuredClone(this.#cleanup);
await rm(this.#stagingRoot, { recursive: true, force: true });
const absent = await isAbsent(this.#stagingRoot);
this.#cleanup = {
@ -218,7 +218,7 @@ export class SafeOutputTree {
"integrity",
"Extraction output cleanup could not be verified",
);
return this.#cleanup;
return structuredClone(this.#cleanup);
}
async #prepareParent(relativePath: string): Promise<string> {

View File

@ -3,7 +3,7 @@ import { Cli, z } from "incur";
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
import {
readProjectPermissionStore,
writeProjectPermissionStore,
revokeProjectPermissionGrant,
} from "./application/ProjectPermissionStore.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig, type AppConfig } from "./config.js";
@ -68,24 +68,26 @@ export const registerPolicyCommands = (
if (args.action === "revoke") {
if (args.value === undefined)
return { error: "Grant ID is required for policy revoke" };
const result = await readProjectGrants(config.value);
if (!result.ok) return result.error;
const retained = result.grants.filter(
({ grant_id }) => grant_id !== args.value,
const { permissionProjectRoot, permissionProjectStore } =
config.value;
if (
permissionProjectRoot === undefined ||
permissionProjectStore === undefined
)
return { error: "Project policy is not configured" };
const revoked = await revokeProjectPermissionGrant(
permissionProjectStore,
permissionProjectRoot,
args.value,
);
if (retained.length === result.grants.length)
return { error: "Grant was not found", grant_id: args.value };
const written = await writeProjectPermissionStore(
result.path,
result.root,
retained,
);
return written.ok
return revoked.ok && revoked.value
? {
revoked: args.value,
reload: "Send SIGHUP to the REA MCP process",
}
: { error: written.error.message };
: revoked.ok
? { error: "Grant was not found", grant_id: args.value }
: { error: revoked.error.message };
}
const capability = capabilitySchema.safeParse(args.value);
if (!capability.success)

View File

@ -5,6 +5,8 @@ import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
import { functionComparisonResultSchema } from "./functionComparison.js";
import {
comparisonStatusSchema,
deriveProcessComparisonStatus,
PROCESS_COMPARISON_DIMENSIONS,
processCaptureComparisonSchema,
} from "./processCapture.js";
import {
@ -205,20 +207,9 @@ const parseResult = (evidence: Evidence): void => {
const result = processCaptureComparisonSchema.parse(
evidence.normalized_result,
);
const dimensions = [
result.terminal,
result.exit,
result.filesystem,
result.protocol,
result.process,
];
const expected = dimensions.includes("truncated")
? "truncated"
: dimensions.some(isChange)
? "changed"
: dimensions.includes("unknown")
? "unknown"
: "unchanged";
const expected = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]),
);
if (result.status !== expected)
throw new TypeError(
"Process comparison status contradicts its dimensions",
@ -273,10 +264,12 @@ const processFindings = (evidence: Evidence): Finding[] => {
);
const dimensions = [
["terminal", result.terminal, "runtime"],
["interaction", result.interaction, "runtime"],
["exit", result.exit, "runtime"],
["filesystem", result.filesystem, "resource"],
["protocol", result.protocol, "protocol"],
["process", result.process, "runtime"],
["shim", result.shim, "protocol"],
] as const;
return dimensions.flatMap(([dimension, status, scope]) =>
status === "unchanged"

View File

@ -156,10 +156,12 @@ export interface ProcessCapture {
readonly residual_unknowns: readonly {
readonly scope:
| "terminal"
| "interaction"
| "exit"
| "process"
| "filesystem"
| "protocol"
| "shim"
| "cleanup"
| "network";
readonly reason: string;
@ -309,10 +311,12 @@ export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
z.object({
scope: z.enum([
"terminal",
"interaction",
"exit",
"process",
"filesystem",
"protocol",
"shim",
"cleanup",
"network",
]),
@ -349,5 +353,7 @@ export const parseProcessCapture = (input: unknown): ProcessCapture => {
export {
compareProcessCaptures,
comparisonStatusSchema,
deriveProcessComparisonStatus,
PROCESS_COMPARISON_DIMENSIONS,
processCaptureComparisonSchema,
} from "./processComparison.js";

View File

@ -16,6 +16,31 @@ export const comparisonStatusSchema = z.enum([
]);
type ComparisonStatus = z.infer<typeof comparisonStatusSchema>;
/** Canonical ordered dimensions that contribute to an overall process status. */
export const PROCESS_COMPARISON_DIMENSIONS = [
"terminal",
"interaction",
"exit",
"filesystem",
"protocol",
"process",
"shim",
] as const;
/** Derive the overall comparison status from every process dimension. */
export const deriveProcessComparisonStatus = (
dimensions: readonly ComparisonStatus[],
): ComparisonStatus => {
if (dimensions.includes("truncated")) return "truncated";
if (
dimensions.some((status) =>
["changed", "added", "removed"].includes(status),
)
)
return "changed";
return dimensions.includes("unknown") ? "unknown" : "unchanged";
};
/** Pure normalized comparison between two captures. */
export const processCaptureComparisonSchema = z
.object({
@ -51,24 +76,9 @@ export const processCaptureComparisonSchema = z
limitations: z.array(z.string()),
})
.superRefine((comparison, context) => {
const dimensions = [
comparison.terminal,
comparison.interaction,
comparison.exit,
comparison.filesystem,
comparison.protocol,
comparison.process,
comparison.shim,
];
const expected = dimensions.includes("truncated")
? "truncated"
: dimensions.some((status) =>
["added", "removed", "changed"].includes(status),
)
? "changed"
: dimensions.includes("unknown")
? "unknown"
: "unchanged";
const expected = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => comparison[dimension]),
);
if (comparison.status !== expected)
context.addIssue({
code: "custom",
@ -118,18 +128,6 @@ const hasUnknown = (
scope: ProcessCapture["residual_unknowns"][number]["scope"],
): boolean => capture.residual_unknowns.some((item) => item.scope === scope);
const comparisonStatus = (
dimensions: readonly ComparisonStatus[],
): ComparisonStatus => {
if (
dimensions.some((status) =>
["changed", "added", "removed"].includes(status),
)
)
return "changed";
return dimensions.includes("unknown") ? "unknown" : "unchanged";
};
type DivergenceDimension = Exclude<
ProcessCaptureComparison["first_divergence"],
{ readonly status: "none" | "unknown" }
@ -277,7 +275,7 @@ const compareDimensions = (
terminalObservations(right),
),
interaction: classify(
"terminal",
"interaction",
left.interaction_events,
right.interaction_events,
),
@ -295,9 +293,7 @@ const compareDimensions = (
),
protocol: classify("protocol", left.protocol_events, right.protocol_events),
process: classify("process", left.process_samples, right.process_samples),
shim: sameNormalization
? classifyCollection(left.shim_events, right.shim_events)
: "unknown",
shim: classify("shim", left.shim_events, right.shim_events),
};
};
@ -367,15 +363,9 @@ export const compareProcessCaptures = (
} as const)
: observedFirstDivergence;
return {
status: comparisonStatus([
dimensions.terminal,
dimensions.interaction,
dimensions.exit,
dimensions.filesystem,
dimensions.protocol,
dimensions.process,
dimensions.shim,
]),
status: deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => dimensions[dimension]),
),
...dimensions,
first_divergence: firstDivergence,
limitations: [

View File

@ -10,6 +10,8 @@ import { parseEvidenceBundle } from "./evidenceBundle.js";
import { functionComparisonResultSchema } from "./functionComparison.js";
import { functionDossierSchema } from "./hopperValues.js";
import {
deriveProcessComparisonStatus,
PROCESS_COMPARISON_DIMENSIONS,
processCaptureComparisonSchema,
processCaptureSchema,
} from "./processCapture.js";
@ -319,22 +321,9 @@ const observedStatus = (claim: Claim, evidence: Evidence): ObservedStatus => {
const validateProcessStatus = (
result: z.infer<typeof processCaptureComparisonSchema>,
): void => {
const dimensions = [
result.terminal,
result.exit,
result.filesystem,
result.protocol,
result.process,
];
const expected = dimensions.includes("truncated")
? "truncated"
: dimensions.some((status) =>
["added", "removed", "changed"].includes(status),
)
? "changed"
: dimensions.includes("unknown")
? "unknown"
: "unchanged";
const expected = deriveProcessComparisonStatus(
PROCESS_COMPARISON_DIMENSIONS.map((dimension) => result[dimension]),
);
if (result.status !== expected)
throw new TypeError("Process comparison status contradicts its dimensions");
};

View File

@ -20,10 +20,12 @@ const behavioralClaimSchema = z.object({
dimension: z.enum([
"overall",
"terminal",
"interaction",
"exit",
"filesystem",
"protocol",
"process",
"shim",
]),
});
const functionClaimSchema = z.object({

View File

@ -25,10 +25,12 @@ const functionDimensionSchema = z.enum([
]);
const runtimeDimensionSchema = z.enum([
"terminal",
"interaction",
"exit",
"filesystem",
"protocol",
"process",
"shim",
]);
const expectedPatternSchema = z.enum([
"cochanged",

View File

@ -26,11 +26,8 @@ export const discoverObjcClasses = (
): JsonValue => {
const classes = uniqueByName(
names
.filter(
({ name }) =>
["OBJC_CLASS", "OBJC_$_CLASS"].some((marker) =>
name.includes(marker),
) || name.startsWith("_OBJC_"),
.filter(({ name }) =>
["OBJC_CLASS", "OBJC_$_CLASS"].some((marker) => name.includes(marker)),
)
.filter(({ name }) => pattern.length === 0 || name.includes(pattern)),
);

View File

@ -52,6 +52,11 @@ export interface NativeCommandRunner {
): Promise<Result<NativeCommandCapture, NativeCommandFailure>>;
}
/** Resolve one allowlisted native tool to an immutable executable identity. */
export type NativeToolResolver = (
tool: string,
) => Promise<Result<ResolvedTool, NativeCommandFailure>>;
/** Run allowlisted Xcode tools directly with bounded output and no shell. */
export class XcrunCommandRunner implements NativeCommandRunner {
readonly #resolved = new Map<
@ -59,6 +64,11 @@ export class XcrunCommandRunner implements NativeCommandRunner {
Promise<Result<ResolvedTool, NativeCommandFailure>>
>();
constructor(
private readonly resolveTool: NativeToolResolver = (tool) =>
resolveXcrunTool(tool),
) {}
run(
tool: string,
arguments_: readonly string[],
@ -96,16 +106,22 @@ export class XcrunCommandRunner implements NativeCommandRunner {
});
}
#resolve(tool: string): Promise<Result<ResolvedTool, NativeCommandFailure>> {
async #resolve(
tool: string,
): Promise<Result<ResolvedTool, NativeCommandFailure>> {
const existing = this.#resolved.get(tool);
if (existing !== undefined) return existing;
const pending = resolveTool(tool);
const pending = this.resolveTool(tool);
this.#resolved.set(tool, pending);
return pending;
const resolved = await pending;
if (!resolved.ok && this.#resolved.get(tool) === pending)
this.#resolved.delete(tool);
return resolved;
}
}
interface ResolvedTool {
/** Immutable executable identity returned by native tool discovery. */
export interface ResolvedTool {
readonly path: string;
readonly sha256: string;
}
@ -124,7 +140,7 @@ const ALLOWED_TOOLS = new Set([
"vtool",
]);
const resolveTool = async (
const resolveXcrunTool = async (
tool: string,
): Promise<Result<ResolvedTool, NativeCommandFailure>> => {
const found = await captureProcess(

View File

@ -150,10 +150,12 @@ const comparisonUnknownInput = (
return undefined;
const differingScopes = [
["terminal", comparison.terminal],
["interaction", comparison.interaction],
["exit", comparison.exit],
["filesystem", comparison.filesystem],
["protocol", comparison.protocol],
["process", comparison.process],
["shim", comparison.shim],
]
.filter(([, status]) => status !== "unchanged")
.map(([scope]) => scope)

View File

@ -303,6 +303,7 @@ const registerEvidenceTools = ({
const loaded = await readEvidenceBundle(path, filePolicy);
if (!loaded.ok) return toCallToolResult(loaded, importContract);
const imported = session.importEvidenceBundle(loaded.value);
if (imported.ok && imported.value > 0) server.sendResourceListChanged();
return imported.ok
? toCallToolResult(
ok({
@ -378,11 +379,13 @@ const registerUnknownTools = ({
outputSchema: recordContract.outputSchema,
annotations: recordContract.annotations,
},
(input) =>
toCallToolResult(
session.recordUnknown(recordUnknownInputSchema.parse(input)),
recordContract,
),
(input) => {
const recorded = session.recordUnknown(
recordUnknownInputSchema.parse(input),
);
if (recorded.ok) server.sendResourceListChanged();
return toCallToolResult(recorded, recordContract);
},
);
server.registerTool(
updateContract.name,
@ -392,11 +395,13 @@ const registerUnknownTools = ({
outputSchema: updateContract.outputSchema,
annotations: updateContract.annotations,
},
(input) =>
toCallToolResult(
session.updateUnknown(updateUnknownInputSchema.parse(input)),
updateContract,
),
(input) => {
const updated = session.updateUnknown(
updateUnknownInputSchema.parse(input),
);
if (updated.ok) server.sendResourceListChanged();
return toCallToolResult(updated, updateContract);
},
);
server.registerTool(
verifyContract.name,

View File

@ -1,6 +1,9 @@
import { setImmediate } from "node:timers/promises";
import { AnalysisCancelledError } from "../domain/errors.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import { mcpProgressReporter } from "./mcpProgress.js";
@ -25,7 +28,7 @@ export const runDerivedOperation = async <Value>(
context: DerivedOperationContext,
operation: string,
compute: () => Value,
): Promise<Result<Value, AnalysisCancelledError>> => {
): Promise<Result<Value, AnalysisCancelledError | AnalysisInputError>> => {
const progress = mcpProgressReporter(context);
await progress.report({
phase: "prepare",
@ -36,7 +39,16 @@ export const runDerivedOperation = async <Value>(
await setImmediate();
if (context.mcpReq.signal.aborted)
return err(new AnalysisCancelledError(operation));
const value = compute();
let value: Value;
try {
value = compute();
} catch (cause: unknown) {
return err(
cause instanceof AnalysisCancelledError
? cause
: new AnalysisInputError(operation, { cause }),
);
}
await progress.report({
phase: "compute",
completed: 1,

View File

@ -8,6 +8,7 @@ import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
} from "../src/application/AnalysisSnapshotFiles.js";
import { AnalysisSnapshotCache } from "../src/application/AnalysisSnapshotCache.js";
import {
createAnalysisSnapshotEntry,
parseAnalysisSnapshot,
@ -27,6 +28,49 @@ afterEach(async () => {
});
describe("analysis snapshots", () => {
it("returns detached cache entries and evidence bundles", () => {
const cache = new AnalysisSnapshotCache();
const target = {
path: "/tmp/app",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
loaderArgs: [],
} as const;
const provider = { id: "fixture", name: "Fixture", version: "1" };
cache.record(
target,
"address_name",
{ address: "0x1000", document: "fixture" },
createAnalysisExecution("main", provider),
);
const exported = cache.export(target, createEvidenceBundle([]));
expect(exported.ok).toBe(true);
if (!exported.ok) return;
const entry = exported.value.entries[0];
if (entry !== undefined) {
Reflect.set(entry.execution, "provider", {
id: "forged",
name: "Forged",
version: "9",
});
(entry.execution.limitations as string[]).push("forged");
}
expect(
cache.lookup(
target,
"address_name",
{ address: "0x1000", document: "fixture" },
provider,
),
).toMatchObject({
provider,
limitations: [expect.stringContaining("local REA analysis snapshot")],
});
});
it("finds exact CLI evidence for provider-free replay", () => {
const target = {
path: "/tmp/app",

View File

@ -382,6 +382,10 @@ describe("artifact graph provider", () => {
expect(reader.provenance()).toEqual([
expect.objectContaining({ tool: "lipo", effects: ["read"] }),
]);
const provenance = reader.provenance();
if (provenance[0] !== undefined)
Reflect.set(provenance[0], "tool", "forged");
expect(reader.provenance()[0]?.tool).toBe("lipo");
});
});

View File

@ -14,6 +14,8 @@ import {
ProviderAdapterError,
} from "../src/domain/errors.js";
import { err } from "../src/domain/result.js";
import { createEvidenceBundle } from "../src/domain/evidenceBundle.js";
import { createInvestigationWorkspace } from "../src/domain/investigationWorkspace.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
let directory: string | undefined;
@ -24,6 +26,35 @@ afterEach(async () => {
});
describe("binary session", () => {
it("returns detached provider, target, and workspace metadata", async () => {
const [first] = await targets();
const session = new BinarySession(cacheProvider([]));
expect((await session.open(first)).ok).toBe(true);
const identity = session.providerIdentity();
Reflect.set(identity, "id", "forged");
expect(session.providerIdentity().id).toBe("fixture");
const active = session.activeTarget();
expect(active).toBeDefined();
if (active !== undefined) Reflect.set(active, "path", "/tmp/forged");
expect(session.activeTarget()?.path).toBe(first);
const workspace = createInvestigationWorkspace(
"Detached workspace",
createEvidenceBundle([]),
[],
);
expect(session.retainInvestigationWorkspace(workspace)).toBe("added");
const direct = session.investigationWorkspace(workspace.workspace_id, 1);
if (direct !== undefined) Reflect.set(direct, "revision", 999);
const listed = session.investigationWorkspaces()[0];
if (listed !== undefined) Reflect.set(listed, "workspace_id", "forged");
expect(
session.investigationWorkspace(workspace.workspace_id, 1),
).toMatchObject({ revision: 1, workspace_id: workspace.workspace_id });
await session.close();
});
it("runs through a non-Hopper analysis provider", async () => {
const [first] = await targets();
const operations: string[] = [];

View File

@ -53,17 +53,19 @@ const processResult = (
overrides: Partial<{
status: "unchanged" | "changed" | "unknown" | "truncated";
terminal: "unchanged" | "changed" | "unknown" | "truncated";
interaction: "unchanged" | "changed" | "unknown" | "truncated";
exit: "unchanged" | "changed" | "unknown" | "truncated";
shim: "unchanged" | "changed" | "unknown" | "truncated";
}> = {},
): JsonValue => ({
status: overrides.status ?? "unchanged",
terminal: overrides.terminal ?? "unchanged",
interaction: "unchanged",
interaction: overrides.interaction ?? "unchanged",
exit: overrides.exit ?? "unchanged",
filesystem: "unchanged",
protocol: "unchanged",
process: "unchanged",
shim: "unchanged",
shim: overrides.shim ?? "unchanged",
first_divergence:
overrides.status === "changed"
? {
@ -121,6 +123,31 @@ const artifactResult = (
});
describe("changed behavior", () => {
it("reports interaction and shim-only process changes", () => {
const evidence = comparison(
"compare_process_captures",
processResult({
status: "changed",
interaction: "changed",
shim: "changed",
}),
);
expect(findChangedBehavior([evidence], 0, 100)).toMatchObject({
behavior_status: "observed_changed",
summary: { observed_changes: 2 },
findings: {
items: expect.arrayContaining([
expect.objectContaining({
dimension: "interaction",
scope: "runtime",
}),
expect.objectContaining({ dimension: "shim", scope: "protocol" }),
]),
},
});
});
it("classifies runtime changes as observed and cites both observations", () => {
const evidence = comparison(
"compare_process_captures",

View File

@ -69,4 +69,22 @@ describe("derived MCP operation boundary", () => {
expect(notifications.map(({ params }) => params?.progress)).toEqual([0, 2]);
expect(notifications.every(({ params }) => params?.total === 2)).toBe(true);
});
it("projects synchronous computation failures into a typed result", async () => {
const result = await runDerivedOperation(
context(new AbortController()),
"compare_artifacts",
() => {
throw new TypeError("malformed comparison evidence");
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
operation: "compare_artifacts",
},
});
});
});

View File

@ -215,8 +215,7 @@ describe("enhanced MCP tools", () => {
);
}
expect(results[0]).toMatchObject({ count: 1 });
// Legacy behavior treats any `_OBJC_` label, including protocol labels, as a class.
expect(results[1]).toMatchObject({ count: 2 });
expect(results[1]).toMatchObject({ count: 1 });
expect(results[2]).toMatchObject({ count: 1 });
expect(results[3]).toEqual({
items: [

View File

@ -290,7 +290,27 @@ describe("full MCP integration with multi-tool sequences", () => {
const left = processCaptureSchema.parse(EMPTY_PROCESS_CAPTURE_EXAMPLE);
const right = processCaptureSchema.parse({
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
exit: { code: 1, signal: null, reason: "exited" },
interaction_events: [
{
sequence: 0,
scheduled_at_ms: 0,
dispatched_at_ms: 0,
type: "input",
data: "fixture",
outcome: "dispatched",
},
],
shim_events: [
{
sequence: 0,
at_ms: 0,
command: "fixture",
route_index: null,
arguments: [],
working_directory: "/tmp",
outcome: "unmatched",
},
],
});
const captureEvidence = (capture: typeof left) =>
createEvidence(undefined, PROCESS_PROVIDER, {
@ -347,6 +367,7 @@ describe("full MCP integration with multi-tool sequences", () => {
{
status: "contradicted",
domain: "process-comparison",
question: "Process captures disagree across: interaction, shim",
contradicting_evidence_ids: [rightEvidence.evidence_id],
},
],

View File

@ -41,6 +41,10 @@ describe("native DMG artifact reader", () => {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry.path);
expect(entries).toContain("image.dmg/Fixture/hello.txt");
const provenance = reader.provenance();
if (provenance[0] !== undefined)
Reflect.set(provenance[0], "tool", "forged");
expect(reader.provenance()[0]?.tool).toBe("hdiutil");
await reader.close();
expect(calls).toContainEqual(["verify", "/tmp/image.dmg"]);
expect(calls).toContainEqual(["detach", "/dev/disk-fixture"]);

View File

@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { NativeMacOSProvider } from "../src/native/NativeMacOSProvider.js";
import {
NativeCommandFailure,
XcrunCommandRunner,
type NativeCommandCapture,
type NativeCommandRunner,
} from "../src/native/CommandRunner.js";
@ -22,6 +23,24 @@ afterEach(async () => {
});
describe("native macOS provider", () => {
it("retries a failed native tool resolution and caches only success", async () => {
let resolutions = 0;
const runner = new XcrunCommandRunner((tool) => {
resolutions += 1;
return Promise.resolve(
resolutions === 1
? err(new NativeCommandFailure(tool, "unavailable"))
: ok({ path: "/bin/true", sha256: "a".repeat(64) }),
);
});
const options = { timeoutMs: 1_000, maxOutputBytes: 1_024 };
expect((await runner.run("file", [], options)).ok).toBe(false);
expect((await runner.run("file", [], options)).ok).toBe(true);
expect((await runner.run("file", [], options)).ok).toBe(true);
expect(resolutions).toBe(2);
});
it("normalizes comprehensive Mach-O inspection with exact bounded provenance", async () => {
const runner = new FixtureRunner();
const client = new NativeMacOSProvider(runner, "darwin").createClient(

View File

@ -13,6 +13,14 @@ import {
ProcessCaptureError,
} from "../src/application/ProcessHarness.js";
import { snapshotRoots } from "../src/application/FilesystemSnapshot.js";
import {
prepareProcessCapture,
type ProcessPreparationHost,
} from "../src/application/ProcessCaptureLifecycle.js";
import { ProcessCheckpoints } from "../src/application/ProcessCheckpoints.js";
import { normalizeProcessSamples } from "../src/application/ProcessNormalization.js";
import { readLinuxChildren } from "../src/application/ProcessSampling.js";
import { TerminalRenderer } from "../src/application/TerminalRenderer.js";
import {
authorizeProcessScenario,
compareProcessCaptures,
@ -106,6 +114,132 @@ describe("process capture domain", () => {
working_directory: "/tmp",
};
it("returns detached terminal and filesystem checkpoint observations", async () => {
const renderer = new TerminalRenderer({
columns: 40,
rows: 12,
scrollback: 100,
maxFrames: 10,
maxBytes: 10_000,
normalize: (value) => value,
});
renderer.write("A", 0);
const frames = await renderer.frames();
if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999);
expect((await renderer.frames())[0]?.cursor_x).not.toBe(999);
await renderer.dispose();
const scenario = parseProcessScenario(base);
const checkpoints = new ProcessCheckpoints(
scenario,
Date.now(),
{ files: [], truncated: false },
undefined,
);
const first = await checkpoints.finish({ files: [], truncated: false });
if (first[0] !== undefined) Reflect.set(first[0], "name", "tampered");
const second = await checkpoints.finish({ files: [], truncated: false });
expect(second[0]?.name).toBe("before");
await checkpoints.dispose();
});
it("cleans the temporary root when capture home creation fails", async () => {
const cleaned: string[] = [];
const host: ProcessPreparationHost = {
createTemporaryRoot: () => Promise.resolve("/tmp/rea-process-fixture"),
createHome: () => Promise.reject(new Error("mkdir failed")),
cleanup: (path) => {
cleaned.push(path);
return Promise.resolve();
},
};
await expect(
prepareProcessCapture(
parseProcessScenario(base),
{
enabled: true,
executableRoots: ["/bin"],
workingRoots: ["/tmp"],
allowedEnvironment: [],
allowExternalNetwork: true,
},
undefined,
host,
),
).rejects.toThrow("mkdir failed");
expect(cleaned).toEqual(["/tmp/rea-process-fixture"]);
});
it("normalizes every sampled process identifier in command text", () => {
const samples = normalizeProcessSamples(
[
{
at_ms: 0,
pid: 101,
parent_pid: 0,
process_group_id: 101,
session_id: 101,
command: "root 101",
},
{
at_ms: 10,
pid: 202,
parent_pid: 101,
process_group_id: 101,
session_id: 101,
command: "child 202 peer=101 unrelated 1202",
},
],
parseProcessScenario(base),
101,
);
expect(samples[1]?.command).toBe("child <pid> peer=<pid> unrelated 1202");
});
it("collects and deduplicates children from every Linux thread", async () => {
const signal = new AbortController().signal;
expect(
await readLinuxChildren(100, signal, {
taskIds: () => Promise.resolve([100, 101, 102]),
children: (_pid, taskId) =>
Promise.resolve(
taskId === 100 ? "201 202" : taskId === 101 ? "202 203" : "",
),
}),
).toEqual([201, 202, 203]);
});
it("keeps interaction and shim residual uncertainty in separate scopes", () => {
const baseCapture = emptyCapture();
const interaction = parseProcessCapture({
...baseCapture,
residual_unknowns: [
{ scope: "interaction", reason: "Interaction capture was partial." },
],
});
const shim = parseProcessCapture({
...baseCapture,
residual_unknowns: [
{ scope: "shim", reason: "Shim capture was partial." },
],
});
expect(compareProcessCaptures(interaction, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unknown",
shim: "unchanged",
});
expect(compareProcessCaptures(shim, baseCapture)).toMatchObject({
status: "unknown",
terminal: "unchanged",
interaction: "unchanged",
shim: "unknown",
});
});
it("cancels filesystem snapshots before traversing declared roots", async () => {
const controller = new AbortController();
controller.abort();

View File

@ -175,4 +175,17 @@ describe("process CLI errors", () => {
"Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.",
});
});
it("classifies malformed capture evidence as invalid input", async () => {
const root = await fixture();
const malformed = join(root, "malformed-evidence.json");
await writeFile(malformed, "{}");
expect(await compareProcessEvidenceFiles(malformed, malformed)).toEqual({
error: "Process command failed",
category: "invalid_input",
message:
"Capture evidence is malformed. Create new capture evidence, then try again.",
});
});
});

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
parseProcessEnvironment,
type ProcessOwnershipHost,
} from "../src/application/ProcessOwnership.js";
@ -36,6 +37,12 @@ const host = (
};
describe("owned process-group cleanup", () => {
it("drops nameless Linux environment entries", () => {
expect(
parseProcessEnvironment("=ignored\0REA_PROCESS_RUN_ID=owned\0EMPTY=\0"),
).toEqual({ REA_PROCESS_RUN_ID: "owned", EMPTY: "" });
});
it("signals only a group whose every member carries the run token", async () => {
const { adapter, signalGroup } = host({
100: { REA_PROCESS_RUN_ID: "run-token" },

View File

@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import {
readProjectPermissionStore,
revokeProjectPermissionGrant,
writeProjectPermissionStore,
} from "../src/application/ProjectPermissionStore.js";
@ -60,4 +61,43 @@ describe("project permission store", () => {
error: { reason: "invalid" },
});
});
it("serializes concurrent grant revocations without losing an update", async () => {
const project = await mkdtemp(join(tmpdir(), "rea-policy-revoke-"));
const path = join(project, ".rea", "permissions.json");
const grant = (grantId: string) => ({
grant_id: grantId,
capability: "evidence_read" as const,
roots: [project],
executables: [],
environment_names: [],
network: "none" as const,
mount: false,
lifetime: "project" as const,
operation_identity: null,
expires_at: null,
});
expect(
(
await writeProjectPermissionStore(path, project, [
grant("grant-one"),
grant("grant-two"),
])
).ok,
).toBe(true);
const revoked = await Promise.all([
revokeProjectPermissionGrant(path, project, "grant-one"),
revokeProjectPermissionGrant(path, project, "grant-two"),
]);
expect(revoked).toEqual([
{ ok: true, value: true },
{ ok: true, value: true },
]);
expect(await readProjectPermissionStore(path, project)).toMatchObject({
ok: true,
value: { grants: [] },
});
});
});

View File

@ -161,6 +161,62 @@ const artifactComparison = (
);
describe("reconstruction verification", () => {
it("accepts interaction and shim behavioral claim dimensions", () => {
const left = source("1", "controlled-replay");
const right = source("2", "controlled-replay");
const result = {
status: "changed",
terminal: "unchanged",
interaction: "changed",
exit: "unchanged",
filesystem: "unchanged",
protocol: "unchanged",
process: "unchanged",
shim: "unchanged",
first_divergence: {
status: "found",
dimension: "interaction",
index: 0,
left_at_ms: 0,
right_at_ms: 0,
left: "left",
right: "right",
},
limitations: [],
} satisfies JsonValue;
const comparison = processComparison(left, right, result);
expect(
verifyReconstruction(
{
schema_version: 1,
name: "Interaction reconstruction",
claims: [
{
kind: "behavioral",
claim_id: "interaction",
title: "Interaction remains equivalent",
comparison_evidence_id: comparison.evidence_id,
dimension: "interaction",
},
{
kind: "behavioral",
claim_id: "shim",
title: "Shim remains equivalent",
comparison_evidence_id: comparison.evidence_id,
dimension: "shim",
},
],
},
createEvidenceBundle([comparison, right, left]),
0,
100,
),
).toMatchObject({
summary: { total: 2, passed: 1, failed: 1, unknown: 0 },
});
});
it("passes a complete authoritative claim with two-sided citations", () => {
const left = source("1", "controlled-replay");
const right = source("2", "controlled-replay");

View File

@ -78,6 +78,38 @@ const update = (
});
describe("residual unknown registry", () => {
it("returns detached unknowns and evidence bundles from every read surface", () => {
const store = ledger();
expect(store.record(evidence("detached-record")).ok).toBe(true);
const created = store.recordUnknown(
input("Does detached state remain intact?"),
mutation("detached-unknown"),
);
expect(created.ok).toBe(true);
if (!created.ok) return;
Reflect.set(created.value, "status", "resolved");
const listed = store.listUnknowns();
expect(listed[0]?.status).toBe("open");
if (listed[0] !== undefined) Reflect.set(listed[0], "status", "resolved");
const verified = store.verifyUnknownResolution(created.value.unknown_id);
expect(verified).toMatchObject({ ok: true, value: { valid: false } });
if (verified.ok) Reflect.set(verified.value.unknown, "status", "resolved");
const exported = store.export();
const exportedRecord = exported.records.find(
({ operation }) => operation === "detached-record",
);
if (exportedRecord !== undefined)
Reflect.set(exportedRecord, "operation", "forged");
expect(store.export().records.map(({ operation }) => operation)).toContain(
"detached-record",
);
expect(
store.verifyUnknownResolution(created.value.unknown_id),
).toMatchObject({ ok: true, value: { valid: false } });
});
it("requires approval, derives stable IDs, filters heads, and rejects duplicate creation", () => {
expect(() =>
recordUnknownInputSchema.parse({

View File

@ -56,4 +56,14 @@ describe("safe artifact output tree", () => {
"visible before seal",
);
});
it("returns detached cleanup reports", async () => {
const parent = await mkdtemp(join(tmpdir(), "rea-safe-output-"));
const tree = await SafeOutputTree.create(join(parent, "published"), LIMITS);
const cleanup = await tree.rollback();
(cleanup.residualPaths as string[]).push("/forged/path");
expect(tree.cleanup.residualPaths).toEqual([]);
expect((await tree.rollback()).residualPaths).toEqual([]);
});
});

View File

@ -12,7 +12,11 @@ import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import { probeProcessCaptureCapability } from "../src/application/ProcessHarness.js";
import { observed as ok } from "./fixtures/analysisExecution.js";
import { createServer } from "../src/server/createServer.js";
import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js";
import {
createEvidenceBundle,
evidenceBundleSchema,
} from "../src/domain/evidenceBundle.js";
import { createEvidence } from "../src/domain/evidence.js";
import { silentLogger } from "../src/logger.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
@ -54,6 +58,10 @@ describe("target-free MCP lifecycle", () => {
},
});
const mcp = new Client({ name: "session-test", version: "1.0.0" });
let resourceListChanges = 0;
mcp.setNotificationHandler("notifications/resources/list_changed", () => {
resourceListChanges += 1;
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
@ -116,6 +124,31 @@ describe("target-free MCP lifecycle", () => {
}),
).result,
).toEqual({ imported: 0, total: 1 });
const externalEvidencePath = join(directory, "external-evidence.json");
await writeFile(
externalEvidencePath,
JSON.stringify(
createEvidenceBundle([
createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{ operation: "external_observation", parameters: {}, result: true },
),
]),
),
);
let changesBeforeMutation = resourceListChanges;
expect(
structured(
await mcp.callTool({
name: "import_evidence_bundle",
arguments: { path: externalEvidencePath },
}),
).result,
).toEqual({ imported: 1, total: 2 });
await new Promise<void>((resolve) => setImmediate(resolve));
expect(resourceListChanges).toBe(changesBeforeMutation + 1);
changesBeforeMutation = resourceListChanges;
const recordedUnknown = z
.object({
result: z.object({
@ -142,6 +175,8 @@ describe("target-free MCP lifecycle", () => {
),
).result;
expect(recordedUnknown.revision).toBe(1);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(resourceListChanges).toBe(changesBeforeMutation + 1);
expect(
z
.object({ result: z.array(z.unknown()) })
@ -174,6 +209,8 @@ describe("target-free MCP lifecycle", () => {
},
});
expect(resolved.isError).not.toBe(true);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(resourceListChanges).toBe(changesBeforeMutation + 2);
expect(
structured(
await mcp.callTool({

View File

@ -88,7 +88,7 @@ const processComparison = (
const mapping = (
runtime: Evidence,
expected: "cochanged" | "static_only" | "runtime_only" | "both_unchanged",
dimension: "terminal" | "exit" = "terminal",
dimension: "terminal" | "interaction" | "exit" | "shim" = "terminal",
) => ({
static_comparisons: [staticComparison],
runtime_comparisons: [runtime],
@ -115,6 +115,16 @@ const mapping = (
});
describe("static/runtime correlation", () => {
it("accepts interaction and shim runtime dimensions", () => {
const runtime = processComparison("unchanged");
expect(
correlateStaticAndRuntime(mapping(runtime, "static_only", "interaction")),
).toMatchObject({ status: "correlated" });
expect(
correlateStaticAndRuntime(mapping(runtime, "static_only", "shim")),
).toMatchObject({ status: "correlated" });
});
it("keeps consistent cochange as a hypothesis with complete citations", () => {
const runtime = processComparison("changed");
const result = correlateStaticAndRuntime(mapping(runtime, "cochanged"));

View File

@ -39,6 +39,24 @@ describe("symbol analysis", () => {
expect(discoverObjcProtocols(names)).toMatchObject({ count: 1 });
});
it("excludes Objective-C ivars, metaclasses, properties, and protocols from classes", () => {
const result = discoverObjcClasses(
[
{ address: "0x1", name: "_OBJC_IVAR_$_Fixture.value" },
{ address: "0x2", name: "_OBJC_METACLASS_$_Fixture" },
{ address: "0x3", name: "_OBJC_PROP_$_Fixture.value" },
{ address: "0x4", name: "_OBJC_PROTOCOL_$_FixtureProtocol" },
{ address: "0x5", name: "_OBJC_CLASS_$_Fixture" },
],
"",
);
expect(result).toMatchObject({
count: 1,
classes: [{ address: "0x5", name: "_OBJC_CLASS_$_Fixture" }],
});
});
it("categorizes every Swift mangling family and deduplicates names", () => {
const result = categorizeSwiftTypes([
{ address: "1", name: "_TtCClass" },