From 85caba6985d3efc11821ea9cafdd68be334a4106 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Tue, 27 Oct 2026 17:57:47 +0530 Subject: [PATCH 1/4] fix(daemon): run the systemd unit from /opt, not the home directory On Fedora, RHEL, Rocky and Alma, SELinux labels files under /home user_home_t and gives systemd (init_t) no execute permission on that type, so a unit whose ExecStart pointed at ~/.failproofai/bin/failproofaid- failed 203/EXEC and `failproofai config` aborted at its last step. On Linux the install now copies the binary as root to /opt/failproofai//bin/failproofaid- (bin_t under the policy's generic /opt/(.*/)?bin rule; restorecon re-applies it, since a new file inherits its directory's usr_t) and points ExecStart there. The daemon then runs as unconfined_service_t, which may run the worker's node from ~/.nvm and accept hooks on the socket. The home copy stays for the version checks; install prunes the /opt copy to current + one, and uninstall removes it. macOS is unchanged. Verified against the shipped policies (matchpathcon/sesearch on Fedora 41 and Rocky 9) and end to end on real systemd: the real daemon + worker installed by a sudo user on Fedora 41 answers the hook and policy probes; the root-only lifecycle suite passes on Fedora 41 and Ubuntu 24.04. --- CHANGELOG.md | 4 + CLAUDE.md | 15 ++- __tests__/hooks/daemon-service.test.ts | 117 ++++++++++++++++- __tests__/hooks/uninstall-cli.test.ts | 5 +- src/hooks/daemon-service.ts | 171 ++++++++++++++++++++++++- src/hooks/uninstall-cli.ts | 3 + 6 files changed, 308 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1277f9307..75d6b5dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - **`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-`, 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 (#PR) + ### 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) diff --git a/CLAUDE.md b/CLAUDE.md index b09272271..c5c881e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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 +968,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/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts index 4b6eb3f85..67efe0dbe 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,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//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"); @@ -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 diff --git a/__tests__/hooks/uninstall-cli.test.ts b/__tests__/hooks/uninstall-cli.test.ts index 897ded16b..fafd27e93 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 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/src/hooks/daemon-service.ts b/src/hooks/daemon-service.ts index 742c5c498..7070c5c0c 100644 --- a/src/hooks/daemon-service.ts +++ b/src/hooks/daemon-service.ts @@ -13,12 +13,14 @@ 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"; @@ -413,6 +415,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 +930,18 @@ function daemonInstallCommands( cliCmd: string | null, ): string[] { if (process.platform === "linux") { + const target = systemBinaryTarget(binaryPath); + const dir = systemBinaryDir(); + const stage = target + ? [ + `sudo install -d -m 0755 ${dir}`, + `sudo install -m 0755 ${binaryPath} ${target}`, + `{ sudo restorecon -R ${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 +1013,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 +1079,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 +1836,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/uninstall-cli.ts b/src/hooks/uninstall-cli.ts index 2296fdd8b..f5989daa4 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, @@ -417,10 +418,12 @@ function manualServiceRemoval(servicePath: string): string[] { return [`sudo launchctl unload -w ${servicePath}`, `sudo rm -f ${servicePath}`]; } const unit = servicePath.split("/").pop() ?? servicePath; + const binDir = daemonSystemBinaryDir(); return [ `sudo systemctl disable --now ${unit}`, `sudo rm -f ${servicePath}`, `sudo systemctl daemon-reload`, + ...(binDir ? [`sudo rm -rf ${binDir}`] : []), ]; } From aeffbd3987467c44ed9f9447de9bcf947769aef7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Tue, 27 Oct 2026 18:18:13 +0530 Subject: [PATCH 2/4] docs(changelog): link #957 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d6b5dd2..d0a3e4791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 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 (#PR) +- `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) ### Dependencies From c15b64e35bbc651ef4230d0face4863ac8f508e7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 28 Oct 2026 17:02:46 +0530 Subject: [PATCH 3/4] chore: bump to 1.0.13-beta.2 for the Fedora beta 1.0.13-beta.1 was published from another branch, and main's 1.0.13-beta.0 slot should stay free for main. --- CHANGELOG.md | 2 +- Cargo.lock | 6 +++--- Cargo.toml | 2 +- hermes-plugin/plugin.yaml | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a3e4791..217782e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.13-beta.0 — 2026-10-27 +## 1.0.13-beta.2 — 2026-10-28 ### Features diff --git a/Cargo.lock b/Cargo.lock index 5fd2dd1ce..780bf2874 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-beta.2" dependencies = [ "fpai-collect", "fpai-ipc", @@ -361,7 +361,7 @@ dependencies = [ [[package]] name = "fpai-collect" -version = "1.0.13-beta.0" +version = "1.0.13-beta.2" 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-beta.2" dependencies = [ "libc", "proptest", diff --git a/Cargo.toml b/Cargo.toml index b849a4d33..50147554a 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-beta.2" edition = "2024" license-file = "LICENSE" repository = "https://github.com/FailproofAI/failproofai" diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml index be7d1ca00..9033ab385 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-beta.2 description: Real-time FailproofAI policy enforcement for Hermes manifest_version: 2 api_version: 1 diff --git a/package.json b/package.json index 66d88efdb..c813f4840 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "1.0.13-beta.0", + "version": "1.0.13-beta.2", "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", From 70d96abbcb97d6cf84eb222c19d237c0b2be4cde Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Wed, 28 Oct 2026 17:37:57 +0530 Subject: [PATCH 4/4] chore: bump to 1.0.13-beta.3 1.0.13-beta.2 was published from this branch for the Fedora test. --- CHANGELOG.md | 2 +- Cargo.lock | 6 +++--- Cargo.toml | 2 +- hermes-plugin/plugin.yaml | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217782e81..5cfbc0e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.13-beta.2 — 2026-10-28 +## 1.0.13-beta.3 — 2026-10-28 ### Features diff --git a/Cargo.lock b/Cargo.lock index 780bf2874..d07d214a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,7 @@ dependencies = [ [[package]] name = "failproofaid" -version = "1.0.13-beta.2" +version = "1.0.13-beta.3" dependencies = [ "fpai-collect", "fpai-ipc", @@ -361,7 +361,7 @@ dependencies = [ [[package]] name = "fpai-collect" -version = "1.0.13-beta.2" +version = "1.0.13-beta.3" dependencies = [ "axum", "getrandom 0.4.3", @@ -382,7 +382,7 @@ dependencies = [ [[package]] name = "fpai-ipc" -version = "1.0.13-beta.2" +version = "1.0.13-beta.3" dependencies = [ "libc", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 50147554a..203bd902b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "1.0.13-beta.2" +version = "1.0.13-beta.3" edition = "2024" license-file = "LICENSE" repository = "https://github.com/FailproofAI/failproofai" diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml index 9033ab385..9d86dbb90 100644 --- a/hermes-plugin/plugin.yaml +++ b/hermes-plugin/plugin.yaml @@ -1,5 +1,5 @@ name: failproofai -version: 1.0.13-beta.2 +version: 1.0.13-beta.3 description: Real-time FailproofAI policy enforcement for Hermes manifest_version: 2 api_version: 1 diff --git a/package.json b/package.json index c813f4840..cb2470df4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "failproofai", - "version": "1.0.13-beta.2", + "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",