Skip to content
Closed
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# Changelog

## 1.0.13-beta.0 — 2026-10-27
## 1.0.13-beta.3 — 2026-10-28

### Features

- **`fp-cloud-cli` cuts `0.0.2` stable.** The `0.0.2b0` line opened after `0.0.1` was never published; it is cut stable rather than published, so `pipx install fp-cloud-cli` resolves the `fp` that drives what FailproofAI Cloud now serves (entries in `fp-cloud-cli/CHANGELOG.md`). This is the PyPI package's own line and does not move the npm version, which stays at `1.0.13-beta.0` (#936)

### 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)

### Dependencies

- `failproofai-sdk` drops Python 3.10, which reached end of life this month, clearing the 47 Dependabot alerts against its dev/test lockfile. `pillow` (41) and `filelock` (6) are fixed only in releases that require Python 3.11 (13.0.0 and 4.2.1), so the lockfile's 3.10 slice was held on 12.3.0 and 4.1.0 behind two version-pinned OSV exceptions due to expire on 2026-11-20. `requires-python` is now `>=3.11`, the SDK's CI matrix runs 3.11–3.14, both exceptions are gone, and the 3.10-only `tomli` and `onnxruntime` entries leave the `dev` and `crewai` extras. On 3.10, pip keeps resolving `0.0.1b6`; `fp-cloud-cli` still supports 3.10 (#953)
Expand Down
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -926,10 +926,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 +968,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-beta.3"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
117 changes: 116 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,76 @@ 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();
});

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 +942,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
5 changes: 4 additions & 1 deletion __tests__/hooks/uninstall-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
Expand Down Expand Up @@ -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 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",
]);
});
Expand Down
2 changes: 1 addition & 1 deletion hermes-plugin/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: failproofai
version: 1.0.13-beta.0
version: 1.0.13-beta.3
description: Real-time FailproofAI policy enforcement for Hermes
manifest_version: 2
api_version: 1
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "failproofai",
"version": "1.0.13-beta.0",
"version": "1.0.13-beta.3",
"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",
Expand Down
Loading
Loading