@darkhunt-security/endpoint-codex 0.9.9 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import { createRequire as __dhCreateRequire } from 'node:module';
3
3
  const require = __dhCreateRequire(import.meta.url);
4
4
 
5
5
  // adapters/codex/bin/status.mjs
6
- import { readFileSync as readFileSync7 } from "node:fs";
6
+ import { readFileSync as readFileSync8 } from "node:fs";
7
7
  import { dirname, join as join12 } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
@@ -1228,6 +1228,7 @@ import { join as join9 } from "node:path";
1228
1228
  var CLI_CREDENTIALS_PATH = join9(homedir3(), ".darkhunt", "credentials.json");
1229
1229
 
1230
1230
  // adapters/codex/dist/transcript.js
1231
+ import { readFileSync as readFileSync6 } from "node:fs";
1231
1232
  import { basename, join as join10 } from "node:path";
1232
1233
  import { homedir as homedir4 } from "node:os";
1233
1234
  function str(value) {
@@ -1264,17 +1265,128 @@ function textOf(content) {
1264
1265
  const parts = content.map((b) => b && typeof b === "object" ? str(b["text"]) : void 0).filter((t) => !!t);
1265
1266
  return parts.length > 0 ? parts.join("\n") : void 0;
1266
1267
  }
1268
+ var CODEX_APPROVAL_MODES = {
1269
+ never: "auto",
1270
+ "on-request": "ask"
1271
+ };
1272
+ var CODEX_SANDBOX_MODES = {
1273
+ "workspace-write": "workspace-write",
1274
+ "read-only": "read-only",
1275
+ "danger-full-access": "danger-full-access"
1276
+ };
1277
+ var PERMISSION_SNAPSHOT_LIMIT = 32e3;
1278
+ function budgeted(steps) {
1279
+ const kept = [];
1280
+ let bytes = 0;
1281
+ for (const step of steps) {
1282
+ bytes += JSON.stringify(step ?? null).length + 1;
1283
+ if (bytes > PERMISSION_SNAPSHOT_LIMIT)
1284
+ break;
1285
+ kept.push(step);
1286
+ }
1287
+ return kept;
1288
+ }
1289
+ function unwrapExecOutput(value) {
1290
+ if (typeof value !== "string")
1291
+ return { output: value };
1292
+ let parsed;
1293
+ try {
1294
+ parsed = JSON.parse(value);
1295
+ } catch {
1296
+ return { output: value };
1297
+ }
1298
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1299
+ return { output: value };
1300
+ const bag = parsed;
1301
+ if (!Object.prototype.hasOwnProperty.call(bag, "output"))
1302
+ return { output: value };
1303
+ const metadata = bag["metadata"];
1304
+ if (!metadata || typeof metadata !== "object")
1305
+ return { output: value };
1306
+ const exitCode = num(metadata["exit_code"]);
1307
+ if (exitCode === void 0)
1308
+ return { output: value };
1309
+ return { output: bag["output"], exitCode };
1310
+ }
1311
+ function jwtClaims(token) {
1312
+ const parts = token.split(".");
1313
+ if (parts.length !== 3 || !parts[1])
1314
+ return void 0;
1315
+ try {
1316
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
1317
+ const claims = JSON.parse(json);
1318
+ return claims && typeof claims === "object" ? claims : void 0;
1319
+ } catch {
1320
+ return void 0;
1321
+ }
1322
+ }
1323
+ function emailFromAuth(raw) {
1324
+ let parsed;
1325
+ try {
1326
+ parsed = JSON.parse(raw);
1327
+ } catch {
1328
+ return void 0;
1329
+ }
1330
+ const tokens = parsed?.tokens;
1331
+ const token = tokens?.id_token;
1332
+ if (typeof token !== "string" || token === "")
1333
+ return void 0;
1334
+ const email = jwtClaims(token)?.["email"];
1335
+ return typeof email === "string" && email !== "" ? email : void 0;
1336
+ }
1337
+ function repositoryIdentity(url) {
1338
+ if (url === void 0)
1339
+ return void 0;
1340
+ const trimmed = url.trim();
1341
+ if (trimmed === "")
1342
+ return void 0;
1343
+ const scp = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(\S+)$/.exec(trimmed);
1344
+ if (scp)
1345
+ return `${scp[1]}/${stripSuffix(scp[2])}`;
1346
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `ssh://${trimmed}`;
1347
+ try {
1348
+ const parsed = new URL(withScheme);
1349
+ if (!parsed.hostname)
1350
+ return trimmed;
1351
+ const path = stripSuffix(parsed.pathname.replace(/^\/+/, ""));
1352
+ return path === "" ? parsed.hostname : `${parsed.hostname}/${path}`;
1353
+ } catch {
1354
+ return trimmed;
1355
+ }
1356
+ }
1357
+ function stripSuffix(path) {
1358
+ return path.replace(/\/+$/, "").replace(/\.git$/i, "");
1359
+ }
1267
1360
  var codexTranscript = {
1268
1361
  vendor: "codex",
1269
1362
  sessionRoots() {
1270
1363
  return [join10(homedir4(), ".codex", "sessions")];
1271
1364
  },
1272
- // No `resolveUserId` yet, so Codex traces fall back to the configured `userId`.
1273
- // The identity exists but not as a plain field: `~/.codex/auth.json` holds
1274
- // `tokens.account_id` (a UUID, not a person) and an `email` claim inside the
1275
- // `id_token` JWT. Reading it means decoding an OAuth token and honouring its expiry
1276
- // a stale token would attribute sessions to a *wrong* address, which is worse than
1277
- // leaving them unattributed. Tracked separately.
1365
+ /**
1366
+ * `~/.codex/auth.json` -> the `email` claim of `tokens.id_token`.
1367
+ *
1368
+ * Codex records no identity in the rollout itself and none in plain text anywhere
1369
+ * else: `tokens.account_id` is a UUID, not a person. The address is a claim inside the
1370
+ * OAuth id_token, so reading it means decoding that token — hence {@link jwtClaims}.
1371
+ *
1372
+ * The token's own `exp` is deliberately not enforced. It is an hour long and the file
1373
+ * is only rewritten on sign-in and on refresh, so gating on expiry would leave most
1374
+ * sessions unattributed while answering a question nobody asked: an expired id_token
1375
+ * is evidence that a token needs refreshing, never evidence that a different person is
1376
+ * now signed in. Signing in as someone else rewrites this file, which is what makes
1377
+ * the stale-address worry unfounded — the claim always names the last account to
1378
+ * authenticate on this machine, which is the account that wrote these rollouts.
1379
+ *
1380
+ * `undefined` for API-key auth (`OPENAI_API_KEY` with no tokens), for a signed-out
1381
+ * agent, and for anything unreadable; the caller then falls back to configuration.
1382
+ */
1383
+ resolveUserId() {
1384
+ try {
1385
+ return emailFromAuth(readFileSync6(join10(homedir4(), ".codex", "auth.json"), "utf8"));
1386
+ } catch {
1387
+ return void 0;
1388
+ }
1389
+ },
1278
1390
  sessionIdFor(path) {
1279
1391
  const name = basename(path, ".jsonl");
1280
1392
  const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(name);
@@ -1297,12 +1409,20 @@ var codexTranscript = {
1297
1409
  const uuid = `codex:${offset}`;
1298
1410
  const base = { vendor: "codex", ts, uuid };
1299
1411
  if (raw.type === "session_meta") {
1412
+ const rawGit = payload["git"];
1413
+ const git = rawGit && typeof rawGit === "object" ? rawGit : void 0;
1414
+ const gitBranch = str(git?.["branch"]);
1415
+ const gitRepositoryUrl = repositoryIdentity(str(git?.["repository_url"]));
1300
1416
  return [
1301
1417
  {
1302
1418
  ...base,
1303
1419
  kind: "session_meta",
1304
1420
  meta: {
1305
1421
  ...str(payload["cwd"]) !== void 0 ? { cwd: str(payload["cwd"]) } : {},
1422
+ // Conditional spread rather than a plain assignment: the 7 `{}` blocks and
1423
+ // the 92 absent ones must leave the key absent, not present-and-undefined.
1424
+ ...gitBranch !== void 0 ? { gitBranch } : {},
1425
+ ...gitRepositoryUrl !== void 0 ? { gitRepositoryUrl } : {},
1306
1426
  ...str(payload["cli_version"]) !== void 0 ? { version: str(payload["cli_version"]) } : {},
1307
1427
  ...str(payload["originator"]) !== void 0 ? { entrypoint: str(payload["originator"]) } : {},
1308
1428
  // A Codex subagent is a rollout of its own, a peer of its parent in the
@@ -1318,15 +1438,61 @@ var codexTranscript = {
1318
1438
  }
1319
1439
  if (raw.type === "turn_context") {
1320
1440
  const model = str(payload["model"]);
1321
- if (!model)
1441
+ const approval = str(payload["approval_policy"]);
1442
+ const sandbox = payload["sandbox_policy"];
1443
+ const sandboxMode = (() => {
1444
+ if (!sandbox || typeof sandbox !== "object")
1445
+ return void 0;
1446
+ const bag = sandbox;
1447
+ return str(bag["mode"]) ?? str(bag["type"]);
1448
+ })();
1449
+ const meta = {
1450
+ ...model !== void 0 ? { model } : {},
1451
+ ...approval !== void 0 ? { approvalMode: CODEX_APPROVAL_MODES[approval] ?? approval } : {},
1452
+ ...sandboxMode !== void 0 ? { sandboxMode: CODEX_SANDBOX_MODES[sandboxMode] ?? sandboxMode } : {}
1453
+ };
1454
+ if (Object.keys(meta).length === 0)
1322
1455
  return [];
1323
- return [{ ...base, kind: "session_meta", meta: { model } }];
1456
+ return [{ ...base, kind: "session_meta", meta }];
1324
1457
  }
1325
1458
  const kind = str(payload["type"]);
1326
1459
  if (raw.type === "event_msg" && kind === "token_count") {
1327
1460
  const usage = mapTokenCount(payload["info"]);
1328
1461
  return usage ? [{ ...base, kind: "usage", usage }] : [];
1329
1462
  }
1463
+ if (raw.type === "compacted") {
1464
+ const note = str(payload["message"]);
1465
+ return [{ ...base, kind: "compaction", ...note ? { text: note } : {} }];
1466
+ }
1467
+ if (raw.type === "world_state") {
1468
+ const state = payload["state"];
1469
+ const perms = state && typeof state === "object" ? state["permissions"] : void 0;
1470
+ if (!perms || typeof perms !== "object")
1471
+ return [];
1472
+ const list = perms["approved_command_prefixes"];
1473
+ const steps = Array.isArray(list) ? list : [];
1474
+ return [
1475
+ {
1476
+ ...base,
1477
+ kind: "permission_grant",
1478
+ input: {
1479
+ steps: budgeted(steps),
1480
+ count: steps.length,
1481
+ ...payload["full"] === true ? { full: true } : {}
1482
+ }
1483
+ }
1484
+ ];
1485
+ }
1486
+ if (raw.type === "event_msg" && kind === "turn_aborted") {
1487
+ return [
1488
+ {
1489
+ ...base,
1490
+ kind: "user_message",
1491
+ isError: true,
1492
+ errorKind: `aborted:${str(payload["reason"]) ?? "unknown"}`
1493
+ }
1494
+ ];
1495
+ }
1330
1496
  if (raw.type !== "response_item")
1331
1497
  return [];
1332
1498
  switch (kind) {
@@ -1340,18 +1506,74 @@ var codexTranscript = {
1340
1506
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1341
1507
  }
1342
1508
  ];
1343
- case "function_call_output":
1509
+ case "function_call_output": {
1510
+ const flattened = textOf(payload["output"]) ?? payload["output"];
1511
+ const { output, exitCode } = unwrapExecOutput(flattened);
1344
1512
  return [
1345
1513
  {
1346
1514
  ...base,
1347
1515
  kind: "tool_result",
1348
- output: payload["output"],
1516
+ output,
1517
+ // Only where an exit status was actually found. On the 993 string outputs
1518
+ // that are not envelopes and the 144 array ones the key stays ABSENT rather
1519
+ // than `false`: the emitter tests `record.isError`, and "this tool reported
1520
+ // success" is a different claim from "nothing here reported an outcome".
1521
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
1349
1522
  ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1350
1523
  }
1351
1524
  ];
1525
+ }
1526
+ // Codex's OTHER tool-call shape, and the one it actually uses: `exec` and
1527
+ // `apply_patch` arrive as `custom_tool_call`, never as `function_call`. Measured
1528
+ // across 251 rollouts, 2179 of 5382 tool calls — 40% — took this shape, so
1529
+ // handling only `function_call` left every shell command and every file write
1530
+ // this endpoint made invisible to both the trace and anything reading it.
1531
+ case "custom_tool_call":
1532
+ return [
1533
+ {
1534
+ ...base,
1535
+ kind: "tool_call",
1536
+ toolName: str(payload["name"]) ?? "tool",
1537
+ input: payload["input"],
1538
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1539
+ }
1540
+ ];
1541
+ case "custom_tool_call_output": {
1542
+ const flattened = textOf(payload["output"]) ?? payload["output"];
1543
+ const { output, exitCode } = unwrapExecOutput(flattened);
1544
+ return [
1545
+ {
1546
+ ...base,
1547
+ kind: "tool_result",
1548
+ output,
1549
+ ...exitCode !== void 0 ? { isError: exitCode !== 0 } : {},
1550
+ ...str(payload["call_id"]) !== void 0 ? { toolCallId: str(payload["call_id"]) } : {}
1551
+ }
1552
+ ];
1553
+ }
1554
+ // The model's own web reads. 49 across the corpus in 9 rollouts — `search` 31,
1555
+ // `open_page` 16, `find_in_page` 2 — every one of which mapped to nothing, so a
1556
+ // session where the agent searched and opened pages rendered as reasoning
1557
+ // followed by an answer with no source anywhere in it. The queries and the URLs
1558
+ // are the whole point of the record: they are the evidence of what the agent
1559
+ // read from outside the machine.
1560
+ //
1561
+ // `action` is passed through whole. It is already a vendor-neutral argument bag
1562
+ // ({type, query?, queries?, url?, pattern?}) and it is small — 322 bytes at the
1563
+ // worst, 121 on average across all 49.
1564
+ //
1565
+ // Deliberately unpaired: no `toolCallId`. Only 1 of the 49 lines carries an
1566
+ // `id`, `call_id` is the pairing key everywhere else in this stream, and no
1567
+ // response_item output line ever pairs with a search — so setting one would
1568
+ // register a span in the emitter's pairing table that nothing ever closes, and
1569
+ // `finish()` would end it at pass end and stamp a multi-minute duration on a
1570
+ // search that took seconds. Left unpaired, the emitter closes it at the call
1571
+ // timestamp, which is the truthful reading.
1572
+ case "web_search_call":
1573
+ return [{ ...base, kind: "tool_call", toolName: "web_search", input: payload["action"] }];
1352
1574
  case "reasoning": {
1353
1575
  const text = textOf(payload["summary"]) ?? textOf(payload["content"]);
1354
- return text ? [{ ...base, kind: "thinking", text }] : [];
1576
+ return [{ ...base, kind: "thinking", ...text !== void 0 ? { text } : {} }];
1355
1577
  }
1356
1578
  case "message": {
1357
1579
  const text = textOf(payload["content"]);
@@ -1373,7 +1595,7 @@ var codexTranscript = {
1373
1595
  };
1374
1596
 
1375
1597
  // adapters/codex/bin/lib/launchers.mjs
1376
- import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
1598
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
1377
1599
  import { homedir as homedir5 } from "node:os";
1378
1600
  import { join as join11 } from "node:path";
1379
1601
  var LAUNCHERS = {
@@ -1399,7 +1621,7 @@ function launcherState(binDir = BIN_DIR, path = process.env["PATH"] ?? "") {
1399
1621
  }
1400
1622
  let body = "";
1401
1623
  try {
1402
- body = readFileSync6(file, "utf8");
1624
+ body = readFileSync7(file, "utf8");
1403
1625
  } catch {
1404
1626
  }
1405
1627
  if (body !== script(target)) stale.push(name);
@@ -1437,7 +1659,7 @@ try {
1437
1659
  const root = join12(dirname(fileURLToPath(import.meta.url)), "..", "..");
1438
1660
  let installed = "unknown";
1439
1661
  try {
1440
- installed = JSON.parse(readFileSync7(join12(root, "package.json"), "utf8")).version;
1662
+ installed = JSON.parse(readFileSync8(join12(root, "package.json"), "utf8")).version;
1441
1663
  } catch {
1442
1664
  }
1443
1665
  console.log(` plugin ${installed} ${root}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darkhunt-security/endpoint-codex",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "Darkhunt endpoint adapter for Codex CLI: hook codec, rollout transcript mapper, plugin manifest.",
6
6
  "bin": {
@@ -13,8 +13,8 @@
13
13
  ".codex-plugin"
14
14
  ],
15
15
  "devDependencies": {
16
- "@darkhunt-security/endpoint-contracts": "0.9.1",
17
- "@darkhunt-security/endpoint-core": "0.9.1"
16
+ "@darkhunt-security/endpoint-contracts": "0.9.3",
17
+ "@darkhunt-security/endpoint-core": "0.9.3"
18
18
  },
19
19
  "publishConfig": {
20
20
  "access": "public",