diff --git a/docs/linux-lock-attribution.md b/docs/linux-lock-attribution.md new file mode 100644 index 0000000..6b87d1a --- /dev/null +++ b/docs/linux-lock-attribution.md @@ -0,0 +1,87 @@ +# Linux descriptor participation + +Linux `live_owner` classification requires positive evidence of a granted, +whole-file exclusive advisory FLOCK in the candidate's `/proc/PID/fdinfo/FD`. +The kernel's acquisition PID is not compared with the candidate: inherited +descriptions can retain the acquiring PID. A positive row proves participation +by a description, not exclusive lifetime control or complete system visibility. +Existing unique-opener, same-user, process-mode, transcript and lock-set checks +remain necessary. Multiple visible candidates are refused. + +Opener/identity samples are collected before attribution probes, not atomically +with those probes. Each later guarded attribution is tied to its original +inspected file snapshot and sampled process start ticks; changing evidence is +refused. This does not establish atomic visibility across both sample windows. + +Descriptor names and readlink-only candidate paths are prepared outside native +coordination. Candidate and observer mount namespaces must match. Paths are +negative filters, never positive lock evidence; FD paths that do not match the +prepared target path are excluded from stat while holding coordination. This +reduces unrelated FUSE/NFS exposure but is not proof against overmounted paths +that retain identical kernel path strings. Under coordination, +the independent nonblocking thread probe must report held; synchronous reads +then verify the descriptor's actual target, granted lock row and process start +ticks, rechecking identity and descriptor enumeration afterward. Collection +allows at most 256 descriptors, 64 KiB per proc file and 25 ms of monotonic +elapsed time. These are fail-closed budgets, not a hard filesystem I/O deadline. +Even proc cmdline reads can wait for the target's memory-map lock or remote +pages; matching-path FD metadata can also block. A stalled synchronous syscall +can therefore retain native coordination beyond the advertised work budget. +This liveness risk is a remaining draft acceptance gate, not a solved bound. +No child process, await or signal runs while coordination is held. +The final budget check occurs after the final target sample, before a positive +result is returned. Linux CI must execute positive participation fixtures on a +supported filesystem; unsupported CI storage fails, not silently skips. + +Only direct local ext-family, tmpfs and XFS filesystem mappings are accepted. +Btrfs, overlay, network and FUSE filesystems are unknown; their exposed stat +device/inode may differ from the lock table. Device and inode comparisons use +BigInt throughout. Missing, denied, malformed, changed, truncated, oversized, +unsupported and ambiguous evidence remains unknown. `/proc/self` must match +the running PID, rejecting a visibly inconsistent proc PID namespace. + +Process start ticks are sampled around command/identity collection, across +inspection samples and in the final synchronous guarded sample before signal. +An optional SHA-256 command fingerprint of bounded raw argv bytes and the +executable readlink value is bound to these same samples. It detects observed +same-PID argv/image changes without statting the executable's filesystem, but +does not prove that an exec/re-exec never occurred between observations. +Coordination is released before the existing SIGTERM operation. `kill(pid)` +still has a residual process-replacement window; this adapter adds no pidfd. +Hidden references (including mmap, in-flight SCM_RIGHTS and io_uring registered +files) are not enumerable by this evidence. Post-signal actual release and +transcript verification remain mandatory. This is not owner-cooperative +release and does not establish Windows or macOS positive attribution. + +JSON v1 adds optional `owner.startTicks` and +`owner.commandFingerprint` and `lock.probe.participation` +(`pid`, `startTicks`, `descriptor`). Consumers must +not interpret participation as independent permission to terminate a process. + +## Native Codex and draft acceptance gates + +The pinned Codex +[process-hardening implementation](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/process-hardening/src/lib.rs#L43-L60) +sets `PR_SET_DUMPABLE` to zero on Linux when called. This library behavior alone +does not establish that an ordinary thread-lock owner calls it. Verified callers +include the +[responses API proxy](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/responses-api-proxy/src/main.rs#L4-L7), +[voice host](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/voice-host/src/main.rs#L48-L49), +and [sandbox host bridge](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/linux-sandbox/src/proxy_lifecycle.rs#L116-L120). +The inspected [CLI entry point](https://github.com/openai/codex/blob/092d3acd6bec3e3a14bdc7e7a2810ab628ab759d/codex-rs/cli/src/main.rs#L1031-L1039) +does not directly call these hardening functions. This is a targeted source +check, not an exhaustive call-graph proof or a measurement of the deployed +owner's dumpability; helper dispatch, launch context and procfs policy still +matter. If a candidate is non-dumpable, descriptor/executable access can be +denied even for a same-user observer; proc-directory UID alone is not proof of +accessibility. A passing same-user Node fixture cannot establish native access. +The implementation fails closed; it never requests elevated access or changes +dumpability. The native release binary's positive or intentionally refused +behavior has not been measured by these tests. + +Before this draft can become release-ready, record actual Linux supported-FS +positive and full-unlock refusal tests, the native hardening/dumpability boundary, +and a reviewed solution or explicit policy for guard liveness exposure. A +passing Node fixture or CI build alone is insufficient. Additional denied, +namespace, budget and replacement matrix coverage remains required for closing +#126. Known PID signaling and same-executable re-exec limitations are not solved. diff --git a/package.json b/package.json index 7ddcf1a..994b251 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dist/inspection.js", "dist/json-types.d.ts", "dist/lock.js", + "dist/linux-attribution.js", "dist/native-coordination.js", "dist/options.js", "dist/policy.js", diff --git a/schemas/codex-unlock-v1.schema.json b/schemas/codex-unlock-v1.schema.json index fa9bf02..1cfdc5b 100644 --- a/schemas/codex-unlock-v1.schema.json +++ b/schemas/codex-unlock-v1.schema.json @@ -46,6 +46,15 @@ "properties": { "status": { "enum": ["held", "free", "unknown"] }, "method": { "const": "flock_exclusive_nonblocking" }, + "participation": { + "type": "object", + "required": ["pid", "startTicks", "descriptor"], + "properties": { + "pid": { "type": "integer", "minimum": 1 }, + "startTicks": { "type": "string", "pattern": "^[0-9]+$" }, + "descriptor": { "type": "string", "pattern": "^[0-9]+$" } + } + }, "guard": { "type": "object", "required": ["status"], @@ -65,6 +74,8 @@ "ppid": { "type": ["integer", "null"], "minimum": 0 }, "uid": { "type": ["integer", "null"], "minimum": 0 }, "startTime": { "type": ["string", "null"] }, + "startTicks": { "type": "string", "pattern": "^[0-9]+$" }, + "commandFingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "tty": { "type": ["string", "null"] }, "command": { "type": ["string", "null"] }, "arguments": { "type": ["string", "null"] }, diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 22addb7..4bca174 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -186,6 +186,7 @@ try { "dist/inspection.js", "dist/json-types.d.ts", "dist/lock.js", + "dist/linux-attribution.js", "dist/native-coordination.js", "dist/options.js", "dist/policy.js", diff --git a/src/inspection.ts b/src/inspection.ts index e189a09..25b9253 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -2,7 +2,8 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { observeLockDirectory, observeLockFile } from "./lock.js"; -import { probeWithRetry } from "./native-coordination.js"; +import { guardedProbeOnce, probeWithRetry } from "./native-coordination.js"; +import { prepareLinuxParticipation, verifyLinuxParticipation } from "./linux-attribution.js"; import { isThreadId, defaultOptions, validateThreadId } from "./options.js"; import { evaluateSafety } from "./policy.js"; import { @@ -37,6 +38,8 @@ function sameProcessIdentity( before[0].pid === after[0].pid && before[0].startTime !== null && before[0].startTime === after[0].startTime + && (process.platform !== "linux" || (before[0].startTicks !== undefined && before[0].startTicks === after[0].startTicks && + before[0].commandFingerprint !== undefined && before[0].commandFingerprint === after[0].commandFingerprint)) ); } @@ -129,6 +132,31 @@ export async function inspectThread( openersAfter.processes, ); const openerError = openersBefore.error ?? openersAfter.error; + // Correlation alone is insufficient on Linux. Bind a granted descriptor row + // to both process-instance samples under the existing native coordination. + for (const [probe, openers, observedLock] of [[probeBefore, openersBefore, lockBefore], [probeAfter, openersAfter, lockAfter]] as const) { + if (process.platform !== "linux" || probe.status !== "held") continue; + // Invalid opener discovery already blocks classification and policy. Keep + // the independent OS held result; it is not a process-visibility result. + if (openers.error || openers.processes.length !== 1) continue; + try { + const candidate = openers.processes[0]; + const prepared = prepareLinuxParticipation(candidate.pid, lockPath); + if (!candidate.startTicks || prepared.startTicks !== candidate.startTicks || + !candidate.commandFingerprint || prepared.commandFingerprint !== candidate.commandFingerprint) throw new Error("linux_identity_changed"); + // Attribute only the ORIGINAL inspected inode. The existing native + // thread probe validates this snapshot against path and opened FD. + const fresh = observeLockFile(lockPath); + if (!fresh.regularFile || fresh.symlink || !fresh.ownedByCurrentUser || + !sameSnapshot(observedLock.snapshot, fresh.snapshot)) throw new Error("linux_target_changed"); + const result = guardedProbeOnce(lockPath, observedLock, undefined, + () => verifyLinuxParticipation(lockPath, prepared)); + Object.assign(probe, result); + } catch { + probe.status = "unknown"; + probe.error = "linux_participation_unproven"; + } + } const probeStable = probeBefore.status === probeAfter.status; const classification = classify( lockAfter.status, diff --git a/src/linux-attribution.ts b/src/linux-attribution.ts new file mode 100644 index 0000000..e87925d --- /dev/null +++ b/src/linux-attribution.ts @@ -0,0 +1,191 @@ +import { closeSync, constants, openSync, opendirSync, readSync, readlinkSync, realpathSync, statSync, statfsSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import { createHash } from "node:crypto"; + +// A positive row proves participation by an open description, not exclusive +// lifetime control: inherited and otherwise hidden references remain possible. +const BYTE_LIMIT = 65536; +const FD_LIMIT = 256; +const BUDGET_MS = 25; +const FILESYSTEMS = new Set([0xef53n, 0x01021994n, 0x58465342n]); // ext, tmpfs, XFS + +export interface LinuxParticipation { pid: number; startTicks: string; descriptor: string } +export interface LinuxPreparation { + pid: number; descriptors: string[]; candidates: string[]; + targetPath: string; mountNamespace: string; startTicks: string; commandFingerprint: string; uid: bigint; +} + +function checkDeadline(deadline: number): void { + if (performance.now() > deadline) throw new Error("linux_attribution_budget"); +} + +function boundedBytes(path: string, deadline = performance.now() + BUDGET_MS): Buffer { + const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const buffer = Buffer.alloc(BYTE_LIMIT + 1); + let used = 0; + while (used < buffer.length) { + checkDeadline(deadline); + const count = readSync(fd, buffer, used, buffer.length - used, null); + if (count === 0) break; + used += count; + } + if (used > BYTE_LIMIT) throw new Error("linux_attribution_truncated"); + return buffer.subarray(0, used); + } finally { closeSync(fd); } +} + +function boundedRead(path: string, deadline = performance.now() + BUDGET_MS): string { + return boundedBytes(path, deadline).toString("utf8"); +} + +export function fingerprintCommand(commandLine: Uint8Array, executable: string): string { + return createHash("sha256").update(executable).update("\0").update(commandLine).digest("hex"); +} + +export function linuxCommandFingerprint(pid: number, deadline = performance.now() + BUDGET_MS): string { + const executable = readlinkSync(`/proc/${pid}/exe`); + if (!executable.startsWith("/") || executable.endsWith(" (deleted)")) throw new Error("linux_image_unverified"); + const commandLine = boundedBytes(`/proc/${pid}/cmdline`, deadline); + if (!commandLine.length) throw new Error("linux_command_unverified"); + checkDeadline(deadline); + return fingerprintCommand(commandLine, executable); +} + +export function parseStartTicks(text: string, pid: number): string { + const end = text.lastIndexOf(") "); + if (!text.startsWith(`${pid} (`) || end < 0) throw new Error("linux_identity_malformed"); + const fields = text.slice(end + 2).trim().split(/\s+/); + if (fields[0] === "Z" || fields[0] === "X" || !/^\d+$/.test(fields[19] ?? "")) { + throw new Error("linux_identity_unavailable"); + } + return fields[19]; +} + +export function parseFlockRows(text: string): { major: bigint; minor: bigint; inode: bigint }[] { + const rows = []; + for (const line of text.split("\n")) { + if (!line.startsWith("lock:")) continue; + const match = /^lock:\s+\d+:\s+FLOCK\s+ADVISORY\s+WRITE\s+-?\d+\s+([\da-f]+):([\da-f]+):(\d+)\s+0\s+EOF\s*$/i.exec(line); + // Any other lock row is unsupported, never negative evidence of absence. + if (!match) throw new Error("linux_lock_row_unsupported"); + const row = { major: BigInt(`0x${match[1]}`), minor: BigInt(`0x${match[2]}`), inode: BigInt(match[3]) }; + if (row.major > 0xffffffffn || row.minor > 0xffffffffn || row.inode > 0xffffffffffffffffn) { + throw new Error("linux_lock_row_unsupported"); + } + rows.push(row); + } + return rows; +} + +function checkNamespace(): void { + if (statfsSync("/proc", { bigint: true }).type !== 0x9fa0n) throw new Error("linux_proc_filesystem_untrusted"); + assertProcNamespace(readlinkSync("/proc/self"), process.pid); +} + +export function assertProcNamespace(procSelf: string, pid: number): void { + if (procSelf !== String(pid)) throw new Error("linux_proc_namespace_mismatch"); +} + +function descriptors(pid: number, deadline: number): string[] { + const directory = opendirSync(`/proc/${pid}/fd`, { bufferSize: 1 }); + const entries: string[] = []; + let seen = 0; + try { + while (true) { + checkDeadline(deadline); + const entry = directory.readSync(); + if (!entry) break; + seen += 1; + if (seen > FD_LIMIT || !/^\d+$/.test(entry.name)) throw new Error("linux_descriptor_limit"); + // Self-observation's directory cursor is an observer-created descriptor, + // not candidate evidence. Production policy separately refuses our family. + if (pid === process.pid && readlinkSync(`/proc/${pid}/fd/${entry.name}`) === `/proc/${pid}/fd`) continue; + entries.push(entry.name); + } + } finally { directory.closeSync(); } + return entries.sort((a, b) => Number(a) - Number(b)); +} + +export function filterCandidateDescriptors( + pid: number, names: string[], targetPath: string, deadline: number, + readLink: (path: string) => string = readlinkSync, +): string[] { + const candidates: string[] = []; + for (const name of names) { + checkDeadline(deadline); + const target = readLink(`/proc/${pid}/fd/${name}`); + if (target === `${targetPath} (deleted)`) throw new Error("linux_target_deleted"); + if (target === targetPath) candidates.push(name); + } + return candidates; +} + +export function prepareLinuxParticipation(pid: number, lockPath: string): LinuxPreparation { + const deadline = performance.now() + BUDGET_MS; + checkNamespace(); + const uid = process.getuid?.(); + if (!Number.isSafeInteger(pid) || pid <= 0 || uid === undefined) throw new Error("linux_identity_unavailable"); + const directory = statSync(`/proc/${pid}`, { bigint: true }); + if (directory.uid !== BigInt(uid)) throw new Error("linux_uid_mismatch"); + const mountNamespace = readlinkSync("/proc/self/ns/mnt"); + if (readlinkSync(`/proc/${pid}/ns/mnt`) !== mountNamespace) throw new Error("linux_mount_namespace_mismatch"); + const targetPath = realpathSync.native(lockPath); + const names = descriptors(pid, deadline); + // Paths only exclude unrelated descriptions. Never stat an unrelated FD: + // it may point at a blocking FUSE/NFS filesystem while coordination is held. + const candidates = filterCandidateDescriptors(pid, names, targetPath, deadline); + checkDeadline(deadline); + return { pid, uid: directory.uid, targetPath, mountNamespace, candidates, + startTicks: parseStartTicks(boundedRead(`/proc/${pid}/stat`, deadline), pid), + commandFingerprint: linuxCommandFingerprint(pid, deadline), descriptors: names }; +} + +export function linuxStartTicks(pid: number): string { + checkNamespace(); + return parseStartTicks(boundedRead(`/proc/${pid}/stat`), pid); +} + +/** Called synchronously under native coordination, after independent held probe. */ +export function verifyLinuxParticipation(lockPath: string, prepared: LinuxPreparation): LinuxParticipation { + const deadline = performance.now() + BUDGET_MS; + const checkBudget = (): void => { if (performance.now() > deadline) throw new Error("linux_attribution_budget"); }; + checkNamespace(); + if (!FILESYSTEMS.has(statfsSync(lockPath, { bigint: true }).type)) throw new Error("linux_filesystem_unsupported"); + const target = statSync(lockPath, { bigint: true }); + if (!target.isFile() || target.nlink !== 1n || target.uid !== prepared.uid) throw new Error("linux_target_changed"); + const validateIdentity = (): void => { + checkBudget(); + if (statSync(`/proc/${prepared.pid}`, { bigint: true }).uid !== prepared.uid || + readlinkSync("/proc/self/ns/mnt") !== prepared.mountNamespace || + readlinkSync(`/proc/${prepared.pid}/ns/mnt`) !== prepared.mountNamespace || + parseStartTicks(boundedRead(`/proc/${prepared.pid}/stat`, deadline), prepared.pid) !== prepared.startTicks || + linuxCommandFingerprint(prepared.pid, deadline) !== prepared.commandFingerprint || + JSON.stringify(descriptors(prepared.pid, deadline)) !== JSON.stringify(prepared.descriptors)) { + throw new Error("linux_identity_changed"); + } + }; + validateIdentity(); + const matches: string[] = []; + for (const descriptor of prepared.candidates) { + checkBudget(); + const path = `/proc/${prepared.pid}/fd/${descriptor}`; + if (!prepared.descriptors.includes(descriptor) || readlinkSync(path) !== prepared.targetPath) throw new Error("linux_descriptor_changed"); + const before = statSync(path, { bigint: true }); + if (before.dev !== target.dev || before.ino !== target.ino) throw new Error("linux_descriptor_changed"); + const rows = parseFlockRows(boundedRead(`/proc/${prepared.pid}/fdinfo/${descriptor}`, deadline)); + const after = statSync(path, { bigint: true }); + if (readlinkSync(path) !== prepared.targetPath || before.dev !== after.dev || before.ino !== after.ino) throw new Error("linux_descriptor_changed"); + const major = ((target.dev >> 8n) & 0xfffn) | ((target.dev >> 32n) & 0xfffff000n); + const minor = (target.dev & 0xffn) | ((target.dev >> 12n) & 0xffffff00n); + if (rows.some((row) => row.major !== major || row.minor !== minor || row.inode !== target.ino)) throw new Error("linux_row_identity_mismatch"); + if (rows.length === 1) matches.push(descriptor); + else if (rows.length > 1) throw new Error("linux_rows_ambiguous"); + } + validateIdentity(); + const after = statSync(lockPath, { bigint: true }); + if (!after.isFile() || after.nlink !== 1n || after.uid !== prepared.uid || + after.dev !== target.dev || after.ino !== target.ino || matches.length !== 1) throw new Error("linux_participation_unproven"); + checkBudget(); + return { pid: prepared.pid, startTicks: prepared.startTicks, descriptor: matches[0] }; +} diff --git a/src/lock.ts b/src/lock.ts index 950e7ab..6c312b8 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -161,7 +161,9 @@ export function probeThreadLockOnce( if ( !value.isFile() || opened.device !== before.snapshot.device || - opened.inode !== before.snapshot.inode + opened.inode !== before.snapshot.inode || + opened.uid !== before.snapshot.uid || + opened.links !== before.snapshot.links ) { throw new Error("lock file changed while it was opened"); } @@ -179,8 +181,11 @@ export function probeThreadLockOnce( error: errorText(error), }; } - const after = publicSnapshot(lstatSync(path, { bigint: true })); - if (after.device !== opened.device || after.inode !== opened.inode) { + const afterValue = lstatSync(path, { bigint: true }); + const after = publicSnapshot(afterValue); + if (!afterValue.isFile() || afterValue.isSymbolicLink() || + after.device !== opened.device || after.inode !== opened.inode || + after.uid !== opened.uid || after.links !== opened.links) { throw new Error("lock file changed during the probe"); } } catch (error) { diff --git a/src/native-coordination.ts b/src/native-coordination.ts index 5f94bc6..f503146 100644 --- a/src/native-coordination.ts +++ b/src/native-coordination.ts @@ -9,7 +9,7 @@ import { flockSync } from "fs-ext-extra-prebuilt"; import { observeLockFile, probeThreadLockOnce, type LockFileObservation } from "./lock.js"; import type { LockProbe, NativeGuardEvidence } from "./types.js"; -import { delay, errorText } from "./util.js"; +import { delay, errorText, sameSnapshot } from "./util.js"; export const NATIVE_COORDINATION_FILE = ".coordination.lock"; export const NATIVE_COORDINATION_RETRY_MS = 100; @@ -39,6 +39,7 @@ export function guardedProbeOnce( lockPath: string, observation: LockFileObservation = observeLockFile(lockPath), hooksForTesting?: { afterGuardAcquired?: () => void; afterThreadAcquired?: () => void }, + attributeHeld?: () => NonNullable, ): LockProbe { if (observation.status !== "present") { return observation.status === "absent" @@ -99,6 +100,16 @@ export function guardedProbeOnce( ...probeThreadLockOnce(lockPath, observation, hooksForTesting?.afterThreadAcquired), guard: { status: "acquired", attempts: 1 }, }; + if (result.status === "held" && attributeHeld) { + try { result.participation = attributeHeld(); } + catch (error) { + result = { status: "unknown", method: "flock_exclusive_nonblocking", + guard: { status: "acquired", attempts: 1 }, error: `linux_participation_unproven:${errorText(error)}` }; + } + if (!sameSnapshot(observation.snapshot, observeLockFile(lockPath).snapshot)) { + throw new Error("linux_target_changed"); + } + } } } const after = lstatSync(path, { bigint: true }); diff --git a/src/policy.ts b/src/policy.ts index 08f0e4b..4b4db5b 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -6,6 +6,7 @@ export type InspectionEvidence = Omit< >; export interface SafetyContext { + platform?: NodeJS.Platform; currentProcessFamily: Set | null; openerError?: string; ownerLockError?: string; @@ -38,6 +39,11 @@ export function evaluateSafety( const blockers: string[] = []; const warnings: string[] = []; const { lock, owner, transcript } = result; + if ((context.platform ?? process.platform) === "linux" && + (!owner?.startTicks || lock.probe.participation?.pid !== owner.pid || + lock.probe.participation?.startTicks !== owner.startTicks)) { + addOnce(blockers, "linux_participation_unproven"); + } if (result.classification !== "live_owner") { addOnce(blockers, `classification_${result.classification}`); diff --git a/src/process.ts b/src/process.ts index e191b50..0cda4f4 100644 --- a/src/process.ts +++ b/src/process.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { access, lstat, readFile, realpath, stat } from "node:fs/promises"; import { basename, dirname, isAbsolute, join } from "node:path"; +import { linuxCommandFingerprint, linuxStartTicks } from "./linux-attribution.js"; import type { ProcessInfo, ProcessStartObservation } from "./types.js"; import { @@ -503,6 +504,14 @@ export function reconcileArguments( export async function inspectProcess( candidate: LsofProcess, ): Promise { + let startTicks: string | undefined; + let commandFingerprint: string | undefined; + if (process.platform === "linux") { + try { + startTicks = linuxStartTicks(candidate.pid); + commandFingerprint = linuxCommandFingerprint(candidate.pid); + } catch { /* Fail closed below. */ } + } const [ppidField, uidField, startTime, ttyField, commandField, argumentsField, cwd, commandLine] = await Promise.all([ psField(candidate.pid, "ppid"), @@ -529,6 +538,14 @@ export async function inspectProcess( /(^|\/)codex(?:\s|$)/i.test(args ?? ""); const isSharedService = argumentEvidence.isSharedService; const errors: string[] = []; + if (process.platform === "linux") { + try { + if (!startTicks || !commandFingerprint || linuxStartTicks(candidate.pid) !== startTicks || + linuxCommandFingerprint(candidate.pid) !== commandFingerprint) throw new Error("changed"); + } catch { + errors.push("linux_process_instance_unverified"); startTicks = undefined; commandFingerprint = undefined; + } + } if (ppid === null) errors.push(`ppid_${ppidField.status}${ppidField.error ? `:${ppidField.error}` : ""}`); if (uid === null) errors.push(`uid_${uidField.status}${uidField.error ? `:${uidField.error}` : ""}`); if (startTime.status !== "present") errors.push(`start_time_${startTime.status}${startTime.error ? `:${startTime.error}` : ""}`); @@ -537,6 +554,8 @@ export async function inspectProcess( else if (args === null) errors.push(`arguments_${argumentsField.status}${argumentsField.error ? `:${argumentsField.error}` : ""}`); return { + ...(startTicks ? { startTicks } : {}), + ...(commandFingerprint ? { commandFingerprint } : {}), pid: candidate.pid, ppid, uid, diff --git a/src/types.ts b/src/types.ts index 9c3f9f1..8338cbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,6 +71,7 @@ export interface LockProbe { method: "flock_exclusive_nonblocking"; error?: string; guard?: NativeGuardEvidence; + participation?: { pid: number; startTicks: string; descriptor: string }; } export type ProcessObservationStatus = "present" | "absent" | "unknown"; @@ -84,6 +85,8 @@ export interface ProcessStartObservation { } export interface ProcessInfo { + startTicks?: string; + commandFingerprint?: string; pid: number; ppid: number | null; uid: number | null; diff --git a/src/unlock.ts b/src/unlock.ts index 1dcd05b..04fcdf0 100644 --- a/src/unlock.ts +++ b/src/unlock.ts @@ -1,5 +1,6 @@ import { acquireUnlockLease } from "./coordination.js"; import { inspectThread } from "./inspection.js"; +import { prepareLinuxParticipation, verifyLinuxParticipation } from "./linux-attribution.js"; import { performance } from "node:perf_hooks"; import { closeSync, constants, fstatSync, lstatSync, openSync } from "node:fs"; @@ -57,6 +58,9 @@ function sameOwnerEvidence(before: ProcessInfo, after: ProcessInfo): boolean { before.uid === after.uid && before.startTime !== null && before.startTime === after.startTime && + (process.platform !== "linux" || (before.startTicks !== undefined && + before.startTicks === after.startTicks && before.commandFingerprint !== undefined && + before.commandFingerprint === after.commandFingerprint)) && before.command === after.command && before.arguments === after.arguments && before.identityComplete === after.identityComplete && @@ -472,16 +476,32 @@ export async function unlockInspectedThread( revalidationReasons.push(`transcript_observation_failed_before_signal:${errorText(error)}`); } const preSignalLock = observeLockFile(inspection.lock.path); + const metadataVerified = preSignalLock.status === "present" && + preSignalLock.regularFile === true && preSignalLock.symlink === false && + preSignalLock.ownedByCurrentUser === true && preSignalLock.snapshot?.links === 1; + if (!metadataVerified) revalidationReasons.push("lock_metadata_changed_before_signal"); // No await from this try-once probe through process.kill. Its native guard // is released before returning; a busy coordinator refuses immediately. - const preSignalProbe = guardedProbeOnce(inspection.lock.path, preSignalLock); + let preSignalProbe: LockProbe; + try { + if (!metadataVerified) throw new Error("lock_metadata_changed"); + const prepared = process.platform === "linux" ? prepareLinuxParticipation(owner.pid, inspection.lock.path) : undefined; + if (prepared && (!owner.startTicks || prepared.startTicks !== owner.startTicks || + prepared.startTicks !== finalInspection.owner?.startTicks || + !owner.commandFingerprint || prepared.commandFingerprint !== owner.commandFingerprint || + prepared.commandFingerprint !== finalInspection.owner?.commandFingerprint)) throw new Error("linux_identity_changed"); + preSignalProbe = guardedProbeOnce(inspection.lock.path, preSignalLock, undefined, + prepared ? () => verifyLinuxParticipation(inspection.lock.path, prepared) : undefined); + } catch { + preSignalProbe = { status: "unknown", method: "flock_exclusive_nonblocking", error: "linux_participation_unproven" }; + } if (preSignalProbe.guard?.status === "busy") { revalidationReasons.push("native_coordination_busy_before_signal"); } - if ( + if (metadataVerified && ( !sameSnapshot(finalInspection.lock.snapshot, preSignalLock.snapshot) || (preSignalProbe.status !== "held" && preSignalProbe.guard?.status !== "busy") - ) { + )) { revalidationReasons.push("lock_changed_before_signal"); } } diff --git a/test/helpers/native-signal-boundary.mjs b/test/helpers/native-signal-boundary.mjs index 2cea323..e51f879 100644 --- a/test/helpers/native-signal-boundary.mjs +++ b/test/helpers/native-signal-boundary.mjs @@ -16,6 +16,9 @@ let finalDescriptor; let finalClosed = false; let microtaskRan = false; const held = new Set(); +// Two inspections each use two OS probes. Linux additionally performs two +// guarded attribution probes per inspection; the final signal probe is last. +const signalProbeAttempt = 2 * (2 + (process.platform === "linux" ? 2 : 0)) + 1; fs.openSync = (path, ...args) => { const fd = open(path, ...args); @@ -33,7 +36,7 @@ syncBuiltinESMExports(); addon.flockSync = (fd, operation) => { if (guards.has(fd) && operation === "exnb") { attempts += 1; - if (attempts === 5) { + if (attempts === signalProbeAttempt) { finalDescriptor = fd; if (process.env.CODEX_UNLOCK_TEST_NATIVE_SIGNAL === "busy") { throw Object.assign(new Error("injected native guard contention"), { code: "EAGAIN" }); @@ -52,7 +55,7 @@ addon.flockSync = (fd, operation) => { }; process.kill = (pid, signal) => { if (signal === "SIGTERM") { - assert.equal(attempts, 5); + assert.equal(attempts, signalProbeAttempt); assert.equal(held.size, 0); assert.equal(finalClosed, true); assert.equal(microtaskRan, false); diff --git a/test/linux-attribution.test.mjs b/test/linux-attribution.test.mjs new file mode 100644 index 0000000..b784c81 --- /dev/null +++ b/test/linux-attribution.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { URL } from 'node:url'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { Buffer } from 'node:buffer'; +import { test } from 'node:test'; +import { mkdtempSync, writeFileSync, openSync, closeSync, rmSync, readFileSync, renameSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { flockSync } from 'fs-ext-extra-prebuilt'; +import { assertProcNamespace, filterCandidateDescriptors, fingerprintCommand, parseFlockRows, parseStartTicks, prepareLinuxParticipation, verifyLinuxParticipation } from '../dist/linux-attribution.js'; +import { guardedProbeOnce } from '../dist/native-coordination.js'; +import { observeLockFile } from '../dist/lock.js'; + +test('granted flock rows preserve inode precision and ignore acquisition PID', () => { + assert.deepEqual(parseFlockRows('lock:\t1: FLOCK ADVISORY WRITE -1 00:02:9007199254740993 0 EOF\n'), + [{ major: 0n, minor: 2n, inode: 9007199254740993n }]); + assert.deepEqual(parseFlockRows('pos:\t0\n'), []); + for (const type of ['POSIX', 'OFDLCK', 'LEASE', '-> FLOCK']) { + assert.throws(() => parseFlockRows(`lock:\t1: ${type} ADVISORY WRITE 5 00:02:1 0 EOF\n`)); + } + assert.throws(() => parseFlockRows('lock: truncated')); + assert.throws(() => parseFlockRows('lock: 1: FLOCK ADVISORY READ 5 00:02:1 0 EOF')); + const large = (1n << 512n).toString(); + assert.throws(() => parseFlockRows(`lock: 1: FLOCK ADVISORY WRITE 1 00:02:${large} 0 EOF`)); +}); + +test('readlink-only negative filtering never traverses unrelated FD filesystem targets', () => { + const links = new Map([ + ['/proc/42/fd/1', '/mnt/nfs/unrelated'], ['/proc/42/fd/2', '/mnt/fuse/unrelated'], + ['/proc/42/fd/3', '/local/target'], ['/proc/42/fd/4', 'socket:[123]'], + ]); + const read = (path) => links.get(path); + assert.deepEqual(filterCandidateDescriptors(42, ['1','2','3','4'], '/local/target', Infinity, read), ['3']); + links.set('/proc/42/fd/3', '/local/target (deleted)'); + assert.throws(() => filterCandidateDescriptors(42, ['3'], '/local/target', Infinity, read), /target_deleted/); + assert.throws(() => filterCandidateDescriptors(42, ['1'], '/local/target', -Infinity, read), /budget/); +}); + +test('command fingerprint changes on same-PID image/argv changes and preserves raw bytes', () => { + const a = fingerprintCommand(Buffer.from([1, 0, 0xff]), '/usr/bin/codex'); + assert.match(a, /^[0-9a-f]{64}$/); + assert.notEqual(a, fingerprintCommand(Buffer.from([1, 0, 0xfe]), '/usr/bin/codex')); + assert.notEqual(a, fingerprintCommand(Buffer.from([1, 0, 0xff]), '/usr/bin/other')); +}); + +test('positive attribution callback cannot bind a replacement inode to an old snapshot', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-attribution-identity-')); + const path = join(root, 'thread.lock'); + writeFileSync(path, ''); + writeFileSync(join(root, '.coordination.lock'), ''); + const original = observeLockFile(path); + writeFileSync(join(root, 'replacement'), ''); + renameSync(join(root, 'replacement'), path); + const fd = openSync(path, 'r'); + try { + flockSync(fd, 'exnb'); + let attributed = 0; + const result = guardedProbeOnce(path, original, undefined, () => { + attributed += 1; + return { pid: process.pid, startTicks: '1', descriptor: String(fd) }; + }); + assert.equal(result.status, 'unknown'); + assert.equal(attributed, 0); + assert.equal(result.participation, undefined); + } finally { closeSync(fd); rmSync(root, { recursive: true }); } +}); + +test('real native guard refuses a nonlocking candidate while another description holds; no signal', { skip: process.platform !== 'linux' }, async () => { + const root = mkdtempSync(join(tmpdir(), 'codex-attribution-guard-')); + const path = join(root, 'thread.lock'); + writeFileSync(path, ''); + writeFileSync(join(root, '.coordination.lock'), ''); + const holder = openSync(path, 'r'); + flockSync(holder, 'exnb'); + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import {openSync,closeSync} from 'node:fs'; + import process from 'node:process'; + const fd=openSync(process.argv[1],'r'); + const timer=setTimeout(()=>{closeSync(fd);process.exitCode=2;process.stdin.destroy();},5000); + process.stdin.resume(); process.stdin.on('end',()=>{clearTimeout(timer);closeSync(fd);}); + console.log('ready'); + `, path], { stdio: ['pipe', 'pipe', 'pipe'] }); + const closed = once(child, 'close'); + try { + await once(child.stdout, 'data'); + const prepared = prepareLinuxParticipation(child.pid, path); + let signals = 0; + const original = process.kill; + let result; + try { + process.kill = () => { signals += 1; throw new Error('unexpected signal'); }; + result = guardedProbeOnce(path, undefined, undefined, () => verifyLinuxParticipation(path, prepared)); + } finally { process.kill = original; } + assert.equal(result.status, 'unknown'); + assert.equal(result.guard.status, 'acquired'); + assert.equal(signals, 0); + assert.equal(guardedProbeOnce(path).status, 'held'); + } finally { + child.stdin.end(); + const [status] = await closed; + closeSync(holder); + rmSync(root, { recursive: true }); + assert.equal(status, 0); + } +}); + +test('foreign proc namespace is refused, never guessed from observed PID', () => { + assertProcNamespace('42', 42); + assert.throws(() => assertProcNamespace('1', 42), /namespace_mismatch/); + assert.throws(() => assertProcNamespace('self', 42), /namespace_mismatch/); +}); + +test('proc stat uses final comm delimiter and start tick field, rejects zombies', () => { + const fields = ['S', ...Array(18).fill('0'), '9007199254740993']; + assert.equal(parseStartTicks(`42 (name ) with spaces) ${fields.join(' ')}`, 42), '9007199254740993'); + assert.throws(() => parseStartTicks(`42 (name) Z ${Array(19).fill('0').join(' ')}`, 42)); + assert.throws(() => parseStartTicks('43 (wrong pid) S', 42)); +}); + +test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { skip: process.platform !== 'linux' }, (t) => { + const root = mkdtempSync(join(tmpdir(), 'codex-attribution-test-')); + const path = join(root, 'thread.lock'); + writeFileSync(path, ''); + const fd = openSync(path, 'r'); + try { + flockSync(fd, 'exnb'); + const prepared = prepareLinuxParticipation(process.pid, path); + // Proc files on unsupported CI filesystems refuse rather than guessing. + let evidence; + try { evidence = verifyLinuxParticipation(path, prepared); } + catch (error) { + if (error.message === 'linux_filesystem_unsupported' && !process.env.CI) { t.skip('actual filesystem mapping unsupported; positive evidence unverified'); return; } + throw error; + } + assert.equal(evidence.pid, process.pid); + assert.equal(evidence.descriptor, String(fd)); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import {prepareLinuxParticipation,verifyLinuxParticipation} from ${JSON.stringify(new URL('../dist/linux-attribution.js', import.meta.url).href)}; + import process from 'node:process'; + console.log(JSON.stringify(verifyLinuxParticipation(process.argv[1],prepareLinuxParticipation(process.pid,process.argv[1])))); + `, path], { stdio: ['ignore', 'pipe', 'pipe', fd], encoding: 'utf8', timeout: 5000 }); + assert.equal(child.status, 0, child.stderr); + const inherited = JSON.parse(child.stdout); + assert.equal(inherited.descriptor, '3'); + assert.notEqual(inherited.pid, process.pid); + const nonholder = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import {prepareLinuxParticipation,verifyLinuxParticipation} from ${JSON.stringify(new URL('../dist/linux-attribution.js', import.meta.url).href)}; + import {openSync,closeSync} from 'node:fs'; + import process from 'node:process'; + const fd=openSync(process.argv[1],'r'); + try { + try { verifyLinuxParticipation(process.argv[1],prepareLinuxParticipation(process.pid,process.argv[1])); process.exitCode=1; } + catch(error) { console.log(error.message); } + } finally {closeSync(fd);} + `, path], { encoding: 'utf8', timeout: 5000 }); + assert.equal(nonholder.status, 0, nonholder.stderr); + assert.match(nonholder.stdout, /linux_participation_unproven/); + const competing = openSync(path, 'r'); + try { assert.throws(() => flockSync(competing, 'exnb')); } finally { closeSync(competing); } + assert.equal(readFileSync(path, 'utf8'), ''); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, startTicks: '0' })); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, commandFingerprint: '0'.repeat(64) }), /identity_changed/); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, mountNamespace: 'mnt:[0]' }), /identity_changed/); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, descriptors: [] }), /identity_changed/); + flockSync(fd, 'un'); + assert.throws(() => verifyLinuxParticipation(path, prepareLinuxParticipation(process.pid, path)), /linux_participation_unproven/); + } finally { closeSync(fd); rmSync(root, { recursive: true }); } +}); diff --git a/test/native-coordination.test.mjs b/test/native-coordination.test.mjs index 3a3480f..eda3b23 100644 --- a/test/native-coordination.test.mjs +++ b/test/native-coordination.test.mjs @@ -354,7 +354,8 @@ for (const mode of ["verify", "busy"]) { assert.equal(result.code, 0, result.stdout); assert.equal(parsed.outcome, "unlocked"); assert.deepEqual(JSON.parse(readFileSync(marker, "utf8")), { - attempts: 5, guardReleased: true, guardClosed: true, microtaskRan: false, + attempts: process.platform === "linux" ? 9 : 5, + guardReleased: true, guardClosed: true, microtaskRan: false, }); } }); diff --git a/test/policy.test.mjs b/test/policy.test.mjs index c7b14ba..4b8a1d9 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -86,6 +86,7 @@ function context(overrides = {}) { return { currentProcessFamily: new Set([100, 101]), currentUid: process.getuid?.() ?? 501, + platform: "darwin", ...overrides, }; } @@ -99,6 +100,20 @@ test("authorizes complete deterministic evidence without process fixtures", () = }); }); +test("Linux policy requires positive participation tied to owner instance", () => { + const value = evidence(); + const linux = context({ platform: "linux" }); + assert.equal(evaluateSafety(value, linux).safeToUnlock, false); + value.owner.startTicks = "123"; + value.lock.probe.participation = { pid: value.owner.pid, startTicks: "123", descriptor: "7" }; + assert.equal(evaluateSafety(value, linux).safeToUnlock, true); + value.lock.probe.participation.pid += 1; + assert.equal(evaluateSafety(value, linux).safeToUnlock, false); + value.lock.probe.participation.pid = value.owner.pid; + value.lock.probe.participation.startTicks = "124"; + assert.equal(evaluateSafety(value, linux).safeToUnlock, false); +}); + test("refuses shared owners and current-process-family owners", () => { const value = evidence(); value.owner.isSharedService = true; diff --git a/test/signal-evidence.test.mjs b/test/signal-evidence.test.mjs index 2688ded..417f7dd 100644 --- a/test/signal-evidence.test.mjs +++ b/test/signal-evidence.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import fs from "node:fs"; -import { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises"; +import { link, mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises"; import { syncBuiltinESMExports } from "node:module"; import { join, resolve } from "node:path"; import process from "node:process"; @@ -157,6 +157,69 @@ test("late evidence preserves an unchanged owner control", { timeout: 30_000 }, assert.equal(validate(result), true, JSON.stringify(validate.errors)); }); +test("late evidence refuses a hard link added during the last process sample", { timeout: 30_000 }, async (t) => { + const value = await signalFixture(t); + const result = await unlockWithLease(value, { + processStartTime: async (pid) => { + const observation = await processStartTime(pid); + await link(value.lockPath, join(value.codexHome, "late-lock-alias")); + return observation; + }, + }); + assertRefusedUntouched(value, result); + assert.ok(result.reasons.includes("lock_metadata_changed_before_signal")); +}); + +test("thread probe refuses metadata replacement while its descriptor is open", async () => { + const { mkdtemp, rm, writeFile } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const root = await mkdtemp(join(tmpdir(), "codex-lock-metadata-")); + try { + const lock = join(root, "thread.lock"); + await writeFile(lock, ""); + await writeFile(join(root, ".coordination.lock"), ""); + const result = guardedProbeOnce(lock, undefined, { + afterThreadAcquired: () => fs.linkSync(lock, join(root, "alias")), + }); + assert.equal(result.status, "unknown"); + assert.match(result.error, /changed/); + assert.equal(await readFile(lock, "utf8"), ""); + } finally { + await rm(root, { recursive: true }); + } +}); + +test("Linux final fdinfo denial refuses the full unlock path without signaling", { + skip: process.platform !== "linux", timeout: 30_000, +}, async (t) => { + const value = await signalFixture(t); + const originalOpen = fs.openSync; + let injected = false; + let result; + try { + result = await unlockWithLease(value, { + processStartTime: async (pid) => { + const observation = await processStartTime(pid); + fs.openSync = (path, ...args) => { + if (typeof path === "string" && path.startsWith(`/proc/${pid}/fdinfo/`)) { + injected = true; + throw Object.assign(new Error("injected fdinfo denial"), { code: "EACCES" }); + } + return originalOpen(path, ...args); + }; + syncBuiltinESMExports(); + return observation; + }, + }); + } finally { + fs.openSync = originalOpen; + syncBuiltinESMExports(); + } + assert.equal(injected, true); + assertRefusedUntouched(value, result); + assert.ok(result.reasons.includes("lock_changed_before_signal")); +}); + for (const [label, dependency, reason] of [ ["an empty owner lock set", { lockFilesOpenedByProcess: async () => ({ paths: [] }) }, "owner_lock_set_changed_before_signal"], ["unknown owner lock set", { lockFilesOpenedByProcess: async () => ({ paths: [], error: "injected lsof failure" }) }, "owner_lock_set_unknown_before_signal:"],