codiedev 0.9.35 → 0.9.37

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.
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  export declare const CAPTURE_LOG_MAX_BYTES: number;
3
3
  export declare const CAPTURE_LOG_PATH: string;
4
4
  export interface CaptureLogEntry {
5
- stage: "capture-finish" | "upload" | "upload-deferred" | "stderr";
5
+ stage: "capture-finish" | "upload" | "upload-deferred" | "security" | "stderr";
6
6
  sessionId: string | undefined;
7
7
  outcome: string;
8
8
  detail?: string;
package/dist/cli.js CHANGED
@@ -34,6 +34,7 @@ const share_1 = require("./commands/share");
34
34
  const send_1 = require("./commands/send");
35
35
  const react_1 = require("./commands/react");
36
36
  const capture_1 = require("./commands/capture");
37
+ const security_1 = require("./commands/security");
37
38
  const version_1 = require("./version");
38
39
  const HELP = `
39
40
  Codiedev CLI
@@ -44,7 +45,13 @@ Codiedev CLI
44
45
 
45
46
  Connect:
46
47
  codiedev connect Link Claude Code / Codex to your org
47
- codiedev doctor Verify your setup after connecting
48
+ codiedev doctor Verify setup, capture, and Agent Security
49
+ codiedev security pilot Run safe synthetic policy checks
50
+ codiedev security inventory Inventory MCPs, skills, hooks, plugins,
51
+ instructions, and editor extensions
52
+ codiedev security scan --deep Run installed open-source scanners too
53
+ codiedev security sync Fetch administrator policy locally
54
+ codiedev security rollback --yes Restore pre-update hook configuration
48
55
 
49
56
  Harness:
50
57
  codie Launch the governed Codie harness.
@@ -453,6 +460,9 @@ async function main() {
453
460
  case "verify":
454
461
  await (0, doctor_1.runDoctor)(rest);
455
462
  return;
463
+ case "security":
464
+ await (0, security_1.runSecurityCommand)(rest);
465
+ return;
456
466
  case "push":
457
467
  await (0, push_1.runPush)(rest);
458
468
  return;
@@ -45,6 +45,7 @@ const version_1 = require("../version");
45
45
  const upgradeNudge_1 = require("../upgradeNudge");
46
46
  const captureReadiness_1 = require("../captureReadiness");
47
47
  const captureLog_1 = require("../captureLog");
48
+ const securityDoctor_1 = require("../securityDoctor");
48
49
  const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
49
50
  const CLAUDE_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
50
51
  const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
@@ -482,6 +483,10 @@ async function runDoctor(_args) {
482
483
  detail: "not installed on this machine",
483
484
  });
484
485
  }
486
+ // Agent Security is part of installation health, not a separate setup
487
+ // concept. Keep `codiedev security doctor` as an alias for focused output,
488
+ // while the main doctor remains the one command users need to remember.
489
+ checks.push(...(0, securityDoctor_1.securityDoctorChecks)((0, securityDoctor_1.collectSecurityDoctorReport)()));
485
490
  // 8. gh / glab CLIs (optional — only needed for reverse-ticket from a
486
491
  // specific PR/MR URL; branch mode works without either).
487
492
  checks.push({
@@ -0,0 +1 @@
1
+ export declare function runSecurityCommand(args: string[]): Promise<void>;
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runSecurityCommand = runSecurityCommand;
4
+ const securityInstall_1 = require("../securityInstall");
5
+ const securityPilot_1 = require("../securityPilot");
6
+ const securityPolicy_1 = require("../securityPolicy");
7
+ const securityInventory_1 = require("../securityInventory");
8
+ const securityDeepScan_1 = require("../securityDeepScan");
9
+ const utils_1 = require("../utils");
10
+ const securityDoctor_1 = require("../securityDoctor");
11
+ function printPilot(json) {
12
+ const report = (0, securityPilot_1.runSyntheticSecurityPilot)();
13
+ if (json) {
14
+ console.log(JSON.stringify(report, null, 2));
15
+ return report.ok;
16
+ }
17
+ console.log("\nCodiedev security pilot — synthetic, side-effect-free validation\n");
18
+ for (const item of report.cases) {
19
+ console.log(`${item.passed ? "✓" : "✗"} ${item.surface}: ${item.name} (${item.actual})`);
20
+ }
21
+ console.log(`\n${report.ok ? "PASS" : "FAIL"}: ${report.cases.filter((item) => item.passed).length}/${report.cases.length} policy checks matched.`);
22
+ console.log("No commands were executed and no real credentials were used.\n");
23
+ return report.ok;
24
+ }
25
+ function printDoctor(json) {
26
+ const result = (0, securityDoctor_1.collectSecurityDoctorReport)();
27
+ if (json) {
28
+ console.log(JSON.stringify(result, null, 2));
29
+ return result.ok;
30
+ }
31
+ console.log("\nCodiedev doctor — Agent Security details\n");
32
+ for (const check of (0, securityDoctor_1.securityDoctorChecks)(result)) {
33
+ const mark = check.status === "pass" ? "✓" : check.status === "warn" ? "!" : "✗";
34
+ console.log(`${mark} ${check.name}: ${check.detail}`);
35
+ }
36
+ console.log(`\n${result.ok ? "PASS" : "FAIL"}: ${result.ok ? "endpoint security is healthy" : "run `codiedev connect`, then repeat this check"}.\n`);
37
+ return result.ok;
38
+ }
39
+ async function runSecurityCommand(args) {
40
+ const subcommand = args.find((arg) => !arg.startsWith("-")) ?? "doctor";
41
+ const json = args.includes("--json");
42
+ if (subcommand === "inventory" || subcommand === "scan") {
43
+ const workspaceFlag = args.indexOf("--workspace");
44
+ const workspace = workspaceFlag >= 0 && args[workspaceFlag + 1]
45
+ ? args[workspaceFlag + 1]
46
+ : process.cwd();
47
+ const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace });
48
+ const deep = subcommand === "scan" && args.includes("--deep")
49
+ ? (0, securityDeepScan_1.runDeepSecurityScan)(inventory, {
50
+ workspace,
51
+ connectMcp: args.includes("--connect-mcp"),
52
+ supplyChain: args.includes("--supply-chain"),
53
+ })
54
+ : undefined;
55
+ const uploadableInventory = deep ? (0, securityDeepScan_1.mergeDeepScanFindings)(inventory, deep) : inventory;
56
+ let upload;
57
+ const config = (0, utils_1.readConfig)();
58
+ if (config && !args.includes("--no-upload")) {
59
+ try {
60
+ upload = await (0, securityInventory_1.uploadSecurityInventory)(uploadableInventory, config);
61
+ }
62
+ catch (error) {
63
+ if (!json)
64
+ console.warn(`Inventory upload failed; local scan is still valid: ${error.message}`);
65
+ }
66
+ }
67
+ const output = { inventory: uploadableInventory, scanners: (0, securityInventory_1.scannerAvailability)(), ...(deep ? { deep } : {}), ...(upload ? { upload } : {}) };
68
+ if (json)
69
+ console.log(JSON.stringify(output, null, 2));
70
+ else {
71
+ const risky = inventory.components.filter((component) => component.findings.length > 0).length;
72
+ console.log(`\nCodiedev security inventory — ${inventory.components.length} components, ${risky} with local findings, ${inventory.drift.length} changed\n`);
73
+ const counts = new Map();
74
+ for (const component of inventory.components)
75
+ counts.set(component.kind, (counts.get(component.kind) ?? 0) + 1);
76
+ for (const [kind, count] of [...counts].sort())
77
+ console.log(` ${kind}: ${count}`);
78
+ if (deep)
79
+ for (const run of deep.runs)
80
+ console.log(` ${run.scanner}: ${run.status} — ${run.detail}`);
81
+ if (upload)
82
+ console.log(`\nUploaded ${upload.accepted} component records to the security workspace.`);
83
+ console.log("\nUse --json for machine-readable hashes and findings. Use scan --deep for installed OSS scanners; --connect-mcp and --supply-chain are explicit because they start or download third-party code.\n");
84
+ }
85
+ return;
86
+ }
87
+ if (subcommand === "sync") {
88
+ const config = (0, utils_1.readConfig)();
89
+ if (!config) {
90
+ console.error("Not connected. Run `codiedev connect` first.");
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ try {
95
+ const policy = await (0, securityPolicy_1.syncEndpointSecurityPolicy)(config);
96
+ if (json)
97
+ console.log(JSON.stringify({ ok: true, policy }, null, 2));
98
+ else {
99
+ const verification = (0, securityPolicy_1.verifyEndpointSecurityPolicySignature)(policy);
100
+ console.log(`Security policy v${policy.policyVersion} synced (${policy.enforcementMode} mode; ${verification.detail}).`);
101
+ }
102
+ }
103
+ catch (error) {
104
+ console.error(`Security policy sync failed: ${error.message}`);
105
+ process.exitCode = 1;
106
+ }
107
+ return;
108
+ }
109
+ if (subcommand === "rollback") {
110
+ if (!args.includes("--yes")) {
111
+ console.error("Rollback overwrites the installed agent hook configurations. Re-run with `codiedev security rollback --yes` after confirming the snapshot shown by `codiedev doctor`.");
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ try {
116
+ const result = (0, securityInstall_1.restoreSecurityInstallSnapshot)((0, securityInstall_1.readSecurityInstallManifest)());
117
+ if (json)
118
+ console.log(JSON.stringify({ schemaVersion: 1, ok: true, ...result }, null, 2));
119
+ else
120
+ console.log(`Restored ${result.restored} hook configuration(s) from snapshot ${result.snapshotId}.`);
121
+ }
122
+ catch (error) {
123
+ console.error(`Rollback failed: ${error.message}`);
124
+ process.exitCode = 1;
125
+ }
126
+ return;
127
+ }
128
+ const ok = subcommand === "pilot" || subcommand === "self-test"
129
+ ? printPilot(json)
130
+ : subcommand === "doctor" || subcommand === "status"
131
+ ? printDoctor(json)
132
+ : false;
133
+ if (!["pilot", "self-test", "doctor", "status"].includes(subcommand)) {
134
+ console.error("Usage: codiedev security <doctor|pilot|inventory|scan|sync|rollback> [--json] [--deep] [--connect-mcp] [--supply-chain] [--no-upload] [--yes]");
135
+ }
136
+ if (!ok)
137
+ process.exitCode = 1;
138
+ }
package/dist/connect.js CHANGED
@@ -39,11 +39,15 @@ const https = __importStar(require("https"));
39
39
  const http = __importStar(require("http"));
40
40
  const crypto = __importStar(require("crypto"));
41
41
  const os = __importStar(require("os"));
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
42
44
  const utils_1 = require("./utils");
43
45
  const connectFlow_1 = require("./connectFlow");
44
46
  const version_1 = require("./version");
45
47
  const upgradeNudge_1 = require("./upgradeNudge");
46
48
  const shared_1 = require("./commands/shared");
49
+ const securityInstall_1 = require("./securityInstall");
50
+ const securityPolicy_1 = require("./securityPolicy");
47
51
  // cli.codiedev.com is a Convex custom domain on the prod deployment; the
48
52
  // underlying judicious-falcon-861.convex.site URL keeps serving, so configs
49
53
  // written by older CLI versions stay valid without a re-connect.
@@ -280,6 +284,15 @@ async function runConnect(args = []) {
280
284
  // /api/cli/markInstalled so the portal's "Connected" state reflects
281
285
  // what's actually wired up on this machine, not just "token validated."
282
286
  const installedTargets = [];
287
+ const home = os.homedir();
288
+ const securityConfigPaths = [
289
+ path.join(home, ".claude", "settings.json"),
290
+ path.join(home, ".codex", "hooks.json"),
291
+ path.join(home, ".config", "opencode", "plugins", "codiedev.js"),
292
+ ];
293
+ // Snapshot before any hook update. Rollback is intentionally not automatic:
294
+ // an administrator can inspect these 0600 copies before restoring them.
295
+ const securitySnapshot = (0, securityInstall_1.createSecurityInstallSnapshot)(securityConfigPaths);
283
296
  // Wrap each per-target write so we emit one install_target row each. The
284
297
  // existing user-visible behavior (console.warn on failure, continue) is
285
298
  // unchanged — telemetry just observes the outcome.
@@ -317,6 +330,33 @@ async function runConnect(args = []) {
317
330
  // a user connect Claude Code today and install the Codie harness tomorrow
318
331
  // without needing a second setup ritual.
319
332
  runInstall("opencode-capture-adapter", utils_1.installOpenCodeCapturePlugin, "OpenCode session capture adapter (~/.config/opencode/plugins/codiedev.js)", "opencode-capture");
333
+ // Record the exact installed hook bytes and the pre-update rollback files.
334
+ // This supplies health/integrity checks without requiring a package publish
335
+ // or trusting whichever `node` happens to be first on PATH later.
336
+ try {
337
+ const hookScriptPath = path.join(__dirname, "hook.js");
338
+ if (fs.existsSync(hookScriptPath)) {
339
+ const targets = [];
340
+ if (installedTargets.includes("claudeCode-hook"))
341
+ targets.push({ surface: "claude-code", configPath: securityConfigPaths[0] });
342
+ if (installedTargets.includes("codex-hook"))
343
+ targets.push({ surface: "codex", configPath: securityConfigPaths[1] });
344
+ if (installedTargets.includes("opencode-capture"))
345
+ targets.push({ surface: "codie-harness", configPath: securityConfigPaths[2] });
346
+ (0, securityInstall_1.writeSecurityInstallManifest)({ hookScriptPath, snapshot: securitySnapshot, targets });
347
+ }
348
+ }
349
+ catch (err) {
350
+ console.warn(`Warning: Security hooks installed, but integrity manifest could not be written — ${err.message}`);
351
+ }
352
+ // Prime the local cache after installation. Hooks read this file only, so
353
+ // an endpoint decision never depends on live network latency.
354
+ try {
355
+ await (0, securityPolicy_1.syncEndpointSecurityPolicy)(config);
356
+ }
357
+ catch (err) {
358
+ console.warn(`Warning: Agent-security policy will use safe defaults until sync succeeds — ${err.message}`);
359
+ }
320
360
  // Tell the backend which targets we wired up so the portal's onboarding
321
361
  // card flips to "Connected" only when something actually installed —
322
362
  // not on bare token validation. Best-effort: a network failure here
@@ -355,5 +395,6 @@ async function runConnect(args = []) {
355
395
  }
356
396
  console.log();
357
397
  console.log("Run `codiedev doctor` to verify everything's wired up.");
398
+ console.log("Run `codiedev security pilot` to validate blocking safely.");
358
399
  console.log();
359
400
  }
package/dist/hook.js CHANGED
@@ -45,6 +45,10 @@ const utils_1 = require("./utils");
45
45
  const limitEvent_1 = require("./limitEvent");
46
46
  const version_1 = require("./version");
47
47
  const captureLog_1 = require("./captureLog");
48
+ const security_1 = require("./security");
49
+ const securitySession_1 = require("./securitySession");
50
+ const securityPolicy_1 = require("./securityPolicy");
51
+ const securityInventory_1 = require("./securityInventory");
48
52
  const captureExclusions_1 = require("./captureExclusions");
49
53
  // Pure builder for the discovery additionalContext. Returns null when there are
50
54
  // no matches. The suggested `codiedev pull` command carries the EXACT top key
@@ -218,7 +222,7 @@ timeoutMs) {
218
222
  // postJson variant that returns the parsed response body — the upload-URL
219
223
  // handshake needs the { uploadUrl } the server hands back. No timeout: runs
220
224
  // only in detached uploader children, never on the shutdown clock.
221
- function postJsonForResponse(url, body, bearerToken) {
225
+ function postJsonForResponse(url, body, bearerToken, timeoutMs) {
222
226
  return new Promise((resolve, reject) => {
223
227
  const parsed = new URL(url);
224
228
  const data = JSON.stringify(body);
@@ -252,10 +256,114 @@ function postJsonForResponse(url, body, bearerToken) {
252
256
  });
253
257
  });
254
258
  req.on("error", reject);
259
+ if (timeoutMs) {
260
+ req.setTimeout(timeoutMs, () => {
261
+ req.destroy(new Error(`timeout after ${timeoutMs}ms`));
262
+ });
263
+ }
255
264
  req.write(data);
256
265
  req.end();
257
266
  });
258
267
  }
268
+ // Local policy runs synchronously before a tool executes. Workflow telemetry
269
+ // is dispatched separately and fail-open, so a detector outage or timeout can
270
+ // never delay or override the deterministic endpoint decision.
271
+ async function runSecurityHook(hookInput, phase, source) {
272
+ const sessionId = hookInput.session_id ?? hookInput.sessionId;
273
+ const sessionSignal = phase === "pre" ? (0, securitySession_1.readSessionSecuritySignal)(sessionId) : undefined;
274
+ const localDecision = (0, security_1.evaluateToolSecurity)(hookInput, phase, (0, securityPolicy_1.readEndpointSecurityPolicy)(), source, sessionSignal);
275
+ if (phase === "post" && (0, security_1.securityInputHasSensitivePath)(hookInput)) {
276
+ (0, securitySession_1.recordSensitiveReadSignal)(sessionId);
277
+ }
278
+ const event = (0, security_1.buildSecurityWorkflowEvent)(hookInput, phase, source);
279
+ if (event) {
280
+ event.localDecision = localDecision.action;
281
+ event.localCategory = localDecision.category;
282
+ }
283
+ if (localDecision.action === "block" && phase === "pre") {
284
+ (0, captureLog_1.appendCaptureLog)({
285
+ stage: "security",
286
+ sessionId: hookInput.session_id ?? hookInput.sessionId ?? "unknown",
287
+ outcome: "ok",
288
+ detail: `phase=${phase} localDecision=block category=${localDecision.category}`,
289
+ });
290
+ dispatchSecurityEventUpload(event);
291
+ process.stdout.write(JSON.stringify({
292
+ hookSpecificOutput: {
293
+ hookEventName: "PreToolUse",
294
+ permissionDecision: "deny",
295
+ permissionDecisionReason: localDecision.reason,
296
+ },
297
+ }));
298
+ return;
299
+ }
300
+ if (!event)
301
+ return;
302
+ dispatchSecurityEventUpload(event);
303
+ }
304
+ function runPromptSecurityHook(hookInput, source) {
305
+ const decision = (0, security_1.evaluatePromptSecurity)(hookInput.prompt, (0, securityPolicy_1.readEndpointSecurityPolicy)(), source);
306
+ if (decision.action !== "block")
307
+ return false;
308
+ dispatchSecurityEventUpload((0, security_1.buildBlockedSecurityPromptEvent)(hookInput, source, decision.category ?? "secret_in_prompt"));
309
+ process.stdout.write(JSON.stringify({
310
+ decision: "block",
311
+ reason: decision.reason,
312
+ }));
313
+ return true;
314
+ }
315
+ function dispatchAllowedPromptSecurityEvent(hookInput, source) {
316
+ const event = (0, security_1.buildSecurityPromptEvent)(hookInput, source, Date.now, (0, securityPolicy_1.readEndpointSecurityPolicy)());
317
+ if (event)
318
+ dispatchSecurityEventUpload(event);
319
+ }
320
+ function dispatchSecurityEventUpload(event) {
321
+ if (!event || !(0, utils_1.readConfig)())
322
+ return;
323
+ try {
324
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-upload"], {
325
+ detached: true,
326
+ stdio: ["pipe", "ignore", "ignore"],
327
+ });
328
+ child.stdin?.end(JSON.stringify(event));
329
+ child.unref();
330
+ }
331
+ catch {
332
+ // Telemetry is best-effort and must never delay local enforcement.
333
+ }
334
+ }
335
+ async function runSecurityEventUpload() {
336
+ const config = (0, utils_1.readConfig)();
337
+ if (!config)
338
+ return;
339
+ const startedAt = Date.now();
340
+ try {
341
+ const raw = await readStdin();
342
+ const event = JSON.parse(raw);
343
+ const eventWithEndpoint = (0, security_1.withEndpointIdentity)(event, {
344
+ endpointId: config.deviceId,
345
+ endpointName: config.deviceName,
346
+ cliVersion: version_1.CLI_VERSION,
347
+ });
348
+ const result = await postJsonForResponse(`${config.backendUrl}/api/cli/securityEvent`, { ...eventWithEndpoint }, config.token, 1500);
349
+ if (result?.policy?.schemaVersion === 1)
350
+ (0, securityPolicy_1.writeEndpointSecurityPolicy)(result.policy);
351
+ (0, captureLog_1.appendCaptureLog)({
352
+ stage: "security",
353
+ sessionId: event.sessionId,
354
+ outcome: "ok",
355
+ detail: `phase=${event.phase} elapsedMs=${Date.now() - startedAt} verdict=${String(result?.verdict ?? "unknown")}`,
356
+ });
357
+ }
358
+ catch (err) {
359
+ (0, captureLog_1.appendCaptureLog)({
360
+ stage: "security",
361
+ sessionId: "upload",
362
+ outcome: "ERROR",
363
+ detail: `elapsedMs=${Date.now() - startedAt} ${err.message}`,
364
+ });
365
+ }
366
+ }
259
367
  // Raw-bytes POST to a Convex storage upload URL (no auth header — the URL
260
368
  // itself is the one-time credential). Returns the parsed { storageId }.
261
369
  function postBytes(url, bytes, contentType) {
@@ -379,6 +487,20 @@ async function main() {
379
487
  }
380
488
  process.exit(0);
381
489
  }
490
+ // Daily component drift scan runs in a detached hook process. The parent
491
+ // SessionStart path only performs a timestamp read, so agent startup never
492
+ // waits for filesystem traversal or an inventory upload.
493
+ if (process.argv[2] === "security-inventory-upload") {
494
+ const config = (0, utils_1.readConfig)();
495
+ if (config) {
496
+ const inventory = (0, securityInventory_1.buildSecurityInventory)({ workspace: process.argv[3] || process.cwd() });
497
+ try {
498
+ await (0, securityInventory_1.uploadSecurityInventory)(inventory, config);
499
+ }
500
+ catch { /* retry after the daily inventory window */ }
501
+ }
502
+ process.exit(0);
503
+ }
382
504
  // Detached backup uploader child (spawned by runBackup below). The job file
383
505
  // carries only { root, files }; the child re-reads config from disk. No stdin
384
506
  // needed, so handle it before readStdin() like the capture uploader above.
@@ -415,6 +537,13 @@ async function main() {
415
537
  }
416
538
  process.exit(0);
417
539
  }
540
+ // Detached, best-effort security telemetry. Local policy decisions have
541
+ // already completed in the parent hook; this child can never delay or alter
542
+ // the developer's action.
543
+ if (process.argv[2] === "security-upload") {
544
+ await runSecurityEventUpload();
545
+ process.exit(0);
546
+ }
418
547
  let hookInput = {};
419
548
  try {
420
549
  const raw = await readStdin();
@@ -432,8 +561,32 @@ async function main() {
432
561
  if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
433
562
  process.exit(0);
434
563
  }
564
+ const securityCommand = process.argv[2] ?? "";
565
+ const securityMatch = securityCommand.match(/^security-(pre|post|failure)(?:-(codex|codie))?$/);
566
+ if (securityMatch) {
567
+ const source = securityMatch[2] === "codex"
568
+ ? "codex"
569
+ : securityMatch[2] === "codie"
570
+ ? "codie-harness"
571
+ : "claude-code";
572
+ await runSecurityHook(hookInput, securityMatch[1], source);
573
+ process.exit(0);
574
+ }
435
575
  // UserPromptSubmit discovery path — separate from session capture.
436
- if (process.argv[2] === "discover") {
576
+ const promptCommand = process.argv[2] ?? "";
577
+ if (process.argv[2] === "discover" ||
578
+ process.argv[2] === "discover-codex" ||
579
+ process.argv[2] === "discover-codie") {
580
+ const source = promptCommand === "discover-codex"
581
+ ? "codex"
582
+ : promptCommand === "discover-codie"
583
+ ? "codie-harness"
584
+ : "claude-code";
585
+ // Security runs first and locally. A blocked prompt is erased before it
586
+ // reaches the model, so discovery must not send it to the backend.
587
+ if (runPromptSecurityHook(hookInput, source))
588
+ return;
589
+ dispatchAllowedPromptSecurityEvent(hookInput, source);
437
590
  await runDiscover(hookInput.prompt);
438
591
  return;
439
592
  }
@@ -444,6 +597,16 @@ async function main() {
444
597
  }
445
598
  // SessionStart: post the capture-coverage denominator ping. Tiny + fast.
446
599
  if (process.argv[2] === "session-start" || process.argv[2] === "session-start-opencode") {
600
+ if ((0, securityInventory_1.securityInventoryDue)()) {
601
+ try {
602
+ const child = (0, child_process_1.spawn)(process.execPath, [process.argv[1], "security-inventory-upload", hookInput.cwd || process.cwd()], {
603
+ detached: true,
604
+ stdio: "ignore",
605
+ });
606
+ child.unref();
607
+ }
608
+ catch { /* daily inventory is best-effort */ }
609
+ }
447
610
  await runSessionStart(hookInput, process.argv[2] === "session-start-opencode" ? "opencode" : "claude-code");
448
611
  return;
449
612
  }
@@ -0,0 +1,97 @@
1
+ export type SecurityHookPhase = "pre" | "post" | "failure";
2
+ export type AgentSurface = "claude-code" | "codex" | "codie-harness";
3
+ export interface RawSecurityHookInput {
4
+ session_id?: string;
5
+ sessionId?: string;
6
+ cwd?: string;
7
+ hook_event_name?: string;
8
+ hookEventName?: string;
9
+ tool_name?: string;
10
+ toolName?: string;
11
+ tool_input?: unknown;
12
+ toolInput?: unknown;
13
+ tool_response?: unknown;
14
+ toolResponse?: unknown;
15
+ tool_use_id?: string;
16
+ toolUseId?: string;
17
+ duration_ms?: number;
18
+ durationMs?: number;
19
+ error?: unknown;
20
+ prompt?: string;
21
+ }
22
+ export interface SecurityWorkflowEvent {
23
+ schemaVersion: 1;
24
+ source: AgentSurface;
25
+ phase: SecurityHookPhase;
26
+ sessionId: string;
27
+ toolName: string;
28
+ toolUseId?: string;
29
+ cwd?: string;
30
+ observedAt: number;
31
+ toolDurationMs?: number;
32
+ payload: unknown;
33
+ redactionCount: number;
34
+ localDecision?: LocalSecurityAction;
35
+ localCategory?: LocalSecurityDecision["category"];
36
+ endpointId?: string;
37
+ endpointName?: string;
38
+ cliVersion?: string;
39
+ }
40
+ export type LocalSecurityAction = "allow" | "alert" | "block";
41
+ 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";
42
+ export interface EndpointSecurityPolicy {
43
+ schemaVersion: 1;
44
+ policyVersion: number;
45
+ enforcementMode: "alert" | "block";
46
+ rules: Partial<Record<SecurityCategory, LocalSecurityAction>>;
47
+ constraints?: {
48
+ allowedMcpServers?: string[];
49
+ allowedHosts?: string[];
50
+ allowedCommands?: string[];
51
+ };
52
+ exceptions: Array<{
53
+ scope: "company" | "user" | "source" | "repository" | "tool" | "category";
54
+ scopeValue: string;
55
+ }>;
56
+ fetchedAt: number;
57
+ signature?: {
58
+ algorithm: "Ed25519";
59
+ keyId: string;
60
+ signedAt: number;
61
+ value: string;
62
+ };
63
+ }
64
+ export interface LocalSecurityDecision {
65
+ action: LocalSecurityAction;
66
+ category?: SecurityCategory;
67
+ reason?: string;
68
+ }
69
+ export declare function withEndpointIdentity(event: SecurityWorkflowEvent, identity: {
70
+ endpointId?: string;
71
+ endpointName?: string;
72
+ cliVersion?: string;
73
+ }): SecurityWorkflowEvent;
74
+ /**
75
+ * Runs before a user prompt reaches the model. This is intentionally narrow:
76
+ * block credential material, but allow safe references such as
77
+ * `$OPENAI_API_KEY` so developers can ask agents to use environment variables
78
+ * without pasting their values.
79
+ */
80
+ export declare function evaluatePromptSecurity(prompt: string | undefined, policy?: EndpointSecurityPolicy, source?: AgentSurface): LocalSecurityDecision;
81
+ /** Runs synchronously before a tool executes. No network or model is involved. */
82
+ export declare function evaluateToolSecurity(input: RawSecurityHookInput, phase: SecurityHookPhase, policy?: EndpointSecurityPolicy, source?: AgentSurface, sessionContext?: {
83
+ sensitiveReadAt?: number;
84
+ now?: number;
85
+ }): LocalSecurityDecision;
86
+ export declare function securityInputHasSensitivePath(input: RawSecurityHookInput): boolean;
87
+ export declare function buildSecurityWorkflowEvent(input: RawSecurityHookInput, phase: SecurityHookPhase, source: AgentSurface, now?: () => number): SecurityWorkflowEvent | null;
88
+ /**
89
+ * Captures the intent side of the causal chain without weakening the local
90
+ * privacy boundary. A prompt that the deterministic policy blocks never
91
+ * becomes an uploadable event; allowed prompts are redacted and bounded using
92
+ * the same sanitizer as tool payloads.
93
+ */
94
+ export declare function buildSecurityPromptEvent(input: Pick<RawSecurityHookInput, "session_id" | "sessionId" | "cwd" | "prompt">, source: AgentSurface, now?: () => number, policy?: EndpointSecurityPolicy): SecurityWorkflowEvent | null;
95
+ /** Audit-safe evidence for a blocked prompt. The secret-bearing prompt is
96
+ * intentionally omitted; administrators still see that prevention occurred. */
97
+ export declare function buildBlockedSecurityPromptEvent(input: Pick<RawSecurityHookInput, "session_id" | "sessionId" | "cwd">, source: AgentSurface, category: NonNullable<LocalSecurityDecision["category"]>, now?: () => number): SecurityWorkflowEvent | null;