fix(process): stop failing captures on tokens macOS cannot expose (#1057)
* fix(process): stop failing captures on tokens macOS cannot expose On a busy macOS 26 host the residual process sweep fails every capture: KERN_PROCARGS2 omits the environment of Apple platform binaries for any caller and fails with EINVAL for processes owned by another account, so an unrelated process created after the capture baseline escalates to a hard cleanup failure with cleanup_incomplete. Report environment withholding distinctly from the Darwin token reader by reading the platform-binary flag through csops, carry the effective uid in the process table, and re-read unresolved tokens with a bounded retry plus a final liveness confirmation so transient exec and exit states settle. Candidates whose token the OS provably withholds, foreign-uid candidates, and candidates that exited during validation are now recorded as unverified processes instead of failing an otherwise clean capture or being signaled. Related candidates and every other unreadable candidate stay fail-closed. * test(process): align ownership lanes with the uid-aware process table The Darwin sweep reads the effective uid through ps, so the offline verification lanes' syscall allowlists must accept the new argv in verify-binary-layout and verify-evm-interface. The foreign-uid ownership test requires POSIX process uids; skip it where the platform has none (the curated Windows lane). * fix(verify): recognize UID-bearing ownership process tables --------- Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
parent
40e18b2e37
commit
5547c1e697
|
|
@ -327,6 +327,8 @@ jobs:
|
|||
run: npm run test:focused -- tests/boundary/cli/nativeDemangleCli.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts
|
||||
- name: Process filesystem snapshot descriptor regressions
|
||||
run: npm run test:focused -- src/process/capture/FilesystemSnapshot.test.ts
|
||||
- name: Native Darwin ownership and process cleanup regressions
|
||||
run: npm run test:focused -- tests/boundary/process/darwinProcessRunTokenReader.test.ts tests/boundary/process/processCaptureLifecycle.test.ts tests/boundary/cli/processCli.test.ts tests/boundary/mcp/sessionMcp.test.ts
|
||||
|
||||
reconstruction-readiness:
|
||||
name: Reconstruction readiness
|
||||
|
|
|
|||
|
|
@ -112,6 +112,31 @@ enum RunTokenReadError: Error {
|
|||
case ambiguousEnvironmentBoundary
|
||||
}
|
||||
|
||||
@_silgen_name("csops")
|
||||
private func csops(
|
||||
_ pid: Int32,
|
||||
_ ops: UInt32,
|
||||
_ addr: UnsafeMutableRawPointer?,
|
||||
_ size: Int
|
||||
) -> Int32
|
||||
|
||||
private let csOpsStatus: UInt32 = 0
|
||||
private let csPlatformBinary: UInt32 = 0x0400_0000
|
||||
|
||||
/**
|
||||
* Whether the target image is an Apple platform binary. Current macOS omits
|
||||
* the environment of platform binaries from KERN_PROCARGS2 for every caller,
|
||||
* so this distinguishes a policy-withheld environment from a process whose
|
||||
* environment is genuinely absent or transiently unreadable.
|
||||
*/
|
||||
func isPlatformBinary(pid: Int32) -> Bool {
|
||||
var flags: UInt32 = 0
|
||||
let result = withUnsafeMutablePointer(to: &flags) { pointer in
|
||||
csops(pid, csOpsStatus, UnsafeMutableRawPointer(pointer), MemoryLayout<UInt32>.size)
|
||||
}
|
||||
return result == 0 && (flags & csPlatformBinary) != 0
|
||||
}
|
||||
|
||||
private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation {
|
||||
var mib = [CTL_KERN, KERN_PROCARGS2, Int32(pid)]
|
||||
var size = 0
|
||||
|
|
@ -133,7 +158,12 @@ private func readRunToken(pid: Int32, pointerSize: Int) -> RunTokenObservation {
|
|||
} catch RunTokenReadError.invalidTokenEncoding {
|
||||
return unavailable(pid: pid, reason: "invalid_run_token_encoding")
|
||||
} catch RunTokenReadError.environmentUnavailable {
|
||||
return unavailable(pid: pid, reason: "environment_unavailable")
|
||||
return unavailable(
|
||||
pid: pid,
|
||||
reason: isPlatformBinary(pid: pid)
|
||||
? "platform_binary_environment_withheld"
|
||||
: "environment_unavailable"
|
||||
)
|
||||
} catch RunTokenReadError.appleVectorUnavailable {
|
||||
return unavailable(pid: pid, reason: "apple_vector_unavailable")
|
||||
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ run the same owned-process cleanup path. Settlement reports whether the
|
|||
sampled process group quiesced or whether cleanup was needed or unverifiable;
|
||||
sampling cannot prove that every short-lived or detached descendant was seen.
|
||||
|
||||
When the host withholds an unrelated process’s ownership token, REA leaves that
|
||||
process untouched and records its PID and reason in `cleanup.unverified_processes`
|
||||
and process residual unknowns. Successful cleanup verifies the owned group;
|
||||
it does not attribute those unrelated processes. Related or otherwise unexplained
|
||||
unreadable processes still prevent successful cleanup.
|
||||
|
||||
## Compare two captures
|
||||
|
||||
Compare saved capture Evidence with:
|
||||
|
|
|
|||
|
|
@ -713,9 +713,9 @@
|
|||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "18a0131bdea6a7af17c052bc97d343e1f833e1a35218e0098eba9695e9f821a2",
|
||||
"tools_sha256": "0ac2316e6bd007d273c60f69e132341fc06a3dcb6879408ecfdd3271f30a8230",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7",
|
||||
"combined_sha256": "20cc1432b29f11c20206cbaeacafab927dcfd68c81a82cf198ad058f54f06eac",
|
||||
"providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_c39e03fbab9c465463c5c8f729512a712af644971fa1d9cdfd245d583a2aa2e6",
|
||||
"manifest_id": "ecm_bee7251323bddbe561003a45ddee6c43eb9e37839983dc89e438dca6d9e5fdec",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "7542b5f56f36a31aa3a7e75043933747ecf63b6b51ad10ca375fb03b77aca795"
|
||||
"sha256": "62f197bbd05ca97ff21cc69f45ccf776c152004f3c7f1bd589dfea0b8202db57"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -1169,7 +1169,7 @@ try {
|
|||
);
|
||||
const ownershipInspection =
|
||||
["/usr/bin/ps", "/bin/ps"].includes(match[1]) &&
|
||||
line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
|
||||
line.includes('["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]');
|
||||
assert.ok(
|
||||
[process.execPath, python].includes(match[1]) || ownershipInspection,
|
||||
`Unexpected attempted host execution: ${line}`,
|
||||
|
|
|
|||
|
|
@ -474,7 +474,9 @@ try {
|
|||
);
|
||||
const ownershipInspection =
|
||||
["/usr/bin/ps", "/bin/ps"].includes(match[1]) &&
|
||||
line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
);
|
||||
assert.ok(
|
||||
[
|
||||
process.execPath,
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export async function verifyCoreInspectionTrace({
|
|||
(line) =>
|
||||
/^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) &&
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]',
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
) &&
|
||||
line.endsWith(" = 0"),
|
||||
);
|
||||
|
|
@ -128,7 +128,9 @@ export async function verifyCoreInspectionTrace({
|
|||
const executable = await realpath(match[1]).catch(() => match[1]);
|
||||
const ownership =
|
||||
psPaths.has(match[1]) &&
|
||||
line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
|
||||
line.includes(
|
||||
'["ps", "-axo", "pid=,ppid=,pgid=,uid=,stat=,command="]',
|
||||
);
|
||||
const iconv =
|
||||
iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]');
|
||||
const version =
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
|||
metadata:
|
||||
version: "33"
|
||||
tool_count: 139
|
||||
catalog_digest: "b40971d45fb56386599e5f8e904c21ee491b20ef23b089ceb28750641eb58ae7"
|
||||
catalog_digest: "20cc1432b29f11c20206cbaeacafab927dcfd68c81a82cf198ad058f54f06eac"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
type OwnedProcessGroup,
|
||||
type ProcessCleanupResult,
|
||||
type ProcessLineageObservation,
|
||||
type OwnershipSweepUnverifiedProcess,
|
||||
} from "../process/ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessLineage,
|
||||
|
|
@ -238,6 +239,17 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
),
|
||||
);
|
||||
if (closeError !== undefined) return err(providerError(closeError));
|
||||
if (outcome.ok && cleanup.unverified !== undefined)
|
||||
outcome = ok({
|
||||
...outcome.value,
|
||||
limitations: [
|
||||
...outcome.value.limitations,
|
||||
...cleanup.unverified.map(
|
||||
({ pid, diagnostic }) =>
|
||||
`Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${diagnostic}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
|
@ -280,6 +292,7 @@ const cleanupElectronProcesses = async (
|
|||
})),
|
||||
);
|
||||
let signaled = false;
|
||||
const unverified: OwnershipSweepUnverifiedProcess[] = [];
|
||||
for (const processGroupId of groupIds) {
|
||||
const cleanupOwnership: OwnedProcessGroup =
|
||||
processGroupId === ownership.processGroupId
|
||||
|
|
@ -292,14 +305,38 @@ const cleanupElectronProcesses = async (
|
|||
const result = await cleanupOwnedProcessGroup(cleanupOwnership);
|
||||
if (!result.cleaned) return result;
|
||||
signaled ||= result.signaled;
|
||||
unverified.push(...(result.unverified ?? []));
|
||||
}
|
||||
const remaining = await verifyNoTokenOwnedProcesses(
|
||||
ownership.runId,
|
||||
undefined,
|
||||
ownership.captureBaseline,
|
||||
{
|
||||
leaderPid: ownership.leaderPid,
|
||||
processGroupId: ownership.processGroupId,
|
||||
...(ownership.sampledProcessGroupIds === undefined
|
||||
? {}
|
||||
: { sampledProcessGroupIds: ownership.sampledProcessGroupIds }),
|
||||
},
|
||||
);
|
||||
if (!remaining.cleaned) return remaining;
|
||||
return { cleaned: true, signaled };
|
||||
unverified.push(...(remaining.unverified ?? []));
|
||||
return {
|
||||
cleaned: true,
|
||||
signaled,
|
||||
...(unverified.length === 0
|
||||
? {}
|
||||
: {
|
||||
unverified: [
|
||||
...new Map(
|
||||
unverified.map((item) => [
|
||||
`${String(item.pid)}:${item.diagnostic}`,
|
||||
item,
|
||||
]),
|
||||
).values(),
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const createResult = (
|
||||
|
|
|
|||
|
|
@ -223,6 +223,10 @@ export interface UnverifiedProcessCapture {
|
|||
readonly cleanup: {
|
||||
readonly owned_process_group: "verified";
|
||||
readonly temporary_root: "removed";
|
||||
/** Unrelated processes whose ownership token the host could not expose. */
|
||||
readonly unverified_processes?:
|
||||
| readonly UnverifiedCleanupProcess[]
|
||||
| undefined;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -232,10 +236,19 @@ const fileStateShape = {
|
|||
size: z.number().int().nonnegative(),
|
||||
};
|
||||
|
||||
/** An unrelated process left untouched because its ownership is unknown. */
|
||||
export interface UnverifiedCleanupProcess {
|
||||
readonly pid: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** A resource cleanup result retained when observations cannot be verified. */
|
||||
export interface ProcessCaptureResourceCleanup {
|
||||
readonly state: "cleaned" | "failed" | "unverified" | "not_required";
|
||||
readonly reason: string | null;
|
||||
readonly unverified_processes?:
|
||||
| readonly UnverifiedCleanupProcess[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface ProcessCaptureCleanupReport {
|
||||
|
|
@ -348,6 +361,10 @@ const processSettlementSchema = z.discriminatedUnion("state", [
|
|||
}),
|
||||
]);
|
||||
|
||||
const unverifiedCleanupProcessesSchema = z.array(
|
||||
z.strictObject({ pid: z.number().int().positive(), reason: z.string() }),
|
||||
);
|
||||
|
||||
const processCaptureShapeSchema = z.strictObject({
|
||||
manifest: z.strictObject({
|
||||
rea_version: z.string().min(1),
|
||||
|
|
@ -478,12 +495,14 @@ const processCaptureShapeSchema = z.strictObject({
|
|||
cleanup: z.object({
|
||||
owned_process_group: z.literal("verified"),
|
||||
temporary_root: z.literal("removed"),
|
||||
unverified_processes: unverifiedCleanupProcessesSchema.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const processCleanupResourceSchema = z.strictObject({
|
||||
state: z.enum(["cleaned", "failed", "unverified", "not_required"]),
|
||||
reason: z.string().nullable(),
|
||||
unverified_processes: unverifiedCleanupProcessesSchema.optional(),
|
||||
});
|
||||
|
||||
const partialObservationFieldSchema = <Schema extends z.ZodType>(
|
||||
|
|
|
|||
|
|
@ -99,92 +99,102 @@ describe("capture-local process ownership", () => {
|
|||
expect(signalGroup).toHaveBeenCalledWith(process.processGroupId, "SIGKILL");
|
||||
});
|
||||
|
||||
it("cleans verified owners before reporting an unrelated opaque process", 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: "unrelated-process",
|
||||
};
|
||||
const malformed: ProcessTableEntry = {
|
||||
...opaque,
|
||||
pid: 907,
|
||||
processGroupId: 907,
|
||||
command: "malformed-procargs-process",
|
||||
};
|
||||
const sysctlDenied: ProcessTableEntry = {
|
||||
...opaque,
|
||||
pid: 908,
|
||||
processGroupId: 908,
|
||||
command: "sysctl-denied-process",
|
||||
};
|
||||
const signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
listProcesses: () =>
|
||||
Promise.resolve([owned, opaque, malformed, sysctlDenied]),
|
||||
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)
|
||||
: ("environment_unavailable" as const),
|
||||
},
|
||||
]),
|
||||
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 signalGroup = vi.fn();
|
||||
const host: ProcessOwnershipHost = {
|
||||
platform: "darwin",
|
||||
listProcesses: () =>
|
||||
Promise.resolve([owned, opaque, malformed, sysctlDenied]),
|
||||
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,
|
||||
};
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
host,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason:
|
||||
"process ownership token could not be read for 3 live process(es): environment_unavailable=1, malformed_procargs=1, sysctl_errno_1=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",
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
processGroupId: 100,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -214,4 +214,38 @@ describe("process environment permission diagnostics", () => {
|
|||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(typeof process.getuid !== "function")(
|
||||
"records a foreign-uid unreadable candidate as unverified without signaling",
|
||||
async () => {
|
||||
const foreignUid = process.getuid?.() === 0 ? 1 : 0;
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
processGroupId: 900,
|
||||
uid: foreignUid,
|
||||
state: "S",
|
||||
command: "unverifiable-process",
|
||||
},
|
||||
]),
|
||||
environment: () => Promise.reject(new Error("EINVAL: sysctl failed")),
|
||||
signalGroup,
|
||||
};
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(
|
||||
{ ...ownership, sweepTokenOwnedProcesses: true },
|
||||
adapter,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: true,
|
||||
signaled: false,
|
||||
unverified: [{ pid: 900, diagnostic: "EINVAL: sysctl failed" }],
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ describe("owned process-group cleanup discovery", () => {
|
|||
});
|
||||
|
||||
describe("opaque process neighbors during cleanup", () => {
|
||||
it("cleans verified run-owned groups while reporting an opaque neighbor", async () => {
|
||||
it("cleans verified run-owned groups and records a withheld unrelated neighbor", async () => {
|
||||
const processes = [
|
||||
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
|
||||
{
|
||||
|
|
@ -76,6 +76,71 @@ describe("opaque process neighbors during cleanup", () => {
|
|||
processGroupId: 200,
|
||||
command: "detached-child",
|
||||
},
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
processGroupId: 900,
|
||||
command: "/usr/bin/opaque-neighbor",
|
||||
},
|
||||
].map((process) => ({ ...process, state: "S" }));
|
||||
const signalGroup = vi.fn();
|
||||
const identities = new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
||||
]),
|
||||
);
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
platform: "darwin",
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
Promise.resolve({
|
||||
REA_PROCESS_RUN_ID: pid === 900 ? "unowned" : "run-token",
|
||||
}),
|
||||
processIdentities: () => Promise.resolve(identities),
|
||||
runTokens: (members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
pid === 900
|
||||
? {
|
||||
state: "unavailable" as const,
|
||||
reason: "platform_binary_environment_withheld",
|
||||
}
|
||||
: { state: "readable" as const, runId: "run-token" },
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
...ownership,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [],
|
||||
},
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
cleaned: true,
|
||||
signaled: true,
|
||||
unverified: [
|
||||
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
|
||||
],
|
||||
});
|
||||
expect(signalGroup.mock.calls).toEqual([
|
||||
[100, "SIGKILL"],
|
||||
[200, "SIGKILL"],
|
||||
]);
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
|
||||
});
|
||||
|
||||
it("fails closed for an unrelated unreadable candidate the OS should expose", async () => {
|
||||
const processes = [
|
||||
{ pid: 100, parentPid: 1, processGroupId: 100, command: "capture" },
|
||||
{
|
||||
pid: 900,
|
||||
parentPid: 1,
|
||||
|
|
@ -91,6 +156,7 @@ describe("opaque process neighbors during cleanup", () => {
|
|||
]),
|
||||
);
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
platform: "darwin",
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
Promise.resolve({
|
||||
|
|
@ -127,14 +193,83 @@ describe("opaque process neighbors during cleanup", () => {
|
|||
cleaned: false,
|
||||
reason: expect.stringContaining("environment_unavailable=1"),
|
||||
});
|
||||
expect(signalGroup.mock.calls).toEqual([
|
||||
[100, "SIGKILL"],
|
||||
[200, "SIGKILL"],
|
||||
]);
|
||||
expect(signalGroup.mock.calls).toEqual([[100, "SIGKILL"]]);
|
||||
expect(signalGroup).not.toHaveBeenCalledWith(900, "SIGKILL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("related sweep candidates during cleanup", () => {
|
||||
it.each(["environment_unavailable", "platform_binary_environment_withheld"])(
|
||||
"fails closed for a related candidate with %s",
|
||||
async (diagnostic) => {
|
||||
const processes = [
|
||||
{
|
||||
pid: 100,
|
||||
parentPid: 1,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "capture",
|
||||
},
|
||||
{
|
||||
pid: 101,
|
||||
parentPid: 100,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
uid: process.getuid?.() === 0 ? 1 : 0,
|
||||
command: "ready-hang",
|
||||
},
|
||||
];
|
||||
const signalGroup = vi.fn();
|
||||
const identities = new Map(
|
||||
processes.map(({ pid }) => [
|
||||
pid,
|
||||
{ state: "readable" as const, identity: `start-${String(pid)}` },
|
||||
]),
|
||||
);
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: () => Promise.resolve(processes),
|
||||
environment: (pid) =>
|
||||
pid === 100
|
||||
? Promise.resolve({ REA_PROCESS_RUN_ID: "run-token" })
|
||||
: Promise.reject(new Error("EACCES: permission denied")),
|
||||
processIdentities: () => Promise.resolve(identities),
|
||||
runTokens: (members) =>
|
||||
Promise.resolve(
|
||||
new Map(
|
||||
members.map(({ pid }) => [
|
||||
pid,
|
||||
pid === 100
|
||||
? { state: "readable" as const, runId: "run-token" }
|
||||
: {
|
||||
state: "unavailable" as const,
|
||||
reason: diagnostic,
|
||||
},
|
||||
]),
|
||||
),
|
||||
),
|
||||
signalGroup,
|
||||
};
|
||||
|
||||
const result = await cleanupOwnedProcessGroup(
|
||||
{
|
||||
...ownership,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: [],
|
||||
},
|
||||
adapter,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
cleaned: false,
|
||||
reason: expect.stringContaining(
|
||||
"process ownership token could not be read",
|
||||
),
|
||||
});
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("sampled detached process groups during cleanup", () => {
|
||||
it("signals only token-authenticated sampled groups and reports an unowned sample", async () => {
|
||||
const processes = [
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export interface ProcessTableEntry {
|
|||
readonly pid: number;
|
||||
readonly parentPid: number;
|
||||
readonly processGroupId: number;
|
||||
/** Effective owner when the process table reports it. */
|
||||
readonly uid?: number;
|
||||
readonly state: string;
|
||||
readonly command: string;
|
||||
}
|
||||
|
|
@ -123,9 +125,33 @@ interface ProcessOwnershipValidationFailure {
|
|||
readonly diagnostic?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One live process the ownership sweep could not attribute or exonerate.
|
||||
* Recorded as evidence instead of failing the capture: the process is not
|
||||
* related to the owned tree, so cleanup cannot act on it and the operating
|
||||
* system withheld or removed its token (for example, macOS strips the
|
||||
* environment of Apple platform binaries, and foreign-uid processes return
|
||||
* EINVAL for KERN_PROCARGS2).
|
||||
*/
|
||||
export interface OwnershipSweepUnverifiedProcess {
|
||||
readonly pid: number;
|
||||
readonly diagnostic: string;
|
||||
}
|
||||
|
||||
/** Capture-owned process structures used to classify sweep candidates. */
|
||||
export interface OwnershipSweepRelation {
|
||||
readonly leaderPid: number;
|
||||
readonly processGroupId: number;
|
||||
readonly sampledProcessGroupIds?: readonly number[];
|
||||
}
|
||||
|
||||
/** Cleanup outcome with per-member diagnostics when ownership is uncertain. */
|
||||
export type ProcessCleanupResult =
|
||||
| { readonly cleaned: true; readonly signaled: boolean }
|
||||
| {
|
||||
readonly cleaned: true;
|
||||
readonly signaled: boolean;
|
||||
readonly unverified?: readonly OwnershipSweepUnverifiedProcess[];
|
||||
}
|
||||
| {
|
||||
readonly cleaned: false;
|
||||
readonly reason: string;
|
||||
|
|
@ -137,6 +163,7 @@ export type ProcessCleanupResult =
|
|||
| "process-identity-unavailable";
|
||||
readonly diagnostic?: string;
|
||||
}[];
|
||||
readonly unverified?: readonly OwnershipSweepUnverifiedProcess[];
|
||||
};
|
||||
|
||||
/** Token-verified liveness result used by post-root-exit settlement. */
|
||||
|
|
@ -242,8 +269,12 @@ export const cleanupOwnedProcessGroup = async (
|
|||
}
|
||||
}
|
||||
if (plan.unresolved.length > 0)
|
||||
return cleanupValidationFailure(plan.unresolved);
|
||||
return { cleaned: true, signaled };
|
||||
return cleanupValidationFailure(plan.unresolved, plan.unverified);
|
||||
return {
|
||||
cleaned: true,
|
||||
signaled,
|
||||
...(plan.unverified.length === 0 ? {} : { unverified: plan.unverified }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Verify that no live process still carries an owned capture run token. */
|
||||
|
|
@ -251,6 +282,7 @@ export const verifyNoTokenOwnedProcesses = async (
|
|||
runId: string,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
relation?: OwnershipSweepRelation,
|
||||
): Promise<ProcessCleanupResult> => {
|
||||
const processTable = await readLiveProcessTable(host);
|
||||
if (!processTable.available)
|
||||
|
|
@ -263,11 +295,21 @@ export const verifyNoTokenOwnedProcesses = async (
|
|||
runId,
|
||||
host,
|
||||
captureBaseline,
|
||||
relation,
|
||||
);
|
||||
if (scan.failures.length > 0) return cleanupValidationFailure(scan.failures);
|
||||
return scan.owned.length === 0
|
||||
? { cleaned: true, signaled: false }
|
||||
: { cleaned: false, reason: "token-owned process remained after cleanup" };
|
||||
if (scan.failures.length > 0)
|
||||
return cleanupValidationFailure(scan.failures, scan.unverified);
|
||||
if (scan.owned.length > 0)
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "token-owned process remained after cleanup",
|
||||
...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }),
|
||||
};
|
||||
return {
|
||||
cleaned: true,
|
||||
signaled: false,
|
||||
...(scan.unverified.length === 0 ? {} : { unverified: scan.unverified }),
|
||||
};
|
||||
};
|
||||
|
||||
const readLiveProcessTable = async (
|
||||
|
|
@ -293,6 +335,7 @@ interface OwnedCleanupPlan {
|
|||
readonly signalOrder: readonly number[];
|
||||
readonly tokenOwnedIdentities: ReadonlyMap<number, string | null>;
|
||||
readonly unresolved: readonly ProcessOwnershipValidationFailure[];
|
||||
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
|
||||
}
|
||||
|
||||
const createOwnedCleanupPlan = async (
|
||||
|
|
@ -313,8 +356,15 @@ const createOwnedCleanupPlan = async (
|
|||
ownership.runId,
|
||||
host,
|
||||
ownership.captureBaseline,
|
||||
{
|
||||
leaderPid: ownership.leaderPid,
|
||||
processGroupId: ownership.processGroupId,
|
||||
...(sampledProcessGroupIds.size === 0
|
||||
? {}
|
||||
: { sampledProcessGroupIds: [...sampledProcessGroupIds] }),
|
||||
},
|
||||
)
|
||||
: { owned: [], failures: [], identities: new Map() };
|
||||
: { owned: [], failures: [], unverified: [], identities: new Map() };
|
||||
const tokenOwnedGroupIds = new Set(
|
||||
tokenOwned.owned.map(({ processGroupId }) => processGroupId),
|
||||
);
|
||||
|
|
@ -335,8 +385,15 @@ const createOwnedCleanupPlan = async (
|
|||
signalOrder: [],
|
||||
tokenOwnedIdentities: tokenOwned.identities,
|
||||
unresolved: tokenOwned.failures,
|
||||
unverified: tokenOwned.unverified,
|
||||
}
|
||||
: { cleaned: true, signaled: false };
|
||||
: {
|
||||
cleaned: true,
|
||||
signaled: false,
|
||||
...(tokenOwned.unverified.length === 0
|
||||
? {}
|
||||
: { unverified: tokenOwned.unverified }),
|
||||
};
|
||||
let descendants: readonly ProcessTableEntry[] = [];
|
||||
if (launcher !== undefined) {
|
||||
const identityFailure = launcherIdentityFailure(launcher, ownership);
|
||||
|
|
@ -413,7 +470,7 @@ const createOwnedCleanupPlan = async (
|
|||
signalableGroups.add(processGroupId);
|
||||
}
|
||||
if (ownership.sweepTokenOwnedProcesses !== true && unresolved.length > 0)
|
||||
return cleanupValidationFailure(unresolved);
|
||||
return cleanupValidationFailure(unresolved, tokenOwned.unverified);
|
||||
return {
|
||||
signalOrder: [...signalableGroups]
|
||||
.filter((groupId) => processGroupIds.has(groupId))
|
||||
|
|
@ -426,6 +483,7 @@ const createOwnedCleanupPlan = async (
|
|||
),
|
||||
tokenOwnedIdentities: tokenOwned.identities,
|
||||
unresolved,
|
||||
unverified: tokenOwned.unverified,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -478,7 +536,9 @@ const revalidateOwnedProcessGroup = async (
|
|||
|
||||
const cleanupValidationFailure = (
|
||||
failures: readonly ProcessOwnershipValidationFailure[],
|
||||
unverified: readonly OwnershipSweepUnverifiedProcess[] = [],
|
||||
): ProcessCleanupResult => {
|
||||
const unverifiedEntry = unverified.length === 0 ? {} : { unverified };
|
||||
const unreadable = failures.filter(
|
||||
({ reason }) => reason === "environment-unreadable",
|
||||
);
|
||||
|
|
@ -487,12 +547,14 @@ const cleanupValidationFailure = (
|
|||
cleaned: false,
|
||||
reason: "process tree contains an unowned or PID-reused process",
|
||||
failures,
|
||||
...unverifiedEntry,
|
||||
};
|
||||
if (failures.some(({ reason }) => reason === "process-identity-unavailable"))
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "process identity could not be revalidated",
|
||||
failures,
|
||||
...unverifiedEntry,
|
||||
};
|
||||
if (unreadable.length > 0) {
|
||||
const counts = new Map<string, number>();
|
||||
|
|
@ -508,17 +570,20 @@ const cleanupValidationFailure = (
|
|||
cleaned: false,
|
||||
reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}`,
|
||||
failures,
|
||||
...unverifiedEntry,
|
||||
};
|
||||
}
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "process ownership could not be revalidated",
|
||||
failures,
|
||||
...unverifiedEntry,
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => {
|
||||
if (diagnostic === "environment_unavailable") return diagnostic;
|
||||
if (diagnostic === "platform_binary_environment_withheld") return diagnostic;
|
||||
const environmentErrno = /^(EACCES|EPERM|ENOENT|ESRCH):/u.exec(
|
||||
diagnostic ?? "",
|
||||
);
|
||||
|
|
@ -630,14 +695,50 @@ const processOwnershipFailures = async (
|
|||
interface TokenOwnedProcessScan {
|
||||
readonly owned: readonly ProcessTableEntry[];
|
||||
readonly failures: readonly ProcessOwnershipValidationFailure[];
|
||||
readonly unverified: readonly OwnershipSweepUnverifiedProcess[];
|
||||
readonly identities: ReadonlyMap<number, string | null>;
|
||||
}
|
||||
|
||||
/** One sweep candidate whose run token could not be read. */
|
||||
interface UnreadableTokenCandidate {
|
||||
readonly process: ProcessTableEntry;
|
||||
readonly diagnostic: string;
|
||||
}
|
||||
|
||||
const TOKEN_READ_RETRY_ATTEMPTS = 3;
|
||||
const TOKEN_READ_RETRY_DELAY_MS = 40;
|
||||
|
||||
/** Darwin reports this when the kernel withholds a platform binary's environment. */
|
||||
const PLATFORM_BINARY_ENVIRONMENT_WITHHELD =
|
||||
"platform_binary_environment_withheld";
|
||||
|
||||
const currentProcessUid = (): number | undefined =>
|
||||
typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||
|
||||
/**
|
||||
* Whether the operating system provably withheld this candidate's run token:
|
||||
* current macOS omits the environment of Apple platform binaries from
|
||||
* KERN_PROCARGS2 and fails with EINVAL for processes owned by another
|
||||
* account, and neither class can be token-verified by any caller. Such a
|
||||
* candidate is recorded as unverified instead of failing an otherwise clean
|
||||
* capture; every other unreadable candidate stays fail-closed.
|
||||
*/
|
||||
const environmentWithheldFromInspection = (
|
||||
entry: ProcessTableEntry,
|
||||
diagnostic: string,
|
||||
): boolean => {
|
||||
const uid = currentProcessUid();
|
||||
if (uid !== undefined && entry.uid !== undefined && entry.uid !== uid)
|
||||
return true;
|
||||
return diagnostic === PLATFORM_BINARY_ENVIRONMENT_WITHHELD;
|
||||
};
|
||||
|
||||
const scanTokenOwnedProcesses = async (
|
||||
processes: readonly ProcessTableEntry[],
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost,
|
||||
captureBaseline?: ProcessOwnershipBaseline,
|
||||
relation?: OwnershipSweepRelation,
|
||||
): Promise<TokenOwnedProcessScan> => {
|
||||
let candidates = processes;
|
||||
let candidateIdentities: ReadonlyMap<number, ProcessIdentityObservation> =
|
||||
|
|
@ -678,61 +779,98 @@ const scanTokenOwnedProcesses = async (
|
|||
}
|
||||
candidates = stableCandidates;
|
||||
}
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
const ownedIdentities = new Map<number, string | null>();
|
||||
const recordOwned = (process: ProcessTableEntry): void => {
|
||||
owned.push(process);
|
||||
const identity = candidateIdentities.get(process.pid);
|
||||
ownedIdentities.set(
|
||||
process.pid,
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
};
|
||||
let bulkTokens: ReadonlyMap<number, ProcessRunTokenObservation> | undefined;
|
||||
if (host.runTokens !== undefined)
|
||||
bulkTokens = await host.runTokens(candidates);
|
||||
const owned: ProcessTableEntry[] = [];
|
||||
const ownedIdentities = new Map<number, string | null>();
|
||||
const unreadable: UnreadableTokenCandidate[] = [];
|
||||
for (const process of candidates) {
|
||||
const bulkToken = bulkTokens?.get(process.pid);
|
||||
if (bulkToken !== undefined) {
|
||||
if (bulkToken.state === "readable") {
|
||||
if (bulkToken.runId === runId) {
|
||||
owned.push(process);
|
||||
const identity = candidateIdentities.get(process.pid);
|
||||
ownedIdentities.set(
|
||||
process.pid,
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
}
|
||||
if (bulkToken.runId === runId) recordOwned(process);
|
||||
continue;
|
||||
}
|
||||
if (!(await processIsGone(host, process.pid)))
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: bulkToken.reason,
|
||||
});
|
||||
unreadable.push({ process, diagnostic: bulkToken.reason });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId) {
|
||||
owned.push(process);
|
||||
const identity = candidateIdentities.get(process.pid);
|
||||
ownedIdentities.set(
|
||||
process.pid,
|
||||
identity?.state === "readable" ? identity.identity : null,
|
||||
);
|
||||
}
|
||||
if ((await host.environment(process.pid)).REA_PROCESS_RUN_ID === runId)
|
||||
recordOwned(process);
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
const live = liveProcesses(await host.listProcesses());
|
||||
if (!live.some(({ pid }) => pid === process.pid)) continue;
|
||||
} catch (recheckCause: unknown) {
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: `${errorMessage(cause)}; process liveness recheck failed: ${errorMessage(recheckCause)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
failures.push({
|
||||
pid: process.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: errorMessage(cause),
|
||||
});
|
||||
unreadable.push({ process, diagnostic: errorMessage(cause) });
|
||||
}
|
||||
}
|
||||
const remaining = await settleUnreadableTokenCandidates(
|
||||
unreadable,
|
||||
runId,
|
||||
host,
|
||||
recordOwned,
|
||||
);
|
||||
const relatedPids = new Set<number>();
|
||||
const relatedGroupIds = new Set<number>();
|
||||
if (relation !== undefined) {
|
||||
relatedPids.add(relation.leaderPid);
|
||||
for (const descendant of descendantsOf(relation.leaderPid, processes))
|
||||
relatedPids.add(descendant.pid);
|
||||
relatedGroupIds.add(relation.processGroupId);
|
||||
for (const processGroupId of relation.sampledProcessGroupIds ?? [])
|
||||
if (Number.isSafeInteger(processGroupId) && processGroupId > 0)
|
||||
relatedGroupIds.add(processGroupId);
|
||||
for (const process of owned) relatedGroupIds.add(process.processGroupId);
|
||||
}
|
||||
const unverified: OwnershipSweepUnverifiedProcess[] = [];
|
||||
const strict: UnreadableTokenCandidate[] = [];
|
||||
const withheld: UnreadableTokenCandidate[] = [];
|
||||
for (const candidate of remaining) {
|
||||
const isRelated =
|
||||
relation === undefined ||
|
||||
relatedPids.has(candidate.process.pid) ||
|
||||
relatedGroupIds.has(candidate.process.processGroupId);
|
||||
if (
|
||||
!isRelated &&
|
||||
environmentWithheldFromInspection(candidate.process, candidate.diagnostic)
|
||||
) {
|
||||
withheld.push(candidate);
|
||||
continue;
|
||||
}
|
||||
strict.push(candidate);
|
||||
}
|
||||
let finalLive: ReadonlySet<number> | undefined;
|
||||
try {
|
||||
finalLive = new Set(
|
||||
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
finalLive = undefined;
|
||||
}
|
||||
for (const candidate of strict) {
|
||||
if (finalLive !== undefined && !finalLive.has(candidate.process.pid))
|
||||
continue;
|
||||
failures.push({
|
||||
pid: candidate.process.pid,
|
||||
reason: "environment-unreadable",
|
||||
diagnostic: candidate.diagnostic,
|
||||
});
|
||||
}
|
||||
for (const candidate of withheld) {
|
||||
if (finalLive !== undefined && !finalLive.has(candidate.process.pid))
|
||||
continue;
|
||||
unverified.push({
|
||||
pid: candidate.process.pid,
|
||||
diagnostic: candidate.diagnostic,
|
||||
});
|
||||
}
|
||||
if (candidates.length > 0 && host.processIdentities !== undefined) {
|
||||
const afterRead = await host.processIdentities(candidates);
|
||||
const stillOwned: ProcessTableEntry[] = [];
|
||||
|
|
@ -756,9 +894,91 @@ const scanTokenOwnedProcesses = async (
|
|||
"process identity changed or became unavailable during token validation",
|
||||
});
|
||||
}
|
||||
return { owned: stillOwned, failures, identities: ownedIdentities };
|
||||
return {
|
||||
owned: stillOwned,
|
||||
failures,
|
||||
unverified,
|
||||
identities: ownedIdentities,
|
||||
};
|
||||
}
|
||||
return { owned, failures, unverified, identities: ownedIdentities };
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-read run tokens for unresolved sweep candidates so transient exec and
|
||||
* exit states settle before classification. Each attempt revalidates
|
||||
* liveness, refreshes the diagnostic, and is bounded by a fixed attempt
|
||||
* budget; a still-unreadable candidate keeps its latest diagnostic.
|
||||
*/
|
||||
const settleUnreadableTokenCandidates = async (
|
||||
pending: readonly UnreadableTokenCandidate[],
|
||||
runId: string,
|
||||
host: ProcessOwnershipHost,
|
||||
recordOwned: (process: ProcessTableEntry) => void,
|
||||
): Promise<readonly UnreadableTokenCandidate[]> => {
|
||||
let remaining = pending;
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < TOKEN_READ_RETRY_ATTEMPTS && remaining.length > 0;
|
||||
attempt += 1
|
||||
) {
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, TOKEN_READ_RETRY_DELAY_MS),
|
||||
);
|
||||
let live: ReadonlySet<number> | undefined;
|
||||
try {
|
||||
live = new Set(
|
||||
liveProcesses(await host.listProcesses()).map(({ pid }) => pid),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
live = undefined;
|
||||
}
|
||||
let observations:
|
||||
| ReadonlyMap<number, ProcessRunTokenObservation>
|
||||
| undefined;
|
||||
if (host.runTokens !== undefined) {
|
||||
try {
|
||||
observations = await host.runTokens(
|
||||
remaining.map(({ process }) => process),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
observations = undefined;
|
||||
}
|
||||
}
|
||||
const stillUnreadable: UnreadableTokenCandidate[] = [];
|
||||
for (const candidate of remaining) {
|
||||
const pid = candidate.process.pid;
|
||||
if (live !== undefined && !live.has(pid)) continue;
|
||||
const observation =
|
||||
observations?.get(pid) ?? (await readEnvironmentRunToken(host, pid));
|
||||
if (observation.state === "readable") {
|
||||
if (observation.runId === runId) recordOwned(candidate.process);
|
||||
continue;
|
||||
}
|
||||
stillUnreadable.push({
|
||||
process: candidate.process,
|
||||
diagnostic: observation.reason,
|
||||
});
|
||||
}
|
||||
remaining = stillUnreadable;
|
||||
}
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const readEnvironmentRunToken = async (
|
||||
host: ProcessOwnershipHost,
|
||||
pid: number,
|
||||
): Promise<ProcessRunTokenObservation> => {
|
||||
try {
|
||||
return {
|
||||
state: "readable",
|
||||
runId: (await host.environment(pid)).REA_PROCESS_RUN_ID,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
return { state: "unavailable", reason: errorMessage(cause) };
|
||||
}
|
||||
return { owned, failures, identities: ownedIdentities };
|
||||
};
|
||||
|
||||
const processIsGone = async (
|
||||
|
|
|
|||
|
|
@ -192,19 +192,22 @@ export const createSystemProcessOwnershipHost = (
|
|||
if (platform === "win32") return [];
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,pgid=,stat=,command="],
|
||||
["-axo", "pid=,ppid=,pgid=,uid=,stat=,command="],
|
||||
{ env: hostEnvironment, ...(signal === undefined ? {} : { signal }) },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
|
||||
.map((line) =>
|
||||
/\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line),
|
||||
)
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parentPid: Number(match[2]),
|
||||
processGroupId: Number(match[3]),
|
||||
state: match[4] ?? "",
|
||||
command: match[5] ?? "",
|
||||
uid: Number(match[4]),
|
||||
state: match[5] ?? "",
|
||||
command: match[6] ?? "",
|
||||
}));
|
||||
};
|
||||
const processIdentities: NonNullable<
|
||||
|
|
|
|||
|
|
@ -704,10 +704,18 @@ export const releaseProcessResources = async (options: {
|
|||
"owned process cleanup is unverifiable on Windows without process-job authority",
|
||||
};
|
||||
} else {
|
||||
const terminalPid = options.terminal.pid;
|
||||
const relation = {
|
||||
leaderPid: terminalPid,
|
||||
processGroupId: terminalPid,
|
||||
...(options.sampledProcessGroupIds === undefined
|
||||
? {}
|
||||
: { sampledProcessGroupIds: options.sampledProcessGroupIds }),
|
||||
};
|
||||
const cleaned = await host.cleanupProcessGroup({
|
||||
runId: options.runId,
|
||||
leaderPid: options.terminal.pid,
|
||||
processGroupId: options.terminal.pid,
|
||||
leaderPid: terminalPid,
|
||||
processGroupId: terminalPid,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
...(options.sampledProcessGroupIds === undefined
|
||||
? {}
|
||||
|
|
@ -716,6 +724,7 @@ export const releaseProcessResources = async (options: {
|
|||
? {}
|
||||
: { captureBaseline: options.captureBaseline }),
|
||||
});
|
||||
const unverified = [...(cleaned.unverified ?? [])];
|
||||
if (!cleaned.cleaned) {
|
||||
ownedProcessGroup = { state: "unverified", reason: cleaned.reason };
|
||||
} else {
|
||||
|
|
@ -724,11 +733,25 @@ export const releaseProcessResources = async (options: {
|
|||
options.runId,
|
||||
undefined,
|
||||
options.captureBaseline,
|
||||
relation,
|
||||
),
|
||||
);
|
||||
unverified.push(...(verified.unverified ?? []));
|
||||
if (!verified.cleaned)
|
||||
ownedProcessGroup = { state: "unverified", reason: verified.reason };
|
||||
}
|
||||
if (unverified.length > 0)
|
||||
ownedProcessGroup = {
|
||||
...ownedProcessGroup,
|
||||
unverified_processes: [
|
||||
...new Map(
|
||||
unverified.map(({ pid, diagnostic }) => [
|
||||
`${String(pid)}:${diagnostic}`,
|
||||
{ pid, reason: diagnostic },
|
||||
]),
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
let temporaryRoot: ProcessCaptureCleanupReport["temporary_root"] = {
|
||||
|
|
@ -892,7 +915,8 @@ export const resolveProcessResult = (
|
|||
}
|
||||
if (capture === undefined)
|
||||
throw new ProcessCaptureError("process capture produced no result");
|
||||
if ("cleanup" in capture) return capture;
|
||||
const unverifiedProcesses = cleanup.owned_process_group.unverified_processes;
|
||||
if ("cleanup" in capture && unverifiedProcesses === undefined) return capture;
|
||||
|
||||
const settlement: UnverifiedProcessCapture["settlement"] =
|
||||
capture.settlement.state === "quiesced"
|
||||
|
|
@ -902,9 +926,20 @@ export const resolveProcessResult = (
|
|||
return parseProcessCapture({
|
||||
...capture,
|
||||
settlement,
|
||||
residual_unknowns: [
|
||||
...capture.residual_unknowns,
|
||||
...(unverifiedProcesses ?? []).map(({ pid, reason }) => ({
|
||||
scope: "process" as const,
|
||||
reason: `Ownership of unrelated process ${String(pid)} could not be verified; it was left untouched: ${reason}`,
|
||||
})),
|
||||
],
|
||||
cleanup: {
|
||||
...("cleanup" in capture ? capture.cleanup : {}),
|
||||
owned_process_group: "verified",
|
||||
temporary_root: "removed",
|
||||
...(unverifiedProcesses === undefined
|
||||
? {}
|
||||
: { unverified_processes: unverifiedProcesses }),
|
||||
},
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,64 @@ it("waits for token-owned processes to exit after one cleanup signal", async ()
|
|||
}
|
||||
});
|
||||
|
||||
it("retains unrelated withheld processes through cleanup, capture, and JSON", async () => {
|
||||
const host: ProcessCaptureCleanupHost = {
|
||||
platform: "darwin",
|
||||
cleanupProcessGroup: async () => ({
|
||||
cleaned: true,
|
||||
signaled: true,
|
||||
unverified: [
|
||||
{ pid: 900, diagnostic: "platform_binary_environment_withheld" },
|
||||
],
|
||||
}),
|
||||
verifyTokenOwnedProcesses: async () => ({
|
||||
cleaned: true,
|
||||
signaled: false,
|
||||
unverified: [{ pid: 901, diagnostic: "foreign uid: EINVAL" }],
|
||||
}),
|
||||
removeTemporaryRoot: async () => undefined,
|
||||
};
|
||||
const report = await releaseProcessResources({
|
||||
timers: new Set(),
|
||||
terminal: { pid: 321 },
|
||||
renderer: undefined,
|
||||
runId: "fixture-run",
|
||||
temporaryRoot: "/fixture/root",
|
||||
host,
|
||||
});
|
||||
const unverified = [
|
||||
{ pid: 900, reason: "platform_binary_environment_withheld" },
|
||||
{ pid: 901, reason: "foreign uid: EINVAL" },
|
||||
];
|
||||
expect(report.owned_process_group).toEqual({
|
||||
state: "cleaned",
|
||||
reason: null,
|
||||
unverified_processes: unverified,
|
||||
});
|
||||
const original = emptyProcessCapture();
|
||||
const { cleanup: previousCleanup, ...pending } = original;
|
||||
for (const input of [pending, original]) {
|
||||
const resolved = resolveProcessResult(input, undefined, report);
|
||||
const serialized: unknown = JSON.parse(JSON.stringify(resolved));
|
||||
const capture = parseProcessCapture(serialized);
|
||||
expect(capture.cleanup).toEqual({
|
||||
...previousCleanup,
|
||||
unverified_processes: unverified,
|
||||
});
|
||||
expect(capture.residual_unknowns).toEqual([
|
||||
...original.residual_unknowns,
|
||||
{
|
||||
scope: "process",
|
||||
reason: expect.stringContaining("900 could not be verified"),
|
||||
},
|
||||
{
|
||||
scope: "process",
|
||||
reason: expect.stringContaining("901 could not be verified"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the last ownership failure when the verification grace expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
|
@ -246,6 +304,9 @@ it("retains observations and both causes when process cleanup is unverifiable",
|
|||
state: "unverified" as const,
|
||||
reason:
|
||||
"process ownership token could not be read for 1 live process(es): environment_unavailable=1",
|
||||
unverified_processes: [
|
||||
{ pid: 900, reason: "platform_binary_environment_withheld" },
|
||||
],
|
||||
},
|
||||
terminal_renderer: { state: "cleaned" as const, reason: null },
|
||||
temporary_root: { state: "cleaned" as const, reason: null },
|
||||
|
|
|
|||
|
|
@ -31,12 +31,15 @@ const providerNames = [
|
|||
"REA deterministic process harness",
|
||||
];
|
||||
|
||||
const captureEvidence = (side: "left" | "right") =>
|
||||
const captureEvidence = (
|
||||
side: "left" | "right",
|
||||
result: Evidence["normalized_result"] = EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
) =>
|
||||
createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture",
|
||||
operation: "capture_process_scenario",
|
||||
parameters: { side },
|
||||
result: EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
result,
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
|
|
@ -335,3 +338,34 @@ it("separately rejects a tampered digest before admission to any consumer or ses
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps withheld ownership unknown through CLI and MCP comparison", async () => {
|
||||
const { client, right } = await connectedComparison();
|
||||
const left = captureEvidence("left", {
|
||||
...EMPTY_PROCESS_CAPTURE_EXAMPLE,
|
||||
cleanup: {
|
||||
...EMPTY_PROCESS_CAPTURE_EXAMPLE.cleanup,
|
||||
unverified_processes: [
|
||||
{ pid: 900, reason: "platform_binary_environment_withheld" },
|
||||
],
|
||||
},
|
||||
residual_unknowns: [
|
||||
{ scope: "process", reason: "Ownership of process 900 was withheld" },
|
||||
],
|
||||
});
|
||||
const response = await client.callTool({
|
||||
name: "compare_process_captures",
|
||||
arguments: { left, right },
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const comparison = inlineEvidence(response);
|
||||
expect(comparison.normalized_result).toMatchObject({ status: "unknown" });
|
||||
const root = await createTestTempDirectory("rea-withheld-ownership-");
|
||||
const leftPath = join(root, "left.json");
|
||||
const rightPath = join(root, "right.json");
|
||||
await writeFile(leftPath, JSON.stringify(left));
|
||||
await writeFile(rightPath, JSON.stringify(right));
|
||||
expect(
|
||||
parseEvidence(await compareProcessEvidenceFiles(leftPath, rightPath)),
|
||||
).toEqual(comparison);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue