fix: resolve triaged correctness issues
This commit is contained in:
parent
998697155d
commit
e8ed3073ac
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
|||
}
|
||||
|
||||
provenance(): readonly ArtifactCommand[] {
|
||||
return this.#provenance;
|
||||
return structuredClone(this.#provenance);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ const behavioralClaimSchema = z.object({
|
|||
dimension: z.enum([
|
||||
"overall",
|
||||
"terminal",
|
||||
"interaction",
|
||||
"exit",
|
||||
"filesystem",
|
||||
"protocol",
|
||||
"process",
|
||||
"shim",
|
||||
]),
|
||||
});
|
||||
const functionClaimSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -25,10 +25,12 @@ const functionDimensionSchema = z.enum([
|
|||
]);
|
||||
const runtimeDimensionSchema = z.enum([
|
||||
"terminal",
|
||||
"interaction",
|
||||
"exit",
|
||||
"filesystem",
|
||||
"protocol",
|
||||
"process",
|
||||
"shim",
|
||||
]);
|
||||
const expectedPatternSchema = z.enum([
|
||||
"cochanged",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue