failproofai 1.0.0-beta.2 → 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 (196) 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]__1psjybo._.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]__0nb0syg._.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_0oy_-36.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/{3s7myk_co0gbh.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/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.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/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.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 +2195 -770
  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 +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -109,7 +109,7 @@
109
109
  "eslint-plugin-react-hooks": "7.0.1",
110
110
  "vite": "8.0.16",
111
111
  "undici": "7.28.0",
112
- "brace-expansion": "5.0.8",
112
+ "brace-expansion": "5.0.9",
113
113
  "sharp": "0.35.0"
114
114
  }
115
115
  }
@@ -170,6 +170,99 @@ const RUNAS_RE = /(?:^|;|&&|\|\|)\s*runas\s/i;
170
170
 
171
171
  // blockCurlPipeSh
172
172
  const CURL_PIPE_SH_RE = /(?:curl|wget)\s.*\|\s*(?:sh|bash|zsh|dash|ksh|csh|tcsh|fish|ash)\b/;
173
+ /**
174
+ * `failproofai config --pause` in any of its reachable spellings — direct, via
175
+ * `npx`/`bunx`/`pnpm dlx`, by absolute path, or through the `configure`/`setup`
176
+ * aliases the CLI normalizes. Only `--pause` matches: `--resume` and `--status`
177
+ * restore or merely report enforcement, so an agent running either is harmless.
178
+ *
179
+ * Two details carry the whole policy, and an earlier version got both wrong:
180
+ *
181
+ * `failproofai[^\s]*` rather than `\bfailproofai\b`. A word boundary stops at
182
+ * the character after the name, so every ordinary way of pinning or pathing the
183
+ * binary walked straight through: `npx failproofai@latest`, `npx -y
184
+ * failproofai@0.0.16`, `bunx failproofai@latest`, and
185
+ * `node /usr/lib/node_modules/failproofai/bin/failproofai.mjs`. The suffix has
186
+ * to be absorbed by the same token.
187
+ *
188
+ * `\s+--pause` rather than `\s--pause`. With a single `\s`, two spaces before
189
+ * the flag — which no shell cares about and any agent may emit — did not match.
190
+ *
191
+ * This still raises the bar rather than closing the class: base64, a wrapper
192
+ * script, an alias or a shell variable all still reach the same state. Closing
193
+ * it properly means a pause cannot originate from a tool call at all.
194
+ *
195
+ * Matched against BOTH the raw command and its shell-unescaped form (see
196
+ * `stripShellQuoting`), because a shell reconstructs the binary name from
197
+ * fragments a regex over the raw string cannot see.
198
+ */
199
+ const SELF_PAUSE_RE =
200
+ /failproofai[^\s]*(?:\s+\S+)*?\s+(?:config|configure|setup)\b(?:\s+\S+)*?\s+--pause\b/;
201
+
202
+ /**
203
+ * Decode one ANSI-C `$'...'` body (the text between the quotes) to the bytes a
204
+ * shell would produce. Covers the escape forms that can reconstruct an ASCII
205
+ * identifier: `\xHH`, octal `\NNN`, `\uHHHH`, `\UHHHHHHHH`, and the named
206
+ * controls. An out-of-range or malformed sequence is left as-is rather than
207
+ * throwing — this runs on adversarial input and must never crash the hook.
208
+ */
209
+ function decodeAnsiC(body: string): string {
210
+ return body.replace(
211
+ /\\(x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|[0-7]{1,3}|.)/g,
212
+ (_, seq: string) => {
213
+ try {
214
+ if (seq[0] === "x") return String.fromCharCode(parseInt(seq.slice(1), 16));
215
+ if (seq[0] === "u" || seq[0] === "U") return String.fromCodePoint(parseInt(seq.slice(1), 16));
216
+ if (/^[0-7]+$/.test(seq)) return String.fromCharCode(parseInt(seq, 8));
217
+ } catch {
218
+ return seq;
219
+ }
220
+ const named: Record<string, string> = {
221
+ a: "\x07", b: "\b", e: "\x1b", f: "\f", n: "\n",
222
+ r: "\r", t: "\t", v: "\v", "\\": "\\", "'": "'", '"': '"', "?": "?",
223
+ };
224
+ return named[seq] ?? seq;
225
+ },
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Collapse the quoting a POSIX shell resolves LEXICALLY — before it execs a
231
+ * command — so a literal matcher sees roughly what will actually run.
232
+ *
233
+ * Two adversarial red-team rounds defeated the pattern above by reassembling
234
+ * the binary name from fragments the regex could not see: first backslash and
235
+ * quote splitting (`fail\proofai`, `fail"proof"ai`, `f\a\i\l\p\r\o\o\f\a\i`),
236
+ * then ANSI-C quoting (`$'fail\x70roofai'`, octal `\160`, unicode `p`).
237
+ * Each executes the real pause. This now normalizes all three lexical forms:
238
+ * ANSI-C `$'...'` bodies are decoded first (the generic backslash pass below
239
+ * would otherwise turn `\x70` into `x70`), then surrounding single/double
240
+ * quotes and lone backslash-escapes are removed.
241
+ *
242
+ * The boundary is principled, not arbitrary: everything handled here is pure
243
+ * shell LEXING. What remains — a variable (`f=failproofai; $f …`), command
244
+ * substitution (`$(printf failproofai)`), `eval`, an alias, a base64 pipe —
245
+ * requires the shell to EXECUTE something to reconstruct the name, which no
246
+ * PreToolUse hook inspecting a command string can follow. That class is the one
247
+ * the header comment above calls out, and the only real closure is to make the
248
+ * pause ACTION itself refuse to originate from a tool call — deferred with the
249
+ * rest of the daemon-side redesign.
250
+ */
251
+ function stripShellQuoting(command: string): string {
252
+ // Line continuation FIRST. A shell deletes a backslash-newline pair entirely
253
+ // and rejoins the fragments, so `fail\<newline>proofai` runs the real binary.
254
+ // The generic `\(.)` strip below cannot catch it — JS `.` excludes newline —
255
+ // so it has to be removed before that pass, or the fragments never join.
256
+ // (A third red-team round rode exactly this in.) `\r?\n` also covers a
257
+ // CRLF-joined line.
258
+ const joined = command.replace(/\\\r?\n/g, "");
259
+ // `$'...'` next, honoring `\'` inside the body, so its own escapes decode
260
+ // before the generic backslash-strip can mangle them.
261
+ const ansiDecoded = joined.replace(/\$'((?:[^'\\]|\\.)*)'/g, (_, body: string) =>
262
+ decodeAnsiC(body),
263
+ );
264
+ return ansiDecoded.replace(/\\(.)/g, "$1").replace(/['"]/g, "");
265
+ }
173
266
  const PS_WEB_PIPE_RE = /(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm)\s+.*\|\s*(?:Invoke-Expression|iex)/i;
174
267
 
175
268
  // blockForcePush
@@ -579,6 +672,42 @@ function blockSudo(ctx: PolicyContext): PolicyResult {
579
672
  return allow();
580
673
  }
581
674
 
675
+ /**
676
+ * A pause the agent can issue is not a guardrail.
677
+ *
678
+ * `failproofai config --pause` is a human affordance: you watched the agent get
679
+ * blocked, you judged the block wrong, you suspended enforcement for a bit. An
680
+ * agent that can run it turns every other policy advisory — it need only shell
681
+ * out once to switch them all off, and the pause even survives into later turns.
682
+ *
683
+ * This raises the bar rather than closing the class: an agent can still reach
684
+ * the same state obfuscated (base64, a wrapper script, an alias). Closing it
685
+ * properly means the pause cannot originate from a tool call at all — a
686
+ * dashboard action, or a daemon that accepts it only from a TTY. Until then,
687
+ * the honest description of this policy is "stops the obvious attempt".
688
+ *
689
+ * It is deliberately NOT redundant with `block-failproofai-commands`, which
690
+ * blocks the CLI far more broadly. That one anchors on a command boundary
691
+ * (`FAILPROOFAI_CLI_RE`), so `npx -y failproofai config --pause` does not match
692
+ * it — and being broad, it is a policy people plausibly switch off so agents
693
+ * can run `failproofai audit`. Neither gap should leave pausing reachable, so
694
+ * this stays narrow, matches the runner forms, and survives that one being off.
695
+ */
696
+ function blockSelfPause(ctx: PolicyContext): PolicyResult {
697
+ if (ctx.toolName !== "Bash") return allow();
698
+ const cmd = getCommand(ctx);
699
+ // The raw command AND its shell-unescaped form: a shell strips quotes and
700
+ // backslashes before running the binary, so `fail\proofai config --pause`
701
+ // reaches the pause CLI even though the literal name is broken.
702
+ if (SELF_PAUSE_RE.test(cmd) || SELF_PAUSE_RE.test(stripShellQuoting(cmd))) {
703
+ return deny(
704
+ "Pausing failproofai enforcement is a human action, not an agent one. " +
705
+ "If a policy is blocking legitimate work, say so and let the operator decide.",
706
+ );
707
+ }
708
+ return allow();
709
+ }
710
+
582
711
  function blockCurlPipeSh(ctx: PolicyContext): PolicyResult {
583
712
  if (ctx.toolName !== "Bash") return allow();
584
713
  const cmd = getCommand(ctx);
@@ -1752,6 +1881,16 @@ export const BUILTIN_POLICIES: BuiltinPolicyDefinition[] = [
1752
1881
  },
1753
1882
  } satisfies PolicyParamsSchema,
1754
1883
  },
1884
+ {
1885
+ name: "block-self-pause",
1886
+ displayTitle: "Tried to pause failproofai enforcement",
1887
+ impact: "An agent that can pause enforcement can switch off every other policy.",
1888
+ description: "Block agents from pausing failproofai enforcement",
1889
+ fn: blockSelfPause,
1890
+ match: { events: ["PreToolUse", "PermissionRequest"], toolNames: ["Bash"] },
1891
+ defaultEnabled: true,
1892
+ category: "Dangerous Commands",
1893
+ },
1755
1894
  {
1756
1895
  name: "block-sudo",
1757
1896
  displayTitle: "Tried to run a command with sudo",
@@ -0,0 +1,205 @@
1
+ /**
2
+ * One connection to Failproof Cloud, with two capabilities.
3
+ *
4
+ * # Why this exists
5
+ *
6
+ * Enrolment (#632) and collection (#640) were built independently and each
7
+ * arrived with its own credential file, its own URL and its own setup step:
8
+ *
9
+ * pull policy -> ~/.failproofai/cloud.json <base>/enforcement/v1/...
10
+ * push events -> ~/.failproofai/ingest.json <base>/events
11
+ *
12
+ * Same server, same organisation, usually the same key. Asked twice, a user
13
+ * who connects for policy sees an empty dashboard and reasonably concludes the
14
+ * product is broken — the second setup step is invisible until someone goes
15
+ * looking for it.
16
+ *
17
+ * So the URL and token are asked for ONCE and both capabilities are configured
18
+ * from them. The two files stay separate on disk — that is a real security
19
+ * boundary, since `cloud.json` and `ingest.json` can hold different keys with
20
+ * different permissions, and the daemon reads them independently — but nothing
21
+ * above this module has to know that.
22
+ *
23
+ * # Capabilities are verified and reported INDEPENDENTLY
24
+ *
25
+ * A key may carry `policies:pull` and not `events:add`, or the reverse. The
26
+ * honest outcome is then a partial connection with a precise reason, not an
27
+ * all-or-nothing failure: refusing to enrol for policy because the dashboard
28
+ * would be empty protects nothing, and silently skipping ingest reproduces the
29
+ * exact confusion this module removes.
30
+ */
31
+ import {
32
+ cloudCredentialPath,
33
+ verifyCloudCredentials,
34
+ writeCloudCredentials,
35
+ type CloudCredentials,
36
+ } from "./cloud-enrollment";
37
+ import {
38
+ ingestPath,
39
+ validateIngestKey,
40
+ writeCollectorSettings,
41
+ writeIngestCredential,
42
+ type CollectorSettings,
43
+ } from "./collector-config";
44
+
45
+ /** The path ingest is served on, appended to the cloud base URL. */
46
+ export const INGEST_PATH = "/events";
47
+
48
+ /**
49
+ * The ingest endpoint for a cloud base URL.
50
+ *
51
+ * Ingest wants a COMPLETE endpoint while enrolment wants a base, which is the
52
+ * whole reason two URLs were being asked for. One is derivable from the other,
53
+ * so only the base is ever asked for.
54
+ */
55
+ export function ingestUrlFor(baseUrl: string): string {
56
+ return `${baseUrl.replace(/\/$/, "")}${INGEST_PATH}`;
57
+ }
58
+
59
+ /**
60
+ * The cloud base for whatever URL someone actually typed.
61
+ *
62
+ * Accepts either form. People paste the ingest endpoint — it is what the older
63
+ * prompt asked for, and what any existing runbook or `ingest.json` contains —
64
+ * and rejecting it as "not a base URL" would be pedantry about a URL we can
65
+ * read perfectly well.
66
+ */
67
+ export function cloudBaseFor(url: string): string {
68
+ const trimmed = url.replace(/\/$/, "");
69
+ return trimmed.endsWith(INGEST_PATH) ? trimmed.slice(0, -INGEST_PATH.length) : trimmed;
70
+ }
71
+
72
+ export interface ConnectInput {
73
+ /** Cloud base URL, already validated by `validateCloudUrl`. */
74
+ url: string;
75
+ token: string;
76
+ machineId: string;
77
+ /** Transcripts are a separate, explicit opt-in. Never defaulted on. */
78
+ sessions?: boolean;
79
+ environment?: string;
80
+ /** Injected by tests so nothing reaches the network. */
81
+ verifyPolicy?: typeof verifyCloudCredentials;
82
+ verifyIngest?: typeof validateIngestKey;
83
+ }
84
+
85
+ export interface CapabilityOutcome {
86
+ ok: boolean;
87
+ reason?: string;
88
+ }
89
+
90
+ export interface ConnectOutcome {
91
+ policy: CapabilityOutcome & { policyCount?: number; generation?: number };
92
+ ingest: CapabilityOutcome;
93
+ /** True when at least one capability was configured. */
94
+ anyConfigured: boolean;
95
+ }
96
+
97
+ /**
98
+ * Verify both capabilities, then write only what actually works.
99
+ *
100
+ * Verification happens BEFORE any write, per capability. A credential file that
101
+ * does not work is worse than none, because `--status` then reports a
102
+ * connection the machine does not have.
103
+ */
104
+ export async function connectToCloud(input: ConnectInput): Promise<ConnectOutcome> {
105
+ const creds: CloudCredentials = {
106
+ url: input.url,
107
+ machineId: input.machineId,
108
+ token: input.token,
109
+ };
110
+
111
+ const verifyPolicy = input.verifyPolicy ?? verifyCloudCredentials;
112
+ const verifyIngest = input.verifyIngest ?? validateIngestKey;
113
+ const ingestUrl = ingestUrlFor(input.url);
114
+
115
+ // Both checks run regardless of the other's result: reporting "cannot pull
116
+ // policy" while staying silent about ingest would send someone to fix one
117
+ // permission and hit the next one afterwards.
118
+ const [policyResult, ingestResult] = await Promise.all([
119
+ verifyPolicy(creds),
120
+ verifyIngest({ url: ingestUrl, key: input.token }),
121
+ ]);
122
+
123
+ const outcome: ConnectOutcome = {
124
+ policy: policyResult.ok
125
+ ? { ok: true, policyCount: policyResult.policyCount, generation: policyResult.generation }
126
+ : { ok: false, reason: policyResult.reason },
127
+ ingest: ingestResult.ok ? { ok: true } : { ok: false, reason: ingestResult.reason },
128
+ anyConfigured: false,
129
+ };
130
+
131
+ if (policyResult.ok) {
132
+ writeCloudCredentials(creds);
133
+ outcome.anyConfigured = true;
134
+ }
135
+
136
+ if (ingestResult.ok) {
137
+ writeIngestCredential({ url: ingestUrl, key: input.token });
138
+ const settings: CollectorSettings = {
139
+ // Hook activity is what makes the dashboard show anything at all, and it
140
+ // carries decisions and tool names — never file contents.
141
+ hooks: true,
142
+ // Transcripts carry prompts, file contents and whatever was pasted into
143
+ // a terminal, so they stay off unless explicitly asked for.
144
+ sessions: input.sessions === true,
145
+ environment: input.environment,
146
+ };
147
+ writeCollectorSettings(settings);
148
+ outcome.anyConfigured = true;
149
+ }
150
+
151
+ return outcome;
152
+ }
153
+
154
+ /**
155
+ * How to describe the outcome to someone at a terminal.
156
+ *
157
+ * The partial cases are the ones worth wording carefully: each names the
158
+ * missing permission, because "connected, but the dashboard will stay empty"
159
+ * without a cause is the message that sends people to support.
160
+ */
161
+ export function describeOutcome(outcome: ConnectOutcome, machineId: string, url: string): string[] {
162
+ const lines: string[] = [];
163
+
164
+ if (outcome.policy.ok && outcome.ingest.ok) {
165
+ lines.push(`Connected to ${url} as ${machineId}.`);
166
+ const n = outcome.policy.policyCount ?? 0;
167
+ lines.push(` Policy ${n} polic${n === 1 ? "y" : "ies"} assigned (generation ${outcome.policy.generation ?? 0}).`);
168
+ lines.push(` Dashboard hook activity will be sent to ${ingestUrlFor(url)}.`);
169
+ return lines;
170
+ }
171
+
172
+ if (outcome.policy.ok && !outcome.ingest.ok) {
173
+ const n = outcome.policy.policyCount ?? 0;
174
+ lines.push(`Connected to ${url} as ${machineId} for policy only.`);
175
+ lines.push(` Policy ${n} polic${n === 1 ? "y" : "ies"} assigned (generation ${outcome.policy.generation ?? 0}).`);
176
+ lines.push(` Dashboard NOT configured: ${outcome.ingest.reason}`);
177
+ lines.push("");
178
+ lines.push(" Enforcement works. Nothing will appear in the dashboard until this");
179
+ lines.push(" key also carries `events:add`, or you re-run --connect with one that does.");
180
+ return lines;
181
+ }
182
+
183
+ if (!outcome.policy.ok && outcome.ingest.ok) {
184
+ lines.push(`Connected to ${url} as ${machineId} for dashboard reporting only.`);
185
+ lines.push(` Dashboard hook activity will be sent to ${ingestUrlFor(url)}.`);
186
+ lines.push(` Cloud policy NOT configured: ${outcome.policy.reason}`);
187
+ lines.push("");
188
+ lines.push(" This machine keeps enforcing its LOCAL policies and will report what");
189
+ lines.push(" they decide, but will not receive centrally-managed ones.");
190
+ return lines;
191
+ }
192
+
193
+ lines.push(`Could not connect to ${url}.`);
194
+ lines.push(` Policy: ${outcome.policy.reason}`);
195
+ lines.push(` Dashboard: ${outcome.ingest.reason}`);
196
+ return lines;
197
+ }
198
+
199
+ /** Paths written, for a closing note. Only those that exist. */
200
+ export function configuredPaths(outcome: ConnectOutcome): string[] {
201
+ const paths: string[] = [];
202
+ if (outcome.policy.ok) paths.push(cloudCredentialPath());
203
+ if (outcome.ingest.ok) paths.push(ingestPath());
204
+ return paths;
205
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `failproofai config --connect | --disconnect`, and the connection half of
3
+ * `--status`.
4
+ */
5
+ import {
6
+ clearCloudCredentials,
7
+ maskToken,
8
+ readCloudCredentials,
9
+ validateCloudUrl,
10
+ verifyCloudCredentials,
11
+ writeCloudCredentials,
12
+ cloudCredentialPath,
13
+ } from "./cloud-enrollment";
14
+ import { daemonServiceStatus } from "./daemon-service";
15
+ import { daemonSocketPresent } from "./daemon-client";
16
+ import {
17
+ cloudBaseFor,
18
+ configuredPaths,
19
+ connectToCloud,
20
+ describeOutcome,
21
+ ingestUrlFor,
22
+ } from "./cloud-connection";
23
+ import {
24
+ clearIngestCredential,
25
+ hasIngestCredential,
26
+ readIngestCredential,
27
+ validateIngestKey,
28
+ } from "./collector-config";
29
+
30
+ export interface ConnectOptions {
31
+ url?: string;
32
+ token?: string;
33
+ machineId?: string;
34
+ /** Defaults to the hostname; injected for tests. */
35
+ defaultMachineId?: string;
36
+ /**
37
+ * Send full session transcripts as well as hook decisions. Off unless asked
38
+ * for: a transcript carries prompts, file contents and whatever was pasted
39
+ * into a terminal, so it can never be a side effect of connecting.
40
+ */
41
+ sessions?: boolean;
42
+ environment?: string;
43
+ /** Injected for tests so nothing reaches the network. */
44
+ verify?: typeof verifyCloudCredentials;
45
+ verifyIngest?: typeof validateIngestKey;
46
+ daemonStatus?: () => ReturnType<typeof daemonServiceStatus>;
47
+ }
48
+
49
+ export interface CommandResult {
50
+ exitCode: number;
51
+ lines: string[];
52
+ }
53
+
54
+ /**
55
+ * Warn rather than refuse when there is no daemon.
56
+ *
57
+ * Cloud policy is evaluated by failproofaid, so credentials alone pull
58
+ * nothing — but refusing would break baking a machine image where the daemon
59
+ * is installed later, and enrolment is genuinely independent of it. Say so
60
+ * loudly instead of failing.
61
+ */
62
+ function daemonWarning(status: ReturnType<typeof daemonServiceStatus>): string[] {
63
+ if (status === "running") return [];
64
+ // A daemon started by hand is invisible to the service manager but is
65
+ // running and pulling. Telling someone whose machine is actively enforcing
66
+ // that "nothing will be pulled" is false, and it is exactly the state a
67
+ // developer testing locally is in.
68
+ if (daemonSocketPresent()) {
69
+ return [
70
+ "",
71
+ " A daemon is running outside the service manager, so policy is being pulled.",
72
+ " Install it as a service to survive reboot and logout: failproofai config",
73
+ ];
74
+ }
75
+ if (status === "unsupported-platform") {
76
+ return [
77
+ "",
78
+ "! failproofaid does not run on this platform yet, so nothing will be pulled.",
79
+ " The credentials are stored and will work on a supported machine.",
80
+ ];
81
+ }
82
+ if (status === "not-installed") {
83
+ // "as a service", not "at all": this reads the service manager, so a daemon
84
+ // someone is running by hand during development is reported as absent. The
85
+ // wording has to be true in both cases.
86
+ return [
87
+ "",
88
+ "! failproofaid is not installed as a service, so nothing will be pulled yet.",
89
+ " Install it with: failproofai config",
90
+ ];
91
+ }
92
+ return [
93
+ "",
94
+ "! failproofaid is installed but not running, so nothing will be pulled yet.",
95
+ " Check it with: failproofai config --status",
96
+ ];
97
+ }
98
+
99
+ export async function runConnectCommand(opts: ConnectOptions): Promise<CommandResult> {
100
+ if (!opts.url) return { exitCode: 1, lines: ["--connect needs a URL, e.g. --connect https://be.failproof.ai"] };
101
+ if (!opts.token) {
102
+ return {
103
+ exitCode: 1,
104
+ lines: [
105
+ "--connect needs a machine token: --token <key>",
106
+ "",
107
+ "Create an API key carrying only the `policies:pull` permission for this",
108
+ "machine. Do not use an admin key.",
109
+ ],
110
+ };
111
+ }
112
+
113
+ // Accepts the ingest endpoint too. People paste what the older setup asked
114
+ // for, or what is already in their `ingest.json`, and refusing a URL we can
115
+ // read perfectly well is pedantry.
116
+ const validated = validateCloudUrl(cloudBaseFor(opts.url));
117
+ if (!validated.ok) return { exitCode: 1, lines: [validated.reason] };
118
+
119
+ const machineId = (opts.machineId ?? opts.defaultMachineId ?? "").trim();
120
+ if (!machineId) {
121
+ return { exitCode: 1, lines: ["Could not determine a machine id — pass one with --machine-id <id>."] };
122
+ }
123
+
124
+ // ONE connection, both capabilities. Each is verified before anything is
125
+ // written and reported on its own, because a key can carry `policies:pull`
126
+ // without `events:add` and the useful message names which one is missing.
127
+ const outcome = await connectToCloud({
128
+ url: validated.url,
129
+ token: opts.token,
130
+ machineId,
131
+ sessions: opts.sessions,
132
+ environment: opts.environment,
133
+ verifyPolicy: opts.verify,
134
+ verifyIngest: opts.verifyIngest,
135
+ });
136
+
137
+ if (!outcome.anyConfigured) {
138
+ return { exitCode: 1, lines: describeOutcome(outcome, machineId, validated.url) };
139
+ }
140
+
141
+ // The exit code tracks the PRIMARY purpose — enrolling this machine for
142
+ // cloud-managed policy. Configuring the dashboard is worth doing and worth
143
+ // reporting, but it must not turn a failed enrolment into a success: a fleet
144
+ // provisioning script running `--connect … && …` has to stop when the machine
145
+ // will not receive policy. The ingest credential still gets written, because
146
+ // it works and discarding it helps nobody.
147
+ const exitCode = outcome.policy.ok ? 0 : 1;
148
+
149
+ const status = (opts.daemonStatus ?? daemonServiceStatus)();
150
+ const paths = configuredPaths(outcome);
151
+ const lines = [
152
+ ...describeOutcome(outcome, machineId, validated.url),
153
+ ` Token ${maskToken(opts.token)} stored in ${paths.join(" and ")} (owner-only).`,
154
+ ];
155
+ if (outcome.ingest.ok && opts.sessions !== true) {
156
+ // Said out loud rather than left as a default: someone who wanted
157
+ // transcripts should not discover months later that none were sent.
158
+ lines.push(" Session transcripts are NOT being sent. Add --send-transcripts to include them.");
159
+ }
160
+ lines.push(...daemonWarning(status));
161
+
162
+ return { exitCode, lines };
163
+ }
164
+
165
+ export function runDisconnectCommand(): CommandResult {
166
+ const existing = readCloudCredentials();
167
+ const hadIngest = hasIngestCredential();
168
+ const removed = clearCloudCredentials();
169
+ // Disconnect means disconnect. Clearing only the policy credential would
170
+ // leave the machine still shipping activity to a cloud the user believes
171
+ // they have left — the one outcome nobody expects from this command.
172
+ const removedIngest = clearIngestCredential();
173
+
174
+ if (!removed && !existing && !hadIngest) {
175
+ return { exitCode: 0, lines: ["This machine is not connected to Failproof Cloud."] };
176
+ }
177
+
178
+ const lines = [
179
+ "Disconnected from Failproof Cloud.",
180
+ "",
181
+ " Cloud-managed policies already on disk stop being refreshed. Local",
182
+ " builtin, custom and convention policies are unaffected.",
183
+ ];
184
+ if (removedIngest) {
185
+ lines.push(" Hook activity and transcripts stop being sent.");
186
+ }
187
+ return { exitCode: 0, lines };
188
+ }
189
+
190
+ /** The connection half of `--status`. Never prints the token. */
191
+ export function connectionStatusLines(daemonStatus = daemonServiceStatus): string[] {
192
+ const envUrl = process.env.FAILPROOFAI_CLOUD_URL;
193
+ if (envUrl) {
194
+ // Env wins over the file in the daemon, so reporting the file here would
195
+ // describe a configuration that is not in effect.
196
+ return [
197
+ `Cloud: configured by environment (${envUrl}), which takes precedence over ${cloudCredentialPath()}.`,
198
+ ];
199
+ }
200
+ const creds = readCloudCredentials();
201
+ const ingest = readIngestCredential();
202
+
203
+ // Reported as ONE connection with two capabilities. A machine sending
204
+ // activity but pulling no policy, or the reverse, is a state worth seeing at
205
+ // a glance — it is exactly the half-configured case this command exists to
206
+ // catch, and two unrelated lines made it easy to miss.
207
+ if (!creds && !ingest) return ["Cloud: not connected."];
208
+
209
+ const lines: string[] = [];
210
+ if (creds) {
211
+ lines.push(`Cloud: connected to ${creds.url} as ${creds.machineId} (token ${maskToken(creds.token)}).`);
212
+ lines.push(` Policy pulling centrally-managed policies.`);
213
+ } else {
214
+ lines.push(`Cloud: connected to ${cloudBaseFor(ingest!.url)} for reporting only.`);
215
+ lines.push(` Policy NOT pulling — this machine enforces only its local policies.`);
216
+ }
217
+
218
+ if (ingest) {
219
+ lines.push(` Dashboard sending hook activity to ${ingest.url}.`);
220
+ } else {
221
+ lines.push(` Dashboard NOT sending — nothing from this machine appears in the dashboard.`);
222
+ lines.push(` Re-run: failproofai config --connect ${creds!.url} --token <key>`);
223
+ }
224
+
225
+ lines.push(...daemonWarning(daemonStatus()).map((l) => (l === "" ? "" : ` ${l.trim()}`)));
226
+ return lines;
227
+ }