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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0mhtkwwx1pxcq._.js → [root-of-the-server]__19nvo1qfswchs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0ouu886szh6yj._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0bc4f4quq6w47._.js → _1nvqg4si0splb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
- 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
- package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
- package/.next/standalone/.next/static/chunks/{2n-bzb7pxe7hu.js → 061bksch79zq2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{38u_i3j5funpi.js → 1l9lji2he-mbw.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vzut6zpdrvtx.js → 1mkf3bas1g5ka.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0u5weo5jdn2s8.js → 22gs9785ug-vp.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18-icoqze51kl.js → 2f9-xdgd1o3r5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2zc7lg-jq353g.js → 2q-6h4rclnu98.js} +1 -1
- package/.next/standalone/.next/static/chunks/{33djjf12bhcsv.js → 2x-77r0k0xm3b.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3symuj7t4js-c.js → 2xox4fr0q-ftn.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0cbom4-ijx_a_.js → 3lgxb73wehfv2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2d-u90dt4-8rd.js → 44rukkkzacqkl.js} +2 -2
- package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/sdk/python/uv.lock +3 -3
- package/.next/standalone/server.js +1 -1
- package/dist/cli.mjs +368 -221
- package/dist/worker.mjs +1 -1
- package/hermes-plugin/plugin.yaml +1 -1
- package/openclaw-plugin/index.js +211 -17
- package/package.json +9 -9
- package/src/hooks/daemon-service.ts +168 -3
- package/src/hooks/enforcement-capability.ts +1 -1
- package/src/hooks/integrations.ts +2 -5
- package/src/hooks/uninstall-cli.ts +3 -0
- package/openclaw-plugin/daemon-client.js +0 -254
- package/openclaw-plugin/hooks.js +0 -187
- /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{1n2dyfTQb0MDajeqlipJy → TdOlUedz_AHC-0Ops67a_}/_ssgManifest.js +0 -0
package/dist/worker.mjs
CHANGED
package/openclaw-plugin/index.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
* •
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
130
|
-
"@failproofai/failproofaid-linux-arm64": "1.0.13-beta.
|
|
131
|
-
"@failproofai/failproofaid-darwin-x64": "1.0.13-beta.
|
|
132
|
-
"@failproofai/failproofaid-darwin-arm64": "1.0.13-beta.
|
|
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": "
|
|
136
|
-
"linux-arm64": "
|
|
137
|
-
"darwin-x64": "
|
|
138
|
-
"darwin-arm64": "
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
//
|
|
2281
|
-
//
|
|
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
|
|