Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions docs/linux-lock-attribution.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions schemas/codex-unlock-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"] },
Expand Down
1 change: 1 addition & 0 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion src/inspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
);
}

Expand Down Expand Up @@ -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,
Expand Down
191 changes: 191 additions & 0 deletions src/linux-attribution.ts
Original file line number Diff line number Diff line change
@@ -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] };
}
Loading
Loading