mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
chore: sync replay follow-ups with author and latest main
This commit is contained in:
commit
afcd29d29f
24 files changed
+953
-151
No files matched your search
@@ -186,7 +186,8 @@ connecting to `/mcp`:
|
||||
`capabilities.tools` lists the same names, descriptions and input schemas that
|
||||
`tools/list` advertises over MCP. Path parameters are required strings whose
|
||||
description names the segment index, the templating rule and the observed
|
||||
values; query parameter names are optional strings; write methods accept an
|
||||
values (sampled within the list budget above; the manifest keeps them all);
|
||||
query parameter names are optional strings; write methods accept an
|
||||
optional `body` object described by the recorded top-level key types. Each
|
||||
input keeps its own argument: a query name equal to a path parameter's name is
|
||||
advertised as `query_<name>`, and the write body as `request_body` when a query
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Preserve an unexpected JSON result while describing the failed plan check. */
|
||||
export function setupPlanFailureMessage(options: {
|
||||
readonly planned: unknown;
|
||||
readonly plannedExecution: { readonly status: number | null };
|
||||
readonly plannedClaudeConfig: string;
|
||||
readonly plannedCodexConfig: string;
|
||||
readonly plannedCursorConfig: string;
|
||||
}): string;
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Describe a failed package setup-plan check without masking its result shape. */
|
||||
export const setupPlanFailureMessage = ({
|
||||
planned,
|
||||
plannedExecution,
|
||||
plannedClaudeConfig,
|
||||
plannedCodexConfig,
|
||||
plannedCursorConfig,
|
||||
}) => {
|
||||
const actionKinds = (actions) =>
|
||||
Array.isArray(actions)
|
||||
? actions.map((action) => action?.kind ?? action)
|
||||
: actions;
|
||||
return `packaged setup plan was not read-only: ${JSON.stringify({
|
||||
status: planned?.status,
|
||||
exitCode: plannedExecution.status,
|
||||
plannedKinds: actionKinds(planned?.plannedActions),
|
||||
appliedKinds: actionKinds(planned?.appliedActions),
|
||||
result: planned,
|
||||
claudeConfig: plannedClaudeConfig,
|
||||
codexConfig: plannedCodexConfig,
|
||||
cursorConfig: plannedCursorConfig,
|
||||
})}`;
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./lib/verify-package-core.mjs";
|
||||
import { parse as parseJsonc } from "jsonc-parser";
|
||||
import { skillReferenceIssues } from "./lib/docs-facts.mjs";
|
||||
import { setupPlanFailureMessage } from "./verify-package-setup-plan.mjs";
|
||||
|
||||
export const terminalModesRestored = (termios) => {
|
||||
if (typeof termios !== "string") return false;
|
||||
@@ -250,23 +251,37 @@ const verifySetupPlan = async ({
|
||||
);
|
||||
const planned = json(plannedExecution.stdout);
|
||||
if (supportedSetupHost) {
|
||||
const plannedActions = Array.isArray(planned?.plannedActions)
|
||||
? planned.plannedActions
|
||||
: undefined;
|
||||
const appliedActions = Array.isArray(planned?.appliedActions)
|
||||
? planned.appliedActions
|
||||
: undefined;
|
||||
const plannedClaudeConfig = await readFile(claudeConfig, "utf8");
|
||||
const plannedCodexConfig = await readFile(codexTarget, "utf8");
|
||||
const plannedCursorConfig = await readFile(cursorConfig, "utf8");
|
||||
const plannedOpenCodeConfig = await readFile(opencodeConfig, "utf8");
|
||||
if (
|
||||
planned.status !== "planned" ||
|
||||
planned?.status !== "planned" ||
|
||||
plannedExecution.status !== 0 ||
|
||||
planned.appliedActions.length !== 0 ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
|
||||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
|
||||
plannedActions === undefined ||
|
||||
appliedActions === undefined ||
|
||||
appliedActions.length !== 0 ||
|
||||
!plannedActions.some((action) => action?.kind === "configure_client") ||
|
||||
!plannedActions.some((action) => action?.kind === "install_skill") ||
|
||||
plannedClaudeConfig !== '{"existing":true}\n' ||
|
||||
plannedCodexConfig !== CODEX_ORIGINAL ||
|
||||
plannedCursorConfig !== '{"existing":true}\n' ||
|
||||
plannedOpenCodeConfig !== opencodeOriginal
|
||||
)
|
||||
throw new Error(
|
||||
`packaged setup plan was not read-only: ${JSON.stringify({ status: planned.status, exitCode: plannedExecution.status, plannedKinds: planned.plannedActions.map(({ kind }) => kind), appliedKinds: planned.appliedActions.map(({ kind }) => kind), claudeConfig: plannedClaudeConfig, codexConfig: plannedCodexConfig, cursorConfig: plannedCursorConfig })}`,
|
||||
setupPlanFailureMessage({
|
||||
planned,
|
||||
plannedExecution,
|
||||
plannedClaudeConfig,
|
||||
plannedCodexConfig,
|
||||
plannedCursorConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { planned };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Assert the documented procfs allowance over strace -ff process/thread files. */
|
||||
export function assertHistoricalPidReadsAreOwnershipOnly(options: {
|
||||
readonly traces: readonly { readonly pid: number; readonly text: string }[];
|
||||
readonly sentinelPid: number;
|
||||
readonly psExecutables: ReadonlySet<string>;
|
||||
}): string[];
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ownershipPsArguments =
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]';
|
||||
|
||||
/**
|
||||
* Allow only the procfs paths used by the process-ownership scan: ps may
|
||||
* inspect its process-table fields, and its caller's thread group may read
|
||||
* native identity and UID records. With strace -ff, libuv worker threads get
|
||||
* separate files; follow CLONE_THREAD edges, never ordinary child-process
|
||||
* edges. strace cannot distinguish the purpose of a stat/status read inside
|
||||
* the same Node thread group, so this is deliberately limited to those two
|
||||
* files; provider Python/GDB children remain outside the permit.
|
||||
*/
|
||||
export const assertHistoricalPidReadsAreOwnershipOnly = ({
|
||||
traces,
|
||||
sentinelPid,
|
||||
psExecutables,
|
||||
}) => {
|
||||
const processGroups = new Map();
|
||||
const findGroup = (pid) => {
|
||||
const parent = processGroups.get(pid);
|
||||
if (parent === undefined) {
|
||||
processGroups.set(pid, pid);
|
||||
return pid;
|
||||
}
|
||||
if (parent === pid) return pid;
|
||||
const root = findGroup(parent);
|
||||
processGroups.set(pid, root);
|
||||
return root;
|
||||
};
|
||||
const joinThreadGroup = (left, right) => {
|
||||
const leftRoot = findGroup(left);
|
||||
const rightRoot = findGroup(right);
|
||||
if (leftRoot !== rightRoot) processGroups.set(rightRoot, leftRoot);
|
||||
};
|
||||
|
||||
const cloneEvents = [];
|
||||
for (const trace of traces) {
|
||||
let unfinished;
|
||||
for (const rawLine of trace.text.split("\n")) {
|
||||
const line = rawLine.trimStart();
|
||||
const start = /^(clone3?|fork|vfork)\((.*)<unfinished \.\.\.>$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (start !== null) {
|
||||
unfinished = { name: start[1], text: start[2] };
|
||||
continue;
|
||||
}
|
||||
const resumed = /^<\.\.\. (clone3?|fork|vfork) resumed>(.*)$/u.exec(line);
|
||||
if (resumed !== null && resumed[1] === unfinished?.name) {
|
||||
cloneEvents.push({
|
||||
parentPid: trace.pid,
|
||||
text: `${unfinished.text}${resumed[2]}`,
|
||||
});
|
||||
unfinished = undefined;
|
||||
continue;
|
||||
}
|
||||
const complete =
|
||||
/^(clone3?|fork|vfork)\((.*\)) = (\d+)(?:<[^>]*>)?$/u.exec(line);
|
||||
if (complete !== null)
|
||||
cloneEvents.push({
|
||||
parentPid: trace.pid,
|
||||
childPid: Number(complete[3]),
|
||||
text: complete[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of cloneEvents) {
|
||||
const childPid =
|
||||
event.childPid ?? /\) = (\d+)(?:<[^>]*>)?$/u.exec(event.text)?.[1];
|
||||
if (childPid === undefined) continue;
|
||||
event.childPid = Number(childPid);
|
||||
if (/\bCLONE_THREAD\b/u.test(event.text))
|
||||
joinThreadGroup(event.parentPid, event.childPid);
|
||||
}
|
||||
|
||||
const psPids = new Set();
|
||||
for (const trace of traces) {
|
||||
const successfulPsExec = trace.text.split("\n").some((line) => {
|
||||
const executable = /^execve\("([^"]+)"/u.exec(line)?.[1];
|
||||
return (
|
||||
executable !== undefined &&
|
||||
psExecutables.has(executable) &&
|
||||
line.includes(ownershipPsArguments) &&
|
||||
line.endsWith(" = 0")
|
||||
);
|
||||
});
|
||||
if (successfulPsExec) psPids.add(trace.pid);
|
||||
}
|
||||
|
||||
const psParentGroups = new Set(
|
||||
cloneEvents
|
||||
.filter(({ childPid }) => childPid !== undefined && psPids.has(childPid))
|
||||
.map(({ parentPid }) => findGroup(parentPid)),
|
||||
);
|
||||
|
||||
const ownershipReads = [];
|
||||
const pidPrefix = `/proc/${sentinelPid}/`;
|
||||
for (const trace of traces) {
|
||||
const isPsProcess = psPids.has(trace.pid);
|
||||
const isOwnershipProcessThread = psParentGroups.has(findGroup(trace.pid));
|
||||
const pidReads = trace.text
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(pidPrefix));
|
||||
for (const line of pidReads) {
|
||||
const psAllowedPath = new RegExp(
|
||||
`/proc/${sentinelPid}/(?:stat|status|cmdline|wchan|environ)"`,
|
||||
"u",
|
||||
).test(line);
|
||||
const ownershipThreadPath = new RegExp(
|
||||
`/proc/${sentinelPid}/(?:stat|status)"`,
|
||||
"u",
|
||||
).test(line);
|
||||
const psRead = isPsProcess && psAllowedPath;
|
||||
const ownershipThreadRead =
|
||||
isOwnershipProcessThread && ownershipThreadPath;
|
||||
assert.ok(
|
||||
psRead || ownershipThreadRead,
|
||||
`Historical PID caused a provider lookup in trace.${trace.pid}: ${line}`,
|
||||
);
|
||||
ownershipReads.push(line);
|
||||
}
|
||||
}
|
||||
return ownershipReads;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { readdir, readFile, realpath } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js";
|
||||
import { assertHistoricalPidReadsAreOwnershipOnly } from "./trace-ownership.mjs";
|
||||
|
||||
/** Check attempted syscalls, including failed attempts, on a recorded PID that collides with an owned live sentinel. */
|
||||
export async function verifyCoreInspectionTrace({
|
||||
@@ -68,11 +69,22 @@ export async function verifyCoreInspectionTrace({
|
||||
{ timeoutMs: 90_000, diagnosticBytes: 1024 * 1024 },
|
||||
);
|
||||
const executions = [];
|
||||
const ownershipReads = [];
|
||||
let gdbObserved = false;
|
||||
for (const file of await readdir(root)) {
|
||||
if (!file.startsWith(`inspection-${mode}.trace.`)) continue;
|
||||
const text = await readFile(join(root, file), "utf8");
|
||||
const traces = await Promise.all(
|
||||
(await readdir(root))
|
||||
.filter((file) => file.startsWith(`inspection-${mode}.trace.`))
|
||||
.map(async (file) => ({
|
||||
file,
|
||||
pid: Number(file.slice(`inspection-${mode}.trace.`.length)),
|
||||
text: await readFile(join(root, file), "utf8"),
|
||||
})),
|
||||
);
|
||||
const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({
|
||||
traces,
|
||||
sentinelPid: sentinel.pid,
|
||||
psExecutables: psPaths,
|
||||
});
|
||||
for (const { file, text } of traces) {
|
||||
// Keep the source executable present: GDB 16 otherwise silently hides a
|
||||
// core-only violation when its automatic executable lookup finds nothing.
|
||||
const recordedExecutable = JSON.stringify(join(root, "fixture-program"));
|
||||
@@ -80,36 +92,6 @@ export async function verifyCoreInspectionTrace({
|
||||
!text.split("\n").some((line) => line.includes(recordedExecutable)),
|
||||
`Inspection attempted access to the recorded executable: ${file}`,
|
||||
);
|
||||
const ownershipTrace = text
|
||||
.split("\n")
|
||||
.some(
|
||||
(line) =>
|
||||
/^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) &&
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
) &&
|
||||
line.endsWith(" = 0"),
|
||||
);
|
||||
const pidReads = text
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(`/proc/${sentinel.pid}/`));
|
||||
if (ownershipTrace) {
|
||||
for (const line of pidReads) {
|
||||
assert.ok(
|
||||
new RegExp(
|
||||
`/proc/${sentinel.pid}/(?:stat|status|cmdline|wchan|environ)"`,
|
||||
"u",
|
||||
).test(line),
|
||||
`Unexpected ownership observation of the sentinel: ${line}`,
|
||||
);
|
||||
ownershipReads.push(line);
|
||||
}
|
||||
} else
|
||||
assert.deepEqual(
|
||||
pidReads,
|
||||
[],
|
||||
`Historical PID caused a provider lookup in ${file}: ${pidReads.join("\n")}`,
|
||||
);
|
||||
assert.ok(
|
||||
!/\bptrace\(/u.test(text),
|
||||
`Inspection attempted ptrace: ${file}`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { LosslessNumber } from "lossless-json";
|
||||
import { parseCaptureJson } from "./CaptureJson.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../../domain/stringPatterns.js";
|
||||
import { isCanonicalBase64 } from "../../domain/stringPatterns.js";
|
||||
import {
|
||||
WEB_NETWORK_CAPTURE_LIMITS,
|
||||
type WebNetworkCapture,
|
||||
@@ -265,7 +265,7 @@ const integerLexeme = (literal: string): boolean => {
|
||||
|
||||
const decodeHarBase64 = (value: string, pointer: string): Buffer => {
|
||||
const compact = value.replace(/[\t\n\r ]/g, "");
|
||||
if (compact.length % 4 !== 0 || !CANONICAL_BASE64_PATTERN.test(compact))
|
||||
if (!isCanonicalBase64(compact))
|
||||
throw new CaptureFormatError(
|
||||
"format",
|
||||
"HAR content declares base64 but its text is not valid canonical base64 (ASCII whitespace is allowed).",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
const sourceRangeSchema = z.strictObject({
|
||||
offset: z.number().int().nonnegative(),
|
||||
@@ -25,9 +25,7 @@ export const goModuleMetadataSchema = z.strictObject({
|
||||
dependencies: z.array(goModuleSchema),
|
||||
settings: z.array(z.strictObject({ key: z.string(), value: z.string() })),
|
||||
unparsed_lines: z.array(z.string()),
|
||||
unparsed_line_bytes_base64: z.array(
|
||||
z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
),
|
||||
unparsed_line_bytes_base64: z.array(canonicalBase64Schema),
|
||||
complete: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -43,9 +41,9 @@ export const goBuildInfoSchema = z.strictObject({
|
||||
header_offset: z.number().int().nonnegative(),
|
||||
encoding: z.enum(["inline", "pointer"]),
|
||||
go_version: z.string().nullable(),
|
||||
go_version_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
go_version_bytes_base64: canonicalBase64Schema,
|
||||
module_text: z.string().nullable(),
|
||||
module_bytes_base64: z.string().regex(CANONICAL_BASE64_PATTERN),
|
||||
module_bytes_base64: canonicalBase64Schema,
|
||||
version_location: sourceRangeSchema,
|
||||
module_location: sourceRangeSchema,
|
||||
module: goModuleMetadataSchema,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { z } from "zod";
|
||||
import { canBorrowParsedJsonSnapshot } from "../immutableJson.js";
|
||||
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
const unsignedHex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/);
|
||||
const index = z.number().int().nonnegative();
|
||||
const scalar = z.union([z.string(), z.number().int()]);
|
||||
const range = z.strictObject({ offset: unsignedHex, bytes: unsignedHex });
|
||||
const canonicalBase64 = z.string().regex(CANONICAL_BASE64_PATTERN);
|
||||
const canonicalBase64 = canonicalBase64Schema;
|
||||
const name = z.strictObject({
|
||||
display: z.string(),
|
||||
bytes_base64: canonicalBase64.nullable(),
|
||||
|
||||
@@ -2,13 +2,13 @@ import { z } from "zod";
|
||||
|
||||
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
|
||||
|
||||
const hex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/);
|
||||
const index = z.number().int().nonnegative();
|
||||
const signed32 = z.number().int().min(-2147483648).max(2147483647);
|
||||
const base64 = z.string().regex(CANONICAL_BASE64_PATTERN);
|
||||
const base64 = canonicalBase64Schema;
|
||||
const range = z.strictObject({ offset: hex, bytes: hex });
|
||||
const diagnostics = z.strictObject({
|
||||
stdout: z.string(),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { PADDED_BASE64_PATTERN } from "./stringPatterns.js";
|
||||
import { isPaddedBase64 } from "./stringPatterns.js";
|
||||
|
||||
/** Validate padded syntax without imposing canonical zero pad bits. */
|
||||
export const paddedBase64ByteLength = (value: string): number | undefined => {
|
||||
if (value.length % 4 !== 0 || !PADDED_BASE64_PATTERN.test(value))
|
||||
return undefined;
|
||||
if (!isPaddedBase64(value)) return undefined;
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
return (value.length / 4) * 3 - padding;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CANONICAL_BASE64_PATTERN,
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
IDENTIFIER_PATTERN,
|
||||
PADDED_BASE64_PATTERN,
|
||||
STABLE_IDENTIFIER_PATTERN,
|
||||
canonicalBase64Schema,
|
||||
isCanonicalBase64,
|
||||
isPaddedBase64,
|
||||
} from "./stringPatterns.js";
|
||||
|
||||
it("preserves identifier grammars under Unicode and Unicode-set compilation", () => {
|
||||
@@ -43,7 +49,7 @@ it("preserves identifier grammars under Unicode and Unicode-set compilation", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves canonical base64 padding and pad-bit validation", () => {
|
||||
it("preserves canonical and permissive padded base64 validation", () => {
|
||||
const previous =
|
||||
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/;
|
||||
const samples = [
|
||||
@@ -71,28 +77,62 @@ it("preserves canonical base64 padding and pad-bit validation", () => {
|
||||
for (const first of alphabet)
|
||||
for (const second of alphabet)
|
||||
samples.push(`${first}${second}==`, `A${first}${second}=`);
|
||||
for (const flag of ["u", "v"]) {
|
||||
const projected = new RegExp(CANONICAL_BASE64_PATTERN.source, flag);
|
||||
for (const sample of samples)
|
||||
expect(projected.test(sample), JSON.stringify({ flag, sample })).toBe(
|
||||
previous.test(sample),
|
||||
);
|
||||
for (const sample of samples) {
|
||||
expect(isCanonicalBase64(sample), JSON.stringify({ sample })).toBe(
|
||||
previous.test(sample),
|
||||
);
|
||||
expect(isPaddedBase64(sample), JSON.stringify({ sample })).toBe(
|
||||
sample.length % 4 === 0 && /^[A-Za-z0-9+/]*(?:==|=)?$/.test(sample),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates large canonical base64 under JSON Schema Unicode modes", () => {
|
||||
// Near the 32 MiB capture input budget: a quartet expansion alone still
|
||||
// overflows at this size, especially in Unicode-set client validators.
|
||||
it("advertises every portable base64 constraint and keeps large inputs stack-safe", () => {
|
||||
const jsonSchema = z.toJSONSchema(canonicalBase64Schema, {
|
||||
target: "draft-2020-12",
|
||||
});
|
||||
expect(jsonSchema).toMatchObject({
|
||||
allOf: expect.arrayContaining([
|
||||
{ pattern: BASE64_QUARTET_LENGTH_PATTERN.source },
|
||||
{ not: { pattern: INVALID_BASE64_CHARACTER_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_BASE64_PADDING_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source } },
|
||||
]),
|
||||
});
|
||||
|
||||
const body = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81);
|
||||
const encoded = body.toString("base64");
|
||||
for (const flag of ["", "u", "v"]) {
|
||||
const pattern = new RegExp(CANONICAL_BASE64_PATTERN.source, flag);
|
||||
expect(pattern.test(encoded), flag).toBe(true);
|
||||
expect(pattern.test(`${encoded.slice(0, -1)}!`), flag).toBe(false);
|
||||
expect(pattern.test(`${encoded.slice(0, -3)}R==`), flag).toBe(false);
|
||||
expect(pattern.test(`${encoded}A`), flag).toBe(false);
|
||||
const padded = new RegExp(PADDED_BASE64_PATTERN.source, flag);
|
||||
expect(padded.test(encoded), flag).toBe(true);
|
||||
expect(padded.test(`${encoded.slice(0, -1)}!`), flag).toBe(false);
|
||||
expect(
|
||||
new RegExp(BASE64_QUARTET_LENGTH_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(true);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_CHARACTER_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_PADDING_PATTERN.source, flag).test(encoded),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source, flag).test(
|
||||
encoded,
|
||||
),
|
||||
flag,
|
||||
).toBe(false);
|
||||
expect(
|
||||
new RegExp(INVALID_BASE64_CHARACTER_PATTERN.source, flag).test(
|
||||
`${encoded.slice(0, -1)}!`,
|
||||
),
|
||||
flag,
|
||||
).toBe(true);
|
||||
expect(
|
||||
new RegExp(INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source, flag).test(
|
||||
`${encoded.slice(0, -3)}R==`,
|
||||
),
|
||||
flag,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(isCanonicalBase64(encoded)).toBe(true);
|
||||
});
|
||||
@@ -1,20 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Portable ASCII identifier starting with a letter. */
|
||||
export const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._\x2d]*$/u;
|
||||
|
||||
/** Stable evidence identifier, also admitting namespace and path separators. */
|
||||
export const STABLE_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9._:\x2f\x2d]*$/u;
|
||||
|
||||
// Repeated alphabet quartets exhaust V8's regexp stack on large bodies,
|
||||
// including under JSON Schema's Unicode-set mode. Check alphabet and terminal
|
||||
// padding separately, leaving an unconstrained fixed-width loop for length.
|
||||
const PADDED_BASE64_PREFIX = String.raw`^(?!.*[^A-Za-z0-9+\x2f=])(?!.*=[^=])(?!.*===)`;
|
||||
// These rules are separate JSON Schema constraints. A single RE2-compatible
|
||||
// regex for alphabet and quartet validity requires a repeated alphabet group;
|
||||
// V8's Unicode-set engine overflows its regexp stack on that group near the
|
||||
// provider's 32 MiB encoded-input budget. Wildcard quartets and literal bad
|
||||
// token probes stay stack-safe at that size and compose exactly with `allOf`.
|
||||
export const BASE64_QUARTET_LENGTH_PATTERN = /^(?:....)*$/;
|
||||
export const INVALID_BASE64_CHARACTER_PATTERN = /[^A-Za-z0-9+\x2f=]/;
|
||||
export const INVALID_BASE64_PADDING_PATTERN = /(?:=[^=]|===)/;
|
||||
export const INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN =
|
||||
/(?:[^AQgw]==$|[BCDFGHJKLNOPRSTVWXZabdefhijlmnpqrtuvxyz1235679+\x2f]=$)/;
|
||||
|
||||
/** Padded base64 syntax, allowing nonzero pad bits and the empty encoding. */
|
||||
export const PADDED_BASE64_PATTERN = new RegExp(
|
||||
`${PADDED_BASE64_PREFIX}(?:....)*$`,
|
||||
);
|
||||
export const isPaddedBase64 = (value: string): boolean =>
|
||||
value.length % 4 === 0 &&
|
||||
!INVALID_BASE64_CHARACTER_PATTERN.test(value) &&
|
||||
!INVALID_BASE64_PADDING_PATTERN.test(value);
|
||||
|
||||
/** Canonical padded base64, including zero pad bits and the empty encoding. */
|
||||
export const CANONICAL_BASE64_PATTERN = new RegExp(
|
||||
`${PADDED_BASE64_PREFIX}(?!.*[^AQgw]==$)(?!.*[^AEIMQUYcgkosw048=]=$)(?:....)*$`,
|
||||
);
|
||||
export const isCanonicalBase64 = (value: string): boolean =>
|
||||
isPaddedBase64(value) &&
|
||||
!INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.test(value);
|
||||
|
||||
const paddedBase64SchemaRules = [
|
||||
{ pattern: BASE64_QUARTET_LENGTH_PATTERN.source },
|
||||
{ not: { pattern: INVALID_BASE64_CHARACTER_PATTERN.source } },
|
||||
{ not: { pattern: INVALID_BASE64_PADDING_PATTERN.source } },
|
||||
];
|
||||
|
||||
/** Canonical padded base64 with zero pad bits, including the empty encoding. */
|
||||
export const canonicalBase64Schema = z
|
||||
.string()
|
||||
.refine(isCanonicalBase64, "Expected canonical padded base64")
|
||||
.meta({
|
||||
allOf: [
|
||||
...paddedBase64SchemaRules,
|
||||
{ not: { pattern: INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN.source } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
|
||||
|
||||
it("parses the effective UID from the single native status Uid row", () => {
|
||||
expect(
|
||||
parseLinuxProcessEffectiveUid(
|
||||
"Name:\tworker\nUid:\t1000\t1001\t1002\t1003\nGid:\t20\t20\t20\t20\n",
|
||||
),
|
||||
).toBe(1001);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Name:\tworker\n",
|
||||
"Uid:\t1000\t1001\t1002\n",
|
||||
"Uid:\t1000\t1001\t1002\t1003\nUid:\t1000\t1001\t1002\t1003\n",
|
||||
"Uid:\t1000\t-1\t1002\t1003\n",
|
||||
"Uid:\t1000\t9007199254740992\t1002\t1003\n",
|
||||
])("does not infer an effective UID from malformed status: %s", (status) => {
|
||||
expect(parseLinuxProcessEffectiveUid(status)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Effective UID parsed from one Linux `/proc/<pid>/status` record. */
|
||||
export const parseLinuxProcessEffectiveUid = (
|
||||
value: string,
|
||||
): number | undefined => {
|
||||
let effectiveUid: number | undefined;
|
||||
for (const line of value.split("\n")) {
|
||||
if (!line.startsWith("Uid:")) continue;
|
||||
if (effectiveUid !== undefined) return undefined;
|
||||
const match = /^Uid:\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s+([0-9]+)\s*$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (match === null) return undefined;
|
||||
const values = match.slice(1).map(Number);
|
||||
if (values.some((uid) => !Number.isSafeInteger(uid) || uid < 0))
|
||||
return undefined;
|
||||
effectiveUid = values[1];
|
||||
}
|
||||
return effectiveUid;
|
||||
};
|
||||
@@ -32,7 +32,11 @@ describe("Linux process start identities", () => {
|
||||
state: "S",
|
||||
command: process.execPath,
|
||||
} satisfies ProcessTableEntry;
|
||||
const identities = await host.processIdentities?.([observedProcess]);
|
||||
const identities = await host.processIdentities?.(
|
||||
[observedProcess],
|
||||
undefined,
|
||||
new Set([observedProcess.pid]),
|
||||
);
|
||||
expect(identities?.get(observedProcess.pid)).toMatchObject({
|
||||
state: "readable",
|
||||
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
|
||||
@@ -43,8 +47,9 @@ describe("Linux process start identities", () => {
|
||||
processGroupId: expect.any(Number),
|
||||
},
|
||||
});
|
||||
expect(identities?.get(observedProcess.pid)).not.toHaveProperty(
|
||||
expect(identities?.get(observedProcess.pid)).toHaveProperty(
|
||||
"effectiveUid",
|
||||
process.geteuid!(),
|
||||
);
|
||||
const baseline = await host.captureBaseline?.();
|
||||
expect(baseline).toContainEqual({
|
||||
@@ -110,9 +115,11 @@ it("validates and cleans an exact-token owner without a capture baseline", async
|
||||
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it.skipIf(typeof process.getuid !== "function")(
|
||||
"uses a same-generation native UID instead of the process-table UID for unreadable tokens",
|
||||
async () => {
|
||||
it
|
||||
.skipIf(typeof process.getuid !== "function")
|
||||
.each(["EINVAL", "EACCES: permission denied"])(
|
||||
"uses a same-generation native UID instead of the process-table UID for unreadable tokens: %s",
|
||||
async (diagnostic) => {
|
||||
const callerUid = process.getuid!();
|
||||
const foreignUid = callerUid === 0 ? 1 : 0;
|
||||
const verify = async (rowUid: number, effectiveUid: number) => {
|
||||
@@ -132,7 +139,7 @@ it.skipIf(typeof process.getuid !== "function")(
|
||||
runTokens: () =>
|
||||
Promise.resolve(
|
||||
new Map([
|
||||
[906, { state: "unavailable", reason: "EINVAL" } as const],
|
||||
[906, { state: "unavailable", reason: diagnostic } as const],
|
||||
]),
|
||||
),
|
||||
processIdentities: (entries) =>
|
||||
@@ -161,7 +168,7 @@ it.skipIf(typeof process.getuid !== "function")(
|
||||
});
|
||||
await expect(verify(callerUid, foreignUid)).resolves.toMatchObject({
|
||||
cleaned: true,
|
||||
unverified: [{ pid: 906, diagnostic: "EINVAL" }],
|
||||
unverified: [{ pid: 906, diagnostic }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,8 +9,14 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
return { ...original, readFile: filesystem.readFile };
|
||||
});
|
||||
|
||||
import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js";
|
||||
import type { ProcessTableEntry } from "./ProcessOwnership.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
readProcessOwnershipFacts,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import type {
|
||||
ProcessOwnershipHost,
|
||||
ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
|
||||
const entry: ProcessTableEntry = {
|
||||
pid: 42,
|
||||
@@ -23,6 +29,144 @@ const entry: ProcessTableEntry = {
|
||||
const filesystemError = (code: string): NodeJS.ErrnoException =>
|
||||
Object.assign(new Error(code), { code });
|
||||
|
||||
const procStat = (
|
||||
startTime: number,
|
||||
parentPid = 1,
|
||||
processGroupId = 42,
|
||||
pid = 42,
|
||||
) => {
|
||||
const fields = ["S", String(parentPid), String(processGroupId), "42"];
|
||||
while (fields.length < 19) fields.push("0");
|
||||
fields.push(String(startTime));
|
||||
return `${String(pid)} (fixture) ${fields.join(" ")}`;
|
||||
};
|
||||
|
||||
it("binds Linux effective UID to the same native process generation", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100, 10, 42))
|
||||
.mockResolvedValueOnce("Name:\tfixture\nUid:\t1000\t1001\t1002\t1003\n")
|
||||
.mockResolvedValueOnce(procStat(100, 11, 43));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "readable",
|
||||
identity: "linux-starttime:100",
|
||||
effectiveUid: 1001,
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: 11,
|
||||
processGroupId: 43,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(filesystem.readFile.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/proc/42/stat",
|
||||
"/proc/42/status",
|
||||
"/proc/42/stat",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps native identity when status UID is unavailable", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100))
|
||||
.mockRejectedValueOnce(filesystemError("EACCES"));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "readable",
|
||||
identity: "linux-starttime:100",
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: 1,
|
||||
processGroupId: 42,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(filesystem.readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not attach a UID when the process generation changes around status", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
filesystem.readFile
|
||||
.mockResolvedValueOnce(procStat(100))
|
||||
.mockResolvedValueOnce("Uid:\t1000\t1001\t1002\t1003\n")
|
||||
.mockResolvedValueOnce(procStat(101));
|
||||
|
||||
await expect(
|
||||
host.processIdentities?.([entry], undefined, new Set([entry.pid])),
|
||||
).resolves.toEqual(
|
||||
new Map([
|
||||
[
|
||||
42,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "process_identity_changed_during_uid_observation",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads Linux effective UID only for token-unavailable rows", async () => {
|
||||
const rows = [entry, { ...entry, pid: 43, processGroupId: 43 }];
|
||||
const statReads = new Map<number, number>();
|
||||
const uidStatusPids: number[] = [];
|
||||
filesystem.readFile.mockImplementation(async (path) => {
|
||||
const match = /^\/proc\/(\d+)\/(stat|status)$/u.exec(String(path));
|
||||
if (match === null) throw new Error(`unexpected path: ${String(path)}`);
|
||||
const pid = Number(match[1]);
|
||||
if (match[2] === "status") {
|
||||
uidStatusPids.push(pid);
|
||||
return "Uid:\t1000\t1001\t1002\t1003\n";
|
||||
}
|
||||
const reads = (statReads.get(pid) ?? 0) + 1;
|
||||
statReads.set(pid, reads);
|
||||
return procStat(100, 1, pid, pid);
|
||||
});
|
||||
const systemHost = createSystemProcessOwnershipHost("linux", {});
|
||||
const host: ProcessOwnershipHost = {
|
||||
...systemHost,
|
||||
runTokens: async () =>
|
||||
new Map([
|
||||
[entry.pid, { state: "unavailable", reason: "EACCES" }],
|
||||
[43, { state: "readable", runId: undefined }],
|
||||
]),
|
||||
};
|
||||
|
||||
const facts = await readProcessOwnershipFacts(rows, host);
|
||||
|
||||
expect(uidStatusPids).toEqual([entry.pid]);
|
||||
expect(facts.map(({ identityAfter }) => identityAfter)).toMatchObject([
|
||||
{ state: "readable", effectiveUid: 1001 },
|
||||
{ state: "readable" },
|
||||
]);
|
||||
expect(facts[1]?.identityAfter).not.toHaveProperty("effectiveUid");
|
||||
expect(statReads).toEqual(
|
||||
new Map([
|
||||
[entry.pid, 3],
|
||||
[43, 2],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports absence only when /proc lookup and signal-zero both report ESRCH", async () => {
|
||||
const host = createSystemProcessOwnershipHost("linux", {});
|
||||
const signalProbe = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import {
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessIdentityObservation,
|
||||
type ProcessOwnershipHost,
|
||||
type ProcessTableEntry,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
|
||||
|
||||
it.each(["absent", "zombie", "live", "unavailable"] as const)(
|
||||
"settles a baseline candidate against its final native lifetime: %s",
|
||||
async (lifetime) => {
|
||||
const row: ProcessTableEntry = {
|
||||
pid: 910,
|
||||
parentPid: 1,
|
||||
processGroupId: 910,
|
||||
state: "S",
|
||||
command: "baseline-neighbor",
|
||||
};
|
||||
let tokenRead = false;
|
||||
const identity = (): ProcessIdentityObservation => {
|
||||
if (lifetime === "absent")
|
||||
return { state: "absent", reason: "not-in-snapshot" };
|
||||
if (lifetime === "unavailable")
|
||||
return { state: "unavailable", reason: "native identity denied" };
|
||||
return {
|
||||
state: "readable",
|
||||
identity: "new-generation",
|
||||
lifecycle: {
|
||||
state: "observed",
|
||||
lifecycle:
|
||||
tokenRead && lifetime === "zombie" ? "zombie" : "non-zombie",
|
||||
},
|
||||
relation: { state: "observed", parentPid: 1, processGroupId: 910 },
|
||||
};
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
platform: "darwin",
|
||||
listProcesses: async () => [row],
|
||||
processIdentities: async (members) =>
|
||||
new Map(members.map(({ pid }) => [pid, identity()])),
|
||||
runTokens: async (members) => {
|
||||
tokenRead = true;
|
||||
return new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "unavailable" as const, reason: "EACCES" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
environment: async () => ({}),
|
||||
signalGroup,
|
||||
};
|
||||
const result = await verifyNoTokenOwnedProcesses(
|
||||
"run",
|
||||
host,
|
||||
[{ pid: row.pid, identity: "baseline-generation" }],
|
||||
{
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
|
||||
},
|
||||
);
|
||||
if (lifetime === "absent" || lifetime === "zombie")
|
||||
expect(result).toEqual({ cleaned: true, signaled: false });
|
||||
else
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
failures: [{ pid: row.pid }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
@@ -179,6 +179,8 @@ export interface ProcessOwnershipHost {
|
||||
processIdentities?(
|
||||
processes: readonly ProcessTableEntry[],
|
||||
signal?: AbortSignal,
|
||||
/** Linux UID enrichment is selective; omitted means UID remains unknown. */
|
||||
effectiveUidPids?: ReadonlySet<number>,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>>;
|
||||
/** Snapshot identities before launch, honoring startup cancellation. */
|
||||
captureBaseline?(signal?: AbortSignal): Promise<ProcessOwnershipBaseline>;
|
||||
@@ -965,6 +967,14 @@ const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => {
|
||||
return "other_unavailable";
|
||||
};
|
||||
|
||||
const processIdentityHasExited = (
|
||||
identity: ProcessIdentityObservation,
|
||||
): boolean =>
|
||||
identity.state === "absent" ||
|
||||
(identity.state === "readable" &&
|
||||
identity.lifecycle?.state === "observed" &&
|
||||
identity.lifecycle.lifecycle === "zombie");
|
||||
|
||||
const processOwnershipFailures = async (
|
||||
members: readonly ProcessTableEntry[],
|
||||
runId: string,
|
||||
@@ -975,13 +985,7 @@ const processOwnershipFailures = async (
|
||||
const facts = await readProcessOwnershipFacts(members, host);
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
for (const fact of facts) {
|
||||
if (fact.identityAfter.state === "absent") continue;
|
||||
if (
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.lifecycle?.state === "observed" &&
|
||||
fact.identityAfter.lifecycle.lifecycle === "zombie"
|
||||
)
|
||||
continue;
|
||||
if (processIdentityHasExited(fact.identityAfter)) continue;
|
||||
const relation =
|
||||
fact.identityAfter.state === "readable" &&
|
||||
fact.identityAfter.relation?.state === "observed"
|
||||
@@ -1289,14 +1293,17 @@ const scanWithOwnershipEvidence = async (
|
||||
observationRows,
|
||||
host,
|
||||
);
|
||||
const baselineIdentityFailures: ProcessOwnershipValidationFailure[] = [];
|
||||
const baselineIdentityFailures = new Map<
|
||||
number,
|
||||
ProcessOwnershipValidationFailure
|
||||
>();
|
||||
const candidates = observationRows.filter((process) => {
|
||||
if (retainedPids.has(process.pid)) return true;
|
||||
if (!baseline.has(process.pid)) return true;
|
||||
const before = baseline.get(process.pid);
|
||||
const now = firstIdentities.get(process.pid);
|
||||
if (before === null || now?.state !== "readable") {
|
||||
baselineIdentityFailures.push({
|
||||
baselineIdentityFailures.set(process.pid, {
|
||||
pid: process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity was unavailable during token validation",
|
||||
@@ -1320,21 +1327,18 @@ const scanWithOwnershipEvidence = async (
|
||||
},
|
||||
);
|
||||
let facts = initialFacts;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < TOKEN_READ_RETRY_ATTEMPTS &&
|
||||
facts.some(({ token }) => token.state === "unavailable");
|
||||
attempt += 1
|
||||
) {
|
||||
for (let attempt = 0; attempt < TOKEN_READ_RETRY_ATTEMPTS; attempt += 1) {
|
||||
const retryRows = facts
|
||||
.filter(
|
||||
({ token, identityAfter }) =>
|
||||
token.state === "unavailable" &&
|
||||
!processIdentityHasExited(identityAfter),
|
||||
)
|
||||
.map(({ process }) => process);
|
||||
if (retryRows.length === 0) break;
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
|
||||
);
|
||||
const pendingPids = new Set(
|
||||
facts
|
||||
.filter(({ token }) => token.state === "unavailable")
|
||||
.map(({ process }) => process.pid),
|
||||
);
|
||||
const retryRows = candidates.filter(({ pid }) => pendingPids.has(pid));
|
||||
const retries = await readProcessOwnershipFacts(retryRows, host);
|
||||
const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact]));
|
||||
facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact);
|
||||
@@ -1356,24 +1360,26 @@ const scanWithOwnershipEvidence = async (
|
||||
const retainedByPid = new Map(
|
||||
retained.map((candidate) => [candidate.pid, candidate]),
|
||||
);
|
||||
const failures: ProcessOwnershipValidationFailure[] = [
|
||||
...baselineIdentityFailures,
|
||||
];
|
||||
const failures: ProcessOwnershipValidationFailure[] = [];
|
||||
const unverified: OwnershipSweepUnverifiedProcess[] = [];
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
const identities = new Map<number, string | null>();
|
||||
|
||||
for (const fact of facts) {
|
||||
// The table row and baseline predate the final native observation. An
|
||||
// exited candidate needs no ownership proof or signal, even when its
|
||||
// token became unreadable while exiting. Unknown native state stays a
|
||||
// cleanup failure; table omission alone is never proof of exit.
|
||||
if (processIdentityHasExited(fact.identityAfter)) continue;
|
||||
const baselineFailure = baselineIdentityFailures.get(fact.process.pid);
|
||||
if (baselineFailure !== undefined) failures.push(baselineFailure);
|
||||
if (fact.state === "replaced")
|
||||
failures.push({
|
||||
pid: fact.process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
diagnostic: "process identity changed during token validation",
|
||||
});
|
||||
else if (
|
||||
fact.state === "unavailable" &&
|
||||
fact.identityAfter.state !== "absent"
|
||||
)
|
||||
else if (fact.state === "unavailable")
|
||||
failures.push({
|
||||
pid: fact.process.pid,
|
||||
reason: "process-identity-unavailable",
|
||||
@@ -1392,7 +1398,6 @@ const scanWithOwnershipEvidence = async (
|
||||
reason: "run-token-mismatch",
|
||||
});
|
||||
if (fact.token.state !== "unavailable") continue;
|
||||
if (fact.identityAfter.state === "absent") continue;
|
||||
const relationBefore =
|
||||
fact.identityBefore.state === "readable"
|
||||
? fact.identityBefore.relation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
|
||||
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
@@ -498,6 +499,7 @@ const readIdentityFacts = async (
|
||||
host: ProcessOwnershipHost,
|
||||
phase: "before" | "after",
|
||||
signal?: AbortSignal,
|
||||
effectiveUidPids?: ReadonlySet<number>,
|
||||
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
|
||||
signal?.throwIfAborted();
|
||||
if (host.processIdentities === undefined)
|
||||
@@ -511,10 +513,11 @@ const readIdentityFacts = async (
|
||||
]),
|
||||
);
|
||||
try {
|
||||
const observations =
|
||||
signal === undefined
|
||||
? await host.processIdentities(processes)
|
||||
: await host.processIdentities(processes, signal);
|
||||
const observations = await host.processIdentities(
|
||||
processes,
|
||||
signal,
|
||||
effectiveUidPids,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
processes.map(({ pid }) => [pid, identityFact(observations, pid, phase)]),
|
||||
@@ -583,11 +586,17 @@ export const readProcessOwnershipFacts = async (
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const effectiveUidPids = new Set(
|
||||
processes
|
||||
.filter(({ pid }) => tokens.get(pid)?.state !== "readable")
|
||||
.map(({ pid }) => pid),
|
||||
);
|
||||
const identityAfter = await readIdentityFacts(
|
||||
processes,
|
||||
host,
|
||||
"after",
|
||||
signal,
|
||||
effectiveUidPids,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const facts = processes.map((process): ProcessOwnershipFact => {
|
||||
@@ -699,7 +708,7 @@ export const createSystemProcessOwnershipHost = (
|
||||
};
|
||||
const processIdentities: NonNullable<
|
||||
ProcessOwnershipHost["processIdentities"]
|
||||
> = async (processes, signal) => {
|
||||
> = async (processes, signal, effectiveUidPids) => {
|
||||
if (processes.length === 0) return new Map();
|
||||
if (platform === "linux")
|
||||
return new Map<number, ProcessIdentityObservation>(
|
||||
@@ -716,27 +725,78 @@ export const createSystemProcessOwnershipHost = (
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const parsed = parseLinuxProcessStat(stat);
|
||||
return [
|
||||
pid,
|
||||
parsed === undefined || parsed.pid !== pid
|
||||
? ({
|
||||
state: "unavailable",
|
||||
reason: "malformed_proc_stat",
|
||||
} as const)
|
||||
: ({
|
||||
state: "readable",
|
||||
identity: `linux-starttime:${parsed.startTime}`,
|
||||
lifecycle: {
|
||||
state: "observed",
|
||||
lifecycle: parsed.lifecycle,
|
||||
},
|
||||
relation: {
|
||||
state: "observed",
|
||||
parentPid: parsed.parentPid,
|
||||
processGroupId: parsed.processGroupId,
|
||||
},
|
||||
} as const),
|
||||
] as const;
|
||||
if (parsed === undefined || parsed.pid !== pid)
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "malformed_proc_stat",
|
||||
} as const,
|
||||
] as const;
|
||||
|
||||
const readableIdentity = (
|
||||
facts: typeof parsed,
|
||||
effectiveUid?: number,
|
||||
) => ({
|
||||
state: "readable" as const,
|
||||
identity: `linux-starttime:${facts.startTime}`,
|
||||
...(effectiveUid === undefined ? {} : { effectiveUid }),
|
||||
lifecycle: {
|
||||
state: "observed" as const,
|
||||
lifecycle: facts.lifecycle,
|
||||
},
|
||||
relation: {
|
||||
state: "observed" as const,
|
||||
parentPid: facts.parentPid,
|
||||
processGroupId: facts.processGroupId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!effectiveUidPids?.has(pid))
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
|
||||
let status: string;
|
||||
try {
|
||||
status = await readFile(`/proc/${String(pid)}/status`, {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const effectiveUid = parseLinuxProcessEffectiveUid(status);
|
||||
if (effectiveUid === undefined)
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
|
||||
let afterStatus: string;
|
||||
try {
|
||||
afterStatus = await readFile(`/proc/${String(pid)}/stat`, {
|
||||
encoding: "utf8",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const verified = parseLinuxProcessStat(afterStatus);
|
||||
if (
|
||||
verified !== undefined &&
|
||||
verified.pid === pid &&
|
||||
verified.startTime !== parsed.startTime
|
||||
)
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "process_identity_changed_during_uid_observation",
|
||||
} as const,
|
||||
] as const;
|
||||
if (verified === undefined || verified.pid !== pid)
|
||||
return [pid, readableIdentity(parsed)] as const;
|
||||
return [pid, readableIdentity(verified, effectiveUid)] as const;
|
||||
} catch (cause: unknown) {
|
||||
if (isExpectedAbort(cause, signal)) throw cause;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { setupPlanFailureMessage } from "../../../scripts/verify-package-setup-plan.mjs";
|
||||
|
||||
it("preserves setup failure details when an error result has no action arrays", () => {
|
||||
const result = {
|
||||
status: "needs_human",
|
||||
remediation: "Review the host setup requirements.",
|
||||
};
|
||||
const message = setupPlanFailureMessage({
|
||||
planned: result,
|
||||
plannedExecution: { status: 1 },
|
||||
plannedClaudeConfig: "unchanged",
|
||||
plannedCodexConfig: "unchanged",
|
||||
plannedCursorConfig: "unchanged",
|
||||
});
|
||||
|
||||
expect(message).toContain("packaged setup plan was not read-only");
|
||||
expect(message).toContain('"status":"needs_human"');
|
||||
expect(message).toContain("Review the host setup requirements.");
|
||||
expect(message).not.toContain("TypeError");
|
||||
});
|
||||
|
||||
it("still formats action kinds for a structured setup result", () => {
|
||||
const message = setupPlanFailureMessage({
|
||||
planned: {
|
||||
status: "planned",
|
||||
plannedActions: [{ kind: "configure_client" }],
|
||||
appliedActions: [],
|
||||
},
|
||||
plannedExecution: { status: 0 },
|
||||
plannedClaudeConfig: "unchanged",
|
||||
plannedCodexConfig: "unchanged",
|
||||
plannedCursorConfig: "unchanged",
|
||||
});
|
||||
|
||||
expect(message).toContain('"plannedKinds":["configure_client"]');
|
||||
expect(message).toContain('"appliedKinds":[]');
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { assertHistoricalPidReadsAreOwnershipOnly } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs";
|
||||
|
||||
const psExec =
|
||||
'execve("/usr/bin/ps", ["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], 0x7fff) = 0';
|
||||
|
||||
it("correlates the ps child trace with its parent identity read", () => {
|
||||
const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
traces: [
|
||||
{
|
||||
pid: 1200,
|
||||
text: [
|
||||
"vfork() = 1201",
|
||||
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD}, 88) = 1202",
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21',
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
pid: 1202,
|
||||
text: [
|
||||
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21',
|
||||
'openat(AT_FDCWD, "/proc/4008/status", O_RDONLY|O_CLOEXEC) = 22',
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
pid: 1201,
|
||||
text: [
|
||||
psExec,
|
||||
'openat(AT_FDCWD, "/proc/4008/status", O_RDONLY) = 3',
|
||||
'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 4',
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ownershipReads).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("joins unfinished and resumed CLONE_THREAD records", () => {
|
||||
const ownershipReads = assertHistoricalPidReadsAreOwnershipOnly({
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
traces: [
|
||||
{
|
||||
pid: 1200,
|
||||
text: [
|
||||
"vfork() = 1201",
|
||||
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD, child_tid=0x3 <unfinished ...>",
|
||||
"<... clone3 resumed>) = 1202",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
pid: 1202,
|
||||
text: 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
|
||||
},
|
||||
{ pid: 1201, text: psExec },
|
||||
],
|
||||
});
|
||||
|
||||
expect(ownershipReads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects provider-style reads outside the ps child and stat-only caller", () => {
|
||||
const traces = [
|
||||
{
|
||||
pid: 1200,
|
||||
text: [
|
||||
"vfork() = 1201",
|
||||
'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 21',
|
||||
].join("\n"),
|
||||
},
|
||||
{ pid: 1201, text: psExec },
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
assertHistoricalPidReadsAreOwnershipOnly({
|
||||
traces,
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
}),
|
||||
).toThrow("Historical PID caused a provider lookup");
|
||||
});
|
||||
|
||||
it("does not trust a ps command with different arguments or failed exec", () => {
|
||||
for (const text of [
|
||||
'execve("/usr/bin/ps", ["ps", "-ef"], 0x7fff) = 0',
|
||||
psExec.replace(" = 0", " = -1 EACCES"),
|
||||
])
|
||||
expect(() =>
|
||||
assertHistoricalPidReadsAreOwnershipOnly({
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
traces: [
|
||||
{
|
||||
pid: 1200,
|
||||
text: `vfork() = 1201\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3`,
|
||||
},
|
||||
{ pid: 1201, text },
|
||||
],
|
||||
}),
|
||||
).toThrow("Historical PID caused a provider lookup");
|
||||
});
|
||||
|
||||
it("allows only known procfs fields inside the exact ps child", () => {
|
||||
expect(() =>
|
||||
assertHistoricalPidReadsAreOwnershipOnly({
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
traces: [
|
||||
{ pid: 1200, text: "vfork() = 1201" },
|
||||
{
|
||||
pid: 1201,
|
||||
text: `${psExec}\nopenat(AT_FDCWD, "/proc/4008/exe", O_RDONLY) = 3`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Historical PID caused a provider lookup");
|
||||
});
|
||||
|
||||
it("does not grant the ownership process thread permit to a forked child", () => {
|
||||
for (const field of ["stat", "status"])
|
||||
expect(() =>
|
||||
assertHistoricalPidReadsAreOwnershipOnly({
|
||||
sentinelPid: 4008,
|
||||
psExecutables: new Set(["/usr/bin/ps"]),
|
||||
traces: [
|
||||
{
|
||||
pid: 1200,
|
||||
text: ["vfork() = 1201", "clone3({flags=SIGCHLD}, 88) = 1203"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
{ pid: 1201, text: psExec },
|
||||
{
|
||||
pid: 1203,
|
||||
text: `openat(AT_FDCWD, "/proc/4008/${field}", O_RDONLY) = 3`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("Historical PID caused a provider lookup");
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
} from "../../../src/domain/stringPatterns.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
|
||||
interface AdvertisedPattern {
|
||||
@@ -10,6 +17,33 @@ interface AdvertisedPattern {
|
||||
readonly pattern: string;
|
||||
}
|
||||
|
||||
const collectCanonicalBase64Leaves = (
|
||||
value: unknown,
|
||||
leaves: Record<string, unknown>[],
|
||||
): void => {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectCanonicalBase64Leaves(entry, leaves));
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const allOf = record.allOf;
|
||||
if (
|
||||
Array.isArray(allOf) &&
|
||||
allOf.some(
|
||||
(entry) =>
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
"pattern" in entry &&
|
||||
entry.pattern === BASE64_QUARTET_LENGTH_PATTERN.source,
|
||||
)
|
||||
)
|
||||
leaves.push(record);
|
||||
Object.values(record).forEach((entry) =>
|
||||
collectCanonicalBase64Leaves(entry, leaves),
|
||||
);
|
||||
};
|
||||
|
||||
const collectPatterns = (
|
||||
value: unknown,
|
||||
where: string,
|
||||
@@ -48,12 +82,29 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes",
|
||||
|
||||
const advertised = (await client.listTools()).tools;
|
||||
const patterns: AdvertisedPattern[] = [];
|
||||
const canonicalBase64Leaves: Record<string, unknown>[] = [];
|
||||
for (const tool of advertised) {
|
||||
collectPatterns(tool.inputSchema, "inputSchema", tool.name, patterns);
|
||||
collectCanonicalBase64Leaves(tool.inputSchema, canonicalBase64Leaves);
|
||||
if (tool.outputSchema !== undefined)
|
||||
collectPatterns(tool.outputSchema, "outputSchema", tool.name, patterns);
|
||||
if (tool.outputSchema !== undefined)
|
||||
collectCanonicalBase64Leaves(tool.outputSchema, canonicalBase64Leaves);
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
BASE64_QUARTET_LENGTH_PATTERN,
|
||||
INVALID_BASE64_CHARACTER_PATTERN,
|
||||
INVALID_BASE64_PADDING_PATTERN,
|
||||
INVALID_CANONICAL_BASE64_PAD_BITS_PATTERN,
|
||||
])
|
||||
expect(
|
||||
patterns.some(({ pattern: advertisedPattern }) =>
|
||||
advertisedPattern.includes(pattern.source),
|
||||
),
|
||||
pattern.source,
|
||||
).toBe(true);
|
||||
|
||||
// Annex B and `u` mode accept an unescaped `-`, `/` or `[` inside a character
|
||||
// class, so REA's own schema validation cannot observe the defect. `v` mode
|
||||
// rejects them, and a client that compiles advertised patterns rejects the
|
||||
@@ -87,4 +138,19 @@ it("advertises portable NUL escapes and patterns that compile in all JS modes",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(patterns.some(({ pattern }) => pattern.includes("\\x00"))).toBe(true);
|
||||
|
||||
expect(canonicalBase64Leaves.length).toBeGreaterThan(0);
|
||||
const sdkValidator = new AjvJsonSchemaValidator();
|
||||
const validators = canonicalBase64Leaves.map((schema) =>
|
||||
sdkValidator.getValidator(schema),
|
||||
);
|
||||
for (const value of ["", "AA==", "AQID", "AB==", "AAA", "AA$=", "AA\n=="])
|
||||
expect(
|
||||
validators.map((validate) => validate(value).valid),
|
||||
JSON.stringify(value),
|
||||
).toEqual(
|
||||
canonicalBase64Leaves.map(
|
||||
() => value === "" || value === "AA==" || value === "AQID",
|
||||
),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user