failproofai 1.0.0-beta.1 → 1.0.0-beta.3

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 (197) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  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/build-manifest.json +3 -3
  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/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ig795d._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yo1gyz._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_14q3-7b.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{1d71o1_tav721.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{07ix3lb4gib0x.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{35584kn409ad1.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{191njgoik03fm.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2ovmv4esqalpb.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{30is-6r00sbd7.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{1b3a1scouf40o.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{24gs8ip6_spcb.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2294 -786
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +308 -18
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/daemon-service.ts +125 -24
  184. package/src/hooks/handler.ts +144 -6
  185. package/src/hooks/hook-activity-store.ts +50 -0
  186. package/src/hooks/loader-utils.ts +6 -4
  187. package/src/hooks/policy-types.ts +35 -0
  188. package/src/hooks/session-pause-cli.ts +157 -0
  189. package/src/hooks/session-pause.ts +236 -0
  190. package/src/hooks/tui.ts +115 -0
  191. package/.next/standalone/.next/static/chunks/0fdx7ikab08-g.js +0 -6
  192. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  193. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  194. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  195. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  197. /package/.next/standalone/.next/static/{avJMC2AXvrg13Pbm4QH8F → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -8,7 +8,15 @@
8
8
  * `configure-wizard.ts` calls the functions here directly, the same
9
9
  * relationship it already has with `manager.ts`'s `installHooks()`.
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
11
+ import {
12
+ existsSync,
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ readFileSync,
16
+ writeFileSync,
17
+ unlinkSync,
18
+ rmSync,
19
+ } from "node:fs";
12
20
  import { homedir, tmpdir, userInfo } from "node:os";
13
21
  import { resolve, dirname } from "node:path";
14
22
  import { execFileSync } from "node:child_process";
@@ -45,6 +53,14 @@ const SERVICE_START_POLL_MS = 100;
45
53
  */
46
54
  const SERVICE_SETTLE_MS = 750;
47
55
 
56
+ /**
57
+ * How long `sudo -v` may sit at its password prompt. Unlike every other
58
+ * timeout here, this one is waiting on a human typing, not on a service
59
+ * manager — three attempts at a password is well inside two minutes, and
60
+ * sudo gives up on its own long before that.
61
+ */
62
+ const SUDO_PROMPT_TIMEOUT_MS = 120_000;
63
+
48
64
  /**
49
65
  * Writes (or clears) the machine-wide `daemonConfigured` marker in
50
66
  * `~/.failproofai/policies-config.json`.
@@ -175,6 +191,14 @@ export function resolveWorkerCommand(): string | null {
175
191
  if (!packageRoot) return null;
176
192
  const workerScript = resolve(packageRoot, "dist", "worker.mjs");
177
193
  if (!existsSync(workerScript)) return null;
194
+ // Both paths are shell-quoted: the daemon runs this value through `sh -c`
195
+ // (`WorkerCommand::Shell` in crates/failproofaid/src/worker.rs), so an
196
+ // unquoted path splits on its spaces and the worker never starts. Not
197
+ // hypothetical on macOS, where `/Users/First Last/...` is ordinary — and
198
+ // more exposed since execPath replaced a bare `node`, because execPath is
199
+ // home-derived. systemd's own `Environment="..."` quoting does not help
200
+ // here; that protects the unit parse, not the later shell split.
201
+ //
178
202
  // `process.execPath`, not a bare `node`. A system-scope service does not
179
203
  // inherit a login environment, so its PATH is the system default — and the
180
204
  // single most common way to install Node is nvm, which puts it under
@@ -183,7 +207,13 @@ export function resolveWorkerCommand(): string | null {
183
207
  // silently, on exactly the machines least likely to notice. execPath is
184
208
  // whatever runtime is executing this CLI right now (node for the published
185
209
  // bin, bun in a source checkout), absolute either way.
186
- return `${process.execPath} ${workerScript}`;
210
+ return `${shellQuote(process.execPath)} ${shellQuote(workerScript)}`;
211
+ }
212
+
213
+ /** POSIX single-quoting: everything is literal inside '…', and a literal
214
+ * quote is closed, escaped and reopened. */
215
+ function shellQuote(value: string): string {
216
+ return `'${value.replace(/'/g, `'\\''`)}'`;
187
217
  }
188
218
 
189
219
  /**
@@ -224,15 +254,33 @@ function legacySystemdUserUnitPath(): string {
224
254
  return resolve(homedir(), ".config", "systemd", "user", "failproofaid.service");
225
255
  }
226
256
 
227
- const LAUNCHD_LABEL = "ai.failproof.failproofaid";
257
+ /**
258
+ * Per user, for the same reason the systemd unit is: a LaunchDaemon's
259
+ * contents are entirely user-specific — `UserName`, the ExecStart path under
260
+ * that user's `~/.failproofai/bin`, the log paths under that user's home — so
261
+ * one shared label means the second user's install silently overwrites the
262
+ * first's daemon, and their uninstall deletes it.
263
+ */
264
+ function launchdLabel(user: string = serviceUser()): string {
265
+ return `ai.failproof.failproofaid.${user}`;
266
+ }
228
267
 
229
- function launchdPlistPath(): string {
230
- return `/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist`;
268
+ function launchdPlistPath(user: string = serviceUser()): string {
269
+ return `/Library/LaunchDaemons/${launchdLabel(user)}.plist`;
231
270
  }
232
271
 
233
- /** The pre-1.0.0-beta.1 LaunchAgent — same migration problem as above. */
272
+ /** The pre-1.0.0-beta.1 LaunchAgent. */
234
273
  function legacyLaunchAgentPlistPath(): string {
235
- return resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
274
+ return resolve(homedir(), "Library", "LaunchAgents", "ai.failproof.failproofaid.plist");
275
+ }
276
+
277
+ /**
278
+ * The 1.0.0-beta.1 LaunchDaemon, before the label was namespaced. Removed on
279
+ * install like the LaunchAgent is: it holds the same singleton flock, so
280
+ * leaving it would leave two daemons racing for one socket.
281
+ */
282
+ function legacySharedLaunchDaemonPath(): string {
283
+ return "/Library/LaunchDaemons/ai.failproof.failproofaid.plist";
236
284
  }
237
285
 
238
286
  /**
@@ -255,7 +303,38 @@ export function daemonStatusCommand(): string | null {
255
303
  if (!isDaemonSupportedPlatform()) return null;
256
304
  return process.platform === "linux"
257
305
  ? `systemctl status ${systemdUnitName()}`
258
- : `sudo launchctl print system/${LAUNCHD_LABEL}`;
306
+ : `sudo launchctl print system/${launchdLabel()}`;
307
+ }
308
+
309
+ /**
310
+ * Acquires sudo credentials up front, prompting in the terminal if needed.
311
+ *
312
+ * `sudo -v` refreshes the user's sudo timestamp and returns; every later
313
+ * `sudo -n` in this run then succeeds against that cached credential. Doing it
314
+ * here, once, at a known point in the wizard is what makes the rest of the
315
+ * install non-interactive — the alternative, an interactive sudo firing from
316
+ * underneath a half-drawn TUI screen, is a password prompt the user cannot
317
+ * see.
318
+ *
319
+ * NOT the same as running the whole CLI under sudo, which is actively wrong:
320
+ * `homedir()` would become /root, so the hooks, the policy config, the
321
+ * downloaded binary and the unit's own `User=` would all be root's rather
322
+ * than the user's, quietly configuring the wrong account.
323
+ *
324
+ * `stdio: "inherit"` so the prompt and the typed password go straight to the
325
+ * real terminal. The timeout is generous because a human is typing.
326
+ */
327
+ export function primeElevation(): boolean {
328
+ if (typeof process.getuid === "function" && process.getuid() === 0) return true;
329
+ if (canElevate()) return true; // NOPASSWD, or already primed
330
+ try {
331
+ execFileSync("sudo", ["-v"], { stdio: "inherit", timeout: SUDO_PROMPT_TIMEOUT_MS });
332
+ return canElevate();
333
+ } catch {
334
+ // Wrong password, no sudo rights, or the user pressed ctrl-C. All of them
335
+ // mean the same thing here: carry on without a daemon.
336
+ return false;
337
+ }
259
338
  }
260
339
 
261
340
  /** True when privileged commands can run without prompting for a password. */
@@ -289,12 +368,19 @@ function runPrivileged(command: string, args: string[]): void {
289
368
  * mode in the same step, so the file is never briefly world-writable.
290
369
  */
291
370
  function writePrivilegedFile(destination: string, contents: string, mode = "0644"): void {
292
- const staging = resolve(tmpdir(), `failproofaid-${process.pid}-${Date.now()}.tmp`);
371
+ // mkdtempSync, not a name built from pid + timestamp in the shared tmpdir.
372
+ // That name is guessable, and `writeFileSync` follows a symlink already
373
+ // sitting at the path — so on a multi-user box another local user could
374
+ // pre-create it and have `install` copy content they control into
375
+ // /etc/systemd/system as root. mkdtemp gives a 0700 directory with a name
376
+ // they cannot predict, so there is nothing to pre-create.
377
+ const stagingDir = mkdtempSync(resolve(tmpdir(), "failproofaid-stage-"));
378
+ const staging = resolve(stagingDir, "service-definition");
293
379
  try {
294
380
  writeFileSync(staging, contents, "utf8");
295
381
  runPrivileged("install", ["-m", mode, staging, destination]);
296
382
  } finally {
297
- rmSync(staging, { force: true });
383
+ rmSync(stagingDir, { recursive: true, force: true });
298
384
  }
299
385
  }
300
386
 
@@ -326,17 +412,30 @@ function removeLegacyUserService(): void {
326
412
  /* best-effort */
327
413
  }
328
414
  } else {
329
- const legacy = legacyLaunchAgentPlistPath();
330
- if (!existsSync(legacy)) return;
331
- try {
332
- execFileSync("launchctl", ["unload", "-w", legacy], {
333
- stdio: "ignore",
334
- timeout: SERVICE_CMD_TIMEOUT_MS,
335
- });
336
- } catch {
337
- /* not loaded */
415
+ const legacyAgent = legacyLaunchAgentPlistPath();
416
+ if (existsSync(legacyAgent)) {
417
+ try {
418
+ execFileSync("launchctl", ["unload", "-w", legacyAgent], {
419
+ stdio: "ignore",
420
+ timeout: SERVICE_CMD_TIMEOUT_MS,
421
+ });
422
+ } catch {
423
+ /* not loaded */
424
+ }
425
+ unlinkSync(legacyAgent);
426
+ }
427
+
428
+ // The 1.0.0-beta.1 daemon, before the label was per-user. Privileged,
429
+ // and only reachable on a path that has already elevated.
430
+ const legacyDaemon = legacySharedLaunchDaemonPath();
431
+ if (existsSync(legacyDaemon)) {
432
+ try {
433
+ runPrivileged("launchctl", ["unload", "-w", legacyDaemon]);
434
+ } catch {
435
+ /* not loaded */
436
+ }
437
+ runPrivileged("rm", ["-f", legacyDaemon]);
338
438
  }
339
- unlinkSync(legacy);
340
439
  }
341
440
  } catch (err) {
342
441
  hookLogWarn(`could not remove the legacy user-scope daemon: ${err instanceof Error ? err.message : String(err)}`);
@@ -390,7 +489,7 @@ function launchdPlistContents(binaryPath: string, logDir: string, workerCmd: str
390
489
  <plist version="1.0">
391
490
  <dict>
392
491
  <key>Label</key>
393
- <string>${LAUNCHD_LABEL}</string>
492
+ <string>${escapeXml(launchdLabel())}</string>
394
493
  <key>ProgramArguments</key>
395
494
  <array>
396
495
  <string>${escapeXml(binaryPath)}</string>
@@ -471,8 +570,10 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
471
570
  return {
472
571
  installed: false,
473
572
  reason:
474
- "root privileges are required to install the failproofaid system service, and sudo is not available without a password. " +
475
- `Run: sudo failproofai config (or install manually: ${daemonInstallCommands(binaryPath, workerCmd).join(" && ")})`,
573
+ "root privileges are required to install the failproofaid system service, and sudo credentials were not available. " +
574
+ "Re-run `failproofai config` and approve the sudo prompt — do NOT run the CLI itself under sudo, which would " +
575
+ "configure root's account instead of yours. To install by hand: " +
576
+ daemonInstallCommands(binaryPath, workerCmd).join(" && "),
476
577
  };
477
578
  }
478
579
 
@@ -631,7 +732,7 @@ export function daemonServiceStatus(): DaemonServiceStatus {
631
732
  // `launchctl list` cannot see — unlike systemd, reading the state needs
632
733
  // the same elevation installing it did.
633
734
  const root = typeof process.getuid === "function" && process.getuid() === 0;
634
- const args = ["print", `system/${LAUNCHD_LABEL}`];
735
+ const args = ["print", `system/${launchdLabel()}`];
635
736
  const out = root
636
737
  ? execFileSync("launchctl", args, {
637
738
  stdio: ["ignore", "pipe", "ignore"],
@@ -41,6 +41,8 @@ import { resolveTranscriptPath } from "./resolve-transcript-path";
41
41
  import { getInstanceId } from "../../lib/telemetry-id";
42
42
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
43
43
  import { readStdinPayload } from "./read-stdin";
44
+ import { readActiveCloudManagedPolicies, type CloudManagedPolicyArtifact } from "./cloud-managed-policies";
45
+ import { readActivePause, type ActivePause } from "./session-pause";
44
46
 
45
47
  /**
46
48
  * Canonicalize an event name to PascalCase. Codex sends snake_case event names
@@ -134,6 +136,37 @@ export interface EvaluateHookEventOptions {
134
136
  fallbackCwd?: string;
135
137
  }
136
138
 
139
+ /**
140
+ * Runs an observe-mode policy under the same timeout and error handling as an
141
+ * enforcing one, so what gets measured is what would actually have happened —
142
+ * including a policy that times out, which in enforce mode is an allow and must
143
+ * be recorded as one rather than as a would-deny.
144
+ */
145
+ async function runObserved(
146
+ hook: CustomHook,
147
+ ctx: Parameters<CustomHook["fn"]>[0],
148
+ hookName: string,
149
+ eventType: string,
150
+ cli: IntegrationType,
151
+ ): Promise<PolicyResult> {
152
+ try {
153
+ return await Promise.race([
154
+ hook.fn(ctx),
155
+ new Promise<PolicyResult>((_, reject) => setTimeout(() => reject(new Error("timeout")), 10_000)),
156
+ ]);
157
+ } catch (err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ void trackHookEvent(getInstanceId(), "custom_hook_error", {
160
+ hook_name: hookName,
161
+ error_type: msg === "timeout" ? "timeout" : "exception",
162
+ event_type: eventType,
163
+ cli,
164
+ is_observe_mode: true,
165
+ });
166
+ return { decision: "allow" };
167
+ }
168
+ }
169
+
137
170
  /**
138
171
  * The core hook-evaluation logic, decoupled from process stdin/stdout so it
139
172
  * can be called repeatedly inside a long-lived process (the daemon's warm
@@ -209,6 +242,20 @@ export async function evaluateHookEvent(
209
242
  let config: HooksConfig;
210
243
  let customHooksList: CustomHook[] = [];
211
244
  let conventionHookNames = new Set<string>();
245
+ let activePause: ActivePause | null = null;
246
+ /** Registered policy name → where it came from. See the set() below. */
247
+ const policyAttribution = new Map<
248
+ string,
249
+ { source: "custom" | "convention" | "cloud"; cloudPolicyId?: string; cloudRevision?: number }
250
+ >();
251
+ let cloudGeneration: number | undefined;
252
+ /** What observe-mode policies WOULD have done, had they been enforcing. */
253
+ const observedResults: Array<{
254
+ policyId: string;
255
+ revision: number;
256
+ decision: "deny" | "instruct";
257
+ reason: string | null;
258
+ }> = [];
212
259
 
213
260
  if (opts?.forceDecision) {
214
261
  // Fail-closed: no config/custom-hook loading at all — a daemon that
@@ -228,25 +275,80 @@ export async function evaluateHookEvent(
228
275
  // Load enabled policies (merge across project/local/global scopes)
229
276
  config = readMergedHooksConfig(session.cwd);
230
277
  clearPolicies();
231
- registerBuiltinPolicies(config.enabledPolicies);
278
+
279
+ // A session pause suspends LOCAL policy only, for a bounded time. Cloud
280
+ // assignments are exempt below for the same reason `disabledCustomPolicies`
281
+ // already exempts them: a locally-issued command that could switch off a
282
+ // centrally assigned policy would make cloud enforcement decorative.
283
+ activePause = readActivePause(session.sessionId);
284
+ registerBuiltinPolicies(activePause ? [] : config.enabledPolicies);
285
+
286
+ // Cloud-managed policies are daemon-reconciled artifacts, but they use
287
+ // the same public JS policy API as local custom policies. Verify and add
288
+ // only the paths referenced by the atomically active generation.
289
+ const cloudManagedPolicies = readActiveCloudManagedPolicies();
290
+ // Recorded on every row once a machine is managed, whether or not a cloud
291
+ // policy decided this event: "what was deployed here at the time" is a
292
+ // separate question from "what decided", and only the former can tell a
293
+ // rollout that changed nothing from one that never reached the machine.
294
+ cloudGeneration = cloudManagedPolicies[0]?.generation;
295
+ const configuredCustomPaths = config.customPoliciesPaths ?? config.customPoliciesPath;
296
+ const allExplicitPaths =
297
+ cloudManagedPolicies.length === 0
298
+ ? configuredCustomPaths
299
+ : [
300
+ ...(typeof configuredCustomPaths === "string" ? [configuredCustomPaths] : configuredCustomPaths ?? []),
301
+ ...cloudManagedPolicies.map((policy) => policy.path),
302
+ ];
232
303
 
233
304
  // Load and register custom hooks (layer 2, after builtins)
234
- const loadResult = await loadAllCustomHooks(config.customPoliciesPaths ?? config.customPoliciesPath, {
305
+ const loadResult = await loadAllCustomHooks(allExplicitPaths, {
235
306
  sessionCwd: session.cwd,
236
307
  customPoliciesEnabled: config.customPoliciesEnabled,
308
+ ...(cloudManagedPolicies.length > 0 ? { cloudManagedPolicies } : {}),
237
309
  });
238
310
  customHooksList = loadResult.hooks;
239
311
  const disabledCustomPolicies = new Set(config.disabledCustomPolicies ?? []);
240
312
  conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames));
241
313
 
242
314
  for (const hook of customHooksList) {
243
- const policyId = (hook as CustomHook & { __policyId?: string }).__policyId;
244
- if (policyId && disabledCustomPolicies.has(policyId)) continue;
315
+ const taggedHook = hook as CustomHook & {
316
+ __policyId?: string;
317
+ __cloudManaged?: CloudManagedPolicyArtifact;
318
+ };
319
+ const policyId = taggedHook.__policyId;
320
+ const cloudManaged = taggedHook.__cloudManaged;
321
+ // Local config cannot disable a centrally assigned policy merely by
322
+ // copying its generated ID into disabledCustomPolicies.
323
+ if (!cloudManaged && policyId && disabledCustomPolicies.has(policyId)) continue;
324
+ // Same rule for a session pause — it suspends local policy, never cloud.
325
+ if (!cloudManaged && activePause) continue;
245
326
  const hookName = hook.name;
246
327
  const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope;
247
328
  const isConvention = !!conventionScope;
248
- const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom";
329
+ const prefix = cloudManaged
330
+ ? `cloud/${cloudManaged.id}@${cloudManaged.revision}`
331
+ : isConvention
332
+ ? `.failproofai-${conventionScope}`
333
+ : "custom";
334
+ // Observe mode: run it for real, record what it decided, then hand back
335
+ // an allow. Evaluating and discarding is the whole point — a rollout is
336
+ // measured against real traffic before it can break anyone's work, and
337
+ // a policy that did not actually run would measure nothing.
338
+ const observeOnly = cloudManaged?.effect === "observe";
249
339
  const fn: PolicyFunction = async (ctx): Promise<PolicyResult> => {
340
+ if (observeOnly) {
341
+ const shadow = await runObserved(hook, ctx, hookName, eventType, cli);
342
+ if (shadow.decision !== "allow") {
343
+ observedResults.push({
344
+ policyId: cloudManaged!.id,
345
+ revision: cloudManaged!.revision,
346
+ decision: shadow.decision,
347
+ reason: shadow.reason ?? null,
348
+ });
349
+ }
350
+ return { decision: "allow" };
351
+ }
250
352
  try {
251
353
  const result = await Promise.race([
252
354
  hook.fn(ctx),
@@ -268,8 +370,18 @@ export async function evaluateHookEvent(
268
370
  return { decision: "allow" };
269
371
  }
270
372
  };
373
+ const registeredName = `${prefix}/${hookName}`;
374
+ // Record where this policy came from as structured data, keyed by the
375
+ // exact name the evaluator will report back. The display name already
376
+ // encodes it ("cloud/org-guard@7/…"), but only as a string — so the one
377
+ // question cloud attribution has to answer, "which rollout produced
378
+ // this decision", could only be answered by re-parsing our own label.
379
+ policyAttribution.set(registeredName, {
380
+ source: cloudManaged ? "cloud" : isConvention ? "convention" : "custom",
381
+ ...(cloudManaged ? { cloudPolicyId: cloudManaged.id, cloudRevision: cloudManaged.revision } : {}),
382
+ });
271
383
  registerPolicy(
272
- `${prefix}/${hookName}`,
384
+ registeredName,
273
385
  hook.description ?? "",
274
386
  fn,
275
387
  hook.match ?? {},
@@ -335,6 +447,32 @@ export async function evaluateHookEvent(
335
447
  cwd: session.cwd,
336
448
  permissionMode: session.permissionMode,
337
449
  hookEventName: session.hookEventName,
450
+ // Attribution. A builtin is anything registered that is not in the map,
451
+ // so its absence is meaningful rather than missing — but only when a
452
+ // policy actually decided; a plain allow names nobody.
453
+ ...(result.policyName
454
+ ? (() => {
455
+ const attribution = policyAttribution.get(result.policyName);
456
+ return {
457
+ policySource: attribution?.source ?? ("builtin" as const),
458
+ ...(attribution?.cloudPolicyId ? { cloudPolicyId: attribution.cloudPolicyId } : {}),
459
+ ...(attribution?.cloudRevision !== undefined
460
+ ? { cloudRevision: attribution.cloudRevision }
461
+ : {}),
462
+ };
463
+ })()
464
+ : {}),
465
+ ...(cloudGeneration !== undefined ? { cloudGeneration } : {}),
466
+ // The point of observe mode is this record. Without it the rollout is
467
+ // unmeasurable and the row is indistinguishable from one where the policy
468
+ // never matched at all.
469
+ ...(observedResults.length > 0 ? { observed: observedResults } : {}),
470
+ // Without these, a row logged during a pause is indistinguishable from
471
+ // one where every policy ran and allowed — the log would assert a clean
472
+ // window over exactly the window that was not enforced.
473
+ ...(activePause
474
+ ? { pausedBy: activePause.setBy, pauseExpiresAt: activePause.expiresAt }
475
+ : {}),
338
476
  };
339
477
  try {
340
478
  persistHookActivity(activityEntry);
@@ -69,6 +69,45 @@ export interface HookActivityEntry {
69
69
  cwd?: string;
70
70
  permissionMode?: string;
71
71
  hookEventName?: string;
72
+ /**
73
+ * Set only on events evaluated while a session pause was active. Absent on
74
+ * every ordinary row and on every row written before pausing existed, so —
75
+ * like `matchedPolicies` — consumers must treat `undefined` as "not paused"
76
+ * rather than rendering it as unknown.
77
+ */
78
+ pausedBy?: string;
79
+ /** Epoch ms the pause lifts. Present iff `pausedBy` is. */
80
+ pauseExpiresAt?: number;
81
+ /**
82
+ * Where the policy that DECIDED came from. Absent when nothing decided (a
83
+ * plain allow) and on rows written before this existed — so, like
84
+ * `matchedPolicies`, `undefined` means "unknown", not "builtin".
85
+ */
86
+ policySource?: "builtin" | "custom" | "convention" | "cloud";
87
+ /** Cloud policy id of the decider. Present only when `policySource` is "cloud". */
88
+ cloudPolicyId?: string;
89
+ /** Immutable revision of that policy — the half of attribution that identifies WHICH version ran. */
90
+ cloudRevision?: number;
91
+ /**
92
+ * The cloud generation active when this event was evaluated, recorded on
93
+ * every row of a managed machine regardless of what decided. "What was
94
+ * deployed here" is a different question from "what decided", and only this
95
+ * distinguishes a rollout that changed no outcomes from one that never
96
+ * arrived.
97
+ */
98
+ cloudGeneration?: number;
99
+ /**
100
+ * What observe-mode policies WOULD have done, had they been enforcing. This
101
+ * record is the entire point of observe mode: without it the row is
102
+ * indistinguishable from one where the policy never matched, and the rollout
103
+ * being trialled is unmeasurable.
104
+ */
105
+ observed?: Array<{
106
+ policyId: string;
107
+ revision: number;
108
+ decision: "deny" | "instruct";
109
+ reason: string | null;
110
+ }>;
72
111
  }
73
112
 
74
113
  export interface HookActivityFilters {
@@ -77,6 +116,12 @@ export interface HookActivityFilters {
77
116
  policyName?: string;
78
117
  sessionId?: string;
79
118
  integration?: IntegrationType;
119
+ /**
120
+ * Filter by where the deciding policy came from. "Show me everything my
121
+ * organization's policies decided" is the question cloud rollout reporting
122
+ * is built on, and it is unanswerable without this.
123
+ */
124
+ source?: "builtin" | "custom" | "convention" | "cloud";
80
125
  }
81
126
 
82
127
  export interface HookActivityStats {
@@ -298,6 +343,11 @@ export function searchHookActivity(
298
343
  return false;
299
344
  }
300
345
  if (filters.integration && entry.integration !== filters.integration) return false;
346
+ // Rows written before attribution existed have no policySource. They are
347
+ // excluded from every source filter rather than guessed at — a wrong
348
+ // attribution is worse than a missing one when the point is proving which
349
+ // rollout decided something.
350
+ if (filters.source && entry.policySource !== filters.source) return false;
301
351
  return true;
302
352
  });
303
353
 
@@ -77,8 +77,9 @@ export async function resolveLocalImport(
77
77
  export async function createEsmShim(
78
78
  distIndex: string,
79
79
  distUrl: string,
80
+ tmpSuffix = TMP_SUFFIX,
80
81
  ): Promise<{ shimPath: string; shimUrl: string }> {
81
- const shimPath = distIndex + ".__failproofai_esm_shim__.mjs";
82
+ const shimPath = `${distIndex}${tmpSuffix}.shim.mjs`;
82
83
  const shimCode = [
83
84
  `import _cjs from '${distUrl}';`,
84
85
  `export const customPolicies = _cjs.customPolicies;`,
@@ -101,6 +102,7 @@ export async function rewriteFileTree(
101
102
  entryPath: string,
102
103
  distUrl: string | null,
103
104
  distIndex: string | null,
105
+ tmpSuffix = TMP_SUFFIX,
104
106
  ): Promise<string[]> {
105
107
  const queue: string[] = [entryPath];
106
108
  const visited = new Set<string>();
@@ -108,7 +110,7 @@ export async function rewriteFileTree(
108
110
 
109
111
  let esmShimUrl: string | null = null;
110
112
  if (distIndex && distUrl) {
111
- const shim = await createEsmShim(distIndex, distUrl);
113
+ const shim = await createEsmShim(distIndex, distUrl, tmpSuffix);
112
114
  tmpFiles.push(shim.shimPath);
113
115
  esmShimUrl = shim.shimUrl;
114
116
  }
@@ -148,7 +150,7 @@ export async function rewriteFileTree(
148
150
  if (!visited.has(resolved) && !queue.includes(resolved)) {
149
151
  queue.push(resolved);
150
152
  }
151
- let relPath = relative(dir, resolved + TMP_SUFFIX).split("\\").join("/");
153
+ let relPath = relative(dir, resolved + tmpSuffix).split("\\").join("/");
152
154
  if (!relPath.startsWith(".")) relPath = "./" + relPath;
153
155
  rewrites.set(specifier, relPath);
154
156
  }
@@ -163,7 +165,7 @@ export async function rewriteFileTree(
163
165
  code = code.replace(new RegExp(`"${escaped}"`, "g"), `"${replacement}"`);
164
166
  }
165
167
 
166
- const tmpPath = filePath + TMP_SUFFIX;
168
+ const tmpPath = filePath + tmpSuffix;
167
169
  await writeFile(tmpPath, code, "utf-8");
168
170
  tmpFiles.push(tmpPath);
169
171
  }
@@ -80,8 +80,36 @@ export interface LlmConfig {
80
80
  model?: string;
81
81
  }
82
82
 
83
+ /**
84
+ * The collector's non-secret settings. The ingest credential deliberately does
85
+ * NOT live here: this file is written with a bare `writeFileSync`, so it
86
+ * inherits the umask and lands at 0664 inside a 0775 `~/.failproofai/` — an API
87
+ * key here would be readable by every local user. It lives in
88
+ * `~/.failproofai/ingest.json` at 0600 instead.
89
+ *
90
+ * Read by the Rust daemon (`crates/fpai-collect/src/config.rs`); the field
91
+ * names must stay in step with the `Settings` struct there.
92
+ */
93
+ export interface CollectorConfig {
94
+ /**
95
+ * Ship agent session transcripts. Defaults to false and is a SEPARATE opt-in
96
+ * from configuring a key, because transcripts carry prompts, file contents
97
+ * and whatever was pasted into a terminal.
98
+ */
99
+ sessions?: boolean;
100
+ /** Ship hook activity. Carries decisions and tool names, never file contents. */
101
+ hooks?: boolean;
102
+ /** `decisions` keeps every deny/instruct exact and aggregates the ~99% allows. */
103
+ hooksVerbosity?: "all" | "decisions" | "off";
104
+ redact?: "minimal" | "off";
105
+ /** Label stamped on every event. Must not contain a comma — ingest skips those lines. */
106
+ environment?: string;
107
+ }
108
+
83
109
  export interface HooksConfig {
84
110
  enabledPolicies: string[];
111
+ /** Collector settings; see {@link CollectorConfig}. Absent means unconfigured. */
112
+ collector?: CollectorConfig;
85
113
  llm?: LlmConfig;
86
114
  policyParams?: Record<string, Record<string, unknown>>;
87
115
  /** Explicit custom policy files, loaded in array order. */
@@ -98,6 +126,13 @@ export interface HooksConfig {
98
126
  * Only ever written explicitly as `false`.
99
127
  */
100
128
  customPoliciesEnabled?: boolean;
129
+ /**
130
+ * Upper bound, in ms, on how long `failproofai config --pause` may suspend
131
+ * enforcement. Only ever LOWERS the built-in 8h ceiling — a value above it is
132
+ * clamped back down, because a ceiling a project can raise is not a ceiling.
133
+ * Absent means the built-in ceiling applies.
134
+ */
135
+ maxPauseMs?: number;
101
136
  /**
102
137
  * Record of the convention policies (`.failproofai/policies/*policies.mjs`)
103
138
  * discovered at this scope the last time a failproofai command ran.