Skip to content
Open
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
6 changes: 4 additions & 2 deletions sdk/typescript/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ accepted and rejected inputs, and each real bug or security boundary.
From the SDK directory, run a focused test while iterating, then run the package checks:

```bash
bun test tests-ts/<module>.test.ts
bun test --randomize --seed 12345
bun test --timeout 30000 tests-ts/<module>.test.ts
pnpm run test --seed 12345
pnpm run types
pnpm run format
pnpm run test
```

Tests run in random order by default. To reproduce a failure, use the seed printed in Bun's test summary.

After the implementation is verified, keep the test in the final change only
when it provides meaningful, durable regression coverage. If it is merely
disposable implementation scaffolding, duplicates existing coverage, or would
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
randomize = true
1 change: 1 addition & 0 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2232,6 +2232,7 @@ interface ScanEventRunOptions {
onObserverError?: (observer: ScanObserverName, error: unknown) => void;
}

/** @internal */
export async function runScanEvents(
options: ScanEventRunOptions,
): Promise<ScanResult> {
Expand Down
42 changes: 22 additions & 20 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
existsSync,
lstatSync,
realpathSync,
type Stats,
type BigIntStats,
writeSync,
} from "node:fs";
import {
Expand Down Expand Up @@ -598,9 +598,9 @@ async function readPromptFiles(
async function readRegularInputFile(
path: string,
repository: string,
metadata?: Pick<Stats, "isFile" | "dev" | "ino">,
metadata?: Pick<BigIntStats, "isFile" | "dev" | "ino">,
): Promise<string> {
const selected = metadata ?? (await lstat(path));
const selected = metadata ?? (await lstat(path, { bigint: true }));
if (!selected.isFile()) {
throw new CodexSecurityError("Input files must be regular files.");
}
Expand All @@ -627,7 +627,7 @@ async function readRegularInputFile(
(constants.O_NONBLOCK ?? 0),
);
try {
const opened = await file.stat();
const opened = await file.stat({ bigint: true });
if (
!opened.isFile() ||
opened.dev !== selected.dev ||
Expand Down Expand Up @@ -3263,22 +3263,24 @@ async function runSkill(
localDeviceRoot !== normalizedDeviceRoot);
if (!windowsNetworkPath) {
const path = resolve(directory, input);
const metadata = await lstat(path).catch((error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "ENOENT" ||
error.code === "ENOTDIR" ||
error.code === "ENAMETOOLONG" ||
error.code === "EINVAL")
) {
return undefined;
}
throw new CodexSecurityError(
"Could not read the finding or issue input.",
);
});
const metadata = await lstat(path, { bigint: true }).catch(
(error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error.code === "ENOENT" ||
error.code === "ENOTDIR" ||
error.code === "ENAMETOOLONG" ||
error.code === "EINVAL")
) {
return undefined;
}
throw new CodexSecurityError(
"Could not read the finding or issue input.",
);
},
);
if (metadata !== undefined) {
if (!metadata.isFile()) {
throw new CodexSecurityError(
Expand Down
35 changes: 18 additions & 17 deletions sdk/typescript/tests-ts/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4507,7 +4507,7 @@ describe("CodexSecurity orchestration", () => {

try {
const results = await Promise.allSettled(
clients.map((client) => client.run(repository)),
clients.map((client) => client.run(repository).finally(releaseScans)),
);
for (const result of results) {
expect(result).toMatchObject({
Expand All @@ -4519,6 +4519,7 @@ describe("CodexSecurity orchestration", () => {
}
expect(scansStarted).toBe(2);
} finally {
releaseScans();
await Promise.all(clients.map(async (client) => await client.close()));
}
});
Expand Down Expand Up @@ -4610,7 +4611,9 @@ describe("CodexSecurity orchestration", () => {
try {
const results = await Promise.allSettled(
clients.map((client, index) =>
client.run(repository, { mode: "deep", workers: index + 2 }),
client
.run(repository, { mode: "deep", workers: index + 2 })
.finally(releaseScans),
),
);
for (const result of results) {
Expand All @@ -4633,6 +4636,7 @@ describe("CodexSecurity orchestration", () => {
),
).toBe(true);
} finally {
releaseScans();
await Promise.all(clients.map(async (client) => await client.close()));
}
});
Expand Down Expand Up @@ -6521,20 +6525,17 @@ setInterval(() => {}, 1000);
);
const login = client.loginApiKey("secret-key");
void login.catch(() => undefined);
for (let attempt = 0; attempt < 100; attempt += 1) {
const started = await import("node:fs/promises").then(({ stat }) =>
stat(ready).catch(() => null),
);
if (started !== null) break;
await new Promise((resolve) => setTimeout(resolve, 10));
try {
const deadline = Date.now() + 10_000;
while (!existsSync(ready) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(existsSync(ready), "the fake login process started").toBe(true);
await client.close();
await expect(login).rejects.toThrow();
await expect(stat(codexHome)).resolves.toBeDefined();
} finally {
await client.close();
}
await expect(
import("node:fs/promises").then(({ stat }) => stat(ready)),
).resolves.toBeDefined();
await client.close();
await expect(login).rejects.toThrow();
await expect(
import("node:fs/promises").then(({ stat }) => stat(codexHome)),
).resolves.toBeDefined();
});
}, 30_000);
});
122 changes: 108 additions & 14 deletions sdk/typescript/tests-ts/cli-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "../src/cli.js";
import type { LinearClientFactory } from "../src/linear.js";
import { capture, dependencies } from "./cli-fixtures.js";
import { runMockInSubprocess } from "./support/isolated-mock.js";

function linearIssue(identifier: string) {
return {
Expand Down Expand Up @@ -459,11 +460,102 @@ describe("CLI skill commands", () => {
}
});

test("rejects input replacements whose numeric file IDs collide", async () => {
if (
runMockInSubprocess(
import.meta.path,
"rejects input replacements whose numeric file IDs collide",
)
) {
return;
}
const root = await mkdtemp(join(tmpdir(), "codex-security-file-identity-"));
const selected = join(root, "finding.txt");
const replacement = join(root, "replacement.txt");
const selectedInode = 2n ** 60n;
const replacementInode = selectedInode + 1n;
expect(Number(selectedInode)).toBe(Number(replacementInode));
await writeFile(selected, "ordinary finding\n");
await writeFile(replacement, "SYNTHETIC_REPLACEMENT_FINDING\n");
const canonicalSelected = await filesystem.realpath(selected);
const originalLstat = filesystem.lstat;
const originalOpen = filesystem.open;
let restoreOpenedStat: (() => void) | undefined;
let replaced = false;
const reading = spyOn(filesystem, "lstat").mockImplementation((async (
...args: Parameters<typeof filesystem.lstat>
) => {
const metadata = await originalLstat(...args);
if (String(args[0]) === selected) {
metadata.ino =
typeof metadata.ino === "bigint"
? selectedInode
: Number(selectedInode);
}
return metadata;
}) as typeof filesystem.lstat);
const opening = spyOn(filesystem, "open").mockImplementation(
async (...args: Parameters<typeof filesystem.open>) => {
if (String(args[0]) !== canonicalSelected) {
return await originalOpen(...args);
}
replaced = true;
const file = await originalOpen(replacement, args[1], args[2]);
const originalStat = file.stat.bind(file);
const openedStat = spyOn(file, "stat").mockImplementation((async (
...statArgs: Parameters<typeof file.stat>
) => {
const metadata = await originalStat(...statArgs);
metadata.ino =
typeof metadata.ino === "bigint"
? replacementInode
: Number(replacementInode);
return metadata;
}) as typeof file.stat);
restoreOpenedStat = () => openedStat.mockRestore();
return file;
},
);
try {
let started = false;
const stderr = capture();
const status = await main(
["validate", "finding.txt"],
capture().stream,
stderr.stream,
dependencies({
currentDirectory: root,
onCodex: () => {
started = true;
return 0;
},
}),
);
expect(replaced).toBe(true);
expect(status).toBe(2);
expect(stderr.text()).not.toContain("SYNTHETIC_REPLACEMENT_FINDING");
expect(started).toBe(false);
} finally {
restoreOpenedStat?.();
opening.mockRestore();
reading.mockRestore();
await rm(root, { recursive: true, force: true });
}
});

test.each(
process.platform === "win32"
? ["symbolic link"]
: ["symbolic link", "FIFO"],
)("rejects finding files replaced with a %s", async (replacement) => {
if (
runMockInSubprocess(
import.meta.path,
`rejects finding files replaced with a ${replacement}`,
)
) {
return;
}
const root = await mkdtemp(join(tmpdir(), "codex-security-skill-inputs-"));
try {
const repository = join(root, "repository");
Expand All @@ -475,13 +567,15 @@ describe("CLI skill commands", () => {
const canonicalSelected = await filesystem.realpath(selected);

const originalOpen = filesystem.open;
let replaced = false;
const opening = spyOn(filesystem, "open").mockImplementation(
async (...args: Parameters<typeof filesystem.open>) => {
if (String(args[0]) === canonicalSelected) {
opening.mockRestore();
await rm(selected);
if (replacement === "FIFO") execFileSync("mkfifo", [selected]);
else await symlink(external, selected);
replaced = true;
}
return await originalOpen(...args);
},
Expand All @@ -490,20 +584,20 @@ describe("CLI skill commands", () => {
try {
let started = false;
const stderr = capture();
expect(
await main(
["validate", "finding.txt"],
capture().stream,
stderr.stream,
dependencies({
currentDirectory: repository,
onCodex: () => {
started = true;
return 0;
},
}),
),
).toBe(2);
const status = await main(
["validate", "finding.txt"],
capture().stream,
stderr.stream,
dependencies({
currentDirectory: repository,
onCodex: () => {
started = true;
return 0;
},
}),
);
expect(replaced, "the file-open replacement hook ran").toBe(true);
expect(status).toBe(2);
expect(stderr.text()).not.toContain("SYNTHETIC_EXTERNAL_FINDING");
expect(started).toBe(false);
} finally {
Expand Down
Loading
Loading