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,516 @@
1
+ //! Deterministic scrubbing of credentials before an event leaves the machine.
2
+ //!
3
+ //! # Determinism is the binding constraint
4
+ //!
5
+ //! The server dedups on a content hash, so the same input must always produce
6
+ //! the same bytes. That rules out anything sampled, time-dependent, or
7
+ //! model-driven — an LLM pass here would make a re-read of the same source
8
+ //! bytes hash differently and defeat dedup entirely. What is left is a fixed
9
+ //! pattern set, which is exactly what this is.
10
+ //!
11
+ //! # This is a floor, not a guarantee
12
+ //!
13
+ //! It catches the common accident: a key pasted into a terminal, a token in an
14
+ //! environment assignment, a bearer header in a captured request. It does not
15
+ //! catch a secret that looks like ordinary prose, and it is not a substitute
16
+ //! for not collecting sessions you would rather not send. The module says so
17
+ //! plainly because the config option is called `minimal` and users will
18
+ //! reasonably ask what it covers.
19
+ //!
20
+ //! # Redaction is visible
21
+ //!
22
+ //! Every match is replaced with a marker naming the pattern that fired, rather
23
+ //! than being silently dropped. A reader looking at a truncated command should
24
+ //! be able to tell that something was removed and why.
25
+
26
+ use serde_json::Value;
27
+
28
+ use crate::config::Redact;
29
+
30
+ /// Characters that can appear inside an opaque token.
31
+ fn is_token_char(c: char) -> bool {
32
+ c.is_ascii_alphanumeric() || c == '_' || c == '-'
33
+ }
34
+
35
+ /// A token must not be preceded by another token character, or `sk-` would
36
+ /// match inside `risk-averse` and `ri` would be left dangling in the output.
37
+ fn at_boundary(bytes: &[u8], start: usize) -> bool {
38
+ if start == 0 {
39
+ return true;
40
+ }
41
+ let prev = bytes[start - 1] as char;
42
+ !is_token_char(prev)
43
+ }
44
+
45
+ /// A literal-prefixed opaque token: `<prefix><token chars>`.
46
+ struct PrefixRule {
47
+ prefix: &'static str,
48
+ /// Token characters required after the prefix before this counts as a
49
+ /// secret. Keeps ordinary hyphenated words from matching.
50
+ min_len: usize,
51
+ label: &'static str,
52
+ }
53
+
54
+ /// Order matters: longer, more specific prefixes are tried first so
55
+ /// `sk-ant-...` is labelled as an Anthropic key rather than a generic one.
56
+ const PREFIX_RULES: &[PrefixRule] = &[
57
+ PrefixRule {
58
+ prefix: "sk-ant-api",
59
+ min_len: 16,
60
+ label: "anthropic-key",
61
+ },
62
+ PrefixRule {
63
+ prefix: "sk-ant-",
64
+ min_len: 16,
65
+ label: "anthropic-key",
66
+ },
67
+ PrefixRule {
68
+ prefix: "sk-proj-",
69
+ min_len: 16,
70
+ label: "openai-key",
71
+ },
72
+ PrefixRule {
73
+ prefix: "sk-",
74
+ min_len: 16,
75
+ label: "api-key",
76
+ },
77
+ PrefixRule {
78
+ prefix: "ghp_",
79
+ min_len: 20,
80
+ label: "github-token",
81
+ },
82
+ PrefixRule {
83
+ prefix: "gho_",
84
+ min_len: 20,
85
+ label: "github-token",
86
+ },
87
+ PrefixRule {
88
+ prefix: "ghu_",
89
+ min_len: 20,
90
+ label: "github-token",
91
+ },
92
+ PrefixRule {
93
+ prefix: "ghs_",
94
+ min_len: 20,
95
+ label: "github-token",
96
+ },
97
+ PrefixRule {
98
+ prefix: "ghr_",
99
+ min_len: 20,
100
+ label: "github-token",
101
+ },
102
+ PrefixRule {
103
+ prefix: "github_pat_",
104
+ min_len: 20,
105
+ label: "github-token",
106
+ },
107
+ PrefixRule {
108
+ prefix: "sb_secret_",
109
+ min_len: 16,
110
+ label: "supabase-key",
111
+ },
112
+ PrefixRule {
113
+ prefix: "sbp_",
114
+ min_len: 20,
115
+ label: "supabase-key",
116
+ },
117
+ PrefixRule {
118
+ prefix: "xoxb-",
119
+ min_len: 16,
120
+ label: "slack-token",
121
+ },
122
+ PrefixRule {
123
+ prefix: "xoxp-",
124
+ min_len: 16,
125
+ label: "slack-token",
126
+ },
127
+ PrefixRule {
128
+ prefix: "AKIA",
129
+ min_len: 16,
130
+ label: "aws-access-key-id",
131
+ },
132
+ PrefixRule {
133
+ prefix: "ASIA",
134
+ min_len: 16,
135
+ label: "aws-access-key-id",
136
+ },
137
+ ];
138
+
139
+ /// Assignment names whose value is treated as secret.
140
+ ///
141
+ /// Matched case-insensitively as a suffix of the identifier, so `API_KEY`,
142
+ /// `OPENAI_API_KEY` and `--api-key` all hit `key`. Deliberately narrow:
143
+ /// widening it to anything like `NAME` or `ID` would redact most of a command
144
+ /// line and make the capture useless.
145
+ /// Names strong enough to redact on their own, even as a bare identifier.
146
+ const STRONG_SECRET_NAMES: &[&str] = &["secret", "password", "passwd", "credential"];
147
+ /// Names that are only convincing as part of a COMPOUND identifier.
148
+ ///
149
+ /// `key` and `token` are ordinary words in source code — measured against 40
150
+ /// real transcripts, a bare `key=` matched React's `key` prop on every JSX
151
+ /// list. Requiring a `_` or `-` keeps `API_KEY`, `api_key` and `--api-token`
152
+ /// while dropping that entire class of false positive.
153
+ const WEAK_SECRET_NAMES: &[&str] = &["key", "token"];
154
+
155
+ /// Shortest assignment value worth redacting. Below this it is far more likely
156
+ /// to be a placeholder or a flag than a credential.
157
+ const MIN_ASSIGNMENT_VALUE: usize = 12;
158
+
159
+ /// Scrub every string leaf of an event in place.
160
+ ///
161
+ /// Returns the number of replacements, so a caller can log that redaction
162
+ /// actually did something without logging what it removed.
163
+ pub fn scrub_value(v: &mut Value, mode: Redact) -> usize {
164
+ if mode == Redact::Off {
165
+ return 0;
166
+ }
167
+ let mut n = 0;
168
+ scrub_in_place(v, &mut n);
169
+ n
170
+ }
171
+
172
+ fn scrub_in_place(v: &mut Value, n: &mut usize) {
173
+ match v {
174
+ Value::String(s) => {
175
+ if let Some(replaced) = scrub_str(s) {
176
+ *n += replaced.1;
177
+ *s = replaced.0;
178
+ }
179
+ }
180
+ Value::Array(a) => a.iter_mut().for_each(|e| scrub_in_place(e, n)),
181
+ Value::Object(o) => o.values_mut().for_each(|e| scrub_in_place(e, n)),
182
+ _ => {}
183
+ }
184
+ }
185
+
186
+ /// Scrub one string. `None` when nothing matched, so an unchanged string is
187
+ /// never reallocated.
188
+ pub fn scrub_str(s: &str) -> Option<(String, usize)> {
189
+ let bytes = s.as_bytes();
190
+ let mut out = String::new();
191
+ let mut i = 0usize;
192
+ let mut hits = 0usize;
193
+
194
+ while i < s.len() {
195
+ if !s.is_char_boundary(i) {
196
+ i += 1;
197
+ continue;
198
+ }
199
+ let rest = &s[i..];
200
+
201
+ if let Some((len, label)) = match_prefix_rule(bytes, i, rest)
202
+ .or_else(|| match_jwt(bytes, i, rest))
203
+ .or_else(|| match_bearer(rest))
204
+ .or_else(|| match_assignment(s, i, rest))
205
+ {
206
+ if hits == 0 {
207
+ out.push_str(&s[..i]);
208
+ }
209
+ out.push_str(&format!("[redacted:{label}]"));
210
+ hits += 1;
211
+ i += len;
212
+ continue;
213
+ }
214
+
215
+ if hits > 0 {
216
+ out.push_str(&rest[..rest.chars().next().map(char::len_utf8).unwrap_or(1)]);
217
+ }
218
+ i += rest.chars().next().map(char::len_utf8).unwrap_or(1);
219
+ }
220
+
221
+ (hits > 0).then_some((out, hits))
222
+ }
223
+
224
+ fn match_prefix_rule(bytes: &[u8], i: usize, rest: &str) -> Option<(usize, &'static str)> {
225
+ if !at_boundary(bytes, i) {
226
+ return None;
227
+ }
228
+ for rule in PREFIX_RULES {
229
+ let Some(after) = rest.strip_prefix(rule.prefix) else {
230
+ continue;
231
+ };
232
+ let token_len = after.chars().take_while(|c| is_token_char(*c)).count();
233
+ if token_len >= rule.min_len {
234
+ return Some((rule.prefix.len() + token_len, rule.label));
235
+ }
236
+ }
237
+ None
238
+ }
239
+
240
+ /// A JWT: three dot-separated base64url segments starting `eyJ`.
241
+ ///
242
+ /// Matched structurally rather than by prefix alone, because `eyJ` on its own
243
+ /// is just base64 for `{"` and appears in plenty of non-secret payloads.
244
+ fn match_jwt(bytes: &[u8], i: usize, rest: &str) -> Option<(usize, &'static str)> {
245
+ if !at_boundary(bytes, i) || !rest.starts_with("eyJ") {
246
+ return None;
247
+ }
248
+ let is_b64 = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=';
249
+
250
+ let mut len = 0usize;
251
+ let mut segments = 0usize;
252
+ let mut cursor = rest;
253
+ loop {
254
+ let seg = cursor.chars().take_while(|c| is_b64(*c)).count();
255
+ if seg == 0 {
256
+ break;
257
+ }
258
+ len += seg;
259
+ segments += 1;
260
+ cursor = &cursor[seg..];
261
+ if segments == 3 {
262
+ break;
263
+ }
264
+ if cursor.starts_with('.') {
265
+ len += 1;
266
+ cursor = &cursor[1..];
267
+ } else {
268
+ break;
269
+ }
270
+ }
271
+ // Three segments and a plausible total length. A two-segment match is far
272
+ // more likely to be ordinary base64 than a token.
273
+ (segments == 3 && len >= 40).then_some((len, "jwt"))
274
+ }
275
+
276
+ /// `Bearer <token>` from an Authorization header.
277
+ fn match_bearer(rest: &str) -> Option<(usize, &'static str)> {
278
+ let lower = rest.get(..7)?.to_ascii_lowercase();
279
+ if lower != "bearer " {
280
+ return None;
281
+ }
282
+ let after = &rest[7..];
283
+ let token_len = after
284
+ .chars()
285
+ .take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
286
+ .count();
287
+ (token_len >= 8).then_some((7 + token_len, "bearer-token"))
288
+ }
289
+
290
+ /// The VALUE of a `SOMETHING_KEY=` / `--api-token=` assignment.
291
+ ///
292
+ /// Anchored at the start of the value rather than at the `=`, so the marker
293
+ /// replaces only the secret and the assignment still reads as one:
294
+ /// `API_KEY=[redacted:secret-assignment]`, not `API_KEY[redacted:...]`.
295
+ ///
296
+ /// Looks BACKWARDS at the identifier, so it works for env assignments, CLI
297
+ /// flags and `key=value` pairs alike, and steps back over one opening quote so
298
+ /// the quoting survives intact.
299
+ fn match_assignment(s: &str, i: usize, rest: &str) -> Option<(usize, &'static str)> {
300
+ if i == 0 {
301
+ return None;
302
+ }
303
+ let before = &s[..i];
304
+ // Step back over an opening quote, if any, then require the `=`.
305
+ let before = match before.chars().next_back() {
306
+ Some('"') | Some('\'') => &before[..before.len() - 1],
307
+ _ => before,
308
+ };
309
+ if !before.ends_with('=') {
310
+ return None;
311
+ }
312
+ let name_part = &before[..before.len() - 1];
313
+ let name_len = name_part
314
+ .chars()
315
+ .rev()
316
+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
317
+ .count();
318
+ if name_len == 0 {
319
+ return None;
320
+ }
321
+ let raw = name_part[name_part.len() - name_len..].to_ascii_lowercase();
322
+ let name = raw.trim_matches('-');
323
+ let compound = name.contains('_') || name.contains('-');
324
+ let convincing = STRONG_SECRET_NAMES.iter().any(|n| name.ends_with(n))
325
+ || (compound && WEAK_SECRET_NAMES.iter().any(|n| name.ends_with(n)));
326
+ if !convincing {
327
+ return None;
328
+ }
329
+
330
+ // An expression reference is not a literal secret: `key={m.id}`,
331
+ // `Bearer ${API_KEY}`, `token=<placeholder>`. Redacting these adds no
332
+ // safety and makes captured source unreadable.
333
+ if rest.starts_with(['{', '$', '<', '(', '`']) {
334
+ return None;
335
+ }
336
+
337
+ // The value runs to the closing quote, or to whitespace / a shell
338
+ // separator when unquoted. The closing quote is left in place.
339
+ let quoted = matches!(s[..i].chars().next_back(), Some('"') | Some('\''));
340
+ let value_len = rest
341
+ .chars()
342
+ .take_while(|c| {
343
+ if quoted {
344
+ *c != '"' && *c != '\''
345
+ } else {
346
+ !c.is_whitespace() && *c != ';' && *c != '&'
347
+ }
348
+ })
349
+ .count();
350
+ (value_len >= MIN_ASSIGNMENT_VALUE).then_some((value_len, "secret-assignment"))
351
+ }
352
+
353
+ #[cfg(test)]
354
+ mod tests {
355
+ use super::*;
356
+ use serde_json::json;
357
+
358
+ fn scrub(s: &str) -> String {
359
+ scrub_str(s)
360
+ .map(|(v, _)| v)
361
+ .unwrap_or_else(|| s.to_string())
362
+ }
363
+
364
+ #[test]
365
+ fn redacts_the_key_shapes_that_actually_leak() {
366
+ // Labelled by the most specific rule that matches: the key's own
367
+ // prefix beats the generic assignment rule, which is more useful to
368
+ // whoever reads the redacted line.
369
+ assert_eq!(
370
+ scrub("export ANTHROPIC_API_KEY=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv"),
371
+ "export ANTHROPIC_API_KEY=[redacted:anthropic-key]"
372
+ );
373
+ // A value with no recognisable prefix still goes, via the assignment
374
+ // rule — this is the case the prefix list cannot cover.
375
+ assert_eq!(
376
+ scrub("DATABASE_PASSWORD=hunter2hunter2hunter2"),
377
+ "DATABASE_PASSWORD=[redacted:secret-assignment]"
378
+ );
379
+ assert!(
380
+ scrub("here is sk-proj-abcdefghijklmnopqrstuvwxyz").contains("[redacted:openai-key]")
381
+ );
382
+ assert!(
383
+ scrub("token ghp_abcdefghijklmnopqrstuvwxyz0123").contains("[redacted:github-token]")
384
+ );
385
+ assert!(scrub("AKIAIOSFODNN7EXAMPLE0000").contains("[redacted:aws-access-key-id]"));
386
+ }
387
+
388
+ #[test]
389
+ fn redacts_a_bearer_header() {
390
+ let out = scrub(r#"curl -H "Authorization: Bearer sk-pv7KDjLZ2u-uMgM2ym2uyw" https://x"#);
391
+ assert!(out.contains("[redacted:bearer-token]"), "got {out}");
392
+ assert!(!out.contains("pv7KDjLZ"), "the token survived: {out}");
393
+ }
394
+
395
+ #[test]
396
+ fn redacts_a_jwt_but_not_ordinary_base64() {
397
+ let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sIgNaTuRe0123456789ab";
398
+ assert!(scrub(jwt).contains("[redacted:jwt]"));
399
+ // Two segments is far more likely to be ordinary base64 than a token.
400
+ assert_eq!(
401
+ scrub("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0"),
402
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0"
403
+ );
404
+ }
405
+
406
+ #[test]
407
+ fn leaves_ordinary_text_alone() {
408
+ // The false-positive cases that would make a capture useless. `risk-`
409
+ // contains `sk-`, which is why prefixes are boundary-anchored.
410
+ for s in [
411
+ "this is a risk-averse approach",
412
+ "run the task-runner now",
413
+ "cargo build --release",
414
+ "AWS_REGION=us-east-1",
415
+ "let name = compute();",
416
+ "no secrets here at all",
417
+ ] {
418
+ assert_eq!(scrub(s), s, "false positive on {s:?}");
419
+ }
420
+ }
421
+
422
+ #[test]
423
+ fn a_bare_key_prop_in_source_code_is_not_a_secret() {
424
+ // Measured against 40 real transcripts: a bare `key=` matched React's
425
+ // `key` prop on every JSX list, 169 times. Requiring a compound
426
+ // identifier keeps API_KEY and api_key while dropping that whole class.
427
+ assert_eq!(
428
+ scrub("<MatchCard key={m.identifier} match={m} />"),
429
+ "<MatchCard key={m.identifier} match={m} />"
430
+ );
431
+ assert_eq!(scrub("key=someLongIdentifier"), "key=someLongIdentifier");
432
+ // Compound names still redact.
433
+ assert!(scrub("API_KEY=abcdefghijklmnop").contains("[redacted:"));
434
+ assert!(scrub("--api-token=abcdefghijklmnop").contains("[redacted:"));
435
+ // Strong names redact even bare.
436
+ assert!(scrub("password=abcdefghijklmnop").contains("[redacted:"));
437
+ }
438
+
439
+ #[test]
440
+ fn an_expression_reference_is_not_redacted() {
441
+ // Redacting these adds no safety and makes captured source unreadable.
442
+ for s in [
443
+ "Authorization: `Bearer ${API_KEY}`",
444
+ "api_key=$OPENAI_API_KEY",
445
+ "--token=<your-token-here>",
446
+ ] {
447
+ assert!(
448
+ !scrub(s).contains("secret-assignment"),
449
+ "over-redacted {s:?} -> {}",
450
+ scrub(s)
451
+ );
452
+ }
453
+ }
454
+
455
+ #[test]
456
+ fn a_short_assignment_value_is_left_alone() {
457
+ // Placeholders and flags, not credentials.
458
+ assert_eq!(scrub("API_KEY=abc"), "API_KEY=abc");
459
+ assert_eq!(scrub("--token=x"), "--token=x");
460
+ }
461
+
462
+ #[test]
463
+ fn redaction_is_deterministic() {
464
+ // Non-negotiable: the server dedups on a content hash, so the same
465
+ // input must always produce the same bytes.
466
+ let s = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv and ghp_abcdefghijklmnopqrstuvwxyz0123";
467
+ // Three independent runs over the same input, since a single
468
+ // comparison would also pass for a function that returned its input.
469
+ assert_eq!(scrub(s), scrub(s));
470
+ assert_eq!(scrub(s), scrub(s));
471
+ assert!(
472
+ scrub(s).contains("[redacted:"),
473
+ "the input really is redacted"
474
+ );
475
+ }
476
+
477
+ #[test]
478
+ fn the_marker_names_the_pattern_that_fired() {
479
+ // Redaction must be visible, not a silent deletion.
480
+ let out = scrub("ghp_abcdefghijklmnopqrstuvwxyz0123");
481
+ assert_eq!(out, "[redacted:github-token]");
482
+ }
483
+
484
+ #[test]
485
+ fn scrubs_nested_event_payloads() {
486
+ let mut v = json!({
487
+ "type": "tool_use",
488
+ "input": { "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnopqrstuv'" },
489
+ "nested": [{ "output": "ghp_abcdefghijklmnopqrstuvwxyz0123" }]
490
+ });
491
+ let n = scrub_value(&mut v, Redact::Minimal);
492
+ assert!(n >= 2, "expected both leaves scrubbed, got {n}");
493
+ let text = v.to_string();
494
+ assert!(!text.contains("ghp_abcdefghij"));
495
+ assert!(!text.contains("sk-abcdefghij"));
496
+ // Structure is untouched.
497
+ assert_eq!(v["type"], "tool_use");
498
+ }
499
+
500
+ #[test]
501
+ fn off_mode_changes_nothing() {
502
+ let mut v = json!({"output": "ghp_abcdefghijklmnopqrstuvwxyz0123"});
503
+ assert_eq!(scrub_value(&mut v, Redact::Off), 0);
504
+ assert_eq!(v["output"], "ghp_abcdefghijklmnopqrstuvwxyz0123");
505
+ }
506
+
507
+ #[test]
508
+ fn multibyte_text_survives() {
509
+ // Slicing on a non-char-boundary would panic and take the task down.
510
+ let s = "héllo sk-abcdefghijklmnopqrstuv wörld → ✓";
511
+ let out = scrub(s);
512
+ assert!(out.contains("héllo"));
513
+ assert!(out.contains("wörld → ✓"));
514
+ assert!(out.contains("[redacted:"));
515
+ }
516
+ }