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
@@ -0,0 +1,412 @@
1
+ //! Collector configuration: where to send events, and what to collect.
2
+ //!
3
+ //! # Two files, on purpose
4
+ //!
5
+ //! The credential lives alone in `~/.failproofai/ingest.json` at mode 0600.
6
+ //! Everything else — which sources are on, backfill window, hook verbosity —
7
+ //! lives in `policies-config.json` beside the rest of failproofai's settings,
8
+ //! where it is readable, diffable and safe to commit to a dotfiles repo.
9
+ //!
10
+ //! That split is not tidiness. `policies-config.json` is written by
11
+ //! `hooks-config.ts` with a bare `writeFileSync`, so it inherits the umask and
12
+ //! lands at 0664 on a normal machine — inside `~/.failproofai/`, which is
13
+ //! itself 0775. Putting an API key there would publish it to every local user
14
+ //! on the box. `~/.agenteye/cli.json` already stores its session token at 0600,
15
+ //! so the correct precedent existed; this follows it.
16
+ //!
17
+ //! # Disabled is the default, and it is a real default
18
+ //!
19
+ //! No `ingest.json`, or one with no key, means collection is off: no tasks, so
20
+ //! no thread and no runtime (see [`crate::supervisor::spawn_supervised`]). A
21
+ //! machine that has not opted in pays nothing for this code existing. Session
22
+ //! collection additionally requires its own explicit opt-in, because
23
+ //! transcripts carry prompts, file contents and whatever the user pasted into
24
+ //! a terminal — configuring a key must not silently start shipping those.
25
+
26
+ use std::fs;
27
+ use std::io;
28
+ use std::path::{Path, PathBuf};
29
+
30
+ use serde::{Deserialize, Serialize};
31
+
32
+ /// The hosted ingest endpoint. A COMPLETE endpoint including its path, not a
33
+ /// base to join onto — byte-identical to what `agenteye-collector` defaults
34
+ /// to, so a migrated config needs no rewriting and a self-hoster replaces the
35
+ /// whole value with one of their own.
36
+ pub const DEFAULT_INGEST_URL: &str = "https://server.befailproof.ai/events";
37
+
38
+ /// Filename of the credential file inside the failproofai home.
39
+ const INGEST_FILE: &str = "ingest.json";
40
+ /// Filename of the shared settings file, owned by the TypeScript side.
41
+ const POLICIES_FILE: &str = "policies-config.json";
42
+
43
+ /// Mode the credential file must have. Anything wider is a finding.
44
+ #[cfg(unix)]
45
+ const CREDENTIAL_MODE: u32 = 0o600;
46
+
47
+ /// What the daemon needs in order to ship anything at all.
48
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
49
+ pub struct Ingest {
50
+ /// Complete endpoint URL. Absent in the file means [`DEFAULT_INGEST_URL`].
51
+ #[serde(default = "default_url")]
52
+ pub url: String,
53
+ /// An `events:add` API key, sent as a bearer token.
54
+ pub key: String,
55
+ }
56
+
57
+ fn default_url() -> String {
58
+ DEFAULT_INGEST_URL.to_string()
59
+ }
60
+
61
+ /// How much hook activity to ship.
62
+ ///
63
+ /// Measured on a real machine: 19,339 rows over 17 days from one developer on
64
+ /// one CLI, of which 99.1% were plain `allow`. Emitting a triggered/completed
65
+ /// pair per row is ~38,700 events for that one machine, almost all of it
66
+ /// no-ops — hence a default that keeps every decision exact and rolls the rest
67
+ /// up rather than dropping it.
68
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
69
+ #[serde(rename_all = "lowercase")]
70
+ pub enum HooksVerbosity {
71
+ /// Every hook invocation, in full.
72
+ All,
73
+ /// `deny` and `instruct` in full; `allow` aggregated per (session, event,
74
+ /// tool) per minute. The aggregate carries a count, so the denominator
75
+ /// survives — "we evaluated 19,000 calls and blocked 15" stays answerable.
76
+ #[default]
77
+ Decisions,
78
+ /// No hook events at all.
79
+ Off,
80
+ }
81
+
82
+ /// Client-side redaction strength.
83
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
84
+ #[serde(rename_all = "lowercase")]
85
+ pub enum Redact {
86
+ /// A fixed, deterministic pattern set over tool inputs and outputs.
87
+ /// Deterministic is load-bearing, not incidental: the server dedups on a
88
+ /// content hash, so a redaction that varied between reads of the same
89
+ /// bytes would defeat it.
90
+ #[default]
91
+ Minimal,
92
+ /// Ship verbatim.
93
+ Off,
94
+ }
95
+
96
+ /// The non-secret half, read from `policies-config.json` under `"collector"`.
97
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
98
+ #[serde(rename_all = "camelCase")]
99
+ pub struct Settings {
100
+ /// Ship agent session transcripts. Defaults to FALSE and is deliberately
101
+ /// separate from having a key: transcripts carry prompts, file contents
102
+ /// and pasted credentials, so configuring ingest must not silently start
103
+ /// sending them.
104
+ #[serde(default)]
105
+ pub sessions: bool,
106
+ /// Ship hook activity. Defaults to TRUE once ingest is configured — it
107
+ /// carries decisions and tool names, never file contents, and it is the
108
+ /// capability that justifies collecting from inside failproofai at all.
109
+ #[serde(default = "default_true")]
110
+ pub hooks: bool,
111
+ #[serde(default)]
112
+ pub hooks_verbosity: HooksVerbosity,
113
+ #[serde(default)]
114
+ pub redact: Redact,
115
+ /// Label stamped on every event. Rejected if it contains a comma: the
116
+ /// ingest endpoint skips any line whose `environment` has one, so a comma
117
+ /// here would silently drop every event this machine produced.
118
+ #[serde(default = "default_environment")]
119
+ pub environment: String,
120
+ }
121
+
122
+ fn default_true() -> bool {
123
+ true
124
+ }
125
+
126
+ fn default_environment() -> String {
127
+ "local".to_string()
128
+ }
129
+
130
+ impl Default for Settings {
131
+ fn default() -> Self {
132
+ Settings {
133
+ sessions: false,
134
+ hooks: true,
135
+ hooks_verbosity: HooksVerbosity::default(),
136
+ redact: Redact::default(),
137
+ environment: default_environment(),
138
+ }
139
+ }
140
+ }
141
+
142
+ /// Everything the collector needs, resolved.
143
+ #[derive(Debug, Clone, PartialEq, Eq)]
144
+ pub struct CollectorConfig {
145
+ /// `None` means collection is off for this process.
146
+ pub ingest: Option<Ingest>,
147
+ pub settings: Settings,
148
+ /// Directories watched for ready-to-upload event batches, in the order
149
+ /// they are scanned.
150
+ pub spool_dirs: Vec<PathBuf>,
151
+ /// Where this daemon writes its own derived batches. Always the first
152
+ /// entry of `spool_dirs`.
153
+ pub own_spool_dir: PathBuf,
154
+ /// Batches the server rejected, parked for retry.
155
+ pub failed_dir: PathBuf,
156
+ }
157
+
158
+ impl CollectorConfig {
159
+ /// True when there is a usable credential AND at least one stream enabled.
160
+ /// This is what `collector_tasks()` keys off, so an unconfigured machine
161
+ /// starts no thread and no runtime.
162
+ pub fn is_enabled(&self) -> bool {
163
+ self.ingest.is_some() && (self.settings.sessions || self.settings.hooks)
164
+ }
165
+ }
166
+
167
+ #[derive(Debug)]
168
+ pub enum ConfigError {
169
+ Io(io::Error),
170
+ /// The file exists but is not valid JSON. Deliberately NOT treated as
171
+ /// "absent": silently disabling collection because someone fat-fingered a
172
+ /// comma is the kind of quiet failure this project exists to remove.
173
+ Malformed {
174
+ path: PathBuf,
175
+ detail: String,
176
+ },
177
+ /// A value is present but unusable.
178
+ Invalid(String),
179
+ }
180
+
181
+ impl std::fmt::Display for ConfigError {
182
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183
+ match self {
184
+ ConfigError::Io(e) => write!(f, "{e}"),
185
+ ConfigError::Malformed { path, detail } => {
186
+ write!(f, "{} is not valid JSON: {detail}", path.display())
187
+ }
188
+ ConfigError::Invalid(m) => write!(f, "{m}"),
189
+ }
190
+ }
191
+ }
192
+
193
+ impl std::error::Error for ConfigError {}
194
+
195
+ impl From<io::Error> for ConfigError {
196
+ fn from(e: io::Error) -> Self {
197
+ ConfigError::Io(e)
198
+ }
199
+ }
200
+
201
+ /// Resolve the collector's configuration.
202
+ ///
203
+ /// `home` is the failproofai home (`~/.failproofai`), passed rather than
204
+ /// derived so tests need no environment mutation.
205
+ ///
206
+ /// Precedence for the endpoint and key is env → file → default, matching every
207
+ /// other knob in this codebase. The env override exists for containers and for
208
+ /// local development against a stack on `localhost`, where writing a file to a
209
+ /// real home directory would be the wrong shape.
210
+ pub fn load(home: &Path) -> Result<CollectorConfig, ConfigError> {
211
+ let ingest = load_ingest(home)?;
212
+ let settings = load_settings(home)?;
213
+
214
+ if settings.environment.contains(',') {
215
+ // Fail here rather than let ingest silently skip every line. The
216
+ // server splits on commas, so this is unrecoverable downstream and
217
+ // invisible upstream — exactly the shape of bug worth erroring on.
218
+ return Err(ConfigError::Invalid(format!(
219
+ "collector environment {:?} contains a comma, which the ingest endpoint rejects; \
220
+ every event from this machine would be silently dropped",
221
+ settings.environment
222
+ )));
223
+ }
224
+
225
+ let own_spool_dir = home.join("spool");
226
+ let failed_dir = home.join("failed");
227
+
228
+ // Watch our own derived batches first, then the directory the Python SDK
229
+ // and any custom agent already write to. Watching both is what lets
230
+ // failproofaid supersede agenteye-collector without every SDK user having
231
+ // to reconfigure anything: their events keep being collected, by a
232
+ // different daemon, with no change on their side.
233
+ let mut spool_dirs = vec![own_spool_dir.clone()];
234
+ let sdk_spool = agenteye_events_dir();
235
+ if !spool_dirs.contains(&sdk_spool) {
236
+ spool_dirs.push(sdk_spool);
237
+ }
238
+
239
+ Ok(CollectorConfig {
240
+ ingest,
241
+ settings,
242
+ spool_dirs,
243
+ own_spool_dir,
244
+ failed_dir,
245
+ })
246
+ }
247
+
248
+ /// The directory the AgentEye Python SDK drops event batches into. Honors
249
+ /// `AGENTEYE_HOME` for the same reason the SDK and collector do.
250
+ fn agenteye_events_dir() -> PathBuf {
251
+ let base = std::env::var_os("AGENTEYE_HOME")
252
+ .map(PathBuf::from)
253
+ .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".agenteye")))
254
+ .unwrap_or_else(|| PathBuf::from(".agenteye"));
255
+ base.join("events")
256
+ }
257
+
258
+ fn load_ingest(home: &Path) -> Result<Option<Ingest>, ConfigError> {
259
+ let path = home.join(INGEST_FILE);
260
+
261
+ let from_file: Option<Ingest> = match fs::read_to_string(&path) {
262
+ Ok(text) => {
263
+ warn_if_world_readable(&path);
264
+ match serde_json::from_str::<Ingest>(&text) {
265
+ Ok(v) => Some(v),
266
+ Err(e) => {
267
+ return Err(ConfigError::Malformed {
268
+ path,
269
+ detail: e.to_string(),
270
+ });
271
+ }
272
+ }
273
+ }
274
+ Err(e) if e.kind() == io::ErrorKind::NotFound => None,
275
+ Err(e) => return Err(ConfigError::Io(e)),
276
+ };
277
+
278
+ let key = env_nonempty("FAILPROOFAI_INGEST_KEY")
279
+ .or_else(|| from_file.as_ref().map(|i| i.key.clone()));
280
+ let url = env_nonempty("FAILPROOFAI_INGEST_URL")
281
+ .or_else(|| from_file.as_ref().map(|i| i.url.clone()))
282
+ .unwrap_or_else(default_url);
283
+
284
+ // A file with an empty key is "configured but not really" — treat it as
285
+ // absent rather than sending `Authorization: Bearer ` and collecting 401s.
286
+ match key {
287
+ Some(key) if !key.trim().is_empty() => Ok(Some(Ingest { url, key })),
288
+ _ => Ok(None),
289
+ }
290
+ }
291
+
292
+ fn load_settings(home: &Path) -> Result<Settings, ConfigError> {
293
+ let path = home.join(POLICIES_FILE);
294
+ let text = match fs::read_to_string(&path) {
295
+ Ok(t) => t,
296
+ Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Settings::default()),
297
+ Err(e) => return Err(ConfigError::Io(e)),
298
+ };
299
+
300
+ let root: serde_json::Value =
301
+ serde_json::from_str(&text).map_err(|e| ConfigError::Malformed {
302
+ path: path.clone(),
303
+ detail: e.to_string(),
304
+ })?;
305
+
306
+ match root.get("collector") {
307
+ None => Ok(Settings::default()),
308
+ Some(v) => serde_json::from_value(v.clone()).map_err(|e| ConfigError::Malformed {
309
+ path,
310
+ detail: format!("the \"collector\" block is not usable: {e}"),
311
+ }),
312
+ }
313
+ }
314
+
315
+ fn env_nonempty(name: &str) -> Option<String> {
316
+ std::env::var(name).ok().filter(|v| !v.trim().is_empty())
317
+ }
318
+
319
+ /// Log loudly if the credential file is readable by anyone but its owner.
320
+ ///
321
+ /// Warn rather than refuse: the key still works, and disabling collection over
322
+ /// a permission bit would be a confusing failure for something the operator
323
+ /// can fix in one command. But it is never silent — a credential readable by
324
+ /// every local user is worth a line in the journal every startup.
325
+ #[cfg(unix)]
326
+ fn warn_if_world_readable(path: &Path) {
327
+ use std::os::unix::fs::PermissionsExt;
328
+ let Ok(meta) = fs::metadata(path) else { return };
329
+ let mode = meta.permissions().mode() & 0o777;
330
+ if mode & 0o077 != 0 {
331
+ tracing::warn!(
332
+ path = %path.display(),
333
+ mode = format!("{mode:o}"),
334
+ "the ingest credential is readable by other users on this machine; \
335
+ tighten it with `chmod 600`"
336
+ );
337
+ }
338
+ }
339
+
340
+ #[cfg(not(unix))]
341
+ fn warn_if_world_readable(_path: &Path) {}
342
+
343
+ /// Write the credential file with owner-only permissions.
344
+ ///
345
+ /// Used by the setup wizard through the daemon, and by tests. The mode is set
346
+ /// at CREATE time rather than chmod-ed afterwards, so the key is never briefly
347
+ /// world-readable between the write and the fix.
348
+ pub fn write_ingest(home: &Path, ingest: &Ingest) -> Result<PathBuf, ConfigError> {
349
+ fs::create_dir_all(home)?;
350
+ tighten_home(home);
351
+
352
+ let path = home.join(INGEST_FILE);
353
+ let body = serde_json::to_string_pretty(ingest)
354
+ .map_err(|e| ConfigError::Invalid(format!("could not serialize ingest config: {e}")))?;
355
+
356
+ write_private(&path, body.as_bytes())?;
357
+ Ok(path)
358
+ }
359
+
360
+ #[cfg(unix)]
361
+ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
362
+ use std::io::Write;
363
+ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
364
+ let mut f = fs::OpenOptions::new()
365
+ .write(true)
366
+ .create(true)
367
+ .truncate(true)
368
+ .mode(CREDENTIAL_MODE)
369
+ .open(path)?;
370
+ f.write_all(bytes)?;
371
+ f.sync_all()?;
372
+ // `mode()` only applies when the file is CREATED, so an existing file that
373
+ // was already too permissive would keep its mode. Set it explicitly too.
374
+ fs::set_permissions(path, fs::Permissions::from_mode(CREDENTIAL_MODE))?;
375
+ Ok(())
376
+ }
377
+
378
+ #[cfg(not(unix))]
379
+ fn write_private(path: &Path, bytes: &[u8]) -> Result<(), ConfigError> {
380
+ fs::write(path, bytes)?;
381
+ Ok(())
382
+ }
383
+
384
+ /// Best-effort tightening of the failproofai home to owner-only.
385
+ ///
386
+ /// It is 0775 on a normal machine because nothing ever needed it otherwise.
387
+ /// Once a credential lives inside it, a world-traversable parent undermines
388
+ /// the 0600 on the file itself. Best-effort because failing to chmod a
389
+ /// directory we do not own must not stop the daemon starting.
390
+ #[cfg(unix)]
391
+ fn tighten_home(home: &Path) {
392
+ use std::os::unix::fs::PermissionsExt;
393
+ let Ok(meta) = fs::metadata(home) else { return };
394
+ let mode = meta.permissions().mode() & 0o777;
395
+ if mode & 0o077 != 0 {
396
+ match fs::set_permissions(home, fs::Permissions::from_mode(0o700)) {
397
+ Ok(()) => tracing::info!(
398
+ path = %home.display(),
399
+ was = format!("{mode:o}"),
400
+ "tightened the failproofai home to 0700 so the ingest credential inside it is not world-readable"
401
+ ),
402
+ Err(err) => tracing::warn!(
403
+ path = %home.display(),
404
+ %err,
405
+ "could not tighten the failproofai home; the ingest credential's directory stays group/world-traversable"
406
+ ),
407
+ }
408
+ }
409
+ }
410
+
411
+ #[cfg(not(unix))]
412
+ fn tighten_home(_home: &Path) {}