From 5076baba09d3914fa611f5bce6e2f1adcc9e5329 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 19:21:22 +0900 Subject: [PATCH 1/7] fix: require Linux descriptor-level flock participation --- docs/linux-lock-attribution.md | 37 ++++++++ package.json | 1 + schemas/codex-unlock-v1.schema.json | 10 ++ scripts/package-smoke.mjs | 1 + src/inspection.ts | 21 ++++- src/linux-attribution.ts | 137 ++++++++++++++++++++++++++++ src/native-coordination.ts | 9 +- src/process.ts | 11 +++ src/types.ts | 2 + src/unlock.ts | 13 ++- test/linux-attribution.test.mjs | 70 ++++++++++++++ 11 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 docs/linux-lock-attribution.md create mode 100644 src/linux-attribution.ts create mode 100644 test/linux-attribution.test.mjs diff --git a/docs/linux-lock-attribution.md b/docs/linux-lock-attribution.md new file mode 100644 index 0000000..d35a3ea --- /dev/null +++ b/docs/linux-lock-attribution.md @@ -0,0 +1,37 @@ +# 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. + +Descriptor names are prepared outside native coordination. 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. +No child process, await or signal runs while coordination is held. + +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. +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 +`lock.probe.participation` (`pid`, `startTicks`, `descriptor`). Consumers must +not interpret participation as independent permission to terminate a process. 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..e533ade 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,7 @@ "ppid": { "type": ["integer", "null"], "minimum": 0 }, "uid": { "type": ["integer", "null"], "minimum": 0 }, "startTime": { "type": ["string", "null"] }, + "startTicks": { "type": "string", "pattern": "^[0-9]+$" }, "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..784c5b4 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,7 @@ 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)) ); } @@ -129,6 +131,23 @@ 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] of [[probeBefore, openersBefore], [probeAfter, openersAfter]] as const) { + if (process.platform !== "linux" || probe.status !== "held") continue; + try { + if (openers.error || openers.processes.length !== 1) throw new Error("linux_candidate_ambiguous"); + const candidate = openers.processes[0]; + const prepared = prepareLinuxParticipation(candidate.pid); + if (!candidate.startTicks || prepared.startTicks !== candidate.startTicks) throw new Error("linux_identity_changed"); + const result = guardedProbeOnce(lockPath, observeLockFile(lockPath), 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..201bf6f --- /dev/null +++ b/src/linux-attribution.ts @@ -0,0 +1,137 @@ +import { closeSync, constants, openSync, opendirSync, readSync, readlinkSync, statSync, statfsSync } from "node:fs"; +import { performance } from "node:perf_hooks"; + +// 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[]; startTicks: string; uid: bigint } + +function checkDeadline(deadline: number): void { + if (performance.now() > deadline) throw new Error("linux_attribution_budget"); +} + +function boundedRead(path: string, deadline = performance.now() + BUDGET_MS): string { + 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).toString("utf8"); + } finally { closeSync(fd); } +} + +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"); + rows.push({ major: BigInt(`0x${match[1]}`), minor: BigInt(`0x${match[2]}`), inode: BigInt(match[3]) }); + } + 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 prepareLinuxParticipation(pid: number): 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"); + return { pid, uid: directory.uid, startTicks: parseStartTicks(boundedRead(`/proc/${pid}/stat`, deadline), pid), descriptors: descriptors(pid, deadline) }; +} + +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 || + parseStartTicks(boundedRead(`/proc/${prepared.pid}/stat`, deadline), prepared.pid) !== prepared.startTicks || + 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.descriptors) { + checkBudget(); + const path = `/proc/${prepared.pid}/fd/${descriptor}`; + const before = statSync(path, { bigint: true }); + if (before.dev !== target.dev || before.ino !== target.ino) continue; + const rows = parseFlockRows(boundedRead(`/proc/${prepared.pid}/fdinfo/${descriptor}`, deadline)); + const after = statSync(path, { bigint: true }); + if (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"); + return { pid: prepared.pid, startTicks: prepared.startTicks, descriptor: matches[0] }; +} diff --git a/src/native-coordination.ts b/src/native-coordination.ts index 5f94bc6..5c2faf1 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,12 @@ export function guardedProbeOnce( ...probeThreadLockOnce(lockPath, observation, hooksForTesting?.afterThreadAcquired), guard: { status: "acquired", attempts: 1 }, }; + if (result.status === "held" && attributeHeld) { + result.participation = attributeHeld(); + if (!sameSnapshot(observation.snapshot, observeLockFile(lockPath).snapshot)) { + throw new Error("linux_target_changed"); + } + } } } const after = lstatSync(path, { bigint: true }); diff --git a/src/process.ts b/src/process.ts index e191b50..a2d1c0d 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 { linuxStartTicks } from "./linux-attribution.js"; import type { ProcessInfo, ProcessStartObservation } from "./types.js"; import { @@ -503,6 +504,10 @@ export function reconcileArguments( export async function inspectProcess( candidate: LsofProcess, ): Promise { + let startTicks: string | undefined; + if (process.platform === "linux") { + try { startTicks = linuxStartTicks(candidate.pid); } catch { /* Fail closed below. */ } + } const [ppidField, uidField, startTime, ttyField, commandField, argumentsField, cwd, commandLine] = await Promise.all([ psField(candidate.pid, "ppid"), @@ -529,6 +534,11 @@ export async function inspectProcess( /(^|\/)codex(?:\s|$)/i.test(args ?? ""); const isSharedService = argumentEvidence.isSharedService; const errors: string[] = []; + if (process.platform === "linux") { + try { + if (!startTicks || linuxStartTicks(candidate.pid) !== startTicks) throw new Error("changed"); + } catch { errors.push("linux_process_instance_unverified"); startTicks = 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 +547,7 @@ export async function inspectProcess( else if (args === null) errors.push(`arguments_${argumentsField.status}${argumentsField.error ? `:${argumentsField.error}` : ""}`); return { + ...(startTicks ? { startTicks } : {}), pid: candidate.pid, ppid, uid, diff --git a/src/types.ts b/src/types.ts index 9c3f9f1..8605e5e 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,7 @@ export interface ProcessStartObservation { } export interface ProcessInfo { + startTicks?: string; pid: number; ppid: number | null; uid: number | null; diff --git a/src/unlock.ts b/src/unlock.ts index 1dcd05b..fbdd2d2 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,7 @@ 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.command === after.command && before.arguments === after.arguments && before.identityComplete === after.identityComplete && @@ -474,7 +476,16 @@ export async function unlockInspectedThread( const preSignalLock = observeLockFile(inspection.lock.path); // 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 { + const prepared = process.platform === "linux" ? prepareLinuxParticipation(owner.pid) : undefined; + if (prepared && (!owner.startTicks || prepared.startTicks !== owner.startTicks || + prepared.startTicks !== finalInspection.owner?.startTicks)) 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"); } diff --git a/test/linux-attribution.test.mjs b/test/linux-attribution.test.mjs new file mode 100644 index 0000000..27c8317 --- /dev/null +++ b/test/linux-attribution.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { URL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { mkdtempSync, writeFileSync, openSync, closeSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { flockSync } from 'fs-ext-extra-prebuilt'; +import { assertProcNamespace, parseFlockRows, parseStartTicks, prepareLinuxParticipation, verifyLinuxParticipation } from '../dist/linux-attribution.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.equal(parseFlockRows(`lock: 1: FLOCK ADVISORY WRITE 1 00:02:${large} 0 EOF`)[0].inode.toString(), large); +}); + +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' }, () => { + 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); + // 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') 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)))); + `, 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); + assert.equal(readFileSync(path, 'utf8'), ''); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, startTicks: '0' })); + assert.throws(() => verifyLinuxParticipation(path, { ...prepared, descriptors: [] }), /identity_changed/); + flockSync(fd, 'un'); + assert.throws(() => verifyLinuxParticipation(path, prepareLinuxParticipation(process.pid)), /linux_participation_unproven/); + } finally { closeSync(fd); rmSync(root, { recursive: true }); } +}); From 6362fdd6ad0bf014dd1946d5b79bfaff2b4683dc Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 19:24:53 +0900 Subject: [PATCH 2/7] test: bind Linux attribution to original inode and negative evidence --- src/inspection.ts | 9 +++- src/linux-attribution.ts | 6 ++- test/linux-attribution.test.mjs | 87 +++++++++++++++++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/inspection.ts b/src/inspection.ts index 784c5b4..6cae06b 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -133,14 +133,19 @@ export async function inspectThread( 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] of [[probeBefore, openersBefore], [probeAfter, openersAfter]] as const) { + for (const [probe, openers, observedLock] of [[probeBefore, openersBefore, lockBefore], [probeAfter, openersAfter, lockAfter]] as const) { if (process.platform !== "linux" || probe.status !== "held") continue; try { if (openers.error || openers.processes.length !== 1) throw new Error("linux_candidate_ambiguous"); const candidate = openers.processes[0]; const prepared = prepareLinuxParticipation(candidate.pid); if (!candidate.startTicks || prepared.startTicks !== candidate.startTicks) throw new Error("linux_identity_changed"); - const result = guardedProbeOnce(lockPath, observeLockFile(lockPath), undefined, + // 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 { diff --git a/src/linux-attribution.ts b/src/linux-attribution.ts index 201bf6f..ac81734 100644 --- a/src/linux-attribution.ts +++ b/src/linux-attribution.ts @@ -48,7 +48,11 @@ export function parseFlockRows(text: string): { major: bigint; minor: bigint; in 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"); - rows.push({ major: BigInt(`0x${match[1]}`), minor: BigInt(`0x${match[2]}`), inode: BigInt(match[3]) }); + 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; } diff --git a/test/linux-attribution.test.mjs b/test/linux-attribution.test.mjs index 27c8317..53e87d0 100644 --- a/test/linux-attribution.test.mjs +++ b/test/linux-attribution.test.mjs @@ -1,13 +1,16 @@ import assert from 'node:assert/strict'; import process from 'node:process'; import { URL } from 'node:url'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; import { test } from 'node:test'; -import { mkdtempSync, writeFileSync, openSync, closeSync, rmSync, readFileSync } from 'node:fs'; +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, 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'), @@ -19,7 +22,67 @@ test('granted flock rows preserve inode precision and ignore acquisition PID', ( 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.equal(parseFlockRows(`lock: 1: FLOCK ADVISORY WRITE 1 00:02:${large} 0 EOF`)[0].inode.toString(), large); + assert.throws(() => parseFlockRows(`lock: 1: FLOCK ADVISORY WRITE 1 00:02:${large} 0 EOF`)); +}); + +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); + 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(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', () => { @@ -35,7 +98,7 @@ test('proc stat uses final comm delimiter and start tick field, rejects zombies' assert.throws(() => parseStartTicks('43 (wrong pid) S', 42)); }); -test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { skip: process.platform !== 'linux' }, () => { +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, ''); @@ -47,7 +110,7 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s let evidence; try { evidence = verifyLinuxParticipation(path, prepared); } catch (error) { - if (error.message === 'linux_filesystem_unsupported') return; + if (error.message === 'linux_filesystem_unsupported') { t.skip('actual filesystem mapping unsupported; positive evidence unverified'); return; } throw error; } assert.equal(evidence.pid, process.pid); @@ -61,6 +124,20 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s 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.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, descriptors: [] }), /identity_changed/); From 7ba9ae81538e4ef28ee45dd99b378250acbfbd32 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 19:41:49 +0900 Subject: [PATCH 3/7] fix: bound Linux attribution to candidate descriptors and execution state --- docs/linux-lock-attribution.md | 20 ++++++++- schemas/codex-unlock-v1.schema.json | 1 + src/inspection.ts | 8 ++-- src/linux-attribution.ts | 68 +++++++++++++++++++++++++---- src/native-coordination.ts | 6 ++- src/policy.ts | 6 +++ src/process.ts | 16 +++++-- src/types.ts | 1 + test/linux-attribution.test.mjs | 37 +++++++++++++--- test/policy.test.mjs | 15 +++++++ 10 files changed, 152 insertions(+), 26 deletions(-) diff --git a/docs/linux-lock-attribution.md b/docs/linux-lock-attribution.md index d35a3ea..da01da7 100644 --- a/docs/linux-lock-attribution.md +++ b/docs/linux-lock-attribution.md @@ -8,13 +8,24 @@ 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. -Descriptor names are prepared outside native coordination. Under coordination, +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; unrelated FD targets are not +statted while holding coordination (they could block on FUSE or NFS). 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. 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 @@ -25,6 +36,10 @@ 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 @@ -33,5 +48,6 @@ 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 -`lock.probe.participation` (`pid`, `startTicks`, `descriptor`). Consumers must +`owner.commandFingerprint` and `lock.probe.participation` +(`pid`, `startTicks`, `descriptor`). Consumers must not interpret participation as independent permission to terminate a process. diff --git a/schemas/codex-unlock-v1.schema.json b/schemas/codex-unlock-v1.schema.json index e533ade..1cfdc5b 100644 --- a/schemas/codex-unlock-v1.schema.json +++ b/schemas/codex-unlock-v1.schema.json @@ -75,6 +75,7 @@ "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/src/inspection.ts b/src/inspection.ts index 6cae06b..c9a39dc 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -38,7 +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)) + && (process.platform !== "linux" || (before[0].startTicks !== undefined && before[0].startTicks === after[0].startTicks && + before[0].commandFingerprint !== undefined && before[0].commandFingerprint === after[0].commandFingerprint)) ); } @@ -138,8 +139,9 @@ export async function inspectThread( try { if (openers.error || openers.processes.length !== 1) throw new Error("linux_candidate_ambiguous"); const candidate = openers.processes[0]; - const prepared = prepareLinuxParticipation(candidate.pid); - if (!candidate.startTicks || prepared.startTicks !== candidate.startTicks) throw new Error("linux_identity_changed"); + 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); diff --git a/src/linux-attribution.ts b/src/linux-attribution.ts index ac81734..e87925d 100644 --- a/src/linux-attribution.ts +++ b/src/linux-attribution.ts @@ -1,5 +1,6 @@ -import { closeSync, constants, openSync, opendirSync, readSync, readlinkSync, statSync, statfsSync } from "node:fs"; +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. @@ -9,13 +10,16 @@ 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[]; startTicks: string; uid: bigint } +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 boundedRead(path: string, deadline = performance.now() + BUDGET_MS): string { +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); @@ -27,10 +31,27 @@ function boundedRead(path: string, deadline = performance.now() + BUDGET_MS): st used += count; } if (used > BYTE_LIMIT) throw new Error("linux_attribution_truncated"); - return buffer.subarray(0, used).toString("utf8"); + 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"); @@ -86,14 +107,38 @@ function descriptors(pid: number, deadline: number): string[] { return entries.sort((a, b) => Number(a) - Number(b)); } -export function prepareLinuxParticipation(pid: number): LinuxPreparation { +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"); - return { pid, uid: directory.uid, startTicks: parseStartTicks(boundedRead(`/proc/${pid}/stat`, deadline), pid), descriptors: descriptors(pid, deadline) }; + 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 { @@ -112,21 +157,25 @@ export function verifyLinuxParticipation(lockPath: string, prepared: LinuxPrepar 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.descriptors) { + 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) continue; + 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 (before.dev !== after.dev || before.ino !== after.ino) throw new Error("linux_descriptor_changed"); + 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"); @@ -137,5 +186,6 @@ export function verifyLinuxParticipation(lockPath: string, prepared: LinuxPrepar 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/native-coordination.ts b/src/native-coordination.ts index 5c2faf1..f503146 100644 --- a/src/native-coordination.ts +++ b/src/native-coordination.ts @@ -101,7 +101,11 @@ export function guardedProbeOnce( guard: { status: "acquired", attempts: 1 }, }; if (result.status === "held" && attributeHeld) { - result.participation = 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"); } 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 a2d1c0d..0cda4f4 100644 --- a/src/process.ts +++ b/src/process.ts @@ -1,7 +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 { linuxStartTicks } from "./linux-attribution.js"; +import { linuxCommandFingerprint, linuxStartTicks } from "./linux-attribution.js"; import type { ProcessInfo, ProcessStartObservation } from "./types.js"; import { @@ -505,8 +505,12 @@ export async function inspectProcess( candidate: LsofProcess, ): Promise { let startTicks: string | undefined; + let commandFingerprint: string | undefined; if (process.platform === "linux") { - try { startTicks = linuxStartTicks(candidate.pid); } catch { /* Fail closed below. */ } + 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([ @@ -536,8 +540,11 @@ export async function inspectProcess( const errors: string[] = []; if (process.platform === "linux") { try { - if (!startTicks || linuxStartTicks(candidate.pid) !== startTicks) throw new Error("changed"); - } catch { errors.push("linux_process_instance_unverified"); startTicks = undefined; } + 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}` : ""}`); @@ -548,6 +555,7 @@ export async function inspectProcess( return { ...(startTicks ? { startTicks } : {}), + ...(commandFingerprint ? { commandFingerprint } : {}), pid: candidate.pid, ppid, uid, diff --git a/src/types.ts b/src/types.ts index 8605e5e..8338cbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,7 @@ export interface ProcessStartObservation { export interface ProcessInfo { startTicks?: string; + commandFingerprint?: string; pid: number; ppid: number | null; uid: number | null; diff --git a/test/linux-attribution.test.mjs b/test/linux-attribution.test.mjs index 53e87d0..b784c81 100644 --- a/test/linux-attribution.test.mjs +++ b/test/linux-attribution.test.mjs @@ -3,12 +3,13 @@ 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, parseFlockRows, parseStartTicks, prepareLinuxParticipation, verifyLinuxParticipation } from '../dist/linux-attribution.js'; +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'; @@ -25,6 +26,25 @@ test('granted flock rows preserve inode precision and ignore acquisition PID', ( 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'); @@ -65,7 +85,7 @@ test('real native guard refuses a nonlocking candidate while another description const closed = once(child, 'close'); try { await once(child.stdout, 'data'); - const prepared = prepareLinuxParticipation(child.pid); + const prepared = prepareLinuxParticipation(child.pid, path); let signals = 0; const original = process.kill; let result; @@ -74,6 +94,7 @@ test('real native guard refuses a nonlocking candidate while another description 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 { @@ -105,12 +126,12 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s const fd = openSync(path, 'r'); try { flockSync(fd, 'exnb'); - const prepared = prepareLinuxParticipation(process.pid); + 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') { t.skip('actual filesystem mapping unsupported; positive evidence unverified'); return; } + 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); @@ -118,7 +139,7 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s 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)))); + 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); @@ -130,7 +151,7 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s import process from 'node:process'; const fd=openSync(process.argv[1],'r'); try { - try { verifyLinuxParticipation(process.argv[1],prepareLinuxParticipation(process.pid)); process.exitCode=1; } + 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 }); @@ -140,8 +161,10 @@ test('Linux real FLOCK participates; open-but-unlocked descriptor does not', { s 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)), /linux_participation_unproven/); + assert.throws(() => verifyLinuxParticipation(path, prepareLinuxParticipation(process.pid, path)), /linux_participation_unproven/); } finally { closeSync(fd); rmSync(root, { recursive: true }); } }); 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; From e8902b3403750d29d815bed043b1c8b374dfb6bb Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 19:58:44 +0900 Subject: [PATCH 4/7] fix: revalidate lock metadata and execution evidence before signaling --- src/lock.ts | 11 ++++-- src/unlock.ts | 19 +++++++--- test/signal-evidence.test.mjs | 65 ++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 9 deletions(-) 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/unlock.ts b/src/unlock.ts index fbdd2d2..04fcdf0 100644 --- a/src/unlock.ts +++ b/src/unlock.ts @@ -58,7 +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)) && + (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 && @@ -474,13 +476,20 @@ 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. let preSignalProbe: LockProbe; try { - const prepared = process.platform === "linux" ? prepareLinuxParticipation(owner.pid) : undefined; + 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)) throw new Error("linux_identity_changed"); + 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 { @@ -489,10 +498,10 @@ export async function unlockInspectedThread( 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/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:"], From abf1c1a67d4861a1f37daf2f954b26985faf86db Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 20:19:29 +0900 Subject: [PATCH 5/7] docs: disclose Linux draft liveness and native access gates --- docs/linux-lock-attribution.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/linux-lock-attribution.md b/docs/linux-lock-attribution.md index da01da7..0a51a54 100644 --- a/docs/linux-lock-attribution.md +++ b/docs/linux-lock-attribution.md @@ -15,13 +15,19 @@ 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; unrelated FD targets are not -statted while holding coordination (they could block on FUSE or NFS). Under coordination, +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 @@ -51,3 +57,21 @@ 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. Depending on the caller's privileges and +procfs access policy, directory ownership checks or fdinfo/exe access can then +refuse a real native Codex process even though a same-user Node fixture passes. +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. From af514c58313ccf143f88926dbd5771d22c8a7726 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Tue, 13 Oct 2026 21:27:04 +0900 Subject: [PATCH 6/7] fix: preserve independent probe evidence on discovery failures --- src/inspection.ts | 4 +++- test/helpers/native-signal-boundary.mjs | 7 +++++-- test/native-coordination.test.mjs | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/inspection.ts b/src/inspection.ts index c9a39dc..25b9253 100644 --- a/src/inspection.ts +++ b/src/inspection.ts @@ -136,8 +136,10 @@ export async function inspectThread( // 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 { - if (openers.error || openers.processes.length !== 1) throw new Error("linux_candidate_ambiguous"); const candidate = openers.processes[0]; const prepared = prepareLinuxParticipation(candidate.pid, lockPath); if (!candidate.startTicks || prepared.startTicks !== candidate.startTicks || 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/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, }); } }); From b483cd587c62329243366e64aaaac477edd36adc Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Wed, 14 Oct 2026 11:27:24 +0900 Subject: [PATCH 7/7] docs: clarify native Codex dumpability evidence --- docs/linux-lock-attribution.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/linux-lock-attribution.md b/docs/linux-lock-attribution.md index 0a51a54..6b87d1a 100644 --- a/docs/linux-lock-attribution.md +++ b/docs/linux-lock-attribution.md @@ -62,9 +62,19 @@ not interpret participation as independent permission to terminate a process. 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. Depending on the caller's privileges and -procfs access policy, directory ownership checks or fdinfo/exe access can then -refuse a real native Codex process even though a same-user Node fixture passes. +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.