failproofai 0.0.15 → 1.0.0-beta.1

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 (135) 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]__02r5bgf._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ath6v_._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0fopait._.js → [root-of-the-server]__1ig795d._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06b4trb._.js → [root-of-the-server]__1yo1gyz._.js} +2 -2
  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_14q3-7b.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/{3m8wmvlhsy1mx.js → 07ix3lb4gib0x.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{33rm-y-i4uzvv.js → 0fdx7ikab08-g.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{0kf9j0pf_j9-0.js → 191njgoik03fm.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{2f4mxqkkoa_7d.js → 1b3a1scouf40o.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{2geh8s7d3ead-.js → 1d71o1_tav721.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{11qhtpk2cvoxv.js → 24gs8ip6_spcb.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{1-ng45379-ztb.js → 2ovmv4esqalpb.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{1_v8b8mxhk25s.js → 30is-6r00sbd7.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2h7vw-ojgr9fz.js → 35584kn409ad1.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 +1385 -707
  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 +77 -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 +650 -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/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_buildManifest.js +0 -0
  134. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_clientMiddlewareManifest.js +0 -0
  135. /package/.next/standalone/.next/static/{ePKMmXXfXxThNsxwv1EiF → avJMC2AXvrg13Pbm4QH8F}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "0.0.15",
3
+ "version": "1.0.0-beta.1",
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
- "failproofai": "./dist/cli.mjs"
6
+ "failproofai": "./dist/cli.mjs",
7
+ "failproofaid": "./bin/failproofaid-shim.mjs"
7
8
  },
8
9
  "files": [
9
10
  "bin/",
@@ -24,7 +25,8 @@
24
25
  "predev": "bun run build:cli && bun link",
25
26
  "dev": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/dev.ts --port 8020",
26
27
  "build:cli": "bun build --target=node --format=esm --outfile=dist/cli.mjs bin/failproofai.mjs --external posthog-node --external sql.js && node -e \"const fs=require('fs');const c=fs.readFileSync('dist/cli.mjs','utf8');fs.writeFileSync('dist/cli.mjs',c.replace('#!/usr/bin/env bun','#!/usr/bin/env node').replace('// @bun\\n',''))\"",
27
- "build": "bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts && bun run build:cli && bun --bun next build && node -e \"const {cpSync}=require('fs');cpSync('.next/static','.next/standalone/.next/static',{recursive:true});\" && node scripts/prune-standalone.mjs",
28
+ "build:worker": "bun build --target=node --format=esm --outfile=dist/worker.mjs bin/failproofai-worker.mjs --external posthog-node --external sql.js",
29
+ "build": "bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts && bun run build:cli && bun run build:worker && bun --bun next build && node -e \"const {cpSync}=require('fs');cpSync('.next/static','.next/standalone/.next/static',{recursive:true});\" && node scripts/prune-standalone.mjs",
28
30
  "prestart": "bun run build:cli && bun link",
29
31
  "start": "FAILPROOFAI_TELEMETRY_DISABLED=1 bun scripts/start.ts",
30
32
  "test": "vitest",
@@ -2,6 +2,7 @@
2
2
  * Built-in security policies for Claude Code hooks.
3
3
  */
4
4
  import { resolve, join } from "node:path";
5
+ import { statSync } from "node:fs";
5
6
  import { readFile, writeFile, stat, open } from "node:fs/promises";
6
7
  import { execSync, execFileSync } from "node:child_process";
7
8
  import { homedir } from "node:os";
@@ -240,22 +241,45 @@ const HELM_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*helm(?:\s|$)/;
240
241
  // failproofai's own workflow policies depend on them.
241
242
  const GH_PIPELINE_RE = /(?:^|[;\n]|&&|\|\|?|&)\s*gh\s+(?:workflow\s+(?:run|enable|disable)|run\s+(?:rerun|cancel)|pr\s+merge|release\s+(?:create|delete)|cache\s+delete|secret\s+(?:set|delete))\b/;
242
243
 
243
- // Caches the current branch per cwd to avoid repeated execSync calls.
244
- // Trade-off: if the user switches branches externally mid-session, the cache serves
245
- // the stale value until the process restarts. This is acceptable since branch switches
246
- // during an active Claude session are rare.
247
- const gitBranchCache = new Map<string, string>();
244
+ // Caches the current branch per cwd to avoid repeated execSync calls, gated
245
+ // on .git/HEAD's mtime rather than reused unconditionally for the life of
246
+ // the process. In the one-shot-process model that unconditional reuse cost
247
+ // nothing (the cache never outlived a single hook call), but the daemon's
248
+ // warm worker keeps this Map alive across many calls and potentially many
249
+ // different projects' cwds — reusing a branch name forever would silently
250
+ // deny/allow a Stop based on a branch the user checked out an hour ago.
251
+ // git updates .git/HEAD's mtime on every checkout/switch, so a cheap local
252
+ // statSync (no subprocess) is a real, precise invalidation signal. Falls
253
+ // back to always re-fetching (no caching) when .git/HEAD can't be stat'd
254
+ // (worktrees/submodules), matching today's behavior for that case. Bounded
255
+ // at 500 entries so a warm worker touching many projects over its lifetime
256
+ // doesn't grow this unboundedly.
257
+ const gitBranchCache = new Map<string, { branch: string; headMtimeMs: number }>();
258
+ const GIT_BRANCH_CACHE_MAX_ENTRIES = 500;
259
+
260
+ function statGitHeadMtimeMs(cwd: string): number | null {
261
+ try {
262
+ return statSync(join(cwd, ".git", "HEAD")).mtimeMs;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
248
267
 
249
268
  function getCurrentBranch(cwd: string): string | null {
250
269
  try {
251
- let branch = gitBranchCache.get(cwd);
252
- if (branch === undefined) {
253
- branch = execSync("git rev-parse --abbrev-ref HEAD", {
254
- cwd,
255
- encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
256
- timeout: 3000,
257
- }).trim();
258
- gitBranchCache.set(cwd, branch);
270
+ const headMtimeMs = statGitHeadMtimeMs(cwd);
271
+ const cached = gitBranchCache.get(cwd);
272
+ if (cached && headMtimeMs !== null && cached.headMtimeMs === headMtimeMs) {
273
+ return cached.branch || null;
274
+ }
275
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
276
+ cwd,
277
+ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
278
+ timeout: 3000,
279
+ }).trim();
280
+ if (headMtimeMs !== null) {
281
+ if (gitBranchCache.size >= GIT_BRANCH_CACHE_MAX_ENTRIES) gitBranchCache.clear();
282
+ gitBranchCache.set(cwd, { branch, headMtimeMs });
259
283
  }
260
284
  return branch || null;
261
285
  } catch {
@@ -39,6 +39,13 @@ import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-prese
39
39
  import { discoverPolicyFiles, findSkippedPolicyFiles } from "./custom-hooks-loader";
40
40
  import { trackHookEvent } from "./hook-telemetry";
41
41
  import { getInstanceId } from "../../lib/telemetry-id";
42
+ import {
43
+ isDaemonSupportedPlatform,
44
+ installDaemonService,
45
+ daemonServiceFilePath,
46
+ setDaemonConfigured,
47
+ } from "./daemon-service";
48
+ import { hookLogWarn } from "./hook-logger";
42
49
 
43
50
  export interface WizardIO {
44
51
  stdin?: TTYIn;
@@ -263,6 +270,36 @@ export function setCustomPoliciesEnabled(
263
270
  }
264
271
  }
265
272
 
273
+ /**
274
+ * Maps a daemon-install failure to one of a fixed set of codes, safe to
275
+ * send off the machine.
276
+ *
277
+ * `installDaemonService`'s `reason` is a diagnostic for the local log, not a
278
+ * telemetry field: on most failure paths it is an errno message naming an
279
+ * absolute path under `homedir()`, which carries the OS username. Only the
280
+ * classification travels.
281
+ */
282
+ export function classifyDaemonInstallFailure(reason: string | undefined): string {
283
+ if (!reason) return "unknown";
284
+ if (/not supported on/.test(reason)) return "unsupported_platform";
285
+ if (/no prebuilt binary for/.test(reason)) return "unsupported_platform";
286
+ if (/binary not found/.test(reason)) return "binary_not_found";
287
+ // The download path is where a machine acquires the daemon at all, so its
288
+ // failures need to be distinguishable: a checksum mismatch is a supply-chain
289
+ // signal, "disabled" is a deliberate air-gapped opt-out, and a plain fetch
290
+ // failure is usually a proxy or an offline box — three very different
291
+ // stories that would otherwise all land in `service_manager_error`.
292
+ // The service is installed system-wide now, so "couldn't become root" is a
293
+ // first-class outcome with a specific remedy (re-run under sudo) rather
294
+ // than an opaque service_manager_error.
295
+ if (/root privileges are required/.test(reason)) return "needs_root";
296
+ if (/checksum mismatch|has no entry for/.test(reason)) return "checksum_mismatch";
297
+ if (/downloads are disabled/.test(reason)) return "downloads_disabled";
298
+ if (/failed to download/.test(reason)) return "download_failed";
299
+ if (/did not reach a running state/.test(reason)) return "did_not_start";
300
+ return "service_manager_error";
301
+ }
302
+
266
303
  /**
267
304
  * Summarise the custom policy files on disk, for the review screen.
268
305
  *
@@ -322,6 +359,11 @@ export function reviewLines(state: {
322
359
  lines.push(` Where : ${where}`);
323
360
  lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`);
324
361
  lines.push(` Policies : ${policies.length} enabled`);
362
+ if (scope === "user" && isDaemonSupportedPlatform()) {
363
+ lines.push(
364
+ ` Daemon : failproofaid will be installed/started as a background service`,
365
+ );
366
+ }
325
367
 
326
368
  // Reflect the Custom decision, not just what is on disk. Reporting
327
369
  // "1 file (project) (auto-loaded)" after the user had just unticked the row
@@ -347,6 +389,10 @@ export function reviewLines(state: {
347
389
  }
348
390
  }
349
391
  lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`);
392
+ if (scope === "user" && isDaemonSupportedPlatform()) {
393
+ const servicePath = daemonServiceFilePath();
394
+ if (servicePath) lines.push(` ${homeify(servicePath)} failproofaid service`);
395
+ }
350
396
  return lines;
351
397
  }
352
398
 
@@ -568,6 +614,35 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
568
614
  { replace: true, quiet: true },
569
615
  );
570
616
  setCustomPoliciesEnabled(scope, cwd, customEnabled);
617
+
618
+ // Daemon setup — unconditional on Linux/macOS whenever the global scope
619
+ // was chosen (a daemon is per-machine, not per-project), no separate
620
+ // opt-in step or command. A failure here (binary not found, no service
621
+ // manager, ...) never fails the wizard — the rest of setup already
622
+ // applied, and this machine simply stays on the in-process path exactly
623
+ // as before, since `daemonConfigured` is only set on success.
624
+ let daemonInstalled = false;
625
+ if (scope === "user" && isDaemonSupportedPlatform()) {
626
+ const daemonResult = await installDaemonService();
627
+ if (daemonResult.installed) {
628
+ setDaemonConfigured(true);
629
+ daemonInstalled = true;
630
+ } else {
631
+ hookLogWarn(`failproofaid was not installed as a service: ${daemonResult.reason}`);
632
+ }
633
+ void emit("configure_daemon_install", {
634
+ installed: daemonResult.installed,
635
+ // A bounded classification, never the raw reason: on the failure path
636
+ // that string is an errno message from writeFileSync/execFileSync
637
+ // against a homedir()-derived path, so it routinely carries the OS
638
+ // username and the local filesystem layout ("/home/<user>/.config/
639
+ // systemd/user/failproofaid.service"). The full text stays local via
640
+ // the hookLogWarn above.
641
+ reason: daemonResult.installed ? null : classifyDaemonInstallFailure(daemonResult.reason),
642
+ platform: process.platform,
643
+ });
644
+ }
645
+
571
646
  await applied;
572
647
  // Only now — a completed apply — is the launcher considered "seen", so the
573
648
  // first-run bare invocation stops redirecting here and opens the dashboard.
@@ -584,9 +659,10 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
584
659
  : customEnabled === false
585
660
  ? " · custom policies DISABLED"
586
661
  : "";
662
+ const daemonNote = daemonInstalled ? " · background daemon enabled" : "";
587
663
  const assistants = `${clis.length} assistant${clis.length === 1 ? "" : "s"}`;
588
664
  outro(
589
- `Setup complete — ${policies.length} policies${customNote} · ${assistants}`,
665
+ `Setup complete — ${policies.length} policies${customNote} · ${assistants}${daemonNote}`,
590
666
  { ok: true },
591
667
  stdout,
592
668
  );
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Thin client for talking to the failproofaid Rust daemon over its Unix
3
+ * socket. Used by `bin/failproofai.mjs`'s `--hook` path.
4
+ *
5
+ * Wire format matches `crates/PROTOCOL.md` exactly: one connection per
6
+ * request, a 4-byte big-endian u32 length prefix followed by that many
7
+ * bytes of UTF-8 JSON, camelCase fields, `"type"` as the tag.
8
+ *
9
+ * This module makes NO decision about what to do when the daemon can't be
10
+ * reached — `tryDaemonHook` just returns `null` on any failure. The caller
11
+ * (`bin/failproofai.mjs`) decides: fall back to full in-process evaluation
12
+ * on a machine that was never daemon-configured, or fail closed on one that
13
+ * was. See `isDaemonConfigured`.
14
+ */
15
+ import { createConnection } from "node:net";
16
+ import { resolve } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import type { IntegrationType } from "./types";
19
+ import { readHooksConfig } from "./hooks-config";
20
+
21
+ const PROTOCOL_VERSION = 1;
22
+
23
+ /**
24
+ * Reaching the daemon and getting an answer out of it are two different
25
+ * questions, and the caller's fail-closed policy makes conflating them
26
+ * expensive: on a daemon-configured machine a timeout here is a *deny*, not
27
+ * a fallback.
28
+ *
29
+ * `CONNECT` is the "is anything listening?" budget. Generous for a local
30
+ * Unix socket (sub-millisecond, no network); a daemon that can't answer an
31
+ * accept inside this is unhealthy, and failing fast is exactly right — this
32
+ * is the budget that keeps a dead daemon from adding latency to every hook.
33
+ *
34
+ * `RESPONSE` starts once the connection is established, and must cover the
35
+ * whole evaluation the daemon runs on our behalf: `handler.ts` allows each
36
+ * custom policy up to 10s, `worker-server.ts` serializes requests so one
37
+ * can queue behind another, and a project with many convention policies
38
+ * pays real file I/O on its first evaluation. Budgeting those at connect
39
+ * speed turned a slow-but-correct evaluation into an intermittent denial of
40
+ * a legitimate tool call. Matched to the daemon's own ceiling for the same
41
+ * roundtrip (`worker.rs` sets a 30s read timeout on the worker socket), so
42
+ * this side never gives up on a request the daemon is still honestly
43
+ * working on.
44
+ */
45
+ const DAEMON_CONNECT_TIMEOUT_MS = 150;
46
+ const DAEMON_RESPONSE_TIMEOUT_MS = 30_000;
47
+
48
+ const MAX_FRAME_LEN = 16 * 1024 * 1024;
49
+
50
+ export interface DaemonHookRequest {
51
+ hookEvent: string;
52
+ cli: IntegrationType;
53
+ stdin: string;
54
+ /**
55
+ * Best-effort cwd of the *originating* CLI process (parsed from the
56
+ * hook's own stdin payload by the caller) — never the daemon's or this
57
+ * client process's own cwd. See PROTOCOL.md's note on the process.cwd()
58
+ * hazard.
59
+ */
60
+ cwd?: string;
61
+ }
62
+
63
+ export interface DaemonHookResponse {
64
+ exitCode: number;
65
+ stdout: string;
66
+ stderr: string;
67
+ }
68
+
69
+ function socketPath(): string {
70
+ if (process.env.FAILPROOFAI_DAEMON_SOCKET) return process.env.FAILPROOFAI_DAEMON_SOCKET;
71
+ return resolve(homedir(), ".failproofai", "run", "failproofaid.sock");
72
+ }
73
+
74
+ /**
75
+ * `true` once `failproofai config` has installed and started the daemon for
76
+ * this machine (global scope only — see `HooksConfig.daemonConfigured`).
77
+ * Deliberately a single cheap global-only file read (`readHooksConfig`),
78
+ * not the merged project+local+global reader — whether *this machine* has a
79
+ * daemon is not a per-project setting, and the merge logic doesn't know
80
+ * about this key at all.
81
+ */
82
+ export function isDaemonConfigured(): boolean {
83
+ try {
84
+ return readHooksConfig().daemonConfigured === true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function encodeFrame(value: unknown): Buffer {
91
+ const body = Buffer.from(JSON.stringify(value), "utf8");
92
+ const header = Buffer.alloc(4);
93
+ header.writeUInt32BE(body.length, 0);
94
+ return Buffer.concat([header, body]);
95
+ }
96
+
97
+ /**
98
+ * Attempts one hook evaluation via the daemon. Returns `null` on **any**
99
+ * failure — no socket, connection refused, timeout, malformed response,
100
+ * protocol-version mismatch, or an explicit `error` message from the
101
+ * daemon. The caller never needs to distinguish failure modes; it just
102
+ * falls through to whatever its own fallback policy is.
103
+ */
104
+ export async function tryDaemonHook(req: DaemonHookRequest): Promise<DaemonHookResponse | null> {
105
+ // Windows never has a daemon in this phase (see the plan's platform
106
+ // scope) — skip the attempt outright rather than depending on however
107
+ // Node happens to behave when handed a POSIX socket path on Windows.
108
+ if (process.platform === "win32") return null;
109
+
110
+ return new Promise<DaemonHookResponse | null>((resolvePromise) => {
111
+ let settled = false;
112
+ let timer: ReturnType<typeof setTimeout>;
113
+ const arm = (ms: number) => {
114
+ clearTimeout(timer);
115
+ timer = setTimeout(() => finish(null), ms);
116
+ // Don't let this timer alone keep the process alive if everything else
117
+ // has already finished — it's always cleared on the success/failure
118
+ // paths below, this just avoids it being the sole reason a --hook
119
+ // process lingers if something upstream forgets to await us.
120
+ timer.unref?.();
121
+ };
122
+ const finish = (result: DaemonHookResponse | null) => {
123
+ if (settled) return;
124
+ settled = true;
125
+ clearTimeout(timer);
126
+ socket.removeAllListeners();
127
+ socket.destroy();
128
+ resolvePromise(result);
129
+ };
130
+
131
+ const socket = createConnection({ path: socketPath() });
132
+ arm(DAEMON_CONNECT_TIMEOUT_MS);
133
+
134
+ let recvBuf = Buffer.alloc(0);
135
+ let declaredLen: number | null = null;
136
+
137
+ socket.on("connect", () => {
138
+ // Connected: the daemon is demonstrably reachable, so the question is
139
+ // no longer "is it there" but "how long does this evaluation take".
140
+ arm(DAEMON_RESPONSE_TIMEOUT_MS);
141
+ socket.write(
142
+ encodeFrame({
143
+ type: "hook",
144
+ protocolVersion: PROTOCOL_VERSION,
145
+ hookEvent: req.hookEvent,
146
+ cli: req.cli,
147
+ stdin: req.stdin,
148
+ cwd: req.cwd,
149
+ }),
150
+ );
151
+ });
152
+
153
+ socket.on("data", (chunk: Buffer) => {
154
+ recvBuf = Buffer.concat([recvBuf, chunk]);
155
+
156
+ if (declaredLen === null) {
157
+ if (recvBuf.length < 4) return;
158
+ declaredLen = recvBuf.readUInt32BE(0);
159
+ if (declaredLen > MAX_FRAME_LEN) {
160
+ finish(null);
161
+ return;
162
+ }
163
+ recvBuf = recvBuf.subarray(4);
164
+ }
165
+
166
+ if (recvBuf.length < declaredLen) return;
167
+
168
+ const body = recvBuf.subarray(0, declaredLen);
169
+ let message: Record<string, unknown>;
170
+ try {
171
+ message = JSON.parse(body.toString("utf8")) as Record<string, unknown>;
172
+ } catch {
173
+ finish(null);
174
+ return;
175
+ }
176
+
177
+ if (message.protocolVersion !== PROTOCOL_VERSION) {
178
+ finish(null);
179
+ return;
180
+ }
181
+ if (
182
+ message.type === "hookResult" &&
183
+ typeof message.exitCode === "number" &&
184
+ typeof message.stdout === "string" &&
185
+ typeof message.stderr === "string"
186
+ ) {
187
+ finish({ exitCode: message.exitCode, stdout: message.stdout, stderr: message.stderr });
188
+ return;
189
+ }
190
+ // Anything else — an explicit `error` message, a `pong` (protocol
191
+ // confusion), or a well-formed-but-wrong-shape body — is treated
192
+ // identically to a connection failure: no partial trust.
193
+ finish(null);
194
+ });
195
+
196
+ socket.on("error", () => finish(null));
197
+ socket.on("close", () => finish(null));
198
+ });
199
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Fetches the compiled `failproofaid` binary for this platform from the
3
+ * GitHub Release that matches this CLI's own version.
4
+ *
5
+ * Why a download rather than a dependency: the daemon is a 4-way
6
+ * cross-compiled Rust binary, and shipping it through npm means four
7
+ * per-platform packages that every install resolves, an npm org to own the
8
+ * scope, and a second publish channel that can silently drift from the first
9
+ * (it already did — the platform packages were declared as
10
+ * `optionalDependencies` and never published, so every install of them 404'd).
11
+ * The release assets have to exist for standalone installs regardless, so
12
+ * pointing the CLI at the same artifacts leaves exactly one channel.
13
+ *
14
+ * The URL is *constructed*, never discovered — no API call, no `releases/latest`
15
+ * redirect, no rate limit, and no chance of fetching a daemon built from
16
+ * different source than the CLI talking to it: the tag is this package's own
17
+ * version, so a CLI upgrade downloads its own matching binary.
18
+ *
19
+ * Everything here fails closed and never throws. A daemon that cannot be
20
+ * fetched leaves the machine on the in-process path it was already using;
21
+ * a daemon fetched over a corrupted connection is refused outright, because
22
+ * this writes an executable that a service manager will then run at login.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { resolve } from "node:path";
28
+ import { gunzipSync } from "node:zlib";
29
+ import { version } from "../../package.json";
30
+ import type { PlatformKey } from "./daemon-service";
31
+
32
+ /**
33
+ * Where release assets live. Overridable so an air-gapped or proxied site can
34
+ * mirror the four binaries internally — and so tests can serve them from a
35
+ * local HTTP server without touching the network.
36
+ */
37
+ const DEFAULT_BASE_URL = "https://github.com/FailproofAI/failproofai/releases/download";
38
+
39
+ /**
40
+ * One bound for the whole fetch. Long enough for a ~2 MB asset on a slow
41
+ * connection, short enough that a black-holed proxy doesn't turn the config
42
+ * wizard into a hang — the failure mode this replaces is a user staring at a
43
+ * spinner with no output.
44
+ */
45
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
46
+
47
+ function baseUrl(): string {
48
+ return (process.env.FAILPROOFAI_DAEMON_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, "");
49
+ }
50
+
51
+ /** Where downloaded binaries live. One directory, one file per version. */
52
+ export function daemonBinaryDir(): string {
53
+ return resolve(homedir(), ".failproofai", "bin");
54
+ }
55
+
56
+ /**
57
+ * The path this CLI's own daemon binary occupies.
58
+ *
59
+ * Versioned on purpose. An in-place overwrite would hit `ETXTBSY` against the
60
+ * running daemon on Linux, and would silently repoint a service unit at a
61
+ * binary built from different source; a per-version filename makes an upgrade
62
+ * a new file plus a rewritten unit, and leaves the old one recoverable.
63
+ */
64
+ export function installedBinaryPath(ver: string = version): string {
65
+ return resolve(daemonBinaryDir(), `failproofaid-${ver}`);
66
+ }
67
+
68
+ /** The release asset URL for a platform — constructed, never discovered. */
69
+ export function daemonAssetUrl(key: PlatformKey, ver: string = version): string {
70
+ return `${baseUrl()}/v${ver}/failproofaid-${key}.gz`;
71
+ }
72
+
73
+ /** The checksum manifest published alongside the four binaries. */
74
+ export function checksumsUrl(ver: string = version): string {
75
+ return `${baseUrl()}/v${ver}/SHA256SUMS`;
76
+ }
77
+
78
+ export interface DaemonDownloadResult {
79
+ /** Absolute path to the installed, executable binary. */
80
+ path?: string;
81
+ /** Local diagnostic — never sent off the machine verbatim. */
82
+ error?: string;
83
+ }
84
+
85
+ async function fetchBytes(url: string): Promise<Buffer> {
86
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
87
+ if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
88
+ return Buffer.from(await response.arrayBuffer());
89
+ }
90
+
91
+ /**
92
+ * Parses a `sha256sum`-format manifest and returns the digest for one asset.
93
+ *
94
+ * A missing line is an error rather than a skipped check: the manifest is the
95
+ * only thing standing between a truncated or substituted download and an
96
+ * executable this process is about to install as a login service.
97
+ */
98
+ export function digestFor(manifest: string, assetName: string): string | null {
99
+ for (const line of manifest.split("\n")) {
100
+ const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
101
+ if (match && match[2].trim() === assetName) return match[1].toLowerCase();
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Downloads, verifies and installs the daemon binary for `key`.
108
+ *
109
+ * Idempotent: an already-installed binary for this version is returned as-is,
110
+ * so re-running the wizard costs nothing.
111
+ */
112
+ export async function downloadFailproofaidBinary(key: PlatformKey): Promise<DaemonDownloadResult> {
113
+ const target = installedBinaryPath();
114
+ if (existsSync(target)) return { path: target };
115
+
116
+ if (process.env.FAILPROOFAI_NO_DOWNLOAD) {
117
+ return { error: "daemon downloads are disabled (FAILPROOFAI_NO_DOWNLOAD)" };
118
+ }
119
+
120
+ const assetName = `failproofaid-${key}.gz`;
121
+ // Same directory as the target so the install is a rename within one
122
+ // filesystem — atomic, and never a half-written binary a service manager
123
+ // could pick up. The pid keeps two concurrent wizards off each other's file.
124
+ const tempPath = `${target}.${process.pid}.tmp`;
125
+
126
+ try {
127
+ const [asset, manifest] = await Promise.all([
128
+ fetchBytes(daemonAssetUrl(key)),
129
+ fetchBytes(checksumsUrl()).then((b) => b.toString("utf8")),
130
+ ]);
131
+
132
+ const expected = digestFor(manifest, assetName);
133
+ if (!expected) {
134
+ return { error: `SHA256SUMS for v${version} has no entry for ${assetName}` };
135
+ }
136
+ const actual = createHash("sha256").update(asset).digest("hex");
137
+ if (actual !== expected) {
138
+ return { error: `checksum mismatch for ${assetName} (expected ${expected}, got ${actual})` };
139
+ }
140
+
141
+ const binary = gunzipSync(asset);
142
+ mkdirSync(daemonBinaryDir(), { recursive: true });
143
+ writeFileSync(tempPath, binary);
144
+ chmodSync(tempPath, 0o755);
145
+ renameSync(tempPath, target);
146
+ return { path: target };
147
+ } catch (err) {
148
+ rmSync(tempPath, { force: true });
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ return { error: `failed to download failproofaid v${version} for ${key}: ${message}` };
151
+ }
152
+ }