Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d44fe8f5 | ||
|
|
11c188523a | ||
|
|
794debe565 | ||
|
|
f379bc3882 | ||
|
|
97b76aa20a | ||
|
|
8ea0432ffc | ||
|
|
b9d9a474cf | ||
|
|
b88cdc248a | ||
|
|
c039dd81d5 | ||
|
|
58d23e42b6 |
No files matched your search
@@ -298,13 +298,42 @@ subsequent wire recovery and clean shutdown. Caller timeout does not interrupt
|
|||||||
Hopper's synchronous native operation. Socket boundary tests deterministically
|
Hopper's synchronous native operation. Socket boundary tests deterministically
|
||||||
cover active and queued expiry, late replies and timer cleanup.
|
cover active and queued expiry, late replies and timer cleanup.
|
||||||
|
|
||||||
The Linux demo lane remains a separate `verify:hopper:linux` command. Its
|
The Linux demo lane remains a separate `verify:hopper:linux` command. Hopper
|
||||||
lifecycle checks attempt competing CLI launches for the active target and a
|
6.4.2's ELF loader did not recognize the original 4,119-byte literal or three
|
||||||
|
non-ASCII byte strings in the measured fixtures. The lane retains complete raw
|
||||||
|
byte checks for those literals through source-owned symbols. Recognition of the
|
||||||
|
original full-length literal is bound to its exact source address; its decoded
|
||||||
|
object coverage remains explicitly unverified on Linux even when recognized.
|
||||||
|
Absent non-ASCII recognition also reports decoded-object coverage as unverified.
|
||||||
|
Recognized non-ASCII and control-text objects must still match their source
|
||||||
|
bytes, encoding, filters and searches.
|
||||||
|
A separate 548-byte ASCII literal requires native fragmentation, shortened
|
||||||
|
provider display, complete decoded fragments and dossier provenance on Linux;
|
||||||
|
the macOS lane retains the original full-length recognition requirement. These
|
||||||
|
are observed fixture/build capabilities, not universal Hopper length limits.
|
||||||
|
Its lifecycle checks attempt competing CLI launches for the active target and a
|
||||||
different target, requiring rejection before either can change the owning
|
different target, requiring rejection before either can change the owning
|
||||||
MCP session's documents or procedures. Lease boundary tests also preserve
|
MCP session's documents or procedures. Lease boundary tests also preserve
|
||||||
unresponsive or malformed live endpoints while allowing confirmed stale
|
unresponsive or malformed live endpoints while allowing confirmed stale
|
||||||
sockets to recover.
|
sockets to recover.
|
||||||
|
|
||||||
|
`verify:setup:linux` exercises installation on a clean supported Linux runner as
|
||||||
|
root. Use a disposable VM: this lane installs Hopper and its Linux runtime
|
||||||
|
dependencies through the system package manager. Set `REA_VERIFY_SERVER_COMMAND`
|
||||||
|
to the CLI entrypoint being verified. The command builds and checks source-owned
|
||||||
|
ELF fixtures before testing the read-only Hopper setup plan, approved
|
||||||
|
installation, scoped doctor, real CLI/MCP analysis, rejection of a modified
|
||||||
|
launcher, idempotent setup without requesting a reinstall, and owned child
|
||||||
|
cleanup. Before installation and after teardown, a clean-runner process scan
|
||||||
|
also rejects surviving Hopper demo helpers or Xvfb groups, including orphans
|
||||||
|
whose launcher exited. This scan observes processes without signaling them.
|
||||||
|
Hopper detection compares executable file identity through `/proc`, covering
|
||||||
|
alternate or configured launcher paths even after an invoked symlink is removed.
|
||||||
|
The dedicated runner must have no unrelated Hopper/Xvfb sessions. It requires
|
||||||
|
the repository's pinned development toolchain and an
|
||||||
|
existing host C compiler, `file`, `nm`, and `strings`. Use `verify:hopper:linux`
|
||||||
|
instead when Hopper is already installed.
|
||||||
|
|
||||||
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
|
`verify:hopper:fat` is a separate macOS lane requiring installed Hopper and the
|
||||||
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
|
existing Xcode clang/lipo toolchain. It compiles arm64/x86-64 thin executables and
|
||||||
one- and two-slice FAT32 containers, verifies exact/interior address mappings
|
one- and two-slice FAT32 containers, verifies exact/interior address mappings
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@
|
|||||||
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
|
"fixtures:harmony": "node scripts/fixtures/harmony/fetch.mjs",
|
||||||
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
||||||
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
|
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
|
||||||
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
|
"verify:setup:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
|
||||||
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
|
||||||
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
|
"build:ghidra-windows-fixture": "node scripts/create-ghidra-windows-fixture.mjs",
|
||||||
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function assertCleanLinuxHopperRuntime(
|
||||||
|
launcherPaths?: readonly string[],
|
||||||
|
): Promise<void>;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFile, readdir, stat } from "node:fs/promises";
|
||||||
|
import { basename } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe leftover demo processes on a dedicated clean Linux runner, including
|
||||||
|
* groups reparented after their launcher exits. This is not ownership proof and
|
||||||
|
* never authorizes signaling; unrelated runtime processes also block this lane.
|
||||||
|
*/
|
||||||
|
export const assertCleanLinuxHopperRuntime = async (
|
||||||
|
launcherPaths = [
|
||||||
|
"/opt/hopper/bin/Hopper",
|
||||||
|
...(process.env.HOPPER_LAUNCHER_PATH === undefined
|
||||||
|
? []
|
||||||
|
: [process.env.HOPPER_LAUNCHER_PATH]),
|
||||||
|
],
|
||||||
|
) => {
|
||||||
|
const launchers = [];
|
||||||
|
for (const path of launcherPaths) {
|
||||||
|
try {
|
||||||
|
launchers.push(await stat(path, { bigint: true }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// /proc exposes the executed file even when argv[0] names a removed symlink.
|
||||||
|
// Compare file identity, not a display path or a process name.
|
||||||
|
let inspectionError;
|
||||||
|
for (const entry of await readdir("/proc", { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue;
|
||||||
|
let retainedRuntime;
|
||||||
|
try {
|
||||||
|
// procfs preserves argument boundaries. A caller merely mentioning a
|
||||||
|
// runtime in its script text or other arguments is not that runtime.
|
||||||
|
const arguments_ = (
|
||||||
|
await readFile(`/proc/${entry.name}/cmdline`, "utf8")
|
||||||
|
).split("\0");
|
||||||
|
retainedRuntime =
|
||||||
|
arguments_[0] === "/opt/hopper/bin/Hopper" ||
|
||||||
|
basename(arguments_[0] ?? "") === "Xvfb" ||
|
||||||
|
(basename(arguments_[0] ?? "") === "python3" &&
|
||||||
|
basename(arguments_[1] ?? "") === "hopper-demo-x11.py");
|
||||||
|
if (!retainedRuntime && launchers.length > 0) {
|
||||||
|
const executable = await stat(`/proc/${entry.name}/exe`, {
|
||||||
|
bigint: true,
|
||||||
|
});
|
||||||
|
retainedRuntime = launchers.some(
|
||||||
|
({ dev, ino }) => dev === executable.dev && ino === executable.ino,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT" || error?.code === "ESRCH") continue;
|
||||||
|
// Other users' process facts may be unreadable. The setup lane launches
|
||||||
|
// providers as the caller; unreadable caller-owned processes fail closed.
|
||||||
|
if (error?.code === "EACCES") {
|
||||||
|
const owner = await stat(`/proc/${entry.name}`).catch((cause) => {
|
||||||
|
if (cause?.code === "ENOENT") return undefined;
|
||||||
|
throw cause;
|
||||||
|
});
|
||||||
|
if (owner === undefined || owner.uid !== process.getuid()) continue;
|
||||||
|
}
|
||||||
|
// Finish the inventory so an unrelated restricted process cannot mask a
|
||||||
|
// positively identified leak. Unknown caller processes still fail closed.
|
||||||
|
inspectionError ??= error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (retainedRuntime)
|
||||||
|
throw new Error("Clean Linux runner retained a Hopper demo process");
|
||||||
|
}
|
||||||
|
if (inspectionError !== undefined) throw inspectionError;
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import type { analysisStringSchema } from "../../src/domain/hopperValues.js";
|
||||||
|
|
||||||
|
type VerifierCall = (
|
||||||
|
name: string,
|
||||||
|
arguments_?: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
|
||||||
|
interface StringExpectation {
|
||||||
|
readonly value: string;
|
||||||
|
readonly encoding: string;
|
||||||
|
readonly symbol?: string;
|
||||||
|
readonly recognitionOptional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify source bytes, observed decoding, address filters and literal searches. */
|
||||||
|
export function verifyHopperStringObjects(
|
||||||
|
call: VerifierCall,
|
||||||
|
expectations: readonly StringExpectation[],
|
||||||
|
inventory?: readonly z.output<typeof analysisStringSchema>[] | null,
|
||||||
|
): Promise<{
|
||||||
|
readonly sourceBytesVerified: true;
|
||||||
|
readonly recognition: readonly {
|
||||||
|
readonly value: string;
|
||||||
|
readonly address: string;
|
||||||
|
readonly state: "observed" | "not_observed";
|
||||||
|
readonly decodedObject: "verified" | "unverified";
|
||||||
|
}[];
|
||||||
|
readonly displayEscapesExcludedFromMatching: true;
|
||||||
|
}>;
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
import { mcpTextValue } from "./mcp-verifier-results.mjs";
|
||||||
|
|
||||||
|
const fixtureSymbolPrefix = process.platform === "darwin" ? "_" : "";
|
||||||
|
|
||||||
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
|
/** Exercise literal/regex search, Unicode identities and cache invalidation in Hopper. */
|
||||||
export async function verifyHopperSearch(call, invalid, procedure) {
|
export async function verifyHopperSearch(call, invalid, procedure) {
|
||||||
const address = procedure.address;
|
const address = procedure.address;
|
||||||
const original = await call("address_name", { address });
|
const original = await call("address_name", { address });
|
||||||
|
let stringCoverage;
|
||||||
try {
|
try {
|
||||||
await verifySymbolNames(call, invalid, address);
|
await verifySymbolNames(call, invalid, address);
|
||||||
await verifySearchModes(call, address);
|
stringCoverage = await verifySearchModes(call, address);
|
||||||
} finally {
|
} finally {
|
||||||
await call("set_address_name", { address, name: original });
|
await call("set_address_name", { address, name: original });
|
||||||
}
|
}
|
||||||
@@ -26,7 +29,7 @@ export async function verifyHopperSearch(call, invalid, procedure) {
|
|||||||
symbolUtf16LimitValidatedBeforeMutation: true,
|
symbolUtf16LimitValidatedBeforeMutation: true,
|
||||||
regexAndCaseModes: true,
|
regexAndCaseModes: true,
|
||||||
annotationCacheInvalidation: true,
|
annotationCacheInvalidation: true,
|
||||||
escapedUtf8AndByteStrings: true,
|
stringCoverage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,11 +106,26 @@ async function verifySearchModes(call, address) {
|
|||||||
[{ address, value: "REA_Search_Upper" }],
|
[{ address, value: "REA_Search_Upper" }],
|
||||||
);
|
);
|
||||||
const strings = await call("list_strings");
|
const strings = await call("list_strings");
|
||||||
const long = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
|
const linux = process.platform === "linux";
|
||||||
const longItem = strings.find((item) =>
|
const fullLiteral = "REA_LONG_LITERAL_" + "x".repeat(4096) + "needle";
|
||||||
item.value.startsWith("REA_LONG_LITERAL_"),
|
// Recognition is a provider/loader capability, not an adapter guarantee.
|
||||||
|
// Always retain the original size in raw-byte coverage; Linux additionally
|
||||||
|
// requires an extent observed to produce native fragmented string objects.
|
||||||
|
const fullBytes = Buffer.from(fullLiteral + "\0");
|
||||||
|
const raw = await call("read_bytes", {
|
||||||
|
address: `${fixtureSymbolPrefix}rea_long_literal`,
|
||||||
|
length: fullBytes.length,
|
||||||
|
});
|
||||||
|
assert.equal(raw.complete, true);
|
||||||
|
assert.equal(raw.bytes_hex, fullBytes.toString("hex"));
|
||||||
|
const long = linux
|
||||||
|
? "REA_FRAGMENTED_LITERAL_" + "x".repeat(512) + "fragment_tail"
|
||||||
|
: fullLiteral;
|
||||||
|
const longItem = strings.find(
|
||||||
|
(item) => long.startsWith(item.value) && item.value.startsWith("REA_"),
|
||||||
);
|
);
|
||||||
assert.ok(longItem, "fixture omitted its real long literal");
|
assert.ok(longItem, "fixture omitted its real long literal");
|
||||||
|
if (!linux) assert.equal(longItem.address, raw.address);
|
||||||
assert.ok(long.startsWith(longItem.value));
|
assert.ok(long.startsWith(longItem.value));
|
||||||
assert.ok(longItem.provider_value.endsWith("…"));
|
assert.ok(longItem.provider_value.endsWith("…"));
|
||||||
assert.equal(longItem.string.termination, "missing");
|
assert.equal(longItem.string.termination, "missing");
|
||||||
@@ -158,7 +176,7 @@ async function verifySearchModes(call, address) {
|
|||||||
}
|
}
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await call("search_strings", {
|
await call("search_strings", {
|
||||||
pattern: "needle",
|
pattern: linux ? "fragment_tail" : "needle",
|
||||||
mode: "literal",
|
mode: "literal",
|
||||||
case_sensitive: true,
|
case_sensitive: true,
|
||||||
}),
|
}),
|
||||||
@@ -176,36 +194,122 @@ async function verifySearchModes(call, address) {
|
|||||||
}),
|
}),
|
||||||
expected,
|
expected,
|
||||||
);
|
);
|
||||||
await verifyHopperStringObjects(call, [
|
const objects = await verifyHopperStringObjects(
|
||||||
{ value: "REA_UTF8_é_😀", encoding: "utf-8" },
|
call,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: "REA_UTF8_é_😀",
|
||||||
|
encoding: "utf-8",
|
||||||
|
symbol: `${fixtureSymbolPrefix}rea_utf8_literal`,
|
||||||
|
recognitionOptional: linux,
|
||||||
|
},
|
||||||
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
|
{ value: 'REA_ESCAPED_"\\line\nend\t\r', encoding: "utf-8" },
|
||||||
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
|
{ value: "REA_LITERAL_BACKSLASH_\\n", encoding: "utf-8" },
|
||||||
{ value: "REA_LITERAL_…", encoding: "utf-8" },
|
{
|
||||||
{ value: "REA_LATIN1_ÿ", encoding: "latin-1" },
|
value: "REA_LITERAL_…",
|
||||||
]);
|
encoding: "utf-8",
|
||||||
|
symbol: `${fixtureSymbolPrefix}rea_ellipsis_literal`,
|
||||||
|
recognitionOptional: linux,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "REA_LATIN1_ÿ",
|
||||||
|
encoding: "latin-1",
|
||||||
|
symbol: `${fixtureSymbolPrefix}rea_latin1_literal`,
|
||||||
|
recognitionOptional: linux,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
strings,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fullLiteralSourceBytes: fullBytes.length,
|
||||||
|
fragmentedLiteralSourceBytes: Buffer.byteLength(long),
|
||||||
|
fullLiteralRecognition: {
|
||||||
|
address: raw.address,
|
||||||
|
state: strings.some((item) => item.address === raw.address)
|
||||||
|
? "observed"
|
||||||
|
: "not_observed",
|
||||||
|
// Linux validates the original through raw bytes; its required decoded
|
||||||
|
// fragment/dossier checks exercise the separate supported-size literal.
|
||||||
|
decodedObject: linux ? "unverified" : "verified",
|
||||||
|
},
|
||||||
|
...objects,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compare native string identities and values with the source fixture's bytes. */
|
/** Compare native string identities and values with the source fixture's bytes. */
|
||||||
export async function verifyHopperStringObjects(call, expectations) {
|
export async function verifyHopperStringObjects(
|
||||||
const strings = await call("list_strings");
|
call,
|
||||||
for (const { value, encoding } of expectations) {
|
expectations,
|
||||||
const item = strings.find((candidate) => candidate.value === value);
|
inventory = null,
|
||||||
assert.ok(
|
) {
|
||||||
item,
|
// Reuse only the caller's inventory for this unchanged document, never live
|
||||||
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
|
// state from an earlier session. Address-filter and search checks remain real.
|
||||||
);
|
const strings = inventory ?? (await call("list_strings"));
|
||||||
assert.equal(item.string.encoding, encoding);
|
const recognition = [];
|
||||||
assert.equal(item.string.encoding_status, "inferred");
|
for (const {
|
||||||
assert.equal(item.string.termination, "present_or_not_required");
|
value,
|
||||||
|
encoding,
|
||||||
|
symbol,
|
||||||
|
recognitionOptional = false,
|
||||||
|
} of expectations) {
|
||||||
const expectedBytes = Buffer.from(
|
const expectedBytes = Buffer.from(
|
||||||
value + "\0",
|
value + "\0",
|
||||||
encoding.replaceAll("-", ""),
|
encoding.replaceAll("-", ""),
|
||||||
);
|
);
|
||||||
assert.equal(item.string.byte_length, expectedBytes.length);
|
let sourceBytes;
|
||||||
const bytes = await call("read_bytes", {
|
if (symbol !== undefined) {
|
||||||
address: item.address,
|
const raw = await call("read_bytes", {
|
||||||
|
address: symbol,
|
||||||
length: expectedBytes.length,
|
length: expectedBytes.length,
|
||||||
});
|
});
|
||||||
|
sourceBytes = raw;
|
||||||
|
assert.equal(raw.complete, true);
|
||||||
|
assert.equal(raw.bytes_hex, expectedBytes.toString("hex"));
|
||||||
|
}
|
||||||
|
const item = strings.find((candidate) =>
|
||||||
|
sourceBytes === undefined
|
||||||
|
? candidate.value === value
|
||||||
|
: candidate.address === sourceBytes.address,
|
||||||
|
);
|
||||||
|
if (item === undefined && recognitionOptional) {
|
||||||
|
assert.ok(
|
||||||
|
symbol,
|
||||||
|
"optional recognition requires independent source-byte coverage",
|
||||||
|
);
|
||||||
|
recognition.push({
|
||||||
|
value,
|
||||||
|
address: sourceBytes.address,
|
||||||
|
state: "not_observed",
|
||||||
|
decodedObject: "unverified",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
item,
|
||||||
|
`Hopper omitted the decoded string ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
item.value,
|
||||||
|
value,
|
||||||
|
"native string decoding changed the source value",
|
||||||
|
);
|
||||||
|
recognition.push({
|
||||||
|
value,
|
||||||
|
address: item.address,
|
||||||
|
state: "observed",
|
||||||
|
decodedObject: "verified",
|
||||||
|
});
|
||||||
|
assert.equal(item.string.encoding, encoding);
|
||||||
|
assert.equal(item.string.encoding_status, "inferred");
|
||||||
|
assert.equal(item.string.termination, "present_or_not_required");
|
||||||
|
assert.equal(item.string.byte_length, expectedBytes.length);
|
||||||
|
const bytes =
|
||||||
|
sourceBytes ??
|
||||||
|
(await call("read_bytes", {
|
||||||
|
address: item.address,
|
||||||
|
length: expectedBytes.length,
|
||||||
|
}));
|
||||||
|
assert.equal(bytes.complete, true);
|
||||||
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
|
assert.equal(bytes.bytes_hex, expectedBytes.toString("hex"));
|
||||||
assert.deepEqual(await call("list_strings", { address: item.address }), [
|
assert.deepEqual(await call("list_strings", { address: item.address }), [
|
||||||
item,
|
item,
|
||||||
@@ -225,7 +329,7 @@ export async function verifyHopperStringObjects(call, expectations) {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
sourceBytesVerified: true,
|
sourceBytesVerified: true,
|
||||||
inferredEncodingReported: true,
|
recognition,
|
||||||
displayEscapesExcludedFromMatching: true,
|
displayEscapesExcludedFromMatching: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||||
|
import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const verifierRun = createVerifierRun();
|
const verifierRun = createVerifierRun();
|
||||||
@@ -19,12 +20,12 @@ const command = process.env.REA_VERIFY_SERVER_COMMAND;
|
|||||||
if (process.platform !== "linux" || process.getuid?.() !== 0)
|
if (process.platform !== "linux" || process.getuid?.() !== 0)
|
||||||
throw new Error("Linux setup verification requires a root Linux runner");
|
throw new Error("Linux setup verification requires a root Linux runner");
|
||||||
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
|
if (!command) throw new Error("REA_VERIFY_SERVER_COMMAND is required");
|
||||||
|
await assertCleanLinuxHopperRuntime();
|
||||||
try {
|
try {
|
||||||
await access("/opt/hopper/bin/Hopper");
|
await access("/opt/hopper/bin/Hopper");
|
||||||
throw new Error("Linux setup verification requires a clean runner");
|
throw new Error("Linux setup verification requires a clean runner");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes("clean runner"))
|
if (error?.code !== "ENOENT") throw error;
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const runJson = async (args) => {
|
const runJson = async (args) => {
|
||||||
@@ -35,14 +36,25 @@ const runJson = async (args) => {
|
|||||||
return JSON.parse(stdout);
|
return JSON.parse(stdout);
|
||||||
};
|
};
|
||||||
|
|
||||||
const plan = await runJson(["setup", "--json"]);
|
const plan = await runJson([
|
||||||
if (plan.status !== "needs_confirmation")
|
"setup",
|
||||||
throw new Error("setup did not produce a read-only confirmation plan");
|
"--install-hopper",
|
||||||
|
"--dry-run",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
plan.status !== "planned" ||
|
||||||
|
!plan.plannedActions.some(({ kind }) => kind === "install_hopper") ||
|
||||||
|
plan.appliedActions.length !== 0
|
||||||
|
)
|
||||||
|
throw new Error("setup did not produce a read-only Hopper installation plan");
|
||||||
await access("/opt/hopper/bin/Hopper").then(
|
await access("/opt/hopper/bin/Hopper").then(
|
||||||
() => {
|
() => {
|
||||||
throw new Error("setup plan mutated the clean runner");
|
throw new Error("setup plan mutated the clean runner");
|
||||||
},
|
},
|
||||||
() => undefined,
|
(error) => {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const installed = await runJson([
|
const installed = await runJson([
|
||||||
@@ -58,7 +70,7 @@ if (
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`approved setup did not become ready: ${JSON.stringify(installed)}`,
|
`approved setup did not become ready: ${JSON.stringify(installed)}`,
|
||||||
);
|
);
|
||||||
const doctor = await runJson(["doctor", "--json"]);
|
const doctor = await runJson(["doctor", "--provider", "hopper", "--json"]);
|
||||||
if (doctor.healthy !== true)
|
if (doctor.healthy !== true)
|
||||||
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
|
throw new Error(`doctor is unhealthy: ${JSON.stringify(doctor)}`);
|
||||||
|
|
||||||
@@ -67,8 +79,6 @@ await execFileAsync(command, ["analyze", "/usr/bin/true"], {
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
maxBuffer: 10 * 1024 * 1024,
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
process.env.HOPPER_TARGET_PATH = "/usr/bin/true";
|
|
||||||
process.env.HOPPER_SECOND_TARGET_PATH = "/usr/bin/false";
|
|
||||||
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
|
process.env.REA_VERIFY_SERVER_ARGS_JSON = '["mcp"]';
|
||||||
await import("./verify-real-hopper-linux.mjs");
|
await import("./verify-real-hopper-linux.mjs");
|
||||||
|
|
||||||
@@ -95,28 +105,24 @@ try {
|
|||||||
await rm(modifiedDirectory, { recursive: true, force: true });
|
await rm(modifiedDirectory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const repeated = await runJson([
|
const repeated = await runJson(["setup", "--yes", "--json"]);
|
||||||
"setup",
|
|
||||||
"--yes",
|
|
||||||
"--install-hopper",
|
|
||||||
"--json",
|
|
||||||
]);
|
|
||||||
if (
|
if (
|
||||||
repeated.status !== "ready" ||
|
repeated.status !== "ready" ||
|
||||||
repeated.appliedActions.includes("installed_hopper")
|
repeated.plannedActions.length !== 0 ||
|
||||||
|
repeated.appliedActions.length !== 0
|
||||||
)
|
)
|
||||||
throw new Error("a second setup was not idempotent");
|
throw new Error("a second setup was not idempotent");
|
||||||
const { stdout: processes } = await execFileAsync("ps", [
|
await assertCleanLinuxHopperRuntime();
|
||||||
"-ax",
|
const completedVerifierRun = await completeVerifierRun(verifierRun);
|
||||||
"-o",
|
if (
|
||||||
"command=",
|
completedVerifierRun.process_lineage.status !== "verified" ||
|
||||||
]);
|
completedVerifierRun.process_lineage.descendants.length !== 0
|
||||||
if (/\/opt\/hopper\/bin\/Hopper|\bXvfb\b|hopper-demo-x11\.py/u.test(processes))
|
)
|
||||||
throw new Error("Linux setup verification leaked a Hopper demo session");
|
throw new Error("Linux setup verification did not prove owned child cleanup");
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
verifier_run: await completeVerifierRun(verifierRun),
|
verifier_run: completedVerifierRun,
|
||||||
status: "verified",
|
status: "verified",
|
||||||
setup: true,
|
setup: true,
|
||||||
cli: true,
|
cli: true,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ createVerifierRun();
|
|||||||
|
|
||||||
if (process.platform !== "linux")
|
if (process.platform !== "linux")
|
||||||
throw new Error("Linux real-Hopper verification must run on Linux");
|
throw new Error("Linux real-Hopper verification must run on Linux");
|
||||||
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
|
const configuredLauncher = process.env.HOPPER_LAUNCHER_PATH;
|
||||||
|
const launcher = configuredLauncher ?? "/opt/hopper/bin/Hopper";
|
||||||
await access(launcher);
|
await access(launcher);
|
||||||
await access("/usr/bin/Xvfb");
|
await access("/usr/bin/Xvfb");
|
||||||
await access("/usr/bin/xauth");
|
await access("/usr/bin/xauth");
|
||||||
@@ -28,5 +29,7 @@ try {
|
|||||||
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
|
process.env.HOPPER_LAUNCHER_PATH = alternateLauncher;
|
||||||
await import("./verify-real-hopper.mjs");
|
await import("./verify-real-hopper.mjs");
|
||||||
} finally {
|
} finally {
|
||||||
|
if (configuredLauncher === undefined) delete process.env.HOPPER_LAUNCHER_PATH;
|
||||||
|
else process.env.HOPPER_LAUNCHER_PATH = configuredLauncher;
|
||||||
await rm(alternateDirectory, { recursive: true, force: true });
|
await rm(alternateDirectory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
copyFile,
|
||||||
|
readFile,
|
||||||
|
symlink,
|
||||||
|
unlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { assertCleanLinuxHopperRuntime } from "../../../scripts/lib/clean-linux-hopper-runtime.mjs";
|
||||||
|
import { completeVerifierRun } from "../../../scripts/lib/verifier-run.mjs";
|
||||||
|
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||||
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
it.runIf(process.platform === "linux")(
|
||||||
|
"does not treat runtime names in another process's arguments as a runtime",
|
||||||
|
async () => {
|
||||||
|
const observer = new URL(
|
||||||
|
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
|
||||||
|
import.meta.url,
|
||||||
|
).href;
|
||||||
|
const code = `
|
||||||
|
const { assertCleanLinuxHopperRuntime } = await import(${JSON.stringify(observer)});
|
||||||
|
await assertCleanLinuxHopperRuntime([]);
|
||||||
|
console.log('clean');
|
||||||
|
`;
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--input-type=module",
|
||||||
|
"-e",
|
||||||
|
code,
|
||||||
|
"Xvfb",
|
||||||
|
"/opt/hopper/bin/Hopper",
|
||||||
|
"hopper-demo-x11.py",
|
||||||
|
],
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
expect(stdout.trim()).toBe("clean");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.runIf(process.platform === "linux")(
|
||||||
|
"distinguishes an ordinary Node script with the helper's filename",
|
||||||
|
async () => {
|
||||||
|
const directory = await createTestTempDirectory("rea-runtime-arguments-");
|
||||||
|
const script = join(directory, "hopper-demo-x11.py");
|
||||||
|
const observer = new URL(
|
||||||
|
"../../../scripts/lib/clean-linux-hopper-runtime.mjs",
|
||||||
|
import.meta.url,
|
||||||
|
).href;
|
||||||
|
await writeFile(
|
||||||
|
script,
|
||||||
|
`
|
||||||
|
import(${JSON.stringify(observer)}).then(async ({ assertCleanLinuxHopperRuntime }) => {
|
||||||
|
await assertCleanLinuxHopperRuntime([]);
|
||||||
|
console.log('clean');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
const { stdout } = await execFileAsync(process.execPath, [script], {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
expect(stdout.trim()).toBe("clean");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it
|
||||||
|
.runIf(process.platform === "linux")
|
||||||
|
.each(["demo helper", "alternate launcher"])(
|
||||||
|
"rejects a reparented %s without signaling it",
|
||||||
|
async (mode) => {
|
||||||
|
const runId = randomUUID();
|
||||||
|
const childCode =
|
||||||
|
"process.title = 'Xvfb'; setInterval(() => {}, 1000); process.send('ready');";
|
||||||
|
const directory = await createTestTempDirectory("rea-clean-runtime-test-");
|
||||||
|
const launcher = join(directory, "Hopper");
|
||||||
|
const alternate = join(directory, "Hopper-alternate");
|
||||||
|
const alternateMode = mode === "alternate launcher";
|
||||||
|
if (alternateMode) {
|
||||||
|
await copyFile("/usr/bin/sleep", launcher);
|
||||||
|
await chmod(launcher, 0o700);
|
||||||
|
await symlink(launcher, alternate);
|
||||||
|
}
|
||||||
|
const command = alternateMode ? alternate : process.execPath;
|
||||||
|
const arguments_ = alternateMode ? ["30"] : ["-e", childCode];
|
||||||
|
const parentCode = `
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const child = spawn(${JSON.stringify(command)}, ${JSON.stringify(arguments_)}, {
|
||||||
|
detached: true, stdio: ${JSON.stringify(alternateMode ? "ignore" : ["ignore", "ignore", "ignore", "ipc"])}
|
||||||
|
});
|
||||||
|
child.once('error', () => process.exit(1));
|
||||||
|
child.once(${JSON.stringify(alternateMode ? "spawn" : "message")}, () => {
|
||||||
|
process.stdout.write(JSON.stringify({ pid: child.pid, parentPid: process.pid }));
|
||||||
|
if (child.connected) child.disconnect();
|
||||||
|
child.unref();
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const parent = execFileAsync(process.execPath, ["-e", parentCode], {
|
||||||
|
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
const parentPid = parent.child.pid;
|
||||||
|
if (parentPid === undefined)
|
||||||
|
throw new Error("Fixture parent did not spawn");
|
||||||
|
let orphanPid: number | undefined;
|
||||||
|
try {
|
||||||
|
const { stdout } = await parent;
|
||||||
|
const { pid } = JSON.parse(stdout) as { pid: number };
|
||||||
|
orphanPid = pid;
|
||||||
|
const { stdout: coordinates } = await execFileAsync("ps", [
|
||||||
|
"-p",
|
||||||
|
String(pid),
|
||||||
|
"-o",
|
||||||
|
"ppid=,pgid=",
|
||||||
|
]);
|
||||||
|
const [currentParent, group] = coordinates
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/u)
|
||||||
|
.map(Number);
|
||||||
|
expect(currentParent).not.toBe(parentPid);
|
||||||
|
expect(group).toBe(pid);
|
||||||
|
|
||||||
|
const lineage = await completeVerifierRun({
|
||||||
|
run_id: runId,
|
||||||
|
verifier_pid: process.pid,
|
||||||
|
parent_pid: process.ppid,
|
||||||
|
});
|
||||||
|
expect(lineage.process_lineage.status).toBe("verified");
|
||||||
|
if (lineage.process_lineage.status === "verified")
|
||||||
|
expect(
|
||||||
|
lineage.process_lineage.descendants.some((p) => p.pid === pid),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
if (alternateMode) {
|
||||||
|
const commandLine = await readFile(
|
||||||
|
`/proc/${String(pid)}/cmdline`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(commandLine.split("\0")[0]).toBe(alternate);
|
||||||
|
await unlink(alternate);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
assertCleanLinuxHopperRuntime(alternateMode ? [launcher] : []),
|
||||||
|
).rejects.toThrow("Clean Linux runner retained a Hopper demo process");
|
||||||
|
// Observation must not terminate the fixture, even though it resembles Xvfb.
|
||||||
|
const { stdout: alive } = await execFileAsync("ps", [
|
||||||
|
"-p",
|
||||||
|
String(pid),
|
||||||
|
"-o",
|
||||||
|
"stat=",
|
||||||
|
]);
|
||||||
|
expect(alive.trim()).not.toMatch(/^Z/u);
|
||||||
|
} finally {
|
||||||
|
if (orphanPid !== undefined) {
|
||||||
|
const cleanup = await cleanupOwnedProcessGroup({
|
||||||
|
runId,
|
||||||
|
leaderPid: orphanPid,
|
||||||
|
processGroupId: orphanPid,
|
||||||
|
});
|
||||||
|
expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -5,13 +5,24 @@ volatile int rea_c_global = 7;
|
|||||||
#define REA_LONG_CHUNK "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
#define REA_LONG_CHUNK "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
#define REA_LONG_256 REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK
|
#define REA_LONG_256 REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK REA_LONG_CHUNK
|
||||||
#define REA_LONG_1024 REA_LONG_256 REA_LONG_256 REA_LONG_256 REA_LONG_256
|
#define REA_LONG_1024 REA_LONG_256 REA_LONG_256 REA_LONG_256 REA_LONG_256
|
||||||
|
/* Keep the full-size literal even when a loader does not type it as a string. */
|
||||||
|
__attribute__((used)) const char rea_long_literal[] =
|
||||||
|
"REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle";
|
||||||
|
/* Linux Hopper 6.4.2 recognizes this extent and splits it into native objects. */
|
||||||
|
__attribute__((used)) const char rea_fragmented_literal[] =
|
||||||
|
"REA_FRAGMENTED_LITERAL_" REA_LONG_256 REA_LONG_256 "fragment_tail";
|
||||||
|
__attribute__((used)) const char rea_utf8_literal[] = "REA_UTF8_é_😀";
|
||||||
|
__attribute__((used)) const char rea_ellipsis_literal[] = "REA_LITERAL_…";
|
||||||
|
__attribute__((used)) const char rea_latin1_literal[] = "REA_LATIN1_\xff";
|
||||||
|
|
||||||
__attribute__((noinline, used)) void rea_long_literal_procedure(void) {
|
__attribute__((noinline, used)) void rea_long_literal_procedure(void) {
|
||||||
puts("REA_LONG_LITERAL_" REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 REA_LONG_1024 "needle");
|
puts(rea_long_literal);
|
||||||
puts("REA_UTF8_é_😀");
|
puts(rea_fragmented_literal);
|
||||||
|
puts(rea_utf8_literal);
|
||||||
puts("REA_ESCAPED_\"\\line\nend\t\r");
|
puts("REA_ESCAPED_\"\\line\nend\t\r");
|
||||||
puts("REA_LITERAL_BACKSLASH_\\n");
|
puts("REA_LITERAL_BACKSLASH_\\n");
|
||||||
puts("REA_LITERAL_…");
|
puts(rea_ellipsis_literal);
|
||||||
puts("REA_LATIN1_\xff");
|
puts(rea_latin1_literal);
|
||||||
}
|
}
|
||||||
|
|
||||||
__attribute__((noinline, used)) int rea_leaf(int value) {
|
__attribute__((noinline, used)) int rea_leaf(int value) {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { verifyHopperStringObjects } from "../../../../scripts/lib/real-hopper-search.mjs";
|
||||||
|
|
||||||
|
const value = "REA_UTF8_é_😀";
|
||||||
|
const address = "0x402020";
|
||||||
|
const bytes = Buffer.from(`${value}\0`);
|
||||||
|
const item = {
|
||||||
|
address,
|
||||||
|
value,
|
||||||
|
string: {
|
||||||
|
encoding: "utf-8",
|
||||||
|
encoding_status: "inferred" as const,
|
||||||
|
termination: "present_or_not_required" as const,
|
||||||
|
byte_length: bytes.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expectation = {
|
||||||
|
value,
|
||||||
|
encoding: "utf-8",
|
||||||
|
symbol: "rea_utf8_literal",
|
||||||
|
recognitionOptional: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("keeps source identity and unknown decoding when the same text exists elsewhere", async () => {
|
||||||
|
const report = await verifyHopperStringObjects(
|
||||||
|
fixtureCall(),
|
||||||
|
[expectation],
|
||||||
|
[{ ...item, address: "0x403000" }],
|
||||||
|
);
|
||||||
|
expect(report.recognition).toEqual([
|
||||||
|
{ value, address, state: "not_observed", decodedObject: "unverified" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses source bytes and inventory while checking real address filters and searches", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const report = await verifyHopperStringObjects(
|
||||||
|
fixtureCall(calls),
|
||||||
|
[expectation],
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
expect(report.recognition).toEqual([
|
||||||
|
{ value, address, state: "observed", decodedObject: "verified" },
|
||||||
|
]);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
"read_bytes:rea_utf8_literal",
|
||||||
|
`list_strings:${address}`,
|
||||||
|
`search_strings:${value}`,
|
||||||
|
"search_strings:\\xC3\\xA9",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["changed source bytes", { bytes_hex: "00" }, item],
|
||||||
|
["incomplete source read", { complete: false }, item],
|
||||||
|
["wrong decoded value", {}, { ...item, value: "wrong" }],
|
||||||
|
[
|
||||||
|
"wrong decoded extent",
|
||||||
|
{},
|
||||||
|
{ ...item, string: { ...item.string, byte_length: 1 } },
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"rejects %s even when native recognition is optional",
|
||||||
|
async (_label, override, decoded) => {
|
||||||
|
await expect(
|
||||||
|
verifyHopperStringObjects(
|
||||||
|
fixtureCall([], override),
|
||||||
|
[expectation],
|
||||||
|
[decoded],
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function fixtureCall(
|
||||||
|
calls: string[] = [],
|
||||||
|
override: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
return async (name: string, arguments_: Record<string, unknown> = {}) => {
|
||||||
|
calls.push(
|
||||||
|
`${name}:${String(arguments_.address ?? arguments_.pattern ?? "")}`,
|
||||||
|
);
|
||||||
|
if (name === "read_bytes")
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
bytes_hex: bytes.toString("hex"),
|
||||||
|
complete: true,
|
||||||
|
...override,
|
||||||
|
};
|
||||||
|
if (name === "list_strings" && arguments_.address === address)
|
||||||
|
return [item];
|
||||||
|
if (name === "search_strings")
|
||||||
|
return arguments_.pattern === value ? [item] : [];
|
||||||
|
throw new Error(`Unmodeled verifier call: ${name}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user