failproofai 1.0.13-beta.1 → 1.0.13-beta.2

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 (115) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  54. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0mhtkwwx1pxcq._.js → [root-of-the-server]__19nvo1qfswchs._.js} +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/_0ouu886szh6yj._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/{_0bc4f4quq6w47._.js → _1nvqg4si0splb._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1jwgv0pf-gy96.js → node_modules_html-to-image_es_index_1gk40bvx2bftt.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  82. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  83. package/.next/standalone/.next/server/pages/404.html +1 -1
  84. package/.next/standalone/.next/server/pages/500.html +1 -1
  85. package/.next/standalone/.next/server/preview-props.json +3 -3
  86. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  88. package/.next/standalone/.next/static/chunks/{2n-bzb7pxe7hu.js → 061bksch79zq2.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/{38u_i3j5funpi.js → 1l9lji2he-mbw.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{3vzut6zpdrvtx.js → 1mkf3bas1g5ka.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/{0u5weo5jdn2s8.js → 22gs9785ug-vp.js} +1 -1
  92. package/.next/standalone/.next/static/chunks/{18-icoqze51kl.js → 2f9-xdgd1o3r5.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{2zc7lg-jq353g.js → 2q-6h4rclnu98.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{33djjf12bhcsv.js → 2x-77r0k0xm3b.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{3symuj7t4js-c.js → 2xox4fr0q-ftn.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{0cbom4-ijx_a_.js → 3lgxb73wehfv2.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2d-u90dt4-8rd.js → 44rukkkzacqkl.js} +2 -2
  98. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  99. package/.next/standalone/package.json +9 -9
  100. package/.next/standalone/sdk/python/uv.lock +3 -3
  101. package/.next/standalone/server.js +1 -1
  102. package/dist/cli.mjs +368 -221
  103. package/dist/worker.mjs +1 -1
  104. package/hermes-plugin/plugin.yaml +1 -1
  105. package/openclaw-plugin/index.js +211 -17
  106. package/package.json +9 -9
  107. package/src/hooks/daemon-service.ts +168 -3
  108. package/src/hooks/enforcement-capability.ts +1 -1
  109. package/src/hooks/integrations.ts +2 -5
  110. package/src/hooks/uninstall-cli.ts +3 -0
  111. package/openclaw-plugin/daemon-client.js +0 -254
  112. package/openclaw-plugin/hooks.js +0 -187
  113. /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_buildManifest.js +0 -0
  114. /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_clientMiddlewareManifest.js +0 -0
  115. /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_ssgManifest.js +0 -0
package/dist/worker.mjs CHANGED
@@ -1166,7 +1166,7 @@ function instruct(reason) {
1166
1166
  }
1167
1167
 
1168
1168
  // package.json
1169
- var version = "1.0.13-beta.1";
1169
+ var version = "1.0.13-beta.2";
1170
1170
  var init_package = () => {};
1171
1171
 
1172
1172
  // src/hooks/agent-targets.ts
@@ -1,5 +1,5 @@
1
1
  name: failproofai
2
- version: 1.0.13-beta.1
2
+ version: 1.0.13-beta.2
3
3
  description: Real-time FailproofAI policy enforcement for Hermes
4
4
  manifest_version: 2
5
5
  api_version: 1
@@ -4,36 +4,230 @@
4
4
  * Loaded in-process by the gateway (registered via `plugins.load.paths` +
5
5
  * `plugins.entries.failproofai` in ~/.openclaw/openclaw.json). It subscribes to
6
6
  * OpenClaw's typed PLUGIN hooks — the only surface that can block; OpenClaw's
7
- * file-based "internal hooks" are observation-only — and asks failproofaid for
8
- * each verdict over its Unix socket (daemon-client.js), the way the Hermes
9
- * plugin does. The daemon answers with the flat `{permission, reason}` verdict
10
- * `failproofai --hook <event> --cli openclaw` prints; hooks.js maps it to each
11
- * hook's native return shape. On before_tool_call, an `instruct` verdict uses a
12
- * one-shot blockReason as OpenClaw's model-visible instruction channel; retries
13
- * from the same session/policy are allowed for a short window.
7
+ * file-based "internal hooks" are observation-only — and forwards each to the
8
+ * failproofai binary as `failproofai --hook <event> --cli openclaw`. failproofai
9
+ * prints a flat `{permission, reason}` verdict on stdout; this shim maps it to
10
+ * each hook's native return shape. On before_tool_call, an `instruct` verdict
11
+ * uses a one-shot blockReason as OpenClaw's model-visible instruction channel;
12
+ * retries from the same session/policy are allowed for a short window.
14
13
  *
15
14
  * Marker comment for failproofai's installer detection (do not remove):
16
15
  * __failproofai_hook__: true
17
16
  *
18
- * Design notes:
19
- * • No process per hook, and no path outside this directory. OpenClaw ≥
20
- * 2026.9.6 imports plugins from a copy under ~/.openclaw/tmp/plugin-captures,
21
- * so a CLI located relative to this file is not there; the shim that spawned
22
- * one allowed every call once that started. Every module here imports only
23
- * node built-ins and its siblings.
24
- * • Non-blocking — the gateway is a long-running multi-channel process, so all
25
- * I/O is async; a synchronous call on every hook would stall every channel.
17
+ * Design notes (verified live against openclaw v2026.7.1):
18
+ * • ASYNC spawn, never spawnSync — the gateway is a long-running multi-channel
19
+ * process; a synchronous spawn on every hook would stall every channel.
20
+ * 30s guard timer; fail-open on any error (never block on infra failure).
21
+ * • Raw forwarding — we send the raw tool name / params and a Claude-shaped
22
+ * stdin (params→tool_input, toolName→tool_name, transcriptPath→
23
+ * transcript_path, stopHookActive→stop_hook_active, sessionKey→session_id).
24
+ * The binary canonicalizes via OPENCLAW_* maps (single source of truth); the
25
+ * shim ships NO inline maps (unlike the OpenCode/Pi shims).
26
26
  * • No top-level `await` — OpenClaw's plugin loader rejects it; all async work
27
27
  * happens inside register()'s handlers.
28
+ *
29
+ * Binary resolution mirrors pi-extension/index.ts: prefer the bundled
30
+ * dist/cli.mjs (node-compatible), fall back to bin/failproofai.mjs with bun for
31
+ * dev; honor FAILPROOFAI_BINARY_OVERRIDE. Paths resolve relative to this file
32
+ * via import.meta.url (the gateway spawns plugins with an undefined cwd).
28
33
  */
29
34
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
30
- import { registerFailproofaiHooks } from "./hooks.js";
35
+ import { spawn } from "node:child_process";
36
+ import { existsSync } from "node:fs";
37
+ import { resolve, dirname } from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+ import { createInstructRetryGate, mapBeforeToolVerdict } from "./instruct-retry-gate.js";
40
+ import { createWorkspaceContext } from "./workspace-context.js";
41
+
42
+ const HERE = dirname(fileURLToPath(import.meta.url));
43
+ const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs");
44
+ const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs");
45
+ const instructRetryGate = createInstructRetryGate();
46
+ const workspaceContext = createWorkspaceContext();
47
+
48
+ function resolveSpawn() {
49
+ if (process.env.FAILPROOFAI_BINARY_OVERRIDE) {
50
+ return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] };
51
+ }
52
+ if (existsSync(DIST_BIN)) {
53
+ return { cmd: "node", args: [DIST_BIN] };
54
+ }
55
+ return { cmd: "bun", args: [SRC_BIN] };
56
+ }
57
+
58
+ function debug(msg) {
59
+ if (process.env.FAILPROOFAI_OPENCLAW_DEBUG === "1") {
60
+ process.stderr.write(`[failproofai-openclaw-shim] ${msg}\n`);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Spawn `failproofai --hook <rawEvent> --cli openclaw`, write the JSON payload
66
+ * to stdin, and resolve the parsed flat `{permission, reason}` verdict. Async +
67
+ * fail-open: any spawn/parse error or timeout resolves to `{permission:"allow"}`
68
+ * so a failproofai fault never blocks the gateway.
69
+ */
70
+ function callPolicy(rawEvent, payload) {
71
+ return new Promise((done) => {
72
+ const { cmd, args } = resolveSpawn();
73
+ let child;
74
+ try {
75
+ child = spawn(cmd, [...args, "--hook", rawEvent, "--cli", "openclaw"], {
76
+ stdio: ["pipe", "pipe", "pipe"],
77
+ });
78
+ } catch (err) {
79
+ debug(`spawn threw: ${err && err.message ? err.message : String(err)}`);
80
+ done({ permission: "allow" });
81
+ return;
82
+ }
83
+ let out = "";
84
+ let settled = false;
85
+ const finish = (verdict) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ clearTimeout(timer);
89
+ done(verdict);
90
+ };
91
+ const timer = setTimeout(() => {
92
+ debug(`timeout on ${rawEvent}; killing child`);
93
+ try { child.kill(); } catch { /* ignore */ }
94
+ finish({ permission: "allow" });
95
+ }, 30_000);
96
+ child.stdout.on("data", (d) => { out += d; });
97
+ child.on("error", (err) => {
98
+ debug(`child error: ${err && err.message ? err.message : String(err)}`);
99
+ finish({ permission: "allow" });
100
+ });
101
+ child.on("close", () => {
102
+ const stdout = out.trim();
103
+ if (!stdout) { finish({ permission: "allow" }); return; }
104
+ try {
105
+ finish(JSON.parse(stdout));
106
+ } catch (err) {
107
+ debug(`parse error: ${err && err.message ? err.message : String(err)}`);
108
+ finish({ permission: "allow" });
109
+ }
110
+ });
111
+ try {
112
+ child.stdin.end(JSON.stringify(payload));
113
+ } catch (err) {
114
+ debug(`stdin write failed: ${err && err.message ? err.message : String(err)}`);
115
+ finish({ permission: "allow" });
116
+ }
117
+ });
118
+ }
119
+
120
+ /** Claude-shaped stdin base. Extra keys are ignored by the binary's payload
121
+ * parser; they're forwarded for future use / activity attribution. */
122
+ function baseMeta(payload, ctx, config) {
123
+ const p = payload || {};
124
+ const c = ctx || {};
125
+ return {
126
+ session_id: c.sessionId ?? p.sessionId ?? c.sessionKey ?? p.sessionKey,
127
+ cwd: workspaceContext.resolveWorkspace(p, c, config) ?? process.cwd(),
128
+ transcript_path: p.transcriptPath,
129
+ stop_hook_active: p.stopHookActive === true,
130
+ openclaw: {
131
+ agentId: c.agentId,
132
+ sessionKey: c.sessionKey ?? p.sessionKey,
133
+ runId: c.runId ?? p.runId,
134
+ provider: p.provider,
135
+ model: p.model,
136
+ },
137
+ };
138
+ }
31
139
 
32
140
  export default definePluginEntry({
33
141
  id: "failproofai",
34
142
  name: "failproofai",
35
143
  description: "Real-time policy enforcement for OpenClaw by failproofai",
36
144
  register(api) {
37
- registerFailproofaiHooks(api);
145
+ // before_tool_call → PreToolUse. Deny always blocks. Instruct blocks the
146
+ // first matching attempt only, using blockReason to give the model the
147
+ // instruction, then permits retries from that session/policy for 5 minutes.
148
+ api.on(
149
+ "before_tool_call",
150
+ async (payload, ctx) => {
151
+ const p = payload || {};
152
+ const verdict = await callPolicy("before_tool_call", {
153
+ ...baseMeta(payload, ctx, api.config),
154
+ tool_name: p.toolName,
155
+ tool_input: p.params,
156
+ hook_event_name: "before_tool_call",
157
+ });
158
+ return mapBeforeToolVerdict(verdict, payload, ctx, instructRetryGate);
159
+ },
160
+ { priority: 100, timeoutMs: 60_000 },
161
+ );
162
+
163
+ // before_agent_run → UserPromptSubmit. Deny: return {outcome:"block", reason}.
164
+ api.on(
165
+ "before_agent_run",
166
+ async (payload, ctx) => {
167
+ const p = payload || {};
168
+ workspaceContext.remember(payload, ctx);
169
+ const verdict = await callPolicy("before_agent_run", {
170
+ ...baseMeta(payload, ctx, api.config),
171
+ prompt: p.prompt,
172
+ hook_event_name: "before_agent_run",
173
+ });
174
+ if (verdict.permission === "deny") {
175
+ return { outcome: "block", reason: verdict.reason || "Blocked by failproofai" };
176
+ }
177
+ return undefined;
178
+ },
179
+ { priority: 100, timeoutMs: 60_000 },
180
+ );
181
+
182
+ // before_agent_finalize → Stop. The real turn-end gate: a deny becomes a
183
+ // revise so the agent runs another pass. The evaluator supplies the
184
+ // MANDATORY ACTION wording; the payload's stop_hook_active guards the
185
+ // require-*-before-stop builtins against infinite loops.
186
+ api.on(
187
+ "before_agent_finalize",
188
+ async (payload, ctx) => {
189
+ const verdict = await callPolicy("before_agent_finalize", {
190
+ ...baseMeta(payload, ctx, api.config),
191
+ hook_event_name: "before_agent_finalize",
192
+ });
193
+ if (verdict.permission === "deny") {
194
+ return { action: "revise", reason: verdict.reason || "Action required by failproofai" };
195
+ }
196
+ return undefined;
197
+ },
198
+ { priority: 100, timeoutMs: 60_000 },
199
+ );
200
+
201
+ // Observation hooks — await so activity ordering is stable, ignore verdict.
202
+ // after_tool_call → PostToolUse; session_start/end → SessionStart/End;
203
+ // subagent_ended → SubagentStop (obs only); before_compaction → PreCompact.
204
+ for (const ev of [
205
+ "after_tool_call",
206
+ "session_start",
207
+ "session_end",
208
+ "subagent_ended",
209
+ "before_compaction",
210
+ ]) {
211
+ api.on(
212
+ ev,
213
+ async (payload, ctx) => {
214
+ const p = payload || {};
215
+ await callPolicy(ev, {
216
+ ...baseMeta(payload, ctx, api.config),
217
+ tool_name: p.toolName,
218
+ tool_input: p.params,
219
+ tool_response: p.result,
220
+ reason: p.reason,
221
+ hook_event_name: ev,
222
+ });
223
+ if (ev === "session_end") {
224
+ instructRetryGate.clear(payload, ctx);
225
+ workspaceContext.clear(payload, ctx);
226
+ }
227
+ return undefined;
228
+ },
229
+ { priority: 100, timeoutMs: 60_000 },
230
+ );
231
+ }
38
232
  },
39
233
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.13-beta.1",
3
+ "version": "1.0.13-beta.2",
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",
@@ -126,15 +126,15 @@
126
126
  "browserslist": "4.28.8"
127
127
  },
128
128
  "optionalDependencies": {
129
- "@failproofai/failproofaid-linux-x64": "1.0.13-beta.1",
130
- "@failproofai/failproofaid-linux-arm64": "1.0.13-beta.1",
131
- "@failproofai/failproofaid-darwin-x64": "1.0.13-beta.1",
132
- "@failproofai/failproofaid-darwin-arm64": "1.0.13-beta.1"
129
+ "@failproofai/failproofaid-linux-x64": "1.0.13-beta.2",
130
+ "@failproofai/failproofaid-linux-arm64": "1.0.13-beta.2",
131
+ "@failproofai/failproofaid-darwin-x64": "1.0.13-beta.2",
132
+ "@failproofai/failproofaid-darwin-arm64": "1.0.13-beta.2"
133
133
  },
134
134
  "failproofaidBinaries": {
135
- "linux-x64": "d257e616d9f5a7ab76ceabbc7ac918e29555034458468697929fb6d109e35f3f",
136
- "linux-arm64": "583d8e0c5cdae34bb0b2083fd1c8448d06a927ad4ca9ee6978127b54eecb27a0",
137
- "darwin-x64": "68943a0b03c021f693f3c5f57acfc66d096bfd8bc7be57abff9ef826508de11a",
138
- "darwin-arm64": "49fa29ad396b85dccac135cf44ceaeb83efd3934471d86160f75b081fae04eae"
135
+ "linux-x64": "8301567948ddf752b6db30ed5998e62139df4c7e1669b280159a83a9b951b94c",
136
+ "linux-arm64": "ad53ec24d7c09b2dbc07b792c0739dc1e5f443fdb9a795174d3a40453f90bbcf",
137
+ "darwin-x64": "e4e65a1071a5793bafb73625dc49d211ff19d7e0d8c03c804959d1357aebea1e",
138
+ "darwin-arm64": "78aa90272e2ace07ac3648a419b961f8d8bfe56c7fd3a0193d3579085d77e9cb"
139
139
  }
140
140
  }
@@ -13,12 +13,14 @@ import {
13
13
  mkdirSync,
14
14
  mkdtempSync,
15
15
  readFileSync,
16
+ readdirSync,
17
+ statSync,
16
18
  writeFileSync,
17
19
  unlinkSync,
18
20
  rmSync,
19
21
  } from "node:fs";
20
22
  import { homedir, tmpdir, userInfo } from "node:os";
21
- import { resolve } from "node:path";
23
+ import { dirname, resolve } from "node:path";
22
24
  import { execFileSync } from "node:child_process";
23
25
  import { hookLogWarn } from "./hook-logger";
24
26
  import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download";
@@ -413,6 +415,142 @@ function systemdUnitPath(user: string = serviceUser()): string {
413
415
  return resolve(systemdUnitDir(), systemdUnitName(user));
414
416
  }
415
417
 
418
+ /**
419
+ * Where a Linux install puts the copy of the daemon that systemd actually runs:
420
+ * `/opt/failproofai/<user>/bin/failproofaid-<version>`, root-owned.
421
+ *
422
+ * The binary is downloaded into `~/.failproofai/bin`, and a unit whose
423
+ * `ExecStart` points there works on Debian, Ubuntu and Mint — and on Fedora,
424
+ * RHEL, Rocky and Alma it never starts. Those ship SELinux enforcing, a file
425
+ * under a home directory is labelled `user_home_t`, and the targeted policy
426
+ * gives systemd (`init_t`) no execute permission on that type at all. The unit
427
+ * fails `203/EXEC Permission denied`, `waitForDaemonRunning()` times out, and
428
+ * `failproofai config` aborts at its very last step.
429
+ *
430
+ * The policy's generic `/opt/(.*\/)?bin(/.*)?` rule labels this path `bin_t`,
431
+ * which systemd may execute, and executing it moves the daemon into
432
+ * `unconfined_service_t` — the domain that may in turn run the worker's node
433
+ * from `~/.nvm` (also `user_home_t`) and accept hooks on the socket. So only
434
+ * the daemon binary moves; the worker command, the socket and the state under
435
+ * `~/.failproofai` stay where they are. Not `/usr/local/libexec`: both
436
+ * policies label that `usr_t`, which starts the daemon only through a
437
+ * secondary `init_t usr_t → unconfined_service_t` rule, where `bin_t` is the
438
+ * type the policy means for programs. Not `/usr/libexec` either: `/usr` is the
439
+ * package manager's, and read-only on Silverblue and CoreOS. All checked
440
+ * against the shipped policy on Fedora 41 and Rocky 9 with `matchpathcon` and
441
+ * `sesearch`.
442
+ *
443
+ * Per user for the same reason the unit name is: two people on one box may run
444
+ * different versions, and one's uninstall must not delete the other's binary.
445
+ * The `~/.failproofai/bin` copy is kept — `daemonVersionSkew()` and
446
+ * `refreshDaemonToCliVersion()` read the installed version from it.
447
+ *
448
+ * Redirectable for tests ONLY, like the unit directory.
449
+ */
450
+ function systemBinaryRoot(user: string = serviceUser()): string {
451
+ return resolve(process.env.FAILPROOFAI_SYSTEM_BIN_DIR || "/opt/failproofai", user);
452
+ }
453
+
454
+ function systemBinaryDir(): string {
455
+ return resolve(systemBinaryRoot(), "bin");
456
+ }
457
+
458
+ /** The system binary directory on Linux, for the manual-removal instructions. */
459
+ export function daemonSystemBinaryDir(): string | null {
460
+ return process.platform === "linux" ? systemBinaryRoot() : null;
461
+ }
462
+
463
+ /**
464
+ * The path systemd should start for `binaryPath`, or null when it should start
465
+ * `binaryPath` itself.
466
+ *
467
+ * Only a real file is copied. `FAILPROOFAI_DAEMON_BINARY` is "someone named a
468
+ * binary explicitly" and may be a command with arguments (this repo's own
469
+ * systemd tests use `/usr/bin/sleep infinity`), so it is started as given.
470
+ */
471
+ export function systemBinaryTarget(binaryPath: string): string | null {
472
+ if (process.platform !== "linux" || process.env.FAILPROOFAI_DAEMON_BINARY) return null;
473
+ try {
474
+ if (!statSync(binaryPath).isFile()) return null;
475
+ } catch {
476
+ return null;
477
+ }
478
+ return resolve(systemBinaryDir(), `failproofaid-${version}`);
479
+ }
480
+
481
+ /**
482
+ * Whether this kernel has SELinux enabled. `/sys/fs/selinux` is mounted only
483
+ * when it is, enforcing or permissive.
484
+ */
485
+ function selinuxEnabled(): boolean {
486
+ return existsSync("/sys/fs/selinux/enforce");
487
+ }
488
+
489
+ /**
490
+ * Copies the daemon binary into the system directory and returns the path the
491
+ * unit should start. Privileged; throws when the copy fails, which the caller
492
+ * reports as a failed install before any unit is written.
493
+ *
494
+ * Staged under a temp name and renamed into place, so a reinstall of the same
495
+ * version never writes into the file a running daemon was started from.
496
+ *
497
+ * `restorecon` afterwards, because a new file inherits the label of the
498
+ * directory it is created in, not the one the policy assigns to its path: the
499
+ * directories `install -d` creates under `/opt` (`usr_t`) come out `usr_t`
500
+ * too, never the `bin_t` the policy names for them.
501
+ */
502
+ function stageSystemBinary(binaryPath: string): string {
503
+ const target = systemBinaryTarget(binaryPath);
504
+ if (!target) return binaryPath;
505
+ const dir = systemBinaryDir();
506
+ const staging = `${target}.${process.pid}.tmp`;
507
+ runPrivileged("install", ["-d", "-m", "0755", dir]);
508
+ try {
509
+ runPrivileged("install", ["-m", "0755", binaryPath, staging]);
510
+ runPrivileged("mv", ["-f", staging, target]);
511
+ } catch (err) {
512
+ try {
513
+ runPrivileged("rm", ["-f", staging]);
514
+ } catch {
515
+ /* best-effort */
516
+ }
517
+ throw err;
518
+ }
519
+ if (selinuxEnabled()) {
520
+ const root = systemBinaryRoot();
521
+ try {
522
+ runPrivileged("restorecon", ["-R", root]);
523
+ } catch (err) {
524
+ // Not fatal here: a wrong label still surfaces as the daemon not
525
+ // reaching a running state, which fails the install with its status.
526
+ hookLogWarn(`restorecon on ${root} failed: ${err instanceof Error ? err.message : String(err)}`);
527
+ }
528
+ }
529
+ return target;
530
+ }
531
+
532
+ /**
533
+ * Removes system copies other than `current` and the most recent one before
534
+ * it — the same keep-one-for-rollback rule `pruneOldDaemonBinaries()` applies
535
+ * to `~/.failproofai/bin`. Best-effort; only called once the service runs
536
+ * `current`.
537
+ */
538
+ function pruneSystemBinaries(current: string): void {
539
+ try {
540
+ const dir = systemBinaryDir();
541
+ const stale = readdirSync(dir)
542
+ .filter((f) => f.startsWith("failproofaid-"))
543
+ .map((f) => resolve(dir, f))
544
+ .filter((p) => p !== current)
545
+ .map((p) => ({ p, mtime: statSync(p).mtimeMs }))
546
+ .sort((a, b) => b.mtime - a.mtime)
547
+ .slice(1);
548
+ for (const { p } of stale) runPrivileged("rm", ["-f", p]);
549
+ } catch {
550
+ // Nothing to prune, or no elevation left — leaving a file costs disk only.
551
+ }
552
+ }
553
+
416
554
  /**
417
555
  * The pre-1.0.0-beta.1 user-scope unit. Still removed on install and
418
556
  * uninstall: it holds the same flock the new service needs, so leaving one
@@ -792,8 +930,18 @@ function daemonInstallCommands(
792
930
  cliCmd: string | null,
793
931
  ): string[] {
794
932
  if (process.platform === "linux") {
933
+ const target = systemBinaryTarget(binaryPath);
934
+ const dir = systemBinaryDir();
935
+ const stage = target
936
+ ? [
937
+ `sudo install -d -m 0755 ${dir}`,
938
+ `sudo install -m 0755 ${binaryPath} ${target}`,
939
+ `{ sudo restorecon -R ${systemBinaryRoot()} 2>/dev/null || true; }`,
940
+ ]
941
+ : [];
795
942
  return [
796
- `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd, cliCmd)}EOF`,
943
+ ...stage,
944
+ `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(target ?? binaryPath, workerCmd, cliCmd)}EOF`,
797
945
  "sudo systemctl daemon-reload",
798
946
  `sudo systemctl enable ${systemdUnitName()}`,
799
947
  // `restart`, not `enable --now`, for the reason the install path gives:
@@ -865,9 +1013,13 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
865
1013
  // never came up. Clear it first, every time.
866
1014
  removeLegacyUserService();
867
1015
 
1016
+ let execPath = binaryPath;
868
1017
  try {
869
1018
  if (process.platform === "linux") {
870
- writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd, cliCmd));
1019
+ // Before the unit is written, so a copy that fails leaves the previous
1020
+ // unit and the daemon it runs untouched. See `systemBinaryDir()`.
1021
+ execPath = stageSystemBinary(binaryPath);
1022
+ writePrivilegedFile(systemdUnitPath(), systemdUnitContents(execPath, workerCmd, cliCmd));
871
1023
  runPrivileged("systemctl", ["daemon-reload"]);
872
1024
  // `enable` for boot persistence, then `restart` — NOT `enable --now`.
873
1025
  //
@@ -927,6 +1079,7 @@ export async function installDaemonService(): Promise<DaemonInstallResult> {
927
1079
  hookLogWarn(`daemon service install failed: ${reason}`);
928
1080
  return { installed: false, reason };
929
1081
  }
1082
+ if (execPath !== binaryPath) pruneSystemBinaries(execPath);
930
1083
 
931
1084
  // NOTE: this reports that the SERVICE is installed and running, which is all
932
1085
  // it can honestly claim. Whether the daemon can actually evaluate a hook is a
@@ -1683,6 +1836,18 @@ export async function uninstallDaemonService(): Promise<void> {
1683
1836
  } catch {
1684
1837
  // Best-effort.
1685
1838
  }
1839
+ // The copy systemd ran. After the unit is gone, so nothing enabled is
1840
+ // left pointing at a deleted file. The shared parent goes only if no
1841
+ // other user's directory is in it.
1842
+ const binRoot = systemBinaryRoot();
1843
+ if (existsSync(binRoot)) {
1844
+ runPrivileged("rm", ["-rf", binRoot]);
1845
+ try {
1846
+ runPrivileged("rmdir", [dirname(binRoot)]);
1847
+ } catch {
1848
+ // Not empty — another user's daemon lives there.
1849
+ }
1850
+ }
1686
1851
  } else {
1687
1852
  const plistPath = launchdPlistPath();
1688
1853
  if (!existsSync(plistPath)) return;
@@ -182,7 +182,7 @@ export const ENFORCEMENT_CAPABILITY: Record<
182
182
 
183
183
  // ── openclaw ──────────────────────────────────────────────────────────────
184
184
  // openclaw/openclaw @f8ed8ecf (v2026.7.2) + our own shipped shim
185
- // openclaw-plugin/hooks.js, which returns undefined for 5 of the 8 hooks.
185
+ // openclaw-plugin/index.js, which returns undefined for 5 of the 8 hooks.
186
186
  openclaw: {
187
187
  PreToolUse: "block", // before_tool_call -> shim returns {block:true, blockReason}
188
188
  UserPromptSubmit: "block", // before_agent_run -> shim returns {outcome:"block", reason}
@@ -2277,10 +2277,8 @@ export function unhookedHermesProfiles(): string[] {
2277
2277
  // OpenClaw is a self-hosted assistant gateway. Enforcement is via its in-process
2278
2278
  // PLUGIN hooks (file-based "internal hooks" are observation-only), so failproofai
2279
2279
  // ships a static plugin package (`openclaw-plugin/`, like Pi's pi-extension) that
2280
- // asks failproofaid for each verdict over its Unix socket — like the Hermes plugin,
2281
- // and so likewise daemon-only — and maps verdicts back. It cannot spawn the CLI:
2282
- // OpenClaw ≥ 2026.9.6 loads plugins from a copy that holds only the plugin dir.
2283
- // Install registers the plugin in every profile's `openclaw.json` (JSON):
2280
+ // async-spawns the binary and maps verdicts back. Install registers the plugin in
2281
+ // every profile's `openclaw.json` (JSON):
2284
2282
  // • plugins.load.paths[] → the shipped openclaw-plugin dir (absolute path)
2285
2283
  // • plugins.entries.failproofai = { enabled: true, hooks: { allowConversationAccess: true } }
2286
2284
  // (allowConversationAccess is required for the raw-conversation hooks
@@ -2320,7 +2318,6 @@ function isFailproofaiOpenClawPath(p: unknown): boolean {
2320
2318
  export const openclaw: Integration = {
2321
2319
  id: "openclaw",
2322
2320
  displayName: "OpenClaw",
2323
- requiresHealthyDaemon: true,
2324
2321
  scopes: OPENCLAW_HOOK_SCOPES,
2325
2322
  eventTypes: OPENCLAW_HOOK_EVENT_TYPES,
2326
2323
 
@@ -39,6 +39,7 @@ import {
39
39
  daemonServiceFilePath,
40
40
  daemonServiceStatus,
41
41
  daemonStatusCommand,
42
+ daemonSystemBinaryDir,
42
43
  isDaemonSupportedPlatform,
43
44
  primeElevation,
44
45
  setDaemonConfigured,
@@ -417,10 +418,12 @@ function manualServiceRemoval(servicePath: string): string[] {
417
418
  return [`sudo launchctl unload -w ${servicePath}`, `sudo rm -f ${servicePath}`];
418
419
  }
419
420
  const unit = servicePath.split("/").pop() ?? servicePath;
421
+ const binDir = daemonSystemBinaryDir();
420
422
  return [
421
423
  `sudo systemctl disable --now ${unit}`,
422
424
  `sudo rm -f ${servicePath}`,
423
425
  `sudo systemctl daemon-reload`,
426
+ ...(binDir ? [`sudo rm -rf ${binDir}`] : []),
424
427
  ];
425
428
  }
426
429