Skip to content

Commit bfac227

Browse files
authored
refactor(examples): port custom validation to TypeScript (#792)
* refactor(examples): port custom validation to TypeScript * refactor(example): reuse the package build command
1 parent f1413cf commit bfac227

13 files changed

Lines changed: 231 additions & 120 deletions

File tree

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,7 @@ __pycache__/
2222
# Emitted by sdk/typescript's build:ci script.
2323
/.github/scripts/check_plugin_source_compatibility.mjs
2424
/.github/scripts/test_check_plugin_source_compatibility.mjs
25+
26+
# Emitted by sdk/typescript's build:examples script.
27+
/examples/custom-validation/app.mjs
28+
/examples/custom-validation/validate.mjs

‎examples/custom-validation/README.md‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
This deliberately vulnerable invoice API contains only synthetic data. Do not
44
deploy it. The validation script starts a real loopback HTTP server, tests
5-
cross-account access, saves the evidence, and stops the server. It needs Python
6-
3.10 or later and no extra packages or Docker.
5+
cross-account access, saves the evidence, and stops the server. It uses the
6+
SDK's supported Node.js version (including 22.13) and TypeScript compiler, with no
7+
extra packages or Docker.
78

89
From the repository root, build the CLI and run the demo:
910

@@ -14,8 +15,16 @@ node examples/custom-validation/run.mjs
1415
```
1516

1617
The runner uses your existing Codex Security sign-in or API key. It copies the
17-
fixture to a temporary directory and prints the scan output path. Extra CLI
18-
options can be appended, for example `--model gpt-5.6-terra --effort high`.
18+
TypeScript fixture to a temporary directory, compiles it to JavaScript, and
19+
prints the scan output path. Extra CLI options can be appended, for example
20+
`--model gpt-5.6-terra --effort high`.
21+
22+
To run just the HTTP proof without a scan:
23+
24+
```bash
25+
pnpm --dir sdk/typescript run build:examples
26+
node examples/custom-validation/validate.mjs --output reports/http-proof.json
27+
```
1928

2029
Look for these files in the printed scan directory:
2130

@@ -31,4 +40,4 @@ cannot complete; it does not fall back to the default validation workflow.
3140

3241
Adapt [validation.md](validation.md) for your own setup, tests, and cleanup.
3342
For a Docker-based project, the same prompt can run your existing compose or
34-
test script instead of `validate.py`.
43+
test script instead of `validate.mjs`.

‎examples/custom-validation/app.mts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Deliberately vulnerable local fixture. Do not deploy this application.
2+
import { once } from "node:events";
3+
import { createServer as createHttpServer, type Server } from "node:http";
4+
import type { AddressInfo } from "node:net";
5+
import { pathToFileURL } from "node:url";
6+
7+
// These identities, tokens, and records are synthetic demo data.
8+
const tokens = new Map([
9+
["demo-alice", "alice"],
10+
["demo-bob", "bob"],
11+
]);
12+
type Invoice = { id: string; owner: string; amount: number };
13+
const invoices = new Map<string, Invoice>([
14+
["1001", { id: "1001", owner: "alice", amount: 25 }],
15+
["1002", { id: "1002", owner: "bob", amount: 80 }],
16+
]);
17+
18+
export async function createServer(): Promise<Server> {
19+
const server = createHttpServer((request, response) => {
20+
function reply(status: number, body: Invoice | { error: string }): void {
21+
const encoded = JSON.stringify(body);
22+
response.writeHead(status, {
23+
"Content-Type": "application/json",
24+
"Content-Length": Buffer.byteLength(encoded),
25+
});
26+
response.end(encoded);
27+
}
28+
29+
if (request.method !== "GET") {
30+
response.writeHead(501).end();
31+
return;
32+
}
33+
const authorization = request.headers.authorization ?? "";
34+
const token = authorization.startsWith("Bearer ")
35+
? authorization.slice("Bearer ".length)
36+
: authorization;
37+
const user = tokens.get(token);
38+
if (user === undefined) return reply(401, { error: "unauthorized" });
39+
const path = request.url ?? "";
40+
if (!path.startsWith("/invoices/"))
41+
return reply(404, { error: "not found" });
42+
const invoice = invoices.get(path.slice("/invoices/".length));
43+
if (invoice === undefined) return reply(404, { error: "not found" });
44+
// BUG: authentication does not establish ownership of this invoice.
45+
reply(200, invoice);
46+
});
47+
server.listen(0, "127.0.0.1");
48+
await once(server, "listening");
49+
return server;
50+
}
51+
52+
if (
53+
process.argv[1] &&
54+
import.meta.url === pathToFileURL(process.argv[1]).href
55+
) {
56+
const server = await createServer();
57+
console.log(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
58+
}

‎examples/custom-validation/app.py‎

Lines changed: 0 additions & 48 deletions
This file was deleted.

‎examples/custom-validation/run.mjs‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,21 @@ const root = await mkdtemp(join(tmpdir(), "codex-security-validation-demo-"));
88
const target = join(root, "target");
99
const output = join(root, "scan");
1010
await mkdir(target);
11-
for (const name of ["app.py", "validate.py"]) {
11+
for (const name of ["app.mts", "validate.mts"]) {
1212
await copyFile(new URL(name, import.meta.url), join(target, name));
1313
}
1414

15+
const build = spawnSync(
16+
process.execPath,
17+
["--run", "build:examples", "--", "--outDir", target],
18+
{
19+
cwd: fileURLToPath(new URL("../../sdk/typescript", import.meta.url)),
20+
stdio: "inherit",
21+
},
22+
);
23+
if (build.error) throw build.error;
24+
if (build.status !== 0) process.exit(build.status ?? 1);
25+
1526
console.log(`Demo target: ${target}\nScan output: ${output}`);
1627
const child = spawnSync(
1728
process.execPath,
@@ -22,7 +33,7 @@ const child = spawnSync(
2233
"scan",
2334
target,
2435
"--path",
25-
"app.py",
36+
"app.mts",
2637
"--scan-prompt-file",
2738
fileURLToPath(new URL("scan.md", import.meta.url)),
2839
"--validation-prompt-file",

‎examples/custom-validation/scan.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Review invoice ownership checks in `app.py`. An authenticated account must not
1+
Review invoice ownership checks in `app.mts`. An authenticated account must not
22
read another account's invoice. The fixed tokens and records are synthetic test
3-
data, not production credentials. `validate.py` is a test harness, not an
3+
data, not production credentials. `validate.mts` is a test harness, not an
44
application endpoint. Keep discovery source-only.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Exercise the fixture over real HTTP and save the observed evidence.
2+
import assert from "node:assert/strict";
3+
import { mkdir, writeFile } from "node:fs/promises";
4+
import type { AddressInfo } from "node:net";
5+
import { dirname } from "node:path";
6+
import { parseArgs } from "node:util";
7+
import { createServer } from "./app.mjs";
8+
9+
type HttpResult = {
10+
status: number;
11+
body: { id?: string; owner?: string; amount?: number; error?: string };
12+
};
13+
14+
async function main(): Promise<number> {
15+
let output: string;
16+
try {
17+
const { values } = parseArgs({
18+
options: {
19+
output: { type: "string" },
20+
help: { type: "boolean", short: "h" },
21+
},
22+
});
23+
if (values.help) {
24+
console.log("Usage: node validate.mjs --output PATH");
25+
return 0;
26+
}
27+
if (values.output === undefined) throw new Error("--output is required");
28+
output = values.output;
29+
} catch (error) {
30+
console.error((error as Error).message);
31+
return 2;
32+
}
33+
34+
const server = await createServer();
35+
const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
36+
async function get(invoice: string, token?: string): Promise<HttpResult> {
37+
const response = await fetch(`${baseUrl}/invoices/${invoice}`, {
38+
headers: token === undefined ? {} : { Authorization: `Bearer ${token}` },
39+
signal: AbortSignal.timeout(5_000),
40+
});
41+
return {
42+
status: response.status,
43+
body: (await response.json()) as HttpResult["body"],
44+
};
45+
}
46+
47+
let evidence: {
48+
anonymous: HttpResult;
49+
own_invoice: HttpResult;
50+
other_invoice: HttpResult;
51+
cross_account_read: boolean;
52+
};
53+
try {
54+
const anonymous = await get("1002");
55+
const own_invoice = await get("1001", "demo-alice");
56+
const other_invoice = await get("1002", "demo-alice");
57+
assert.equal(anonymous.status, 401, "Authentication control failed");
58+
assert.equal(own_invoice.status, 200, "Own-account control failed");
59+
evidence = {
60+
anonymous,
61+
own_invoice,
62+
other_invoice,
63+
cross_account_read:
64+
other_invoice.status === 200 && other_invoice.body.owner === "bob",
65+
};
66+
} finally {
67+
await new Promise<void>((resolve, reject) => {
68+
server.close((error) => (error ? reject(error) : resolve()));
69+
});
70+
}
71+
72+
const proof = { ...evidence, server_stopped: true };
73+
await mkdir(dirname(output), { recursive: true });
74+
await writeFile(output, `${JSON.stringify(proof, null, 2)}\n`, "utf8");
75+
console.log(JSON.stringify(proof));
76+
return 0;
77+
}
78+
79+
process.exitCode = await main();

‎examples/custom-validation/validate.py‎

Lines changed: 0 additions & 60 deletions
This file was deleted.

‎examples/custom-validation/validation.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
Validate the invoice-ownership finding against this local fixture.
22

3-
1. Use the configured Python interpreter to run `validate.py` from the supplied
3+
1. Run `node validate.mjs` from the supplied
44
repository root. Pass `--output` with the absolute path to
55
`artifacts/custom-validation/http-proof.json` inside this scan's directory.
6-
Set `PYTHONDONTWRITEBYTECODE=1` so the target remains unchanged.
6+
The runner has already compiled the TypeScript fixture to JavaScript.
77
2. The script starts a server on an ephemeral `127.0.0.1` port, makes three HTTP
88
requests using synthetic identities, and shuts the server down. This local
99
server is the only authorized test target. Do not install packages or contact

‎sdk/typescript/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,11 @@
5050
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
5151
"build": "node --run clean && tsc -p tsconfig.build.json && node scripts/build-dashboard.mjs",
5252
"build:ci": "tsc -p tsconfig.ci.json",
53+
"build:examples": "tsc -p tsconfig.examples.json",
5354
"build:plugin": "node scripts/build-plugin.mjs",
5455
"check:plugin-source": "node scripts/check-plugin-source.mjs",
5556
"check:package": "node scripts/check-package.mjs",
56-
"format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\"",
57+
"format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,mts,tsx,json,md}\" \"../../.github/scripts/*.mts\" \"../../examples/custom-validation/*.{mts,md}\"",
5758
"generate:models": "node scripts/generate-models.cjs",
5859
"generate:models:check": "node scripts/generate-models.cjs --check",
5960
"lint": "tsc --noEmit",

0 commit comments

Comments
 (0)