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,921 @@
1
+ //! Local desired-state store for cloud-managed JavaScript policies.
2
+ //!
3
+ //! Cloud transport deliberately does not live here. A caller supplies an
4
+ //! [`ArtifactFetcher`], while this module owns the security-sensitive local
5
+ //! transaction: validate the manifest, verify SHA-256, write immutable cache
6
+ //! objects, materialize a complete generation, then switch `active.json`
7
+ //! atomically. The hook hot path never downloads or partially activates policy.
8
+
9
+ use serde::{Deserialize, Serialize};
10
+ use sha2::{Digest, Sha256};
11
+ use std::collections::HashSet;
12
+ use std::fs::{self, File, OpenOptions};
13
+ use std::io::{self, Read, Write};
14
+ use std::os::unix::fs::PermissionsExt;
15
+ use std::path::{Path, PathBuf};
16
+ use std::sync::Arc;
17
+ use std::sync::atomic::AtomicBool;
18
+ use std::sync::atomic::{AtomicU64, Ordering};
19
+ use std::thread::JoinHandle;
20
+ use std::time::{Duration, Instant};
21
+
22
+ pub const DESIRED_STATE_SCHEMA_VERSION: u32 = 1;
23
+ const MANAGED_FILE_MODE: u32 = 0o600;
24
+ static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
25
+
26
+ /// NOT `deny_unknown_fields`, deliberately, unlike the locally-authored
27
+ /// manifest below. This is parsed from a SERVER response, and daemons update on
28
+ /// their own schedule — so strictness here means the first field cloud adds
29
+ /// makes every older daemon reject desired-state and silently stop pulling,
30
+ /// stranding fleets on whatever generation they happened to hold. Strictness
31
+ /// belongs on files we write ourselves, not on a remote payload.
32
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33
+ #[serde(rename_all = "camelCase")]
34
+ pub struct DesiredState {
35
+ pub schema_version: u32,
36
+ pub generation: u64,
37
+ pub policies: Vec<DesiredPolicy>,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41
+ #[serde(rename_all = "camelCase")]
42
+ pub struct DesiredPolicy {
43
+ pub id: String,
44
+ pub revision: u64,
45
+ pub sha256: String,
46
+ /// Opaque locator interpreted only by the cloud transport implementation.
47
+ pub artifact_url: String,
48
+ /// Defaults to `enforce` so a server that predates observe mode, or omits
49
+ /// the field, keeps behaving exactly as before. The safe default is the
50
+ /// one that keeps enforcing.
51
+ #[serde(default)]
52
+ pub effect: PolicyEffect,
53
+ }
54
+
55
+ /// What an assignment does when it matches.
56
+ ///
57
+ /// `observe` is the design's observe-before-enforce step: the policy is
58
+ /// downloaded, verified and EVALUATED exactly like any other, but its verdict
59
+ /// never changes what the agent is allowed to do. It exists so a rollout can be
60
+ /// measured on real traffic before it can break anyone's work.
61
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
62
+ #[serde(rename_all = "lowercase")]
63
+ pub enum PolicyEffect {
64
+ #[default]
65
+ Enforce,
66
+ Observe,
67
+ }
68
+
69
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
71
+ pub struct ActiveGeneration {
72
+ pub schema_version: u32,
73
+ pub generation: u64,
74
+ pub policies: Vec<ActivePolicy>,
75
+ }
76
+
77
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78
+ #[serde(rename_all = "camelCase", deny_unknown_fields)]
79
+ pub struct ActivePolicy {
80
+ pub id: String,
81
+ pub revision: u64,
82
+ pub sha256: String,
83
+ /// Relative to the cloud-managed root. Never supplied by the server.
84
+ pub path: String,
85
+ /// Carried through from the desired state so the evaluator does not have to
86
+ /// re-consult cloud to know whether a policy may act.
87
+ #[serde(default)]
88
+ pub effect: PolicyEffect,
89
+ }
90
+
91
+ #[derive(Debug, Clone, PartialEq, Eq)]
92
+ pub struct ReconcileOutcome {
93
+ pub generation: u64,
94
+ pub downloaded: usize,
95
+ pub repaired: usize,
96
+ pub activated: bool,
97
+ }
98
+
99
+ #[derive(Debug)]
100
+ pub enum ReconcileError {
101
+ Io(io::Error),
102
+ Json(serde_json::Error),
103
+ InvalidDesiredState(String),
104
+ HashMismatch {
105
+ policy_id: String,
106
+ expected: String,
107
+ actual: String,
108
+ },
109
+ Fetch {
110
+ policy_id: String,
111
+ message: String,
112
+ },
113
+ NoVerifiedCopy {
114
+ policy_id: String,
115
+ },
116
+ }
117
+
118
+ impl std::fmt::Display for ReconcileError {
119
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120
+ match self {
121
+ Self::Io(err) => write!(f, "cloud policy I/O error: {err}"),
122
+ Self::Json(err) => write!(f, "cloud policy JSON error: {err}"),
123
+ Self::InvalidDesiredState(message) => {
124
+ write!(f, "invalid cloud desired state: {message}")
125
+ }
126
+ Self::HashMismatch {
127
+ policy_id,
128
+ expected,
129
+ actual,
130
+ } => write!(
131
+ f,
132
+ "cloud policy {policy_id} hash mismatch: expected {expected}, got {actual}"
133
+ ),
134
+ Self::Fetch { policy_id, message } => {
135
+ write!(f, "failed to fetch cloud policy {policy_id}: {message}")
136
+ }
137
+ Self::NoVerifiedCopy { policy_id } => write!(
138
+ f,
139
+ "cloud policy {policy_id} has no verified artifact or generation copy"
140
+ ),
141
+ }
142
+ }
143
+ }
144
+
145
+ impl std::error::Error for ReconcileError {}
146
+
147
+ impl From<io::Error> for ReconcileError {
148
+ fn from(value: io::Error) -> Self {
149
+ Self::Io(value)
150
+ }
151
+ }
152
+
153
+ impl From<serde_json::Error> for ReconcileError {
154
+ fn from(value: serde_json::Error) -> Self {
155
+ Self::Json(value)
156
+ }
157
+ }
158
+
159
+ /// Transport boundary. Production cloud HTTP and tests both implement this;
160
+ /// fetched bytes are untrusted until the reconciler verifies their digest.
161
+ pub trait ArtifactFetcher {
162
+ fn fetch(&self, policy: &DesiredPolicy) -> Result<Vec<u8>, String>;
163
+ }
164
+
165
+ impl<F> ArtifactFetcher for F
166
+ where
167
+ F: Fn(&DesiredPolicy) -> Result<Vec<u8>, String>,
168
+ {
169
+ fn fetch(&self, policy: &DesiredPolicy) -> Result<Vec<u8>, String> {
170
+ self(policy)
171
+ }
172
+ }
173
+
174
+ #[derive(Debug, Clone)]
175
+ pub struct PolicyStore {
176
+ root: PathBuf,
177
+ /// Highest generation this process has seen the SERVER offer.
178
+ ///
179
+ /// The rollback guard used to compare against `active.json`'s generation.
180
+ /// That file is a derived local pointer owned by the user — which this
181
+ /// module's own comment says — so on the product's stated threat model (a
182
+ /// rogue agent running as the user) it was an attacker-controlled veto over
183
+ /// the control plane: write a high number, and every real deployment is
184
+ /// refused for good. Combined with corrupting the artifacts it points at,
185
+ /// the machine cannot repair locally, cannot accept the server, and fails
186
+ /// closed on every tool call — a permanent denial of service costing one
187
+ /// file write.
188
+ ///
189
+ /// Anchoring on what the server said instead keeps the guard where it
190
+ /// actually means something (a replayed or out-of-order response inside one
191
+ /// session, which is the realistic transport failure) and gives up only
192
+ /// cross-restart rollback protection — which is already carried by TLS, a
193
+ /// bearer token and SHA-256 pinning of every artifact. Tampering now costs
194
+ /// an attacker nothing more than a delay until the next poll.
195
+ ///
196
+ /// `Arc` rather than a bare atomic because `PolicyStore` is `Clone` and the
197
+ /// maintenance lane holds its own handle: a per-clone counter would reset
198
+ /// the floor to zero for whichever clone happened to be asked, quietly
199
+ /// removing the guard it exists to provide.
200
+ server_high_water: Arc<AtomicU64>,
201
+ }
202
+
203
+ impl PolicyStore {
204
+ pub fn new(root: PathBuf) -> Self {
205
+ Self {
206
+ root,
207
+ server_high_water: Arc::new(AtomicU64::new(0)),
208
+ }
209
+ }
210
+
211
+ pub fn root(&self) -> &Path {
212
+ &self.root
213
+ }
214
+
215
+ pub fn active_manifest_path(&self) -> PathBuf {
216
+ self.root.join("active.json")
217
+ }
218
+
219
+ pub fn desired_state_path(&self) -> PathBuf {
220
+ self.root.join("desired-state.json")
221
+ }
222
+
223
+ pub fn read_active(&self) -> Result<Option<ActiveGeneration>, ReconcileError> {
224
+ let path = self.active_manifest_path();
225
+ if !path.exists() {
226
+ return Ok(None);
227
+ }
228
+ let bytes = fs::read(path)?;
229
+ Ok(Some(serde_json::from_slice(&bytes)?))
230
+ }
231
+
232
+ pub fn read_desired(&self) -> Result<Option<DesiredState>, ReconcileError> {
233
+ let path = self.desired_state_path();
234
+ if !path.exists() {
235
+ return Ok(None);
236
+ }
237
+ let bytes = fs::read(path)?;
238
+ let desired: DesiredState = serde_json::from_slice(&bytes)?;
239
+ validate_desired_state(&desired)?;
240
+ Ok(Some(desired))
241
+ }
242
+
243
+ /// Installs a complete desired generation. Any error before the final
244
+ /// `active.json` rename leaves the previous generation authoritative.
245
+ pub fn reconcile(
246
+ &self,
247
+ desired: &DesiredState,
248
+ fetcher: &impl ArtifactFetcher,
249
+ ) -> Result<ReconcileOutcome, ReconcileError> {
250
+ validate_desired_state(desired)?;
251
+ // active.json is a derived local pointer, not authority. If it was
252
+ // truncated or otherwise corrupted, rebuild it from desired state
253
+ // instead of making the corruption permanent. Non-JSON I/O failures
254
+ // still surface.
255
+ let previous = match self.read_active() {
256
+ Ok(active) => active,
257
+ Err(ReconcileError::Json(_)) => None,
258
+ Err(err) => return Err(err),
259
+ };
260
+ // Rollback guard, anchored on what the SERVER has said this session —
261
+ // never on the local pointer. See `server_high_water`.
262
+ let floor = self.server_high_water.load(Ordering::Relaxed);
263
+ if desired.generation < floor {
264
+ return Err(ReconcileError::InvalidDesiredState(format!(
265
+ "generation rollback from {} to {} is not allowed",
266
+ floor, desired.generation
267
+ )));
268
+ }
269
+ // Recorded before the work below so a mid-reconcile failure cannot let
270
+ // an immediately-following lower generation through.
271
+ self.server_high_water
272
+ .fetch_max(desired.generation, Ordering::Relaxed);
273
+
274
+ // A local pointer AHEAD of the server is not authority, but it is worth
275
+ // saying out loud: it means either a restored/re-registered control
276
+ // plane, or that something edited this machine's state.
277
+ if let Some(active) = &previous
278
+ && active.generation > desired.generation
279
+ {
280
+ eprintln!(
281
+ "[failproofaid] local active generation {} is ahead of the server's {}; \
282
+ taking the server's state (the local pointer is not authority)",
283
+ active.generation, desired.generation
284
+ );
285
+ }
286
+
287
+ fs::create_dir_all(self.root.join("artifacts"))?;
288
+ let generation_dir = self
289
+ .root
290
+ .join("generations")
291
+ .join(desired.generation.to_string());
292
+ fs::create_dir_all(&generation_dir)?;
293
+
294
+ let mut downloaded = 0;
295
+ let mut repaired = 0;
296
+ let mut active_policies = Vec::with_capacity(desired.policies.len());
297
+
298
+ for policy in &desired.policies {
299
+ let artifact_path = self.artifact_path(&policy.sha256);
300
+ let generation_path = generation_dir.join(format!("{}.mjs", policy.id));
301
+
302
+ let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?;
303
+ let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?;
304
+
305
+ let bytes = if artifact_valid {
306
+ fs::read(&artifact_path)?
307
+ } else if generation_valid {
308
+ let bytes = fs::read(&generation_path)?;
309
+ write_atomic(&artifact_path, &bytes)?;
310
+ repaired += 1;
311
+ bytes
312
+ } else {
313
+ let bytes = fetcher
314
+ .fetch(policy)
315
+ .map_err(|message| ReconcileError::Fetch {
316
+ policy_id: policy.id.clone(),
317
+ message,
318
+ })?;
319
+ verify_bytes(policy, &bytes)?;
320
+ write_atomic(&artifact_path, &bytes)?;
321
+ downloaded += 1;
322
+ bytes
323
+ };
324
+
325
+ if !generation_valid {
326
+ write_atomic(&generation_path, &bytes)?;
327
+ if artifact_valid {
328
+ repaired += 1;
329
+ }
330
+ }
331
+
332
+ let relative_path = generation_path
333
+ .strip_prefix(&self.root)
334
+ .map_err(|_| {
335
+ ReconcileError::InvalidDesiredState(
336
+ "generation path escaped policy root".into(),
337
+ )
338
+ })?
339
+ .to_string_lossy()
340
+ .into_owned();
341
+ active_policies.push(ActivePolicy {
342
+ id: policy.id.clone(),
343
+ revision: policy.revision,
344
+ effect: policy.effect,
345
+ sha256: policy.sha256.clone(),
346
+ path: relative_path,
347
+ });
348
+ }
349
+
350
+ let active = ActiveGeneration {
351
+ schema_version: DESIRED_STATE_SCHEMA_VERSION,
352
+ generation: desired.generation,
353
+ policies: active_policies,
354
+ };
355
+ let manifest_bytes = serde_json::to_vec_pretty(&active)?;
356
+ write_atomic(&generation_dir.join("manifest.json"), &manifest_bytes)?;
357
+
358
+ // Persist the cloud snapshot before switching active.json. A crash in
359
+ // between is recoverable: the maintenance loop reconstructs the active
360
+ // pointer from this snapshot and the fully staged generation.
361
+ write_atomic(
362
+ &self.desired_state_path(),
363
+ &serde_json::to_vec_pretty(desired)?,
364
+ )?;
365
+
366
+ let activated = previous.as_ref() != Some(&active);
367
+ if activated {
368
+ write_atomic(&self.active_manifest_path(), &manifest_bytes)?;
369
+ }
370
+
371
+ Ok(ReconcileOutcome {
372
+ generation: desired.generation,
373
+ downloaded,
374
+ repaired,
375
+ activated,
376
+ })
377
+ }
378
+
379
+ /// Verifies the active generation and repairs one bad copy from the other
380
+ /// verified local copy. If both copies are missing/corrupt, the cloud
381
+ /// transport must re-fetch; active.json remains unchanged and the worker's
382
+ /// already-loaded generation remains the last known good decision set.
383
+ pub fn repair_active_from_cache(&self) -> Result<usize, ReconcileError> {
384
+ if let Some(desired) = self.read_desired()? {
385
+ let outcome = self.reconcile(&desired, &|policy: &DesiredPolicy| {
386
+ Err(format!(
387
+ "no verified cached bytes remain for {}; cloud refetch required",
388
+ policy.id
389
+ ))
390
+ })?;
391
+ return Ok(outcome.repaired + usize::from(outcome.activated));
392
+ }
393
+
394
+ let Some(active) = self.read_active()? else {
395
+ return Ok(0);
396
+ };
397
+ if active.schema_version != DESIRED_STATE_SCHEMA_VERSION {
398
+ return Err(ReconcileError::InvalidDesiredState(format!(
399
+ "unsupported active schema version {}",
400
+ active.schema_version
401
+ )));
402
+ }
403
+
404
+ let mut repaired = 0;
405
+ for policy in &active.policies {
406
+ validate_policy_identity(&policy.id)?;
407
+ validate_sha256(&policy.sha256)?;
408
+ let generation_path = safe_join_relative(&self.root, &policy.path)?;
409
+ let artifact_path = self.artifact_path(&policy.sha256);
410
+ let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?;
411
+ let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?;
412
+
413
+ match (artifact_valid, generation_valid) {
414
+ (true, true) => {}
415
+ (true, false) => {
416
+ write_atomic(&generation_path, &fs::read(&artifact_path)?)?;
417
+ repaired += 1;
418
+ }
419
+ (false, true) => {
420
+ write_atomic(&artifact_path, &fs::read(&generation_path)?)?;
421
+ repaired += 1;
422
+ }
423
+ (false, false) => {
424
+ return Err(ReconcileError::NoVerifiedCopy {
425
+ policy_id: policy.id.clone(),
426
+ });
427
+ }
428
+ }
429
+ }
430
+ Ok(repaired)
431
+ }
432
+
433
+ fn artifact_path(&self, sha256: &str) -> PathBuf {
434
+ self.root.join("artifacts").join(format!("{sha256}.mjs"))
435
+ }
436
+ }
437
+
438
+ /// Starts the maintenance-lane integrity loop. It performs one pass
439
+ /// immediately, then periodically, and wakes in short slices so daemon
440
+ /// shutdown never waits for the full reconciliation interval.
441
+ pub fn spawn_integrity_monitor(
442
+ store: PolicyStore,
443
+ shutdown: Arc<AtomicBool>,
444
+ interval: Duration,
445
+ ) -> JoinHandle<()> {
446
+ std::thread::spawn(move || {
447
+ while !shutdown.load(Ordering::Relaxed) {
448
+ match store.repair_active_from_cache() {
449
+ Ok(0) => {}
450
+ Ok(repaired) => {
451
+ eprintln!("[failproofaid] repaired {repaired} cloud-managed policy artifact(s)")
452
+ }
453
+ Err(err) => eprintln!("[failproofaid] cloud policy integrity error: {err}"),
454
+ }
455
+
456
+ let deadline = Instant::now() + interval;
457
+ while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline {
458
+ let remaining = deadline.saturating_duration_since(Instant::now());
459
+ std::thread::sleep(remaining.min(Duration::from_millis(50)));
460
+ }
461
+ }
462
+ })
463
+ }
464
+
465
+ fn validate_desired_state(desired: &DesiredState) -> Result<(), ReconcileError> {
466
+ if desired.schema_version != DESIRED_STATE_SCHEMA_VERSION {
467
+ return Err(ReconcileError::InvalidDesiredState(format!(
468
+ "unsupported schema version {}",
469
+ desired.schema_version
470
+ )));
471
+ }
472
+ let mut ids = HashSet::new();
473
+ for policy in &desired.policies {
474
+ validate_policy_identity(&policy.id)?;
475
+ validate_sha256(&policy.sha256)?;
476
+ if policy.artifact_url.trim().is_empty() {
477
+ return Err(ReconcileError::InvalidDesiredState(format!(
478
+ "policy {} has an empty artifactUrl",
479
+ policy.id
480
+ )));
481
+ }
482
+ if !ids.insert(policy.id.as_str()) {
483
+ return Err(ReconcileError::InvalidDesiredState(format!(
484
+ "duplicate policy id {}",
485
+ policy.id
486
+ )));
487
+ }
488
+ }
489
+ Ok(())
490
+ }
491
+
492
+ fn validate_policy_identity(id: &str) -> Result<(), ReconcileError> {
493
+ let valid = !id.is_empty()
494
+ && id.len() <= 128
495
+ && id
496
+ .bytes()
497
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
498
+ && id != "."
499
+ && id != "..";
500
+ if !valid {
501
+ return Err(ReconcileError::InvalidDesiredState(format!(
502
+ "unsafe policy id {id:?}"
503
+ )));
504
+ }
505
+ Ok(())
506
+ }
507
+
508
+ fn validate_sha256(value: &str) -> Result<(), ReconcileError> {
509
+ if value.len() != 64
510
+ || !value
511
+ .bytes()
512
+ .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
513
+ {
514
+ return Err(ReconcileError::InvalidDesiredState(format!(
515
+ "invalid lowercase SHA-256 digest {value:?}"
516
+ )));
517
+ }
518
+ Ok(())
519
+ }
520
+
521
+ fn safe_join_relative(root: &Path, relative: &str) -> Result<PathBuf, ReconcileError> {
522
+ let path = Path::new(relative);
523
+ if path.is_absolute()
524
+ || path
525
+ .components()
526
+ .any(|component| !matches!(component, std::path::Component::Normal(_)))
527
+ {
528
+ return Err(ReconcileError::InvalidDesiredState(format!(
529
+ "unsafe active policy path {relative:?}"
530
+ )));
531
+ }
532
+ Ok(root.join(path))
533
+ }
534
+
535
+ fn verify_bytes(policy: &DesiredPolicy, bytes: &[u8]) -> Result<(), ReconcileError> {
536
+ let actual = sha256_hex(bytes);
537
+ if actual != policy.sha256 {
538
+ return Err(ReconcileError::HashMismatch {
539
+ policy_id: policy.id.clone(),
540
+ expected: policy.sha256.clone(),
541
+ actual,
542
+ });
543
+ }
544
+ Ok(())
545
+ }
546
+
547
+ fn file_matches_hash(path: &Path, expected: &str) -> Result<bool, ReconcileError> {
548
+ let mut file = match File::open(path) {
549
+ Ok(file) => file,
550
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
551
+ Err(err) => return Err(err.into()),
552
+ };
553
+ let mut hasher = Sha256::new();
554
+ let mut buffer = [0_u8; 8192];
555
+ loop {
556
+ let read = file.read(&mut buffer)?;
557
+ if read == 0 {
558
+ break;
559
+ }
560
+ hasher.update(&buffer[..read]);
561
+ }
562
+ Ok(format!("{:x}", hasher.finalize()) == expected)
563
+ }
564
+
565
+ fn sha256_hex(bytes: &[u8]) -> String {
566
+ format!("{:x}", Sha256::digest(bytes))
567
+ }
568
+
569
+ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ReconcileError> {
570
+ let parent = path
571
+ .parent()
572
+ .ok_or_else(|| io::Error::other("managed policy path has no parent"))?;
573
+ fs::create_dir_all(parent)?;
574
+ let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
575
+ let file_name = path
576
+ .file_name()
577
+ .and_then(|name| name.to_str())
578
+ .unwrap_or("managed-policy");
579
+ let temp = parent.join(format!(".{file_name}.tmp-{}-{counter}", std::process::id()));
580
+
581
+ let mut file = OpenOptions::new()
582
+ .write(true)
583
+ .create_new(true)
584
+ .open(&temp)?;
585
+ file.set_permissions(fs::Permissions::from_mode(MANAGED_FILE_MODE))?;
586
+ file.write_all(bytes)?;
587
+ file.sync_all()?;
588
+ drop(file);
589
+ fs::rename(&temp, path)?;
590
+ File::open(parent)?.sync_all()?;
591
+ Ok(())
592
+ }
593
+
594
+ #[cfg(test)]
595
+ mod tests {
596
+ use super::*;
597
+
598
+ #[test]
599
+ fn desired_state_tolerates_fields_this_daemon_does_not_know() {
600
+ // Daemons update on their own schedule. If this struct rejected unknown
601
+ // fields, the first thing cloud added would make every older daemon
602
+ // fail to parse desired-state and silently stop pulling — a fleet
603
+ // stranded on whatever generation it happened to hold, with no error
604
+ // anyone would look for.
605
+ let json = r#"{"schemaVersion":1,"generation":4,"policies":[
606
+ {"id":"guard","revision":2,"sha256":"aa","artifactUrl":"/a","effect":"observe",
607
+ "someFutureField":{"nested":true}}
608
+ ],"anotherFutureField":42}"#;
609
+ let parsed: DesiredState = serde_json::from_str(json).expect("must parse");
610
+ assert_eq!(parsed.generation, 4);
611
+ assert_eq!(parsed.policies[0].effect, PolicyEffect::Observe);
612
+ }
613
+
614
+ #[test]
615
+ fn a_policy_with_no_effect_enforces() {
616
+ // The default has to be the one that keeps enforcing: a server that
617
+ // predates observe mode must not silently downgrade a fleet to
618
+ // observation.
619
+ let json = r#"{"schemaVersion":1,"generation":1,"policies":[
620
+ {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a"}]}"#;
621
+ let parsed: DesiredState = serde_json::from_str(json).unwrap();
622
+ assert_eq!(parsed.policies[0].effect, PolicyEffect::Enforce);
623
+ }
624
+
625
+ #[test]
626
+ fn an_unreadable_effect_is_rejected_rather_than_guessed() {
627
+ // Guessing would mean choosing between enforcing something cloud did
628
+ // not ask to enforce, or observing something it wanted enforced. Both
629
+ // are worse than refusing the generation.
630
+ let json = r#"{"schemaVersion":1,"generation":1,"policies":[
631
+ {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a","effect":"maybe"}]}"#;
632
+ assert!(serde_json::from_str::<DesiredState>(json).is_err());
633
+ }
634
+
635
+ #[test]
636
+ fn the_active_manifest_records_the_effect_it_activated() {
637
+ // active.json is what the evaluator reads. If the effect were not
638
+ // carried here, an observe-mode policy would enforce the moment the
639
+ // daemon restarted and re-read its own manifest.
640
+ let manifest = ActiveGeneration {
641
+ schema_version: 1,
642
+ generation: 9,
643
+ policies: vec![ActivePolicy {
644
+ id: "g".into(),
645
+ revision: 1,
646
+ sha256: "aa".into(),
647
+ path: "generations/9/g.mjs".into(),
648
+ effect: PolicyEffect::Observe,
649
+ }],
650
+ };
651
+ let round_tripped: ActiveGeneration =
652
+ serde_json::from_str(&serde_json::to_string(&manifest).unwrap()).unwrap();
653
+ assert_eq!(round_tripped.policies[0].effect, PolicyEffect::Observe);
654
+ assert!(
655
+ serde_json::to_string(&manifest)
656
+ .unwrap()
657
+ .contains("\"effect\":\"observe\"")
658
+ );
659
+ }
660
+ use std::sync::atomic::{AtomicUsize, Ordering};
661
+
662
+ fn temp_store(name: &str) -> PolicyStore {
663
+ let root = std::env::temp_dir().join(format!(
664
+ "failproofaid-cloud-policy-{name}-{}-{}",
665
+ std::process::id(),
666
+ TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
667
+ ));
668
+ PolicyStore::new(root)
669
+ }
670
+
671
+ fn desired(generation: u64, id: &str, bytes: &[u8]) -> DesiredState {
672
+ DesiredState {
673
+ schema_version: DESIRED_STATE_SCHEMA_VERSION,
674
+ generation,
675
+ policies: vec![DesiredPolicy {
676
+ id: id.to_string(),
677
+ revision: generation,
678
+ sha256: sha256_hex(bytes),
679
+ artifact_url: format!("https://cloud.invalid/{id}/{generation}"),
680
+ effect: PolicyEffect::Enforce,
681
+ }],
682
+ }
683
+ }
684
+
685
+ #[test]
686
+ fn activates_a_complete_verified_generation() {
687
+ let store = temp_store("activate");
688
+ let bytes = b"export default 'cloud policy';\n";
689
+ let state = desired(7, "block-secrets", bytes);
690
+ let fetches = AtomicUsize::new(0);
691
+ let outcome = store
692
+ .reconcile(&state, &|_: &DesiredPolicy| {
693
+ fetches.fetch_add(1, Ordering::Relaxed);
694
+ Ok(bytes.to_vec())
695
+ })
696
+ .unwrap();
697
+
698
+ assert_eq!(outcome.downloaded, 1);
699
+ assert!(outcome.activated);
700
+ assert_eq!(fetches.load(Ordering::Relaxed), 1);
701
+ let active = store.read_active().unwrap().unwrap();
702
+ assert_eq!(active.generation, 7);
703
+ assert_eq!(active.policies[0].id, "block-secrets");
704
+ let active_path = store.root().join(&active.policies[0].path);
705
+ assert_eq!(fs::read(active_path).unwrap(), bytes);
706
+ fs::remove_dir_all(store.root()).ok();
707
+ }
708
+
709
+ #[test]
710
+ fn a_bad_download_never_replaces_last_known_good() {
711
+ let store = temp_store("bad-download");
712
+ let good = b"export default 'good';\n";
713
+ store
714
+ .reconcile(&desired(1, "guard", good), &|_: &DesiredPolicy| {
715
+ Ok(good.to_vec())
716
+ })
717
+ .unwrap();
718
+ let before = fs::read(store.active_manifest_path()).unwrap();
719
+
720
+ let next = desired(2, "guard", b"export default 'expected';\n");
721
+ let err = store
722
+ .reconcile(
723
+ &next,
724
+ &|_: &DesiredPolicy| Ok(b"tampered download".to_vec()),
725
+ )
726
+ .unwrap_err();
727
+ assert!(matches!(err, ReconcileError::HashMismatch { .. }));
728
+ assert_eq!(fs::read(store.active_manifest_path()).unwrap(), before);
729
+ assert_eq!(store.read_active().unwrap().unwrap().generation, 1);
730
+ fs::remove_dir_all(store.root()).ok();
731
+ }
732
+
733
+ #[test]
734
+ fn repairs_a_tampered_generation_copy_from_the_verified_artifact() {
735
+ let store = temp_store("repair-generation");
736
+ let bytes = b"export default 'verified';\n";
737
+ store
738
+ .reconcile(&desired(3, "guard", bytes), &|_: &DesiredPolicy| {
739
+ Ok(bytes.to_vec())
740
+ })
741
+ .unwrap();
742
+ let active = store.read_active().unwrap().unwrap();
743
+ let generation_path = store.root().join(&active.policies[0].path);
744
+ fs::write(&generation_path, b"tampered").unwrap();
745
+
746
+ assert_eq!(store.repair_active_from_cache().unwrap(), 1);
747
+ assert_eq!(fs::read(generation_path).unwrap(), bytes);
748
+ fs::remove_dir_all(store.root()).ok();
749
+ }
750
+
751
+ #[test]
752
+ fn repairs_a_tampered_artifact_from_the_verified_generation_copy() {
753
+ let store = temp_store("repair-artifact");
754
+ let bytes = b"export default 'verified';\n";
755
+ let state = desired(4, "guard", bytes);
756
+ store
757
+ .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec()))
758
+ .unwrap();
759
+ let artifact_path = store.artifact_path(&state.policies[0].sha256);
760
+ fs::write(&artifact_path, b"tampered").unwrap();
761
+
762
+ assert_eq!(store.repair_active_from_cache().unwrap(), 1);
763
+ assert_eq!(fs::read(artifact_path).unwrap(), bytes);
764
+ fs::remove_dir_all(store.root()).ok();
765
+ }
766
+
767
+ #[test]
768
+ fn reports_when_both_verified_copies_are_lost() {
769
+ let store = temp_store("both-lost");
770
+ let bytes = b"export default 'verified';\n";
771
+ let state = desired(5, "guard", bytes);
772
+ store
773
+ .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec()))
774
+ .unwrap();
775
+ let active = store.read_active().unwrap().unwrap();
776
+ fs::write(store.root().join(&active.policies[0].path), b"bad-one").unwrap();
777
+ fs::write(store.artifact_path(&state.policies[0].sha256), b"bad-two").unwrap();
778
+
779
+ assert!(matches!(
780
+ store.repair_active_from_cache(),
781
+ Err(ReconcileError::Fetch { .. })
782
+ ));
783
+ assert_eq!(store.read_active().unwrap().unwrap().generation, 5);
784
+ fs::remove_dir_all(store.root()).ok();
785
+ }
786
+
787
+ #[test]
788
+ fn repairs_a_removed_or_rewritten_active_manifest_from_desired_state() {
789
+ let store = temp_store("repair-active");
790
+ let bytes = b"export default 'verified';\n";
791
+ store
792
+ .reconcile(&desired(11, "guard", bytes), &|_: &DesiredPolicy| {
793
+ Ok(bytes.to_vec())
794
+ })
795
+ .unwrap();
796
+
797
+ fs::write(
798
+ store.active_manifest_path(),
799
+ br#"{"schemaVersion":1,"generation":11,"policies":[]}"#,
800
+ )
801
+ .unwrap();
802
+ assert_eq!(store.repair_active_from_cache().unwrap(), 1);
803
+ assert_eq!(store.read_active().unwrap().unwrap().policies.len(), 1);
804
+
805
+ fs::write(store.active_manifest_path(), b"not-json").unwrap();
806
+ assert_eq!(store.repair_active_from_cache().unwrap(), 1);
807
+ assert_eq!(
808
+ store.read_active().unwrap().unwrap().policies[0].id,
809
+ "guard"
810
+ );
811
+ fs::remove_dir_all(store.root()).ok();
812
+ }
813
+
814
+ #[test]
815
+ fn rejects_traversal_duplicate_ids_and_generation_rollback() {
816
+ let store = temp_store("validation");
817
+ let bytes = b"policy";
818
+ let mut traversal = desired(1, "../escape", bytes);
819
+ assert!(matches!(
820
+ store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())),
821
+ Err(ReconcileError::InvalidDesiredState(_))
822
+ ));
823
+
824
+ traversal.policies[0].id = "guard".to_string();
825
+ traversal.policies.push(traversal.policies[0].clone());
826
+ assert!(matches!(
827
+ store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())),
828
+ Err(ReconcileError::InvalidDesiredState(_))
829
+ ));
830
+
831
+ store
832
+ .reconcile(&desired(9, "guard", bytes), &|_: &DesiredPolicy| {
833
+ Ok(bytes.to_vec())
834
+ })
835
+ .unwrap();
836
+ assert!(matches!(
837
+ store.reconcile(&desired(8, "guard", bytes), &|_: &DesiredPolicy| Ok(
838
+ bytes.to_vec()
839
+ )),
840
+ Err(ReconcileError::InvalidDesiredState(_))
841
+ ));
842
+ fs::remove_dir_all(store.root()).ok();
843
+ }
844
+
845
+ /// A tampered local generation must not be able to veto the control plane.
846
+ ///
847
+ /// The guard used to compare against `active.json`, a 0600 file owned by
848
+ /// the very user the product's threat model treats as compromised. Writing
849
+ /// one large number there made every subsequent real deployment fail
850
+ /// validation for good; corrupt the artifacts it points at as well and the
851
+ /// machine can neither repair locally nor accept the server, and fails
852
+ /// closed on every tool call. A permanent denial of service for one file
853
+ /// write.
854
+ #[test]
855
+ fn a_tampered_local_generation_cannot_permanently_veto_the_server() {
856
+ let store = temp_store("tampered-generation");
857
+ let bytes = b"policy";
858
+
859
+ store
860
+ .reconcile(&desired(5, "guard", bytes), &|_: &DesiredPolicy| {
861
+ Ok(bytes.to_vec())
862
+ })
863
+ .unwrap();
864
+
865
+ // The attack: one edit to a user-owned file.
866
+ let manifest = store.active_manifest_path();
867
+ let raw = fs::read_to_string(&manifest).unwrap();
868
+ let mut active: serde_json::Value = serde_json::from_str(&raw).unwrap();
869
+ active["generation"] = serde_json::json!(u64::MAX);
870
+ fs::write(&manifest, serde_json::to_vec(&active).unwrap()).unwrap();
871
+
872
+ // A fresh process, as after any restart. It must take the server's
873
+ // state rather than treating the local number as authority.
874
+ let restarted = PolicyStore::new(store.root().to_path_buf());
875
+ let outcome = restarted
876
+ .reconcile(&desired(6, "guard", bytes), &|_: &DesiredPolicy| {
877
+ Ok(bytes.to_vec())
878
+ })
879
+ .expect("the server's state must win over a local pointer");
880
+ assert!(outcome.activated);
881
+ assert_eq!(restarted.read_active().unwrap().unwrap().generation, 6);
882
+
883
+ // And replay protection still holds WITHIN the session, which is the
884
+ // transport failure the guard actually exists for.
885
+ assert!(matches!(
886
+ restarted.reconcile(&desired(5, "guard", bytes), &|_: &DesiredPolicy| Ok(
887
+ bytes.to_vec()
888
+ )),
889
+ Err(ReconcileError::InvalidDesiredState(_))
890
+ ));
891
+
892
+ fs::remove_dir_all(store.root()).ok();
893
+ }
894
+
895
+ #[test]
896
+ fn background_monitor_repairs_without_touching_the_hook_path() {
897
+ let store = temp_store("monitor");
898
+ let bytes = b"export default 'verified';\n";
899
+ store
900
+ .reconcile(&desired(10, "guard", bytes), &|_: &DesiredPolicy| {
901
+ Ok(bytes.to_vec())
902
+ })
903
+ .unwrap();
904
+ let active = store.read_active().unwrap().unwrap();
905
+ let generation_path = store.root().join(&active.policies[0].path);
906
+ fs::write(&generation_path, b"tampered").unwrap();
907
+
908
+ let shutdown = Arc::new(AtomicBool::new(false));
909
+ let handle =
910
+ spawn_integrity_monitor(store.clone(), shutdown.clone(), Duration::from_millis(10));
911
+ let deadline = Instant::now() + Duration::from_secs(1);
912
+ while fs::read(&generation_path).unwrap() != bytes && Instant::now() < deadline {
913
+ std::thread::sleep(Duration::from_millis(5));
914
+ }
915
+ shutdown.store(true, Ordering::Relaxed);
916
+ handle.join().unwrap();
917
+
918
+ assert_eq!(fs::read(generation_path).unwrap(), bytes);
919
+ fs::remove_dir_all(store.root()).ok();
920
+ }
921
+ }