Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
# it produced was the daemon cross-compile, and only because it touched
# `crates/`. A PR that cannot go red is not a reviewed PR.
pull_request:
branches: [main, feat/fp-cli, feat/tui-ux]
branches: [main, staging, feat/fp-cli, feat/tui-ux]

concurrency:
group: ci-${{ github.ref }}
Expand Down Expand Up @@ -266,6 +266,13 @@ jobs:
if: steps.crates.outputs.present == 'true'
run: cargo clippy --workspace --all-targets -- -D warnings

# AgentEye's server depends on fpai-collect WITHOUT the `sqlite` feature
# (its sqlx links a different libsqlite3-sys). The workspace build always
# has the feature on, so nothing else here would notice that shape break.
- name: cargo clippy (fpai-collect without sqlite)
if: steps.crates.outputs.present == 'true'
run: cargo clippy -p fpai-collect --no-default-features --all-targets -- -D warnings

- name: cargo test
if: steps.crates.outputs.present == 'true'
run: cargo test --workspace
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/osv-scanner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ name: Supply Chain

on:
pull_request:
branches: [main, feat/tui-ux]
branches: [main, staging, feat/tui-ux]
push:
branches: [main]
schedule:
Expand Down
103 changes: 103 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

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.12-beta.0"
version = "1.0.12"
edition = "2024"
license-file = "LICENSE"
repository = "https://github.com/FailproofAI/failproofai"
27 changes: 19 additions & 8 deletions __tests__/actions/jev-mode-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ function seed(obj: Record<string, unknown>, extra = ""): string {
}
const onDisk = () => JSON.parse(readFileSync(jevConfigFile(), "utf8")) as Record<string, unknown>;

function connect(withJev = true) {
function connect(withJev = true, policies = true) {
writeCredentials({
cloud: { url: ORIGIN, machineId: "m-1", token: POLICY_KEY },
...(policies ? { cloud: { url: ORIGIN, machineId: "m-1", token: POLICY_KEY } } : {}),
// A Jev slot counts only beside a same-origin credential holding its key:
// connecting a Jev key writes it to the reporting credential too.
ingest: { url: `${ORIGIN}/v1/events`, key: withJev ? CLOUD_KEY : INGEST_KEY },
Expand Down Expand Up @@ -89,8 +89,19 @@ afterEach(() => {
});

describe("setJevModeAction", () => {
it("rewrites mode and nothing else, at 0600", async () => {
it("a machine connected for policies: FailproofAI Cloud sets the mode, nothing is written", async () => {
connect();
const before = seed(CLOUD_FILE);
for (const mode of ["enforce", "off", "observe"] as const) {
const res = await setJevModeAction(mode);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.problem).toContain("managed by FailproofAI Cloud");
}
expect(readFileSync(jevConfigFile(), "utf8")).toBe(before);
});

it("rewrites mode and nothing else, at 0600", async () => {
connect(true, false);
// A field this build does not know, and one it does not show: both must survive.
seed({ ...CLOUD_FILE, timeoutMs: 2500, fromANewerBuild: { x: 1 } });
for (const mode of ["enforce", "off", "observe"] as const) {
Expand All @@ -103,7 +114,7 @@ describe("setJevModeAction", () => {
});

it("off keeps the file and runs no Jev; on again runs it with the Cloud key", async () => {
connect();
connect(true, false);
seed(CLOUD_FILE);
const off = await setJevModeAction("off");
expect(existsSync(jevConfigFile())).toBe(true);
Expand All @@ -126,12 +137,12 @@ describe("setJevModeAction", () => {
expect(loadJevConfig()?.apiKey).toBe(BYOK_KEY);
});

it("refuses a mode the loader would refuse: enforce over plain http", async () => {
it("takes enforce over plain http to loopback, as the loader does", async () => {
writeJevCloudCredential({ url: "http://localhost:8080", key: CLOUD_KEY });
const before = seed({ ...CLOUD_FILE, baseUrl: "http://localhost:8080/enforcement/v1/jev" });
seed({ ...CLOUD_FILE, baseUrl: "http://localhost:8080/enforcement/v1/jev" });
const res = await setJevModeAction("enforce");
expect(res.ok).toBe(false);
expect(readFileSync(jevConfigFile(), "utf8")).toBe(before);
expect(res.ok).toBe(true);
expect(onDisk().mode).toBe("enforce");
});

it("refuses anything that is not a mode, and a missing file", async () => {
Expand Down
6 changes: 3 additions & 3 deletions __tests__/actions/update-jev-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,12 @@ describe("validation is the loader's, not a second copy of it", () => {
expect(loadJevConfig()).toBeNull();
});

it("refuses plain http in enforce mode, and accepts loopback http in observe", async () => {
it("accepts loopback http in enforce and observe mode", async () => {
const enforced = await saveJevConfigAction(
input({ provider: "custom", baseUrl: "http://localhost:9999", mode: "enforce" }),
);
expect(enforced.ok).toBe(false);
expect(loadJevConfig()).toBeNull();
expect(enforced.ok).toBe(true);
expect(loadJevConfig()?.mode).toBe("enforce");

const observed = await saveJevConfigAction(
input({ provider: "custom", baseUrl: "http://localhost:9999", mode: "observe" }),
Expand Down
20 changes: 20 additions & 0 deletions __tests__/audit/open-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { spawn } from "node:child_process";
import { openUrl } from "../../src/audit/open-browser";

vi.mock("node:child_process", () => ({
spawn: vi.fn(() => ({ on: vi.fn(), unref: vi.fn() })),
}));

describe("openUrl", () => {
it.each(["&", "|", "^"])("opens a Windows URL containing %s without cmd.exe", (character) => {
const url = `https://cloud.example/cli/authorize?r=a${character}b`;
openUrl(url, "win32");
expect(spawn).toHaveBeenLastCalledWith(
"rundll32.exe",
["url.dll,FileProtocolHandler", url],
{ stdio: "ignore", detached: true },
);
});
});
9 changes: 8 additions & 1 deletion __tests__/e2e/helpers/hook-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export interface HookRunResult {
export function runHook(
event: string,
payload: Record<string, unknown>,
opts?: { homeDir?: string; cli?: "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose" },
opts?: {
homeDir?: string;
cwd?: string;
agentScope?: "user" | "project" | "local";
cli?: "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "hermes" | "openclaw" | "factory" | "devin" | "antigravity" | "goose";
},
): HookRunResult {
const binaryPath = getBinaryPath();

Expand All @@ -57,9 +62,11 @@ export function runHook(

const args = [binaryPath, "--hook", event];
if (opts?.cli) args.push("--cli", opts.cli);
if (opts?.agentScope) args.push("--agent-scope", opts.agentScope);
const result = spawnSync("bun", args, {
input: JSON.stringify(payload),
env,
cwd: opts?.cwd,
encoding: "utf8",
timeout: 15_000,
});
Expand Down
78 changes: 78 additions & 0 deletions __tests__/e2e/hooks/agent-scoped-policies.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createFixtureEnv } from "../helpers/fixture-env";
import { assertAllow, assertPreToolUseDeny, runHook } from "../helpers/hook-runner";

describe("real CLI hook distinguishes simultaneous project and user profiles", () => {
it("applies targeted Cloud JS only when the installed hook carries its actual settings scope", () => {
const fixture = createFixtureEnv();
const projectSettings = join(fixture.cwd, ".claude", "settings.json");
const userSettings = join(fixture.home, ".claude", "settings.json");
mkdirSync(join(fixture.cwd, ".claude"), { recursive: true });
mkdirSync(join(fixture.home, ".claude"), { recursive: true });
writeFileSync(projectSettings, '{"hooks":"failproofai --hook PreToolUse --agent-scope project"}');
writeFileSync(userSettings, '{"hooks":"failproofai --hook PreToolUse --agent-scope user"}');

const fpHome = join(fixture.home, ".failproofai");
const rosterDir = join(fpHome, "agents");
mkdirSync(rosterDir, { recursive: true });
const projectId = "agt_1234567890abcdef";
const userId = "agt_abcdef1234567890";
const roster = join(rosterDir, "roster.json");
writeFileSync(roster, JSON.stringify({
schemaVersion: 1, generation: 2,
agents: [
{ integration: "claude", instanceId: projectId, settingsPath: projectSettings,
profileLabel: "project", scope: "project", hookInstalled: true },
{ integration: "claude", instanceId: userId, settingsPath: userSettings,
profileLabel: "user", scope: "user", hookInstalled: true },
],
}), { mode: 0o600 });
chmodSync(roster, 0o600);

const cloudDir = join(fpHome, "policies", "cloud-policies");
mkdirSync(join(cloudDir, "artifacts"), { recursive: true });
const source = `import { customPolicies, deny } from "failproofai";
customPolicies.add({
name: "only-project", description: "Only this installation",
match: { events: ["PreToolUse"] },
fn: async () => deny("project agent"),
});`;
const digest = createHash("sha256").update(source).digest("hex");
const artifact = `artifacts/${digest}.mjs`;
writeFileSync(join(cloudDir, artifact), source);
writeFileSync(join(cloudDir, "active.json"), JSON.stringify({
schemaVersion: 3, deployment: 1,
policies: [{
id: "scope-check", version: 1, sha256: digest, path: artifact, effect: "enforce",
agentTargets: [{ integration: "claude", instanceId: projectId }],
}],
}));

const payload = {
session_id: "scope-test", hook_event_name: "PreToolUse",
tool_name: "Bash", tool_input: { command: "ls" }, cwd: fixture.cwd,
};
const project = runHook("PreToolUse", payload, {
homeDir: fixture.home, cwd: fixture.cwd, agentScope: "project",
});
assertPreToolUseDeny(project);

const user = runHook("PreToolUse", payload, {
homeDir: fixture.home, cwd: fixture.cwd, agentScope: "user",
});
assertAllow(user);

const legacyAmbiguous = runHook("PreToolUse", payload, {
homeDir: fixture.home, cwd: fixture.cwd,
});
assertAllow(legacyAmbiguous);
const report = JSON.parse(readFileSync(join(cloudDir, "errors.json"), "utf8"));
expect(report.errors).toContainEqual(expect.objectContaining({
id: "agentScope", message: expect.stringContaining("agent_scope_unresolved"),
}));
});
});
7 changes: 7 additions & 0 deletions __tests__/e2e/layout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ not depend on — so they are invoked deliberately.
| `layout-reset.sh` | nothing | fresh/stale/future layout detection, reset semantics, OSS silence, hook warn-don't-delete-don't-deny |
| `cloud-collector.sh` | an AgentEye stack | real connect against a live server, daemon + collector writing layout-2 paths, decisions reaching ClickHouse, disconnect back to OSS |
| `systemd-service.sh` | Docker | REAL `systemctl enable --now` as root in a privileged container: service lifecycle, fail-closed, self-heal, reinstall |
| `cli-login.sh` | an AgentEye stack (dashboard + mailpit + its Postgres), an older Cloud server without `/api/auth/cli/*` routes, Docker, `bun run build` + the release daemon | `config`'s browser login through a real pty in a systemd container: approve, re-login, deny + retry, abandon after approval (no key minted), esc, expiry, revoked saved key, older server, and the person-side refusals (Origin, form posts, no session, start rate limit) |

`systemd-service.sh` exists because the service half cannot be tested on a
developer machine without a password prompt, and a container gives real systemd
Expand All @@ -31,6 +32,12 @@ Two things it caught that a mock could not:
bash __tests__/e2e/layout/layout-reset.sh
bash __tests__/e2e/layout/systemd-service.sh

# cli-login.sh drives the real wizard against a running AgentEye; point it at
# that stack's dashboard, mailpit API and Postgres container. OLD_CLOUD must
# answer requests but lack the CLI login routes; it defaults to localhost:28080.
DASH=http://localhost:3000 MAILPIT=http://localhost:8026 OLD_CLOUD=http://localhost:28080 PG_CONTAINER=agenteye-postgres-1 \
bash __tests__/e2e/layout/cli-login.sh

# cloud-collector.sh expects the stack on the remapped ports (18080/18123) so it
# can run alongside an existing project rather than displacing it:
docker compose -p aefpai -f docker-compose.yml -f ports.yml up -d
Expand Down
102 changes: 102 additions & 0 deletions __tests__/e2e/layout/cli-login-driver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Drive `failproofai config` through a real pseudo-terminal, screen by screen.

Used by cli-login.sh, inside the systemd container, as the unprivileged user.
The wizard only offers browser login to a real terminal, so a pty is the only
honest way to test it end to end.

Usage: cli-login-driver.py <script> <transcript-file> <url-file> -- <command...>

<script> is a comma-separated list of steps, each `wait:<text>` (block until
the screen shows <text>), `key:<name>` (enter, down, up, esc, ctrl-c, tab),
`url` (wait for the authorize link and write it to <url-file>), or
`sleep:<seconds>`. Exit status is the command's own, or 124 on a timeout.
"""
import os
import pty
import re
import select
import sys
import time

KEYS = {
"enter": b"\r",
"down": b"\x1b[B",
"up": b"\x1b[A",
"esc": b"\x1b",
"ctrl-c": b"\x03",
"tab": b"\t",
}
ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]|\x1b[78]")
LINK = re.compile(r"https?://\S+/cli/authorize\?r=[0-9a-f]{64}")
STEP_TIMEOUT = float(os.environ.get("DRIVER_STEP_TIMEOUT", "60"))


def main() -> int:
script, transcript_path, url_path = sys.argv[1], sys.argv[2], sys.argv[3]
cmd = sys.argv[sys.argv.index("--") + 1 :]
pid, fd = pty.fork()
if pid == 0:
os.environ["COLUMNS"] = "100"
os.execvp(cmd[0], cmd)

screen = b""
seen_from = 0 # `wait` only matches text that arrived after the last match

def pump(timeout: float) -> bool:
nonlocal screen
r, _, _ = select.select([fd], [], [], timeout)
if not r:
return True
try:
chunk = os.read(fd, 65536)
except OSError:
return False
if not chunk:
return False
screen += chunk
with open(transcript_path, "ab") as f:
f.write(chunk)
return True

def plain() -> str:
return ANSI.sub(b"", screen).decode("utf-8", "replace")

for step in [s for s in script.split(",") if s]:
kind, _, arg = step.partition(":")
deadline = time.time() + STEP_TIMEOUT
if kind == "wait":
while arg not in plain()[seen_from:]:
if time.time() > deadline or not pump(0.2):
print(f"driver: timed out waiting for {arg!r}", file=sys.stderr)
return 124
seen_from = plain().index(arg, seen_from) + len(arg)
elif kind == "url":
while not LINK.search(plain()[seen_from:]):
if time.time() > deadline or not pump(0.2):
print("driver: no authorize link appeared", file=sys.stderr)
return 124
m = LINK.search(plain()[seen_from:])
seen_from += m.end()
with open(url_path, "w") as f:
f.write(m.group(0))
elif kind == "key":
time.sleep(0.3) # let the screen settle so the key lands on it
os.write(fd, KEYS[arg])
elif kind == "sleep":
end = time.time() + float(arg)
while time.time() < end:
pump(0.2)

# Drain until the command exits.
deadline = time.time() + STEP_TIMEOUT * 2
while pump(0.2):
if time.time() > deadline:
print("driver: command did not exit", file=sys.stderr)
return 124
_, status = os.waitpid(pid, 0)
return os.waitstatus_to_exitcode(status)


if __name__ == "__main__":
sys.exit(main())
Loading
Loading