Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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-<version>`, failed `203/EXEC` and never reached a running state. On Linux the install now copies the binary as root to `/opt/failproofai/<user>/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
Expand Down
51 changes: 38 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }`
Expand All @@ -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/<id>/…/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 <event> --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):
Expand Down Expand Up @@ -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/<user>/bin/failproofaid-<version>` (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/<user>/bin`, HOME, the worker command). Reading status needs no
privileges: `systemctl status failproofaid@<user>`, exposed as `daemonStatusCommand()`.

### How the daemon binary reaches users
Expand Down Expand Up @@ -959,7 +982,9 @@ the CLI talking to it.
Both channels land the file at `~/.failproofai/bin/failproofaid-<version>` 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/<user>/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.

Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
158 changes: 157 additions & 1 deletion __tests__/hooks/daemon-service.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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/<user>/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<typeof import("node:child_process")>()),
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");
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions __tests__/hooks/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
});

Expand Down
Loading
Loading