diff --git a/CHANGELOG.md b/CHANGELOG.md index 1277f9307..6c32d36b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 1.0.13 — 2026-10-28 + +The stable release of OpenClaw enforcement on OpenClaw 2026.9.6 and later, and of daemon setup on Fedora, RHEL, Rocky and Alma. Everything below in the `1.0.13-beta.*` sections ships here too. + +### Fixes + +- `failproofai config` no longer fails at its last step on Fedora, RHEL, Rocky and Alma. Their SELinux policy does not let systemd execute files in a home directory, so the daemon's unit, which started `~/.failproofai/bin/failproofaid-`, failed `203/EXEC` and never reached a running state. On Linux the install now copies the binary as root to `/opt/failproofai//bin/`, which the policy labels `bin_t`, runs `restorecon` on it, and points `ExecStart` there; uninstall removes the copy, and `update` and `config` move existing installs over (#957) +- The commands `config` and `uninstall` print to run as root when sudo is not available now quote every path, so a home directory with a space in it no longer stops the install recipe before the service is written, and the removal recipe's `rm -rf` can no longer be pointed at the wrong path (#958) + +## 1.0.13-beta.1 — 2026-10-28 + +### Fixes + +- **OpenClaw enforcement works again on OpenClaw 2026.9.6 and later.** Those releases import plugins from a copy under `~/.openclaw/tmp/plugin-captures/` that holds only the plugin's own directory, so the plugin's CLI path, worked out relative to its own file (`../dist/cli.mjs`), pointed at nothing; it read the failed spawn as an allow, so every OpenClaw tool call ran unchecked and no hook activity was recorded, while session telemetry kept flowing. The plugin now asks `failproofaid` for each verdict over its Unix socket, as the Hermes plugin does, sending the request `failproofai --hook … --cli openclaw` sends, so policies and verdicts are unchanged. Like Hermes it now requires a running daemon: install refuses without one, and when no verdict comes back tool calls and prompts are blocked with the reason logged instead of being allowed. Restart the OpenClaw gateway after updating so it loads the new plugin (#956) + +### Dependencies + +- Bump `nltk` 3.10.3 → 3.10.4 in `sdk/python/uv.lock`, the SDK's dev/test lockfile (transitive via `llama-index-core`), for GHSA-j456-xh4h-cpf2, published 2026-10-28, which turned OSV-Scanner red on every pull request. The SDK itself ships with zero runtime dependencies (#956) + ## 1.0.13-beta.0 — 2026-10-27 ### Features diff --git a/CLAUDE.md b/CLAUDE.md index b09272271..41697ba92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -438,8 +438,8 @@ OpenClaw has two extension surfaces: file-based **internal hooks** **plugin hooks** (`api.on(name, handler, {priority, timeoutMs})`) which have block/cancel semantics. So — like OpenCode — failproofai ships a **plugin** (`openclaw-plugin/`, a static package like `pi-extension/`, shipped in the npm -tarball via `package.json` "files") that async-spawns the binary and translates -the verdict. The install registers it in `~/.openclaw/openclaw.json` (JSON): +tarball via `package.json` "files") that asks `failproofaid` for each verdict and +translates it. The install registers it in `~/.openclaw/openclaw.json` (JSON): - `plugins.load.paths[]` ← the shipped `openclaw-plugin/` dir (absolute path) - `plugins.entries.failproofai = { enabled: true, hooks: { allowConversationAccess: true } }` @@ -450,15 +450,29 @@ the verdict. The install registers it in `~/.openclaw/openclaw.json` (JSON): disabled by default), so `getSettingsPath` ignores scope/cwd. Uninstall only edits `openclaw.json`; it **never deletes the shipped plugin dir**. -**Async spawn, never `spawnSync`.** OpenClaw is a long-running multi-channel -gateway; a sync spawn on every hook would stall every channel. The shim -(`openclaw-plugin/index.js`) uses async `spawn` + a 30s guard and **fails open** -(any spawn/parse/timeout error → allow). It carries **no inline tool maps** -(unlike the OpenCode/Pi shims) — it forwards raw event/tool names and a -Claude-shaped stdin (`params→tool_input`, `toolName→tool_name`, -`transcriptPath→transcript_path`, `stopHookActive→stop_hook_active`, -`sessionKey→session_id`), and the binary canonicalizes via the `OPENCLAW_*` -maps in `types.ts` (single source of truth). +**The plugin talks to the daemon's socket; it never spawns the CLI.** OpenClaw ≥ +2026.9.6 imports plugins from a **captured copy** +(`~/.openclaw/tmp/plugin-captures//…/node_modules/openclaw-plugin/`) holding only +the plugin directory's files, so a CLI path derived from `import.meta.url` +(`../dist/cli.mjs`) points at nothing there. The old shim spawned exactly that and +read the spawn failure as allow — every tool call ran unchecked and no hook activity +was recorded (verified live on 2026.10.5). So `openclaw-plugin/daemon-client.js` +sends the same `hook` request `failproofai --hook --cli openclaw` sends +(crates/PROTOCOL.md: `hookEvent` = the raw OpenClaw event, `cli: "openclaw"`, the +payload as `stdin`, the gateway profile's `openclaw.json` as `agentSettingsPath`) +over the owner-only socket, like the Hermes plugin, and reads the same flat stdout +verdict back. Every module in `openclaw-plugin/` imports only node built-ins and +its siblings (a test copies the directory and loads it from the copy). Like Hermes +it is **daemon-only** (`requiresHealthyDaemon`): install refuses without a daemon, +and when no verdict comes back `before_tool_call` and `before_agent_run` **fail +closed** (blocked, reason logged through `api.logger`) while `before_agent_finalize` +does not force a revise — that cannot bring the daemon back and would only loop the +turn. All I/O is async: the gateway is a long-running multi-channel process. It +carries **no inline tool maps** (unlike the OpenCode/Pi shims) — it forwards raw +event/tool names and a Claude-shaped stdin (`params→tool_input`, +`toolName→tool_name`, `transcriptPath→transcript_path`, +`stopHookActive→stop_hook_active`, `sessionKey→session_id`), and the worker +canonicalizes via the `OPENCLAW_*` maps in `types.ts` (single source of truth). **Verdict mapping** (`policy-evaluator.ts` emits a flat Pi-style `{permission, reason}`; the shim maps per-hook): @@ -926,10 +940,19 @@ Three consequences, all handled explicitly rather than assumed: and uninstall. It holds the same flock the new service needs, so leaving one behind means the system unit starts, loses the singleton race, and the machine sits fail-closed against a daemon that never came up. +- **On Linux, systemd never starts a binary inside a home directory.** Fedora, RHEL, Rocky + and Alma run SELinux enforcing; a file under `/home` is `user_home_t`, and the targeted + policy gives systemd (`init_t`) no execute permission on it, so a unit pointing into + `~/.failproofai/bin` fails `203/EXEC` and `failproofai config` aborts at its last step. + `stageSystemBinary()` copies the binary as root to + `/opt/failproofai//bin/failproofaid-` (labelled `bin_t` by the policy's + generic `/opt/(.*/)?bin` rule, re-applied with `restorecon`) and `ExecStart` points + there. The `~/.failproofai/bin` copy stays — version checks read it. Uninstall removes + the `/opt` copy; install prunes it to the current version plus one. macOS is unchanged. The unit is named per user (`failproofaid@alice`) so a second person on the same box cannot silently steal the first's service — every field in it is user-specific anyway (ExecStart -under that user's `~/.failproofai/bin`, HOME, the worker command). Reading status needs no +under that user's `/opt/failproofai//bin`, HOME, the worker command). Reading status needs no privileges: `systemctl status failproofaid@`, exposed as `daemonStatusCommand()`. ### How the daemon binary reaches users @@ -959,7 +982,9 @@ the CLI talking to it. Both channels land the file at `~/.failproofai/bin/failproofaid-` through the same `installBinaryBytes()` — atomic rename, mode 0755, versioned filename (which avoids `ETXTBSY` against a running daemon and stops an upgrade from repointing a live service unit -at a binary built from different source). **`ExecStart` never points into `node_modules`**: +at a binary built from different source). On Linux the unit then runs a root-owned copy +under `/opt/failproofai//bin` (see "How the daemon is supervised" — SELinux). +**`ExecStart` never points into `node_modules`**: an `npm i -g failproofai@next` would silently swap the file under a running service, and an uninstall would delete it out from under an enabled unit that then crash-loops at every boot. diff --git a/Cargo.lock b/Cargo.lock index 5fd2dd1ce..c65ec26bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,7 @@ dependencies = [ [[package]] name = "failproofaid" -version = "1.0.13-beta.0" +version = "1.0.13" dependencies = [ "fpai-collect", "fpai-ipc", @@ -361,7 +361,7 @@ dependencies = [ [[package]] name = "fpai-collect" -version = "1.0.13-beta.0" +version = "1.0.13" dependencies = [ "axum", "getrandom 0.4.3", @@ -382,7 +382,7 @@ dependencies = [ [[package]] name = "fpai-ipc" -version = "1.0.13-beta.0" +version = "1.0.13" dependencies = [ "libc", "proptest", diff --git a/Cargo.toml b/Cargo.toml index b849a4d33..77e10961c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "1.0.13-beta.0" +version = "1.0.13" edition = "2024" license-file = "LICENSE" repository = "https://github.com/FailproofAI/failproofai" diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts index 4b6eb3f85..1f115c941 100644 --- a/__tests__/hooks/daemon-service.test.ts +++ b/__tests__/hooks/daemon-service.test.ts @@ -1,12 +1,13 @@ // @vitest-environment node import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir, userInfo } from "node:os"; import { dirname, resolve } from "node:path"; import { binDir } from "../../src/hooks/fp-home"; import { startedAtFromMonotonic, waitForDaemonRunning } from "../../src/hooks/daemon-service"; import * as svc from "../../src/hooks/daemon-service"; +import { version as pkgVersion } from "../../package.json"; vi.mock("../../src/hooks/hook-logger", () => ({ hookLogWarn: vi.fn(), @@ -441,6 +442,117 @@ describe("hooks/daemon-service", () => { ); }); + describe("system binary location (SELinux)", () => { + // Fedora/RHEL: systemd may not execute a `user_home_t` file, so a unit + // pointing into ~/.failproofai/bin fails 203/EXEC. The unit must start a + // root-owned copy under /opt/failproofai//bin instead. + function writeManagedBinary(): string { + process.env.FAILPROOFAI_SYSTEM_BIN_DIR = resolve(home, "opt"); + const path = resolve(binDir(), `failproofaid-${pkgVersion}`); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "#!/bin/sh\n", { mode: 0o755 }); + return path; + } + afterEach(() => { + delete process.env.FAILPROOFAI_SYSTEM_BIN_DIR; + }); + + it("starts a per-user, versioned copy outside the home directory on linux", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + useScratchHome(); + const path = writeManagedBinary(); + setPlatform("linux"); + const { systemBinaryTarget, daemonSystemBinaryDir } = await import("../../src/hooks/daemon-service"); + const root = resolve(home, "opt", userInfo().username); + expect(daemonSystemBinaryDir()).toBe(root); + expect(systemBinaryTarget(path)).toBe(resolve(root, "bin", `failproofaid-${pkgVersion}`)); + }); + + it("defaults to a bin/ under /opt, which SELinux labels bin_t on every RHEL-family policy", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_SYSTEM_BIN_DIR; + useScratchHome(); + const path = resolve(binDir(), `failproofaid-${pkgVersion}`); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "#!/bin/sh\n", { mode: 0o755 }); + setPlatform("linux"); + const { daemonSystemBinaryDir, systemBinaryTarget } = await import("../../src/hooks/daemon-service"); + expect(daemonSystemBinaryDir()).toBe(`/opt/failproofai/${userInfo().username}`); + expect(systemBinaryTarget(path)).toBe(`/opt/failproofai/${userInfo().username}/bin/failproofaid-${pkgVersion}`); + }); + + it("starts an explicitly named binary as given — it may carry arguments", async () => { + useScratchHome(); + const path = writeManagedBinary(); + process.env.FAILPROOFAI_DAEMON_BINARY = path; + setPlatform("linux"); + const { systemBinaryTarget } = await import("../../src/hooks/daemon-service"); + expect(systemBinaryTarget(path)).toBeNull(); + expect(systemBinaryTarget("/usr/bin/sleep infinity")).toBeNull(); + }); + + it("copies nothing for a path that is not a file", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + useScratchHome(); + writeManagedBinary(); + setPlatform("linux"); + const { systemBinaryTarget } = await import("../../src/hooks/daemon-service"); + expect(systemBinaryTarget(resolve(home, "missing"))).toBeNull(); + expect(systemBinaryTarget(home)).toBeNull(); + }); + + // Meaningless as root, where elevation always succeeds. + it.skipIf(typeof process.getuid === "function" && process.getuid() === 0)( + "quotes every path in the manual recipe, so a home directory with a space still installs", + async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + useScratchHome(); + // The home itself holds the space: that is where the managed binary lives. + process.env.HOME = resolve(home, "first last"); + mkdirSync(process.env.HOME, { recursive: true }); + process.env.FAILPROOFAI_SYSTEM_BIN_DIR = resolve(home, "op t"); + const path = resolve(binDir(), `failproofaid-${pkgVersion}`); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "#!/bin/sh\n", { mode: 0o755 }); + setPlatform("linux"); + vi.doMock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync: (cmd: string) => { + if (cmd === "sudo") throw new Error("sudo: a password is required"); + throw new Error(`nothing else should run before elevation succeeds, but got: ${cmd}`); + }, + })); + try { + vi.resetModules(); + const { installDaemonService, systemBinaryTarget, daemonSystemBinaryDir } = await import( + "../../src/hooks/daemon-service" + ); + const target = systemBinaryTarget(path)!; + const result = await installDaemonService(); + + expect(result.installed).toBe(false); + expect(path).toContain(" "); + expect(result.reason).toContain(`sudo install -d -m 0755 '${dirname(target)}'`); + expect(result.reason).toContain(`sudo install -m 0755 '${path}' '${target}'`); + expect(result.reason).toContain(`sudo restorecon -R '${daemonSystemBinaryDir()}'`); + } finally { + vi.doUnmock("node:child_process"); + vi.resetModules(); + } + }, + ); + + it("leaves macOS alone — launchd has no SELinux", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + useScratchHome(); + const path = writeManagedBinary(); + setPlatform("darwin"); + const { systemBinaryTarget, daemonSystemBinaryDir } = await import("../../src/hooks/daemon-service"); + expect(systemBinaryTarget(path)).toBeNull(); + expect(daemonSystemBinaryDir()).toBeNull(); + }); + }); + describe("daemonServiceStatus", () => { it("is unsupported-platform on win32", async () => { setPlatform("win32"); @@ -871,6 +983,50 @@ describe("hooks/daemon-service", () => { expect(daemonServiceStatus()).toBe("running"); }, 40_000); + it("runs a managed binary from a root-owned copy outside HOME, and uninstall removes it", async () => { + // The SELinux fix, end to end: systemd on Fedora/RHEL refuses to + // execute anything labelled user_home_t, so the unit must start the + // copy under the system binary directory, never ~/.failproofai/bin. + const scratch = mkdtempSync(resolve(tmpdir(), "fpai-sysbin-")); + const originalFpHome = process.env.FAILPROOFAI_HOME; + process.env.FAILPROOFAI_HOME = resolve(scratch, "fp"); + process.env.FAILPROOFAI_SYSTEM_BIN_DIR = resolve(scratch, "opt"); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + try { + const managed = resolve(scratch, "fp", "bin", `failproofaid-${pkgVersion}`); + mkdirSync(dirname(managed), { recursive: true }); + writeFileSync(managed, "#!/bin/sh\nexec sleep infinity\n", { mode: 0o755 }); + setPlatform("linux"); + const { installDaemonService, daemonServiceStatus, uninstallDaemonService } = await import( + "../../src/hooks/daemon-service" + ); + const sysDir = resolve(scratch, "opt", userInfo().username); + const sysBin = resolve(sysDir, "bin", `failproofaid-${pkgVersion}`); + + expect(await installDaemonService()).toEqual({ installed: true }); + expect(readFileSync(unitPath, "utf8")).toContain(`ExecStart=${sysBin}\n`); + const st = statSync(sysBin); + expect(st.uid).toBe(0); + expect(st.mode & 0o777).toBe(0o755); + expect(readFileSync(sysBin, "utf8")).toBe(readFileSync(managed, "utf8")); + expect(existsSync(managed)).toBe(true); + expect(daemonServiceStatus()).toBe("running"); + + await uninstallDaemonService(); + expect(existsSync(unitPath)).toBe(false); + expect(existsSync(sysDir)).toBe(false); + } finally { + if (originalFpHome !== undefined) process.env.FAILPROOFAI_HOME = originalFpHome; + else delete process.env.FAILPROOFAI_HOME; + delete process.env.FAILPROOFAI_SYSTEM_BIN_DIR; + try { + run(["rm", "-rf", scratch]); + } catch { + rmSync(scratch, { recursive: true, force: true }); + } + } + }, 30_000); + it("installDaemonService fails cleanly when the binary cannot be resolved", async () => { // Scratch HOME + downloads off, or "cannot be resolved" is a lie: // install would reach ensureFailproofaidBinary, fetch the real diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index c27679d93..46038bd89 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -129,6 +129,7 @@ describe("integrations registry", () => { it("declares daemon-only installation requirements in registry metadata", () => { expect(listIntegrations().filter((integration) => integration.requiresHealthyDaemon)).toEqual([ hermes, + openclaw, ]); }); diff --git a/__tests__/hooks/openclaw-plugin.test.ts b/__tests__/hooks/openclaw-plugin.test.ts new file mode 100644 index 000000000..87a6b23ec --- /dev/null +++ b/__tests__/hooks/openclaw-plugin.test.ts @@ -0,0 +1,454 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server, type Socket } from "node:net"; +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + daemonSocketPath, + openclawAgentSettingsPath, + requestHookVerdict, +} from "../../openclaw-plugin/daemon-client.js"; +import { registerFailproofaiHooks } from "../../openclaw-plugin/hooks.js"; +import { daemonSocket } from "../../src/hooks/fp-home"; +import { runtimeAgentSettingsPath } from "../../src/hooks/agent-roster"; + +const PLUGIN_DIR = resolve(__dirname, "../../openclaw-plugin"); + +type Reply = Record | Buffer | "hang"; + +function frame(message: Record): Buffer { + const body = Buffer.from(JSON.stringify(message), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +/** A failproofaid stand-in: reads one frame, records it, answers with `reply`. */ +async function fakeDaemon(socketPath: string, reply: (request: Record) => Reply) { + const requests: Record[] = []; + const sockets = new Set(); + const server: Server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + let received = Buffer.alloc(0); + socket.on("data", (chunk) => { + received = Buffer.concat([received, chunk as Buffer]); + if (received.length < 4) return; + const length = received.readUInt32BE(0); + if (received.length < 4 + length) return; + const request = JSON.parse(received.subarray(4, 4 + length).toString("utf8")); + requests.push(request); + const answer = reply(request); + if (answer === "hang") return; + socket.end(Buffer.isBuffer(answer) ? answer : frame(answer)); + }); + }); + await new Promise((done) => server.listen(socketPath, done)); + return { + requests, + close: () => + new Promise((done) => { + for (const socket of sockets) socket.destroy(); + server.close(() => done()); + }), + }; +} + +const hookResult = (stdout: string, exitCode = 0) => ({ + type: "hookResult", + protocolVersion: 2, + exitCode, + stdout, + stderr: "", +}); + +let dir: string; +let socketPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpoc-")); + socketPath = join(dir, "d.sock"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +describe("openclaw-plugin daemon-client: paths", () => { + it("resolves the daemon socket exactly as the CLI does", () => { + const cases: Record[] = [ + {}, + { FAILPROOFAI_HOME: "/srv/fp-home" }, + { FAILPROOFAI_DAEMON_SOCKET: "/run/custom/failproofaid.sock" }, + { FAILPROOFAI_HOME: "/srv/fp-home", FAILPROOFAI_DAEMON_SOCKET: "/run/custom/failproofaid.sock" }, + ]; + for (const env of cases) { + vi.unstubAllEnvs(); + vi.stubEnv("FAILPROOFAI_HOME", env.FAILPROOFAI_HOME ?? ""); + vi.stubEnv("FAILPROOFAI_DAEMON_SOCKET", env.FAILPROOFAI_DAEMON_SOCKET ?? ""); + expect(daemonSocketPath(process.env)).toBe(daemonSocket()); + } + }); + + it("names the gateway profile's openclaw.json exactly as the spawned CLI did", () => { + const home = "/home/gw"; + const keys = ["OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR", "OPENCLAW_HOME", "OPENCLAW_PROFILE"]; + const cases: Record[] = [ + {}, + { OPENCLAW_CONFIG_PATH: "/etc/openclaw/custom.json" }, + { OPENCLAW_STATE_DIR: "/var/lib/openclaw" }, + { OPENCLAW_HOME: "/opt/openclaw-home" }, + { OPENCLAW_PROFILE: "work" }, + { OPENCLAW_PROFILE: "default" }, + { OPENCLAW_PROFILE: "../escape" }, + { OPENCLAW_PROFILE: ".hidden" }, + { OPENCLAW_STATE_DIR: "/var/lib/openclaw", OPENCLAW_PROFILE: "work" }, + ]; + for (const env of cases) { + vi.unstubAllEnvs(); + for (const key of keys) vi.stubEnv(key, env[key] ?? ""); + expect(openclawAgentSettingsPath(process.env, home)).toBe(runtimeAgentSettingsPath("openclaw", undefined, home)); + } + }); +}); + +describe("openclaw-plugin daemon-client: requestHookVerdict", () => { + it("sends the CLI's hook request and reads the daemon's verdict", async () => { + const daemon = await fakeDaemon(socketPath, () => + hookResult(JSON.stringify({ permission: "deny", reason: "nope" })), + ); + try { + const payload = { session_id: "s1", cwd: "/work", tool_name: "exec", tool_input: { command: "ls" } }; + const result = await requestHookVerdict({ + event: "before_tool_call", + payload, + cwd: "/work", + agentSettingsPath: "/home/gw/.openclaw/openclaw.json", + socketPath, + }); + expect(result).toEqual({ ok: true, verdict: { permission: "deny", reason: "nope" } }); + expect(daemon.requests).toHaveLength(1); + expect(daemon.requests[0]).toEqual({ + type: "hook", + protocolVersion: 2, + hookEvent: "before_tool_call", + cli: "openclaw", + stdin: JSON.stringify(payload), + cwd: "/work", + agentSettingsPath: "/home/gw/.openclaw/openclaw.json", + }); + } finally { + await daemon.close(); + } + }); + + it("treats empty stdout as allow", async () => { + const daemon = await fakeDaemon(socketPath, () => hookResult("")); + try { + const result = await requestHookVerdict({ event: "after_tool_call", payload: {}, socketPath }); + expect(result).toEqual({ ok: true, verdict: { permission: "allow" } }); + } finally { + await daemon.close(); + } + }); + + it("sends a payload just under the CLI's 1 MiB stdin limit intact", async () => { + // Quotes escape twice on the way into the envelope (once in stdin, again + // in the envelope), so this envelope is about twice the payload's size. + const fill = '"'.repeat(524_150); + const payload = { tool_name: "write", tool_input: { content: fill } }; + const stdin = JSON.stringify(payload); + expect(Buffer.byteLength(stdin)).toBeGreaterThan(1_048_000); + expect(Buffer.byteLength(stdin)).toBeLessThanOrEqual(1_048_576); + const daemon = await fakeDaemon(socketPath, () => hookResult("")); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload, socketPath }); + expect(result).toEqual({ ok: true, verdict: { permission: "allow" } }); + expect(daemon.requests[0].stdin).toBe(stdin); + } finally { + await daemon.close(); + } + }); + + it("evaluates an over-limit payload as empty, as the CLI does, and says so", async () => { + const payload = { tool_input: { content: "x".repeat(1_048_577) } }; + const daemon = await fakeDaemon(socketPath, () => hookResult("")); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload, socketPath }); + expect(result).toEqual({ ok: true, verdict: { permission: "allow" }, oversized: true }); + expect(daemon.requests[0].stdin).toBe(""); + } finally { + await daemon.close(); + } + }); + + it("accepts the instruct verdict the evaluator prints", async () => { + // policy-evaluator.ts's `cli === "openclaw"` PreToolUse instruct shape. + const verdict = { + permission: "instruct", + reason: "Instruction from failproofai: re-read the ticket", + policyName: "custom/read-first", + policyNames: ["custom/read-first", "custom/cite-source"], + }; + const daemon = await fakeDaemon(socketPath, () => hookResult(JSON.stringify(verdict))); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload: {}, socketPath }); + expect(result).toEqual({ ok: true, verdict }); + } finally { + await daemon.close(); + } + }); + + it("reads a frame that arrives in pieces", async () => { + const whole = frame(hookResult(JSON.stringify({ permission: "instruct", reason: "slow down" }))); + const server = createServer((socket) => { + socket.once("data", () => { + socket.write(whole.subarray(0, 2)); + setTimeout(() => socket.write(whole.subarray(2, 9)), 5); + setTimeout(() => socket.end(whole.subarray(9)), 10); + }); + }); + await new Promise((done) => server.listen(socketPath, done)); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload: {}, socketPath }); + expect(result).toEqual({ ok: true, verdict: { permission: "instruct", reason: "slow down" } }); + } finally { + await new Promise((done) => server.close(() => done())); + } + }); + + it.each([ + ["an error frame", { type: "error", protocolVersion: 2, message: "worker is down" }, "evaluation"], + ["another protocol version", { type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "" }, "protocol-mismatch"], + ["an unknown message type", { type: "pong", protocolVersion: 2 }, "invalid"], + ["a non-zero exit", hookResult("", 1), "evaluation"], + ["stdout that is not JSON", hookResult("not json"), "invalid"], + ["a verdict without a permission", hookResult(JSON.stringify({ reason: "x" })), "invalid"], + ["a hookResult with no stdout", { type: "hookResult", protocolVersion: 2, exitCode: 0, stderr: "" }, "invalid"], + ["a hookResult with no stderr", { type: "hookResult", protocolVersion: 2, exitCode: 0, stdout: "" }, "invalid"], + ["a hookResult with no exitCode", { type: "hookResult", protocolVersion: 2, stdout: "", stderr: "" }, "invalid"], + ["a non-string stdout", { type: "hookResult", protocolVersion: 2, exitCode: 0, stdout: 1, stderr: "" }, "invalid"], + ["an unknown permission", hookResult(JSON.stringify({ permission: "unknown" })), "invalid"], + ["a non-string reason", hookResult(JSON.stringify({ permission: "deny", reason: 7 })), "invalid"], + ["non-string policyNames", hookResult(JSON.stringify({ permission: "instruct", policyNames: [1] })), "invalid"], + ["a verdict that is an array", hookResult(JSON.stringify(["deny"])), "invalid"], + ])("never reads %s as an allow", async (_label, reply, failure) => { + const daemon = await fakeDaemon(socketPath, () => reply as Record); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload: {}, socketPath }); + expect(result).toMatchObject({ ok: false, failure }); + expect((result as { message: string }).message).toBeTruthy(); + } finally { + await daemon.close(); + } + }); + + it("refuses a declared response length over 16 MiB", async () => { + const header = Buffer.alloc(4); + header.writeUInt32BE(16 * 1024 * 1024 + 1, 0); + const daemon = await fakeDaemon(socketPath, () => header); + try { + const result = await requestHookVerdict({ event: "before_tool_call", payload: {}, socketPath }); + expect(result).toMatchObject({ ok: false, failure: "invalid" }); + } finally { + await daemon.close(); + } + }); + + it("reports an absent daemon as unreachable", async () => { + const result = await requestHookVerdict({ event: "before_tool_call", payload: {}, socketPath }); + expect(result).toMatchObject({ ok: false, failure: "unreachable" }); + }); + + it("gives up on a daemon that never answers", async () => { + const daemon = await fakeDaemon(socketPath, () => "hang"); + try { + const result = await requestHookVerdict({ + event: "before_tool_call", + payload: {}, + socketPath, + responseTimeoutMs: 50, + }); + expect(result).toMatchObject({ ok: false, failure: "timeout" }); + } finally { + await daemon.close(); + } + }); +}); + +type Handler = (payload: unknown, ctx: unknown) => Promise; + +function fakeApi() { + const handlers = new Map(); + const options = new Map(); + const logged: string[] = []; + return { + handlers, + options, + logged, + api: { + config: {}, + logger: { info() {}, warn() {}, error: (line: string) => logged.push(line) }, + on(name: string, handler: Handler, opts: unknown) { + handlers.set(name, handler); + options.set(name, opts); + }, + }, + }; +} + +const toolCall = { toolName: "exec", params: { command: "rm -rf /" } }; +const ctx = { sessionKey: "agent:main:main", runId: "r1", agentId: "main" }; + +describe("openclaw-plugin hooks", () => { + it("registers the eight hooks it maps", () => { + const { api, handlers, options } = fakeApi(); + registerFailproofaiHooks(api, { evaluate: async () => ({ ok: true, verdict: { permission: "allow" } }) }); + expect([...handlers.keys()].sort()).toEqual([ + "after_tool_call", + "before_agent_finalize", + "before_agent_run", + "before_compaction", + "before_tool_call", + "session_end", + "session_start", + "subagent_ended", + ]); + for (const opts of options.values()) expect(opts).toEqual({ priority: 100, timeoutMs: 60_000 }); + }); + + it("forwards raw names in a Claude-shaped payload", async () => { + const { api, handlers } = fakeApi(); + const evaluate = vi.fn(async () => ({ ok: true, verdict: { permission: "allow" } })); + registerFailproofaiHooks(api, { evaluate }); + await handlers.get("before_tool_call")!(toolCall, ctx); + expect(evaluate).toHaveBeenCalledWith( + "before_tool_call", + expect.objectContaining({ + session_id: "agent:main:main", + tool_name: "exec", + tool_input: { command: "rm -rf /" }, + hook_event_name: "before_tool_call", + }), + ); + }); + + it("maps a deny to each gate's native shape", async () => { + const { api, handlers } = fakeApi(); + registerFailproofaiHooks(api, { + evaluate: async () => ({ ok: true, verdict: { permission: "deny", reason: "policy says no" } }), + }); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toEqual({ + block: true, + blockReason: "policy says no", + }); + expect(await handlers.get("before_agent_run")!({ prompt: "hi" }, ctx)).toEqual({ + outcome: "block", + reason: "policy says no", + }); + expect(await handlers.get("before_agent_finalize")!({}, ctx)).toEqual({ + action: "revise", + reason: "policy says no", + }); + expect(await handlers.get("after_tool_call")!(toolCall, ctx)).toBeUndefined(); + }); + + it("blocks tool calls and prompts, and logs, when no verdict comes back", async () => { + const { api, handlers, logged } = fakeApi(); + registerFailproofaiHooks(api, { + evaluate: async () => ({ ok: false, failure: "unreachable", message: "failproofaid could not be reached." }), + }); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toEqual({ + block: true, + blockReason: expect.stringContaining("failproofaid could not be reached."), + }); + expect(await handlers.get("before_agent_run")!({ prompt: "hi" }, ctx)).toMatchObject({ outcome: "block" }); + // A revise cannot bring the daemon back; forcing one would only loop the turn. + expect(await handlers.get("before_agent_finalize")!({}, ctx)).toBeUndefined(); + expect(await handlers.get("session_end")!({}, ctx)).toBeUndefined(); + expect(logged.length).toBe(4); + expect(logged[0]).toContain("before_tool_call"); + }); + + it.each([ + ["an unknown permission", hookResult(JSON.stringify({ permission: "unknown" }))], + ["a hookResult with no stdout", { type: "hookResult", protocolVersion: 2, exitCode: 0, stderr: "" }], + ])("blocks the gates, through the real daemon client, on %s", async (_label, reply) => { + vi.stubEnv("FAILPROOFAI_DAEMON_SOCKET", socketPath); + const daemon = await fakeDaemon(socketPath, () => reply); + try { + const { api, handlers } = fakeApi(); + registerFailproofaiHooks(api); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toMatchObject({ block: true }); + expect(await handlers.get("before_agent_run")!({ prompt: "hi" }, ctx)).toMatchObject({ outcome: "block" }); + } finally { + await daemon.close(); + } + }); + + it("logs a payload that was too large to evaluate", async () => { + const { api, handlers, logged } = fakeApi(); + registerFailproofaiHooks(api, { + evaluate: async () => ({ ok: true, verdict: { permission: "allow" }, oversized: true }), + }); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toBeUndefined(); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain("exceeds 1 MiB"); + }); + + it("blocks when the evaluator itself throws", async () => { + const { api, handlers } = fakeApi(); + registerFailproofaiHooks(api, { + evaluate: async () => { + throw new Error("boom"); + }, + }); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toEqual({ + block: true, + blockReason: expect.stringContaining("boom"), + }); + }); +}); + +describe("openclaw-plugin under OpenClaw's plugin capture", () => { + it("imports nothing from outside its own directory", () => { + for (const file of readdirSync(PLUGIN_DIR).filter((name) => name.endsWith(".js"))) { + const source = readFileSync(join(PLUGIN_DIR, file), "utf8"); + const specifiers = [...source.matchAll(/^\s*import\s[^"']*["']([^"']+)["']/gm)].map((m) => m[1]); + for (const specifier of specifiers) { + const allowed = + specifier.startsWith("node:") || + (specifier.startsWith("./") && !specifier.includes("..")) || + specifier === "openclaw/plugin-sdk/plugin-entry"; + expect(allowed, `${file} imports ${specifier}`).toBe(true); + } + } + }); + + it("still enforces when loaded from a copy of the plugin directory", async () => { + // OpenClaw ≥ 2026.9.6 imports plugins from …/plugin-captures//…/node_modules//, + // a copy holding only the plugin's own files. Nothing beside it exists there. + const capture = join(dir, "captures", "build", "package-0", "node_modules", "openclaw-plugin"); + cpSync(PLUGIN_DIR, capture, { recursive: true }); + vi.stubEnv("FAILPROOFAI_DAEMON_SOCKET", socketPath); + const daemon = await fakeDaemon(socketPath, () => + hookResult(JSON.stringify({ permission: "deny", reason: "Blocked Bash by failproofai" })), + ); + try { + const copied = await import(pathToFileURL(join(capture, "hooks.js")).href); + const { api, handlers } = fakeApi(); + copied.registerFailproofaiHooks(api); + expect(await handlers.get("before_tool_call")!(toolCall, ctx)).toEqual({ + block: true, + blockReason: "Blocked Bash by failproofai", + }); + expect(daemon.requests).toHaveLength(1); + expect(daemon.requests[0]).toMatchObject({ type: "hook", cli: "openclaw", hookEvent: "before_tool_call" }); + } finally { + await daemon.close(); + } + }); +}); diff --git a/__tests__/hooks/shell-quote.test.ts b/__tests__/hooks/shell-quote.test.ts new file mode 100644 index 000000000..17a26da22 --- /dev/null +++ b/__tests__/hooks/shell-quote.test.ts @@ -0,0 +1,17 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { shellQuote } from "../../src/hooks/shell-quote"; + +describe("shellQuote", () => { + it.each([ + ["/opt/failproofai/tester/bin/failproofaid-1.0.13"], + ["/home/First Last/.failproofai/bin/failproofaid"], + ["/Users/o'brien/bin"], + ["$(touch pwned) `id` *.service ; rm -rf ~"], + ])("survives a shell unchanged: %s", (value) => { + // The shell, not a regex, decides whether the quoting holds. + const echoed = execFileSync("sh", ["-c", `printf %s ${shellQuote(value)}`], { encoding: "utf8" }); + expect(echoed).toBe(value); + }); +}); diff --git a/__tests__/hooks/uninstall-cli.test.ts b/__tests__/hooks/uninstall-cli.test.ts index 897ded16b..258336a4c 100644 --- a/__tests__/hooks/uninstall-cli.test.ts +++ b/__tests__/hooks/uninstall-cli.test.ts @@ -46,6 +46,7 @@ vi.mock("../../src/hooks/daemon-service", () => ({ isDaemonSupportedPlatform: () => true, daemonServiceFilePath: () => servicePath, daemonStatusCommand: () => "systemctl status failproofaid@tester", + daemonSystemBinaryDir: () => "/opt/failproofai/tester", daemonServiceStatus: () => "running", setDaemonConfigured: vi.fn((v: boolean) => { calls.push(`setDaemonConfigured(${v})`); @@ -407,11 +408,13 @@ describe("hooks/uninstall-cli — who decides the daemon's fate", () => { const at = res.lines.findIndex((l) => l.startsWith("✕ The failproofaid service is still there")); expect(at).toBeGreaterThanOrEqual(0); - expect(res.lines.slice(at + 1, at + 6)).toEqual([ + expect(res.lines.slice(at + 1, at + 7)).toEqual([ " Remove it as root:", " sudo systemctl disable --now failproofaid@tester.service", - ` sudo rm -f ${servicePath}`, + ` sudo rm -f '${servicePath}'`, " sudo systemctl daemon-reload", + // The root-owned copy the unit ran, outside HOME for SELinux. + " sudo rm -rf '/opt/failproofai/tester'", " Check it with systemctl status failproofaid@tester", ]); }); diff --git a/__tests__/integration-suite/local-runner.test.ts b/__tests__/integration-suite/local-runner.test.ts index 7d241f7d3..027ed8fad 100644 --- a/__tests__/integration-suite/local-runner.test.ts +++ b/__tests__/integration-suite/local-runner.test.ts @@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { listIntegrations } from "../../src/hooks/integrations"; const ROOT = path.join(__dirname, "../.."); const SUITE = path.join(ROOT, "integration-suite"); @@ -358,6 +359,39 @@ describe("daemon-mode probe path", () => { expect(markerSet).toBeGreaterThan(wireCall); }); + it("names exactly the integrations whose install requires a healthy daemon", () => { + // probe-cli.sh keeps its own list (it is shell); this keeps it honest. + const listed = /DAEMON_ONLY_CLIS="([^"]*)"/.exec(probeSh); + expect(listed).not.toBeNull(); + const required = listIntegrations().filter((i) => i.requiresHealthyDaemon).map((i) => i.id); + expect(listed![1].split(/\s+/).filter(Boolean).sort()).toEqual([...required].sort()); + }); + + it("starts the daemon before a daemon-only CLI's install, which refuses without one", () => { + const setupDaemon = probeSh.indexOf('[ "$DAEMON_ONLY" = 1 ] && daemon_cycle "$BASE/log-setup"'); + const installCall = probeSh.indexOf("\ninstall_hooks\n"); + expect(setupDaemon).toBeGreaterThan(0); + expect(setupDaemon).toBeLessThan(installCall); + }); + + it("never drives a daemon-only CLI unhooked on a leg without a live daemon", () => { + // Driving it would let the benign side effect score a silent-allow FAIL. + const skip = /if \[ "\$DAEMON_ONLY" = 1 \] && \{ \[ "\$\{CANARY_DAEMON:-0\}" != 1 \] \|\| \[ "\$\{CANARY_DAEMON_DEAD:-0\}" = 1 \]; \}; then([\s\S]*?)\nfi/.exec(probeSh); + expect(skip).not.toBeNull(); + expect(skip![1]).toContain('"bash":"INCONCLUSIVE","read":"INCONCLUSIVE"'); + expect(skip![1]).toContain("exit 0"); + // …and it is decided before anything is installed or driven. + expect(probeSh.indexOf(skip![0])).toBeLessThan(probeSh.indexOf("\ninstall_hooks\n")); + }); + + it("reports a daemon-only install refusal instead of discarding it", () => { + expect(probeSh).toMatch(/INSTALL_FAILED="policies --install --cli \$CLI: /); + expect(probeSh).toMatch(/INSTALL_FAILED="policies --install --cli openclaw: /); + const report = probeSh.indexOf('if [ -n "$INSTALL_FAILED" ]; then'); + expect(report).toBeGreaterThan(0); + expect(report).toBeLessThan(probeSh.lastIndexOf("printf 'VERDICT_JSON")); + }); + it("a dead daemon cannot false-PASS either probe", () => { // bin/failproofai.mjs shapes an unreachable-daemon deny through a // synthetic policy (see handler.ts's forceDecision branch). Its oracle diff --git a/docs/reference/harnesses.mdx b/docs/reference/harnesses.mdx index 861a2d987..72d275b0c 100644 --- a/docs/reference/harnesses.mdx +++ b/docs/reference/harnesses.mdx @@ -88,6 +88,16 @@ Run `failproofai config --status` to detect a disabled, incomplete, duplicated, or newly unconfigured profile, or one still on legacy shell hooks (reported as "Hermes cron jobs are not checked"). +### OpenClaw plugin + +OpenClaw loads FailproofAI as an in-process gateway plugin, registered in every +profile's `openclaw.json`. Like the Hermes plugin, it asks the local +`failproofaid` daemon for each verdict over its socket, so it needs a running +daemon: installation refuses without one, and if the daemon cannot answer, tool +calls and prompts are blocked and the reason is written to the gateway log. +After `npm install -g failproofai@latest`, restart the OpenClaw gateway so it +loads the updated plugin. + ### Codex hook trust Codex 0.129 and later runs a hook from `~/.codex/hooks.json` only after you approve it in interactive `codex` ("Review hooks"); until then, every Codex session — headless `codex exec` included — skips it. Hooks in Codex's system config, `/etc/codex/config.toml` on Linux and macOS, run without that approval. diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml index be7d1ca00..dd549896c 100644 --- a/hermes-plugin/plugin.yaml +++ b/hermes-plugin/plugin.yaml @@ -1,5 +1,5 @@ name: failproofai -version: 1.0.13-beta.0 +version: 1.0.13 description: Real-time FailproofAI policy enforcement for Hermes manifest_version: 2 api_version: 1 diff --git a/integration-suite/probe-cli.sh b/integration-suite/probe-cli.sh index 9c0290dfb..00c9a2f07 100644 --- a/integration-suite/probe-cli.sh +++ b/integration-suite/probe-cli.sh @@ -139,6 +139,25 @@ fi # against a marker with no daemon up yet. Daemon mode re-sets it after wire. bun -e 'const m=await import("/repo/src/hooks/fp-config.ts");m.updateConfig({daemon:{configured:false}})' 2>/dev/null || true +# Hermes and OpenClaw enforce ONLY through failproofaid: their plugins ask the +# daemon directly (no CLI process, no in-process path), and their installs +# refuse unless it answers (requiresHealthyDaemon in src/hooks/integrations.ts). +# So they are probed on the live daemon leg only. On any other leg nothing can +# be hooked, and driving the CLI anyway would score its benign side effect as a +# silent-allow FAIL — say why instead, as an INCONCLUSIVE that never gates. +DAEMON_ONLY_CLIS="hermes openclaw" +case " $DAEMON_ONLY_CLIS " in *" $CLI "*) DAEMON_ONLY=1 ;; *) DAEMON_ONLY=0 ;; esac +if [ "$DAEMON_ONLY" = 1 ] && { [ "${CANARY_DAEMON:-0}" != 1 ] || [ "${CANARY_DAEMON_DEAD:-0}" = 1 ]; }; then + echo "=== $CLI live Tier-2 verdicts (DEFINITE benign-marker probes) ===" + echo " not probed: $CLI enforces only through a live failproofaid, and this leg has none" \ + "(CANARY_DAEMON=${CANARY_DAEMON:-0} CANARY_DAEMON_DEAD=${CANARY_DAEMON_DEAD:-0}) — see the daemon leg" + printf 'VERDICT_JSON {"cli":"%s","probes":{"bash":"INCONCLUSIVE","read":"INCONCLUSIVE"}}\n' "$CLI" + exit 0 +fi +# Why a daemon-only CLI ended up unhooked, when its installer refused. Printed +# with the verdicts: the install output itself is not kept. +INSTALL_FAILED="" + BASE="$HOME/probe-$CLI" # openclaw runs every tool inside its OWN workspace, not the cwd it is invoked # from: `[tools] read failed: File not found: @@ -161,9 +180,16 @@ cp /opt/canary/canary-policies.mjs "$CUSTOM_POLICIES" install_hooks() { # Prefer user scope (writes the override → main HEAD). Fall back to project + # command-swap for CLIs without a user scope. `-c` loads the custom canary policies. - if bun /repo/bin/failproofai.mjs policies --install "${POLICIES[@]}" --cli "$CLI" --scope user -c "$CUSTOM_POLICIES" >/dev/null 2>&1; then + local out + if out="$(bun /repo/bin/failproofai.mjs policies --install "${POLICIES[@]}" --cli "$CLI" --scope user -c "$CUSTOM_POLICIES" 2>&1)"; then return 0 fi + # Hermes and OpenClaw are user-scope only: no project fallback, and the + # installer's refusal is the one thing that says why no hook will fire. + if [ "$DAEMON_ONLY" = 1 ]; then + INSTALL_FAILED="policies --install --cli $CLI: $(printf '%s' "$out" | tail -3 | tr '\n' ' ')" + return 1 + fi ( cd "$BASE" && bun /repo/bin/failproofai.mjs policies --install "${POLICIES[@]}" --cli "$CLI" --scope project -c "$CUSTOM_POLICIES" >/dev/null 2>&1 ) grep -rl "npx -y failproofai" "$BASE" 2>/dev/null | while read -r f; do sed -i 's#npx -y failproofai#bun /repo/bin/failproofai.mjs#g' "$f" @@ -214,8 +240,13 @@ YAML --custom-compatibility openai --custom-model-id "$CANARY_LLM_MODEL" --custom-text-input >/dev/null 2>&1 # onboard rewrites openclaw.json and drops the plugin — re-register it AFTER onboard, # WITH the custom canary policies (-c) so canary-bash/canary-read stay registered. - bun /repo/bin/failproofai.mjs policies --install "${POLICIES[@]}" --cli openclaw --scope user \ - -c "$CUSTOM_POLICIES" >/dev/null 2>&1 + # This is the install that counts, so it also decides INSTALL_FAILED. + if oc_out="$(bun /repo/bin/failproofai.mjs policies --install "${POLICIES[@]}" --cli openclaw --scope user \ + -c "$CUSTOM_POLICIES" 2>&1)"; then + INSTALL_FAILED="" + else + INSTALL_FAILED="policies --install --cli openclaw: $(printf '%s' "$oc_out" | tail -3 | tr '\n' ' ')" + fi # open exec approval (both layers) so the agent issues tool calls headlessly node -e 'const fs=require("fs"),p=process.env.HOME+"/.openclaw/openclaw.json";const c=JSON.parse(fs.readFileSync(p,"utf8"));c.tools=c.tools||{};c.tools.exec=Object.assign({},c.tools.exec,{security:"full",ask:"off",host:"gateway"});fs.writeFileSync(p,JSON.stringify(c,null,2));' # ── four things openclaw needs that no other CLI does ────────── @@ -241,16 +272,14 @@ YAML mkdir -p "$HOME/node_modules" ln -sfn "$HOME/.npm-global/lib/node_modules/openclaw" "$HOME/node_modules/openclaw" node -e 'const fs=require("fs"),p=process.env.HOME+"/.openclaw/openclaw.json";const c=JSON.parse(fs.readFileSync(p,"utf8"));c.plugins=c.plugins||{};c.plugins.load=c.plugins.load||{};c.plugins.load.paths=[process.env.HOME+"/oc-plugin"];fs.writeFileSync(p,JSON.stringify(c,null,2));' - # 3. THE BINARY THE SHIM SPAWNS. It runs `node `, and the - # canary's override is a /bin/sh wrapper — `node …/bin/failproofai` - # is a syntax error (the same trap pi hits, see its branch). The - # old fix was to unset it, but that made the shim self-resolve to - # `../dist/cli.mjs` relative to ITSELF, which after (1) is a copy - # in HOME whose bundle cannot resolve its own imports. Point it at - # the real one instead: node-runnable AND still inside /repo, so - # it resolves and stays main HEAD. The shim fails OPEN on a spawn - # error, so every one of these lands as a silent allow. - export FAILPROOFAI_BINARY_OVERRIDE=/repo/dist/cli.mjs + # 3. THE DAEMON. The plugin spawns nothing: like the Hermes plugin it + # asks failproofaid over its socket (FAILPROOFAI_DAEMON_SOCKET, + # inherited by the gateway below), and like Hermes its install + # refuses unless that daemon answers — hence the setup daemon + # started before install_hooks. This used to export a CLI path + # for the old spawning shim, which is exactly why the canary never + # saw that shim lose its CLI under OpenClaw ≥ 2026.9.6's plugin + # capture. # (4. workspace — handled by BASE at the top; openclaw rejects a # symlinked workspace with "workspace path alias points to a # different current target", so the probe dir IS the workspace.) @@ -347,6 +376,11 @@ fi MARKER_CONTENT='CANARY_LEAK_SENTINEL_q7k2' printf '%s\n' "$MARKER_CONTENT" > "$BASE/CANARY_MARKER.txt" +# A daemon-only CLI's installer refuses unless failproofaid answers, so here +# the daemon must be up BEFORE install/wire rather than first at probe A. It +# logs to a setup dir of its own; each probe still cycles a fresh daemon into +# its own oracle dir. (Only the live daemon leg gets this far — see above.) +[ "$DAEMON_ONLY" = 1 ] && daemon_cycle "$BASE/log-setup" install_hooks wire @@ -554,4 +588,7 @@ if [ "${CANARY_DAEMON:-0}" = 1 ] && [ "${CANARY_DAEMON_DEAD:-0}" != 1 ]; then echo " daemon: routed, no fail-closed denies (verdicts reflect real daemon evaluation)" fi fi +if [ -n "$INSTALL_FAILED" ]; then + echo " ✗ INSTALL FAILED — nothing was hooked, so these verdicts measured an unhooked $CLI: $INSTALL_FAILED" +fi printf 'VERDICT_JSON {"cli":"%s","probes":{"bash":"%s","read":"%s"}}\n' "$CLI" "$VA" "$VB" diff --git a/openclaw-plugin/daemon-client.js b/openclaw-plugin/daemon-client.js new file mode 100644 index 000000000..7111fa2a7 --- /dev/null +++ b/openclaw-plugin/daemon-client.js @@ -0,0 +1,254 @@ +/** + * Asks failproofaid for a verdict over its owner-only Unix socket — the same + * `hook` request `failproofai --hook --cli openclaw` sends to the + * daemon (crates/PROTOCOL.md), made from inside the gateway instead of from a + * process spawned per hook. The Hermes plugin (hermes-plugin/client.py) reaches + * the daemon the same way. + * + * Why not spawn the CLI: OpenClaw ≥ 2026.9.6 imports this plugin from a copy + * under ~/.openclaw/tmp/plugin-captures/…, and the copy holds only this + * directory's files. A CLI path derived from import.meta.url (`../dist/cli.mjs`) + * then points at nothing; the old shim read the spawn failure as "allow", so + * every tool call ran unchecked and no hook activity was recorded. The socket + * path depends only on the environment, never on where this file sits. + * + * Self-contained on purpose — node built-ins only, nothing imported from + * outside this directory — because the capture copies nothing else. + */ +import { createConnection } from "node:net"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +export const PROTOCOL_VERSION = 2; +// The daemon's frame limit (crates/fpai-ipc/src/framing.rs), both ways. +const MAX_FRAME_BYTES = 16 * 1024 * 1024; +// The payload limit the CLI applies to its stdin (handler.ts, read-stdin.ts). +// It bounds the RAW payload: the envelope carries it as an escaped JSON +// string plus routing fields, so it is larger, and only the frame limit +// applies to the envelope. +export const MAX_PAYLOAD_BYTES = 1_048_576; + +// The CLI's daemon client budgets these separately for the reason it gives: +// connecting is the "is anything listening" probe, the response covers the +// whole evaluation. 250 ms matches the Hermes plugin; 30 s matches the +// daemon's own read timeout on its worker (worker.rs). +export const CONNECT_TIMEOUT_MS = 250; +export const RESPONSE_TIMEOUT_MS = 30_000; + +const PROTOCOL_MISMATCH_MESSAGE = + "failproofaid is running a different protocol version than this plugin, so it cannot " + + "evaluate this call. Run `failproofai update` to update the daemon."; +const UNREACHABLE_MESSAGE = + "failproofaid could not be reached. This machine is configured to run hooks through it " + + "— check the daemon (see `failproofai config`) rather than retrying blindly."; + +/** Mirrors `daemonSocket()` in src/hooks/fp-home.ts. */ +export function daemonSocketPath(env = process.env, home = homedir()) { + return ( + env.FAILPROOFAI_DAEMON_SOCKET || + resolve(env.FAILPROOFAI_HOME || resolve(home, ".failproofai"), "run", "failproofaid.sock") + ); +} + +/** + * The gateway profile's openclaw.json — what the spawned CLI computed from the + * gateway's environment (`runtimeAgentSettingsPath("openclaw", …)` in + * src/hooks/agent-roster.ts). Sent explicitly: left out, the daemon's worker + * would resolve it from the DAEMON's environment and attribute a + * `--profile ` gateway to the default profile. + */ +export function openclawAgentSettingsPath(env = process.env, home = homedir()) { + const config = env.OPENCLAW_CONFIG_PATH; + if (config?.trim()) return resolve(config); + const state = env.OPENCLAW_STATE_DIR || env.OPENCLAW_HOME; + if (state?.trim()) return resolve(state, "openclaw.json"); + const profile = env.OPENCLAW_PROFILE?.trim(); + if (profile && profile !== "default" && /^[A-Za-z0-9._-]{1,80}$/.test(profile) && !profile.startsWith(".")) { + return resolve(home, `.openclaw-${profile}`, "openclaw.json"); + } + return resolve(home, ".openclaw", "openclaw.json"); +} + +function failed(failure, message) { + return { ok: false, failure, message }; +} + +/** + * Turn the daemon's reply into a flat `{permission, reason}` verdict. For + * OpenClaw the evaluator always exits 0 and prints either nothing (allow) or + * one JSON object, so anything else is an evaluation failure — never an allow. + */ +function readHookResult(message) { + if (message === null || typeof message !== "object" || Array.isArray(message)) { + return failed("invalid", "failproofaid returned a non-object response"); + } + if (message.protocolVersion !== PROTOCOL_VERSION) { + return failed("protocol-mismatch", PROTOCOL_MISMATCH_MESSAGE); + } + if (message.type === "error") { + const detail = typeof message.message === "string" && message.message ? message.message : "no detail"; + return failed("evaluation", `failproofaid could not evaluate this call: ${detail}`); + } + if (message.type !== "hookResult") { + return failed("invalid", `failproofaid returned an unexpected ${JSON.stringify(message.type)} response`); + } + // The daemon always serializes all three (crates/fpai-ipc/src/envelope.rs); + // a frame missing one is not a hookResult, and must not read as an empty + // stdout — that is the allow. + if ( + !Number.isInteger(message.exitCode) || + typeof message.stdout !== "string" || + typeof message.stderr !== "string" + ) { + return failed("invalid", "failproofaid returned an incomplete hookResult"); + } + if (message.exitCode !== 0) { + return failed("evaluation", `failproofai exited ${String(message.exitCode)} without a verdict`); + } + const stdout = message.stdout.trim(); + if (!stdout) return { ok: true, verdict: { permission: "allow" } }; + let verdict; + try { + verdict = JSON.parse(stdout); + } catch { + return failed("invalid", "failproofai returned a verdict that is not JSON"); + } + return isVerdict(verdict) + ? { ok: true, verdict } + : failed("invalid", "failproofai returned a malformed verdict"); +} + +const PERMISSIONS = new Set(["allow", "deny", "instruct"]); + +/** The flat shape policy-evaluator.ts prints for `--cli openclaw`; unknown keys are ignored. */ +function isVerdict(verdict) { + if (verdict === null || typeof verdict !== "object" || Array.isArray(verdict)) return false; + if (!PERMISSIONS.has(verdict.permission)) return false; + if (verdict.reason !== undefined && typeof verdict.reason !== "string") return false; + if (verdict.policyName !== undefined && typeof verdict.policyName !== "string") return false; + if ( + verdict.policyNames !== undefined && + !(Array.isArray(verdict.policyNames) && verdict.policyNames.every((name) => typeof name === "string")) + ) { + return false; + } + return true; +} + +/** + * One request, one connection, one frame each way. Never rejects: resolves to + * `{ok: true, verdict}` (plus `oversized: true`, see below) or + * `{ok: false, failure, message}`. + * + * A payload over MAX_PAYLOAD_BYTES is sent as an empty stdin, exactly as the + * CLI discards an oversized stdin and evaluates an empty payload; + * `oversized: true` lets the caller say so instead of discarding it silently. + */ +export function requestHookVerdict({ + event, + payload, + cwd = "", + agentSettingsPath = "", + socketPath = daemonSocketPath(), + connectTimeoutMs = CONNECT_TIMEOUT_MS, + responseTimeoutMs = RESPONSE_TIMEOUT_MS, +}) { + return new Promise((done) => { + let body; + let oversized = false; + try { + let stdin = JSON.stringify(payload ?? {}); + if (Buffer.byteLength(stdin, "utf8") > MAX_PAYLOAD_BYTES) { + stdin = ""; + oversized = true; + } + body = Buffer.from( + JSON.stringify({ + type: "hook", + protocolVersion: PROTOCOL_VERSION, + hookEvent: event, + cli: "openclaw", + stdin, + ...(typeof cwd === "string" && cwd ? { cwd } : {}), + agentSettingsPath: agentSettingsPath || "", + }), + "utf8", + ); + } catch (err) { + done(failed("invalid", `could not encode the hook request: ${err?.message ?? String(err)}`)); + return; + } + // Unreachable for a payload within MAX_PAYLOAD_BYTES (escaping at most + // sextuples it); kept so nothing over the daemon's limit is ever sent. + if (body.length > MAX_FRAME_BYTES) { + done(failed("invalid", "hook request exceeds the daemon's 16 MiB frame limit")); + return; + } + + let settled = false; + let connected = false; + let received = Buffer.alloc(0); + let timer; + let socket; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { socket?.destroy(); } catch { /* already closed */ } + done(result); + }; + + try { + socket = createConnection(socketPath); + } catch { + finish(failed("unreachable", UNREACHABLE_MESSAGE)); + return; + } + timer = setTimeout(() => finish(failed("unreachable", UNREACHABLE_MESSAGE)), connectTimeoutMs); + + socket.on("connect", () => { + connected = true; + clearTimeout(timer); + timer = setTimeout( + () => finish(failed("timeout", `failproofaid did not answer within ${responseTimeoutMs} ms`)), + responseTimeoutMs, + ); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + socket.write(Buffer.concat([header, body])); + }); + socket.on("data", (chunk) => { + received = Buffer.concat([received, chunk]); + if (received.length < 4) return; + const length = received.readUInt32BE(0); + if (length > MAX_FRAME_BYTES) { + finish(failed("invalid", "failproofaid response exceeds the 16 MiB limit")); + return; + } + if (received.length < 4 + length) return; + let message; + try { + message = JSON.parse(received.subarray(4, 4 + length).toString("utf8")); + } catch { + finish(failed("invalid", "failproofaid returned a response that is not JSON")); + return; + } + const result = readHookResult(message); + finish(result.ok && oversized ? { ...result, oversized: true } : result); + }); + socket.on("error", (err) => { + finish( + connected + ? failed("evaluation", `failproofaid connection failed: ${err?.message ?? String(err)}`) + : failed("unreachable", UNREACHABLE_MESSAGE), + ); + }); + socket.on("close", () => { + finish( + connected + ? failed("evaluation", "failproofaid closed the connection before returning a verdict") + : failed("unreachable", UNREACHABLE_MESSAGE), + ); + }); + }); +} diff --git a/openclaw-plugin/hooks.js b/openclaw-plugin/hooks.js new file mode 100644 index 000000000..2e7e5e3ad --- /dev/null +++ b/openclaw-plugin/hooks.js @@ -0,0 +1,187 @@ +/** + * OpenClaw typed plugin hooks → failproofai verdicts. + * + * Kept apart from index.js (whose only job is `definePluginEntry`) so the + * hook wiring is importable without the `openclaw/plugin-sdk` package, which + * only resolves inside the gateway. + * + * Each hook sends a Claude-shaped payload (params→tool_input, toolName→ + * tool_name, transcriptPath→transcript_path, stopHookActive→stop_hook_active, + * sessionKey→session_id) with the RAW event and tool names; the daemon's + * worker canonicalizes them via the OPENCLAW_* maps in src/hooks/types.ts + * (single source of truth — no inline maps here, unlike the OpenCode/Pi shims). + * + * When failproofaid cannot return a verdict the gates fail closed, as every + * other CLI does on a daemon-configured machine (bin/failproofai.mjs): a tool + * call or prompt is blocked with the reason, and the reason is logged. The + * turn-end gate is the exception — a revise cannot bring the daemon back, so + * forcing one would loop the turn instead of protecting anything. + */ +import { createInstructRetryGate, mapBeforeToolVerdict } from "./instruct-retry-gate.js"; +import { createWorkspaceContext } from "./workspace-context.js"; +import { openclawAgentSettingsPath, requestHookVerdict } from "./daemon-client.js"; + +const sharedRetryGate = createInstructRetryGate(); +const sharedWorkspace = createWorkspaceContext(); + +const OBSERVATION_EVENTS = [ + "after_tool_call", + "session_start", + "session_end", + "subagent_ended", + "before_compaction", +]; + +const HOOK_OPTIONS = { priority: 100, timeoutMs: 60_000 }; + +/** Claude-shaped stdin base. Extra keys are ignored by the evaluator's payload + * parser; they're forwarded for activity attribution. */ +function baseMeta(payload, ctx, config, workspace) { + const p = payload || {}; + const c = ctx || {}; + return { + session_id: c.sessionId ?? p.sessionId ?? c.sessionKey ?? p.sessionKey, + cwd: workspace.resolveWorkspace(p, c, config) ?? process.cwd(), + transcript_path: p.transcriptPath, + stop_hook_active: p.stopHookActive === true, + openclaw: { + agentId: c.agentId, + sessionKey: c.sessionKey ?? p.sessionKey, + runId: c.runId ?? p.runId, + provider: p.provider, + model: p.model, + }, + }; +} + +function defaultEvaluate(event, payload) { + return requestHookVerdict({ + event, + payload, + cwd: payload.cwd, + agentSettingsPath: openclawAgentSettingsPath(), + }); +} + +function logFailure(api, event, message) { + const line = `[failproofai] ${event}: ${message}`; + try { + if (typeof api?.logger?.error === "function") { + api.logger.error(line); + return; + } + } catch { /* fall through to stderr */ } + process.stderr.write(`${line}\n`); +} + +const blockedReason = (message) => `FailproofAI could not check this call, so it was blocked: ${message}`; + +/** + * Register every hook on `api`. `evaluate(event, payload)` resolves to + * `{ok: true, verdict}` or `{ok: false, message}`; tests inject their own. + */ +export function registerFailproofaiHooks(api, { + evaluate = defaultEvaluate, + retryGate = sharedRetryGate, + workspace = sharedWorkspace, +} = {}) { + const ask = async (event, payload) => { + let result; + try { + result = await evaluate(event, payload); + } catch (err) { + result = { ok: false, message: `the failproofai plugin failed: ${err?.message ?? String(err)}` }; + } + if (!result?.ok) logFailure(api, event, result?.message ?? "no verdict"); + // Same outcome as the CLI's oversized stdin (warn, evaluate an empty + // payload), and said out loud for the same reason it is there. + else if (result.oversized) { + logFailure(api, event, "payload exceeds 1 MiB, so it was evaluated as an empty payload"); + } + return result ?? { ok: false, message: "no verdict" }; + }; + + // before_tool_call → PreToolUse. Deny always blocks. Instruct blocks the + // first matching attempt only, using blockReason to give the model the + // instruction, then permits retries from that session/policy for 5 minutes. + api.on( + "before_tool_call", + async (payload, ctx) => { + const p = payload || {}; + const result = await ask("before_tool_call", { + ...baseMeta(payload, ctx, api.config, workspace), + tool_name: p.toolName, + tool_input: p.params, + hook_event_name: "before_tool_call", + }); + if (!result.ok) return { block: true, blockReason: blockedReason(result.message) }; + return mapBeforeToolVerdict(result.verdict, payload, ctx, retryGate); + }, + HOOK_OPTIONS, + ); + + // before_agent_run → UserPromptSubmit. Deny: return {outcome:"block", reason}. + api.on( + "before_agent_run", + async (payload, ctx) => { + const p = payload || {}; + workspace.remember(payload, ctx); + const result = await ask("before_agent_run", { + ...baseMeta(payload, ctx, api.config, workspace), + prompt: p.prompt, + hook_event_name: "before_agent_run", + }); + if (!result.ok) return { outcome: "block", reason: blockedReason(result.message) }; + if (result.verdict.permission === "deny") { + return { outcome: "block", reason: result.verdict.reason || "Blocked by failproofai" }; + } + return undefined; + }, + HOOK_OPTIONS, + ); + + // before_agent_finalize → Stop. The real turn-end gate: a deny becomes a + // revise so the agent runs another pass. The evaluator supplies the + // MANDATORY ACTION wording; the payload's stop_hook_active guards the + // require-*-before-stop builtins against infinite loops. + api.on( + "before_agent_finalize", + async (payload, ctx) => { + const result = await ask("before_agent_finalize", { + ...baseMeta(payload, ctx, api.config, workspace), + hook_event_name: "before_agent_finalize", + }); + if (result.ok && result.verdict.permission === "deny") { + return { action: "revise", reason: result.verdict.reason || "Action required by failproofai" }; + } + return undefined; + }, + HOOK_OPTIONS, + ); + + // Observation hooks — await so activity ordering is stable, ignore verdict. + // after_tool_call → PostToolUse; session_start/end → SessionStart/End; + // subagent_ended → SubagentStop (obs only); before_compaction → PreCompact. + for (const event of OBSERVATION_EVENTS) { + api.on( + event, + async (payload, ctx) => { + const p = payload || {}; + await ask(event, { + ...baseMeta(payload, ctx, api.config, workspace), + tool_name: p.toolName, + tool_input: p.params, + tool_response: p.result, + reason: p.reason, + hook_event_name: event, + }); + if (event === "session_end") { + retryGate.clear(payload, ctx); + workspace.clear(payload, ctx); + } + return undefined; + }, + HOOK_OPTIONS, + ); + } +} diff --git a/openclaw-plugin/index.js b/openclaw-plugin/index.js index 8b163dc25..e1cf93d65 100644 --- a/openclaw-plugin/index.js +++ b/openclaw-plugin/index.js @@ -4,230 +4,36 @@ * Loaded in-process by the gateway (registered via `plugins.load.paths` + * `plugins.entries.failproofai` in ~/.openclaw/openclaw.json). It subscribes to * OpenClaw's typed PLUGIN hooks — the only surface that can block; OpenClaw's - * file-based "internal hooks" are observation-only — and forwards each to the - * failproofai binary as `failproofai --hook --cli openclaw`. failproofai - * prints a flat `{permission, reason}` verdict on stdout; this shim maps it to - * each hook's native return shape. On before_tool_call, an `instruct` verdict - * uses a one-shot blockReason as OpenClaw's model-visible instruction channel; - * retries from the same session/policy are allowed for a short window. + * file-based "internal hooks" are observation-only — and asks failproofaid for + * each verdict over its Unix socket (daemon-client.js), the way the Hermes + * plugin does. The daemon answers with the flat `{permission, reason}` verdict + * `failproofai --hook --cli openclaw` prints; hooks.js maps it to each + * hook's native return shape. On before_tool_call, an `instruct` verdict uses a + * one-shot blockReason as OpenClaw's model-visible instruction channel; retries + * from the same session/policy are allowed for a short window. * * Marker comment for failproofai's installer detection (do not remove): * __failproofai_hook__: true * - * Design notes (verified live against openclaw v2026.7.1): - * • ASYNC spawn, never spawnSync — the gateway is a long-running multi-channel - * process; a synchronous spawn on every hook would stall every channel. - * 30s guard timer; fail-open on any error (never block on infra failure). - * • Raw forwarding — we send the raw tool name / params and a Claude-shaped - * stdin (params→tool_input, toolName→tool_name, transcriptPath→ - * transcript_path, stopHookActive→stop_hook_active, sessionKey→session_id). - * The binary canonicalizes via OPENCLAW_* maps (single source of truth); the - * shim ships NO inline maps (unlike the OpenCode/Pi shims). + * Design notes: + * • No process per hook, and no path outside this directory. OpenClaw ≥ + * 2026.9.6 imports plugins from a copy under ~/.openclaw/tmp/plugin-captures, + * so a CLI located relative to this file is not there; the shim that spawned + * one allowed every call once that started. Every module here imports only + * node built-ins and its siblings. + * • Non-blocking — the gateway is a long-running multi-channel process, so all + * I/O is async; a synchronous call on every hook would stall every channel. * • No top-level `await` — OpenClaw's plugin loader rejects it; all async work * happens inside register()'s handlers. - * - * Binary resolution mirrors pi-extension/index.ts: prefer the bundled - * dist/cli.mjs (node-compatible), fall back to bin/failproofai.mjs with bun for - * dev; honor FAILPROOFAI_BINARY_OVERRIDE. Paths resolve relative to this file - * via import.meta.url (the gateway spawns plugins with an undefined cwd). */ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createInstructRetryGate, mapBeforeToolVerdict } from "./instruct-retry-gate.js"; -import { createWorkspaceContext } from "./workspace-context.js"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const DIST_BIN = resolve(HERE, "..", "dist", "cli.mjs"); -const SRC_BIN = resolve(HERE, "..", "bin", "failproofai.mjs"); -const instructRetryGate = createInstructRetryGate(); -const workspaceContext = createWorkspaceContext(); - -function resolveSpawn() { - if (process.env.FAILPROOFAI_BINARY_OVERRIDE) { - return { cmd: "node", args: [process.env.FAILPROOFAI_BINARY_OVERRIDE] }; - } - if (existsSync(DIST_BIN)) { - return { cmd: "node", args: [DIST_BIN] }; - } - return { cmd: "bun", args: [SRC_BIN] }; -} - -function debug(msg) { - if (process.env.FAILPROOFAI_OPENCLAW_DEBUG === "1") { - process.stderr.write(`[failproofai-openclaw-shim] ${msg}\n`); - } -} - -/** - * Spawn `failproofai --hook --cli openclaw`, write the JSON payload - * to stdin, and resolve the parsed flat `{permission, reason}` verdict. Async + - * fail-open: any spawn/parse error or timeout resolves to `{permission:"allow"}` - * so a failproofai fault never blocks the gateway. - */ -function callPolicy(rawEvent, payload) { - return new Promise((done) => { - const { cmd, args } = resolveSpawn(); - let child; - try { - child = spawn(cmd, [...args, "--hook", rawEvent, "--cli", "openclaw"], { - stdio: ["pipe", "pipe", "pipe"], - }); - } catch (err) { - debug(`spawn threw: ${err && err.message ? err.message : String(err)}`); - done({ permission: "allow" }); - return; - } - let out = ""; - let settled = false; - const finish = (verdict) => { - if (settled) return; - settled = true; - clearTimeout(timer); - done(verdict); - }; - const timer = setTimeout(() => { - debug(`timeout on ${rawEvent}; killing child`); - try { child.kill(); } catch { /* ignore */ } - finish({ permission: "allow" }); - }, 30_000); - child.stdout.on("data", (d) => { out += d; }); - child.on("error", (err) => { - debug(`child error: ${err && err.message ? err.message : String(err)}`); - finish({ permission: "allow" }); - }); - child.on("close", () => { - const stdout = out.trim(); - if (!stdout) { finish({ permission: "allow" }); return; } - try { - finish(JSON.parse(stdout)); - } catch (err) { - debug(`parse error: ${err && err.message ? err.message : String(err)}`); - finish({ permission: "allow" }); - } - }); - try { - child.stdin.end(JSON.stringify(payload)); - } catch (err) { - debug(`stdin write failed: ${err && err.message ? err.message : String(err)}`); - finish({ permission: "allow" }); - } - }); -} - -/** Claude-shaped stdin base. Extra keys are ignored by the binary's payload - * parser; they're forwarded for future use / activity attribution. */ -function baseMeta(payload, ctx, config) { - const p = payload || {}; - const c = ctx || {}; - return { - session_id: c.sessionId ?? p.sessionId ?? c.sessionKey ?? p.sessionKey, - cwd: workspaceContext.resolveWorkspace(p, c, config) ?? process.cwd(), - transcript_path: p.transcriptPath, - stop_hook_active: p.stopHookActive === true, - openclaw: { - agentId: c.agentId, - sessionKey: c.sessionKey ?? p.sessionKey, - runId: c.runId ?? p.runId, - provider: p.provider, - model: p.model, - }, - }; -} +import { registerFailproofaiHooks } from "./hooks.js"; export default definePluginEntry({ id: "failproofai", name: "failproofai", description: "Real-time policy enforcement for OpenClaw by failproofai", register(api) { - // before_tool_call → PreToolUse. Deny always blocks. Instruct blocks the - // first matching attempt only, using blockReason to give the model the - // instruction, then permits retries from that session/policy for 5 minutes. - api.on( - "before_tool_call", - async (payload, ctx) => { - const p = payload || {}; - const verdict = await callPolicy("before_tool_call", { - ...baseMeta(payload, ctx, api.config), - tool_name: p.toolName, - tool_input: p.params, - hook_event_name: "before_tool_call", - }); - return mapBeforeToolVerdict(verdict, payload, ctx, instructRetryGate); - }, - { priority: 100, timeoutMs: 60_000 }, - ); - - // before_agent_run → UserPromptSubmit. Deny: return {outcome:"block", reason}. - api.on( - "before_agent_run", - async (payload, ctx) => { - const p = payload || {}; - workspaceContext.remember(payload, ctx); - const verdict = await callPolicy("before_agent_run", { - ...baseMeta(payload, ctx, api.config), - prompt: p.prompt, - hook_event_name: "before_agent_run", - }); - if (verdict.permission === "deny") { - return { outcome: "block", reason: verdict.reason || "Blocked by failproofai" }; - } - return undefined; - }, - { priority: 100, timeoutMs: 60_000 }, - ); - - // before_agent_finalize → Stop. The real turn-end gate: a deny becomes a - // revise so the agent runs another pass. The evaluator supplies the - // MANDATORY ACTION wording; the payload's stop_hook_active guards the - // require-*-before-stop builtins against infinite loops. - api.on( - "before_agent_finalize", - async (payload, ctx) => { - const verdict = await callPolicy("before_agent_finalize", { - ...baseMeta(payload, ctx, api.config), - hook_event_name: "before_agent_finalize", - }); - if (verdict.permission === "deny") { - return { action: "revise", reason: verdict.reason || "Action required by failproofai" }; - } - return undefined; - }, - { priority: 100, timeoutMs: 60_000 }, - ); - - // Observation hooks — await so activity ordering is stable, ignore verdict. - // after_tool_call → PostToolUse; session_start/end → SessionStart/End; - // subagent_ended → SubagentStop (obs only); before_compaction → PreCompact. - for (const ev of [ - "after_tool_call", - "session_start", - "session_end", - "subagent_ended", - "before_compaction", - ]) { - api.on( - ev, - async (payload, ctx) => { - const p = payload || {}; - await callPolicy(ev, { - ...baseMeta(payload, ctx, api.config), - tool_name: p.toolName, - tool_input: p.params, - tool_response: p.result, - reason: p.reason, - hook_event_name: ev, - }); - if (ev === "session_end") { - instructRetryGate.clear(payload, ctx); - workspaceContext.clear(payload, ctx); - } - return undefined; - }, - { priority: 100, timeoutMs: 60_000 }, - ); - } + registerFailproofaiHooks(api); }, }); diff --git a/package.json b/package.json index 66d88efdb..2414b40ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "1.0.13-beta.0", + "version": "1.0.13", "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.", "bin": { "failproofai": "./dist/cli.mjs", diff --git a/sdk/python/uv.lock b/sdk/python/uv.lock index 373511ada..eccfed17f 100644 --- a/sdk/python/uv.lock +++ b/sdk/python/uv.lock @@ -2534,7 +2534,7 @@ wheels = [ [[package]] name = "nltk" -version = "3.10.3" +version = "3.10.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -2543,9 +2543,9 @@ dependencies = [ { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/cb/bbaa9be58e95d21729c6b9194376610230e0755f6de0bf38b6acc0dce079/nltk-3.10.4.tar.gz", hash = "sha256:44679f4e9220abfd496d2c8cdb22f8425ccbe557604f9aa5cacb1c44003dabc1", size = 4300570, upload-time = "2026-10-21T11:34:35.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/e7816813e409ca874ba2fcc35455db0e4130b79eb277e1f9edd270ca3c69/nltk-3.10.4-py3-none-any.whl", hash = "sha256:769ba5413f265b7d3e87cd163cc28efa01e29eeef8bb6080bc907949d9fcfb51", size = 1722994, upload-time = "2026-10-21T11:34:27.975Z" }, ] [[package]] diff --git a/src/hooks/daemon-service.ts b/src/hooks/daemon-service.ts index 742c5c498..c81fa5c10 100644 --- a/src/hooks/daemon-service.ts +++ b/src/hooks/daemon-service.ts @@ -13,16 +13,19 @@ import { mkdirSync, mkdtempSync, readFileSync, + readdirSync, + statSync, writeFileSync, unlinkSync, rmSync, } from "node:fs"; import { homedir, tmpdir, userInfo } from "node:os"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { execFileSync } from "node:child_process"; import { hookLogWarn } from "./hook-logger"; import { downloadFailproofaidBinary, installFromNpmPackage, installedBinaryPath } from "./daemon-download"; import { logsDir } from "./fp-home"; +import { shellQuote } from "./shell-quote"; import { version } from "../../package.json"; import { readVersionFile, updateConfig, writeVersionFile } from "./fp-config"; @@ -288,11 +291,6 @@ export function resolveCliCommand(): string | null { return `${shellQuote(process.execPath)} ${shellQuote(cliScript)}`; } -/** POSIX single-quoting: everything is literal inside '…', and a literal - * quote is closed, escaped and reopened. */ -function shellQuote(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} /** * The raw, unquoted path to the worker script the service will run — the same @@ -413,6 +411,142 @@ function systemdUnitPath(user: string = serviceUser()): string { return resolve(systemdUnitDir(), systemdUnitName(user)); } +/** + * Where a Linux install puts the copy of the daemon that systemd actually runs: + * `/opt/failproofai//bin/failproofaid-`, root-owned. + * + * The binary is downloaded into `~/.failproofai/bin`, and a unit whose + * `ExecStart` points there works on Debian, Ubuntu and Mint — and on Fedora, + * RHEL, Rocky and Alma it never starts. Those ship SELinux enforcing, a file + * under a home directory is labelled `user_home_t`, and the targeted policy + * gives systemd (`init_t`) no execute permission on that type at all. The unit + * fails `203/EXEC Permission denied`, `waitForDaemonRunning()` times out, and + * `failproofai config` aborts at its very last step. + * + * The policy's generic `/opt/(.*\/)?bin(/.*)?` rule labels this path `bin_t`, + * which systemd may execute, and executing it moves the daemon into + * `unconfined_service_t` — the domain that may in turn run the worker's node + * from `~/.nvm` (also `user_home_t`) and accept hooks on the socket. So only + * the daemon binary moves; the worker command, the socket and the state under + * `~/.failproofai` stay where they are. Not `/usr/local/libexec`: both + * policies label that `usr_t`, which starts the daemon only through a + * secondary `init_t usr_t → unconfined_service_t` rule, where `bin_t` is the + * type the policy means for programs. Not `/usr/libexec` either: `/usr` is the + * package manager's, and read-only on Silverblue and CoreOS. All checked + * against the shipped policy on Fedora 41 and Rocky 9 with `matchpathcon` and + * `sesearch`. + * + * Per user for the same reason the unit name is: two people on one box may run + * different versions, and one's uninstall must not delete the other's binary. + * The `~/.failproofai/bin` copy is kept — `daemonVersionSkew()` and + * `refreshDaemonToCliVersion()` read the installed version from it. + * + * Redirectable for tests ONLY, like the unit directory. + */ +function systemBinaryRoot(user: string = serviceUser()): string { + return resolve(process.env.FAILPROOFAI_SYSTEM_BIN_DIR || "/opt/failproofai", user); +} + +function systemBinaryDir(): string { + return resolve(systemBinaryRoot(), "bin"); +} + +/** The system binary directory on Linux, for the manual-removal instructions. */ +export function daemonSystemBinaryDir(): string | null { + return process.platform === "linux" ? systemBinaryRoot() : null; +} + +/** + * The path systemd should start for `binaryPath`, or null when it should start + * `binaryPath` itself. + * + * Only a real file is copied. `FAILPROOFAI_DAEMON_BINARY` is "someone named a + * binary explicitly" and may be a command with arguments (this repo's own + * systemd tests use `/usr/bin/sleep infinity`), so it is started as given. + */ +export function systemBinaryTarget(binaryPath: string): string | null { + if (process.platform !== "linux" || process.env.FAILPROOFAI_DAEMON_BINARY) return null; + try { + if (!statSync(binaryPath).isFile()) return null; + } catch { + return null; + } + return resolve(systemBinaryDir(), `failproofaid-${version}`); +} + +/** + * Whether this kernel has SELinux enabled. `/sys/fs/selinux` is mounted only + * when it is, enforcing or permissive. + */ +function selinuxEnabled(): boolean { + return existsSync("/sys/fs/selinux/enforce"); +} + +/** + * Copies the daemon binary into the system directory and returns the path the + * unit should start. Privileged; throws when the copy fails, which the caller + * reports as a failed install before any unit is written. + * + * Staged under a temp name and renamed into place, so a reinstall of the same + * version never writes into the file a running daemon was started from. + * + * `restorecon` afterwards, because a new file inherits the label of the + * directory it is created in, not the one the policy assigns to its path: the + * directories `install -d` creates under `/opt` (`usr_t`) come out `usr_t` + * too, never the `bin_t` the policy names for them. + */ +function stageSystemBinary(binaryPath: string): string { + const target = systemBinaryTarget(binaryPath); + if (!target) return binaryPath; + const dir = systemBinaryDir(); + const staging = `${target}.${process.pid}.tmp`; + runPrivileged("install", ["-d", "-m", "0755", dir]); + try { + runPrivileged("install", ["-m", "0755", binaryPath, staging]); + runPrivileged("mv", ["-f", staging, target]); + } catch (err) { + try { + runPrivileged("rm", ["-f", staging]); + } catch { + /* best-effort */ + } + throw err; + } + if (selinuxEnabled()) { + const root = systemBinaryRoot(); + try { + runPrivileged("restorecon", ["-R", root]); + } catch (err) { + // Not fatal here: a wrong label still surfaces as the daemon not + // reaching a running state, which fails the install with its status. + hookLogWarn(`restorecon on ${root} failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + return target; +} + +/** + * Removes system copies other than `current` and the most recent one before + * it — the same keep-one-for-rollback rule `pruneOldDaemonBinaries()` applies + * to `~/.failproofai/bin`. Best-effort; only called once the service runs + * `current`. + */ +function pruneSystemBinaries(current: string): void { + try { + const dir = systemBinaryDir(); + const stale = readdirSync(dir) + .filter((f) => f.startsWith("failproofaid-")) + .map((f) => resolve(dir, f)) + .filter((p) => p !== current) + .map((p) => ({ p, mtime: statSync(p).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime) + .slice(1); + for (const { p } of stale) runPrivileged("rm", ["-f", p]); + } catch { + // Nothing to prune, or no elevation left — leaving a file costs disk only. + } +} + /** * The pre-1.0.0-beta.1 user-scope unit. Still removed on install and * uninstall: it holds the same flock the new service needs, so leaving one @@ -792,8 +926,21 @@ function daemonInstallCommands( cliCmd: string | null, ): string[] { if (process.platform === "linux") { + const target = systemBinaryTarget(binaryPath); + const dir = systemBinaryDir(); + const stage = target + ? [ + // Quoted: a home directory may hold a space, and an unquoted path + // splits into words that stop this `&&`-joined recipe before the + // unit is ever written. + `sudo install -d -m 0755 ${shellQuote(dir)}`, + `sudo install -m 0755 ${shellQuote(binaryPath)} ${shellQuote(target)}`, + `{ sudo restorecon -R ${shellQuote(systemBinaryRoot())} 2>/dev/null || true; }`, + ] + : []; return [ - `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(binaryPath, workerCmd, cliCmd)}EOF`, + ...stage, + `sudo tee ${systemdUnitPath()} <<'EOF'\n${systemdUnitContents(target ?? binaryPath, workerCmd, cliCmd)}EOF`, "sudo systemctl daemon-reload", `sudo systemctl enable ${systemdUnitName()}`, // `restart`, not `enable --now`, for the reason the install path gives: @@ -865,9 +1012,13 @@ export async function installDaemonService(): Promise { // never came up. Clear it first, every time. removeLegacyUserService(); + let execPath = binaryPath; try { if (process.platform === "linux") { - writePrivilegedFile(systemdUnitPath(), systemdUnitContents(binaryPath, workerCmd, cliCmd)); + // Before the unit is written, so a copy that fails leaves the previous + // unit and the daemon it runs untouched. See `systemBinaryDir()`. + execPath = stageSystemBinary(binaryPath); + writePrivilegedFile(systemdUnitPath(), systemdUnitContents(execPath, workerCmd, cliCmd)); runPrivileged("systemctl", ["daemon-reload"]); // `enable` for boot persistence, then `restart` — NOT `enable --now`. // @@ -927,6 +1078,7 @@ export async function installDaemonService(): Promise { hookLogWarn(`daemon service install failed: ${reason}`); return { installed: false, reason }; } + if (execPath !== binaryPath) pruneSystemBinaries(execPath); // NOTE: this reports that the SERVICE is installed and running, which is all // it can honestly claim. Whether the daemon can actually evaluate a hook is a @@ -1683,6 +1835,18 @@ export async function uninstallDaemonService(): Promise { } catch { // Best-effort. } + // The copy systemd ran. After the unit is gone, so nothing enabled is + // left pointing at a deleted file. The shared parent goes only if no + // other user's directory is in it. + const binRoot = systemBinaryRoot(); + if (existsSync(binRoot)) { + runPrivileged("rm", ["-rf", binRoot]); + try { + runPrivileged("rmdir", [dirname(binRoot)]); + } catch { + // Not empty — another user's daemon lives there. + } + } } else { const plistPath = launchdPlistPath(); if (!existsSync(plistPath)) return; diff --git a/src/hooks/enforcement-capability.ts b/src/hooks/enforcement-capability.ts index e73cc45a1..38f2bf5e2 100644 --- a/src/hooks/enforcement-capability.ts +++ b/src/hooks/enforcement-capability.ts @@ -182,7 +182,7 @@ export const ENFORCEMENT_CAPABILITY: Record< // ── openclaw ────────────────────────────────────────────────────────────── // openclaw/openclaw @f8ed8ecf (v2026.7.2) + our own shipped shim - // openclaw-plugin/index.js, which returns undefined for 5 of the 8 hooks. + // openclaw-plugin/hooks.js, which returns undefined for 5 of the 8 hooks. openclaw: { PreToolUse: "block", // before_tool_call -> shim returns {block:true, blockReason} UserPromptSubmit: "block", // before_agent_run -> shim returns {outcome:"block", reason} diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index 6ea8df95a..d055102c0 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -2277,8 +2277,10 @@ export function unhookedHermesProfiles(): string[] { // OpenClaw is a self-hosted assistant gateway. Enforcement is via its in-process // PLUGIN hooks (file-based "internal hooks" are observation-only), so failproofai // ships a static plugin package (`openclaw-plugin/`, like Pi's pi-extension) that -// async-spawns the binary and maps verdicts back. Install registers the plugin in -// every profile's `openclaw.json` (JSON): +// asks failproofaid for each verdict over its Unix socket — like the Hermes plugin, +// and so likewise daemon-only — and maps verdicts back. It cannot spawn the CLI: +// OpenClaw ≥ 2026.9.6 loads plugins from a copy that holds only the plugin dir. +// Install registers the plugin in every profile's `openclaw.json` (JSON): // • plugins.load.paths[] → the shipped openclaw-plugin dir (absolute path) // • plugins.entries.failproofai = { enabled: true, hooks: { allowConversationAccess: true } } // (allowConversationAccess is required for the raw-conversation hooks @@ -2318,6 +2320,7 @@ function isFailproofaiOpenClawPath(p: unknown): boolean { export const openclaw: Integration = { id: "openclaw", displayName: "OpenClaw", + requiresHealthyDaemon: true, scopes: OPENCLAW_HOOK_SCOPES, eventTypes: OPENCLAW_HOOK_EVENT_TYPES, diff --git a/src/hooks/shell-quote.ts b/src/hooks/shell-quote.ts new file mode 100644 index 000000000..ac2fb2d24 --- /dev/null +++ b/src/hooks/shell-quote.ts @@ -0,0 +1,9 @@ +/** + * POSIX single-quoting: everything is literal inside '…', and a literal quote + * is closed, escaped and reopened. For every path this package puts into a + * shell command — the service's worker command, and the recipes printed for + * someone to run as root when the install or uninstall could not elevate. + */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/src/hooks/uninstall-cli.ts b/src/hooks/uninstall-cli.ts index 2296fdd8b..d0c17824b 100644 --- a/src/hooks/uninstall-cli.ts +++ b/src/hooks/uninstall-cli.ts @@ -39,6 +39,7 @@ import { daemonServiceFilePath, daemonServiceStatus, daemonStatusCommand, + daemonSystemBinaryDir, isDaemonSupportedPlatform, primeElevation, setDaemonConfigured, @@ -47,6 +48,7 @@ import { import { listInstallableIds, getIntegration } from "./integrations"; import type { IntegrationType } from "./types"; import { removeHooks } from "./manager"; +import { shellQuote } from "./shell-quote"; import { installedAgentSkillDirs, removeAgentSkill } from "./agent-skill"; import { CODEX_MANAGED_BEGIN, CODEX_MANAGED_END, codexSystemConfigPath, codexSystemHooksPresent } from "./codex-system-hooks"; import { optsFor, screenKit, type RenderOpts } from "./tui"; @@ -411,16 +413,20 @@ export async function runUninstallCommand(opts: UninstallOptions = {}): Promise< return { exitCode: 0, lines, planLines, purged }; } -/** The exact commands to finish a service removal that could not elevate. */ +/** The exact commands to finish a service removal that could not elevate. The + * paths are quoted: these are pasted into a root shell, and an unquoted path + * holding a space would make `rm -rf` remove something else. */ function manualServiceRemoval(servicePath: string): string[] { if (process.platform === "darwin") { - return [`sudo launchctl unload -w ${servicePath}`, `sudo rm -f ${servicePath}`]; + return [`sudo launchctl unload -w ${shellQuote(servicePath)}`, `sudo rm -f ${shellQuote(servicePath)}`]; } const unit = servicePath.split("/").pop() ?? servicePath; + const binDir = daemonSystemBinaryDir(); return [ `sudo systemctl disable --now ${unit}`, - `sudo rm -f ${servicePath}`, + `sudo rm -f ${shellQuote(servicePath)}`, `sudo systemctl daemon-reload`, + ...(binDir ? [`sudo rm -rf ${shellQuote(binDir)}`] : []), ]; }