Compare commits

...
Author SHA1 Message Date
gensokyo-dev 6d5610fe5f fix(process): preserve exit scope and share native wait budgets
Retain the original bracketed ownership snapshot before applying later exit observations, so an exiting verified parent cannot erase a still-live child’s scope. Own one lazily started native-exit waiting budget per cleanup attempt and pass it through discovery, validation, revalidation and final verification.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
2026-10-19 18:12:05 +00:00
gensokyo-dev 33450d02ea fix(ci): retain trace coverage limits after integration
Restore explicit unverified symlink aliases and same-Node stat/status purpose in the shared coverage contract and guide. The native-exit and trace-attribution integration does not establish those properties. Preserve both limitations rather than silently broadening the reported qualification.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 17:46:06 +00:00
gensokyo-dev 3de261cc5e Merge remote-tracking branch 'origin/audit/ci-tests-20261019-round2' into fix/ci-process-observation-baseline 2026-10-19 17:39:33 +00:00
gensokyo-dev 2026d0099b fix(process): type native exit predicates and ownership fixtures precisely 2026-10-19 17:37:40 +00:00
gensokyo-dev 0ac49fbb1f Merge remote-tracking branch 'origin/main' into fix/ci-process-observation-baseline 2026-10-19 17:31:08 +00:00
gensokyo-dev 6892c8bc52 chore: integrate registration and native capture clock fixes 2026-10-19 17:31:03 +00:00
gensokyo-dev 8ea965261e fix(process): integrate main and confirm native exit transitions 2026-10-19 17:30:45 +00:00
gensokyo-dev 54e971a148 chore: integrate current main Evidence parity controls
Adopt main’s selected-path versus artifact-identity test correction and Rizin plain-output update. Product ownership and recorded-crash qualification code are unchanged from the locally and remotely verified 2a42096e1 implementation.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 17:27:38 +00:00
gensokyo-dev 2a42096e15 chore: integrate main registration isolation and native lifecycle clocks
Retain main’s provider-independent registration fixtures and stronger native lifecycle controls. The earlier shared-host EACCES failure remains recorded; no permission error is suppressed.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 17:04:32 +00:00
gensokyo-dev 40ab4048bf fix(ci): retain process edges in ownership trace inventory
Keep fork/vfork and resumed edges along with clone events so the shared attribution owner receives the complete process relation facts it consumes.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 16:32:48 +00:00
gensokyo-dev 2761410d44 chore: integrate current main ownership and verification fixes
Use main’s shared canonical base64 schema and lifetime/UID facts. Consolidate traced ownership attribution with the strict PID/path policy, preserving exact ps arguments, thread-group boundaries and both historical-PID controls. Remove superseded schemas, classifiers and absence controls; declare alias and same-process attribution gaps.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 16:30:12 +00:00
gensokyo-dev 6768d4ab52 fix(process): keep ownership retries on the native clock 2026-10-19 16:16:28 +00:00
gensokyo-dev 818a9c6dfe chore: integrate I/O priority coverage and selector controls
Preserve the shared syscall catalog and the explicit unverified coverage for
other PID-directed interfaces. Consolidate I/O priority cases while retaining
UID, self, distinct-PID, cross-selector and unresolved-target controls.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 16:00:40 +00:00
gensokyo-dev 1c73ea7e4e chore: integrate portable schemas and PID target contracts
Keep one producer-owned PID syscall catalog while incorporating the portable
canonical-base64 schema fix. Consolidate equivalent resource cases around the
stronger read/write, failed-attempt, self and distinct-PID controls.

The integrated policy and base64 families pass 91 tests across four files.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 15:39:50 +00:00
gensokyo-dev 1130d1b222 fix(ci): classify I/O priority and qualify PID trace coverage 2026-10-19 15:28:47 +00:00
gensokyo-dev 128610df12 fix(ci): share explicit PID syscall contracts with tracing
Derive tracer selection and reported coverage from the same named target
contracts used by the access checker. Cover resource limits, scheduler calls,
process metadata, PID groups, capabilities and pidfd operations without mixing
UIDs, policy enums, self selectors or output values with target PIDs.

Interpret each target once and retain unresolved target facts as a distinct
failure. Emit the coverage catalog once per verifier result.

The previous oracle misses 28 new controls. All 83 controls pass after the
change; real native resource/scheduler probes capture 14 previously missed
calls with resource limits and affinity unchanged. All 35 selected syscall
names are accepted by the pinned local strace.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 15:25:11 +00:00
gensokyo-dev 99cec9a56e fix(schema): preserve portable large base64 validation 2026-10-19 15:17:47 +00:00
gensokyo-dev 73c88afb87 fix(ci): reject historical PID resource and scheduler calls 2026-10-19 15:12:14 +00:00
gensokyo-dev 9c49e7a24c chore: integrate historical PID priority verification 2026-10-19 14:58:53 +00:00
gensokyo-dev 26b870b1b2 fix(ci): preserve C-quoted components in traced path checks
strace renders non-ASCII pathname bytes as C octal escapes, not JSON strings.
Keep those components opaque when resolving literal ASCII path separators and
proc PID selectors, matching the existing absolute-path interpretation.

Two controls fail before the correction and all 35 pass afterward. An actual
strace observation independently reproduces the rejection of an unrelated
Chinese filename and verifies the corrected classification.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 14:56:01 +00:00
gensokyo-dev 8dd8ca0804 chore: integrate current CI pathname verification
# Conflicts:
#	docs/recorded-crashes.md
2026-10-19 14:48:25 +00:00
gensokyo-dev 1e84699018 fix(ci): reject priority access to historical PIDs 2026-10-19 14:46:50 +00:00
gensokyo-dev 5c656717ef test(ci): declare unresolved legacy pathname coverage
Report the historical-PID access surfaces actually checked and explicitly mark
legacy cwd-relative pathname calls unverified. The existing tracer does not
provide their cwd identity, and a blanket rejection also rejects legitimate
GDB build-ID metadata queries.

Retain every committed access guard and document the preexisting limit without
claiming absence from missing path evidence. The complete real 44-case CLI/MCP
recorded-crash qualification passes with the coverage declaration.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 14:45:05 +00:00
gensokyo-dev 804caa2cb2 chore: merge latest main for CI verification 2026-10-19 14:19:22 +00:00
gensokyo-dev 5d2895d2b2 fix(ci): resolve traced directory paths across pathname syscalls
Classify producer-decoded FD/path pairs once instead of limiting failed relative
lookup detection to openat on /proc. This preserves attempted readlinkat, statx
and newfstatat accesses with no returned descriptor.

Three regression cases fail against the previous policy and all 33 controls
pass with the shared pathname interpretation.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 14:19:22 +00:00
gensokyo-dev d34c8514a1 fix(ci): trace historical PID handles and signal probes 2026-10-19 14:14:27 +00:00
gensokyo-dev b82ae1962f test(ci): verify decoded historical PID controls before tracing 2026-10-19 13:40:12 +00:00
gensokyo-dev b32ce58c3a fix(ci): distinguish process inventory from historical PID lookups 2026-10-19 13:28:25 +00:00
gensokyo-dev 5f94077b3c fix(process): classify baseline cleanup with current ownership facts
Use the pre-launch baseline only to select candidates for current native
identity/token validation. Preserve confirmed absence and verified foreign
generations instead of carrying a premature baseline identity failure.

Exercise the real Linux enumeration/exit race and retain countercontrols for
unknown identities and live token-owned processes.

Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
2026-10-19 13:11:13 +00:00
24 changed files with 1562 additions and 319 deletions

No files matched your search

+22 -1
View File
@@ -105,6 +105,27 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test
program; subsequent inspection only reads the resulting core. Real acceptance
checks recorded threads, high registers, missing/malformed notes, opaque owners,
CLI/MCP parity, optional debugger context and historical PID collision with an
owned live sentinel. The syscall trace also rejects access to the still-present
owned live sentinel. An observed Node thread group must launch the exact ownership
`ps` inventory before it can read that live process's `stat` or `status` for native
identity/UID facts. Only traced `CLONE_THREAD` members share this allowance;
exec-replaced threads, ordinary children, debugger and decoder processes cannot
use it. The purpose of stat/status reads within the same Node thread group
cannot be distinguished by strace. A second recording uses a valid
historical PID above the host's assignable PID range and rejects resolved lookups,
including Node `stat`/`status` reads, to detect historical-PID-driven access separately
from live inventory. Descriptor paths come from strace's `-yy` decoding; exact
PID-directory opens and relative accesses through those handles are rejected.
Failed FD-relative pathname attempts are checked across syscall variants.
Legacy relative pathname calls without dirfd/cwd evidence and symlink aliases
remain unverified; the verification report declares these gaps rather than
establishing their absence.
One syscall/selector contract supplies trace selection, target interpretation
and the report's explicit coverage catalog. Unlisted PID-directed syscalls remain
unverified. The catalog includes PID-directed resource
limits, scheduling, priority, process metadata, signals and pidfd controls.
Self, group and UID selectors retain their syscall-defined meanings; unresolved
targets cannot qualify. Coverage is reported once for all CLI/MCP traces and
does not establish containment of every possible kernel interaction.
The syscall trace also rejects access to the still-present
source executable. The CI workflow defines Ubuntu 24.04 and Debian 13 (GDB 16)
container lanes. Original inputs and host configuration remain unchanged.
+12 -11
View File
@@ -8,7 +8,7 @@ from elftools.elf.elffile import ELFFile
from elftools.construct import Container
from pwnlib.elf.datatypes import elf_prstatus_amd64
source, directory, sentinel = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3])
source, directory, sentinel, unlisted = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
original = source.read_bytes()
image = ELFFile(io.BytesIO(original))
segments = list(image.iter_segments())
@@ -81,14 +81,15 @@ content = bytearray(original)
start = descriptor_start(status) + elf_prstatus_amd64.pr_pid.offset
content[start:start + ctypes.sizeof(ctypes.c_uint32)] = (0xfffffffe).to_bytes(4, "little")
save("negative-pid", content)
content = bytearray(original)
content[start:start + ctypes.sizeof(ctypes.c_uint32)] = sentinel.to_bytes(4, "little")
for note in notes:
if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO":
# Build with unchanged pyelftools; no hard-coded PID offset or host file resolution.
raw = dict(note.n_desc, pr_pid=sentinel)
encoded = image.structs.Elf_Prpsinfo.build(Container(**raw))
offset = descriptor_start(note)
content[offset:offset + len(encoded)] = encoded
save("historical-pid-collision", content)
for name, pid in [("historical-pid-collision", sentinel), ("historical-pid-unlisted", unlisted)]:
content = bytearray(original)
content[start:start + ctypes.sizeof(ctypes.c_uint32)] = pid.to_bytes(4, "little")
for note in notes:
if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO":
# Build with unchanged pyelftools; no hard-coded PID offset or host file resolution.
raw = dict(note.n_desc, pr_pid=pid)
encoded = image.structs.Elf_Prpsinfo.build(Container(**raw))
offset = descriptor_start(note)
content[offset:offset + len(encoded)] = encoded
save(name, content)
print(json.dumps(files))
+16
View File
@@ -13,6 +13,7 @@ import {
startCoreSentinel,
} from "./verify/recorded-crash/fixtures.mjs";
import { verifyCoreInspectionTrace } from "./verify/recorded-crash/trace.mjs";
import { historicalPidTraceContract } from "./verify/recorded-crash/trace-policy.mjs";
const environment = Object.fromEntries(
Object.entries(process.env).filter(([, value]) => typeof value === "string"),
@@ -61,6 +62,10 @@ try {
const core = await createRecordedCore(options);
sentinel = await startCoreSentinel(options);
sentinel.assertAlive();
// Valid signed ELF PID, outside this Linux host's assignable PID range.
const unlistedPid = 2 ** 31 - 1;
const pidMaximum = Number(await readFile("/proc/sys/kernel/pid_max", "utf8"));
assert.ok(Number.isSafeInteger(pidMaximum) && pidMaximum < unlistedPid);
const fixtures = JSON.parse(
await fixtureCommand(
environment.REA_PWNTOOLS_PYTHON,
@@ -72,6 +77,7 @@ try {
core,
root.path,
String(sentinel.pid),
String(unlistedPid),
],
options,
),
@@ -276,6 +282,15 @@ try {
core: fixtures["historical-pid-collision"],
sentinel,
});
trace.push(
...(await verifyCoreInspectionTrace({
...options,
entrypoint,
core: fixtures["historical-pid-unlisted"],
historicalPid: unlistedPid,
sentinel,
})),
);
cases += trace.length;
assert.ok((await readFile(core)).length > 0);
} catch (cause) {
@@ -308,6 +323,7 @@ const result = {
"Linux x64; pwntools4.15.0/pyelftools0.33/Unicorn2.1.2; core-only BYO GDB/pwndbg2026.09.15",
fixture_generation: "owned test program executed",
inspection_trace: trace,
historical_pid_access_coverage: historicalPidTraceContract,
verifier,
};
console.log(JSON.stringify(result, null, 2));
@@ -1,11 +1,19 @@
import { connectRecordedCrash } from "./public.mjs";
import assert from "node:assert/strict";
const session = await connectRecordedCrash({
entrypoint: process.argv[2],
environment: process.env,
});
try {
await session.inspect("mcp", process.argv[3], { debuggerContext: true });
const report = await session.inspect("mcp", process.argv[3], {
debuggerContext: true,
});
const historicalPid = Number(process.argv[4]);
assert.ok(
report.threads.some((thread) => thread.historical_pid === historicalPid),
"MCP must decode the selected historical-PID control before its trace qualifies",
);
} finally {
await session.close();
}
@@ -1,6 +1,17 @@
/** Assert the documented procfs allowance over strace -ff process/thread files. */
export function assertHistoricalPidReadsAreOwnershipOnly(options: {
export interface OwnershipTraceExecution {
readonly pid: number;
readonly path: string;
readonly line: string;
readonly succeeded: boolean;
readonly ownershipInventory: boolean;
}
/** Parsed topology and execution facts for one complete strace observation. */
export function collectOwnershipTraceFacts(options: {
readonly traces: readonly { readonly pid: number; readonly text: string }[];
readonly sentinelPid: number;
readonly nodeExecutable: string;
readonly psExecutables: ReadonlySet<string>;
}): string[];
}): {
readonly ownershipThreads: ReadonlySet<number>;
readonly psProcesses: ReadonlySet<number>;
readonly executions: readonly OwnershipTraceExecution[];
};
@@ -3,18 +3,10 @@ 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 = ({
/** Parse execution/thread facts once; only an observed Node ps caller owns inventory reads. */
export const collectOwnershipTraceFacts = ({
traces,
sentinelPid,
nodeExecutable,
psExecutables,
}) => {
const processGroups = new Map();
@@ -71,57 +63,65 @@ export const assertHistoricalPidReadsAreOwnershipOnly = ({
const childPid =
event.childPid ?? /\) = (\d+)(?:<[^>]*>)?$/u.exec(event.text)?.[1];
if (childPid === undefined) continue;
event.childPid = Number(childPid);
const observedChild = Number(childPid);
// A child-side clone return of zero is not a parent-to-child edge.
if (!Number.isSafeInteger(observedChild) || observedChild <= 0) continue;
event.childPid = observedChild;
if (/\bCLONE_THREAD\b/u.test(event.text))
joinThreadGroup(event.parentPid, event.childPid);
}
const psPids = new Set();
const executions = [];
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;
for (const line of trace.text.split("\n")) {
if (!/execve(?:at)?\(/u.test(line)) continue;
const match = /^execve\("([^"]+)"/u.exec(line);
assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
assert.ok(
psRead || ownershipThreadRead,
`Historical PID caused a provider lookup in trace.${trace.pid}: ${line}`,
/ = (?:0|-1 [A-Z]+)/u.test(line),
`Incomplete execution observation: ${line}`,
);
ownershipReads.push(line);
executions.push({
pid: trace.pid,
path: match[1],
line,
succeeded: line.endsWith(" = 0"),
ownershipInventory:
psExecutables.has(match[1]) && line.includes(ownershipPsArguments),
});
}
}
return ownershipReads;
const psProcesses = new Set(
executions
.filter(
({ succeeded, ownershipInventory }) => succeeded && ownershipInventory,
)
.map(({ pid }) => pid),
);
const psParentGroups = new Set(
cloneEvents
.filter(({ childPid }) => psProcesses.has(childPid))
.map(({ parentPid }) => findGroup(parentPid)),
);
const nodeGroups = new Set(
executions
.filter(({ path, succeeded }) => succeeded && path === nodeExecutable)
.map(({ pid }) => findGroup(pid)),
);
const replaced = new Set(
executions
.filter(({ path, succeeded }) => succeeded && path !== nodeExecutable)
.map(({ pid }) => pid),
);
const ownershipThreads = new Set(
traces
.filter(
({ pid }) =>
nodeGroups.has(findGroup(pid)) &&
psParentGroups.has(findGroup(pid)) &&
!replaced.has(pid),
)
.map(({ pid }) => pid),
);
return { ownershipThreads, psProcesses, executions };
};
@@ -0,0 +1,16 @@
export interface SyscallTrace {
readonly pid: number;
readonly text: string;
}
export const historicalPidTraceContract: {
readonly checked_paths: readonly string[];
readonly pid_syscalls: readonly string[];
readonly unverified: readonly string[];
};
export function assertHistoricalPidAccess(input: {
readonly trace: SyscallTrace;
readonly historicalPid: number;
readonly ownershipThreads: ReadonlySet<number>;
readonly liveCollision: boolean;
readonly psInventory: boolean;
}): readonly string[];
@@ -0,0 +1,202 @@
import assert from "node:assert/strict";
import { posix } from "node:path";
// This owner supplies both tracer selection and target interpretation. Kinds
// describe syscall argument semantics, never catalog positions or output PIDs.
const pidTargetKinds = new Map([
...[
"prlimit64",
"sched_getaffinity",
"sched_setaffinity",
"sched_getparam",
"sched_setparam",
"sched_getscheduler",
"sched_setscheduler",
"sched_getattr",
"sched_setattr",
"sched_rr_get_interval",
"getpgid",
"getsid",
"get_robust_list",
"migrate_pages",
"move_pages",
"pidfd_open",
"tkill",
"rt_sigqueueinfo",
].map((name) => [name, "pid"]),
...["tgkill", "rt_tgsigqueueinfo", "setpgid", "kcmp"].map((name) => [
name,
"pair",
]),
...["getpriority", "setpriority"].map((name) => [name, "priority"]),
...["ioprio_get", "ioprio_set"].map((name) => [name, "ioprio"]),
...[
"pidfd_send_signal",
"pidfd_getfd",
"process_madvise",
"process_mrelease",
].map((name) => [name, "pidfd"]),
...["capget", "capset"].map((name) => [name, "capability"]),
["kill", "signal-group"],
["wait4", "pid-group"],
["waitid", "wait-selector"],
]);
export const historicalPidTraceContract = Object.freeze({
checked_paths: Object.freeze(["absolute-proc-paths", "decoded-fd-paths"]),
pid_syscalls: Object.freeze([...pidTargetKinds.keys()]),
unverified: Object.freeze([
"legacy-cwd-relative-paths",
"symlink-path-aliases",
"same-node-thread-stat-status-purpose",
"other-pid-directed-syscalls",
]),
});
const readPid = (value) => {
const match = /^(-?\d+)(?=[),]|$)/u.exec(value ?? "");
const pid = match === null ? NaN : Number(match[1]);
return Number.isSafeInteger(pid) ? pid : null;
};
const readPidfd = (value) => {
const match = /^\d+<pid:(\d+)>$/u.exec(value ?? "");
return match === null ? null : readPid(match[1]);
};
const historicalPidTarget = (line, pid) => {
const call = /^(\w+)\((.*)$/u.exec(line);
const kind = call === null ? undefined : pidTargetKinds.get(call[1]);
if (kind === undefined) return "unselected";
const args = call[2].split(", ", 3);
const match = (...targets) =>
targets.includes(pid)
? "historical"
: targets.includes(null)
? "unresolved"
: "other";
switch (kind) {
case "pid":
return match(readPid(args[0]));
case "pair":
return match(readPid(args[0]), readPid(args[1]));
case "pidfd":
return match(readPidfd(args[0]));
case "pid-group":
case "signal-group": {
const target = readPid(args[0]);
if (target === null) return "unresolved";
return target === pid ||
target === -pid ||
(kind === "signal-group" && target === -1)
? "historical"
: "other";
}
case "priority":
case "ioprio": {
const prefix = kind === "priority" ? "PRIO_" : "IOPRIO_WHO_";
if (args[0] === `${prefix}USER`) return "other";
if (![`${prefix}PROCESS`, `${prefix}PGRP`].includes(args[0]))
return "unresolved";
return match(readPid(args[1]));
}
case "wait-selector":
if (args[0] === "P_ALL") return "other";
if (args[0] === "P_PIDFD") return match(readPidfd(args[1]));
if (!["P_PID", "P_PGID"].includes(args[0])) return "unresolved";
return match(readPid(args[1]));
case "capability": {
const header = /^\{[^}]*\bpid=(-?\d+)(?=[,}])/u.exec(call[2]);
return match(header === null ? null : readPid(header[1]));
}
default:
return "unresolved";
}
};
const readsMetadata = (line, root, names) => {
const path = `${root}/(?:${names.join("|")})`;
return (
new RegExp(
`^openat\\([^,]+, "${path}", O_RDONLY(?:\\|O_CLOEXEC)?\\)`,
"u",
).test(line) ||
new RegExp(
`^statx\\(\\d+<${path}>, "", [^,]*\\bAT_EMPTY_PATH\\b`,
"u",
).test(line) ||
new RegExp(`^fstat(?:64)?\\(\\d+<${path}>,`, "u").test(line)
);
};
/** Check resolved PID references; cwd-relative legacy paths remain unqualified. */
export function assertHistoricalPidAccess({
trace,
historicalPid,
ownershipThreads,
liveCollision,
psInventory,
}) {
const root = `/proc/${historicalPid}`;
const pathReference = new RegExp(`${root}(?=[/">]|$)`, "u");
const referencesHistoricalPath = (line) => {
if (pathReference.test(line)) return true;
// strace prints the ASCII /proc, PID and dot segments literally. Keep the
// original line as evidence while recognizing lexical path aliases too.
for (const quoted of line.matchAll(/"((?:[^"\\]|\\.)*)"/gu))
if (
quoted[1].startsWith("/proc/") &&
pathReference.test(posix.normalize(quoted[1]))
)
return true;
// -yy owns the current dirfd identity, including descriptor reuse. Resolve
// each FD/path argument pair, not just openat: failed readlinkat, statx and
// newfstatat attempts have no returned descriptor to reveal their target.
for (const relative of line.matchAll(
/(?:\(|, )(?:-?\d+|AT_FDCWD)<(\/[^>]*)>, ("(?:[^"\\]|\\.)*")/gu,
)) {
// strace prints ASCII separators, dot segments, /proc and decimal PIDs
// literally. Other C-escaped bytes remain opaque path components; JSON
// decoding would incorrectly reject valid non-ASCII caller paths.
const pathname = relative[2].slice(1, -1);
const path = posix.resolve(relative[1], pathname);
if (path === root || path.startsWith(`${root}/`)) return true;
}
return false;
};
const accesses = [];
for (const line of trace.text.split("\n")) {
const target = historicalPidTarget(line, historicalPid);
if (
target !== "historical" &&
target !== "unresolved" &&
!referencesHistoricalPath(line)
)
continue;
const ownershipMetadata =
ownershipThreads.has(trace.pid) &&
readsMetadata(line, root, ["stat", "status"]);
const psMetadata =
psInventory &&
(readsMetadata(line, root, [
"stat",
"status",
"cmdline",
"wchan",
"environ",
]) ||
// procps reads the PID directory's UID without opening a directory FD.
new RegExp(
`^newfstatat\\([^,]+, "${root}", .*?, (?:0|AT_SYMLINK_NOFOLLOW)\\)`,
"u",
).test(line));
assert.ok(
liveCollision &&
target !== "historical" &&
target !== "unresolved" &&
(ownershipMetadata || psMetadata),
`${target === "unresolved" ? "Unresolved process target in" : "Historical PID caused a"} non-inventory lookup in trace ${trace.pid}: ${line}`,
);
accesses.push(line);
}
return accesses;
}
+90 -58
View File
@@ -3,7 +3,12 @@ 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";
import { parseEvidence } from "../../../dist/domain/evidence.js";
import {
assertHistoricalPidAccess,
historicalPidTraceContract,
} from "./trace-policy.mjs";
import { collectOwnershipTraceFacts } 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({
@@ -13,6 +18,7 @@ export async function verifyCoreInspectionTrace({
core,
sentinel,
runId,
historicalPid = sentinel.pid,
}) {
const gdb = await realpath(environment.REA_PWNDBG_GDB);
const python = await realpath(environment.REA_PWNTOOLS_PYTHON);
@@ -31,7 +37,8 @@ export async function verifyCoreInspectionTrace({
const observations = [];
for (const mode of ["cli", "mcp"]) {
sentinel.assertAlive();
const prefix = join(root, `inspection-${mode}.trace`);
const traceName = `inspection-${historicalPid}-${mode}.trace.`;
const prefix = join(root, traceName.slice(0, -1));
const invocation =
mode === "cli"
? [
@@ -45,17 +52,19 @@ export async function verifyCoreInspectionTrace({
fileURLToPath(new URL("./trace-client.mjs", import.meta.url)),
entrypoint,
core,
String(historicalPid),
];
await runOwnedCommand(
const result = await runOwnedCommand(
{
command: environment.REA_VERIFY_STRACE_COMMAND,
arguments: [
"-ff",
"-qq",
"-yy",
"-s",
"8192",
"-e",
"trace=%process,%network,%file,ptrace,process_vm_readv,process_vm_writev",
`trace=%process,%signal,%network,%file,ptrace,process_vm_readv,process_vm_writev,${historicalPidTraceContract.pid_syscalls.join(",")}`,
"-o",
prefix,
process.execPath,
@@ -68,23 +77,45 @@ export async function verifyCoreInspectionTrace({
},
{ timeoutMs: 90_000, diagnosticBytes: 1024 * 1024 },
);
if (mode === "cli") {
const report = parseEvidence(
JSON.parse(result.stdout.text),
).normalized_result;
assert.ok(
report.threads.some(
(thread) => thread.historical_pid === historicalPid,
),
"CLI must decode the selected historical-PID control before its trace qualifies",
);
}
const executions = [];
const ownershipReads = [];
let gdbObserved = false;
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({
const traces = [];
for (const file of await readdir(root)) {
if (!file.startsWith(traceName)) continue;
const pid = Number(file.slice(traceName.length));
assert.ok(Number.isSafeInteger(pid) && pid > 0);
const text = await readFile(join(root, file), "utf8");
// Retain only execution/thread evidence across files, not all raw traces.
const inventory = text
.split("\n")
.filter((line) =>
/^(?:execve|clone|fork|vfork|<\.\.\. (?:clone|fork|vfork))/u.test(
line,
),
)
.join("\n");
traces.push({ pid, text: inventory, file });
}
const ownershipFacts = collectOwnershipTraceFacts({
traces,
sentinelPid: sentinel.pid,
nodeExecutable: process.execPath,
psExecutables: psPaths,
});
for (const { file, text } of traces) {
for (const trace of traces) {
const { file } = trace;
const text = await readFile(join(root, file), "utf8");
// 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"));
@@ -92,6 +123,15 @@ export async function verifyCoreInspectionTrace({
!text.split("\n").some((line) => line.includes(recordedExecutable)),
`Inspection attempted access to the recorded executable: ${file}`,
);
ownershipReads.push(
...assertHistoricalPidAccess({
trace: { pid: trace.pid, text },
historicalPid,
ownershipThreads: ownershipFacts.ownershipThreads,
liveCollision: historicalPid === sentinel.pid,
psInventory: ownershipFacts.psProcesses.has(trace.pid),
}),
);
assert.ok(
!/\bptrace\(/u.test(text),
`Inspection attempted ptrace: ${file}`,
@@ -106,48 +146,40 @@ export async function verifyCoreInspectionTrace({
),
`Inspection attempted an Internet socket: ${file}`,
);
for (const line of text.split("\n")) {
if (!/execve(?:at)?\(/u.test(line)) continue;
const match = /^execve\("([^"]+)"/u.exec(line);
assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
assert.ok(
/ = (?:0|-1 [A-Z]+)/u.test(line),
`Incomplete execution observation: ${line}`,
}
for (const execution of ownershipFacts.executions) {
const { line } = execution;
const executable = await realpath(execution.path).catch(
() => execution.path,
);
const ownership = execution.ownershipInventory;
const iconv =
iconvPaths.has(execution.path) && line.includes('["iconv", "-l"]');
const version =
gitPaths.has(execution.path) &&
line.includes(
"[" +
[
"git",
"--git-dir",
join(plugin, ".git"),
"rev-parse",
"--short",
"HEAD",
]
.map((argument) => JSON.stringify(argument))
.join(", ") +
"]",
);
const executable = await realpath(match[1]).catch(() => match[1]);
const ownership =
psPaths.has(match[1]) &&
line.includes(
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
);
const iconv =
iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]');
const version =
gitPaths.has(match[1]) &&
line.includes(
"[" +
[
"git",
"--git-dir",
join(plugin, ".git"),
"rev-parse",
"--short",
"HEAD",
]
.map((argument) => JSON.stringify(argument))
.join(", ") +
"]",
);
assert.ok(
[process.execPath, python, gdb].includes(executable) ||
ownership ||
iconv ||
version,
`Unexpected attempted host execution: ${line}`,
);
if (executable === gdb) gdbObserved = true;
executions.push(executable);
}
assert.ok(
[process.execPath, python, gdb].includes(executable) ||
ownership ||
iconv ||
version,
`Unexpected attempted host execution: ${line}`,
);
if (executable === gdb) gdbObserved = true;
executions.push(executable);
}
assert.ok(gdbObserved, "Trace must include the actual core-only GDB stage");
assert.ok(executions.includes(python));
@@ -155,7 +187,7 @@ export async function verifyCoreInspectionTrace({
observations.push({
mode,
executions,
historical_pid: sentinel.pid,
historical_pid: historicalPid,
sentinel: "still-alive",
ownership_process_table_reads: ownershipReads,
});
+18
View File
@@ -1,3 +1,4 @@
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
@@ -136,3 +137,20 @@ it("advertises every portable base64 constraint and keeps large inputs stack-saf
}
expect(isCanonicalBase64(encoded)).toBe(true);
});
it("validates complete projected canonical schemas at the encoded-input budget", () => {
const projected = z.toJSONSchema(canonicalBase64Schema, {
target: "draft-2020-12",
});
const validate = new Ajv2020({ strict: true }).compile(projected);
const encoded = Buffer.alloc(24 * 1024 * 1024 - 3, 0x81).toString("base64");
for (const { value, expected } of [
{ value: encoded, expected: true },
{ value: `${encoded.slice(0, -1)}!`, expected: false },
{ value: `${encoded.slice(0, -3)}R==`, expected: false },
{ value: `${encoded}A`, expected: false },
]) {
expect(canonicalBase64Schema.safeParse(value).success).toBe(expected);
expect(validate(value)).toBe(expected);
}
});
+37
View File
@@ -7,6 +7,7 @@ const statRecord = (
readonly pid?: string;
readonly command?: string;
readonly state?: string;
readonly flags?: string;
readonly parentPid?: string;
readonly processGroupId?: string;
readonly sessionId?: string;
@@ -20,6 +21,7 @@ const statRecord = (
options.sessionId ?? "19",
];
while (fields.length < 19) fields.push("0");
fields[6] = options.flags ?? "0";
fields.push(options.startTime ?? "987654");
return `${options.pid ?? "42"} (${options.command ?? "worker"}) ${fields.join(" ")}`;
};
@@ -56,3 +58,38 @@ it("distinguishes zombie lifecycle and rejects malformed or missing identity fie
undefined,
);
});
it.each(["R", "S", "D"])(
"retains PF_EXITING separately from terminal state %s",
(state) => {
expect(
parseLinuxProcessStat(statRecord({ state, flags: "4194316" }))?.lifecycle,
).toBe("exiting");
expect(
parseLinuxProcessStat(statRecord({ state, flags: "4194312" }))?.lifecycle,
).toBe("non-zombie");
},
);
it.each(["Z", "X", "x"])(
"recognizes terminal native state %s independently of flags",
(state) => {
expect(
parseLinuxProcessStat(statRecord({ state, flags: "invalid" }))?.lifecycle,
).toBe(state === "Z" ? "zombie" : "dead");
},
);
it.each(["invalid", "-1", "4.1", "9007199254740992"])(
"preserves identity and relations with unknown flags %s",
(flags) => {
expect(parseLinuxProcessStat(statRecord({ flags }))).toEqual({
pid: 42,
startTime: "987654",
lifecycle: undefined,
parentPid: 17,
processGroupId: 18,
sessionId: 19,
});
},
);
+22 -2
View File
@@ -1,8 +1,10 @@
import type { ProcessLifecycle } from "./ProcessLifecycle.js";
/** Native process facts parsed from one Linux `/proc/<pid>/stat` record. */
export interface LinuxProcessStat {
readonly pid: number;
readonly startTime: string;
readonly lifecycle: "non-zombie" | "zombie";
readonly lifecycle: ProcessLifecycle | undefined;
readonly parentPid: number;
readonly processGroupId: number;
readonly sessionId: number;
@@ -58,10 +60,28 @@ export const parseLinuxProcessStat = (
)
return undefined;
// Field 9 is task flags. PF_EXITING can precede state Z and procfs token
// access can already fail during that interval. Neither proves terminal exit.
const flagsText = fields[6];
const flags =
flagsText !== undefined && /^\d+$/u.test(flagsText)
? Number(flagsText)
: Number.NaN;
const lifecycle: ProcessLifecycle | undefined =
state === "Z"
? "zombie"
: state === "X" || state === "x"
? "dead"
: !Number.isSafeInteger(flags)
? undefined
: (flags & 0x00000004) !== 0
? "exiting"
: "non-zombie";
return {
pid,
startTime,
lifecycle: state === "Z" ? "zombie" : "non-zombie",
lifecycle,
parentPid,
processGroupId,
sessionId,
+32
View File
@@ -0,0 +1,32 @@
import type { ProcessIdentityObservation } from "./ProcessOwnership.js";
/** Native lifecycle is independent of ownership, token readability and ps text. */
export type ProcessLifecycle = "non-zombie" | "exiting" | "zombie" | "dead";
export type ProcessLifecycleObservation =
| { readonly state: "observed"; readonly lifecycle: ProcessLifecycle }
| { readonly state: "unavailable"; readonly reason: string };
export const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
export const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
/** One operation owns a lazily started native-exit waiting window. */
export interface ProcessExitSettlementBudget {
deadline: number | undefined;
}
export const processIdentityHasExited = (
identity: ProcessIdentityObservation | undefined,
): boolean =>
identity?.state === "absent" ||
(identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
(identity.lifecycle.lifecycle === "zombie" ||
identity.lifecycle.lifecycle === "dead"));
export const processIdentityIsExiting = (
identity: ProcessIdentityObservation | undefined,
): boolean =>
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "exiting";
@@ -0,0 +1,126 @@
import { performance } from "node:perf_hooks";
import { expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const row = (pid: number, parentPid: number): ProcessTableEntry => ({
pid,
parentPid,
processGroupId: pid,
state: "R",
command: "/fixture/node",
});
const identity = (
process: ProcessTableEntry,
exiting: boolean,
): ProcessIdentityObservation => ({
state: "readable",
identity: `start:${process.pid}`,
lifecycle: {
state: "observed",
lifecycle: exiting ? "exiting" : "non-zombie",
},
relation: {
state: "observed",
parentPid: process.parentPid,
processGroupId: process.processGroupId,
},
});
it("retains a live token-cleared child when its verified parent exits during observation", async () => {
const parent = row(200, 1);
const child = row(300, parent.pid);
const owner = new ProcessOwnershipEvidenceOwner();
let parentReads = 0;
const host: ProcessOwnershipHost = {
listProcesses: async () => [parent, child],
environment: async (pid) =>
pid === parent.pid ? { REA_PROCESS_RUN_ID: "run" } : {},
processIdentities: async (rows) =>
new Map<number, ProcessIdentityObservation>(
rows.map((process) => [
process.pid,
process.pid === parent.pid && ++parentReads > 2
? { state: "absent" as const, reason: "not-in-snapshot" }
: identity(
process,
process.pid === parent.pid && parentReads === 2,
),
]),
),
signalGroup: vi.fn(),
};
const result = await verifyNoTokenOwnedProcesses("run", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: owner,
});
// The fixture's child never exited or changed generation. Its previously
// observed parent edge must survive the parent's later terminal observation.
expect(result.cleaned).toBe(false);
expect(owner.candidates).toContainEqual(
expect.objectContaining({
pid: child.pid,
identity: "start:300",
applicability: "related-ownership-unresolved",
}),
);
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("shares native exit waiting before signaling healthy groups in one cleanup attempt", async () => {
const root = row(100, 1);
const rows = [
root,
row(200, root.pid),
row(300, root.pid),
row(400, root.pid),
];
let clock = 0;
let firstSignalAt: number | undefined;
const nativeClock = vi
.spyOn(performance, "now")
.mockImplementation(() => clock);
const host: ProcessOwnershipHost = {
listProcesses: async () => rows,
environment: async () => ({ REA_PROCESS_RUN_ID: "run" }),
processIdentities: async (selected) => {
clock += 25;
return new Map<number, ProcessIdentityObservation>(
selected.map((process) => [
process.pid,
identity(process, process.pid !== root.pid),
]),
);
},
signalGroup: vi.fn((group) => {
expect(group).toBe(root.pid);
firstSignalAt ??= clock;
}),
};
try {
const result = await cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: root.pid,
processGroupId: root.pid,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
},
host,
);
expect(result.cleaned).toBe(false);
expect(firstSignalAt).toBeDefined();
// Controlled native-observation time, not a wall-clock timeout: three
// stuck helpers must not each add another full second before root signal.
expect(firstSignalAt).toBeLessThan(2_000);
} finally {
nativeClock.mockRestore();
}
});
@@ -0,0 +1,179 @@
import { expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
type ProcessIdentityObservation,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import {
readProcessOwnershipFacts,
settleExitingProcessIdentities,
} from "./ProcessOwnershipObservation.js";
import { processIdentityHasExited } from "./ProcessLifecycle.js";
const row: ProcessTableEntry = {
pid: 910,
parentPid: 17,
processGroupId: 910,
state: "R",
command: "[MainThread]",
};
const identity = (
lifecycle: "non-zombie" | "exiting" | "zombie" | "dead",
generation = "start:1",
): ProcessIdentityObservation => ({
state: "readable",
identity: generation,
lifecycle: { state: "observed", lifecycle },
relation: {
state: "observed",
parentPid: row.parentPid,
processGroupId: row.processGroupId,
},
});
const hostWith = (
observe: () => ProcessIdentityObservation,
): ProcessOwnershipHost => ({
listProcesses: vi.fn(async () => [row]),
environment: async () => ({}),
processIdentities: vi.fn(
async (rows: readonly ProcessTableEntry[]) =>
new Map(rows.map(({ pid }) => [pid, observe()])),
),
runTokens: vi.fn(
async (rows: readonly ProcessTableEntry[]) =>
new Map(
rows.map(({ pid }) => [
pid,
{ state: "unavailable" as const, reason: "EACCES" },
]),
),
),
signalGroup: vi.fn(),
});
it.each(["zombie", "dead", "absent"] as const)(
"settles exiting token reads only after a fresh native %s observation",
async (terminal) => {
let reads = 0;
const host = hostWith(() =>
++reads < 3
? identity("exiting")
: terminal === "absent"
? { state: "absent", reason: "not-in-snapshot" }
: identity(terminal),
);
const facts = await readProcessOwnershipFacts([row], host);
expect(processIdentityHasExited(facts[0]?.identityAfter)).toBe(true);
expect(facts[0]?.token).toEqual({ state: "unavailable", reason: "EACCES" });
expect(host.processIdentities).toHaveBeenCalledTimes(3);
expect(host.runTokens).toHaveBeenCalledTimes(1);
expect(host.listProcesses).not.toHaveBeenCalled();
expect(host.signalGroup).not.toHaveBeenCalled();
},
);
it("keeps unconfirmed exit explicit at the bounded deadline", async () => {
const host = hostWith(() => identity("exiting"));
const result = await cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: row.pid,
processGroupId: row.processGroupId,
expectedCommand: "/exact/node",
},
host,
);
expect(result).toMatchObject({
cleaned: false,
reason: "owned launcher exit could not be confirmed",
});
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("settles launcher exit before comparing a stale command display", async () => {
let reads = 0;
const host = hostWith(() =>
++reads === 1 ? identity("exiting") : identity("zombie"),
);
await expect(
cleanupOwnedProcessGroup(
{
runId: "run",
leaderPid: row.pid,
processGroupId: row.processGroupId,
expectedCommand: "/exact/node",
},
host,
),
).resolves.toMatchObject({ cleaned: true, signaled: false });
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("stops at PID reuse and preserves the changed generation", async () => {
let reads = 0;
const host = hostWith(() =>
++reads < 3 ? identity("exiting") : identity("exiting", "start:2"),
);
const facts = await readProcessOwnershipFacts([row], host);
expect(facts[0]).toMatchObject({
state: "replaced",
identityBefore: { identity: "start:1" },
identityAfter: { identity: "start:2" },
});
expect(host.processIdentities).toHaveBeenCalledTimes(3);
expect(host.signalGroup).not.toHaveBeenCalled();
});
it("preserves unknown exit evidence without inferring terminal absence", async () => {
const host = hostWith(() => ({
state: "unavailable",
reason: "native identity denied",
}));
const result = await settleExitingProcessIdentities(
[row],
host,
new Map([[row.pid, identity("exiting")]]),
);
expect(result.get(row.pid)).toEqual({
state: "unavailable",
reason: "native identity denied",
});
expect(processIdentityHasExited(result.get(row.pid))).toBe(false);
});
it("refreshes only selected exiting PIDs and preserves other native observations", async () => {
const other = { ...row, pid: 911 };
const host = hostWith(() => identity("zombie"));
const initial = new Map([
[row.pid, identity("exiting")],
[other.pid, identity("non-zombie")],
]);
const result = await settleExitingProcessIdentities(
[row, other],
host,
initial,
);
expect(host.processIdentities).toHaveBeenCalledExactlyOnceWith(
[row],
undefined,
undefined,
);
expect(result.get(other.pid)).toBe(initial.get(other.pid));
expect(host.listProcesses).not.toHaveBeenCalled();
});
it("preserves caller cancellation during native exit settlement", async () => {
const abort = new AbortController();
const reason = new Error("caller cancelled exit observation");
const host = hostWith(() => identity("exiting"));
const pending = settleExitingProcessIdentities(
[row],
host,
new Map([[row.pid, identity("exiting")]]),
abort.signal,
);
abort.abort(reason);
await expect(pending).rejects.toBe(reason);
expect(host.processIdentities).not.toHaveBeenCalled();
});
@@ -1,6 +1,7 @@
import { afterEach, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessOwnershipHost,
type ProcessRunTokenObservation,
type ProcessTableEntry,
@@ -353,3 +354,41 @@ it("retries a moved token-owned helper group without signaling a released peer a
expect(signalGroups).toEqual([100, 200, 300]);
expect(owner.evidenceOwner.candidates).toEqual([]);
});
it("settles transient token reads without borrowing the application timer", async () => {
const fixture = makeHost(() => undefined);
let reads = 0;
const host: ProcessOwnershipHost = {
...fixture.host,
runTokens: async (entries) => {
reads += 1;
return new Map(
entries.map(({ pid }) => [
pid,
reads === 1
? {
state: "unavailable" as const,
reason: "environment_errno_EACCES",
}
: { state: "readable" as const, runId: undefined },
]),
);
},
};
const applicationTimer = vi
.spyOn(globalThis, "setTimeout")
.mockImplementation(() => {
throw new Error(
"application timer must not mediate native ownership retries",
);
});
try {
await expect(
verifyNoTokenOwnedProcesses("sweep-run", host),
).resolves.toEqual({ cleaned: true, signaled: false });
expect(reads).toBe(2);
expect(applicationTimer).not.toHaveBeenCalled();
} finally {
applicationTimer.mockRestore();
}
});
@@ -20,6 +20,52 @@ const observedIdentity = (entry: ProcessTableEntry, identity: string) => ({
},
});
it.each([undefined, "run-token"])(
"validates a current generation with token %s when its baseline identity was unknown",
async (runId) => {
const row: ProcessTableEntry = {
pid: 909,
parentPid: 1,
processGroupId: 909,
state: "S",
command: "foreign-process",
};
const environment = vi.fn(() =>
Promise.resolve(runId === undefined ? {} : { REA_PROCESS_RUN_ID: runId }),
);
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([row]),
environment,
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, "current-start"),
]),
),
),
signalGroup,
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: row.pid, identity: null },
]),
).resolves.toEqual(
runId === undefined
? { cleaned: true, signaled: false }
: {
cleaned: false,
reason: "token-owned process remained after cleanup",
},
);
expect(environment).toHaveBeenCalledWith(row.pid);
expect(signalGroup).not.toHaveBeenCalled();
},
);
describe("Linux process start identities", () => {
itOnLinux("reads Linux process start identities from proc stat", async () => {
const host = createSystemProcessOwnershipHost("linux", {});
+92 -51
View File
@@ -1,12 +1,13 @@
import { execFile } from "node:child_process";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { promisify } from "node:util";
import { setTimeout as delay } from "node:timers/promises";
import {
errorMessage,
readNativeProcessIdentityMap,
readProcessOwnershipFacts,
settleExitingProcessIdentities,
systemProcessOwnershipHost as systemHost,
} from "./ProcessOwnershipObservation.js";
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
@@ -15,10 +16,16 @@ import { readProcessRunTokens } from "./ProcessRunTokenObservations.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const execFileAsync = promisify(execFile);
import {
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS,
processIdentityHasExited,
processIdentityIsExiting,
type ProcessLifecycleObservation,
type ProcessExitSettlementBudget,
} from "./ProcessLifecycle.js";
const PROCESS_CLEANUP_VERIFICATION_GRACE_MS = 1_000;
const PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS = 25;
const execFileAsync = promisify(execFile);
/** Shared fields for a process group acquired by a resource owner. */
interface OwnedProcessGroupFacts {
@@ -99,13 +106,7 @@ export type ProcessIdentityObservation =
}
| { readonly state: "unavailable"; readonly reason: string };
/** Native lifecycle fact observed alongside (but independent of) start identity. */
export type ProcessLifecycleObservation =
| {
readonly state: "observed";
readonly lifecycle: "non-zombie" | "zombie";
}
| { readonly state: "unavailable"; readonly reason: string };
export type { ProcessLifecycleObservation } from "./ProcessLifecycle.js";
/** Parent and process-group membership from the same native identity read. */
export type ProcessNativeRelationObservation =
@@ -117,7 +118,7 @@ export type ProcessNativeRelationObservation =
| { readonly state: "unavailable"; readonly reason: string };
/** One caller-supplied process row validated across identity/token/identity reads. */
export interface ProcessOwnershipFact {
interface ProcessOwnershipFactSnapshot {
/** Caller-supplied candidate row; its parent/group may predate this read. */
readonly process: ProcessTableEntry;
/** Identity relation only; token ownership still requires matching readable runId. */
@@ -129,6 +130,11 @@ export interface ProcessOwnershipFact {
readonly identityAfter: ProcessIdentityObservation;
}
export interface ProcessOwnershipFact extends ProcessOwnershipFactSnapshot {
/** Bracketed ownership proof before a later native exit observation. */
readonly beforeExitSettlement?: ProcessOwnershipFactSnapshot;
}
/** Token-verified process lineage retained for one owned provider run. */
export interface OwnedProcessLineage {
readonly runId: string;
@@ -327,6 +333,7 @@ export const cleanupOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost = systemHost,
): Promise<ProcessCleanupResult> => {
const exitSettlement: ProcessExitSettlementBudget = { deadline: undefined };
const processTable = await readLiveProcessTable(host);
if (!processTable.available)
return {
@@ -337,6 +344,7 @@ export const cleanupOwnedProcessGroup = async (
ownership,
processTable.processes,
host,
exitSettlement,
);
if ("cleaned" in plan) return plan;
let signaled = false;
@@ -346,6 +354,7 @@ export const cleanupOwnedProcessGroup = async (
ownership,
host,
plan.tokenOwnedIdentities,
exitSettlement,
);
if ("cleaned" in revalidation)
return mergeUnverifiedObservations(revalidation, plan.unverified);
@@ -386,6 +395,7 @@ export const cleanupOwnedProcessGroup = async (
host,
ownership.captureBaseline,
relation,
exitSettlement,
);
while (!verification.cleaned) {
const remaining = deadline - performance.now();
@@ -398,6 +408,7 @@ export const cleanupOwnedProcessGroup = async (
host,
ownership.captureBaseline,
relation,
exitSettlement,
);
}
} catch (cause: unknown) {
@@ -429,6 +440,7 @@ export const verifyNoTokenOwnedProcesses = async (
host: ProcessOwnershipHost = systemHost,
captureBaseline?: ProcessOwnershipBaseline,
relation?: OwnershipSweepRelation,
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
): Promise<ProcessCleanupResult> => {
const processTable = await readLiveProcessTable(host);
if (!processTable.available)
@@ -442,6 +454,7 @@ export const verifyNoTokenOwnedProcesses = async (
host,
captureBaseline,
relation,
exitSettlement,
);
if (scan.failures.length > 0)
return cleanupValidationFailure(scan.failures, scan.unverified);
@@ -526,12 +539,7 @@ const readProcessRelationSnapshot = async (
const failures: ProcessOwnershipValidationFailure[] = [];
for (const process of processes) {
const identity = identities.get(process.pid);
if (
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
continue;
if (processIdentityHasExited(identity)) continue;
if (
identity?.state === "readable" &&
identity.relation?.state === "observed"
@@ -554,12 +562,13 @@ const readProcessRelationSnapshot = async (
};
// A process-table read can race exit and retain the old state with an altered
// command. Only a fresh table proving that PID is no longer live can settle
// that mismatch; surviving group members still require normal token checks.
// command. Native terminal facts can settle that mismatch; exit-in-progress
// must be confirmed first. Surviving members still require normal token checks.
const settleLauncherExit = async (
ownership: OwnedProcessGroup,
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise<
| readonly ProcessTableEntry[]
| Extract<ProcessCleanupResult, { readonly cleaned: false }>
@@ -568,15 +577,34 @@ const settleLauncherExit = async (
if (launcher === undefined) return processes;
let identityCheckedLauncher = launcher;
if (host.processIdentities !== undefined) {
const identities = await readNativeProcessIdentityMap([launcher], host);
const initialIdentities = await readNativeProcessIdentityMap(
[launcher],
host,
);
const identities = await settleExitingProcessIdentities(
[launcher],
host,
initialIdentities,
undefined,
exitSettlement,
);
const identity = identities.get(launcher.pid);
if (identity?.state === "absent")
return processes.filter(({ pid }) => pid !== launcher.pid);
const initialIdentity = initialIdentities.get(launcher.pid);
if (
initialIdentity?.state === "readable" &&
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
initialIdentity.identity !== identity.identity
)
return {
cleaned: false,
reason: "owned launcher identity changed while verifying exit",
};
if (processIdentityIsExiting(identity))
return {
cleaned: false,
reason: "owned launcher exit could not be confirmed",
};
if (processIdentityHasExited(identity))
return processes.filter(({ pid }) => pid !== launcher.pid);
if (
identity?.state !== "readable" ||
@@ -635,8 +663,14 @@ const createOwnedCleanupPlan = async (
ownership: OwnedProcessGroup,
initialProcesses: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
exitSettlement: ProcessExitSettlementBudget,
): Promise<OwnedCleanupPlan | ProcessCleanupResult> => {
const processes = await settleLauncherExit(ownership, initialProcesses, host);
const processes = await settleLauncherExit(
ownership,
initialProcesses,
host,
exitSettlement,
);
if ("cleaned" in processes) return processes;
const relationSnapshot = await readProcessRelationSnapshot(
ownership,
@@ -664,6 +698,7 @@ const createOwnedCleanupPlan = async (
? {}
: { sampledProcessGroupIds: [...sampledProcessGroupIds] }),
},
exitSettlement,
)
: { owned: [], failures: [], unverified: [], identities: new Map() };
const tokenOwnedGroupIds = new Set(
@@ -752,6 +787,7 @@ const createOwnedCleanupPlan = async (
ownership.runId,
host,
tokenOwned.identities,
exitSettlement,
);
if (failures.length === 0 && unverifiableSampledGroups.has(processGroupId))
unresolved.push(
@@ -827,6 +863,7 @@ const revalidateOwnedProcessGroup = async (
ownership: OwnedProcessGroup,
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap<number, string | null>,
exitSettlement: ProcessExitSettlementBudget,
): Promise<{ readonly empty: boolean } | ProcessCleanupResult> => {
let members: readonly ProcessTableEntry[];
try {
@@ -834,6 +871,7 @@ const revalidateOwnedProcessGroup = async (
ownership,
liveProcesses(await host.listProcesses()),
host,
exitSettlement,
);
if ("cleaned" in processes) return processes;
const relationSnapshot = await readProcessRelationSnapshot(
@@ -858,6 +896,7 @@ const revalidateOwnedProcessGroup = async (
ownership.runId,
host,
expectedIdentities,
exitSettlement,
);
if (failures.length > 0) return cleanupValidationFailure(failures);
if (
@@ -965,25 +1004,29 @@ 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,
host: ProcessOwnershipHost,
expectedIdentities: ReadonlyMap<number, string | null> = new Map(),
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
): Promise<readonly ProcessOwnershipValidationFailure[]> => {
if (host.processIdentities !== undefined) {
const facts = await readProcessOwnershipFacts(members, host);
const facts = await readProcessOwnershipFacts(members, host, undefined, {
exitSettlement,
});
const failures: ProcessOwnershipValidationFailure[] = [];
for (const fact of facts) {
if (processIdentityHasExited(fact.identityAfter)) continue;
if (processIdentityIsExiting(fact.identityAfter)) {
failures.push({
pid: fact.process.pid,
reason: "process-identity-unavailable",
diagnostic:
"native process exit could not be confirmed before signal",
});
continue;
}
const relation =
fact.identityAfter.state === "readable" &&
fact.identityAfter.relation?.state === "observed"
@@ -1160,6 +1203,7 @@ const scanTokenOwnedProcesses = async (
host: ProcessOwnershipHost,
captureBaseline?: ProcessOwnershipBaseline,
relation?: OwnershipSweepRelation,
exitSettlement: ProcessExitSettlementBudget = { deadline: undefined },
): Promise<TokenOwnedProcessScan> => {
if (host.processIdentities !== undefined)
return scanWithOwnershipEvidence(
@@ -1168,6 +1212,7 @@ const scanTokenOwnedProcesses = async (
host,
captureBaseline,
relation,
exitSettlement,
);
const candidates = processes;
@@ -1260,6 +1305,7 @@ const scanWithOwnershipEvidence = async (
host: ProcessOwnershipHost,
captureBaseline: ProcessOwnershipBaseline | undefined,
relation: OwnershipSweepRelation | undefined,
exitSettlement: ProcessExitSettlementBudget,
): Promise<TokenOwnedProcessScan> => {
const evidenceOwner =
relation === undefined
@@ -1289,24 +1335,14 @@ const scanWithOwnershipEvidence = async (
observationRows,
host,
);
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.set(process.pid, {
pid: process.pid,
reason: "process-identity-unavailable",
diagnostic: "process identity was unavailable during token validation",
});
// Keep the row in the owning fact pass. The failure is not evidence that
// the process is absent, and a related unknown generation must survive
// a later process-table omission.
// The baseline selects candidates; current native facts and retained
// ownership evidence decide identity, liveness and applicability.
return true;
}
return now.identity !== before;
@@ -1320,6 +1356,7 @@ const scanWithOwnershipEvidence = async (
undefined,
{
identityBefore: firstIdentities,
exitSettlement,
},
);
let facts = initialFacts;
@@ -1328,12 +1365,18 @@ const scanWithOwnershipEvidence = async (
.filter(
({ token, identityAfter }) =>
token.state === "unavailable" &&
!processIdentityHasExited(identityAfter),
!processIdentityHasExited(identityAfter) &&
!processIdentityIsExiting(identityAfter),
)
.map(({ process }) => process);
if (retryRows.length === 0) break;
await delay(TOKEN_READ_RETRY_DELAY_MS);
const retries = await readProcessOwnershipFacts(retryRows, host);
const retries = await readProcessOwnershipFacts(
retryRows,
host,
undefined,
{ exitSettlement },
);
const retryByPid = new Map(retries.map((fact) => [fact.process.pid, fact]));
facts = facts.map((fact) => retryByPid.get(fact.process.pid) ?? fact);
}
@@ -1365,8 +1408,6 @@ const scanWithOwnershipEvidence = async (
// 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,
+11 -6
View File
@@ -1,3 +1,4 @@
import { processIdentityHasExited } from "./ProcessLifecycle.js";
import type {
ProcessIdentityObservation,
ProcessNativeRelationObservation,
@@ -82,6 +83,14 @@ export class ProcessOwnershipEvidenceOwner {
readonly scopeFacts?: readonly ProcessOwnershipFact[];
},
): readonly RetainedProcessOwnershipCandidate[] {
// Acquire parent/child scope while its bracketed token proof still exists.
// A later terminal observation may retire that parent, never its child's
// already observed relationship to the run.
if (facts.some((fact) => fact.beforeExitSettlement !== undefined))
this.reconcile(
facts.map((fact) => fact.beforeExitSettlement ?? fact),
options,
);
this.#snapshotCount += 1;
const factsByPid = new Map(facts.map((fact) => [fact.process.pid, fact]));
const sameGeneration = new Map<
@@ -208,9 +217,7 @@ export class ProcessOwnershipEvidenceOwner {
fact.identityAfter.state === "readable" &&
fact.identityAfter.identity !== previous.identity) ||
(fact.state === "same-generation" &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie")
processIdentityHasExited(fact.identityAfter))
) {
this.#candidates.delete(pid);
retiredPids.add(pid);
@@ -274,9 +281,7 @@ export class ProcessOwnershipEvidenceOwner {
(retiredPids.has(pid) && fact.state !== "same-generation") ||
fact.state === "absent" ||
(fact.state === "same-generation" &&
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie")
processIdentityHasExited(fact.identityAfter))
)
continue;
const identity = fact.identityAfter;
+97 -33
View File
@@ -1,4 +1,13 @@
import { readFile } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import {
processIdentityHasExited,
processIdentityIsExiting,
PROCESS_CLEANUP_VERIFICATION_GRACE_MS,
PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS,
type ProcessExitSettlementBudget,
} from "./ProcessLifecycle.js";
import { parseLinuxProcessStat } from "./LinuxProcessStat.js";
import { parseLinuxProcessEffectiveUid } from "./LinuxProcessStatus.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
@@ -63,11 +72,7 @@ export const observeOwnedProcessGroups = async (
if (retainedPids.has(process.pid)) return true;
const identity = firstIdentities.get(process.pid);
if (identity?.state === "readable") {
if (
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
return false;
if (processIdentityHasExited(identity)) return false;
if (
identity.relation?.state === "observed" &&
groupIds.includes(identity.relation.processGroupId)
@@ -116,12 +121,7 @@ export const observeOwnedProcessGroups = async (
fact.identityAfter.relation?.state === "observed"
? fact.identityAfter.relation.processGroupId
: undefined;
if (
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie"
)
continue;
if (processIdentityHasExited(fact.identityAfter)) continue;
const candidateGroups = groupIds.filter(
(groupId) =>
nativeGroupId === groupId ||
@@ -301,10 +301,7 @@ const observeNativeOwnedProcessLineage = async (
ownership,
`owned launcher identity could not be read: ${launcherIdentity?.reason ?? "identity observation was omitted"}`,
);
if (
launcherIdentity.lifecycle?.state === "observed" &&
launcherIdentity.lifecycle.lifecycle === "zombie"
)
if (processIdentityHasExited(launcherIdentity))
return unavailableLineage(ownership, "owned launcher is not live");
const nativeLauncher = processWithNativeRelation(launcher, launcherIdentity);
if (nativeLauncher === undefined)
@@ -318,12 +315,7 @@ const observeNativeOwnedProcessLineage = async (
const nativeProcesses = processes.flatMap((process) => {
const identity = initialIdentities.get(process.pid);
if (
identity?.state === "readable" &&
identity.lifecycle?.state === "observed" &&
identity.lifecycle.lifecycle === "zombie"
)
return [];
if (processIdentityHasExited(identity)) return [];
const nativeProcess =
identity?.state === "readable"
? processWithNativeRelation(process, identity)
@@ -371,11 +363,7 @@ const observeNativeOwnedProcessLineage = async (
ownership,
`process identity changed or became unavailable during lineage validation for PID ${String(member.pid)}`,
);
if (
fact.identityAfter.state === "readable" &&
fact.identityAfter.lifecycle?.state === "observed" &&
fact.identityAfter.lifecycle.lifecycle === "zombie"
) {
if (processIdentityHasExited(fact.identityAfter)) {
if (member.pid === ownership.leaderPid)
return unavailableLineage(
ownership,
@@ -542,6 +530,57 @@ export const readNativeProcessIdentityMap = async (
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> =>
readIdentityFacts(processes, host, "before", signal);
/**
* Wait only for positively observed native exit-in-progress. Never infer exit
* from EACCES or ps text, enumerate the process table, or follow a reused PID.
* A deadline, unavailable read or changed generation remains explicit evidence.
*/
export const settleExitingProcessIdentities = async (
processes: readonly ProcessTableEntry[],
host: ProcessOwnershipHost,
initial: ReadonlyMap<number, ProcessIdentityObservation>,
signal?: AbortSignal,
budget: ProcessExitSettlementBudget = { deadline: undefined },
): Promise<ReadonlyMap<number, ProcessIdentityObservation>> => {
signal?.throwIfAborted();
let pending = processes.filter(({ pid }) =>
processIdentityIsExiting(initial.get(pid)),
);
if (pending.length === 0) return initial;
const identities = new Map(initial);
const deadline = (budget.deadline ??=
performance.now() + PROCESS_CLEANUP_VERIFICATION_GRACE_MS);
while (pending.length > 0) {
const remaining = deadline - performance.now();
if (remaining <= 0) break;
try {
await delay(
Math.min(PROCESS_CLEANUP_VERIFICATION_INTERVAL_MS, remaining),
undefined,
signal === undefined ? undefined : { signal },
);
} catch (cause: unknown) {
signal?.throwIfAborted();
throw cause;
}
const refreshed = await readNativeProcessIdentityMap(pending, host, signal);
for (const { pid } of pending)
identities.set(pid, identityFact(refreshed, pid, "after"));
pending = pending.filter(({ pid }) => {
const first = initial.get(pid);
const current = identities.get(pid);
return (
first?.state === "readable" &&
current?.state === "readable" &&
processIdentityIsExiting(current) &&
current.identity === first.identity
);
});
}
signal?.throwIfAborted();
return identities;
};
/**
* Read identity, token, then identity for caller-provided rows. This owner
* never enumerates the process table; a token is tied to a generation only
@@ -553,6 +592,7 @@ export const readProcessOwnershipFacts = async (
signal?: AbortSignal,
options: {
readonly identityBefore?: ReadonlyMap<number, ProcessIdentityObservation>;
readonly exitSettlement?: ProcessExitSettlementBudget;
} = {},
): Promise<readonly ProcessOwnershipFact[]> => {
signal?.throwIfAborted();
@@ -591,20 +631,30 @@ export const readProcessOwnershipFacts = async (
.filter(({ pid }) => tokens.get(pid)?.state !== "readable")
.map(({ pid }) => pid),
);
const identityAfter = await readIdentityFacts(
const observedAfter = await readIdentityFacts(
processes,
host,
"after",
signal,
effectiveUidPids,
);
const identityAfter = await settleExitingProcessIdentities(
processes,
host,
observedAfter,
signal,
options.exitSettlement,
);
signal?.throwIfAborted();
const facts = processes.map((process): ProcessOwnershipFact => {
const deriveFact = (
process: ProcessTableEntry,
observation: ProcessIdentityObservation | undefined,
): ProcessOwnershipFact => {
const before = identityBefore.get(process.pid) ?? {
state: "unavailable" as const,
reason: "process identity was not returned during before observation",
};
const after = identityAfter.get(process.pid) ?? {
const after = observation ?? {
state: "unavailable" as const,
reason: "process identity was not returned during after observation",
};
@@ -629,6 +679,14 @@ export const readProcessOwnershipFacts = async (
token,
identityAfter: after,
};
};
const facts = processes.map((process): ProcessOwnershipFact => {
const after = identityAfter.get(process.pid);
const original = observedAfter.get(process.pid);
const fact = deriveFact(process, after);
return after === original
? fact
: { ...fact, beforeExitSettlement: deriveFact(process, original) };
});
signal?.throwIfAborted();
return facts;
@@ -741,10 +799,16 @@ export const createSystemProcessOwnershipHost = (
state: "readable" as const,
identity: `linux-starttime:${facts.startTime}`,
...(effectiveUid === undefined ? {} : { effectiveUid }),
lifecycle: {
state: "observed" as const,
lifecycle: facts.lifecycle,
},
lifecycle:
facts.lifecycle === undefined
? {
state: "unavailable" as const,
reason: "malformed_proc_flags",
}
: {
state: "observed" as const,
lifecycle: facts.lifecycle,
},
relation: {
state: "observed" as const,
parentPid: facts.parentPid,
@@ -1,23 +1,43 @@
import { expect, it } from "vitest";
import { assertHistoricalPidReadsAreOwnershipOnly } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs";
import { collectOwnershipTraceFacts } from "../../../scripts/verify/recorded-crash/trace-ownership.mjs";
import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
const node = "/fixture/node";
const nodeExec = 'execve("/fixture/node", ["node", "rea.mjs"], 0x7fff) = 0';
const psExec =
'execve("/usr/bin/ps", ["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="], 0x7fff) = 0';
const parent = (...events: readonly string[]) => ({
pid: 1200,
text: [nodeExec, ...events].join("\n"),
});
const check = (
traces: readonly { readonly pid: number; readonly text: string }[],
) => {
const facts = collectOwnershipTraceFacts({
traces,
nodeExecutable: node,
psExecutables: new Set(["/usr/bin/ps"]),
});
return traces.flatMap((trace) =>
assertHistoricalPidAccess({
trace,
historicalPid: 4008,
ownershipThreads: facts.ownershipThreads,
liveCollision: true,
psInventory: facts.psProcesses.has(trace.pid),
}),
);
};
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"),
},
expect(
check([
parent(
"vfork() = 1201",
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD}, 88) = 1202",
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY|O_CLOEXEC) = 21',
),
{
pid: 1202,
text: [
@@ -33,55 +53,34 @@ it("correlates the ps child trace with its parent identity read", () => {
'openat(AT_FDCWD, "/proc/4008/cmdline", O_RDONLY) = 4',
].join("\n"),
},
],
});
expect(ownershipReads).toHaveLength(5);
]),
).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',
},
expect(
check([
parent(
"vfork() = 1201",
"clone3({flags=CLONE_VM|CLONE_SIGHAND|CLONE_THREAD, child_tid=0x3 <unfinished ...>",
"<... clone3 resumed>) = 1202",
),
{ pid: 1202, text: 'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3' },
{ pid: 1201, text: psExec },
],
});
expect(ownershipReads).toHaveLength(1);
]),
).toHaveLength(1);
});
it("rejects provider-style reads outside the ps child and stat-only caller", () => {
const traces = [
{
pid: 1200,
text: [
it("rejects provider-style reads outside the ps child and identity-only caller", () => {
expect(() =>
check([
parent(
"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");
),
{ pid: 1201, text: psExec },
]),
).toThrow("non-inventory lookup");
});
it("does not trust a ps command with different arguments or failed exec", () => {
@@ -90,55 +89,83 @@ it("does not trust a ps command with different arguments or failed exec", () =>
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");
check([
parent(
"vfork() = 1201",
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
),
{ pid: 1201, text },
]),
).toThrow("non-inventory 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");
check([
parent("vfork() = 1201"),
{
pid: 1201,
text: `${psExec}\nopenat(AT_FDCWD, "/proc/4008/exe", O_RDONLY) = 3`,
},
]),
).toThrow("non-inventory 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");
check([
parent("vfork() = 1201", "clone3({flags=SIGCHLD}, 88) = 1203"),
{ pid: 1201, text: psExec },
{
pid: 1203,
text: `openat(AT_FDCWD, "/proc/4008/${field}", O_RDONLY) = 3`,
},
]),
).toThrow("non-inventory lookup");
});
it("requires an observed Node execution as well as the inventory ps child", () => {
expect(() =>
check([
{
pid: 1200,
text: 'vfork() = 1201\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
},
{ pid: 1201, text: psExec },
]),
).toThrow("non-inventory lookup");
});
it("refuses identity reads in an exec-replaced ownership thread", () => {
expect(() =>
check([
parent(
"vfork() = 1201",
"clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 1202",
),
{ pid: 1201, text: psExec },
{
pid: 1202,
text: 'execve("/fixture/gdb", ["gdb"], 0x7fff) = 0\nopenat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
},
]),
).toThrow("non-inventory lookup");
});
it("does not join unrelated groups through child-side clone returns", () => {
expect(() =>
check([
parent("vfork() = 1201", "clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0"),
{ pid: 1201, text: psExec },
{
pid: 1300,
text: [
nodeExec,
"clone3({flags=CLONE_VM|CLONE_THREAD}, 88) = 0",
'openat(AT_FDCWD, "/proc/4008/stat", O_RDONLY) = 3',
].join("\n"),
},
]),
).toThrow("non-inventory lookup");
});
+17 -1
View File
@@ -917,9 +917,21 @@ it("retains typed memory failure through failed cleanup and restores it after re
"heap-failure",
() => fails,
);
const originalRootClose = PrivateRuntimeRoot.prototype.close;
const workspaceCloseCause = new Error("fixture workspace close failure");
let workspaceCloseFails = true;
let workspaceCloseAttempts = 0;
const rootClose = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockRejectedValueOnce(new Error("fixture workspace close failure"));
.mockImplementation(function (this: PrivateRuntimeRoot) {
// Independent native helpers own their own roots. Keep the acquired
// Android workspace's failure active until the test explicitly clears it.
if (this.path === launches[0]?.cwd) {
workspaceCloseAttempts += 1;
if (workspaceCloseFails) return Promise.reject(workspaceCloseCause);
}
return originalRootClose.call(this);
});
try {
const result = await service.execute("inspect_android_package", {
path: apk,
@@ -935,11 +947,14 @@ it("retains typed memory failure through failed cleanup and restores it after re
});
const workspace = launches[0]?.cwd;
expect(failure.cleanupResources).toContain(workspace);
expect(workspaceCloseAttempts).toBe(0);
fails = false;
await expect(provider.close()).rejects.toMatchObject({
cleanupIncomplete: true,
});
expect(failure.cleanupResources).toEqual([workspace]);
expect(workspaceCloseAttempts).toBeGreaterThan(0);
workspaceCloseFails = false;
await provider.close();
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupResources).toEqual([]);
@@ -955,6 +970,7 @@ it("retains typed memory failure through failed cleanup and restores it after re
await verifyCleanup(launches);
} finally {
rootClose.mockRestore();
workspaceCloseFails = false;
fails = false;
await provider.close();
}
@@ -0,0 +1,50 @@
import { randomUUID } from "node:crypto";
import { expect, it } from "vitest";
import { verifyNoTokenOwnedProcesses } from "../../../src/process/ProcessOwnership.js";
import { createSystemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js";
import {
spawnProviderProcessFixture,
stopProviderProcessFixture,
waitForProviderProcessReady,
} from "../../fixtures/providerProcess.js";
it.skipIf(process.platform !== "linux")(
"accepts native absence after a baseline process exits between table and identity reads",
async () => {
const child = spawnProviderProcessFixture("graceful");
const native = createSystemProcessOwnershipHost("linux", process.env);
try {
await waitForProviderProcessReady(child);
const baseline = await native.captureBaseline?.();
expect(baseline).toContainEqual({
pid: child.pid,
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
const rows = (await native.listProcesses()).filter(
({ pid }) => pid === child.pid,
);
expect(rows).toHaveLength(1);
await expect(
verifyNoTokenOwnedProcesses(
randomUUID(),
{
...native,
listProcesses: async () => {
// Keep one real candidate row, then force the enumeration race
// before the unmodified native identity/token observer runs.
await stopProviderProcessFixture(child);
return rows;
},
},
baseline,
),
).resolves.toEqual({ cleaned: true, signaled: false });
} finally {
await stopProviderProcessFixture(child);
await native.close?.();
}
},
);
@@ -0,0 +1,236 @@
import { expect, it } from "vitest";
import { assertHistoricalPidAccess } from "../../../scripts/verify/recorded-crash/trace-policy.mjs";
const stat = 'openat(AT_FDCWD, "/proc/900/stat", O_RDONLY|O_CLOEXEC) = 22';
const check = (pid: number, text: string, liveCollision = true) =>
assertHistoricalPidAccess({
trace: { pid, text },
historicalPid: 900,
ownershipThreads: new Set([100, 101]),
liveCollision,
psInventory: false,
});
it("allows stat reads only in the supplied ownership thread set", () => {
expect(check(100, stat)).toEqual([stat]);
expect(check(101, stat)).toEqual([stat]);
expect(() => check(102, stat)).toThrow("non-inventory lookup");
});
it("permits the native descriptor metadata query used by Node's readFile", () => {
const metadata =
'statx(20</proc/900/stat>, "", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {}) = 0';
expect(check(101, metadata)).toEqual([metadata]);
expect(() => check(101, metadata, false)).toThrow("non-inventory lookup");
expect(() => check(101, metadata.replace("/stat>", "/mem>"))).toThrow(
"non-inventory lookup",
);
});
it("permits procps UID metadata without granting a PID directory handle", () => {
const metadata =
'newfstatat(AT_FDCWD</tmp>, "/proc/900", {st_mode=S_IFDIR|0555}, 0) = 0';
const input = {
trace: { pid: 102, text: metadata },
historicalPid: 900,
ownershipThreads: new Set<number>(),
liveCollision: true,
psInventory: true,
};
expect(assertHistoricalPidAccess(input)).toEqual([metadata]);
expect(() =>
assertHistoricalPidAccess({ ...input, liveCollision: false }),
).toThrow("non-inventory lookup");
expect(() =>
assertHistoricalPidAccess({ ...input, psInventory: false }),
).toThrow("non-inventory lookup");
expect(() =>
assertHistoricalPidAccess({
...input,
trace: {
pid: 102,
text: 'openat(AT_FDCWD, "/proc/900", O_RDONLY|O_DIRECTORY) = 5</proc/900>',
},
}),
).toThrow("non-inventory lookup");
});
it.each(["mem", "maps", "environ", "exe", "root"])(
"rejects Node access to the live historical PID's %s",
(name) => {
expect(() => check(101, stat.replace("/stat", `/${name}`))).toThrow(
"non-inventory lookup",
);
},
);
it("rejects an unlisted historical PID lookup even from an observed inventory thread", () => {
expect(() => check(101, stat, false)).toThrow("non-inventory lookup");
});
it.each([
'openat(AT_FDCWD, "/proc/900", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 5</proc/900>',
'openat(5</proc/900>, "mem", O_RDONLY|O_CLOEXEC) = 6</proc/900/mem>',
'openat(5</proc/900>, "stat", O_RDONLY|O_CLOEXEC) = -1 EACCES (Permission denied)',
'openat(5</proc>, "900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
'openat(5</proc>, "self/../900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
'openat(AT_FDCWD, "/proc/./900", O_RDONLY|O_DIRECTORY) = -1 ENOENT (No such file or directory)',
'openat(AT_FDCWD, "/proc/self/../900/mem", O_RDONLY) = -1 ENOENT (No such file or directory)',
"kill(900, 0) = 0",
"kill(-900, SIGSTOP) = 0",
"kill(-1, SIGSTOP) = 0",
"tkill(900, SIGSTOP) = 0",
"tgkill(900, 901, SIGSTOP) = 0",
"tgkill(901, 900, SIGSTOP) = 0",
"rt_sigqueueinfo(900, SIGUSR1, {}) = 0",
"rt_tgsigqueueinfo(901, 900, SIGUSR1, {}) = 0",
"pidfd_open(900, 0) = 7<pid:900>",
"pidfd_send_signal(7<pid:900>, SIGSTOP, NULL, 0) = 0",
"pidfd_send_signal(7, SIGSTOP, NULL, 0) = 0",
])("rejects historical PID directory/handle/control access: %s", (line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
});
it("keeps descriptor reuse and distinct PIDs separate and refuses ps writes", () => {
expect(check(101, 'openat(5</tmp>, "mem", O_RDONLY) = 6</tmp/mem>')).toEqual(
[],
);
expect(check(101, stat.replaceAll("900", "9000"))).toEqual([]);
expect(() =>
assertHistoricalPidAccess({
trace: { pid: 102, text: stat.replace("O_RDONLY", "O_WRONLY") },
historicalPid: 900,
ownershipThreads: new Set(),
liveCollision: true,
psInventory: true,
}),
).toThrow("non-inventory lookup");
});
it.each([
'readlinkat(5</proc>, "900/exe", 0x1, 4096) = -1 EACCES (Permission denied)',
'newfstatat(5</proc>, "900/mem", 0x1, 0) = -1 EACCES (Permission denied)',
'statx(5</proc>, "900/mem", 0, STATX_ALL, 0x1) = -1 EACCES (Permission denied)',
'openat(5</tmp>, "../proc/900/mem", O_RDONLY) = -1 EACCES (Permission denied)',
])("checks failed relative pathname operations beyond openat: %s", (line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
});
it.each([
"getpriority(PRIO_PROCESS, 900) = 20",
"setpriority(PRIO_PROCESS, 900, 0) = 0",
"getpriority(PRIO_PGRP, 900) = 20",
"setpriority(PRIO_PGRP, 900, 0) = 0",
"getpriority(0x3 /* unknown PRIO selector */, 900) = -1 EINVAL (Invalid argument)",
])("rejects historical PID priority access: %s", (line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
});
it("keeps native priority UID selectors and distinct PIDs separate", () => {
expect(check(101, "getpriority(PRIO_USER, 900) = 20")).toEqual([]);
expect(check(101, "getpriority(PRIO_PROCESS, 9000) = 20")).toEqual([]);
});
it.each([
String.raw`openat(AT_FDCWD</fixture>, "/fixture/\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`,
String.raw`openat(5</fixture>, "\345\267\245\345\205\267", O_RDONLY) = -1 ENOENT (No such file or directory)`,
])("preserves unrelated C-quoted pathname components: %s", (line) => {
expect(check(101, line)).toEqual([]);
});
it.each([
"getpgid(900) = 900",
"getsid(900) = 900",
"get_robust_list(900, [0x1], [24]) = 0",
"migrate_pages(900, 64, [1], [1]) = 0",
"move_pages(900, 0, NULL, NULL, NULL, 0) = 0",
"setpgid(0, 900) = 0",
"kcmp(901, 900, KCMP_FILE, 0, 0) = 0",
"wait4(900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)",
"wait4(-900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)",
"waitid(P_PID, 900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)",
"waitid(P_PGID, 900, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)",
"waitid(P_PIDFD, 7<pid:900>, NULL, WNOHANG, NULL) = -1 ECHILD (No child processes)",
"capget({version=_LINUX_CAPABILITY_VERSION_3, pid=900}, NULL) = 0",
"capset({version=_LINUX_CAPABILITY_VERSION_3, pid=900}, NULL) = 0",
"process_madvise(7<pid:900>, [], 0, MADV_NORMAL, 0) = -1 EPERM (Operation not permitted)",
"process_mrelease(7<pid:900>, 0) = -1 EPERM (Operation not permitted)",
])(
"rejects resolved PID resource, scheduler and metadata targets: %s",
(line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
},
);
it.each([
"prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=4096}) = 0",
"sched_getaffinity(0, 8, [0]) = 8",
"sched_setscheduler(901, 900, {sched_priority=0}) = -1 EINVAL (Invalid argument)",
"sched_get_priority_max(900) = -1 EINVAL (Invalid argument)",
"getpgid(901) = 900",
"wait4(-1, NULL, WNOHANG, NULL) = 0",
"waitid(P_ALL, 900, NULL, WNOHANG, NULL) = 0",
"capget({version=_LINUX_CAPABILITY_VERSION_3, pid=0}, NULL) = 0",
"kcmp(901, 902, KCMP_FILE, 900, 0) = 0",
])(
"keeps self, UID, policy and output values distinct from PID targets: %s",
(line) => {
expect(check(101, line)).toEqual([]);
},
);
it.each([
"sched_getaffinity(unknown, 8, [0]) = -1 EFAULT (Bad address)",
"capget(NULL, NULL) = -1 EFAULT (Bad address)",
"waitid(P_PIDFD, 7, NULL, WNOHANG, NULL) = -1 EBADF (Bad file descriptor)",
"ioprio_get(0x4 /* unknown selector */, 900) = -1 EINVAL (Invalid argument)",
])("retains unresolved target facts: %s", (line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("Unresolved process target");
});
it.each([
"prlimit64(900, RLIMIT_NOFILE, NULL, {rlim_cur=1024, rlim_max=1024}) = 0",
"prlimit64(900, RLIMIT_NOFILE, {rlim_cur=1024, rlim_max=1024}, NULL) = 0",
"sched_setaffinity(900, 128, [0 1]) = 0",
"sched_getaffinity(900, 128, [0 1]) = 8",
"sched_setscheduler(900, SCHED_OTHER, {sched_priority=0}) = 0",
"sched_getscheduler(900) = SCHED_OTHER",
"sched_setparam(900, {sched_priority=0}) = 0",
"sched_getparam(900, {sched_priority=0}) = 0",
"sched_setattr(900, {size=56, sched_policy=SCHED_OTHER}, 0) = 0",
"sched_getattr(900, {size=56, sched_policy=SCHED_OTHER}, 56, 0) = 0",
"sched_rr_get_interval(900, {tv_sec=0, tv_nsec=0}) = 0",
])("rejects historical PID resource and scheduler access: %s", (line) => {
for (const live of [true, false]) {
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
expect(() =>
check(101, line.replace(/ = .*/u, " = -1 ESRCH (No such process)"), live),
).toThrow("non-inventory lookup");
}
expect(check(101, line.replace("(900", "(9000"))).toEqual([]);
expect(check(101, line.replace("(900", "(0"))).toEqual([]);
});
it.each([
"ioprio_get(IOPRIO_WHO_PROCESS, 900) = IOPRIO_PRIO_VALUE(IOPRIO_CLASS_NONE, 0)",
"ioprio_set(IOPRIO_WHO_PROCESS, 900, IOPRIO_PRIO_VALUE(IOPRIO_CLASS_NONE, 0)) = 0",
"ioprio_get(IOPRIO_WHO_PGRP, 900) = 0",
"ioprio_set(IOPRIO_WHO_PGRP, 900, 0) = -1 ESRCH (No such process)",
"ioprio_get(PRIO_USER, 900) = -1 EINVAL (Invalid argument)",
"getpriority(IOPRIO_WHO_USER, 900) = -1 EINVAL (Invalid argument)",
])("rejects historical PID I/O priority access: %s", (line) => {
for (const live of [true, false])
expect(() => check(101, line, live)).toThrow("non-inventory lookup");
});
it("keeps I/O priority UID selectors and distinct PID/self selectors separate", () => {
expect(check(101, "ioprio_get(IOPRIO_WHO_USER, 900) = 0")).toEqual([]);
expect(check(101, "ioprio_get(IOPRIO_WHO_PROCESS, 9000) = 0")).toEqual([]);
expect(check(101, "ioprio_get(IOPRIO_WHO_PROCESS, 0) = 0")).toEqual([]);
});