Skip to content
Closed
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
4 changes: 4 additions & 0 deletions scripts/test-layout/layout-file-identity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"file-identity.test.ts": "lib",
"file-identity-callers.test.ts": "lib"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"],
"explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"],
"explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows",
"stop-restore-history-ownership.test.ts": "service",
"cli-stop-refusal-transport.test.ts": "cli",
Expand Down
6 changes: 3 additions & 3 deletions src/adapters/command-code-project-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,15 @@ function truncateUtf8(text: string, capBytes: number): string {
async function openedFileIsConfined(
handle: Awaited<ReturnType<typeof open>>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope,
): Promise<boolean> {
const opened = await withinDeadline(() => handle.stat(), deadline, scope);
const opened = await withinDeadline(() => handle.stat({ bigint: true }), deadline, scope);
if (!opened.isFile()) return false;
const current = await withinDeadline(() => lstat(path), deadline, scope);
const current = await withinDeadline(() => lstat(path, { bigint: true }), deadline, scope);
if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false;
const resolved = await withinDeadline(() => realpath(path), deadline, scope);
// The input path was canonical before open. A changed intermediate symlink changes this
// result even though O_NOFOLLOW protects only the final component on macOS and Linux.
if (!isContainedCanonicalPath(cwdCanonical, resolved) || resolved !== path) return false;
const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope);
const resolvedInfo = await withinDeadline(() => lstat(resolved, { bigint: true }), deadline, scope);
return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino;
}

Expand Down
16 changes: 8 additions & 8 deletions src/claude/desktop-remote-store-io.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs";
import { chmodSync, lstatSync, mkdirSync, realpathSync, unlinkSync, type BigIntStats } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { atomicWriteFile, getConfigDir } from "../config";
import { hardenSecretDir } from "../lib/windows-secret-acl";
Expand All @@ -8,7 +8,7 @@ import { DesktopStoreError, digest } from "./desktop-remote-store-state";
export const MAX_DESKTOP_METADATA_ENTRIES = 256;

export interface JsonFile { value: Record<string, unknown>; hash: string; identity: string }
const identity = (s: NonNullable<ReturnType<typeof lstatSync>>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`;
const identity = (s: BigIntStats): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}`;
function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
export function canonicalDirectory(path: string): string {
const absolute = resolve(path);
Expand All @@ -33,20 +33,20 @@ export class StoreIO {
private bytes = 0;
private readonly seenSizes = new Map<string, number>();
read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null {
let stat: ReturnType<typeof lstatSync>;
try { stat = lstatSync(path); }
let stat: BigIntStats;
try { stat = lstatSync(path, { bigint: true }); }
catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); }
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes
|| (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe");
|| (privateFile && process.platform !== "win32" && (stat.mode & 0o077n) !== 0n)) throw new DesktopStoreError("unsafe");
const counted = this.seenSizes.get(path) ?? 0;
this.bytes += Math.max(0, stat.size - counted);
this.seenSizes.set(path, Math.max(counted, stat.size));
this.bytes += Math.max(0, Number(stat.size) - counted);
this.seenSizes.set(path, Math.max(counted, Number(stat.size)));
if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe");
try {
// The shared verified regular-file read keeps a swapped FIFO from parking this
// open before the post-read identity recheck below can run.
const bytes = readDesktopLibraryBytes(path, Math.min(maxBytes, MAX_DESKTOP_PROFILE_FILE_BYTES));
if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
if (bytes.byteLength > maxBytes || identity(lstatSync(path, { bigint: true })) !== identity(stat)) throw new DesktopStoreError("conflict");
const value: unknown = JSON.parse(bytes.toString("utf8"));
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) };
Expand Down
32 changes: 16 additions & 16 deletions src/claude/intercept/proxy-auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomBytes } from "node:crypto";
import {
closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync,
openSync, readSync, unlinkSync, writeFileSync, type Stats,
openSync, readSync, unlinkSync, writeFileSync, type BigIntStats,
} from "node:fs";
import { join } from "node:path";
import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath } from "../../lib/windows-secret-acl";
Expand All @@ -20,33 +20,33 @@ function invalidCredential(): Error {
return new Error("Claude intercept credential is not a safe, valid owner-controlled file");
}

function assertOwnedNode(stat: Stats, directory = false): void {
function assertOwnedNode(stat: BigIntStats, directory = false): void {
if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile())) throw invalidCredential();
if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== process.geteuid()) {
if (process.platform !== "win32" && typeof process.geteuid === "function" && stat.uid !== BigInt(process.geteuid())) {
throw invalidCredential();
}
}

function assertSameNode(path: string, expected: Stats, directory = false): void {
const current = lstatSync(path);
function assertSameNode(path: string, expected: BigIntStats, directory = false): void {
const current = lstatSync(path, { bigint: true });
assertOwnedNode(current, directory);
if (current.dev !== expected.dev || current.ino !== expected.ino) throw invalidCredential();
}

/** Read only through the validated descriptor, with a fixed maximum allocation and no links. */
function readPinnedToken(configDir: string, harden: boolean): string {
const dir = join(configDir, "claude-intercept");
const directory = lstatSync(dir);
const directory = lstatSync(dir, { bigint: true });
assertOwnedNode(directory, true);
if (process.platform !== "win32" && (directory.mode & 0o077) !== 0) throw invalidCredential();
if (process.platform !== "win32" && (directory.mode & 0o077n) !== 0n) throw invalidCredential();
const path = claudeInterceptProxyTokenPath(configDir);
const entry = lstatSync(path);
const entry = lstatSync(path, { bigint: true });
assertOwnedNode(entry);
if (entry.size < TOKEN_LENGTH || entry.size > TOKEN_MAX_BYTES) throw invalidCredential();
// NONBLOCK prevents a raced-in FIFO from blocking before fstat can reject it.
const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
try {
const opened = fstatSync(fd);
const opened = fstatSync(fd, { bigint: true });
assertOwnedNode(opened);
if (opened.dev !== entry.dev || opened.ino !== entry.ino
|| opened.size < TOKEN_LENGTH || opened.size > TOKEN_MAX_BYTES) throw invalidCredential();
Expand All @@ -62,16 +62,16 @@ function readPinnedToken(configDir: string, harden: boolean): string {
const ending = contents.slice(TOKEN_LENGTH);
if (token.length !== TOKEN_LENGTH || !/^[A-Za-z0-9_-]+$/.test(token)
|| !["", "\n", "\r\n"].includes(ending)) throw invalidCredential();
const afterRead = fstatSync(fd);
if (length !== afterRead.size || afterRead.mtimeMs !== opened.mtimeMs) throw invalidCredential();
const afterRead = fstatSync(fd, { bigint: true });
if (BigInt(length) !== afterRead.size || afterRead.mtimeNs !== opened.mtimeNs) throw invalidCredential();
assertSameNode(path, opened);
assertSameNode(dir, directory, true);
if (harden) {
if (process.platform === "win32") hardenSecretPath(path, { required: true });
else fchmodSync(fd, 0o600); // Never chmod a followed/replaced pathname.
assertSameNode(path, opened);
assertSameNode(dir, directory, true);
} else if (process.platform !== "win32" && (afterRead.mode & 0o077) !== 0) {
} else if (process.platform !== "win32" && (afterRead.mode & 0o077n) !== 0n) {
throw invalidCredential();
}
return token;
Expand All @@ -89,22 +89,22 @@ export function readClaudeInterceptProxyToken(configDir: string): string | null
}
}

function ensurePrivateDirectory(configDir: string): Stats {
function ensurePrivateDirectory(configDir: string): BigIntStats {
const dir = join(configDir, "claude-intercept");
mkdirSync(configDir, { recursive: true, mode: 0o700 });
try {
mkdirSync(dir, { mode: 0o700 });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
const entry = lstatSync(dir);
const entry = lstatSync(dir, { bigint: true });
assertOwnedNode(entry, true);
if (process.platform === "win32") {
hardenSecretDir(dir, { required: true });
} else {
const fd = openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW);
try {
const opened = fstatSync(fd);
const opened = fstatSync(fd, { bigint: true });
assertOwnedNode(opened, true);
if (entry.dev !== opened.dev || entry.ino !== opened.ino) throw invalidCredential();
fchmodSync(fd, 0o700);
Expand All @@ -130,7 +130,7 @@ export function ensureClaudeInterceptProxyToken(configDir: string): string {
const token = randomBytes(32).toString("base64url");
const temp = join(dir, `.${TOKEN_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, 0o600);
const created = fstatSync(fd);
const created = fstatSync(fd, { bigint: true });
try {
writeFileSync(fd, `${token}\n`, { encoding: "utf8" });
if (process.platform !== "win32") fchmodSync(fd, 0o600);
Expand Down
36 changes: 18 additions & 18 deletions src/codex/catalog/write-audit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync,
type Stats,
type BigIntStats,
} from "node:fs";
import { join } from "node:path";
import { redactUserPath } from "../../lib/redact";
Expand Down Expand Up @@ -40,15 +40,15 @@ function auditLine(event: CatalogWriteAuditEvent): Buffer {
})}\n`);
}

function regularPrivateFile(stat: Stats): boolean {
function regularPrivateFile(stat: BigIntStats): boolean {
// POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening.
return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1
&& stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600;
return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1n
&& stat.uid === BigInt(process.getuid?.() ?? -1) && (stat.mode & 0o777n) === 0o600n;
}

function sameFile(left: Stats, right: Stats): boolean {
function sameFile(left: BigIntStats, right: BigIntStats): boolean {
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
&& left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
}

/** Read at most the tail budget plus one byte to locate a complete first record. */
Expand Down Expand Up @@ -116,8 +116,8 @@ export function appendCatalogWriteAudit(
if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped";
const path = codexCatalogAuditPath(codexHome);
const windows = windowsSecretAclApplies();
let before: Stats | undefined;
try { before = lstatSync(path); }
let before: BigIntStats | undefined;
try { before = lstatSync(path, { bigint: true }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped";
}
Expand All @@ -126,29 +126,29 @@ export function appendCatalogWriteAudit(
const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
| (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL);
fd = openSync(path, flags, 0o600);
let opened = fstatSync(fd);
let opened = fstatSync(fd, { bigint: true });
if (windows) {
// Existing files have already been refused: no read-only NTFS privacy verifier exists.
if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0
|| !sameFile(opened, lstatSync(path))) return "skipped";
if (!opened.isFile() || opened.nlink !== 1n || opened.size !== 0n
|| !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped";
if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped";
const hardened = fstatSync(fd);
const hardened = fstatSync(fd, { bigint: true });
// ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline.
if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0
if (!hardened.isFile() || hardened.nlink !== 1n || hardened.size !== 0n
|| opened.dev !== hardened.dev || opened.ino !== hardened.ino
|| !sameFile(hardened, lstatSync(path))) return "skipped";
|| !sameFile(hardened, lstatSync(path, { bigint: true }))) return "skipped";
opened = hardened;
} else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped";
if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped";
const tail = readTail(fd, opened.size);
if (!options.create && opened.size + BigInt(line.length) > CATALOG_AUDIT_MAX_BYTES) return "skipped";
const tail = readTail(fd, Number(opened.size));
const parsed = completeRecords(tail);
if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped";
if (!sameFile(opened, fstatSync(fd, { bigint: true })) || !sameFile(opened, lstatSync(path, { bigint: true }))) return "skipped";
if (!options.create) {
if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped";
writeAll(fd, line, null);
} else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length
&& parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) {
writeAll(fd, line, opened.size);
writeAll(fd, line, Number(opened.size));
} else {
const retained: Buffer[] = [line];
let bytes = line.length;
Expand Down
12 changes: 6 additions & 6 deletions src/config/initialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,16 @@ function hardenInitialConfig(fd: number, temp: string, target: string): void {
}
}

function identifiesDescriptor(fd: number, path: string): boolean {
const opened = fstatSync(fd);
const entry = lstatSync(path);
function identifiesDescriptor(fd: number, path: string, privateMode = false): boolean {
const opened = fstatSync(fd, { bigint: true });
const entry = lstatSync(path, { bigint: true });
return opened.isFile() && entry.isFile()
&& opened.dev === entry.dev && opened.ino === entry.ino;
&& opened.dev === entry.dev && opened.ino === entry.ino
&& (!privateMode || process.platform === "win32" || (opened.mode & 0o777n) === 0o600n);
}

function verifyPrivateTemp(fd: number, temp: string): void {
if (!identifiesDescriptor(fd, temp)
|| (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) {
if (!identifiesDescriptor(fd, temp, true)) {
throw new Error("Initial config temporary file identity or permissions changed.");
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/aside-profile-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function storeGuard(anchor: string, target: string): () => void {
const identities = new Map<string, string>();
const inspect = (path: string, directory: boolean): boolean => {
try {
const stat = lstatSync(path);
const stat = lstatSync(path, { bigint: true });
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe();
if (directory) {
const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`;
Expand Down
Loading
Loading