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
@@ -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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `failproofai config --pause | --resume | --status`.
3
+ *
4
+ * Lives on `config` rather than as its own verb because `config` is the single
5
+ * place this product configures anything. The state it writes is NOT config
6
+ * though — see session-pause.ts for why a pause must never reach a file that
7
+ * gets committed.
8
+ */
9
+ import {
10
+ PAUSE_CEILING_MS,
11
+ clearPause,
12
+ formatDuration,
13
+ listActivePauses,
14
+ parsePauseDuration,
15
+ readActivePause,
16
+ resolveSessionForCwd,
17
+ writePause,
18
+ type ActivePause,
19
+ } from "./session-pause";
20
+ import { readMergedHooksConfig } from "./hooks-config";
21
+
22
+ export interface PauseCommandOptions {
23
+ action: "pause" | "resume" | "status";
24
+ /** Raw `--pause <duration>` argument, if any. */
25
+ duration?: string;
26
+ /** Explicit `--session <id>`. */
27
+ sessionId?: string;
28
+ /** `--all`. */
29
+ all?: boolean;
30
+ cwd?: string;
31
+ now?: number;
32
+ }
33
+
34
+ export interface PauseCommandResult {
35
+ exitCode: number;
36
+ lines: string[];
37
+ /** For telemetry; never the session id itself. */
38
+ affected: number;
39
+ }
40
+
41
+ /**
42
+ * The ceiling, after any config lowering. A project may tighten this; nothing
43
+ * may loosen it, so a config asking for 24h still gets 8h.
44
+ */
45
+ export function effectiveCeilingMs(cwd?: string): number {
46
+ try {
47
+ const configured = readMergedHooksConfig(cwd).maxPauseMs;
48
+ if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
49
+ return Math.min(configured, PAUSE_CEILING_MS);
50
+ }
51
+ } catch {
52
+ // An unreadable config must not remove the ceiling.
53
+ }
54
+ return PAUSE_CEILING_MS;
55
+ }
56
+
57
+ function describe(pause: ActivePause, now: number): string {
58
+ const remaining = formatDuration(Math.max(0, pause.expiresAt - now));
59
+ const at = new Date(pause.expiresAt).toLocaleTimeString();
60
+ const where = pause.cwd ? ` · ${pause.cwd}` : "";
61
+ return ` ${pause.sessionId} ${remaining} left (until ${at})${where}`;
62
+ }
63
+
64
+ export function runPauseCommand(opts: PauseCommandOptions): PauseCommandResult {
65
+ const now = opts.now ?? Date.now();
66
+ const cwd = opts.cwd ?? process.cwd();
67
+
68
+ if (opts.action === "status") {
69
+ const active = listActivePauses(now);
70
+ if (active.length === 0) {
71
+ return { exitCode: 0, lines: ["Enforcement is active — nothing is paused."], affected: 0 };
72
+ }
73
+ return {
74
+ exitCode: 0,
75
+ lines: [
76
+ `Enforcement paused for ${active.length} session${active.length === 1 ? "" : "s"}:`,
77
+ ...active.map((p) => describe(p, now)),
78
+ "",
79
+ "Cloud-managed policies keep enforcing regardless.",
80
+ "Resume early with: failproofai config --resume",
81
+ ],
82
+ affected: active.length,
83
+ };
84
+ }
85
+
86
+ if (opts.action === "resume") {
87
+ if (opts.all) {
88
+ const active = listActivePauses(now);
89
+ let cleared = 0;
90
+ for (const pause of active) if (clearPause(pause.sessionId)) cleared++;
91
+ return {
92
+ exitCode: 0,
93
+ lines: [cleared === 0 ? "Nothing was paused." : `Resumed enforcement for ${cleared} session(s).`],
94
+ affected: cleared,
95
+ };
96
+ }
97
+ const sessionId = opts.sessionId ?? resolveSessionForCwd(cwd, undefined, now);
98
+ if (!sessionId) {
99
+ return { exitCode: 0, lines: ["Nothing was paused for this directory."], affected: 0 };
100
+ }
101
+ const cleared = clearPause(sessionId);
102
+ return {
103
+ exitCode: 0,
104
+ lines: [cleared ? "Enforcement resumed." : "Nothing was paused for that session."],
105
+ affected: cleared ? 1 : 0,
106
+ };
107
+ }
108
+
109
+ // pause
110
+ let durationMs: number;
111
+ try {
112
+ durationMs = parsePauseDuration(opts.duration, effectiveCeilingMs(cwd));
113
+ } catch (err) {
114
+ return { exitCode: 1, lines: [err instanceof Error ? err.message : String(err)], affected: 0 };
115
+ }
116
+
117
+ const sessionId = opts.sessionId ?? resolveSessionForCwd(cwd, undefined, now);
118
+ if (!sessionId) {
119
+ // Deliberately an error, not a guess. Pausing the wrong session would leave
120
+ // the user believing enforcement is off while it is on, or vice versa.
121
+ return {
122
+ exitCode: 1,
123
+ lines: [
124
+ "No recent agent session found for this directory.",
125
+ "",
126
+ "A pause applies to one agent session, and the session id only appears once",
127
+ "an agent has run at least one tool call here. Either run your agent first,",
128
+ "or name the session explicitly:",
129
+ "",
130
+ " failproofai config --pause --session <id>",
131
+ "",
132
+ "Session ids are listed in the dashboard's activity view.",
133
+ ],
134
+ affected: 0,
135
+ };
136
+ }
137
+
138
+ const existing = readActivePause(sessionId, now);
139
+ const pause = writePause({ sessionId, durationMs, cwd, setBy: "cli", now });
140
+ const until = new Date(pause.expiresAt).toLocaleTimeString();
141
+
142
+ return {
143
+ exitCode: 0,
144
+ lines: [
145
+ existing
146
+ ? `Enforcement pause extended · ${formatDuration(durationMs)} · resumes at ${until}`
147
+ : `Enforcement paused · ${formatDuration(durationMs)} · resumes at ${until}`,
148
+ "",
149
+ " Builtin, custom and convention policies are suspended for this session.",
150
+ " Cloud-managed policies keep enforcing.",
151
+ "",
152
+ "It lifts on its own — no action needed. To end it early:",
153
+ " failproofai config --resume",
154
+ ],
155
+ affected: 1,
156
+ };
157
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Per-session enforcement pause — the state behind `failproofai config --pause`.
3
+ *
4
+ * A pause suspends LOCAL policy (builtins, explicit custom, convention) for one
5
+ * agent session, for a bounded time. It is deliberately NOT configuration:
6
+ *
7
+ * - Config is persistent, merged across project/local/global, and routinely
8
+ * committed to git. A paused enforcement state written there would outlive
9
+ * the session that asked for it and travel to everyone who checks out the
10
+ * branch — enforcement silently off for a whole team.
11
+ * - So pause state lives here, under `~/.failproofai/state/sessions/`, owner-
12
+ * only, keyed by session, and carrying an absolute expiry.
13
+ *
14
+ * Disk is the source of truth rather than the daemon: most machines have no
15
+ * daemon at all, and even where one exists the CLI that writes a pause is a
16
+ * different process from the hook that reads it. A daemon may cache this; it
17
+ * must not own it.
18
+ *
19
+ * Expiry is evaluated at READ time against the clock, never by a sweeper. A
20
+ * stale file is therefore inert rather than dangerous — the failure mode worth
21
+ * engineering against is not "the pause didn't work", it is "the pause silently
22
+ * never ended".
23
+ */
24
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { createHash } from "node:crypto";
27
+ import { join, resolve } from "node:path";
28
+ import { writeJsonAtomically } from "../../lib/atomic-write";
29
+ import { getAllHookActivityEntries } from "./hook-activity-store";
30
+
31
+ const SCHEMA_VERSION = 1;
32
+
33
+ /** Default when `--pause` is given no duration. */
34
+ export const PAUSE_DEFAULT_MS = 30 * 60_000;
35
+
36
+ /**
37
+ * Hard ceiling. Config may lower this, never raise it — a ceiling a project
38
+ * could raise is not a ceiling, and the whole point is that a forgotten pause
39
+ * costs little.
40
+ */
41
+ export const PAUSE_CEILING_MS = 8 * 60 * 60_000;
42
+
43
+ export interface ActivePause {
44
+ sessionId: string;
45
+ /** Epoch ms. */
46
+ pausedAt: number;
47
+ /** Epoch ms. Always finite — there is no unbounded pause. */
48
+ expiresAt: number;
49
+ /** Free-form provenance, e.g. "cli". Recorded so the activity log can say who. */
50
+ setBy: string;
51
+ /** Where it was issued from, for `--status` and for cwd-based resolution. */
52
+ cwd?: string;
53
+ }
54
+
55
+ interface StoredPause extends ActivePause {
56
+ schemaVersion: number;
57
+ }
58
+
59
+ export function pauseStateDir(): string {
60
+ const override = process.env.FAILPROOFAI_STATE_DIR;
61
+ if (override) return resolve(override, "sessions");
62
+ return join(homedir(), ".failproofai", "state", "sessions");
63
+ }
64
+
65
+ /**
66
+ * Filename is a digest of the session id, not the id itself.
67
+ *
68
+ * Session ids come from twelve different agent CLIs and are not a format we
69
+ * control — Hermes and OpenClaw mint their own, and nothing stops one
70
+ * containing a path separator or `..`. Hashing sidesteps the entire class
71
+ * rather than trying to enumerate what is safe; the real id is stored inside
72
+ * the file, so `--status` and `--all` still report it.
73
+ */
74
+ function pauseFilePath(sessionId: string): string {
75
+ const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
76
+ return join(pauseStateDir(), `${digest}.json`);
77
+ }
78
+
79
+ /**
80
+ * Parse `10m` / `2h` / `90s`, or a bare integer read as minutes.
81
+ *
82
+ * Refuses anything over the ceiling rather than silently clamping: someone
83
+ * typing `--pause 12h` has a expectation about how long enforcement is off,
84
+ * and quietly giving them 8h is worse than telling them no.
85
+ */
86
+ export function parsePauseDuration(input: string | undefined, ceilingMs = PAUSE_CEILING_MS): number {
87
+ if (input === undefined || input === "") return Math.min(PAUSE_DEFAULT_MS, ceilingMs);
88
+
89
+ const match = /^(\d+)\s*(s|m|h)?$/i.exec(input.trim());
90
+ if (!match) {
91
+ throw new Error(
92
+ `Invalid duration ${JSON.stringify(input)}. Use e.g. 30m, 2h, 90s (a bare number means minutes).`,
93
+ );
94
+ }
95
+ const value = Number(match[1]);
96
+ const unit = (match[2] ?? "m").toLowerCase();
97
+ const ms = unit === "s" ? value * 1000 : unit === "h" ? value * 3_600_000 : value * 60_000;
98
+
99
+ if (ms <= 0) throw new Error("Duration must be greater than zero.");
100
+ if (ms > ceilingMs) {
101
+ throw new Error(`Duration ${input} exceeds the maximum pause of ${formatDuration(ceilingMs)}.`);
102
+ }
103
+ return ms;
104
+ }
105
+
106
+ /** Human-readable, for CLI output and log reasons. */
107
+ export function formatDuration(ms: number): string {
108
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
109
+ const minutes = Math.round(ms / 60_000);
110
+ if (minutes < 60) return `${minutes}m`;
111
+ const hours = Math.floor(minutes / 60);
112
+ const rest = minutes % 60;
113
+ return rest === 0 ? `${hours}h` : `${hours}h${rest}m`;
114
+ }
115
+
116
+ function parseStored(raw: unknown): StoredPause | null {
117
+ if (!raw || typeof raw !== "object") return null;
118
+ const p = raw as Partial<StoredPause>;
119
+ if (p.schemaVersion !== SCHEMA_VERSION) return null;
120
+ if (typeof p.sessionId !== "string" || p.sessionId.length === 0) return null;
121
+ if (!Number.isFinite(p.pausedAt) || !Number.isFinite(p.expiresAt)) return null;
122
+ return {
123
+ schemaVersion: SCHEMA_VERSION,
124
+ sessionId: p.sessionId,
125
+ pausedAt: p.pausedAt as number,
126
+ expiresAt: p.expiresAt as number,
127
+ setBy: typeof p.setBy === "string" ? p.setBy : "unknown",
128
+ ...(typeof p.cwd === "string" ? { cwd: p.cwd } : {}),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * The active pause for a session, or null.
134
+ *
135
+ * Never throws: this is called on the hook path, and a malformed or unreadable
136
+ * state file must mean "enforcement is ON" rather than crashing the evaluation.
137
+ * Failing toward enforcement is the only safe direction here.
138
+ */
139
+ export function readActivePause(sessionId: string | undefined, now = Date.now()): ActivePause | null {
140
+ if (!sessionId) return null;
141
+ try {
142
+ const path = pauseFilePath(sessionId);
143
+ if (!existsSync(path)) return null;
144
+ const stored = parseStored(JSON.parse(readFileSync(path, "utf8")));
145
+ if (!stored) return null;
146
+ if (stored.expiresAt <= now) return null; // expired — inert, not dangerous
147
+ const { schemaVersion: _ignored, ...pause } = stored;
148
+ return pause;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ export function writePause(opts: {
155
+ sessionId: string;
156
+ durationMs: number;
157
+ cwd?: string;
158
+ setBy?: string;
159
+ now?: number;
160
+ }): ActivePause {
161
+ const now = opts.now ?? Date.now();
162
+ const pause: ActivePause = {
163
+ sessionId: opts.sessionId,
164
+ pausedAt: now,
165
+ expiresAt: now + opts.durationMs,
166
+ setBy: opts.setBy ?? "cli",
167
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
168
+ };
169
+ mkdirSync(pauseStateDir(), { recursive: true, mode: 0o700 });
170
+ writeJsonAtomically(pauseFilePath(opts.sessionId), { schemaVersion: SCHEMA_VERSION, ...pause });
171
+ return pause;
172
+ }
173
+
174
+ /** Returns true if a pause file was removed. */
175
+ export function clearPause(sessionId: string): boolean {
176
+ const path = pauseFilePath(sessionId);
177
+ if (!existsSync(path)) return false;
178
+ try {
179
+ rmSync(path);
180
+ return true;
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /** Every pause that has not yet expired, newest first. Expired files are ignored. */
187
+ export function listActivePauses(now = Date.now()): ActivePause[] {
188
+ const dir = pauseStateDir();
189
+ if (!existsSync(dir)) return [];
190
+ const out: ActivePause[] = [];
191
+ let names: string[];
192
+ try {
193
+ names = readdirSync(dir);
194
+ } catch {
195
+ return [];
196
+ }
197
+ for (const name of names) {
198
+ if (!name.endsWith(".json")) continue;
199
+ try {
200
+ const stored = parseStored(JSON.parse(readFileSync(join(dir, name), "utf8")));
201
+ if (!stored || stored.expiresAt <= now) continue;
202
+ const { schemaVersion: _ignored, ...pause } = stored;
203
+ out.push(pause);
204
+ } catch {
205
+ // A single unreadable file must not hide every other pause from --status.
206
+ }
207
+ }
208
+ return out.sort((a, b) => b.pausedAt - a.pausedAt);
209
+ }
210
+
211
+ /**
212
+ * The session to act on when the user names none.
213
+ *
214
+ * Whoever types the command has no session id — it exists only inside the hook
215
+ * payload. But every hook event already records `sessionId` alongside `cwd` and
216
+ * a timestamp, so the most recent session seen in this directory is derivable
217
+ * from data we already write. Returns null when nothing recent matches, so the
218
+ * caller can say so instead of guessing at a stranger's session.
219
+ */
220
+ export function resolveSessionForCwd(cwd: string, maxAgeMs = 12 * 3_600_000, now = Date.now()): string | null {
221
+ let best: { sessionId: string; timestamp: number } | null = null;
222
+ let entries;
223
+ try {
224
+ entries = getAllHookActivityEntries();
225
+ } catch {
226
+ return null;
227
+ }
228
+ for (const entry of entries) {
229
+ if (!entry.sessionId || entry.cwd !== cwd) continue;
230
+ if (now - entry.timestamp > maxAgeMs) continue;
231
+ if (!best || entry.timestamp > best.timestamp) {
232
+ best = { sessionId: entry.sessionId, timestamp: entry.timestamp };
233
+ }
234
+ }
235
+ return best?.sessionId ?? null;
236
+ }
package/src/hooks/tui.ts CHANGED
@@ -643,3 +643,118 @@ export function multiSelect<T>(opts: MultiSelectOptions<T>): Promise<T[] | null>
643
643
  ),
644
644
  });
645
645
  }
646
+
647
+ // ── promptText (single line, optionally masked) ───────────────────────────────
648
+
649
+ export interface PromptTextOptions {
650
+ message: string;
651
+ /** Shown dimmed under the prompt. */
652
+ hint?: string;
653
+ /** Used when the user submits an empty line. */
654
+ defaultValue?: string;
655
+ /**
656
+ * Render `•` instead of the typed characters. For credentials: the wizard is
657
+ * routinely run while screen-sharing, and a pasted key would otherwise sit in
658
+ * the scrollback of every recording of that session.
659
+ */
660
+ mask?: boolean;
661
+ /** Return an error string to reject and re-ask, or null to accept. */
662
+ validate?: (value: string) => string | null;
663
+ stdin?: TTYIn;
664
+ stdout?: TTYOut;
665
+ }
666
+
667
+ /**
668
+ * Read one line. Resolves `null` on Ctrl-C / Escape, which every caller in the
669
+ * wizard treats as "cancel the whole run" — consistent with selectOne.
670
+ *
671
+ * Falls back to a plain non-TTY read so `failproofai config` still works when
672
+ * driven from a pipe or a test, the same way the other prompts do.
673
+ */
674
+ export function promptText(opts: PromptTextOptions): Promise<string | null> {
675
+ const stdin: TTYIn = opts.stdin ?? process.stdin;
676
+ const stdout: TTYOut = opts.stdout ?? process.stdout;
677
+ const c = paint(colorsEnabled(stdout));
678
+
679
+ const accept = (raw: string): { ok: true; value: string } | { ok: false; error: string } => {
680
+ const value = raw.trim() || opts.defaultValue || "";
681
+ const error = opts.validate?.(value) ?? null;
682
+ return error ? { ok: false, error } : { ok: true, value };
683
+ };
684
+
685
+ if (!stdin.isTTY) {
686
+ // Non-TTY: read whatever is piped, validate once, no re-ask loop (there is
687
+ // no one to re-ask).
688
+ return new Promise((resolve) => {
689
+ let buf = "";
690
+ const onData = (chunk: Buffer | string) => {
691
+ buf += String(chunk);
692
+ if (buf.includes("\n")) done();
693
+ };
694
+ const done = () => {
695
+ stdin.removeListener("data", onData);
696
+ stdin.removeListener("end", done);
697
+ const r = accept(buf.split("\n")[0] ?? "");
698
+ resolve(r.ok ? r.value : null);
699
+ };
700
+ stdin.on("data", onData);
701
+ stdin.on("end", done);
702
+ });
703
+ }
704
+
705
+ return new Promise((resolve) => {
706
+ let value = "";
707
+ const draw = (error?: string) => {
708
+ const shown = opts.mask ? "•".repeat(value.length) : value;
709
+ const hint = opts.hint ? ` ${c.dim(opts.hint)}` : "";
710
+ const err = error ? `\n ${c.warn(error)}` : "";
711
+ stdout.write(`\r\x1b[2K${c.bold(opts.message)} ${shown}${hint}${err}`);
712
+ if (err) stdout.write("\x1b[1A");
713
+ };
714
+ draw();
715
+
716
+ readline.emitKeypressEvents(stdin);
717
+ const wasRaw = stdin.isRaw;
718
+ stdin.setRawMode?.(true);
719
+ stdin.resume();
720
+
721
+ const cleanup = () => {
722
+ stdin.removeListener("keypress", onKey);
723
+ stdin.setRawMode?.(wasRaw ?? false);
724
+ stdin.pause();
725
+ stdout.write("\n");
726
+ };
727
+
728
+ function onKey(str: string | undefined, key: readline.Key): void {
729
+ if (!key) return;
730
+ if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") {
731
+ cleanup();
732
+ resolve(null);
733
+ return;
734
+ }
735
+ if (key.name === "return" || key.name === "enter") {
736
+ const r = accept(value);
737
+ if (r.ok) {
738
+ cleanup();
739
+ resolve(r.value);
740
+ } else {
741
+ draw(r.error);
742
+ }
743
+ return;
744
+ }
745
+ if (key.name === "backspace") {
746
+ value = value.slice(0, -1);
747
+ draw();
748
+ return;
749
+ }
750
+ // Ignore control keys; accept any printable character, including a
751
+ // bracketed paste arriving as one chunk.
752
+ if (str && !key.ctrl && !key.meta) {
753
+ value += str;
754
+ draw();
755
+ }
756
+ }
757
+
758
+ stdin.on("keypress", onKey);
759
+ });
760
+ }