failproofai 0.0.7 → 0.0.9-beta.0

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 (130) hide show
  1. package/.next/standalone/.codex/hooks.json +77 -0
  2. package/.next/standalone/.next/BUILD_ID +1 -1
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/required-server-files.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  21. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  22. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  23. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  26. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  27. package/.next/standalone/.next/server/app/index.html +1 -1
  28. package/.next/standalone/.next/server/app/index.rsc +16 -16
  29. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  31. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  32. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  33. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  34. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  35. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  38. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  39. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +1 -1
  54. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_05pz9._._.js +1 -1
  55. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  56. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0m72uj7._.js → [root-of-the-server]__03rd.z8._.js} +2 -2
  57. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__09icjsf._.js +2 -2
  59. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0zn7uo6._.js → [root-of-the-server]__0ca1zru._.js} +2 -2
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e74wa-._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ea22pr._.js +3 -0
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0okos0k._.js → [root-of-the-server]__0vu.o-3._.js} +3 -3
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w6l33k._.js +7 -7
  66. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0of~riu._.js → [root-of-the-server]__0zqcovi._.js} +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/_07a1g.3._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/ssr/_0uy6m~m._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/ssr/_0zaq1hm._.js +3 -0
  72. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/_11rg2a_._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0h9llsw._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_0mebn66._.js +1 -1
  78. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  79. package/.next/standalone/.next/server/pages/404.html +2 -2
  80. package/.next/standalone/.next/server/pages/500.html +1 -1
  81. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  82. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  83. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +1 -0
  84. package/.next/standalone/.next/static/chunks/00b5h4r1el.6f.js +1 -0
  85. package/.next/standalone/.next/static/chunks/{01l2mh88iy.ga.js → 03lsndql_yml5.js} +1 -1
  86. package/.next/standalone/.next/static/chunks/0amfi~vb_gfgo.js +1 -0
  87. package/.next/standalone/.next/static/chunks/0fw2h.g66c0h3.js +1 -0
  88. package/.next/standalone/.next/static/chunks/{0f_9854du76y2.js → 0jce49ygr4fdv.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/{0388wpenm9-a4.js → 0mungg3~jpwe7.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{0x0o8~u4jsatb.js → 0uq_5p-p7myfe.js} +2 -2
  91. package/.next/standalone/.next/static/chunks/0v.xuf4ynzp~~.js +6 -0
  92. package/.next/standalone/.next/static/chunks/{0kkzzoo.s-t3p.js → 0vb8xxj_v2tz8.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{0vlk_pv4somht.js → 0vwqucikost_q.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/0~mroziiwl1m5.js +1 -0
  95. package/.next/standalone/app/actions/install-hooks-web.ts +21 -5
  96. package/.next/standalone/app/policies/hooks-client.tsx +23 -0
  97. package/.next/standalone/assets/logos/claude.svg +1 -0
  98. package/.next/standalone/assets/logos/openai-dark.svg +1 -0
  99. package/.next/standalone/assets/logos/openai-light.svg +1 -0
  100. package/.next/standalone/package.json +2 -2
  101. package/.next/standalone/server.js +1 -1
  102. package/README.md +22 -3
  103. package/bin/failproofai.mjs +89 -9
  104. package/dist/cli.mjs +1040 -297
  105. package/package.json +2 -2
  106. package/src/hooks/builtin-policies.ts +39 -33
  107. package/src/hooks/handler.ts +39 -10
  108. package/src/hooks/hook-activity-store.ts +2 -0
  109. package/src/hooks/install-prompt.ts +69 -0
  110. package/src/hooks/integrations.ts +373 -0
  111. package/src/hooks/manager.ts +96 -171
  112. package/src/hooks/policy-evaluator.ts +28 -1
  113. package/src/hooks/policy-types.ts +3 -1
  114. package/src/hooks/resolve-permission-mode.ts +147 -0
  115. package/src/hooks/types.ts +30 -1
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dj-tbi._.js +0 -3
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0tjjyb9._.js +0 -3
  118. package/.next/standalone/.next/server/chunks/ssr/_0h21oar._.js +0 -3
  119. package/.next/standalone/.next/server/chunks/ssr/_0i~.gk_._.js +0 -3
  120. package/.next/standalone/.next/server/chunks/ssr/_0q3h.2s._.js +0 -3
  121. package/.next/standalone/.next/server/chunks/ssr/_0x..fj-._.js +0 -3
  122. package/.next/standalone/.next/static/chunks/0a0lh_a4f_xs-.js +0 -6
  123. package/.next/standalone/.next/static/chunks/0bkir2pd22ski.js +0 -1
  124. package/.next/standalone/.next/static/chunks/0j2o20pqkib~d.js +0 -1
  125. package/.next/standalone/.next/static/chunks/0ksdlt_1hucdm.js +0 -1
  126. package/.next/standalone/.next/static/chunks/0mir9jdxn35~s.css +0 -1
  127. package/.next/standalone/.next/static/chunks/12wu.28cbx4dl.js +0 -1
  128. /package/.next/standalone/.next/static/{9FNjQiktocMN-qDiGqDL5 → oUO8u4z9JvtTzS_2RJoGo}/_buildManifest.js +0 -0
  129. /package/.next/standalone/.next/static/{9FNjQiktocMN-qDiGqDL5 → oUO8u4z9JvtTzS_2RJoGo}/_clientMiddlewareManifest.js +0 -0
  130. /package/.next/standalone/.next/static/{9FNjQiktocMN-qDiGqDL5 → oUO8u4z9JvtTzS_2RJoGo}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.7",
3
+ "version": "0.0.9-beta.0",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs"
@@ -90,7 +90,7 @@
90
90
  "tailwind-merge": "^3.4.0",
91
91
  "tailwindcss": "^4.1.18",
92
92
  "typescript": "^6.0.2",
93
- "@anthropic-ai/sdk": "^0.90.0",
93
+ "@anthropic-ai/sdk": "^0.91.1",
94
94
  "vitest": "^4.0.18"
95
95
  },
96
96
  "dependencies": {
@@ -10,15 +10,36 @@ import { allow, deny, instruct } from "./policy-helpers";
10
10
  import { normalizePolicyName, registerPolicy } from "./policy-registry";
11
11
  import { hookLogWarn } from "./hook-logger";
12
12
 
13
- function isClaudeInternalPath(resolved: string): boolean {
14
- const claudeDir = join(homedir(), ".claude");
15
- return resolved === claudeDir || resolved.startsWith(claudeDir + "/");
13
+ /**
14
+ * Whether `resolved` lives under an agent CLI's home directory
15
+ * (~/.claude/ or ~/.codex/). Used to whitelist agent self-reads of their own
16
+ * config and transcripts.
17
+ */
18
+ function isAgentInternalPath(resolved: string): boolean {
19
+ for (const dir of [".claude", ".codex"]) {
20
+ const root = join(homedir(), dir);
21
+ if (resolved === root || resolved.startsWith(root + "/")) return true;
22
+ }
23
+ return false;
16
24
  }
17
25
 
18
- function isClaudeSettingsFile(resolved: string): boolean {
19
- return /[\\/]\.claude[\\/]settings(?:\.[^/\\]+)?\.json$/.test(resolved);
26
+ /**
27
+ * Whether `resolved` is a settings/hooks file for an agent CLI:
28
+ * • Claude Code: `.claude/settings.json`, `.claude/settings.local.json`, etc.
29
+ * • Codex: `.codex/hooks.json`
30
+ * These must NEVER be edited by the agent itself — that would let it disable
31
+ * its own protections.
32
+ */
33
+ function isAgentSettingsFile(resolved: string): boolean {
34
+ if (/[\\/]\.claude[\\/]settings(?:\.[^/\\]+)?\.json$/.test(resolved)) return true;
35
+ if (/[\\/]\.codex[\\/]hooks\.json$/.test(resolved)) return true;
36
+ return false;
20
37
  }
21
38
 
39
+ // Back-compat aliases — kept for any caller that imports the old names.
40
+ const isClaudeInternalPath = isAgentInternalPath;
41
+ const isClaudeSettingsFile = isAgentSettingsFile;
42
+
22
43
  function getCommand(ctx: PolicyContext): string {
23
44
  return (ctx.toolInput?.command as string) ?? "";
24
45
  }
@@ -1204,36 +1225,19 @@ function requirePrBeforeStop(ctx: PolicyContext): PolicyResult {
1204
1225
  return allow(`PR #${pr.number} exists: ${pr.url}`);
1205
1226
  }
1206
1227
 
1207
- // PR is merged/closed. The earlier origin/{baseBranch} checks may have
1208
- // used a stale ref. Fetch and re-verify before denying.
1228
+ // Trust GitHub's authoritative state. Local-ref reconciliation can never
1229
+ // converge after squash-merge or rebase-merge (the original branch commit
1230
+ // is orphaned, never an ancestor of base) or when base is auto-modified
1231
+ // post-merge (e.g. release-workflow version bumps). The PR being MERGED
1232
+ // is itself the proof that the work shipped.
1209
1233
  if (pr.state === "MERGED") {
1210
- try {
1211
- execFileSync("git", ["fetch", "origin", `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`], {
1212
- cwd,
1213
- encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
1214
- timeout: 10000,
1215
- });
1216
- const freshAhead = execFileSync(
1217
- "git",
1218
- ["log", `origin/${baseBranch}..HEAD`, "--oneline"],
1219
- { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1220
- ).trim();
1221
- if (!freshAhead) {
1222
- return allow(`PR #${pr.number} was merged; branch is up to date with ${baseBranch}.`);
1223
- }
1224
- const freshDiff = execFileSync(
1225
- "git",
1226
- ["diff", "--stat", `origin/${baseBranch}`, "HEAD"],
1227
- { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 },
1228
- ).trim();
1229
- if (!freshDiff) {
1230
- return allow(`PR #${pr.number} was merged; no file changes vs ${baseBranch}.`);
1231
- }
1232
- } catch {
1233
- // Fetch or git command failed — fall through to deny
1234
- }
1234
+ return allow(
1235
+ `PR #${pr.number} was merged: ${pr.url}. ` +
1236
+ `Switch off this branch (e.g. 'git checkout ${baseBranch} && git pull') before stopping again.`,
1237
+ );
1235
1238
  }
1236
1239
 
1240
+ // Reaches here only for CLOSED-without-merge — PR was rejected.
1237
1241
  return deny(
1238
1242
  `Pull request for branch "${branch}" is ${pr.state.toLowerCase()}. Run now: gh pr create`,
1239
1243
  );
@@ -1500,7 +1504,9 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1500
1504
  name: "block-sudo",
1501
1505
  description: "Block sudo commands",
1502
1506
  fn: blockSudo,
1503
- match: { events: ["PreToolUse"], toolNames: ["Bash"] },
1507
+ // PermissionRequest is Codex's escalation-approval event; fire the same
1508
+ // sudo guard there so Codex sandbox bypasses are blocked too.
1509
+ match: { events: ["PreToolUse", "PermissionRequest"], toolNames: ["Bash"] },
1504
1510
  defaultEnabled: true,
1505
1511
  category: "Dangerous Commands",
1506
1512
  params: {
@@ -5,7 +5,8 @@
5
5
  * ~/.failproofai/policies-config.json, evaluates matching policies, persists
6
6
  * activity to disk, and returns the appropriate exit code + stdout response.
7
7
  */
8
- import type { HookEventType, SessionMetadata } from "./types";
8
+ import type { HookEventType, IntegrationType, SessionMetadata, CodexHookEventType } from "./types";
9
+ import { CODEX_EVENT_MAP } from "./types";
9
10
  import type { PolicyFunction, PolicyResult } from "./policy-types";
10
11
  import { readMergedHooksConfig } from "./hooks-config";
11
12
  import { registerBuiltinPolicies } from "./builtin-policies";
@@ -15,10 +16,28 @@ import { loadAllCustomHooks } from "./custom-hooks-loader";
15
16
  import type { CustomHook } from "./policy-types";
16
17
  import { persistHookActivity } from "./hook-activity-store";
17
18
  import { trackHookEvent } from "./hook-telemetry";
19
+ import { resolvePermissionMode } from "./resolve-permission-mode";
18
20
  import { getInstanceId } from "../../lib/telemetry-id";
19
21
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
20
22
 
21
- export async function handleHookEvent(eventType: string): Promise<number> {
23
+ /**
24
+ * Canonicalize an event name to PascalCase. Codex sends snake_case event names
25
+ * on stdin and as the --hook arg; Claude Code sends PascalCase. The internal
26
+ * registry, builtin policies, and policy.match.events all key on PascalCase.
27
+ */
28
+ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
29
+ if (cli === "codex") {
30
+ const mapped = CODEX_EVENT_MAP[raw as CodexHookEventType];
31
+ if (mapped) return mapped;
32
+ }
33
+ // Already PascalCase or unknown — pass through; HOOK_EVENT_TYPES type-checks downstream.
34
+ return raw as HookEventType;
35
+ }
36
+
37
+ export async function handleHookEvent(
38
+ eventType: string,
39
+ cli: IntegrationType = "claude",
40
+ ): Promise<number> {
22
41
  const startTime = performance.now();
23
42
 
24
43
  // Read stdin payload (Claude passes JSON)
@@ -57,13 +76,18 @@ export async function handleHookEvent(eventType: string): Promise<number> {
57
76
  }
58
77
  }
59
78
 
79
+ // Canonicalize event name (Codex sends snake_case; internals expect PascalCase)
80
+ const canonicalEventType = canonicalizeEventType(eventType, cli);
81
+
60
82
  // Extract session metadata from payload
83
+ const sessionId = parsed.session_id as string | undefined;
61
84
  const session: SessionMetadata = {
62
- sessionId: parsed.session_id as string | undefined,
85
+ sessionId,
63
86
  transcriptPath: parsed.transcript_path as string | undefined,
64
87
  cwd: parsed.cwd as string | undefined,
65
- permissionMode: parsed.permission_mode as string | undefined,
88
+ permissionMode: resolvePermissionMode(cli, parsed, sessionId),
66
89
  hookEventName: parsed.hook_event_name as string | undefined,
90
+ cli,
67
91
  };
68
92
 
69
93
  // Load enabled policies (merge across project/local/global scopes)
@@ -98,6 +122,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
98
122
  hook_name: hookName,
99
123
  error_type: isTimeout ? "timeout" : "exception",
100
124
  event_type: eventType,
125
+ cli,
101
126
  is_convention_policy: isConvention,
102
127
  convention_scope: conventionScope ?? null,
103
128
  });
@@ -116,6 +141,7 @@ export async function handleHookEvent(eventType: string): Promise<number> {
116
141
  // Fire telemetry once per invocation for custom hook loads
117
142
  if (customHooksList.length > 0) {
118
143
  void trackHookEvent(getInstanceId(), "custom_hooks_loaded", {
144
+ cli,
119
145
  custom_hooks_count: customHooksList.length,
120
146
  custom_hook_names: customHooksList.map((h) => h.name),
121
147
  event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
@@ -125,7 +151,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
125
151
  // Fire telemetry for convention-based policy discovery
126
152
  if (loadResult.conventionSources.length > 0) {
127
153
  void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
128
- event_type: eventType,
154
+ event_type: canonicalEventType,
155
+ cli,
129
156
  project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
130
157
  user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
131
158
  convention_hook_count: conventionHookNames.size,
@@ -133,10 +160,10 @@ export async function handleHookEvent(eventType: string): Promise<number> {
133
160
  });
134
161
  }
135
162
 
136
- hookLogInfo(`event=${eventType} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
163
+ hookLogInfo(`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
137
164
 
138
- // Evaluate policies
139
- const result = await evaluatePolicies(eventType as HookEventType, parsed, session, config);
165
+ // Evaluate policies (use canonical PascalCase event type)
166
+ const result = await evaluatePolicies(canonicalEventType, parsed, session, config);
140
167
  const durationMs = Math.round(performance.now() - startTime);
141
168
  hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`);
142
169
 
@@ -150,7 +177,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
150
177
  // Persist activity to disk (visible in /policies activity tab)
151
178
  const activityEntry = {
152
179
  timestamp: Date.now(),
153
- eventType,
180
+ eventType: canonicalEventType,
181
+ integration: cli,
154
182
  toolName: (parsed.tool_name as string) ?? null,
155
183
  policyName: result.policyName,
156
184
  policyNames: result.policyNames,
@@ -203,7 +231,8 @@ export async function handleHookEvent(eventType: string): Promise<number> {
203
231
  : [];
204
232
  const distinctId = getInstanceId();
205
233
  await trackHookEvent(distinctId, "hook_policy_triggered", {
206
- event_type: eventType,
234
+ event_type: canonicalEventType,
235
+ cli,
207
236
  tool_name: (parsed.tool_name as string) ?? null,
208
237
  policy_name: result.policyName,
209
238
  decision: result.decision,
@@ -41,6 +41,8 @@ let rotateSeq = 0;
41
41
  export interface HookActivityEntry {
42
42
  timestamp: number;
43
43
  eventType: string;
44
+ /** Which agent CLI fired the hook (claude | codex). */
45
+ integration?: string;
44
46
  toolName: string | null;
45
47
  policyName: string | null;
46
48
  policyNames?: string[];
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import * as readline from "node:readline";
13
13
  import { BUILTIN_POLICIES } from "./builtin-policies";
14
+ import { detectInstalledClis, getIntegration } from "./integrations";
15
+ import type { IntegrationType } from "./types";
14
16
 
15
17
  interface SelectItem {
16
18
  name: string;
@@ -28,6 +30,73 @@ export interface PromptOptions {
28
30
  includeBeta?: boolean;
29
31
  }
30
32
 
33
+ /**
34
+ * Resolve which agent CLIs to install hooks for.
35
+ *
36
+ * Rules:
37
+ * • If `explicit` is provided (from `--cli`), use it as-is.
38
+ * • Else, detect installed CLIs (PATH probe).
39
+ * • If exactly one detected → use just that one (no prompt).
40
+ * • If both detected and stdin is a TTY → ask single-keypress B/C/D.
41
+ * • Otherwise → default to ["claude"] for back-compat.
42
+ *
43
+ * Returns the selected IntegrationType[] (always non-empty).
44
+ */
45
+ export async function resolveTargetClis(explicit?: IntegrationType[]): Promise<IntegrationType[]> {
46
+ if (explicit && explicit.length > 0) return [...new Set(explicit)];
47
+
48
+ const detected = detectInstalledClis();
49
+
50
+ if (detected.length === 0) {
51
+ console.log(
52
+ "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex). " +
53
+ "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m",
54
+ );
55
+ return ["claude"];
56
+ }
57
+
58
+ if (detected.length === 1) {
59
+ const integration = getIntegration(detected[0]);
60
+ console.log(`Detected ${integration.displayName}; installing hooks for it.`);
61
+ return detected;
62
+ }
63
+
64
+ // Both detected. Prompt or default.
65
+ if (!process.stdin.isTTY) return detected; // non-interactive: install for both
66
+
67
+ const labels = detected.map((id) => getIntegration(id).displayName).join(" + ");
68
+ process.stdout.write(
69
+ `Detected ${labels}. Install for [B]oth (default), [C]laude Code only, or co[D]ex only? `,
70
+ );
71
+
72
+ return new Promise<IntegrationType[]>((resolve) => {
73
+ readline.emitKeypressEvents(process.stdin);
74
+ const wasRaw = process.stdin.isRaw;
75
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
76
+ const restore = () => {
77
+ if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false);
78
+ process.stdin.removeListener("keypress", onKey);
79
+ };
80
+ const onKey = (str: string, key: { ctrl?: boolean; name?: string } | undefined) => {
81
+ // Honor Ctrl+C / Ctrl+D as abort — restore terminal and exit, never
82
+ // silently install for both. Mirrors the keypress contract used by
83
+ // promptPolicySelection().
84
+ if (key && key.ctrl && (key.name === "c" || key.name === "d")) {
85
+ restore();
86
+ process.stdout.write("\n");
87
+ process.exit(130); // SIGINT-equivalent
88
+ }
89
+ const ch = (str || "").toLowerCase();
90
+ restore();
91
+ process.stdout.write("\n");
92
+ if (ch === "c") resolve(["claude"]);
93
+ else if (ch === "d") resolve(["codex"]);
94
+ else resolve(detected); // Enter, B, anything else → both
95
+ };
96
+ process.stdin.on("keypress", onKey);
97
+ });
98
+ }
99
+
31
100
  /**
32
101
  * Show interactive searchable policy selector.
33
102
  * @param preSelected — policy names to pre-check (e.g. from existing config).