failproofai 0.0.15 → 1.0.0-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 (136) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/index.html +1 -1
  39. package/.next/standalone/.next/server/app/index.rsc +14 -14
  40. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  41. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  42. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  43. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  44. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +9 -9
  49. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +3 -0
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  66. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0114ewg._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__0259h38._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zj-87g._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0hs_5mh.js → node_modules_html-to-image_es_index_1-upb14.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  84. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  85. package/.next/standalone/.next/server/pages/404.html +1 -1
  86. package/.next/standalone/.next/server/pages/500.html +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.json +11 -11
  89. package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 0ip1n8xmgxarv.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0u9hc6oxx-gpj.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 1d6ncspjagm53.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1grd7y94-igz9.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{3m8wmvlhsy1mx.js → 1s-_swlc80y43.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 29k6ef49zppxj.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 29ydr-8q0zis6.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2vr7aazw6k2-n.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 3wrgbih-516uj.js} +1 -1
  98. package/.next/standalone/Cargo.lock +428 -0
  99. package/.next/standalone/Cargo.toml +9 -0
  100. package/.next/standalone/crates/PROTOCOL.md +122 -0
  101. package/.next/standalone/crates/failproofaid/Cargo.toml +18 -0
  102. package/.next/standalone/crates/failproofaid/src/lock.rs +81 -0
  103. package/.next/standalone/crates/failproofaid/src/main.rs +83 -0
  104. package/.next/standalone/crates/failproofaid/src/paths.rs +172 -0
  105. package/.next/standalone/crates/failproofaid/src/server.rs +502 -0
  106. package/.next/standalone/crates/failproofaid/src/worker.rs +400 -0
  107. package/.next/standalone/crates/failproofaid/tests/daemon_e2e.rs +241 -0
  108. package/.next/standalone/crates/fpai-ipc/Cargo.toml +16 -0
  109. package/.next/standalone/crates/fpai-ipc/src/envelope.rs +175 -0
  110. package/.next/standalone/crates/fpai-ipc/src/framing.rs +177 -0
  111. package/.next/standalone/crates/fpai-ipc/src/lib.rs +10 -0
  112. package/.next/standalone/crates/fpai-ipc/src/peer.rs +80 -0
  113. package/.next/standalone/package.json +5 -3
  114. package/.next/standalone/rust-toolchain.toml +4 -0
  115. package/.next/standalone/server.js +1 -1
  116. package/bin/failproofai-worker.mjs +51 -0
  117. package/bin/failproofai.mjs +68 -1
  118. package/bin/failproofaid-shim.mjs +67 -0
  119. package/dist/cli.mjs +1202 -624
  120. package/dist/worker.mjs +8441 -0
  121. package/package.json +5 -3
  122. package/src/hooks/builtin-policies.ts +37 -13
  123. package/src/hooks/configure-wizard.ts +73 -1
  124. package/src/hooks/daemon-client.ts +199 -0
  125. package/src/hooks/daemon-download.ts +152 -0
  126. package/src/hooks/daemon-service.ts +449 -0
  127. package/src/hooks/handler.ts +231 -195
  128. package/src/hooks/normalize-cli-payload.ts +61 -0
  129. package/src/hooks/policy-types.ts +15 -0
  130. package/src/hooks/read-stdin.ts +48 -0
  131. package/src/hooks/worker-server.ts +154 -0
  132. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1is3glr._.js +0 -3
  133. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0fopait._.js +0 -3
  134. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_buildManifest.js +0 -0
  135. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_clientMiddlewareManifest.js +0 -0
  136. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → 7Lxtd352rSaHB-pmm6G8M}/_ssgManifest.js +0 -0
@@ -25,7 +25,8 @@ import {
25
25
  ANTIGRAVITY_EVENT_MAP,
26
26
  } from "./types";
27
27
  import { canonicalizeToolName, canonicalizeToolInput } from "./tool-name-canonicalize";
28
- import type { PolicyFunction, PolicyResult } from "./policy-types";
28
+ import { normalizeCliPayload } from "./normalize-cli-payload";
29
+ import type { PolicyFunction, PolicyResult, HooksConfig } from "./policy-types";
29
30
  import { readMergedHooksConfig } from "./hooks-config";
30
31
  import { registerBuiltinPolicies } from "./builtin-policies";
31
32
  import { evaluatePolicies } from "./policy-evaluator";
@@ -39,6 +40,7 @@ import { resolvePermissionMode } from "./resolve-permission-mode";
39
40
  import { resolveTranscriptPath } from "./resolve-transcript-path";
40
41
  import { getInstanceId } from "../../lib/telemetry-id";
41
42
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
43
+ import { readStdinPayload } from "./read-stdin";
42
44
 
43
45
  /**
44
46
  * Canonicalize an event name to PascalCase. Codex sends snake_case event names
@@ -49,8 +51,11 @@ import { hookLogInfo, hookLogWarn } from "./hook-logger";
49
51
  * NAMES arrive PascalCase already (note: tool names are a separate matter and
50
52
  * are canonicalized in canonicalizeToolName below). The internal
51
53
  * registry, builtin policies, and policy.match.events all key on PascalCase.
54
+ *
55
+ * Exported so fail-closed.ts can produce the same canonical event type
56
+ * without duplicating this per-CLI mapping.
52
57
  */
53
- function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
58
+ export function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType {
54
59
  if (cli === "codex") {
55
60
  const mapped = CODEX_EVENT_MAP[raw as CodexHookEventType];
56
61
  if (mapped) return mapped;
@@ -87,115 +92,79 @@ function canonicalizeEventType(raw: string, cli: IntegrationType): HookEventType
87
92
  return raw as HookEventType;
88
93
  }
89
94
 
90
- export async function handleHookEvent(
95
+ export interface HookEventOutcome {
96
+ exitCode: number;
97
+ stdout: string;
98
+ stderr: string;
99
+ }
100
+
101
+ export interface EvaluateHookEventOptions {
102
+ /**
103
+ * Await pending telemetry POSTs before returning. Defaults to `true`,
104
+ * matching the one-shot CLI process's need to flush before `process.exit()`
105
+ * drops anything in flight. The warm worker (which never exits between
106
+ * calls) passes `false` — telemetry simply resolves on its own schedule,
107
+ * a real latency win unlocked by the process staying alive.
108
+ */
109
+ awaitTelemetryFlush?: boolean;
110
+ /**
111
+ * When set, skips loading policies-config.json / builtins / custom hooks
112
+ * entirely and registers ONLY a single synthetic policy that always
113
+ * returns this decision — then runs the real, unmodified
114
+ * `evaluatePolicies()` so the response still gets this event/CLI's exact
115
+ * per-CLI shaping (Cursor's flat `continue:false`, Factory's exit-2, …).
116
+ * This is the fail-closed path: reusing the real evaluator for shaping
117
+ * instead of hand-rolling a generic denial avoids a decision that *looks*
118
+ * like protection but is silently inert for at least one CLI.
119
+ */
120
+ forceDecision?: { decision: "deny"; reason: string };
121
+ /**
122
+ * Used only when the stdin payload itself carries no `cwd` (and, for
123
+ * Cursor, no `workspace_roots`) — see `resolveCwd`. Callers running
124
+ * inside a long-lived process (the warm worker) MUST pass the
125
+ * *originating* CLI process's cwd here rather than leaving this unset:
126
+ * `readMergedHooksConfig`/`loadAllCustomHooks` fall back to
127
+ * `process.cwd()` internally when `session.cwd` is undefined, and inside
128
+ * a warm worker `process.cwd()` is fixed at wherever the worker itself
129
+ * was spawned — not wherever this particular request's session actually
130
+ * is. The one-shot CLI process doesn't need this: its own `process.cwd()`
131
+ * already IS the right value, which is exactly what that fallback was
132
+ * written for.
133
+ */
134
+ fallbackCwd?: string;
135
+ }
136
+
137
+ /**
138
+ * The core hook-evaluation logic, decoupled from process stdin/stdout so it
139
+ * can be called repeatedly inside a long-lived process (the daemon's warm
140
+ * worker) as well as from the one-shot `handleHookEvent` wrapper below.
141
+ * Never reads `process.stdin` or writes `process.stdout`/`process.stderr`
142
+ * itself — every caller gets the full `{exitCode, stdout, stderr}` back and
143
+ * decides what to do with it.
144
+ */
145
+ export async function evaluateHookEvent(
91
146
  eventType: string,
92
147
  cli: IntegrationType = "claude",
93
- ): Promise<number> {
148
+ stdinPayload: string,
149
+ opts?: EvaluateHookEventOptions,
150
+ ): Promise<HookEventOutcome> {
94
151
  const startTime = performance.now();
95
152
  try {
96
-
97
- // Read stdin payload (Claude passes JSON)
98
- const MAX_STDIN_BYTES = 1_048_576; // 1 MB
99
- let payload = "";
100
- let stdinOversized = false;
101
- try {
102
- payload = await new Promise<string>((resolve, reject) => {
103
- const chunks: string[] = [];
104
- let totalBytes = 0;
105
- process.stdin.setEncoding("utf8");
106
- process.stdin.on("data", (chunk: string) => {
107
- totalBytes += Buffer.byteLength(chunk);
108
- if (totalBytes > MAX_STDIN_BYTES) {
109
- hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`);
110
- stdinOversized = true;
111
- process.stdin.destroy();
112
- resolve("");
113
- return;
114
- }
115
- chunks.push(chunk);
116
- });
117
- process.stdin.on("end", () => resolve(chunks.join("")));
118
- process.stdin.on("error", reject);
119
- // If stdin is already closed or not piped, resolve immediately
120
- if (process.stdin.readableEnded) resolve("");
121
- });
122
- } catch (err) {
123
- hookLogWarn(`stdin read failed for ${eventType}`);
124
- void trackHookEvent(getInstanceId(), "hook_stdin_error", {
125
- event_type: eventType,
126
- cli,
127
- error_type: err instanceof Error ? err.name : "unknown",
128
- });
129
- }
130
- if (stdinOversized) {
131
- void trackHookEvent(getInstanceId(), "hook_stdin_error", {
132
- event_type: eventType,
133
- cli,
134
- error_type: "oversized",
135
- });
136
- }
137
-
138
153
  let parsed: Record<string, unknown> = {};
139
- if (payload) {
154
+ if (stdinPayload) {
140
155
  try {
141
- parsed = JSON.parse(payload) as Record<string, unknown>;
156
+ parsed = JSON.parse(stdinPayload) as Record<string, unknown>;
142
157
  } catch {
143
- hookLogWarn(`payload parse failed for ${eventType} (${payload.length} bytes)`);
158
+ hookLogWarn(`payload parse failed for ${eventType} (${stdinPayload.length} bytes)`);
144
159
  void trackHookEvent(getInstanceId(), "hook_payload_parse_error", {
145
160
  event_type: eventType,
146
161
  cli,
147
- payload_size: payload.length,
162
+ payload_size: stdinPayload.length,
148
163
  });
149
164
  }
150
165
  }
151
166
 
152
- // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields
153
- // the handler downstream reads to canonical snake_case BEFORE any
154
- // canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`,
155
- // `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`,
156
- // `transcriptPath` → `transcript_path`. Verified against agy v1.1.2.
157
- if (cli === "antigravity") {
158
- const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined;
159
- if (tc && typeof tc === "object") {
160
- if (tc.name !== undefined) parsed.tool_name = tc.name;
161
- if (tc.args !== undefined) parsed.tool_input = tc.args;
162
- }
163
- if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId;
164
- if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") {
165
- parsed.cwd = parsed.workspacePaths[0];
166
- }
167
- if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath;
168
- }
169
-
170
- // Copilot's snake_case events (PreToolUse/PostToolUse/Stop/…) are already
171
- // Claude-shaped, but `permissionRequest` alone pipes a camelCase payload
172
- // (`hookName`, `sessionId`, `toolName` in lowercase, `toolInput`) — verified
173
- // live against Copilot CLI 1.0.71. Normalize the fields the handler reads so
174
- // PermissionRequest-matched policies (e.g. block-sudo's Codex-escalation
175
- // guard) fire instead of seeing a null tool name.
176
- if (cli === "copilot") {
177
- if (typeof parsed.toolName === "string" && parsed.tool_name === undefined) {
178
- parsed.tool_name = parsed.toolName;
179
- }
180
- if (parsed.toolInput !== undefined && parsed.tool_input === undefined) {
181
- parsed.tool_input = parsed.toolInput;
182
- }
183
- if (typeof parsed.sessionId === "string" && parsed.session_id === undefined) {
184
- parsed.session_id = parsed.sessionId;
185
- }
186
- }
187
-
188
- // Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` /
189
- // `cwd` (its `tool_name` / `tool_input` are already canonical field names).
190
- // Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the
191
- // round-tripped event name is available. The --hook arg is already PascalCase,
192
- // so canonicalizeEventType needs no goose branch. Verified goose v1.43.0.
193
- if (cli === "goose") {
194
- if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir;
195
- if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) {
196
- parsed.hook_event_name = parsed.event;
197
- }
198
- }
167
+ normalizeCliPayload(cli, parsed);
199
168
 
200
169
  // Canonicalize event name (Codex sends snake_case; internals expect PascalCase)
201
170
  const canonicalEventType = canonicalizeEventType(eventType, cli);
@@ -226,7 +195,7 @@ export async function handleHookEvent(
226
195
  const session: SessionMetadata = {
227
196
  sessionId,
228
197
  transcriptPath: resolveTranscriptPath(cli, parsed, sessionId),
229
- cwd: resolveCwd(cli, parsed),
198
+ cwd: resolveCwd(cli, parsed) ?? opts?.fallbackCwd,
230
199
  permissionMode: resolvePermissionMode(cli, parsed, sessionId),
231
200
  hookEventName: parsed.hook_event_name as string | undefined,
232
201
  // Preserve the raw CLI-side event name (eventType arg) before
@@ -237,108 +206,117 @@ export async function handleHookEvent(
237
206
  cli,
238
207
  };
239
208
 
240
- // Load enabled policies (merge across project/local/global scopes)
241
- const config = readMergedHooksConfig(session.cwd);
242
- clearPolicies();
243
- registerBuiltinPolicies(config.enabledPolicies);
244
-
245
- // Load and register custom hooks (layer 2, after builtins)
246
- const loadResult = await loadAllCustomHooks(
247
- config.customPoliciesPaths ?? config.customPoliciesPath,
248
- {
249
- sessionCwd: session.cwd,
250
- customPoliciesEnabled: config.customPoliciesEnabled,
251
- },
252
- );
253
- const customHooksList = loadResult.hooks;
254
- const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
255
- const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
209
+ let config: HooksConfig;
210
+ let customHooksList: CustomHook[] = [];
211
+ let conventionHookNames = new Set<string>();
256
212
 
257
- for (const hook of customHooksList) {
258
- const policyId = (hook as CustomHook & { __policyId?: string }).__policyId;
259
- if (policyId && disabledCustomPolicies.has(policyId)) continue;
260
- const hookName = hook.name;
261
- const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
262
- const isConvention = !!conventionScope;
263
- const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
264
- const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
265
- try {
266
- const result = await Promise.race([
267
- hook.fn(ctx),
268
- new Promise<PolicyResult>((_, reject) =>
269
- setTimeout(() => reject(new Error("timeout")), 10_000),
270
- ),
271
- ]);
272
- return result;
273
- } catch (err) {
274
- const msg = err instanceof Error ? err.message : String(err);
275
- const isTimeout = msg === "timeout";
276
- hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
277
- void trackHookEvent(getInstanceId(), "custom_hook_error", {
278
- hook_name: hookName,
279
- error_type: isTimeout ? "timeout" : "exception",
280
- event_type: eventType,
281
- cli,
282
- is_convention_policy: isConvention,
283
- convention_scope: conventionScope ?? null,
284
- });
285
- return { decision: "allow" };
286
- }
287
- };
213
+ if (opts?.forceDecision) {
214
+ // Fail-closed: no config/custom-hook loading at all — a daemon that
215
+ // can't be reached can't have run any of a project's custom policies
216
+ // either, so there is nothing to load. One synthetic policy stands in
217
+ // for the entire enabled set.
218
+ config = { enabledPolicies: [] };
219
+ clearPolicies();
220
+ const decision = opts.forceDecision;
288
221
  registerPolicy(
289
- `${prefix}/${hookName}`,
290
- hook.description ?? "",
291
- fn,
292
- hook.match ?? {},
293
- -1, // Custom hooks run after builtins (priority 0)
222
+ "failproofai/daemon-unreachable",
223
+ "Fail-closed: the failproofaid daemon could not be reached for this daemon-configured scope.",
224
+ async (): Promise<PolicyResult> => ({ decision: decision.decision, reason: decision.reason }),
225
+ {},
294
226
  );
295
- }
227
+ } else {
228
+ // Load enabled policies (merge across project/local/global scopes)
229
+ config = readMergedHooksConfig(session.cwd);
230
+ clearPolicies();
231
+ registerBuiltinPolicies(config.enabledPolicies);
296
232
 
297
- // Fire telemetry once per invocation for custom hook loads
298
- if (customHooksList.length > 0) {
299
- void trackHookEvent(getInstanceId(), "custom_hooks_loaded", {
300
- cli,
301
- custom_hooks_count: customHooksList.length,
302
- custom_hook_names: customHooksList.map((h) => h.name),
303
- event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
233
+ // Load and register custom hooks (layer 2, after builtins)
234
+ const loadResult = await loadAllCustomHooks(config.customPoliciesPaths ?? config.customPoliciesPath, {
235
+ sessionCwd: session.cwd,
236
+ customPoliciesEnabled: config.customPoliciesEnabled,
304
237
  });
305
- }
238
+ customHooksList = loadResult.hooks;
239
+ const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
240
+ conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
306
241
 
307
- // Fire telemetry for convention-based policy discovery
308
- if (loadResult.conventionSources.length > 0) {
309
- void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
310
- event_type: canonicalEventType,
311
- cli,
312
- project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
313
- user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
314
- convention_hook_count: conventionHookNames.size,
315
- convention_hook_names: [...conventionHookNames],
316
- });
317
- }
242
+ for (const hook of customHooksList) {
243
+ const policyId = (hook as CustomHook & { __policyId?: string }).__policyId;
244
+ if (policyId && disabledCustomPolicies.has(policyId)) continue;
245
+ const hookName = hook.name;
246
+ const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
247
+ const isConvention = !!conventionScope;
248
+ const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
249
+ const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
250
+ try {
251
+ const result = await Promise.race([
252
+ hook.fn(ctx),
253
+ new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
254
+ ]);
255
+ return result;
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err);
258
+ const isTimeout = msg === "timeout";
259
+ hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`);
260
+ void trackHookEvent(getInstanceId(), "custom_hook_error", {
261
+ hook_name: hookName,
262
+ error_type: isTimeout ? "timeout" : "exception",
263
+ event_type: eventType,
264
+ cli,
265
+ is_convention_policy: isConvention,
266
+ convention_scope: conventionScope ?? null,
267
+ });
268
+ return { decision: "allow" };
269
+ }
270
+ };
271
+ registerPolicy(
272
+ `${prefix}/${hookName}`,
273
+ hook.description ?? "",
274
+ fn,
275
+ hook.match ?? {},
276
+ -1, // Custom hooks run after builtins (priority 0)
277
+ );
278
+ }
318
279
 
319
- hookLogInfo(`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`);
280
+ // Fire telemetry once per invocation for custom hook loads
281
+ if (customHooksList.length > 0) {
282
+ void trackHookEvent(getInstanceId(), "custom_hooks_loaded", {
283
+ cli,
284
+ custom_hooks_count: customHooksList.length,
285
+ custom_hook_names: customHooksList.map((h) => h.name),
286
+ event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))],
287
+ });
288
+ }
289
+
290
+ // Fire telemetry for convention-based policy discovery
291
+ if (loadResult.conventionSources.length > 0) {
292
+ void trackHookEvent(getInstanceId(), "convention_policies_loaded", {
293
+ event_type: canonicalEventType,
294
+ cli,
295
+ project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length,
296
+ user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length,
297
+ convention_hook_count: conventionHookNames.size,
298
+ convention_hook_names: [...conventionHookNames],
299
+ });
300
+ }
301
+
302
+ hookLogInfo(
303
+ `event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`,
304
+ );
305
+ }
320
306
 
321
307
  // Evaluate policies (use canonical PascalCase event type)
322
308
  const result = await evaluatePolicies(canonicalEventType, parsed, session, config);
323
309
  const durationMs = Math.round(performance.now() - startTime);
324
310
  hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`);
325
311
 
326
- if (result.stdout) {
327
- process.stdout.write(result.stdout);
328
- }
329
- if (result.stderr) {
330
- process.stderr.write(result.stderr);
331
- }
332
-
333
312
  // Which policies actually ran for this event, regardless of how they
334
313
  // decided. `result.policyName` names only the decider — null on a plain
335
314
  // allow — so without this a row cannot tell "your policy ran and allowed"
336
315
  // from "no policy covers this event". The lookup is the same cached call
337
316
  // the evaluator already made, so it costs nothing.
338
- const matchedPolicies = getPoliciesForEvent(
339
- canonicalEventType,
340
- parsed.tool_name as string | undefined,
341
- ).map((p) => p.name);
317
+ const matchedPolicies = getPoliciesForEvent(canonicalEventType, parsed.tool_name as string | undefined).map(
318
+ (p) => p.name,
319
+ );
342
320
 
343
321
  // Persist activity to disk (visible in /policies activity tab)
344
322
  const activityEntry = {
@@ -370,15 +348,15 @@ export async function handleHookEvent(
370
348
  const isCustomHook = result.policyName?.startsWith("custom/") ?? false;
371
349
  const isConventionPolicy = result.policyName?.startsWith(".failproofai-") ?? false;
372
350
  const conventionScope = isConventionPolicy
373
- ? result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null
351
+ ? (result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null)
374
352
  : null;
375
353
  const hasCustomParams =
376
- !isCustomHook && !isConventionPolicy && !!(result.policyName && config.policyParams?.[result.policyName]);
377
- const paramKeysOverridden = hasCustomParams
378
- ? Object.keys(config.policyParams![result.policyName!])
379
- : [];
354
+ !isCustomHook &&
355
+ !isConventionPolicy &&
356
+ !!(result.policyName && config.policyParams?.[result.policyName]);
357
+ const paramKeysOverridden = hasCustomParams ? Object.keys(config.policyParams![result.policyName!]) : [];
380
358
  const distinctId = getInstanceId();
381
- await trackHookEvent(distinctId, "hook_policy_triggered", {
359
+ const trackPromise = trackHookEvent(distinctId, "hook_policy_triggered", {
382
360
  event_type: canonicalEventType,
383
361
  cli,
384
362
  tool_name: (parsed.tool_name as string) ?? null,
@@ -390,17 +368,75 @@ export async function handleHookEvent(
390
368
  has_custom_params: hasCustomParams,
391
369
  param_keys_overridden: paramKeysOverridden,
392
370
  });
371
+ // Deny/instruct is exactly the response the daemon's warm worker
372
+ // needs to return fast — a live network POST to PostHog on this path
373
+ // (previously always awaited here, regardless of opts.awaitTelemetryFlush)
374
+ // made every deny/instruct decision pay a real network round-trip
375
+ // (hundreds of ms, up to sendEvent's 5s abort timeout when PostHog is
376
+ // unreachable) before the result could return. Caught by a real
377
+ // Docker daemon test: a `sudo` deny took ~700ms even against an
378
+ // already-warm worker and blew through the client's 150ms
379
+ // fail-closed budget every time. Same opt-out this file already
380
+ // grants `flushHookTelemetry()` below — the worker passes
381
+ // awaitTelemetryFlush:false and never calls that flush either, so
382
+ // this becomes true fire-and-forget for it.
383
+ if (opts?.awaitTelemetryFlush ?? true) {
384
+ await trackPromise;
385
+ }
393
386
  } catch {
394
387
  // Telemetry is best-effort — never block the hook
395
388
  }
396
389
  }
397
- return result.exitCode;
390
+
391
+ return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
398
392
  } finally {
399
- // Await any un-awaited (`void trackHookEvent(...)`) events fired during
400
- // this invocation. bin/failproofai.mjs calls process.exit() the moment we
401
- // return OR throw, which would otherwise drop in-flight POSTs — notably on
402
- // the allow path (no trailing awaited event) and on any early throw
403
- // (custom-hook load / policy eval) before the happy path is reached.
404
- await flushHookTelemetry();
393
+ if (opts?.awaitTelemetryFlush ?? true) {
394
+ // Await any un-awaited (`void trackHookEvent(...)`) events fired during
395
+ // this invocation. The one-shot CLI process calls process.exit() the
396
+ // moment it gets its result back, which would otherwise drop in-flight
397
+ // POSTs — notably on the allow path (no trailing awaited event) and on
398
+ // any early throw (custom-hook load / policy eval) before the happy
399
+ // path is reached. The warm worker passes awaitTelemetryFlush:false —
400
+ // it never exits between calls, so there's nothing to drop.
401
+ await flushHookTelemetry();
402
+ }
403
+ }
404
+ }
405
+
406
+ /**
407
+ * One-shot CLI entrypoint — reads `process.stdin`, evaluates the hook, writes
408
+ * `process.stdout`/`process.stderr`, and returns just the exit code. This is
409
+ * the exact contract `bin/failproofai.mjs`'s `--hook` path has always had;
410
+ * kept unchanged so nothing about the one-shot (non-daemon) path regresses.
411
+ * Internally now just a thin wrapper around `evaluateHookEvent`.
412
+ */
413
+ export async function handleHookEvent(eventType: string, cli: IntegrationType = "claude"): Promise<number> {
414
+ const MAX_STDIN_BYTES = 1_048_576; // 1 MB
415
+ const stdinRead = await readStdinPayload(MAX_STDIN_BYTES);
416
+ if (stdinRead.readError) {
417
+ hookLogWarn(`stdin read failed for ${eventType}`);
418
+ void trackHookEvent(getInstanceId(), "hook_stdin_error", {
419
+ event_type: eventType,
420
+ cli,
421
+ error_type: "unknown",
422
+ });
423
+ }
424
+ if (stdinRead.oversized) {
425
+ hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`);
426
+ void trackHookEvent(getInstanceId(), "hook_stdin_error", {
427
+ event_type: eventType,
428
+ cli,
429
+ error_type: "oversized",
430
+ });
431
+ }
432
+
433
+ const result = await evaluateHookEvent(eventType, cli, stdinRead.payload);
434
+
435
+ if (result.stdout) {
436
+ process.stdout.write(result.stdout);
437
+ }
438
+ if (result.stderr) {
439
+ process.stderr.write(result.stderr);
405
440
  }
441
+ return result.exitCode;
406
442
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Per-CLI payload field normalization, applied in place BEFORE any
3
+ * canonicalization or session-metadata resolution runs.
4
+ *
5
+ * Extracted out of handler.ts so the fail-closed evaluator (fail-closed.ts)
6
+ * can produce a correctly-shaped decision without duplicating a second copy
7
+ * of these CLI-specific field mappings — the exact quirks below took real
8
+ * verification effort against each CLI's live payload shape (see CLAUDE.md),
9
+ * so they must stay described in exactly one place.
10
+ */
11
+ import type { IntegrationType } from "./types";
12
+
13
+ export function normalizeCliPayload(cli: IntegrationType, parsed: Record<string, unknown>): void {
14
+ // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields
15
+ // the handler downstream reads to canonical snake_case BEFORE any
16
+ // canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`,
17
+ // `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`,
18
+ // `transcriptPath` → `transcript_path`. Verified against agy v1.1.2.
19
+ if (cli === "antigravity") {
20
+ const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined;
21
+ if (tc && typeof tc === "object") {
22
+ if (tc.name !== undefined) parsed.tool_name = tc.name;
23
+ if (tc.args !== undefined) parsed.tool_input = tc.args;
24
+ }
25
+ if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId;
26
+ if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") {
27
+ parsed.cwd = parsed.workspacePaths[0];
28
+ }
29
+ if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath;
30
+ }
31
+
32
+ // Copilot's snake_case events (PreToolUse/PostToolUse/Stop/…) are already
33
+ // Claude-shaped, but `permissionRequest` alone pipes a camelCase payload
34
+ // (`hookName`, `sessionId`, `toolName` in lowercase, `toolInput`) — verified
35
+ // live against Copilot CLI 1.0.71. Normalize the fields the handler reads so
36
+ // PermissionRequest-matched policies (e.g. block-sudo's Codex-escalation
37
+ // guard) fire instead of seeing a null tool name.
38
+ if (cli === "copilot") {
39
+ if (typeof parsed.toolName === "string" && parsed.tool_name === undefined) {
40
+ parsed.tool_name = parsed.toolName;
41
+ }
42
+ if (parsed.toolInput !== undefined && parsed.tool_input === undefined) {
43
+ parsed.tool_input = parsed.toolInput;
44
+ }
45
+ if (typeof parsed.sessionId === "string" && parsed.session_id === undefined) {
46
+ parsed.session_id = parsed.sessionId;
47
+ }
48
+ }
49
+
50
+ // Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` /
51
+ // `cwd` (its `tool_name` / `tool_input` are already canonical field names).
52
+ // Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the
53
+ // round-tripped event name is available. The --hook arg is already PascalCase,
54
+ // so canonicalizeEventType needs no goose branch. Verified goose v1.43.0.
55
+ if (cli === "goose") {
56
+ if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir;
57
+ if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) {
58
+ parsed.hook_event_name = parsed.event;
59
+ }
60
+ }
61
+ }
@@ -116,6 +116,21 @@ export interface HooksConfig {
116
116
  * locking would corrupt the file that governs enforcement.
117
117
  */
118
118
  conventionPolicies?: ConventionPolicyRecord[];
119
+ /**
120
+ * `true` once `failproofai config` has installed and started the
121
+ * failproofaid background daemon for this machine. Global scope ONLY —
122
+ * whether *this specific machine* has a running daemon is not something a
123
+ * committed project config should be able to assert on a teammate's
124
+ * behalf, so `readMergedHooksConfig` does not merge this key across
125
+ * scopes the way `enabledPolicies` merges. See `daemon-client.ts`.
126
+ *
127
+ * Governs the fail-closed/in-process split entirely: unset (or `false`)
128
+ * means this machine has never been daemon-configured, so hooks run the
129
+ * same full in-process evaluation they always have, with no socket
130
+ * attempt at all. Once `true`, an unreachable daemon fails closed instead
131
+ * of silently falling back — see the plan's "Confirmed scope decisions".
132
+ */
133
+ daemonConfigured?: boolean;
119
134
  }
120
135
 
121
136
  /** One convention policy file and the hooks it registered, as last observed. */
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reads the hook payload off `process.stdin`, capped at `maxBytes`.
3
+ *
4
+ * Pure I/O only — no logging, no telemetry. Extracted out of handler.ts so
5
+ * it's reusable and independently testable; callers that need the existing
6
+ * `hook_stdin_error` telemetry-on-failure behavior (currently only the
7
+ * one-shot `handleHookEvent` wrapper) add that themselves from the returned
8
+ * flags. The daemon's warm worker never calls this at all — it receives the
9
+ * payload directly over the socket, already read by the client.
10
+ */
11
+
12
+ export interface StdinRead {
13
+ payload: string;
14
+ /** The payload exceeded `maxBytes` and was discarded (payload is `""`). */
15
+ oversized: boolean;
16
+ /** `process.stdin` emitted an `error` event before `end`. */
17
+ readError: boolean;
18
+ }
19
+
20
+ export async function readStdinPayload(maxBytes = 1_048_576): Promise<StdinRead> {
21
+ let payload = "";
22
+ let oversized = false;
23
+ let readError = false;
24
+ try {
25
+ payload = await new Promise<string>((resolve, reject) => {
26
+ const chunks: string[] = [];
27
+ let totalBytes = 0;
28
+ process.stdin.setEncoding("utf8");
29
+ process.stdin.on("data", (chunk: string) => {
30
+ totalBytes += Buffer.byteLength(chunk);
31
+ if (totalBytes > maxBytes) {
32
+ oversized = true;
33
+ process.stdin.destroy();
34
+ resolve("");
35
+ return;
36
+ }
37
+ chunks.push(chunk);
38
+ });
39
+ process.stdin.on("end", () => resolve(chunks.join("")));
40
+ process.stdin.on("error", reject);
41
+ // If stdin is already closed or not piped, resolve immediately
42
+ if (process.stdin.readableEnded) resolve("");
43
+ });
44
+ } catch {
45
+ readError = true;
46
+ }
47
+ return { payload, oversized, readError };
48
+ }