chore: sync replay follow-ups with author and latest main

This commit is contained in:
gensokyo-dev committed 2026-10-19 16:33:41 +00:00
commit afcd29d29f
24 files changed
+953 -151

No files matched your search

+2 -1
View File
@@ -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
+8
View File
@@ -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;
+23
View File
@@ -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,
})}`;
};
+20 -5
View File
@@ -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;
};
+16 -34
View File
@@ -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 -2
View 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).",
+4 -6
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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(),
+2 -3
View File
@@ -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;
};
+60 -20
View File
@@ -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);
});
+36 -12
View File
@@ -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 } },
],
});
+21
View File
@@ -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();
});
+19
View File
@@ -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;
};
+14 -7
View File
@@ -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();
},
);
+34 -29
View File
@@ -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
+86 -26
View File
@@ -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",
),
);
});