failproofai 1.0.9-beta.0 → 1.0.9-beta.1

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 (143) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  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 +2 -2
  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 +6 -6
  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/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/index.html +1 -1
  36. package/.next/standalone/.next/server/app/index.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  38. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  47. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  64. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0tmz862._.js → [root-of-the-server]__03m2iw6._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12e7nhs._.js → [root-of-the-server]__1-bm5zf._.js} +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/{_1p3iz2d._.js → _11ilmst._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +3 -3
  89. package/.next/standalone/.next/server/chunks/ssr/{_0n0lja6._.js → _1jatlmf._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +1 -1
  99. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  100. package/.next/standalone/.next/server/pages/404.html +1 -1
  101. package/.next/standalone/.next/server/pages/500.html +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  103. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  104. package/.next/standalone/.next/static/chunks/{06jxjnw8bfczc.js → 0542cuk_va8g1.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{2qsrrrl2maqsb.js → 08ckontevw-0w.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{42m0f2hh8vfb1.js → 0pefjgvu4x-sx.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{0snylg3y1i47a.js → 1-d48e9k-7e1a.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/{03001k9jy-kst.js → 1r74hmr1wm_sd.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/{0qejem4-zt-hy.js → 1uec0n0lcx5h1.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/21-pqc3-l9ak2.js +6 -0
  111. package/.next/standalone/.next/static/chunks/{1h0if8ky362as.js → 21qlo00jis4w-.js} +1 -1
  112. package/.next/standalone/.next/static/chunks/{3rq7fjj9we0v3.js → 2n00c3zqyloy9.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0247hc9ath5t-.js → 3tv107_for_n7.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{06k7gsfnb7jkh.js → 3vp54x0dyrrm6.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{turbopack-38nzuvp95_2rv.js → turbopack-11dn2pegmg9_9.js} +1 -1
  116. package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
  117. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  118. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  119. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  120. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  121. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  122. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  123. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  124. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  125. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  126. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  127. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  128. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  129. package/.next/standalone/node_modules/next/package.json +10 -10
  130. package/.next/standalone/package.json +11 -11
  131. package/.next/standalone/sdk/python/uv.lock +18 -18
  132. package/.next/standalone/server.js +1 -1
  133. package/dist/cli.mjs +911 -719
  134. package/dist/worker.mjs +4 -1
  135. package/package.json +11 -11
  136. package/src/hooks/integrations.ts +128 -18
  137. package/src/hooks/jev-activity.ts +5 -0
  138. package/src/hooks/policy-reviewability.ts +4 -2
  139. package/src/hooks/safe-config-write.ts +119 -0
  140. package/.next/standalone/.next/static/chunks/3ns-egfh8i8i5.js +0 -6
  141. /package/.next/standalone/.next/static/{z1C0kJVF0NCtWn5R88hW3 → ozuWoyIwOTqeUUjKqOKiH}/_buildManifest.js +0 -0
  142. /package/.next/standalone/.next/static/{z1C0kJVF0NCtWn5R88hW3 → ozuWoyIwOTqeUUjKqOKiH}/_clientMiddlewareManifest.js +0 -0
  143. /package/.next/standalone/.next/static/{z1C0kJVF0NCtWn5R88hW3 → ozuWoyIwOTqeUUjKqOKiH}/_ssgManifest.js +0 -0
package/dist/worker.mjs CHANGED
@@ -1162,7 +1162,7 @@ function instruct(reason) {
1162
1162
  }
1163
1163
 
1164
1164
  // package.json
1165
- var version = "1.0.9-beta.0";
1165
+ var version = "1.0.9-beta.1";
1166
1166
  var init_package = () => {};
1167
1167
 
1168
1168
  // src/hooks/policy-types.ts
@@ -4172,8 +4172,11 @@ function sanitizeJevActivity(entry) {
4172
4172
  drop("evaluator");
4173
4173
  if (!(typeof out.jevDecision === "string" && DECISIONS.has(out.jevDecision)))
4174
4174
  drop("jevDecision");
4175
+ const unknownMode = out.jevMode !== undefined && !(typeof out.jevMode === "string" && MODES.has(out.jevMode));
4175
4176
  if (!(typeof out.jevMode === "string" && MODES.has(out.jevMode)))
4176
4177
  drop("jevMode");
4178
+ if (unknownMode)
4179
+ drop("jevCleared");
4177
4180
  if (Array.isArray(out.jevCleared)) {
4178
4181
  const names = [
4179
4182
  ...new Set(out.jevCleared.filter(isJevPolicyName))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.9-beta.0",
3
+ "version": "1.0.9-beta.1",
4
4
  "description": "Observability and enforcement for AI agent harnesses. 40 built-in policies hooked into 12 of them — Claude Code, Codex, Cursor, Hermes, OpenClaw and more — blocking the tool call before it runs. Local dashboard included, no account needed.",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -81,14 +81,14 @@
81
81
  "access": "public"
82
82
  },
83
83
  "devDependencies": {
84
- "@anthropic-ai/sdk": "^0.127.0",
84
+ "@anthropic-ai/sdk": "^0.128.0",
85
85
  "@mdx-js/mdx": "^3.1.1",
86
86
  "@tailwindcss/postcss": "^4.3.1",
87
87
  "@tanstack/react-virtual": "^3.14.10",
88
88
  "@testing-library/jest-dom": "^7.0.0",
89
89
  "@testing-library/react": "^16.3.2",
90
90
  "@testing-library/user-event": "^14.6.5",
91
- "@types/node": "26.6.1",
91
+ "@types/node": "26.6.2",
92
92
  "@types/react": "19.3.0",
93
93
  "@types/react-dom": "^19.2.3",
94
94
  "@vitejs/plugin-react": "^6.1.0",
@@ -122,15 +122,15 @@
122
122
  "browserslist": "4.28.8"
123
123
  },
124
124
  "optionalDependencies": {
125
- "@failproofai/failproofaid-linux-x64": "1.0.9-beta.0",
126
- "@failproofai/failproofaid-linux-arm64": "1.0.9-beta.0",
127
- "@failproofai/failproofaid-darwin-x64": "1.0.9-beta.0",
128
- "@failproofai/failproofaid-darwin-arm64": "1.0.9-beta.0"
125
+ "@failproofai/failproofaid-linux-x64": "1.0.9-beta.1",
126
+ "@failproofai/failproofaid-linux-arm64": "1.0.9-beta.1",
127
+ "@failproofai/failproofaid-darwin-x64": "1.0.9-beta.1",
128
+ "@failproofai/failproofaid-darwin-arm64": "1.0.9-beta.1"
129
129
  },
130
130
  "failproofaidBinaries": {
131
- "linux-x64": "044b6619007227b8aa1fc1095b8c61a17bd90e11da53183ac1f5adecdcae6839",
132
- "linux-arm64": "479450d7d15c52a59bd1a8cf4c7086be9210e8d31ad4f98d99b123f571e944bd",
133
- "darwin-x64": "11562912ff726195bfb966ed24a774ffea38d01acc3edc1f9bfa3ea9c298977f",
134
- "darwin-arm64": "5bbaee1ec89d2d8032b7f16e502fb96891fbc6aa2ee6c861f43a64a0a60e61ba"
131
+ "linux-x64": "a0db1ad28bc0584bb4554b1dc60b55353fad9d982354880f9cc1e1ee567e5d36",
132
+ "linux-arm64": "b8d2797db8d93ce0deec86707093dd22ecd2d3fe2b137944938f4851d31718f1",
133
+ "darwin-x64": "94dc7ea33121c884736eadafc8281b0b752824b5c7fbe1726745bd1fa2b2a577",
134
+ "darwin-arm64": "8da18bbede18b8c112e8c46b6f0de798e84445c163a91a80edeef6d9e21da8ad"
135
135
  }
136
136
  }
@@ -25,6 +25,7 @@ import { resolve, dirname, basename } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { homedir } from "node:os";
27
27
  import { parseDocument, type Document } from "yaml";
28
+ import { writeConfigFileAtomic } from "./safe-config-write";
28
29
  import { listHermesProfiles, hermesRoot } from "../../lib/hermes-profiles";
29
30
  import { listOpenClawProfiles } from "../../lib/openclaw-profiles";
30
31
  import {
@@ -65,32 +66,82 @@ import {
65
66
 
66
67
  // ── Generic helpers ─────────────────────────────────────────────────────────
67
68
 
69
+ /**
70
+ * The error every config reader throws for a file it cannot read faithfully.
71
+ *
72
+ * These files belong to the user's agent. A config that exists but does not
73
+ * parse is never treated as empty: writing our entries into an "empty" document
74
+ * would replace every other setting in it, and the agent would come back without
75
+ * its model, keys or channels. Refusing leaves the file exactly as the user has
76
+ * it and says which file and why.
77
+ */
78
+ export class UnreadableAgentConfigError extends Error {
79
+ constructor(readonly path: string, reason: string) {
80
+ super(
81
+ `Refusing to modify ${path}: ${reason}. ` +
82
+ `failproofai never rewrites a config it could not parse — fix or move the file, then retry.`,
83
+ );
84
+ this.name = "UnreadableAgentConfigError";
85
+ }
86
+ }
87
+
68
88
  function readJsonFile(path: string): Record<string, unknown> {
69
89
  if (!existsSync(path)) return {};
70
- const raw = readFileSync(path, "utf8");
71
- return JSON.parse(raw) as Record<string, unknown>;
90
+ let raw: string;
91
+ try {
92
+ raw = readFileSync(path, "utf8");
93
+ } catch (err) {
94
+ throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
95
+ }
96
+ if (raw.trim() === "") return {};
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ } catch (err) {
101
+ throw new UnreadableAgentConfigError(path, `it is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
102
+ }
103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
104
+ throw new UnreadableAgentConfigError(path, "its top level is not a JSON object");
105
+ }
106
+ return parsed as Record<string, unknown>;
72
107
  }
73
108
 
74
109
  function writeJsonFile(path: string, data: Record<string, unknown>): void {
75
- mkdirSync(dirname(path), { recursive: true });
76
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
110
+ writeConfigFileAtomic(path, JSON.stringify(data, null, 2) + "\n");
77
111
  }
78
112
 
79
113
  /** Read a YAML file as a `Document` so writes round-trip the user's other keys +
80
- * comments (a plain parse→stringify would strip comments). Empty / missing /
81
- * corrupt → an empty Document. Used by the Hermes integration, whose config
82
- * lives in `~/.hermes/config.yaml`. */
114
+ * comments (a plain parse→stringify would strip comments). Missing or empty →
115
+ * an empty Document; unreadable or unparseable → {@link UnreadableAgentConfigError},
116
+ * never an empty Document that the next write would put in the user's place.
117
+ * Used by the Hermes integration, whose config lives in `~/.hermes/config.yaml`. */
83
118
  function readYamlDoc(path: string): Document {
119
+ if (!existsSync(path)) return parseDocument("");
120
+ let text: string;
121
+ try {
122
+ text = readFileSync(path, "utf8");
123
+ } catch (err) {
124
+ throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
125
+ }
126
+ const doc = parseDocument(text);
127
+ if (doc.errors.length > 0) {
128
+ const first = doc.errors[0]?.message.split("\n")[0] ?? "syntax error";
129
+ throw new UnreadableAgentConfigError(path, `it does not parse as YAML (${first})`);
130
+ }
131
+ return doc;
132
+ }
133
+
134
+ /** {@link readYamlDoc} for read-only callers (status, health): an unreadable file is `null`. */
135
+ function inspectYamlDoc(path: string): Document | null {
84
136
  try {
85
- return existsSync(path) ? parseDocument(readFileSync(path, "utf8")) : parseDocument("");
137
+ return readYamlDoc(path);
86
138
  } catch {
87
- return parseDocument("");
139
+ return null;
88
140
  }
89
141
  }
90
142
 
91
143
  function writeYamlDoc(path: string, doc: Document): void {
92
- mkdirSync(dirname(path), { recursive: true });
93
- writeFileSync(path, doc.toString(), "utf8");
144
+ writeConfigFileAtomic(path, doc.toString());
94
145
  }
95
146
 
96
147
  function isMarkedHook(hook: unknown): boolean {
@@ -1127,7 +1178,7 @@ export const opencode: Integration = {
1127
1178
  // (a) Write the shim file. mkdirSync is recursive so the plugins/ dir
1128
1179
  // is created on first install.
1129
1180
  mkdirSync(dirname(pluginPath), { recursive: true });
1130
- writeFileSync(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope), "utf8");
1181
+ writeConfigFileAtomic(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope));
1131
1182
 
1132
1183
  // (b) Merge our entry into the plugin array idempotently. Replace any
1133
1184
  // existing failproofai-marked entry; otherwise append.
@@ -1422,7 +1473,30 @@ const HERMES_PLUGIN_SOURCE_DIR = "hermes-plugin";
1422
1473
  function getHermesPluginSourcePath(): string {
1423
1474
  const fromEnv = process.env.FAILPROOFAI_PACKAGE_ROOT;
1424
1475
  if (fromEnv) return resolve(fromEnv, HERMES_PLUGIN_SOURCE_DIR);
1425
- return resolve(fileURLToPath(import.meta.url), "..", "..", "..", HERMES_PLUGIN_SOURCE_DIR);
1476
+ return findHermesPluginSourcePath(dirname(fileURLToPath(import.meta.url)));
1477
+ }
1478
+
1479
+ /**
1480
+ * The package's `hermes-plugin/`, found by walking up from `startDir`.
1481
+ *
1482
+ * The CLI entry point sets `FAILPROOFAI_PACKAGE_ROOT`, so this is only the
1483
+ * fallback — but a fixed number of parents is right for exactly one layout:
1484
+ * three from `src/hooks/` is the package root, three from the bundled
1485
+ * `dist/cli.mjs` is the package's PARENT. Walking up to the first directory
1486
+ * that really holds the plugin's manifest works from source, from `dist/`, and
1487
+ * from the dashboard's standalone build alike. When none is found the old
1488
+ * answer is returned, so the install still fails loudly on a missing asset.
1489
+ */
1490
+ export function findHermesPluginSourcePath(startDir: string): string {
1491
+ let dir = resolve(startDir);
1492
+ for (let i = 0; i < 8; i++) {
1493
+ const candidate = resolve(dir, HERMES_PLUGIN_SOURCE_DIR);
1494
+ if (existsSync(resolve(candidate, "plugin.yaml"))) return candidate;
1495
+ const parent = dirname(dir);
1496
+ if (parent === dir) break;
1497
+ dir = parent;
1498
+ }
1499
+ return resolve(startDir, "..", "..", HERMES_PLUGIN_SOURCE_DIR);
1426
1500
  }
1427
1501
 
1428
1502
  export function hermesPluginPathForSettings(settingsPath: string): string {
@@ -1599,11 +1673,16 @@ export function installHermesPlugin(
1599
1673
  if (linked && (existing.kind === "absent" || existing.kind === "link")) {
1600
1674
  try {
1601
1675
  renameSync(temporary, destination);
1676
+ return "linked";
1602
1677
  } catch (err) {
1603
- rmSync(temporary, { force: true });
1604
- throw err;
1678
+ // Windows cannot always rename a junction over another junction; the
1679
+ // backup swap below moves the old link aside first, so try that before
1680
+ // giving up. With nothing there to move aside, the rename is the error.
1681
+ if (existing.kind !== "link") {
1682
+ rmSync(temporary, { force: true });
1683
+ throw err;
1684
+ }
1605
1685
  }
1606
- return "linked";
1607
1686
  }
1608
1687
 
1609
1688
  const backup = `${destination}.backup-${suffix}`;
@@ -1671,11 +1750,13 @@ function removeLegacyHermesHooks(doc: Document): number {
1671
1750
  function hermesConfigState(settingsPath: string): {
1672
1751
  pluginEnabled: boolean;
1673
1752
  legacyShellHookPresent: boolean;
1753
+ unreadable?: boolean;
1674
1754
  } {
1675
1755
  if (!existsSync(settingsPath)) {
1676
1756
  return { pluginEnabled: false, legacyShellHookPresent: false };
1677
1757
  }
1678
- const doc = readYamlDoc(settingsPath);
1758
+ const doc = inspectYamlDoc(settingsPath);
1759
+ if (!doc) return { pluginEnabled: false, legacyShellHookPresent: false, unreadable: true };
1679
1760
  const js = (doc.toJS() ?? {}) as {
1680
1761
  hooks?: Record<string, HermesHookEntry[]>;
1681
1762
  plugins?: HermesPluginsConfig;
@@ -1763,6 +1844,29 @@ export async function migrateHermesProfiles(opts: {
1763
1844
  const base = { name: profile.name, home: profile.home };
1764
1845
  const legacy = config.legacyShellHookPresent;
1765
1846
 
1847
+ // A config.yaml that does not parse is never edited. Whether it is ours to
1848
+ // report is read from the raw text: it cannot be parsed to find out.
1849
+ if (config.unreadable) {
1850
+ let raw = "";
1851
+ try {
1852
+ raw = readFileSync(settingsPath, "utf8");
1853
+ } catch {
1854
+ raw = "";
1855
+ }
1856
+ const mentionsUs = ours || raw.includes(FAILPROOFAI_HOOK_MARKER) || /failproofai/.test(raw);
1857
+ results.push(
1858
+ mentionsUs
1859
+ ? {
1860
+ ...base,
1861
+ status: "failed",
1862
+ detail: `${settingsPath} does not parse as YAML; left untouched`,
1863
+ legacyShellHooksRemain: /--hook\s+\S+\s+--cli\s+hermes|__failproofai_hook__/.test(raw),
1864
+ }
1865
+ : { ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false },
1866
+ );
1867
+ continue;
1868
+ }
1869
+
1766
1870
  if (!legacy && !ours && !config.pluginEnabled) {
1767
1871
  results.push({ ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false });
1768
1872
  continue;
@@ -1850,6 +1954,8 @@ export interface HermesProfileHealth {
1850
1954
  * with none of those, so their tool calls go unchecked.
1851
1955
  */
1852
1956
  cronUnchecked: boolean;
1957
+ /** config.yaml exists but does not parse; failproofai will not touch it. */
1958
+ configUnreadable: boolean;
1853
1959
  healthy: boolean;
1854
1960
  }
1855
1961
 
@@ -1870,7 +1976,8 @@ export function hermesProfileHealth(): HermesProfileHealth[] {
1870
1976
  pluginEnabled: config.pluginEnabled,
1871
1977
  legacyShellHookPresent: config.legacyShellHookPresent,
1872
1978
  cronUnchecked: config.legacyShellHookPresent && !pluginWorks,
1873
- healthy: pluginWorks && !config.legacyShellHookPresent,
1979
+ configUnreadable: config.unreadable === true,
1980
+ healthy: pluginWorks && !config.legacyShellHookPresent && config.unreadable !== true,
1874
1981
  };
1875
1982
  });
1876
1983
  }
@@ -1881,6 +1988,9 @@ export function hermesProfileStatusRows(): Array<[string, string]> {
1881
1988
  .filter((profile) => existsSync(profile.home))
1882
1989
  .map((profile) => {
1883
1990
  const label = "hermes/" + profile.name;
1991
+ if (profile.configUnreadable) {
1992
+ return [label, `UNHEALTHY — ${profile.settingsPath} does not parse; failproofai leaves it untouched until it is fixed`];
1993
+ }
1884
1994
  if (profile.cronUnchecked) {
1885
1995
  // Not a cosmetic leftover: the shell-hook integration does not reach
1886
1996
  // cron jobs at all, so this profile has an unenforced path.
@@ -236,7 +236,12 @@ export function sanitizeJevActivity<T extends JevActivityFields>(entry: T): T {
236
236
 
237
237
  if (!(typeof out.evaluator === "string" && EVALUATORS.has(out.evaluator))) drop("evaluator");
238
238
  if (!(typeof out.jevDecision === "string" && DECISIONS.has(out.jevDecision))) drop("jevDecision");
239
+ // A mode this build does not know (a row written before the mode was named
240
+ // `observe`) cannot say whether its clears were enforced, so they are not
241
+ // counted as anything: dropping only the mode would file them as enforced.
242
+ const unknownMode = out.jevMode !== undefined && !(typeof out.jevMode === "string" && MODES.has(out.jevMode));
239
243
  if (!(typeof out.jevMode === "string" && MODES.has(out.jevMode))) drop("jevMode");
244
+ if (unknownMode) drop("jevCleared");
240
245
 
241
246
  if (Array.isArray(out.jevCleared)) {
242
247
  const names = [
@@ -61,7 +61,7 @@ import { resolve } from "node:path";
61
61
  import { discoverPolicyFiles } from "./custom-hooks-loader";
62
62
  import { customPoliciesDir } from "./fp-home";
63
63
  import { configuredCustomPolicyPaths, findProjectConfigDir, readMergedHooksConfig } from "./hooks-config";
64
- import { hasInstalledPacks, readInstalledPacks } from "./pack-manifest";
64
+ import { hasInstalledRegexPacks, readInstalledPacks } from "./pack-manifest";
65
65
  import { resolvePolicyAuthority } from "./policy-authority";
66
66
  import { POLICY_CATALOG } from "./policy-catalog";
67
67
  import { normalizePolicyName } from "./policy-registry";
@@ -167,7 +167,9 @@ export function surveyReviewableCoverage(cwd?: string): ReviewableCoverage {
167
167
  */
168
168
  let reviewers: ReadonlySet<string> | undefined;
169
169
  try {
170
- packsInstalled = hasInstalledPacks();
170
+ // Only a pack with regex policies retires the shim, exactly as handler.ts
171
+ // decides it: a Jev-only pack (FailproofAI/jev-policies) replaces none.
172
+ packsInstalled = hasInstalledRegexPacks();
171
173
  const packs = readInstalledPacks().packs;
172
174
  for (const pack of packs) {
173
175
  const selected = pack.enabled;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Crash-safe writes for configuration files that belong to someone else.
3
+ *
4
+ * Every agent integration edits a file the user's agent reads on startup:
5
+ * `~/.claude/settings.json`, `~/.hermes/config.yaml`, `~/.openclaw/openclaw.json`
6
+ * and the rest. A plain `writeFileSync` truncates that file first and fills it
7
+ * second, so a crash, a full disk or a killed process in between leaves the agent
8
+ * with a half-written config — for Hermes that is a gateway that will not start.
9
+ * And a later install then refuses to touch the file because it no longer parses,
10
+ * so nothing repairs it.
11
+ *
12
+ * So the new content never goes near the live file until it is complete and on
13
+ * disk:
14
+ *
15
+ * 1. write it to a uniquely named temporary file in the SAME directory (a
16
+ * rename is only atomic within one filesystem),
17
+ * 2. fsync that file,
18
+ * 3. copy the current file to `<name>.failproofai-backup` — the last version
19
+ * before failproofai changed it, for a person to restore by hand,
20
+ * 4. rename the temporary file over the live one (atomic on POSIX, and a
21
+ * replacing move on Windows), then fsync the directory so the rename
22
+ * itself survives a power cut.
23
+ *
24
+ * Any failure before step 4 leaves the live file exactly as it was and removes
25
+ * the temporary file. The file keeps its permission bits (Hermes keeps
26
+ * `config.yaml` at 0600), and a config that is a symlink — a dotfiles checkout —
27
+ * is written through to its target rather than replaced by a regular file.
28
+ */
29
+ import {
30
+ chmodSync,
31
+ closeSync,
32
+ copyFileSync,
33
+ existsSync,
34
+ fsyncSync,
35
+ lstatSync,
36
+ mkdirSync,
37
+ openSync,
38
+ realpathSync,
39
+ renameSync,
40
+ rmSync,
41
+ statSync,
42
+ writeSync,
43
+ } from "node:fs";
44
+ import { randomBytes } from "node:crypto";
45
+ import { basename, dirname, join } from "node:path";
46
+
47
+ /** Where the copy of the previous version is kept, beside the file itself. */
48
+ export function configBackupPath(path: string): string {
49
+ return `${resolveWriteTarget(path)}.failproofai-backup`;
50
+ }
51
+
52
+ /** Hooks for tests to simulate an interruption at the one step that matters. */
53
+ export interface AtomicWriteDeps {
54
+ rename?: (from: string, to: string) => void;
55
+ }
56
+
57
+ /** A symlinked config is written through to what it points at. */
58
+ function resolveWriteTarget(path: string): string {
59
+ try {
60
+ if (lstatSync(path).isSymbolicLink()) return realpathSync(path);
61
+ } catch {
62
+ // Absent or unreadable: write to the path as given.
63
+ }
64
+ return path;
65
+ }
66
+
67
+ export function writeConfigFileAtomic(path: string, content: string, deps: AtomicWriteDeps = {}): void {
68
+ const target = resolveWriteTarget(path);
69
+ const dir = dirname(target);
70
+ mkdirSync(dir, { recursive: true });
71
+
72
+ let mode: number | undefined;
73
+ try {
74
+ mode = statSync(target).mode & 0o777;
75
+ } catch {
76
+ mode = undefined; // a new file: the process umask decides, as writeFileSync would
77
+ }
78
+
79
+ const temporary = join(dir, `.${basename(target)}.failproofai-${process.pid}-${randomBytes(4).toString("hex")}.tmp`);
80
+ let fd: number | undefined;
81
+ try {
82
+ fd = openSync(temporary, "wx", mode ?? 0o666);
83
+ const bytes = Buffer.from(content, "utf8");
84
+ let written = 0;
85
+ while (written < bytes.length) written += writeSync(fd, bytes, written, bytes.length - written);
86
+ fsyncSync(fd);
87
+ closeSync(fd);
88
+ fd = undefined;
89
+ // `open` applies the umask; the live file's own bits are what should survive.
90
+ if (mode !== undefined) chmodSync(temporary, mode);
91
+
92
+ if (existsSync(target)) copyFileSync(target, `${target}.failproofai-backup`);
93
+
94
+ (deps.rename ?? renameSync)(temporary, target);
95
+ } catch (err) {
96
+ if (fd !== undefined) {
97
+ try {
98
+ closeSync(fd);
99
+ } catch {
100
+ // already closed or never opened
101
+ }
102
+ }
103
+ rmSync(temporary, { force: true });
104
+ throw err;
105
+ }
106
+
107
+ // Make the rename itself durable. Not supported on every platform (Windows
108
+ // cannot open a directory); the file content is already safe either way.
109
+ try {
110
+ const dirFd = openSync(dir, "r");
111
+ try {
112
+ fsyncSync(dirFd);
113
+ } finally {
114
+ closeSync(dirFd);
115
+ }
116
+ } catch {
117
+ // best effort
118
+ }
119
+ }