failproofai 0.0.8 → 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 (131) 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]__0h3orxc._.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/{151bdxm9n-pry.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/{0mbc8hyeqe2c4.js → 0jce49ygr4fdv.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/0mungg3~jpwe7.js +1 -0
  90. package/.next/standalone/.next/static/chunks/{175-vim0.ztb2.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/{0eowehbf5egcz.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 +1039 -281
  105. package/package.json +2 -2
  106. package/src/hooks/builtin-policies.ts +29 -6
  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]__0_rr1ty._.js +0 -3
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dj-tbi._.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/096~b1zwv69ph.js +0 -1
  123. package/.next/standalone/.next/static/chunks/0bkir2pd22ski.js +0 -1
  124. package/.next/standalone/.next/static/chunks/0ksdlt_1hucdm.js +0 -1
  125. package/.next/standalone/.next/static/chunks/0lua3p__elu_..js +0 -6
  126. package/.next/standalone/.next/static/chunks/0mir9jdxn35~s.css +0 -1
  127. package/.next/standalone/.next/static/chunks/0s_18.dox44e9.js +0 -1
  128. package/.next/standalone/.next/static/chunks/0t3euwspxi_zg.js +0 -1
  129. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_buildManifest.js +0 -0
  130. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_clientMiddlewareManifest.js +0 -0
  131. /package/.next/standalone/.next/static/{RYld7TSCDXm2_WhJq20rD → oUO8u4z9JvtTzS_2RJoGo}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.8",
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
  }
@@ -1483,7 +1504,9 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1483
1504
  name: "block-sudo",
1484
1505
  description: "Block sudo commands",
1485
1506
  fn: blockSudo,
1486
- 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"] },
1487
1510
  defaultEnabled: true,
1488
1511
  category: "Dangerous Commands",
1489
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).
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Per-CLI hook integration registry.
3
+ *
4
+ * An `Integration` describes how failproofai hooks are installed, detected, and
5
+ * read for a specific agent CLI (Claude Code, OpenAI Codex). The runtime hot
6
+ * path (`handler.ts`, `policy-evaluator.ts`, `BUILTIN_POLICIES`, `policy-helpers`)
7
+ * is agent-agnostic — only install/uninstall plumbing varies.
8
+ */
9
+ import { execSync } from "node:child_process";
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
11
+ import { resolve, dirname } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import {
14
+ HOOK_EVENT_TYPES,
15
+ HOOK_SCOPES,
16
+ CODEX_HOOK_EVENT_TYPES,
17
+ CODEX_HOOK_SCOPES,
18
+ CODEX_EVENT_MAP,
19
+ FAILPROOFAI_HOOK_MARKER,
20
+ INTEGRATION_TYPES,
21
+ type IntegrationType,
22
+ type HookScope,
23
+ type ClaudeSettings,
24
+ type ClaudeHookMatcher,
25
+ type ClaudeHookEntry,
26
+ type CodexHookEventType,
27
+ } from "./types";
28
+
29
+ // ── Generic helpers ─────────────────────────────────────────────────────────
30
+
31
+ function readJsonFile(path: string): Record<string, unknown> {
32
+ if (!existsSync(path)) return {};
33
+ const raw = readFileSync(path, "utf8");
34
+ return JSON.parse(raw) as Record<string, unknown>;
35
+ }
36
+
37
+ function writeJsonFile(path: string, data: Record<string, unknown>): void {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
40
+ }
41
+
42
+ function isMarkedHook(hook: Record<string, unknown>): boolean {
43
+ if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true;
44
+ // Fallback for legacy installs predating the marker
45
+ const cmd = typeof hook.command === "string" ? hook.command : "";
46
+ return cmd.includes("failproofai") && cmd.includes("--hook");
47
+ }
48
+
49
+ function binaryExists(name: string): boolean {
50
+ try {
51
+ const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
52
+ execSync(cmd, { encoding: "utf8", stdio: "pipe" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ // ── Integration interface ───────────────────────────────────────────────────
60
+
61
+ export interface Integration {
62
+ id: IntegrationType;
63
+ displayName: string;
64
+ /** Settings scopes this integration supports (e.g. claude: user/project/local; codex: user/project). */
65
+ scopes: readonly HookScope[];
66
+ /** Hook events this integration fires (Claude: PascalCase, Codex: snake_case stored as Pascal in settings). */
67
+ eventTypes: readonly string[];
68
+
69
+ /** Resolve the per-scope settings/hooks file path. */
70
+ getSettingsPath(scope: HookScope, cwd?: string): string;
71
+
72
+ /** Read the raw settings/hooks file (returns {} when missing). */
73
+ readSettings(settingsPath: string): Record<string, unknown>;
74
+
75
+ /** Write the settings/hooks file. */
76
+ writeSettings(settingsPath: string, settings: Record<string, unknown>): void;
77
+
78
+ /** Build a single hook entry for a given event. */
79
+ buildHookEntry(binaryPath: string, eventType: string, scope?: HookScope): Record<string, unknown>;
80
+
81
+ /** Whether a hook entry is owned by failproofai. */
82
+ isFailproofaiHook(hook: Record<string, unknown>): boolean;
83
+
84
+ /** Mutate `settings` in place, registering failproofai across all event types. Idempotent. */
85
+ writeHookEntries(settings: Record<string, unknown>, binaryPath: string, scope?: HookScope): void;
86
+
87
+ /** Remove all failproofai hook entries from a settings file. Returns the number removed. */
88
+ removeHooksFromFile(settingsPath: string): number;
89
+
90
+ /** Whether failproofai hooks are present in a given scope. */
91
+ hooksInstalledInSettings(scope: HookScope, cwd?: string): boolean;
92
+
93
+ /** Whether the agent CLI binary is installed (probes PATH). */
94
+ detectInstalled(): boolean;
95
+ }
96
+
97
+ // ── Claude Code integration ─────────────────────────────────────────────────
98
+
99
+ export const claudeCode: Integration = {
100
+ id: "claude",
101
+ displayName: "Claude Code",
102
+ scopes: HOOK_SCOPES,
103
+ eventTypes: HOOK_EVENT_TYPES,
104
+
105
+ getSettingsPath(scope, cwd) {
106
+ const base = cwd ? resolve(cwd) : process.cwd();
107
+ switch (scope) {
108
+ case "user":
109
+ return resolve(homedir(), ".claude", "settings.json");
110
+ case "project":
111
+ return resolve(base, ".claude", "settings.json");
112
+ case "local":
113
+ return resolve(base, ".claude", "settings.local.json");
114
+ }
115
+ },
116
+
117
+ readSettings(settingsPath) {
118
+ return readJsonFile(settingsPath);
119
+ },
120
+
121
+ writeSettings(settingsPath, settings) {
122
+ writeJsonFile(settingsPath, settings);
123
+ },
124
+
125
+ buildHookEntry(binaryPath, eventType, scope) {
126
+ // No --cli flag on the Claude command line: the handler defaults to
127
+ // claude when --cli is omitted, preserving back-compat with hooks
128
+ // installed before multi-CLI support was added.
129
+ const command =
130
+ scope === "project"
131
+ ? `npx -y failproofai --hook ${eventType}`
132
+ : `"${binaryPath}" --hook ${eventType}`;
133
+ return {
134
+ type: "command",
135
+ command,
136
+ timeout: 60_000,
137
+ [FAILPROOFAI_HOOK_MARKER]: true,
138
+ };
139
+ },
140
+
141
+ isFailproofaiHook: isMarkedHook,
142
+
143
+ writeHookEntries(settings, binaryPath, scope) {
144
+ const s = settings as ClaudeSettings;
145
+ if (!s.hooks) s.hooks = {};
146
+
147
+ for (const eventType of HOOK_EVENT_TYPES) {
148
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
149
+ if (!s.hooks[eventType]) s.hooks[eventType] = [];
150
+ const matchers: ClaudeHookMatcher[] = s.hooks[eventType];
151
+
152
+ let found = false;
153
+ for (const matcher of matchers) {
154
+ if (!matcher.hooks) continue;
155
+ const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
156
+ if (idx >= 0) {
157
+ matcher.hooks[idx] = hookEntry;
158
+ found = true;
159
+ break;
160
+ }
161
+ }
162
+ if (!found) matchers.push({ hooks: [hookEntry] });
163
+ }
164
+ },
165
+
166
+ removeHooksFromFile(settingsPath) {
167
+ const settings = this.readSettings(settingsPath) as ClaudeSettings;
168
+ if (!settings.hooks) return 0;
169
+
170
+ let removed = 0;
171
+ for (const eventType of Object.keys(settings.hooks)) {
172
+ const matchers = settings.hooks[eventType];
173
+ if (!Array.isArray(matchers)) continue;
174
+ for (let i = matchers.length - 1; i >= 0; i--) {
175
+ const matcher = matchers[i];
176
+ if (!matcher.hooks) continue;
177
+ const before = matcher.hooks.length;
178
+ matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record<string, unknown>));
179
+ removed += before - matcher.hooks.length;
180
+ if (matcher.hooks.length === 0) matchers.splice(i, 1);
181
+ }
182
+ if (matchers.length === 0) delete settings.hooks[eventType];
183
+ }
184
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
185
+
186
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
187
+ return removed;
188
+ },
189
+
190
+ hooksInstalledInSettings(scope, cwd) {
191
+ const settingsPath = this.getSettingsPath(scope, cwd);
192
+ if (!existsSync(settingsPath)) return false;
193
+ try {
194
+ const settings = this.readSettings(settingsPath) as ClaudeSettings;
195
+ if (!settings.hooks) return false;
196
+ for (const matchers of Object.values(settings.hooks)) {
197
+ if (!Array.isArray(matchers)) continue;
198
+ for (const matcher of matchers) {
199
+ if (!matcher.hooks) continue;
200
+ if (matcher.hooks.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
201
+ }
202
+ }
203
+ } catch {
204
+ // Corrupt settings — treat as not installed
205
+ }
206
+ return false;
207
+ },
208
+
209
+ detectInstalled() {
210
+ return binaryExists("claude") || binaryExists("claude-code");
211
+ },
212
+ };
213
+
214
+ // ── OpenAI Codex integration ────────────────────────────────────────────────
215
+ //
216
+ // Codex's hook protocol is Claude-compatible by design (see the parity matrix
217
+ // in plans/great-in-failproofai-i-vectorized-treasure.md). The only material
218
+ // differences are:
219
+ // • Settings paths: ~/.codex/hooks.json (user) and <cwd>/.codex/hooks.json (project)
220
+ // • Stdin event names arrive snake_case (pre_tool_use); we canonicalize to PascalCase before policy lookup
221
+ // • No "local" scope
222
+ // • Settings file carries a top-level "version": 1 marker
223
+
224
+ interface CodexSettingsFile {
225
+ version?: number;
226
+ hooks?: Record<string, ClaudeHookMatcher[]>;
227
+ [key: string]: unknown;
228
+ }
229
+
230
+ export const codex: Integration = {
231
+ id: "codex",
232
+ displayName: "OpenAI Codex",
233
+ scopes: CODEX_HOOK_SCOPES,
234
+ eventTypes: CODEX_HOOK_EVENT_TYPES,
235
+
236
+ getSettingsPath(scope, cwd) {
237
+ const base = cwd ? resolve(cwd) : process.cwd();
238
+ switch (scope) {
239
+ case "user":
240
+ return resolve(homedir(), ".codex", "hooks.json");
241
+ case "project":
242
+ return resolve(base, ".codex", "hooks.json");
243
+ case "local":
244
+ // Codex has no "local" scope; fall back to project so callers don't crash.
245
+ // The CLI rejects --cli codex --scope local before reaching here.
246
+ return resolve(base, ".codex", "hooks.json");
247
+ }
248
+ },
249
+
250
+ readSettings(settingsPath) {
251
+ const raw = readJsonFile(settingsPath);
252
+ if (raw.version === undefined) raw.version = 1;
253
+ return raw;
254
+ },
255
+
256
+ writeSettings(settingsPath, settings) {
257
+ writeJsonFile(settingsPath, settings);
258
+ },
259
+
260
+ buildHookEntry(binaryPath, eventType, scope) {
261
+ // `eventType` here is the snake_case Codex event name; Codex stores under
262
+ // PascalCase keys but invokes the command with the snake_case form, which
263
+ // we canonicalize on the way into policy-evaluator.
264
+ const command =
265
+ scope === "project"
266
+ ? `npx -y failproofai --hook ${eventType} --cli codex`
267
+ : `"${binaryPath}" --hook ${eventType} --cli codex`;
268
+ return {
269
+ type: "command",
270
+ command,
271
+ timeout: 60_000,
272
+ [FAILPROOFAI_HOOK_MARKER]: true,
273
+ };
274
+ },
275
+
276
+ isFailproofaiHook: isMarkedHook,
277
+
278
+ writeHookEntries(settings, binaryPath, scope) {
279
+ const s = settings as CodexSettingsFile;
280
+ if (s.version === undefined) s.version = 1;
281
+ if (!s.hooks) s.hooks = {};
282
+
283
+ for (const eventType of CODEX_HOOK_EVENT_TYPES) {
284
+ const pascalKey = CODEX_EVENT_MAP[eventType as CodexHookEventType];
285
+ const hookEntry = this.buildHookEntry(binaryPath, eventType, scope) as unknown as ClaudeHookEntry;
286
+ if (!s.hooks[pascalKey]) s.hooks[pascalKey] = [];
287
+ const matchers: ClaudeHookMatcher[] = s.hooks[pascalKey];
288
+
289
+ let found = false;
290
+ for (const matcher of matchers) {
291
+ if (!matcher.hooks) continue;
292
+ const idx = matcher.hooks.findIndex((h) => isMarkedHook(h as Record<string, unknown>));
293
+ if (idx >= 0) {
294
+ matcher.hooks[idx] = hookEntry;
295
+ found = true;
296
+ break;
297
+ }
298
+ }
299
+ if (!found) matchers.push({ hooks: [hookEntry] });
300
+ }
301
+ },
302
+
303
+ removeHooksFromFile(settingsPath) {
304
+ const settings = this.readSettings(settingsPath) as CodexSettingsFile;
305
+ if (!settings.hooks) return 0;
306
+
307
+ let removed = 0;
308
+ for (const eventType of Object.keys(settings.hooks)) {
309
+ const matchers = settings.hooks[eventType];
310
+ if (!Array.isArray(matchers)) continue;
311
+ for (let i = matchers.length - 1; i >= 0; i--) {
312
+ const matcher = matchers[i];
313
+ if (!matcher.hooks) continue;
314
+ const before = matcher.hooks.length;
315
+ matcher.hooks = matcher.hooks.filter((h) => !isMarkedHook(h as Record<string, unknown>));
316
+ removed += before - matcher.hooks.length;
317
+ if (matcher.hooks.length === 0) matchers.splice(i, 1);
318
+ }
319
+ if (matchers.length === 0) delete settings.hooks[eventType];
320
+ }
321
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
322
+
323
+ this.writeSettings(settingsPath, settings as Record<string, unknown>);
324
+ return removed;
325
+ },
326
+
327
+ hooksInstalledInSettings(scope, cwd) {
328
+ const settingsPath = this.getSettingsPath(scope, cwd);
329
+ if (!existsSync(settingsPath)) return false;
330
+ try {
331
+ const settings = this.readSettings(settingsPath) as CodexSettingsFile;
332
+ if (!settings.hooks) return false;
333
+ for (const matchers of Object.values(settings.hooks)) {
334
+ if (!Array.isArray(matchers)) continue;
335
+ for (const matcher of matchers) {
336
+ if (!matcher.hooks) continue;
337
+ if (matcher.hooks.some((h) => isMarkedHook(h as Record<string, unknown>))) return true;
338
+ }
339
+ }
340
+ } catch {
341
+ // Corrupt settings — treat as not installed
342
+ }
343
+ return false;
344
+ },
345
+
346
+ detectInstalled() {
347
+ return binaryExists("codex");
348
+ },
349
+ };
350
+
351
+ // ── Registry ────────────────────────────────────────────────────────────────
352
+
353
+ const INTEGRATIONS: Record<IntegrationType, Integration> = {
354
+ claude: claudeCode,
355
+ codex,
356
+ };
357
+
358
+ export function getIntegration(id: IntegrationType): Integration {
359
+ const integration = INTEGRATIONS[id];
360
+ if (!integration) {
361
+ throw new Error(`Unknown integration: ${id}. Valid: ${INTEGRATION_TYPES.join(", ")}`);
362
+ }
363
+ return integration;
364
+ }
365
+
366
+ export function listIntegrations(): Integration[] {
367
+ return INTEGRATION_TYPES.map((id) => INTEGRATIONS[id]);
368
+ }
369
+
370
+ /** Detect which agent CLIs are installed on PATH. */
371
+ export function detectInstalledClis(): IntegrationType[] {
372
+ return INTEGRATION_TYPES.filter((id) => INTEGRATIONS[id].detectInstalled());
373
+ }