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.
- package/README.md +5 -4
- package/dist/captureLog.d.ts +1 -1
- package/dist/cli.js +11 -1
- package/dist/codexLimitScan.d.ts +0 -2
- package/dist/codexLimitScan.js +2 -15
- package/dist/codie.js +146 -1
- package/dist/codieLauncher.d.ts +5 -0
- package/dist/codieLauncher.js +23 -1
- package/dist/codieRuntime.d.ts +11 -1
- package/dist/codieRuntime.js +20 -3
- package/dist/codieRuntimeInstall.d.ts +12 -11
- package/dist/codieRuntimeInstall.js +3 -1
- package/dist/codieStartup.d.ts +26 -0
- package/dist/codieStartup.js +30 -0
- package/dist/commands/doctor.js +8 -0
- package/dist/commands/security.d.ts +1 -0
- package/dist/commands/security.js +156 -0
- package/dist/connect.js +56 -4
- package/dist/cursorHooks.d.ts +16 -2
- package/dist/cursorHooks.js +31 -6
- package/dist/hook.js +232 -22
- package/dist/limitEvent.d.ts +5 -1
- package/dist/limitEvent.js +6 -27
- package/dist/security.d.ts +102 -0
- package/dist/security.js +437 -0
- package/dist/securityDeepScan.d.ts +41 -0
- package/dist/securityDeepScan.js +248 -0
- package/dist/securityDoctor.d.ts +32 -0
- package/dist/securityDoctor.js +63 -0
- package/dist/securityFeature.d.ts +5 -0
- package/dist/securityFeature.js +7 -0
- package/dist/securityInstall.d.ts +63 -0
- package/dist/securityInstall.js +239 -0
- package/dist/securityInventory.d.ts +54 -0
- package/dist/securityInventory.js +556 -0
- package/dist/securityPilot.d.ts +19 -0
- package/dist/securityPilot.js +61 -0
- package/dist/securityPolicy.d.ts +33 -0
- package/dist/securityPolicy.js +188 -0
- package/dist/securitySession.d.ts +6 -0
- package/dist/securitySession.js +72 -0
- package/dist/upgradeNudge.d.ts +1 -0
- package/dist/upgradeNudge.js +1 -0
- package/dist/utils.d.ts +12 -6
- package/dist/utils.js +201 -38
- 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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
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 (
|
|
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
|
|
664
|
-
// per-turn Stop hook through "capture-codex" —
|
|
665
|
-
// close the server lease every turn.
|
|
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
|
-
//
|
|
788
|
-
//
|
|
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
|
|
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
|
-
//
|
|
906
|
-
// snapshot.
|
|
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
|
package/dist/limitEvent.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/limitEvent.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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;
|