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
@@ -1,19 +1,20 @@
1
1
  /**
2
- * Install/remove/list failproofai hooks in Claude Code's settings.
2
+ * Install/remove/list failproofai hooks for one or more agent CLIs.
3
+ *
4
+ * Per-CLI path resolution and settings I/O live in `./integrations` (one
5
+ * `Integration` impl per CLI). This module orchestrates: validation, policy
6
+ * selection, telemetry, multi-scope warnings, and console output.
3
7
  */
4
8
  import { execSync } from "node:child_process";
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
- import { resolve, dirname, basename } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { resolve, basename } from "node:path";
7
11
  import { homedir, platform, arch, release, hostname } from "node:os";
8
12
  import {
9
- HOOK_EVENT_TYPES,
10
13
  HOOK_SCOPES,
11
- FAILPROOFAI_HOOK_MARKER,
12
14
  type HookScope,
13
- type ClaudeHookEntry,
14
- type ClaudeHookMatcher,
15
- type ClaudeSettings,
15
+ type IntegrationType,
16
16
  } from "./types";
17
+ import { claudeCode, getIntegration } from "./integrations";
17
18
  import { promptPolicySelection } from "./install-prompt";
18
19
  import { readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig } from "./hooks-config";
19
20
  import type { HooksConfig } from "./policy-types";
@@ -25,16 +26,9 @@ import { CliError } from "../cli-error";
25
26
 
26
27
  const VALID_POLICY_NAMES = new Set(BUILTIN_POLICIES.map((p) => p.name));
27
28
 
29
+ /** Settings path for the Claude Code integration. Kept as a public export for `app/actions/get-hooks-config.ts`. */
28
30
  export function getSettingsPath(scope: HookScope, cwd?: string): string {
29
- const base = cwd ? resolve(cwd) : process.cwd();
30
- switch (scope) {
31
- case "user":
32
- return resolve(homedir(), ".claude", "settings.json");
33
- case "project":
34
- return resolve(base, ".claude", "settings.json");
35
- case "local":
36
- return resolve(base, ".claude", "settings.local.json");
37
- }
31
+ return claudeCode.getSettingsPath(scope, cwd);
38
32
  }
39
33
 
40
34
  function scopeLabel(scope: HookScope): string {
@@ -48,20 +42,11 @@ function scopeLabel(scope: HookScope): string {
48
42
  }
49
43
  }
50
44
 
51
- function readSettings(settingsPath: string): ClaudeSettings {
52
- if (!existsSync(settingsPath)) {
53
- return {};
54
- }
55
- const raw = readFileSync(settingsPath, "utf8");
56
- return JSON.parse(raw) as ClaudeSettings;
57
- }
58
-
59
- function writeSettings(settingsPath: string, settings: ClaudeSettings): void {
60
- mkdirSync(dirname(settingsPath), { recursive: true });
61
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
62
- }
63
-
64
45
  function resolveFailproofaiBinary(): string {
46
+ // Test/CI override: lets E2E tests point at the in-tree bin/failproofai.mjs
47
+ // without requiring `npm install -g` or `bun link`.
48
+ const override = process.env.FAILPROOFAI_BINARY_OVERRIDE;
49
+ if (override && override.trim()) return override.trim();
65
50
  try {
66
51
  const cmd = process.platform === "win32" ? "where failproofai" : "which failproofai";
67
52
  const result = execSync(cmd, { encoding: "utf8" }).trim();
@@ -75,13 +60,6 @@ function resolveFailproofaiBinary(): string {
75
60
  }
76
61
  }
77
62
 
78
- function isFailproofaiHook(hook: Record<string, unknown>): boolean {
79
- if (hook[FAILPROOFAI_HOOK_MARKER] === true) return true;
80
- // Fallback for legacy installs that predate the marker
81
- const cmd = typeof hook.command === "string" ? hook.command : "";
82
- return cmd.includes("failproofai") && cmd.includes("--hook");
83
- }
84
-
85
63
  function validatePolicyNames(names: string[]): void {
86
64
  const invalid = names.filter((n) => !VALID_POLICY_NAMES.has(n));
87
65
  if (invalid.length > 0) {
@@ -105,67 +83,7 @@ function deduplicateScopes(scopes: readonly HookScope[], cwd?: string): HookScop
105
83
  }
106
84
 
107
85
  export function hooksInstalledInSettings(scope: HookScope, cwd?: string): boolean {
108
- const settingsPath = getSettingsPath(scope, cwd);
109
- if (!existsSync(settingsPath)) return false;
110
- try {
111
- const settings = readSettings(settingsPath);
112
- if (!settings.hooks) return false;
113
- for (const matchers of Object.values(settings.hooks)) {
114
- if (!Array.isArray(matchers)) continue;
115
- for (const matcher of matchers) {
116
- if (!matcher.hooks) continue;
117
- if (matcher.hooks.some((h) => isFailproofaiHook(h as Record<string, unknown>))) {
118
- return true;
119
- }
120
- }
121
- }
122
- } catch {
123
- // Corrupted settings — treat as not installed
124
- }
125
- return false;
126
- }
127
-
128
-
129
- function removeHooksFromSettingsFile(settingsPath: string): number {
130
- const settings = readSettings(settingsPath);
131
-
132
- if (!settings.hooks) return 0;
133
-
134
- let removed = 0;
135
-
136
- for (const eventType of Object.keys(settings.hooks)) {
137
- const matchers = settings.hooks[eventType];
138
- if (!Array.isArray(matchers)) continue;
139
-
140
- for (let i = matchers.length - 1; i >= 0; i--) {
141
- const matcher = matchers[i];
142
- if (!matcher.hooks) continue;
143
-
144
- const before = matcher.hooks.length;
145
- matcher.hooks = matcher.hooks.filter(
146
- (h) => !isFailproofaiHook(h as Record<string, unknown>)
147
- );
148
- removed += before - matcher.hooks.length;
149
-
150
- // Remove empty matchers
151
- if (matcher.hooks.length === 0) {
152
- matchers.splice(i, 1);
153
- }
154
- }
155
-
156
- // Remove empty event type arrays
157
- if (matchers.length === 0) {
158
- delete settings.hooks[eventType];
159
- }
160
- }
161
-
162
- // Remove empty hooks object
163
- if (Object.keys(settings.hooks).length === 0) {
164
- delete settings.hooks;
165
- }
166
-
167
- writeSettings(settingsPath, settings);
168
- return removed;
86
+ return claudeCode.hooksInstalledInSettings(scope, cwd);
169
87
  }
170
88
 
171
89
  /**
@@ -185,6 +103,7 @@ export async function installHooks(
185
103
  source?: string,
186
104
  customPoliciesPath?: string,
187
105
  removeCustomHooks = false,
106
+ cli?: IntegrationType[],
188
107
  ): Promise<void> {
189
108
  // Validate user input first before any system checks
190
109
  if (policyNames !== undefined && policyNames.length > 0) {
@@ -200,6 +119,21 @@ export async function installHooks(
200
119
  }
201
120
  }
202
121
 
122
+ // Back-compat default: ["claude"]. Callers (bin/failproofai.mjs) prompt
123
+ // the user for multi-CLI selection before reaching here when --cli is omitted.
124
+ const selectedClis: IntegrationType[] = cli && cli.length > 0 ? [...new Set(cli)] : ["claude"];
125
+
126
+ // Per-CLI scope validation: Codex doesn't have a "local" scope.
127
+ for (const cliId of selectedClis) {
128
+ const integration = getIntegration(cliId);
129
+ if (!integration.scopes.includes(scope)) {
130
+ throw new CliError(
131
+ `Scope "${scope}" is not supported by ${integration.displayName}. ` +
132
+ `Valid scopes: ${integration.scopes.join(", ")}`
133
+ );
134
+ }
135
+ }
136
+
203
137
  const binaryPath = resolveFailproofaiBinary();
204
138
 
205
139
  // Capture existing config before overwriting (used for telemetry diff)
@@ -259,52 +193,17 @@ export async function installHooks(
259
193
  console.log(`Custom hooks path: ${configToWrite.customPoliciesPath}`);
260
194
  }
261
195
 
262
- const settingsPath = getSettingsPath(scope, cwd);
263
- const settings = readSettings(settingsPath);
264
-
265
- if (!settings.hooks) {
266
- settings.hooks = {};
196
+ // Write hooks for each selected CLI
197
+ const writtenSettingsPaths: { cli: IntegrationType; path: string }[] = [];
198
+ for (const cliId of selectedClis) {
199
+ const integration = getIntegration(cliId);
200
+ const settingsPath = integration.getSettingsPath(scope, cwd);
201
+ const settings = integration.readSettings(settingsPath);
202
+ integration.writeHookEntries(settings, binaryPath, scope);
203
+ integration.writeSettings(settingsPath, settings);
204
+ writtenSettingsPaths.push({ cli: cliId, path: settingsPath });
267
205
  }
268
206
 
269
- for (const eventType of HOOK_EVENT_TYPES) {
270
- const command = scope === "project"
271
- ? `npx -y failproofai --hook ${eventType}`
272
- : `"${binaryPath}" --hook ${eventType}`;
273
- const hookEntry: ClaudeHookEntry = {
274
- type: "command",
275
- command,
276
- timeout: 60_000,
277
- [FAILPROOFAI_HOOK_MARKER]: true,
278
- };
279
-
280
- if (!settings.hooks[eventType]) {
281
- settings.hooks[eventType] = [];
282
- }
283
-
284
- const matchers: ClaudeHookMatcher[] = settings.hooks[eventType];
285
-
286
- // Find existing failproofai matcher
287
- let found = false;
288
- for (const matcher of matchers) {
289
- if (!matcher.hooks) continue;
290
- const failproofaiIdx = matcher.hooks.findIndex((h: ClaudeHookEntry | Record<string, unknown>) =>
291
- isFailproofaiHook(h as Record<string, unknown>)
292
- );
293
- if (failproofaiIdx >= 0) {
294
- matcher.hooks[failproofaiIdx] = hookEntry;
295
- found = true;
296
- break;
297
- }
298
- }
299
-
300
- if (!found) {
301
- // Append a new matcher with the failproofai hook
302
- matchers.push({ hooks: [hookEntry] });
303
- }
304
- }
305
-
306
- writeSettings(settingsPath, settings);
307
-
308
207
  // Telemetry: track successful hook installation (with diff vs previous config)
309
208
  try {
310
209
  const newSet = new Set(selectedPolicies);
@@ -313,6 +212,8 @@ export async function installHooks(
313
212
  const distinctId = getInstanceId();
314
213
  await trackHookEvent(distinctId, "hooks_installed", {
315
214
  scope,
215
+ cli: selectedClis,
216
+ cli_count: selectedClis.length,
316
217
  policies: selectedPolicies,
317
218
  policy_count: selectedPolicies.length,
318
219
  policies_added: policiesAdded,
@@ -331,8 +232,14 @@ export async function installHooks(
331
232
  // Telemetry is best-effort — never block the operation
332
233
  }
333
234
 
334
- console.log(`Failproof AI hooks installed for all ${HOOK_EVENT_TYPES.length} event types (scope: ${scope}).`);
335
- console.log(`Settings: ${settingsPath}`);
235
+ for (const { cli: cliId, path } of writtenSettingsPaths) {
236
+ const integration = getIntegration(cliId);
237
+ console.log(
238
+ `Failproof AI hooks installed for ${integration.displayName} ` +
239
+ `(${integration.eventTypes.length} event types, scope: ${scope}).`
240
+ );
241
+ console.log(`Settings: ${path}`);
242
+ }
336
243
  if (scope === "project") {
337
244
  console.log(`Command: npx -y failproofai`);
338
245
  console.log(`\nThis file can be committed to git — no machine-specific paths.`);
@@ -340,7 +247,7 @@ export async function installHooks(
340
247
  console.log(`Binary: ${binaryPath}`);
341
248
  }
342
249
 
343
- // Warn about duplicate-scope installations
250
+ // Warn about duplicate-scope installations (Claude Code only — uses HOOK_SCOPES)
344
251
  const otherScopes = deduplicateScopes(HOOK_SCOPES, cwd).filter((s) => s !== scope);
345
252
  const duplicates = otherScopes.filter((s) => hooksInstalledInSettings(s, cwd));
346
253
  if (duplicates.length > 0) {
@@ -362,9 +269,13 @@ export async function installHooks(
362
269
  * @param scope — settings scope to remove from (default: "user"), or "all" to remove from all scopes
363
270
  * @param opts.betaOnly — set to true when removing only beta policies (adds beta_only flag to telemetry)
364
271
  */
365
- export async function removeHooks(policyNames?: string[], scope: HookScope | "all" = "user", cwd?: string, opts?: { betaOnly?: boolean; source?: string; removeCustomHooks?: boolean }): Promise<void> {
272
+ export async function removeHooks(policyNames?: string[], scope: HookScope | "all" = "user", cwd?: string, opts?: { betaOnly?: boolean; source?: string; removeCustomHooks?: boolean; cli?: IntegrationType[] }): Promise<void> {
366
273
  // Resolve the effective config scope ("all" falls back to "user" for config reads/writes)
367
274
  const configScope: HookScope = scope === "all" ? "user" : scope;
275
+ // Back-compat default: ["claude"]. The bin layer prompts for CLI selection
276
+ // when --cli is omitted and an interactive TTY is attached.
277
+ const selectedClis: IntegrationType[] =
278
+ opts?.cli && opts.cli.length > 0 ? [...new Set(opts.cli)] : ["claude"];
368
279
 
369
280
  // Clear custom hooks path if requested
370
281
  if (opts?.removeCustomHooks) {
@@ -401,6 +312,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
401
312
  const actuallyRemoved = policyNames.filter((p) => config.enabledPolicies.includes(p));
402
313
  await trackHookEvent(distinctId, "hooks_removed", {
403
314
  scope,
315
+ cli: selectedClis,
404
316
  removal_mode: opts?.betaOnly ? "beta_policies" : "policies",
405
317
  beta_only: opts?.betaOnly ?? false,
406
318
  policies_removed: actuallyRemoved,
@@ -423,44 +335,56 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
423
335
  // Capture enabled policies before clearing (used for accurate telemetry below)
424
336
  const configBeforeRemoval = readScopedHooksConfig(configScope, cwd);
425
337
 
426
- // Remove all failproofai hooks from Claude Code settings
427
- const scopesToRemove: HookScope[] = scope === "all" ? [...HOOK_SCOPES] : [scope];
338
+ // Remove failproofai hooks from each selected CLI's settings file(s)
428
339
  let totalRemoved = 0;
340
+ let nothingToReport = false;
341
+
342
+ for (const cliId of selectedClis) {
343
+ const integration = getIntegration(cliId);
344
+ // For "all" scope, iterate over the integration's scopes; otherwise, only
345
+ // touch the single scope (skipping CLIs that don't support it).
346
+ const scopesToRemove: HookScope[] =
347
+ scope === "all"
348
+ ? [...integration.scopes]
349
+ : integration.scopes.includes(scope)
350
+ ? [scope]
351
+ : [];
429
352
 
430
- for (const s of scopesToRemove) {
431
- const settingsPath = getSettingsPath(s, cwd);
353
+ for (const s of scopesToRemove) {
354
+ const settingsPath = integration.getSettingsPath(s, cwd);
432
355
 
433
- if (!existsSync(settingsPath)) {
434
- if (scope !== "all") {
435
- console.log("No settings file found. Nothing to remove.");
436
- return;
356
+ if (!existsSync(settingsPath)) {
357
+ if (scope !== "all" && selectedClis.length === 1) {
358
+ console.log("No settings file found. Nothing to remove.");
359
+ nothingToReport = true;
360
+ }
361
+ continue;
437
362
  }
438
- continue;
439
- }
440
363
 
441
- const settings = readSettings(settingsPath);
442
-
443
- if (!settings.hooks) {
444
- if (scope !== "all") {
364
+ const removed = integration.removeHooksFromFile(settingsPath);
365
+ if (removed === 0 && scope !== "all" && selectedClis.length === 1) {
445
366
  console.log("No hooks found in settings. Nothing to remove.");
446
- return;
367
+ nothingToReport = true;
368
+ continue;
447
369
  }
448
- continue;
449
- }
370
+ totalRemoved += removed;
450
371
 
451
- const removed = removeHooksFromSettingsFile(settingsPath);
452
- totalRemoved += removed;
453
-
454
- if (scope !== "all") {
455
- console.log(`Removed ${removed} failproofai hook(s) from settings.`);
456
- console.log(`Settings: ${settingsPath}`);
372
+ if (scope !== "all") {
373
+ console.log(`Removed ${removed} failproofai hook(s) from ${integration.displayName} settings.`);
374
+ console.log(`Settings: ${settingsPath}`);
375
+ }
457
376
  }
458
377
  }
459
378
 
379
+ if (nothingToReport && totalRemoved === 0) return;
380
+
460
381
  if (scope === "all") {
461
382
  console.log(`Removed ${totalRemoved} failproofai hook(s) from all scopes.`);
462
- for (const s of scopesToRemove) {
463
- console.log(` ${s}: ${getSettingsPath(s, cwd)}`);
383
+ for (const cliId of selectedClis) {
384
+ const integration = getIntegration(cliId);
385
+ for (const s of integration.scopes) {
386
+ console.log(` ${integration.displayName} / ${s}: ${integration.getSettingsPath(s, cwd)}`);
387
+ }
464
388
  }
465
389
  }
466
390
 
@@ -469,6 +393,7 @@ export async function removeHooks(policyNames?: string[], scope: HookScope | "al
469
393
  const distinctId = getInstanceId();
470
394
  await trackHookEvent(distinctId, "hooks_removed", {
471
395
  scope,
396
+ cli: selectedClis,
472
397
  removal_mode: "hooks",
473
398
  policies_removed: configBeforeRemoval.enabledPolicies,
474
399
  removed_count: totalRemoved,
@@ -76,6 +76,7 @@ export async function evaluatePolicies(
76
76
  toolName,
77
77
  toolInput,
78
78
  session,
79
+ cli: session?.cli,
79
80
  };
80
81
 
81
82
  // Track all instruct results (accumulated, does not short-circuit)
@@ -137,6 +138,28 @@ export async function evaluatePolicies(
137
138
  };
138
139
  }
139
140
 
141
+ if (eventType === "PermissionRequest") {
142
+ // Codex-only: hookSpecificOutput.decision.behavior = "allow" | "deny"
143
+ // (per https://developers.openai.com/codex/hooks#permissionrequest).
144
+ const response = {
145
+ hookSpecificOutput: {
146
+ hookEventName: eventType,
147
+ decision: {
148
+ behavior: "deny",
149
+ message: `Blocked ${displayTool} by failproofai because: ${reason}, as per the policy configured by the user`,
150
+ },
151
+ },
152
+ };
153
+ return {
154
+ exitCode: 0,
155
+ stdout: JSON.stringify(response),
156
+ stderr: "",
157
+ policyName: policy.name,
158
+ reason,
159
+ decision: "deny",
160
+ };
161
+ }
162
+
140
163
  if (eventType === "PostToolUse") {
141
164
  const response = {
142
165
  hookSpecificOutput: {
@@ -235,7 +258,11 @@ export async function evaluatePolicies(
235
258
  if (allowEntries.length > 0) {
236
259
  const combined = allowEntries.map((e) => e.reason).join("\n");
237
260
  const policyNames = allowEntries.map((e) => e.policyName);
238
- const supportsHookSpecificOutput = eventType === "PreToolUse" || eventType === "PostToolUse" || eventType === "UserPromptSubmit";
261
+ const supportsHookSpecificOutput =
262
+ eventType === "PreToolUse" ||
263
+ eventType === "PostToolUse" ||
264
+ eventType === "UserPromptSubmit" ||
265
+ eventType === "PermissionRequest";
239
266
  const response = supportsHookSpecificOutput
240
267
  ? { hookSpecificOutput: { hookEventName: eventType, additionalContext: `Note from failproofai: ${combined}` } }
241
268
  : { reason: combined };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Types for the hook policy system.
3
3
  */
4
- import type { HookEventType, SessionMetadata } from "./types";
4
+ import type { HookEventType, IntegrationType, SessionMetadata } from "./types";
5
5
 
6
6
  export type PolicyDecision = "allow" | "deny" | "instruct";
7
7
 
@@ -12,6 +12,8 @@ export interface PolicyContext {
12
12
  toolInput?: Record<string, unknown>;
13
13
  session?: SessionMetadata;
14
14
  params?: Record<string, unknown>;
15
+ /** Which agent CLI fired this hook. Mirrors session.cli; exposed at the top level for ergonomics. */
16
+ cli?: IntegrationType;
15
17
  }
16
18
 
17
19
  export interface PolicyResult {
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Per-CLI permission mode resolver.
3
+ *
4
+ * • Claude Code: reads `permission_mode` directly from the hook stdin payload.
5
+ * Possible values per `claude --help`: acceptEdits, auto, bypassPermissions,
6
+ * default, dontAsk, plan.
7
+ *
8
+ * • Codex: stdin doesn't carry the permission mode. We walk
9
+ * ~/.codex/sessions/<YYYY>/<MM>/<DD>/<file containing sessionId>.jsonl
10
+ * looking for a `turn_context` record whose payload has `approval_policy`,
11
+ * and map: never → full-auto, on-request → default. Other values pass
12
+ * through. If the transcript can't be read, falls back to "default".
13
+ *
14
+ * Hot-path note: handleHookEvent calls this for every Codex tool use. To
15
+ * avoid an O(history-size) tree scan, we (1) try today + yesterday's date
16
+ * directories first (transcripts for an active session live there in the
17
+ * common case), (2) cache the resolved transcript path to disk keyed by
18
+ * sessionId so subsequent hooks in the same session skip the walk entirely.
19
+ */
20
+ import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ import type { IntegrationType } from "./types";
24
+
25
+ export function resolvePermissionMode(
26
+ integration: IntegrationType,
27
+ parsed: Record<string, unknown>,
28
+ sessionId: string | undefined,
29
+ ): string {
30
+ if (integration === "claude") {
31
+ return (parsed.permission_mode as string | undefined) ?? "default";
32
+ }
33
+
34
+ if (integration === "codex" && sessionId) {
35
+ return resolveCodexMode(sessionId) ?? "default";
36
+ }
37
+
38
+ return "default";
39
+ }
40
+
41
+ const CACHE_PATH = join(homedir(), ".failproofai", "cache", "codex-session-paths.json");
42
+
43
+ function readCache(): Record<string, string> {
44
+ try {
45
+ if (!existsSync(CACHE_PATH)) return {};
46
+ return JSON.parse(readFileSync(CACHE_PATH, "utf-8")) as Record<string, string>;
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ function writeCacheEntry(sessionId: string, path: string): void {
53
+ try {
54
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
55
+ const cache = readCache();
56
+ cache[sessionId] = path;
57
+ writeFileSync(CACHE_PATH, JSON.stringify(cache), "utf-8");
58
+ } catch {
59
+ // Cache is best-effort — never block the hook on a write failure.
60
+ }
61
+ }
62
+
63
+ function dirSearch(dir: string, sessionId: string): string | null {
64
+ try {
65
+ for (const f of readdirSync(dir, { withFileTypes: true })) {
66
+ if (f.isFile() && f.name.includes(sessionId) && f.name.endsWith(".jsonl")) {
67
+ return join(dir, f.name);
68
+ }
69
+ }
70
+ } catch {
71
+ // dir doesn't exist or unreadable
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function findCodexTranscriptSync(sessionId: string): string | null {
77
+ // 1) Cache hit — fastest path, O(1).
78
+ const cache = readCache();
79
+ const cached = cache[sessionId];
80
+ if (cached && existsSync(cached)) return cached;
81
+
82
+ const root = join(homedir(), ".codex", "sessions");
83
+
84
+ // 2) Today + yesterday (covers the active-session common case).
85
+ const today = new Date();
86
+ const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
87
+ const datedDirs = [today, yesterday].map((d) => {
88
+ const y = String(d.getUTCFullYear());
89
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
90
+ const day = String(d.getUTCDate()).padStart(2, "0");
91
+ return join(root, y, m, day);
92
+ });
93
+ for (const dir of datedDirs) {
94
+ const hit = dirSearch(dir, sessionId);
95
+ if (hit) {
96
+ writeCacheEntry(sessionId, hit);
97
+ return hit;
98
+ }
99
+ }
100
+
101
+ // 3) Fallback — full tree scan (rare; older or out-of-clock-skew sessions).
102
+ try {
103
+ for (const y of readdirSync(root, { withFileTypes: true })) {
104
+ if (!y.isDirectory()) continue;
105
+ for (const m of readdirSync(join(root, y.name), { withFileTypes: true })) {
106
+ if (!m.isDirectory()) continue;
107
+ for (const d of readdirSync(join(root, y.name, m.name), { withFileTypes: true })) {
108
+ if (!d.isDirectory()) continue;
109
+ const hit = dirSearch(join(root, y.name, m.name, d.name), sessionId);
110
+ if (hit) {
111
+ writeCacheEntry(sessionId, hit);
112
+ return hit;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ } catch {
118
+ // Session may not have flushed yet; or path doesn't exist.
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function resolveCodexMode(sessionId: string): string | undefined {
124
+ try {
125
+ const path = findCodexTranscriptSync(sessionId);
126
+ if (!path) return undefined;
127
+ for (const line of readFileSync(path, "utf-8").split("\n")) {
128
+ if (!line.includes("turn_context")) continue;
129
+ try {
130
+ const obj = JSON.parse(line) as Record<string, unknown>;
131
+ if (obj.type === "turn_context") {
132
+ const policy = (obj.payload as Record<string, unknown> | undefined)?.approval_policy as
133
+ | string
134
+ | undefined;
135
+ if (policy === "never") return "full-auto";
136
+ if (policy === "on-request") return "default";
137
+ if (policy) return policy;
138
+ }
139
+ } catch {
140
+ // skip malformed line
141
+ }
142
+ }
143
+ } catch {
144
+ // file vanished or permission denied — fall through to undefined
145
+ }
146
+ return undefined;
147
+ }
@@ -1,10 +1,35 @@
1
1
  /**
2
- * Constants and interfaces for Claude Code hooks integration.
2
+ * Constants and interfaces for agent CLI hooks integrations (Claude Code, OpenAI Codex, …).
3
3
  */
4
4
 
5
5
  export const HOOK_SCOPES = ["user", "project", "local"] as const;
6
6
  export type HookScope = (typeof HOOK_SCOPES)[number];
7
7
 
8
+ export const INTEGRATION_TYPES = ["claude", "codex"] as const;
9
+ export type IntegrationType = (typeof INTEGRATION_TYPES)[number];
10
+
11
+ export const CODEX_HOOK_SCOPES = ["user", "project"] as const;
12
+ export type CodexHookScope = (typeof CODEX_HOOK_SCOPES)[number];
13
+
14
+ export const CODEX_HOOK_EVENT_TYPES = [
15
+ "session_start",
16
+ "pre_tool_use",
17
+ "permission_request",
18
+ "post_tool_use",
19
+ "user_prompt_submit",
20
+ "stop",
21
+ ] as const;
22
+ export type CodexHookEventType = (typeof CODEX_HOOK_EVENT_TYPES)[number];
23
+
24
+ export const CODEX_EVENT_MAP: Record<CodexHookEventType, HookEventType> = {
25
+ session_start: "SessionStart",
26
+ pre_tool_use: "PreToolUse",
27
+ permission_request: "PermissionRequest",
28
+ post_tool_use: "PostToolUse",
29
+ user_prompt_submit: "UserPromptSubmit",
30
+ stop: "Stop",
31
+ };
32
+
8
33
  export const HOOK_EVENT_TYPES = [
9
34
  "SessionStart",
10
35
  "SessionEnd",
@@ -32,6 +57,8 @@ export const HOOK_EVENT_TYPES = [
32
57
  "PostCompact",
33
58
  "Elicitation",
34
59
  "ElicitationResult",
60
+ "UserPromptExpansion",
61
+ "PostToolBatch",
35
62
  ] as const;
36
63
 
37
64
  export type HookEventType = (typeof HOOK_EVENT_TYPES)[number];
@@ -55,6 +82,8 @@ export interface SessionMetadata {
55
82
  cwd?: string;
56
83
  permissionMode?: string;
57
84
  hookEventName?: string;
85
+ /** Which agent CLI fired this hook (claude | codex). Set by handler.ts from --cli. */
86
+ cli?: IntegrationType;
58
87
  }
59
88
 
60
89
  export interface ClaudeSettings {