Files
rea/src/process/ProcessOwnership.identity.test.ts
T
gensokyo-dev 1c2c2f1e78 fix(process): settle native exit and repair ownership verification (#5249)
* 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)

* fix(ci): distinguish process inventory from historical PID lookups

* test(ci): verify decoded historical PID controls before tracing

* fix(ci): trace historical PID handles and signal probes

* 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)

* 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)

* fix(ci): reject priority access to historical PIDs

* 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)

* fix(ci): reject historical PID resource and scheduler calls

* fix(schema): preserve portable large base64 validation

* 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)

* fix(ci): classify I/O priority and qualify PID trace coverage

* fix(process): keep ownership retries on the native clock

* 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)

* fix(process): type native exit predicates and ownership fixtures precisely

* 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-20 02:01:00 +08:00

1283 lines
36 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
cleanupOwnedProcessGroup,
verifyNoTokenOwnedProcesses,
type ProcessOwnershipHost,
type ProcessTableEntry,
} from "./ProcessOwnership.js";
import { createSystemProcessOwnershipHost } from "./ProcessOwnershipObservation.js";
import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
const observedIdentity = (entry: ProcessTableEntry, identity: string) => ({
state: "readable" as const,
identity,
relation: {
state: "observed" as const,
parentPid: entry.parentPid,
processGroupId: entry.processGroupId,
},
});
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", {});
expect(host.processIdentities).toBeDefined();
expect(host.captureBaseline).toBeDefined();
const observedProcess = {
pid: process.pid,
parentPid: process.ppid,
processGroupId: process.pid,
state: "S",
command: process.execPath,
} satisfies ProcessTableEntry;
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),
lifecycle: { state: "observed", lifecycle: "non-zombie" },
relation: {
state: "observed",
parentPid: process.ppid,
processGroupId: expect.any(Number),
},
});
expect(identities?.get(observedProcess.pid)).toHaveProperty(
"effectiveUid",
process.geteuid!(),
);
const baseline = await host.captureBaseline?.();
expect(baseline).toContainEqual({
pid: observedProcess.pid,
identity: expect.stringMatching(/^linux-starttime:\d+$/u),
});
});
});
it("validates and cleans an exact-token owner without a capture baseline", async () => {
const process: ProcessTableEntry = {
pid: 904,
parentPid: 1,
processGroupId: 904,
state: "S",
command: "generic-darwin-owner",
};
let live = true;
const signalGroup = vi.fn(() => {
live = false;
});
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(live ? [process] : []),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
live
? observedIdentity(entry, "owner-start")
: {
state: "absent" as const,
reason: "not-in-snapshot" as const,
},
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: process.pid,
processGroupId: process.processGroupId,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
},
host,
),
).resolves.toEqual({ cleaned: true, signaled: true });
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
});
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) => {
const row: ProcessTableEntry = {
pid: 906,
parentPid: 1,
processGroupId: 906,
uid: rowUid,
state: "S",
command: "unrelated-process",
};
const host: ProcessOwnershipHost = {
platform: "darwin",
signalGroup: vi.fn(),
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({}),
runTokens: () =>
Promise.resolve(
new Map([
[906, { state: "unavailable", reason: diagnostic } as const],
]),
),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
{
...observedIdentity(entry, "reused-current-generation"),
effectiveUid,
},
]),
),
),
};
return verifyNoTokenOwnedProcesses("run-token", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
});
};
await expect(verify(foreignUid, callerUid)).resolves.toMatchObject({
cleaned: false,
failures: [{ pid: 906, reason: "environment-unreadable" }],
});
await expect(verify(callerUid, foreignUid)).resolves.toMatchObject({
cleaned: true,
unverified: [{ pid: 906, diagnostic }],
});
},
);
it.each(["omitted", "throws"] as const)(
"keeps a matching token unresolved when the first native identity is %s",
async (firstRead) => {
const row: ProcessTableEntry = {
pid: 907,
parentPid: 1,
processGroupId: 907,
state: "S",
command: "identity-read-failure-candidate",
};
let identityReads = 0;
const host: ProcessOwnershipHost = {
platform: "linux",
signalGroup: vi.fn(),
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({}),
runTokens: () =>
Promise.resolve(
new Map([[row.pid, { state: "readable", runId: "run-token" }]]),
),
processIdentities: (entries) => {
identityReads += 1;
if (identityReads === 1) {
if (firstRead === "throws")
return Promise.reject(new Error("native identity read denied"));
return Promise.resolve(new Map());
}
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, "current-generation"),
]),
),
);
},
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
}),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [{ pid: row.pid, reason: "process-identity-unavailable" }],
});
expect(identityReads).toBe(2);
},
);
it("retains a scoped unknown generation when a later table omits its PID", async () => {
const row: ProcessTableEntry = {
pid: 101,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "identity-unavailable-related-process",
};
const tables = [[row], []] as const;
let tableRead = 0;
let identityReads = 0;
let nativeAbsence = false;
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const host: ProcessOwnershipHost = {
platform: "linux",
signalGroup: vi.fn(),
listProcesses: () => Promise.resolve(tables[tableRead++] ?? []),
environment: () => Promise.resolve({}),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "unavailable" as const, reason: "EPERM: denied" },
]),
),
),
processIdentities: (entries) => {
identityReads += 1;
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
nativeAbsence
? {
state: "absent" as const,
reason: "not-found-in-caller-pid-namespace" as const,
}
: {
state: "unavailable" as const,
reason: "native identity read denied",
},
]),
),
);
},
};
const relation = {
leaderPid: 100,
processGroupId: 100,
evidenceOwner,
};
const first = await verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
relation,
);
expect(first).toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "process-identity-unavailable" }],
});
expect(evidenceOwner.candidates).toMatchObject([
{
pid: row.pid,
identity: undefined,
applicability: "identity-unresolved",
scopeEvidence: "native-identity-unresolved",
},
]);
const readsAfterFirstSnapshot = identityReads;
const second = await verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
relation,
);
expect(second).toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "process-identity-unavailable" }],
});
expect(identityReads).toBeGreaterThan(readsAfterFirstSnapshot);
expect(evidenceOwner.candidates).toMatchObject([
{
pid: row.pid,
identity: undefined,
applicability: "identity-unresolved",
},
]);
expect(evidenceOwner.coverage).toMatchObject({
listedRows: 0,
retainedOnlyPids: 1,
retainedUnresolved: 1,
});
nativeAbsence = true;
await expect(
verifyNoTokenOwnedProcesses("run-token", host, undefined, relation),
).resolves.toMatchObject({ cleaned: true });
expect(evidenceOwner.candidates).toEqual([]);
});
it("requires a scoped current relation before exempting a withheld token", async () => {
const row: ProcessTableEntry = {
pid: 908,
parentPid: 1,
processGroupId: 908,
state: "S",
command: "opaque-unrelated-process",
};
const verify = async (scoped: boolean, nativeRelation: boolean) => {
const host: ProcessOwnershipHost = {
platform: "darwin",
signalGroup: vi.fn(),
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({}),
runTokens: () =>
Promise.resolve(
new Map([
[
row.pid,
{
state: "unavailable",
reason: "platform_binary_environment_withheld",
} as const,
],
]),
),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
nativeRelation
? observedIdentity(entry, "opaque-generation")
: {
state: "readable" as const,
identity: "opaque-generation",
},
]),
),
),
};
return verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
scoped
? {
leaderPid: 100,
processGroupId: 100,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
}
: undefined,
);
};
await expect(verify(true, false)).resolves.toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "environment-unreadable" }],
});
await expect(verify(false, false)).resolves.toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "environment-unreadable" }],
});
await expect(verify(false, true)).resolves.toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "environment-unreadable" }],
});
await expect(verify(true, true)).resolves.toMatchObject({
cleaned: true,
unverified: [
{ pid: row.pid, diagnostic: "platform_binary_environment_withheld" },
],
});
});
it("retains related scope when a same-generation process reparents during its first token read", async () => {
const row: ProcessTableEntry = {
pid: 909,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "reparented-withheld-process",
};
let identityReads = 0;
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const host: ProcessOwnershipHost = {
platform: "darwin",
signalGroup: vi.fn(),
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({}),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "unavailable" as const,
reason: "platform_binary_environment_withheld",
},
]),
),
),
processIdentities: (entries) => {
identityReads += 1;
const reparented = identityReads > 1;
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: "generation-909",
relation: {
state: "observed" as const,
parentPid: reparented ? 1 : 100,
processGroupId: reparented ? 777 : 100,
},
},
]),
),
);
},
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner,
}),
).resolves.toMatchObject({
cleaned: false,
failures: [
{
pid: row.pid,
reason: "environment-unreadable",
diagnostic: "platform_binary_environment_withheld",
},
],
});
expect(evidenceOwner.candidates).toMatchObject([
{
pid: row.pid,
identity: "generation-909",
applicability: "related-ownership-unresolved",
scopeEvidence: "previously-related-generation",
relation: { parentPid: 1, processGroupId: 777 },
},
]);
});
it.each([undefined, "foreign-token"] as const)(
"does not treat a readable %s token as exonerating a related generation that moved during its first read",
async (observedRunId) => {
const row: ProcessTableEntry = {
pid: 909,
parentPid: 100,
processGroupId: 100,
state: "S",
command: "reparented-cleared-token-process",
};
let identityReads = 0;
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
platform: "darwin",
signalGroup,
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({}),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: observedRunId },
]),
),
),
processIdentities: (entries) => {
identityReads += 1;
const reparented = identityReads > 1;
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable" as const,
identity: "generation-909",
relation: {
state: "observed" as const,
parentPid: reparented ? 1 : 100,
processGroupId: reparented ? 777 : 100,
},
},
]),
),
);
},
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, undefined, {
leaderPid: 100,
processGroupId: 100,
evidenceOwner,
}),
).resolves.toMatchObject({
cleaned: false,
failures: [{ pid: row.pid, reason: "run-token-mismatch" }],
});
expect(identityReads).toBe(2);
expect(signalGroup).not.toHaveBeenCalled();
expect(evidenceOwner.candidates).toMatchObject([
{
pid: row.pid,
identity: "generation-909",
applicability: "related-ownership-unresolved",
scopeEvidence: "captured-process-group-id",
relation: { parentPid: 1, processGroupId: 777 },
},
]);
},
);
it.skipIf(typeof process.getuid !== "function")(
"cleans verified owners and records unrelated opaque candidates as unverified",
async () => {
const owned: ProcessTableEntry = {
pid: 905,
parentPid: 1,
processGroupId: 905,
state: "S",
command: "owned-detached-child",
};
const opaque: ProcessTableEntry = {
pid: 906,
parentPid: 1,
processGroupId: 906,
state: "S",
command: "/usr/bin/unrelated-process",
};
const malformed: ProcessTableEntry = {
...opaque,
pid: 907,
processGroupId: 907,
uid: process.getuid?.() === 0 ? 1 : 0,
command: "/usr/bin/malformed-procargs-process",
};
const sysctlDenied: ProcessTableEntry = {
...opaque,
pid: 908,
processGroupId: 908,
uid: process.getuid?.() === 0 ? 1 : 0,
command: "/usr/bin/sysctl-denied-process",
};
const processes = [owned, opaque, malformed, sysctlDenied];
const signalGroup = vi.fn((processGroupId: number) => {
for (let index = processes.length - 1; index >= 0; index -= 1)
if (processes[index]?.processGroupId === processGroupId)
processes.splice(index, 1);
});
const host: ProcessOwnershipHost = {
platform: "darwin",
listProcesses: () => Promise.resolve(processes),
environment: (pid) =>
pid === owned.pid
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
: Promise.reject(new Error("permission denied")),
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
pid === owned.pid
? { state: "readable", runId: "run-token" as const }
: {
state: "unavailable",
reason:
pid === malformed.pid
? ("malformed_procargs" as const)
: pid === sysctlDenied.pid
? ("sysctl_failed_1" as const)
: ("platform_binary_environment_withheld" as const),
},
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
},
host,
),
).resolves.toMatchObject({
cleaned: true,
signaled: true,
unverified: [
{ pid: 906, diagnostic: "platform_binary_environment_withheld" },
{ pid: 907, diagnostic: "malformed_procargs" },
{ pid: 908, diagnostic: "sysctl_failed_1" },
],
});
expect(signalGroup).toHaveBeenCalledWith(owned.processGroupId, "SIGKILL");
expect(signalGroup).not.toHaveBeenCalledWith(
opaque.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
malformed.processGroupId,
"SIGKILL",
);
expect(signalGroup).not.toHaveBeenCalledWith(
sysctlDenied.processGroupId,
"SIGKILL",
);
},
);
describe("capture-local process identity baselines", () => {
it("checks reused and unknown PIDs while skipping unchanged identities", async () => {
const tokenReads: number[][] = [];
const processes: readonly ProcessTableEntry[] = [
{
pid: 899,
parentPid: 1,
processGroupId: 899,
state: "S",
command: "stable",
},
{
pid: 900,
parentPid: 1,
processGroupId: 900,
state: "S",
command: "reused",
},
{
pid: 902,
parentPid: 1,
processGroupId: 902,
state: "S",
command: "unknown",
},
];
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(processes),
environment: () => Promise.resolve({}),
processIdentities: (entries) =>
Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
entry.pid === 902
? { state: "unavailable", reason: "fixture" }
: observedIdentity(
entry,
entry.pid === 899 ? "stable-start" : "reused-start",
),
]),
),
),
runTokens: (entries) => {
tokenReads.push(entries.map(({ pid }) => pid));
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "readable",
runId: pid === 900 ? "run-token" : undefined,
},
]),
),
);
},
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses("run-token", host, [
{ pid: 899, identity: "stable-start" },
{ pid: 900, identity: "old-start" },
{ pid: 902, identity: null },
]),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: [
{
pid: 902,
reason: "process-identity-unavailable",
diagnostic:
"process identity was unavailable during token validation",
},
],
});
expect(tokenReads).toEqual([[900, 902]]);
});
it("revalidates fallback environment owners before signaling after PID reuse", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 910,
parentPid: 1,
processGroupId: 910,
state: "S",
command: "fallback-environment-owner",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, identity),
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});
});
it("retains initially related scope when bounded token retries later move out of scope", async () => {
const process: ProcessTableEntry = {
pid: 921,
parentPid: 919,
processGroupId: 919,
state: "S",
command: "retry-reparented-candidate",
};
const tokenReads: number[][] = [];
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
let tokenAttempt = 0;
let identityRead = 0;
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityRead += 1;
const moved = identityRead > 2;
return Promise.resolve(
new Map(
entries.map((entry) => {
return [
entry.pid,
{
state: "readable" as const,
identity: "generation-921",
relation: {
state: "observed" as const,
parentPid: moved ? 1 : 919,
processGroupId: moved ? 930 : 919,
},
},
];
}),
),
);
},
runTokens: (entries) => {
tokenAttempt += 1;
tokenReads.push(entries.map(({ pid }) => pid));
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{
state: "unavailable" as const,
reason:
tokenAttempt === 1
? "permission denied"
: "platform_binary_environment_withheld",
},
]),
),
);
},
signalGroup: vi.fn(),
};
const result = await verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
{
leaderPid: 919,
processGroupId: 919,
sampledProcessGroupIds: [919],
evidenceOwner,
},
);
expect(result).toMatchObject({
cleaned: false,
failures: [{ pid: 921, reason: "environment-unreadable" }],
});
expect(tokenReads).toEqual([[921], [921], [921], [921]]);
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 921,
applicability: "related-ownership-unresolved",
scopeEvidence: "previously-related-generation",
relation: { state: "observed", parentPid: 1, processGroupId: 930 },
},
]);
});
it("rechecks retained related generations when a later table omits them", async () => {
const process: ProcessTableEntry = {
pid: 920,
parentPid: 919,
processGroupId: 919,
state: "S",
command: "retained-related-candidate",
};
const tables = [[process], []] as const;
let tableRead = 0;
const tokenReads: number[][] = [];
const evidenceOwner = new ProcessOwnershipEvidenceOwner();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve(tables[tableRead++] ?? []),
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
const processGroupId = tableRead < 2 ? 919 : 930;
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
{
state: "readable" as const,
identity: "generation-920",
relation: {
state: "observed" as const,
parentPid: tableRead < 2 ? 919 : 1,
processGroupId,
},
},
]),
),
);
},
runTokens: (entries) => {
tokenReads.push(entries.map(({ pid }) => pid));
return Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "unavailable" as const, reason: "permission denied" },
]),
),
);
},
signalGroup: vi.fn(),
};
const relation = {
leaderPid: 919,
processGroupId: 919,
sampledProcessGroupIds: [919],
evidenceOwner,
};
const first = await verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
relation,
);
expect(tokenReads).toEqual([[920], [920], [920], [920]]);
expect(evidenceOwner.candidates).toMatchObject([
{ pid: 920, applicability: "related-ownership-unresolved" },
]);
const second = await verifyNoTokenOwnedProcesses(
"run-token",
host,
undefined,
relation,
);
expect(first).toMatchObject({ cleaned: false, failures: [{ pid: 920 }] });
expect(second).toMatchObject({ cleaned: false, failures: [{ pid: 920 }] });
expect(tokenReads).toEqual([
[920],
[920],
[920],
[920],
[920],
[920],
[920],
[920],
]);
expect(evidenceOwner.candidates).toMatchObject([
{
pid: 920,
identity: "generation-920",
applicability: "related-ownership-unresolved",
relation: { parentPid: 1, processGroupId: 930 },
},
]);
expect(evidenceOwner.coverage).toMatchObject({
snapshotCount: 2,
listedRows: 0,
retainedOnlyPids: 1,
processTableCoverage: "unreported-by-host",
});
});
it("revalidates identity after the final environment token read", async () => {
let environmentReads = 0;
let reusedDuringFinalRead = false;
const process: ProcessTableEntry = {
pid: 912,
parentPid: 1,
processGroupId: 912,
state: "S",
command: "reused-during-final-token-read",
};
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => {
environmentReads += 1;
if (environmentReads === 3) reusedDuringFinalRead = true;
return Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" });
},
processIdentities: (entries) => {
const identity = reusedDuringFinalRead ? "reused-start" : "owned-start";
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, identity),
]),
),
);
},
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(reusedDuringFinalRead).toBe(true);
});
it("fails closed when candidate PIDs change identity during token reading", async () => {
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 911,
parentPid: 1,
processGroupId: 911,
state: "S",
command: "reused-during-token-read",
};
const processes = [process, { ...process, pid: 912, processGroupId: 912 }];
const listProcesses = vi.fn(async () => processes);
const host: ProcessOwnershipHost = {
listProcesses,
environment: () => Promise.resolve({}),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads === 1 ? "new-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, identity),
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: undefined } as const,
]),
),
),
signalGroup: vi.fn(),
};
await expect(
verifyNoTokenOwnedProcesses(
"run-token",
host,
processes.map(({ pid }) => ({ pid, identity: "old-start" })),
),
).resolves.toEqual({
cleaned: false,
reason: "process identity could not be revalidated",
failures: processes.map(({ pid }) => ({
pid,
reason: "process-identity-unavailable",
diagnostic: "process identity changed during token validation",
})),
});
// Retry the exact candidate generations with native identity reads; do not
// rescan the full process table, which would add unrelated transient rows.
expect(listProcesses).toHaveBeenCalledTimes(1);
});
it("uses the launcher's native relation instead of stale process-table fields", async () => {
const row = {
pid: 100,
parentPid: 999,
processGroupId: 999,
state: "S",
command: "/owned/hopper",
} satisfies ProcessTableEntry;
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
runTokens: (members) =>
Promise.resolve(
new Map(
members.map(({ pid }) => [
pid,
{ state: "readable" as const, runId: "run-token" },
]),
),
),
processIdentities: (members) =>
Promise.resolve(
new Map(
members.map((member) => [
member.pid,
observedIdentity(
{ ...member, parentPid: 1, processGroupId: 100 },
"start-100",
),
]),
),
),
signalGroup,
};
const result = await cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
expectedCommand: "/owned/hopper",
},
host,
);
expect(result).toMatchObject({ cleaned: true, signaled: true });
expect(signalGroup).toHaveBeenCalledWith(100, "SIGKILL");
});
it("does not settle a live launcher from stale table ancestry when native relation mismatches", async () => {
const row = {
pid: 100,
parentPid: 1,
processGroupId: 100,
state: "S",
command: "/owned/hopper",
} satisfies ProcessTableEntry;
const signalGroup = vi.fn();
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([row]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: () =>
Promise.resolve(
new Map([
[
100,
{
...observedIdentity(row, "start-100"),
relation: {
state: "observed" as const,
parentPid: 2,
processGroupId: 200,
},
},
],
]),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
expectedParentPid: 1,
expectedCommand: "/owned/hopper",
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "owned launcher process-group identity did not match",
});
expect(signalGroup).not.toHaveBeenCalled();
});
it("refuses to signal when PID identity changes after token validation", async () => {
const signalGroup = vi.fn();
let identityReads = 0;
const process: ProcessTableEntry = {
pid: 903,
parentPid: 1,
processGroupId: 903,
state: "S",
command: "reused-before-signal",
};
const host: ProcessOwnershipHost = {
listProcesses: () => Promise.resolve([process]),
environment: () => Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" }),
processIdentities: (entries) => {
identityReads += 1;
const identity = identityReads < 3 ? "owned-start" : "reused-start";
return Promise.resolve(
new Map(
entries.map((entry) => [
entry.pid,
observedIdentity(entry, identity),
]),
),
);
},
runTokens: (entries) =>
Promise.resolve(
new Map(
entries.map(({ pid }) => [
pid,
{ state: "readable", runId: "run-token" } as const,
]),
),
),
signalGroup,
};
await expect(
cleanupOwnedProcessGroup(
{
runId: "run-token",
leaderPid: 100,
processGroupId: 100,
sweepTokenOwnedProcesses: true,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
captureBaseline: [{ pid: process.pid, identity: "old-start" }],
},
host,
),
).resolves.toMatchObject({
cleaned: false,
reason: "process identity could not be revalidated",
});
expect(signalGroup).not.toHaveBeenCalled();
expect(identityReads).toBeGreaterThanOrEqual(3);
});