Skip to content
Merged
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
17 changes: 10 additions & 7 deletions sdk/typescript/src/multiscan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,30 +360,32 @@ function notifyProgress(
}

async function ensureOutputDirectory(path: string): Promise<string> {
const metadata = await lstat(path).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
return undefined;
});
const metadata = await lstat(path, { bigint: true }).catch(
(error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
return undefined;
},
);
if (metadata?.isSymbolicLink()) {
throw new Error("Multiscan output directories must not be symbolic links.");
}
await mkdir(path, { recursive: true, mode: 0o700 });
const canonical = await realpath(path);
const directory = await lstat(canonical);
const directory = await lstat(canonical, { bigint: true });
if (
metadata !== undefined &&
(directory.dev !== metadata.dev || directory.ino !== metadata.ino)
) {
throw new Error("Multiscan output directories changed during preparation.");
}
if (process.platform === "win32") return canonical;
if ((directory.mode & 0o022) !== 0) {
if ((directory.mode & 0o022n) !== 0n) {
throw new Error(
"Multiscan output directories must not be group- or world-writable.",
);
}
const owner = process.geteuid?.();
if (owner !== undefined && directory.uid !== owner) {
if (owner !== undefined && directory.uid !== BigInt(owner)) {
throw new Error(
"Multiscan output directories must be owned by the current user.",
);
Expand Down Expand Up @@ -419,6 +421,7 @@ async function acquireLock(output: string): Promise<() => Promise<void>> {
await rm(stale, { recursive: true, force: true });
}
}
// Windows file IDs can exceed JavaScript's safe integer range.
const createdLock = await lstat(path, { bigint: true });
const owner = `${JSON.stringify({
pid: process.pid,
Expand Down
45 changes: 27 additions & 18 deletions sdk/typescript/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { execFile as execFileCallback, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants, existsSync, readdirSync, type Stats } from "node:fs";
import {
constants,
existsSync,
readdirSync,
type BigIntStats,
type Stats,
} from "node:fs";
import {
chmod,
cp,
Expand Down Expand Up @@ -167,7 +173,7 @@ export async function prepareCodexSecurityCredentialHome(
throw error;
}
if ((process.umask() & 0o700) !== 0) await chmod(path, 0o700);
const metadata = await lstat(path);
const metadata = await lstat(path, { bigint: true });
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new OutputDirectoryError(
`Codex Security credential home is not a directory: ${path}`,
Expand Down Expand Up @@ -202,17 +208,17 @@ export async function requireSecureCredentialHome(
options: {
platform?: NodeJS.Platform;
secureWindowsHome?: (path: string) => Promise<void>;
metadata?: Stats;
expectedDevice?: number;
expectedInode?: number;
metadata?: BigIntStats;
expectedDevice?: bigint;
expectedInode?: bigint;
validateWindowsAcl?: boolean;
} = {},
): Promise<Stats> {
): Promise<BigIntStats> {
const platform = options.platform ?? process.platform;
let metadata = options.metadata;
if (metadata === undefined) {
try {
metadata = await lstat(path);
metadata = await lstat(path, { bigint: true });
} catch (error) {
throw new OutputDirectoryError(
`Unable to inspect the Codex Security credential home: ${path}`,
Expand All @@ -227,7 +233,7 @@ export async function requireSecureCredentialHome(
}
const canonical = await realpath(path);
requireModelSafeOutputDir(canonical);
const canonicalMetadata = await lstat(canonical);
const canonicalMetadata = await lstat(canonical, { bigint: true });
if (
canonicalMetadata.dev !== metadata.dev ||
canonicalMetadata.ino !== metadata.ino
Expand All @@ -252,17 +258,19 @@ export async function requireSecureCredentialHome(
`Codex Security credential home was replaced: ${canonical}`,
);
}
if (platform === "win32") {
if (options.validateWindowsAcl !== false) {
await requirePrivateCredentialHome(metadata, canonical, {
if (platform !== "win32" || options.validateWindowsAcl !== false) {
await requirePrivateCredentialHome(
{ mode: Number(metadata.mode), uid: Number(metadata.uid) },
canonical,
{
platform,
secureWindowsHome: options.secureWindowsHome,
});
}
return metadata;
},
);
}
if (platform !== "win32") {
await requireSecureOutputAncestry(canonical);
}
await requirePrivateCredentialHome(metadata, canonical, { platform });
await requireSecureOutputAncestry(canonical);
return metadata;
}

Expand Down Expand Up @@ -2546,9 +2554,10 @@ async function isRegularFile(path: string): Promise<boolean> {

async function sameFile(left: string, right: string): Promise<boolean> {
try {
// NTFS file IDs can exceed JavaScript's safe integer range.
const [leftMetadata, rightMetadata] = await Promise.all([
stat(left),
stat(right),
stat(left, { bigint: true }),
stat(right, { bigint: true }),
]);
return (
leftMetadata.dev === rightMetadata.dev &&
Expand Down
52 changes: 52 additions & 0 deletions sdk/typescript/tests-ts/multiscan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,58 @@ describe("multiscan", () => {
expect(await readdir(external)).toEqual(["attempt-1"]);
});

test("rejects an output directory replaced during preparation when numeric identities collide", async () => {
const paths = await fixture();
const source = await repository(paths.root, "output-identity-race");
await writeFile(
paths.input,
`id,repository,revision\nrace,${source.path},${source.revision}\n`,
);
await mkdir(paths.output, { mode: 0o700 });
const originalLstat = filesystem.lstat;
const canonicalOutput = await realpath(paths.output);
const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
let outputInspections = 0;
const inspectOutput = spyOn(filesystem, "lstat").mockImplementation(
async (path, options) => {
const stats = await originalLstat(path, options as never);
if (String(path) !== paths.output && String(path) !== canonicalOutput) {
return stats as never;
}
const exactIdentity =
firstExactIdentity + (outputInspections++ === 0 ? 0n : 1n);
return Object.assign(
Object.create(Object.getPrototypeOf(stats)),
stats,
{
ino:
typeof stats.ino === "bigint"
? exactIdentity
: Number(exactIdentity),
},
) as never;
},
);
let scans = 0;

try {
await expect(
runMultiscan(
options(
paths,
client(async (_repository, scanOptions = {}) => {
scans += 1;
return await completedScan(scanOptions.outputDir!);
}),
),
),
).rejects.toThrow("changed during preparation");
expect(scans).toBe(0);
} finally {
inspectOutput.mockRestore();
}
});

testPosix(
"rejects other-user-writable campaigns while preserving readable existing campaigns",
async () => {
Expand Down
91 changes: 89 additions & 2 deletions sdk/typescript/tests-ts/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
} from "node:path";
import { promisify } from "node:util";
import { brotliDecompressSync } from "node:zlib";
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { strToU8, zipSync } from "fflate";
import {
BUNDLED_PLUGIN_VERSION,
Expand Down Expand Up @@ -61,6 +61,7 @@ import {
planOutputArchive,
prepareCodexSecurityCredentialHome,
preparePersistentOutputRoot,
preserveCodexSecurityPluginRegistration,
requirePrivateCredentialHome,
requirePrivateCredentialFile,
requirePrivateOutputDirectory,
Expand Down Expand Up @@ -1498,6 +1499,51 @@ describe("plugin runtime preparation", () => {
]);
});

test("does not preserve a different marketplace when numeric identities collide", async () => {
const root = await temporaryDirectory();
const home = join(root, "home");
const marketplace = join(home, "sdk-marketplace");
const differentSource = join(home, "different-marketplace");
await mkdir(marketplace, { recursive: true });
await mkdir(differentSource);
await writeFile(
join(home, "config.toml"),
`[marketplaces.codex-security-sdk]\nsource_type = "local"\nsource = ${JSON.stringify(differentSource)}\n[plugins."codex-security@codex-security-sdk"]\nenabled = true\n`,
);
const originalStat = fsPromises.stat;
const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
const inspectMarketplaces = spyOn(fsPromises, "stat").mockImplementation(
async (path, options) => {
const stats = await originalStat(path, options as never);
const value = String(path);
if (value !== marketplace && value !== differentSource) {
return stats as never;
}
const exactIdentity =
firstExactIdentity + (value === marketplace ? 1n : 0n);
return Object.assign(
Object.create(Object.getPrototypeOf(stats)),
stats,
{
ino:
typeof stats.ino === "bigint"
? exactIdentity
: Number(exactIdentity),
},
) as never;
},
);
const config = { model: "comparison-model" };

try {
expect(await preserveCodexSecurityPluginRegistration(home, config)).toBe(
config,
);
} finally {
inspectMarketplaces.mockRestore();
}
});

test("refreshes cached plugins before forwarding delegated scan attribution", async () => {
const root = await temporaryDirectory();
const previous = await plugin(join(root, "previous"), "0.1.19");
Expand Down Expand Up @@ -2043,7 +2089,7 @@ describe("runtime directories and plugin Python boundary", () => {
const home = await prepareCodexSecurityCredentialHome({
CODEX_SECURITY_STATE_DIR: join(root, "state"),
});
const stale = await lstat(home);
const stale = await lstat(home, { bigint: true });
await rename(home, join(root, "original-home"));
await mkdir(home, { mode: 0o700 });

Expand Down Expand Up @@ -2988,6 +3034,47 @@ describe("runtime directories and plugin Python boundary", () => {
}
});

test("rejects replacement credential homes when numeric identities collide", async () => {
const root = await temporaryDirectory();
const home = join(root, "home");
await mkdir(home);
const canonicalHome = await realpath(home);
const originalLstat = fsPromises.lstat;
const firstExactIdentity = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
let homeInspections = 0;
const inspectHome = spyOn(fsPromises, "lstat").mockImplementation(
async (path, options) => {
const stats = await originalLstat(path, options as never);
if (String(path) !== home && String(path) !== canonicalHome) {
return stats as never;
}
const exactIdentity =
firstExactIdentity + (homeInspections++ === 0 ? 0n : 1n);
return Object.assign(
Object.create(Object.getPrototypeOf(stats)),
stats,
{
ino:
typeof stats.ino === "bigint"
? exactIdentity
: Number(exactIdentity),
},
) as never;
},
);

try {
await expect(
requireSecureCredentialHome(home, {
platform: "win32",
secureWindowsHome: async () => {},
}),
).rejects.toThrow("credential home was replaced");
} finally {
inspectHome.mockRestore();
}
});

test("revalidates the Windows credential ACL every time the home is used", async () => {
const root = await temporaryDirectory();
const home = join(root, "home");
Expand Down
Loading