codiedev 0.9.56 → 0.10.0-beta.2

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.
Files changed (46) hide show
  1. package/README.md +5 -4
  2. package/dist/captureLog.d.ts +1 -1
  3. package/dist/cli.js +11 -1
  4. package/dist/codexLimitScan.d.ts +0 -2
  5. package/dist/codexLimitScan.js +2 -15
  6. package/dist/codie.js +146 -1
  7. package/dist/codieLauncher.d.ts +5 -0
  8. package/dist/codieLauncher.js +23 -1
  9. package/dist/codieRuntime.d.ts +11 -1
  10. package/dist/codieRuntime.js +20 -3
  11. package/dist/codieRuntimeInstall.d.ts +12 -11
  12. package/dist/codieRuntimeInstall.js +3 -1
  13. package/dist/codieStartup.d.ts +26 -0
  14. package/dist/codieStartup.js +30 -0
  15. package/dist/commands/doctor.js +8 -0
  16. package/dist/commands/security.d.ts +1 -0
  17. package/dist/commands/security.js +156 -0
  18. package/dist/connect.js +56 -4
  19. package/dist/cursorHooks.d.ts +16 -2
  20. package/dist/cursorHooks.js +31 -6
  21. package/dist/hook.js +232 -22
  22. package/dist/limitEvent.d.ts +5 -1
  23. package/dist/limitEvent.js +6 -27
  24. package/dist/security.d.ts +102 -0
  25. package/dist/security.js +437 -0
  26. package/dist/securityDeepScan.d.ts +41 -0
  27. package/dist/securityDeepScan.js +248 -0
  28. package/dist/securityDoctor.d.ts +32 -0
  29. package/dist/securityDoctor.js +63 -0
  30. package/dist/securityFeature.d.ts +5 -0
  31. package/dist/securityFeature.js +7 -0
  32. package/dist/securityInstall.d.ts +63 -0
  33. package/dist/securityInstall.js +239 -0
  34. package/dist/securityInventory.d.ts +54 -0
  35. package/dist/securityInventory.js +556 -0
  36. package/dist/securityPilot.d.ts +19 -0
  37. package/dist/securityPilot.js +61 -0
  38. package/dist/securityPolicy.d.ts +33 -0
  39. package/dist/securityPolicy.js +188 -0
  40. package/dist/securitySession.d.ts +6 -0
  41. package/dist/securitySession.js +72 -0
  42. package/dist/upgradeNudge.d.ts +1 -0
  43. package/dist/upgradeNudge.js +1 -0
  44. package/dist/utils.d.ts +12 -6
  45. package/dist/utils.js +201 -38
  46. package/package.json +11 -2
package/dist/hook.js CHANGED
@@ -47,6 +47,11 @@ const limitEventReporter_1 = require("./limitEventReporter");
47
47
  const codexLimitScan_1 = require("./codexLimitScan");
48
48
  const version_1 = require("./version");
49
49
  const captureLog_1 = require("./captureLog");
50
+ const security_1 = require("./security");
51
+ const securitySession_1 = require("./securitySession");
52
+ const securityPolicy_1 = require("./securityPolicy");
53
+ const securityFeature_1 = require("./securityFeature");
54
+ const securityInventory_1 = require("./securityInventory");
50
55
  const captureExclusions_1 = require("./captureExclusions");
51
56
  const cursorHooks_1 = require("./cursorHooks");
52
57
  // Pure builder for the discovery additionalContext. Returns null when there are
@@ -121,6 +126,8 @@ async function readAndPostLimitEvent(hookInput, source, emitMinimalOnMiss) {
121
126
  const tp = hookInput.transcript_path;
122
127
  if (tp) {
123
128
  try {
129
+ // Codex invokes Stop after every turn. Bound this read so long-running
130
+ // sessions do not repeatedly load their entire rollout into memory.
124
131
  transcript = readTranscriptTailFile(tp);
125
132
  }
126
133
  catch {
@@ -210,10 +217,14 @@ async function runCodexLimitSweep(sessionId) {
210
217
  // A shorter timeout than the default: this is on the user's prompt
211
218
  // clock, and the next prompt retries anything that does not land.
212
219
  post: (payload) => (0, limitEventReporter_1.postLimitEvent)(config, payload, PROMPT_LIMIT_POST_TIMEOUT_MS),
213
- // Unchanged rollouts cost one statSync and do not spend the read
214
- // budget, so in steady state this is nearly free. The module default
215
- // covers a cold start; see DEFAULT_MAX_SCANS for why it is not small.
220
+ // This runs on the prompt path, so keep it bounded. Unchanged rollouts
221
+ // cost one statSync and do not spend the read budget, so a busy machine
222
+ // cannot bury an unreported block behind newer idle sessions. When a
223
+ // block is still unreported it sits at the END of its rollout, so a
224
+ // small scan window is enough; the Stop path keeps the larger default
225
+ // for a session that kept going after the window reset.
216
226
  findOptions: { withinMs: 24 * 3600_000 },
227
+ maxScans: 8,
217
228
  scanOptions: { maxScanBytes: 4 * 1024 * 1024 },
218
229
  });
219
230
  if (posted > 0) {
@@ -320,7 +331,7 @@ timeoutMs) {
320
331
  // postJson variant that returns the parsed response body — the upload-URL
321
332
  // handshake needs the { uploadUrl } the server hands back. No timeout: runs
322
333
  // only in detached uploader children, never on the shutdown clock.
323
- function postJsonForResponse(url, body, bearerToken) {
334
+ function postJsonForResponse(url, body, bearerToken, timeoutMs) {
324
335
  return new Promise((resolve, reject) => {
325
336
  const parsed = new URL(url);
326
337
  const data = JSON.stringify(body);
@@ -354,10 +365,145 @@ function postJsonForResponse(url, body, bearerToken) {
354
365
  });
355
366
  });
356
367
  req.on("error", reject);
368
+ if (timeoutMs) {
369
+ req.setTimeout(timeoutMs, () => {
370
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
371
+ });
372
+ }
357
373
  req.write(data);
358
374
  req.end();
359
375
  });
360
376
  }
377
+ // Local policy runs synchronously before a tool executes. Workflow telemetry
378
+ // is dispatched separately and fail-open, so a detector outage or timeout can
379
+ // never delay or override the deterministic endpoint decision.
380
+ async function runSecurityHook(hookInput, phase, source, uploadTelemetry = true) {
381
+ const config = (0, utils_1.readConfig)();
382
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
383
+ return;
384
+ const policy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
385
+ const sessionId = hookInput.session_id ?? hookInput.sessionId;
386
+ const sessionSignal = phase === "pre" ? (0, securitySession_1.readSessionSecuritySignal)(sessionId) : undefined;
387
+ const localDecision = (0, security_1.evaluateToolSecurity)(hookInput, phase, policy, source, sessionSignal);
388
+ if (phase === "post" && (0, security_1.securityInputHasSensitivePath)(hookInput)) {
389
+ (0, securitySession_1.recordSensitiveReadSignal)(sessionId);
390
+ }
391
+ const event = (0, security_1.buildSecurityWorkflowEvent)(hookInput, phase, source);
392
+ if (event) {
393
+ event.localDecision = localDecision.action;
394
+ event.localCategory = localDecision.category;
395
+ }
396
+ if (localDecision.action === "block" && phase === "pre") {
397
+ (0, captureLog_1.appendCaptureLog)({
398
+ stage: "security",
399
+ sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
400
+ outcome: "ok",
401
+ detail: `phase=${phase} localDecision=block category=${localDecision.category}`,
402
+ });
403
+ if (uploadTelemetry)
404
+ dispatchSecurityEventUpload(event);
405
+ const response = source === "cursor"
406
+ ? (0, cursorHooks_1.cursorPreToolBlockResponse)(localDecision.reason ?? "Codie blocked this action")
407
+ : {
408
+ hookSpecificOutput: {
409
+ hookEventName: "PreToolUse",
410
+ permissionDecision: "deny",
411
+ permissionDecisionReason: localDecision.reason,
412
+ },
413
+ };
414
+ process.stdout.write(JSON.stringify(response));
415
+ return;
416
+ }
417
+ if (!event)
418
+ return;
419
+ if (uploadTelemetry)
420
+ dispatchSecurityEventUpload(event);
421
+ }
422
+ function runPromptSecurityHook(hookInput, source, uploadTelemetry = true) {
423
+ const config = (0, utils_1.readConfig)();
424
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
425
+ return false;
426
+ const decision = (0, security_1.evaluatePromptSecurity)(hookInput.prompt, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId), source);
427
+ if (decision.action !== "block")
428
+ return false;
429
+ (0, captureLog_1.appendCaptureLog)({
430
+ stage: "security",
431
+ sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
432
+ outcome: "ok",
433
+ detail: `phase=prompt localDecision=block category=${decision.category} telemetry=${uploadTelemetry ? "enabled" : "suppressed"} cwd=${hookInput.cwd ?? "unknown"}`,
434
+ });
435
+ if (uploadTelemetry) {
436
+ dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
437
+ }
438
+ const response = source === "cursor"
439
+ ? (0, cursorHooks_1.cursorPromptBlockResponse)(decision.reason ?? "Codie blocked this prompt")
440
+ : { decision: "block", reason: decision.reason };
441
+ process.stdout.write(JSON.stringify(response));
442
+ return true;
443
+ }
444
+ function dispatchAllowedPromptSecurityEvent(hookInput, source) {
445
+ const config = (0, utils_1.readConfig)();
446
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
447
+ return;
448
+ const event = (0, security_1.buildSecurityPromptEvent)(hookInput, source, Date.now, (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId));
449
+ if (event)
450
+ dispatchSecurityEventUpload(event);
451
+ }
452
+ function dispatchSecurityEventUpload(event) {
453
+ if (!event || !(0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()))
454
+ return;
455
+ try {
456
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-upload"], {
457
+ detached: true,
458
+ stdio: ["pipe", "ignore", "ignore"],
459
+ });
460
+ child.stdin?.end(JSON.stringify(event));
461
+ child.unref();
462
+ }
463
+ catch {
464
+ // Telemetry is best-effort and must never delay local enforcement.
465
+ }
466
+ }
467
+ async function runSecurityEventUpload() {
468
+ const config = (0, utils_1.readConfig)();
469
+ if (!config || !(0, securityFeature_1.isAgentSecurityEnabled)(config))
470
+ return;
471
+ const startedAt = Date.now();
472
+ try {
473
+ const raw = await readStdin();
474
+ const event = JSON.parse(raw);
475
+ const cachedPolicy = (0, securityPolicy_1.readEndpointSecurityPolicy)(undefined, undefined, config.companyId);
476
+ const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
477
+ endpointId: config.deviceId,
478
+ endpointName: config.deviceName,
479
+ cliVersion: version_1.CLI_VERSION,
480
+ securityRuntimeVersion: security_1.SECURITY_RUNTIME_VERSION,
481
+ securityPolicyVersion: cachedPolicy?.policyVersion,
482
+ });
483
+ const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token,
484
+ // Leave room for a cold backend and durable scheduling. This uploader is
485
+ // detached, so the network budget never delays the developer's prompt or
486
+ // tool call. Policy refreshes use the separately signed sync endpoint.
487
+ 5_000);
488
+ if (result?.policy?.schemaVersion === 1) {
489
+ (0, securityPolicy_1.writeEndpointSecurityPolicy)(result.policy, undefined, undefined, config.companyId);
490
+ }
491
+ (0, captureLog_1.appendCaptureLog)({
492
+ stage: "security",
493
+ sessionId: event.sessionId,
494
+ outcome: "ok",
495
+ detail: `phase=${event.phase} elapsedMs=${Date.now() - startedAt} verdict=${String(result?.verdict ?? "unknown")}`,
496
+ });
497
+ }
498
+ catch (err) {
499
+ (0, captureLog_1.appendCaptureLog)({
500
+ stage: "security",
501
+ sessionId: "upload",
502
+ outcome: "ERROR",
503
+ detail: `elapsedMs=${Date.now() - startedAt} ${err.message}`,
504
+ });
505
+ }
506
+ }
361
507
  // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
362
508
  // itself is the one-time credential). Returns the parsed { storageId }.
363
509
  function postBytes(url, bytes, contentType) {
@@ -481,6 +627,20 @@ async function main() {
481
627
  }
482
628
  process.exit(0);
483
629
  }
630
+ // Daily component drift scan runs in a detached hook process. The parent
631
+ // SessionStart path only performs a timestamp read, so agent startup never
632
+ // waits for filesystem traversal or an inventory upload.
633
+ if (process.argv[2] === "security-inventory-upload") {
634
+ const config = (0, utils_1.readConfig)();
635
+ if (config && (0, securityFeature_1.isAgentSecurityEnabled)(config)) {
636
+ const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace: process.argv[3] || process.cwd() });
637
+ try {
638
+ await (0, securityInventory_1.uploadSecurityInventory)(inventory, config);
639
+ }
640
+ catch { /* retry after the daily inventory window */ }
641
+ }
642
+ process.exit(0);
643
+ }
484
644
  // Detached backup uploader child (spawned by runBackup below). The job file
485
645
  // carries only { root, files }; the child re-reads config from disk. No stdin
486
646
  // needed, so handle it before readStdin() like the capture uploader above.
@@ -517,6 +677,13 @@ async function main() {
517
677
  }
518
678
  process.exit(0);
519
679
  }
680
+ // Detached, best-effort security telemetry. Local policy decisions have
681
+ // already completed in the parent hook; this child can never delay or alter
682
+ // the developer's action.
683
+ if (process.argv[2] === "security-upload") {
684
+ await runSecurityEventUpload();
685
+ process.exit(0);
686
+ }
520
687
  let hookInput = {};
521
688
  try {
522
689
  const raw = await readStdin();
@@ -530,25 +697,57 @@ async function main() {
530
697
  catch {
531
698
  process.exit(0);
532
699
  }
533
- // Local privacy boundary: every stdin-driven hook mode passes through this
534
- // check BEFORE prompt discovery, limit-event tails, SessionStart pings,
535
- // backup, self-heal, transcript reads, or detached dispatch. Ignored means
536
- // no Codiedev request at all for this session.
537
- if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
700
+ // Privacy exclusions suppress every Codiedev upload, but they must never
701
+ // disable local security enforcement. Security hooks run first with telemetry
702
+ // disabled; all other hook modes still exit before making a network request.
703
+ const captureIgnored = (0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput);
704
+ const securityCommand = process.argv[2] ?? "";
705
+ const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|cursor|codie))?$/);
706
+ if (securityMatch) {
707
+ const source = securityMatch[2] === "codex"
708
+ ? "codex"
709
+ : securityMatch[2] === "cursor"
710
+ ? "cursor"
711
+ : securityMatch[2] === "codie"
712
+ ? "codie-harness"
713
+ : "claude-code";
714
+ await runSecurityHook(hookInput, securityMatch[1], source, !captureIgnored);
538
715
  process.exit(0);
539
716
  }
540
717
  // UserPromptSubmit discovery path — separate from session capture.
718
+ const promptCommand = process.argv[2] ?? "";
719
+ if (promptCommand === "security-prompt-cursor") {
720
+ if (runPromptSecurityHook(hookInput, "cursor", !captureIgnored))
721
+ return;
722
+ if (captureIgnored)
723
+ return;
724
+ dispatchAllowedPromptSecurityEvent(hookInput, "cursor");
725
+ process.exit(0);
726
+ }
541
727
  if (process.argv[2] === "discover" ||
542
728
  process.argv[2] === "discover-codex" ||
543
729
  process.argv[2] === "discover-codie") {
730
+ const source = promptCommand === "discover-codex"
731
+ ? "codex"
732
+ : promptCommand === "discover-codie"
733
+ ? "codie-harness"
734
+ : "claude-code";
735
+ // Security runs first and locally. A blocked prompt is erased before it
736
+ // reaches the model, so discovery must not send it to the backend.
737
+ if (runPromptSecurityHook(hookInput, source, !captureIgnored))
738
+ return;
739
+ if (captureIgnored)
740
+ return;
741
+ dispatchAllowedPromptSecurityEvent(hookInput, source);
544
742
  // A Codex block leaves no hook of its own, so this prompt is the first
545
743
  // chance to report it. Runs before runDiscover, which exits the process.
546
- if (process.argv[2] === "discover-codex") {
744
+ if (source === "codex")
547
745
  await runCodexLimitSweep(hookInput.session_id);
548
- }
549
746
  await runDiscover(hookInput.prompt);
550
747
  return;
551
748
  }
749
+ if (captureIgnored)
750
+ process.exit(0);
552
751
  // StopFailure (matcher rate_limit): post the rate-limit event.
553
752
  if (process.argv[2] === "limit") {
554
753
  await runStopFailure(hookInput);
@@ -562,6 +761,16 @@ async function main() {
562
761
  if (process.argv[2] === "session-start" ||
563
762
  process.argv[2] === "session-start-cursor" ||
564
763
  process.argv[2] === "session-start-opencode") {
764
+ if ((0, securityFeature_1.isAgentSecurityEnabled)((0, utils_1.readConfig)()) && (0, securityInventory_1.securityInventoryDue)()) {
765
+ try {
766
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
767
+ detached: true,
768
+ stdio: "ignore",
769
+ });
770
+ child.unref();
771
+ }
772
+ catch { /* daily inventory is best-effort */ }
773
+ }
565
774
  await runSessionStart(hookInput, process.argv[2] === "session-start-opencode"
566
775
  ? "opencode"
567
776
  : process.argv[2] === "session-start-cursor"
@@ -598,8 +807,6 @@ async function main() {
598
807
  if (transcript_path) {
599
808
  try {
600
809
  (0, utils_1.dispatchCaptureFinish)({
601
- // "capture" = the claude-code entrypoint literal, same as the
602
- // SessionEnd path below (resolveMode maps it to claude-code).
603
810
  mode: process.argv[2] === "backup-cursor" ? "capture-cursor" : "capture",
604
811
  session_id,
605
812
  transcript_path,
@@ -660,10 +867,9 @@ async function main() {
660
867
  transcript_path,
661
868
  cwd,
662
869
  workspace_roots,
663
- // final:true ONLY for claude-code's SessionEnd. Codex routes its
664
- // per-turn Stop hook through "capture-codex" — final:true there would
665
- // close the server lease every turn. Cursor SessionEnd is final:true;
666
- // Codex remains the legacy final-omitted per-turn path.
870
+ // final:true for real SessionEnd signals (Claude Code and Cursor).
871
+ // Codex routes its per-turn Stop hook through "capture-codex" —
872
+ // final:true there would close the server lease every turn.
667
873
  final: (0, utils_1.resolveCaptureFinal)(process.argv[2]),
668
874
  }, process.argv[1], { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile: jobFile, openSync: fs.openSync, closeSync: fs.closeSync });
669
875
  }
@@ -784,8 +990,10 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
784
990
  const stats = parserMode === "codex"
785
991
  ? (0, utils_1.parseCodexStats)(transcriptContent)
786
992
  : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
787
- // Never use parsed message count here: Cursor's private JSONL shape can
788
- // evolve independently, and parser drift must not discard a real session.
993
+ // Cursor can emit duplicate lifecycle notifications while one transcript
994
+ // path is still byte-empty. Never use parsed message count here: Cursor's
995
+ // private JSONL shape can evolve independently, and parser drift must not
996
+ // silently discard a real non-empty session.
789
997
  if (parserMode === "cursor" && (0, cursorHooks_1.isCursorTranscriptEmpty)(rawTranscriptContent)) {
790
998
  (0, captureLog_1.appendCaptureLog)({
791
999
  stage: "capture-finish",
@@ -794,7 +1002,8 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
794
1002
  detail: "empty-cursor-transcript",
795
1003
  });
796
1004
  // A non-final stop snapshot is only a backup; reporting it as a skipped
797
- // session would close server-side coverage while the session is alive.
1005
+ // session would close/remove server-side start coverage while the session
1006
+ // is still alive. The real SessionEnd remains responsible for a skip ping.
798
1007
  if ((0, utils_1.shouldReportCaptureSkip)(input.final)) {
799
1008
  await postSessionSkip(config, session_id, "empty-cursor-transcript");
800
1009
  }
@@ -902,8 +1111,9 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
902
1111
  body: payload,
903
1112
  scopePaths,
904
1113
  }, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
905
- // Persist the immediate payload before removing the older scheduled
906
- // snapshot. Every immediate dispatch supersedes a sleeping deferred job.
1114
+ // The immediate payload is durable before removing the older scheduled
1115
+ // snapshot. Do this for every immediate dispatch, not only SessionEnd:
1116
+ // first/outside-window snapshots also supersede a sleeping deferred job.
907
1117
  if (session_id)
908
1118
  (0, utils_1.cancelDeferredCaptureUpload)(session_id);
909
1119
  // Retry any earlier capture jobs that failed to upload (transient network
@@ -7,6 +7,10 @@ export interface LimitEvent {
7
7
  }
8
8
  export declare function classifyLimitKind(text: string): LimitKind;
9
9
  export declare function parseLimitEventFromTranscript(transcript: string): LimitEvent | null;
10
- /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
10
+ /**
11
+ * Codex writes a structured RateLimitSnapshot on token_count events. A normal
12
+ * turn has `rate_limit_reached_type: null`; a blocked turn names the exact
13
+ * boundary. Reading that field avoids locale-dependent terminal copy.
14
+ */
11
15
  export declare function parseCodexLimitEventFromTranscript(transcript: string): LimitEvent | null;
12
16
  export declare function tailForLimitEvent(transcript: string, maxChars?: number): string;
@@ -51,36 +51,11 @@ function parseLimitEventFromTranscript(transcript) {
51
51
  }
52
52
  return found;
53
53
  }
54
- /**
55
- * Codex's `rate_limit_reached_type` is exactly five values (read off the 0.154
56
- * binary): `rate_limit_reached`, `workspace_{owner,member}_credits_depleted`
57
- * and `workspace_{owner,member}_usage_limit_reached`.
58
- *
59
- * The split that matters to a reader of the dashboard is "someone must act on
60
- * money" vs "this dev is throttled and it will reset" — so map on what the
61
- * snapshot actually asserts, not on the word "credits":
62
- *
63
- * - A member out of their OWN allowance is a rate limit. Real snapshots carry
64
- * `spend_control_reached: null` and `individual_limit: null` — nobody
65
- * configured a budget, the window is simply used up. Calling that personal
66
- * spend tells a manager the dev overspent, which is not what happened.
67
- *
68
- * Deliberately NOT gated on `individual_limit`: it has only ever been
69
- * observed null, so its populated shape and meaning are unknown, and a
70
- * wrong guess reinstates the mislabel this exists to remove. The reason
71
- * string is the causal signal; `individual_limit` is at best correlative.
72
- * - A member whose ADMIN capped them (`usage_limit_reached`) is personal spend:
73
- * there really is a cap, and someone can raise it.
74
- * - Anything owner-scoped is org spend — the org's allowance or credits are
75
- * gone and somebody has to act.
76
- */
77
54
  function codexLimitKind(reason, spendControlReached) {
78
55
  if (spendControlReached || reason.startsWith("workspace_owner_"))
79
56
  return "org_spend";
80
- if (reason === "workspace_member_usage_limit_reached")
57
+ if (reason.startsWith("workspace_member_"))
81
58
  return "user_spend";
82
- if (reason === "workspace_member_credits_depleted")
83
- return "session";
84
59
  return reason === "rate_limit_reached" ? "session" : "other";
85
60
  }
86
61
  function codexResetText(rateLimits) {
@@ -94,7 +69,11 @@ function codexResetText(rateLimits) {
94
69
  .sort((a, b) => a - b)[0];
95
70
  return resetAt ? new Date(resetAt * 1000).toISOString() : null;
96
71
  }
97
- /** Parse the structured rate-limit snapshot written to Codex rollout JSONL. */
72
+ /**
73
+ * Codex writes a structured RateLimitSnapshot on token_count events. A normal
74
+ * turn has `rate_limit_reached_type: null`; a blocked turn names the exact
75
+ * boundary. Reading that field avoids locale-dependent terminal copy.
76
+ */
98
77
  function parseCodexLimitEventFromTranscript(transcript) {
99
78
  let found = null;
100
79
  for (const line of transcript.split("\n")) {
@@ -0,0 +1,102 @@
1
+ export type SecurityHookPhase = "pre" | "post" | "failure";
2
+ export type AgentSurface = "claude-code" | "codex" | "cursor" | "codie-harness";
3
+ export declare const SECURITY_RUNTIME_VERSION = 1;
4
+ export interface RawSecurityHookInput {
5
+ session_id?: string;
6
+ sessionId?: string;
7
+ cwd?: string;
8
+ hook_event_name?: string;
9
+ hookEventName?: string;
10
+ tool_name?: string;
11
+ toolName?: string;
12
+ tool_input?: unknown;
13
+ toolInput?: unknown;
14
+ tool_response?: unknown;
15
+ toolResponse?: unknown;
16
+ tool_use_id?: string;
17
+ toolUseId?: string;
18
+ duration_ms?: number;
19
+ durationMs?: number;
20
+ error?: unknown;
21
+ prompt?: string;
22
+ }
23
+ export interface SecurityWorkflowEvent {
24
+ schemaVersion: 1;
25
+ source: AgentSurface;
26
+ phase: SecurityHookPhase;
27
+ sessionId: string;
28
+ toolName: string;
29
+ toolUseId?: string;
30
+ cwd?: string;
31
+ observedAt: number;
32
+ toolDurationMs?: number;
33
+ payload: unknown;
34
+ redactionCount: number;
35
+ localDecision?: LocalSecurityAction;
36
+ localCategory?: LocalSecurityDecision["category"];
37
+ endpointId?: string;
38
+ endpointName?: string;
39
+ cliVersion?: string;
40
+ securityRuntimeVersion?: number;
41
+ securityPolicyVersion?: number;
42
+ }
43
+ export type LocalSecurityAction = "allow" | "alert" | "block";
44
+ export type SecurityCategory = "secret_in_prompt" | "secret_exfiltration" | "destructive_outside_workspace" | "sensitive_file_read" | "sensitive_file_write" | "protected_agent_config_write" | "unapproved_network_egress" | "untrusted_mcp_tool" | "unapproved_command" | "obfuscated_shell_command" | "package_install" | "sensitive_read_then_egress";
45
+ export interface EndpointSecurityPolicy {
46
+ schemaVersion: 1;
47
+ policyVersion: number;
48
+ enforcementMode: "alert" | "block";
49
+ rules: Partial<Record<SecurityCategory, LocalSecurityAction>>;
50
+ constraints?: {
51
+ allowedMcpServers?: string[];
52
+ allowedHosts?: string[];
53
+ allowedCommands?: string[];
54
+ };
55
+ exceptions: Array<{
56
+ scope: "company" | "user" | "source" | "repository" | "tool" | "category";
57
+ scopeValue: string;
58
+ }>;
59
+ fetchedAt: number;
60
+ signature?: {
61
+ algorithm: "Ed25519";
62
+ keyId: string;
63
+ signedAt: number;
64
+ value: string;
65
+ };
66
+ }
67
+ export interface LocalSecurityDecision {
68
+ action: LocalSecurityAction;
69
+ category?: SecurityCategory;
70
+ reason?: string;
71
+ }
72
+ export declare function withEndpointIdentity(event: SecurityWorkflowEvent, identity: {
73
+ endpointId?: string;
74
+ endpointName?: string;
75
+ cliVersion?: string;
76
+ securityRuntimeVersion?: number;
77
+ securityPolicyVersion?: number;
78
+ }): SecurityWorkflowEvent;
79
+ /**
80
+ * Runs before a user prompt reaches the model. This is intentionally narrow:
81
+ * block credential material, but allow safe references such as
82
+ * `$OPENAI_API_KEY` so developers can ask agents to use environment variables
83
+ * without pasting their values.
84
+ */
85
+ export declare function evaluatePromptSecurity(prompt: string | undefined, policy?: EndpointSecurityPolicy, source?: AgentSurface): LocalSecurityDecision;
86
+ /** Runs synchronously before a tool executes. No network or model is involved. */
87
+ export declare function evaluateToolSecurity(input: RawSecurityHookInput, phase: SecurityHookPhase, policy?: EndpointSecurityPolicy, source?: AgentSurface, sessionContext?: {
88
+ sensitiveReadAt?: number;
89
+ now?: number;
90
+ }): LocalSecurityDecision;
91
+ export declare function securityInputHasSensitivePath(input: RawSecurityHookInput): boolean;
92
+ export declare function buildSecurityWorkflowEvent(input: RawSecurityHookInput, phase: SecurityHookPhase, source: AgentSurface, now?: () => number): SecurityWorkflowEvent | null;
93
+ /**
94
+ * Captures the intent side of the causal chain without weakening the local
95
+ * privacy boundary. A prompt that the deterministic policy blocks never
96
+ * becomes an uploadable event; allowed prompts are redacted and bounded using
97
+ * the same sanitizer as tool payloads.
98
+ */
99
+ export declare function buildSecurityPromptEvent(input: Pick<RawSecurityHookInput, "session_id" | "sessionId" | "cwd" | "prompt">, source: AgentSurface, now?: () => number, policy?: EndpointSecurityPolicy): SecurityWorkflowEvent | null;
100
+ /** Audit-safe evidence for a blocked prompt. The secret-bearing prompt is
101
+ * intentionally omitted; administrators still see that prevention occurred. */
102
+ export declare function buildBlockedSecurityPromptEvent(input: Pick<RawSecurityHookInput, "session_id" | "sessionId" | "cwd">, source: AgentSurface, category: NonNullable<LocalSecurityDecision["category"]>, now?: () => number): SecurityWorkflowEvent | null;