From 0f26c6e06f0100051d9dc1458d6f345a14bdb3d1 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 16:25:32 +0530 Subject: [PATCH 1/8] feat(enforcement): track agent profiles and enforce scoped Cloud policies --- CHANGELOG.md | 1 + .../fixtures/hermes-native-plugin-check.py | 2 + __tests__/hooks/agent-roster.test.ts | 91 ++++ __tests__/hooks/cloud-jev-policies.test.ts | 71 ++- .../hooks/cloud-managed-policies.test.ts | 53 ++ bin/failproofai.mjs | 2 + crates/failproofaid/src/agent_roster.rs | 452 ++++++++++++++++++ crates/failproofaid/src/cloud_client.rs | 181 ++++++- crates/failproofaid/src/cloud_policies.rs | 162 ++++++- crates/failproofaid/src/main.rs | 1 + crates/failproofaid/src/paths.rs | 11 + crates/failproofaid/src/server.rs | 66 ++- crates/failproofaid/src/worker.rs | 4 +- crates/fpai-ipc/src/envelope.rs | 9 + fp-cloud-cli/CHANGELOG.md | 1 + fp-cloud-cli/README.md | 20 +- fp-cloud-cli/fp_cli/commands/fleet_cmds.py | 18 +- fp-cloud-cli/fp_cli/enforcement.py | 45 +- fp-cloud-cli/fp_cli/models.py | 16 +- fp-cloud-cli/fp_cli/output.py | 40 +- fp-cloud-cli/tests/test_enforcement_logic.py | 46 ++ hermes-plugin/__init__.py | 17 + hermes-plugin/client.py | 3 + src/hooks/agent-roster.ts | 113 +++++ src/hooks/agent-targets.ts | 60 +++ src/hooks/cloud-managed-policies.ts | 42 +- src/hooks/cloud-policy-errors.ts | 8 + src/hooks/daemon-client.ts | 5 + src/hooks/effective-reviewers.ts | 7 +- src/hooks/fp-home.ts | 6 + src/hooks/handler.ts | 28 +- src/hooks/policy-registry.ts | 5 +- src/hooks/semantic/cloud-jev.ts | 17 +- src/hooks/semantic/jev-review.ts | 4 +- src/hooks/worker-server.ts | 6 +- 35 files changed, 1546 insertions(+), 67 deletions(-) create mode 100644 __tests__/hooks/agent-roster.test.ts create mode 100644 crates/failproofaid/src/agent_roster.rs create mode 100644 src/hooks/agent-roster.ts create mode 100644 src/hooks/agent-targets.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc98b995..f194c6e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Cloud-managed assignments can target integrations or individual agent profiles. The daemon keeps an owner-only, stable profile roster and reports it to FailproofAI Cloud; scoped schema-3 deployments filter JavaScript before import and send agent identity for Cloud Jev selection. `fp fleet deploy --target POLICY=INTEGRATION[/agt_ID]` narrows a machine assignment; `--all-agents POLICY` clears its scope. - Uploads, `fp` calls and evaluator calls carry a request id; the daemon also sends batch and machine ids, and `fp` errors show a `ref` (#872) - **Jev policies deploy from FailproofAI Cloud, individually, and run there.** A Cloud policy has a kind — `regex` (JavaScript, as before), `jev` (Jev checks only) or `both` (JavaScript reviewable by its own checks). Jev checks run on FailproofAI Cloud; nothing is installed on the machine: the daemon receives a `both` policy's JavaScript (with the server-derived `authority`/`reviewedBy`) and the machine's Jev mode, and nothing else Jev-related. `failproofai policies` lists `both` policies with the Cloud checks that review them. - **FailproofAI Cloud can set a machine's Jev mode.** `off` switches Jev off whatever `jev.json` says. `observe`/`enforce` send every gated tool call to FailproofAI Cloud on the machine's Cloud Jev credential (`jev.json` is not used): the machine's own questions — the global intent questions always, plus its installed packs' checks — and a `cloud` block of tool-call metadata; Cloud asks its checks for that machine in the same request and returns their verdict, which the machine merges with its packs' (Cloud first, most severe wins) before the regex combine. A `both` policy is cleared only by its own Cloud checks' outcomes, never by a pack's check of the same name. Cloud gets 5 s to answer (a local `jev.json` keeps its own timeout); a Cloud failure or timeout is today's fallback: the regex decides alone. A session pause does not stop Cloud's checks, as it never stopped Cloud JS policies: a paused session's calls still go to FailproofAI Cloud, with no installed pack's check in them. `failproofai jev status` says "Jev checks run on FailproofAI Cloud (mode: …)" and names each `both` policy's Cloud reviewers. diff --git a/__tests__/fixtures/hermes-native-plugin-check.py b/__tests__/fixtures/hermes-native-plugin-check.py index 76503424e..08678948c 100644 --- a/__tests__/fixtures/hermes-native-plugin-check.py +++ b/__tests__/fixtures/hermes-native-plugin-check.py @@ -451,10 +451,12 @@ def server() -> None: event="pre_tool_call", payload={"tool_name": "write_file", "tool_input": {"path": "/tmp/a"}}, cwd="/tmp", + agent_settings_path="/tmp/hermes-work/config.yaml", ) thread.join(timeout=2) self.assertEqual(received["type"], "policyEvaluation") self.assertEqual(received["integration"], "hermes") + self.assertEqual(received["agentSettingsPath"], "/tmp/hermes-work/config.yaml") self.assertEqual(verdict.decision, "instruct") self.assertEqual(verdict.tool_name, "Write") diff --git a/__tests__/hooks/agent-roster.test.ts b/__tests__/hooks/agent-roster.test.ts new file mode 100644 index 000000000..b54dc29ab --- /dev/null +++ b/__tests__/hooks/agent-roster.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readRuntimeAgentIdentity, runtimeAgentSettingsPath } from "../../src/hooks/agent-roster"; +import { agentTargetsMatch, parseAgentTargets } from "../../src/hooks/agent-targets"; + +const roots: string[] = []; +const previousHome = process.env.FAILPROOFAI_HOME; +const previousHermes = process.env.HERMES_HOME; +const previousClaude = process.env.CLAUDE_CONFIG_DIR; +afterEach(() => { + if (previousHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = previousHome; + if (previousHermes === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = previousHermes; + if (previousClaude === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaude; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("runtime agent identity", () => { + it("detects a project-only hook and refuses to guess when project and user hooks both apply", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-agent-scopes-")); + roots.push(root); + delete process.env.CLAUDE_CONFIG_DIR; + const user = join(root, "user"); + const project = join(root, "repo"); + const nested = join(project, "src"); + const projectSettings = join(project, ".claude", "settings.json"); + const userSettings = join(user, ".claude", "settings.json"); + mkdirSync(nested, { recursive: true }); + mkdirSync(join(project, ".claude"), { recursive: true }); + mkdirSync(join(user, ".claude"), { recursive: true }); + writeFileSync(projectSettings, '{"hooks":"failproofai --hook PreToolUse"}'); + expect(runtimeAgentSettingsPath("claude", nested, user)).toBe(projectSettings); + writeFileSync(userSettings, '{"hooks":"failproofai --hook PreToolUse"}'); + expect(runtimeAgentSettingsPath("claude", nested, user)).toBeNull(); + rmSync(projectSettings); + expect(runtimeAgentSettingsPath("claude", nested, user)).toBe(userSettings); + }); + + it("resolves a named profile from the invoking hook's config path", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-agent-roster-")); + roots.push(root); + process.env.FAILPROOFAI_HOME = root; + process.env.HERMES_HOME = join(root, "profiles", "work"); + const path = runtimeAgentSettingsPath("hermes"); + const rosterPath = join(root, "agents", "roster.json"); + mkdirSync(join(root, "agents")); + writeFileSync(rosterPath, JSON.stringify({ + schemaVersion: 1, generation: 5, + agents: [ + { integration: "hermes", instanceId: "agt_1234567890abcdef", + settingsPath: path, profileLabel: "work", scope: "user", hookInstalled: true }, + { integration: "hermes", instanceId: "agt_abcdef1234567890", + settingsPath: join(root, "profiles", "personal", "config.yaml"), + profileLabel: "personal", scope: "user", hookInstalled: true }, + ], + })); + chmodSync(rosterPath, 0o600); + expect(readRuntimeAgentIdentity("hermes", path)).toEqual({ + integration: "hermes", instanceId: "agt_1234567890abcdef", + }); + expect(readRuntimeAgentIdentity("hermes", join(root, "profiles", "absent", "config.yaml"))).toBeNull(); + expect(readRuntimeAgentIdentity("codex", path)).toBeNull(); + const exact = parseAgentTargets([{ integration: "hermes", instanceId: "agt_1234567890abcdef" }], 3); + expect(agentTargetsMatch(exact, readRuntimeAgentIdentity("hermes", path))).toBe(true); + expect(agentTargetsMatch(exact, null)).toBe(false); + }); + + it("withholds a scoped identity if the roster is unreadable or not owner-only", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-agent-roster-")); + roots.push(root); + process.env.FAILPROOFAI_HOME = root; + const path = join(root, "agent", "config.yaml"); + const rosterPath = join(root, "agents", "roster.json"); + mkdirSync(join(root, "agents")); + writeFileSync(rosterPath, JSON.stringify({ + schemaVersion: 1, agents: [ + { integration: "hermes", instanceId: "agt_1234567890abcdef", settingsPath: path }, + ], + })); + chmodSync(rosterPath, 0o644); + expect(readRuntimeAgentIdentity("hermes", path)).toBeNull(); + chmodSync(rosterPath, 0o600); + writeFileSync(rosterPath, "not JSON"); + expect(readRuntimeAgentIdentity("hermes", path)).toBeNull(); + }); +}); diff --git a/__tests__/hooks/cloud-jev-policies.test.ts b/__tests__/hooks/cloud-jev-policies.test.ts index 7a1f53357..973021ff1 100644 --- a/__tests__/hooks/cloud-jev-policies.test.ts +++ b/__tests__/hooks/cloud-jev-policies.test.ts @@ -129,6 +129,7 @@ interface CloudJs { reviewedBy?: string[]; effect?: "enforce" | "observe"; verdict?: "allow" | "deny"; + agentTargets?: Array<{ integration: string; instanceId?: string }>; } /** Write a deployment exactly as the daemon materialises it: JS artifacts, the Jev mode, nothing else Jev. */ @@ -146,6 +147,7 @@ function deploy(opts: { policies?: CloudJs[]; jevMode?: string; deployment?: num effect: p.effect ?? "enforce", ...(p.authority ? { authority: p.authority } : {}), ...(p.reviewedBy ? { reviewedBy: p.reviewedBy } : {}), + ...(p.agentTargets ? { agentTargets: p.agentTargets } : {}), }; }); // Written by rename, as the daemon does: a new deployment is a new inode. @@ -153,7 +155,7 @@ function deploy(opts: { policies?: CloudJs[]; jevMode?: string; deployment?: num writeFileSync( tmp, JSON.stringify({ - schemaVersion: 2, + schemaVersion: policies.some((p) => p.agentTargets) ? 3 : 2, deployment: opts.deployment ?? 43, policies, ...(opts.jevMode !== undefined ? { jevMode: opts.jevMode } : {}), @@ -294,6 +296,47 @@ async function hook(command = "ls", sessionId = "cloud-jev-policies") { ); } +it("filters a targeted Cloud JS policy before import and runs it only for its selected profile", async () => { + const work = "agt_1234567890abcdef"; + const personal = "agt_abcdef1234567890"; + const workPath = join(home, "profiles", "work", "settings.json"); + const personalPath = join(home, "profiles", "personal", "settings.json"); + mkdirSync(join(home, "agents"), { recursive: true }); + const rosterPath = join(home, "agents", "roster.json"); + writeFileSync(rosterPath, JSON.stringify({ + schemaVersion: 1, generation: 1, + agents: [ + { instanceId: work, integration: "claude", settingsPath: workPath }, + { instanceId: personal, integration: "claude", settingsPath: personalPath }, + ], + }), { mode: 0o600 }); + chmodSync(rosterPath, 0o600); + deploy({ policies: [{ + id: "scoped", version: 1, hooks: ["scoped-deny"], verdict: "deny", + agentTargets: [{ integration: "claude", instanceId: work }], + }] }); + const { evaluateHookEvent } = await import("@/src/hooks/handler"); + const input = JSON.stringify({ + hook_event_name: "PreToolUse", tool_name: "Bash", + tool_input: { command: "ls" }, session_id: "scope-1", cwd: project, + }); + const other = await evaluateHookEvent("PreToolUse", "claude", input, { agentSettingsPath: personalPath }); + const { getAllPolicies } = await import("@/src/hooks/policy-registry"); + expect(getAllPolicies().some((policy) => policy.name === "cloud/scoped@1/scoped-deny")).toBe(false); + expect(other.evaluation?.decision).toBe("allow"); + const selected = await evaluateHookEvent("PreToolUse", "claude", input, { agentSettingsPath: workPath }); + expect(getAllPolicies().some((policy) => policy.name === "cloud/scoped@1/scoped-deny")).toBe(true); + expect(selected.evaluation?.decision).toBe("deny"); + const unknown = await evaluateHookEvent("PreToolUse", "claude", input, { + agentSettingsPath: join(home, "profiles", "missing", "settings.json"), + }); + expect(unknown.evaluation?.decision).toBe("allow"); + expect(readErrors().errors).toContainEqual(expect.objectContaining({ + id: "agentScope", kind: "daemon", + message: expect.stringContaining("agent_scope_unresolved"), + })); +}); + async function registeredAfterOneEvent(): Promise> { await hook(); const { getAllPolicies } = await import("@/src/hooks/policy-registry"); @@ -358,10 +401,34 @@ describe("C10.2 gating: jevMode × Cloud Jev credential × decisions-only × BYO for (const c of seen) { expect(c.url).toBe(CLOUD_ENDPOINT); expect(Object.keys(c.body.questions)).toEqual(["injection"]); - expect(c.body.cloud).toMatchObject({ v: 1, machineId: MACHINE, intentMode: "v1", localPolicies: [] }); + // C11: the running hook sends v2 even when no local profile is known; + // Cloud then runs only unscoped checks, never every scoped check. + expect(c.body.cloud).toMatchObject({ v: 2, machineId: MACHINE, intentMode: "v1", localPolicies: [] }); + expect(c.body.cloud?.agent).toBeUndefined(); } }); + it("sends the daemon-rostered profile identity, not telemetry's agent id, with a Cloud v2 call", async () => { + await connect(); + deploy({ jevMode: "enforce" }); + const { runtimeAgentSettingsPath } = await import("@/src/hooks/agent-roster"); + mkdirSync(join(home, "agents"), { recursive: true }); + const rosterPath = join(home, "agents", "roster.json"); + writeFileSync(rosterPath, JSON.stringify({ + schemaVersion: 1, generation: 1, + agents: [{ integration: "claude", instanceId: "agt_1234567890abcdef", + settingsPath: runtimeAgentSettingsPath("claude", project), + profileLabel: "default", scope: "user", hookInstalled: true }], + }), { mode: 0o600 }); + chmodSync(rosterPath, 0o600); + const seen = stubFetch(cloudReplies); + await hook("cat README.md"); + expect(seen[0]?.body.cloud).toMatchObject({ + v: 2, + agent: { integration: "claude", instanceId: "agt_1234567890abcdef" }, + }); + }); + it("a known tool with no side effects is not sent: Cloud's selection of it is empty by construction", async () => { await connect(); installPacks([{ id: "acme/pack", semantic: [decl("acme-local", { appliesTo: ["shell", "write", "read", "network", "other"] })] }]); diff --git a/__tests__/hooks/cloud-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts index 682e76847..c51390375 100644 --- a/__tests__/hooks/cloud-managed-policies.test.ts +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { clearActiveCloudManagedPolicies, readActiveCloudManagedPolicies, + readCloudAuthorityInputs, } from "../../src/hooks/cloud-managed-policies"; import { cloudPoliciesDir } from "../../src/hooks/fp-home"; @@ -128,6 +129,58 @@ describe("policy effect", () => { }); }); +describe("agent-scoped assignments", () => { + const hermesWork = { integration: "hermes" as const, instanceId: "agt_1234567890abcdef" }; + const hermesOther = { integration: "hermes" as const, instanceId: "agt_abcdef1234567890" }; + const codex = { integration: "codex" as const, instanceId: "agt_0000000000000000" }; + + function scoped(targets: unknown, schemaVersion = 3): string { + const { root, sha256 } = fixture(); + writeFileSync(join(root, "active.json"), JSON.stringify({ + schemaVersion, + deployment: 12, + policies: [{ + id: "guard", version: 3, sha256, + path: "deployments/12/guard.mjs", + agentTargets: targets, + authority: "reviewable", + reviewedBy: ["check"], + }], + })); + return root; + } + + it("filters before touching the artifact and before registering a reviewer", () => { + const root = scoped([{ integration: "hermes", instanceId: hermesWork.instanceId }]); + // A mismatched profile must not even read this file; importing it would + // execute code from a deployment that is not assigned to that profile. + rmSync(join(root, "deployments", "12", "guard.mjs")); + expect(readActiveCloudManagedPolicies(hermesOther)).toEqual([]); + expect(readCloudAuthorityInputs(hermesOther)).toEqual([]); + expect(readActiveCloudManagedPolicies(codex)).toEqual([]); + expect(readActiveCloudManagedPolicies()).toEqual([]); + expect(() => readActiveCloudManagedPolicies(hermesWork)).toThrow(); + }); + + it("matches integration-wide targets including profiles added later", () => { + scoped([{ integration: "hermes" }, { integration: "codex", instanceId: codex.instanceId }]); + expect(readActiveCloudManagedPolicies(hermesWork)).toHaveLength(1); + expect(readActiveCloudManagedPolicies(hermesOther)).toHaveLength(1); + expect(readActiveCloudManagedPolicies(codex)).toHaveLength(1); + expect(readCloudAuthorityInputs(hermesWork)).toHaveLength(1); + }); + + it("rejects scope in schema two and malformed or empty selectors", () => { + scoped([{ integration: "hermes" }], 2); + expect(() => readActiveCloudManagedPolicies(hermesWork)).toThrow(/agentTargets/); + for (const targets of [[], [{ integration: "stranger" }], [{ integration: "hermes", instanceId: "bad" }], + [{ integration: "hermes" }, { integration: "hermes" }]]) { + scoped(targets); + expect(() => readActiveCloudManagedPolicies(hermesWork)).toThrow(/agentTargets/); + } + }); +}); + describe("clearActiveCloudManagedPolicies", () => { it("stops enforcement while leaving the verified artifacts on disk", () => { // `--disconnect` cleared the credential, which ends POLLING. Every artifact diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 9367cd3b9..30e3eb83b 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -155,6 +155,7 @@ if (hookIdx >= 0) { if (isDaemonConfigured()) { const { readStdinPayload } = await import("../src/hooks/read-stdin"); const { evaluateHookEvent } = await import("../src/hooks/handler"); + const { runtimeAgentSettingsPath } = await import("../src/hooks/agent-roster"); const stdinRead = await readStdinPayload(); const attempt = await attemptDaemonHook({ @@ -165,6 +166,7 @@ if (hookIdx >= 0) { // process is spawned fresh, at that location, by the calling agent // CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md. cwd: process.cwd(), + agentSettingsPath: runtimeAgentSettingsPath(cli, process.cwd()) ?? undefined, }); // On a daemon-configured machine the daemon is the ONLY evaluator. Every diff --git a/crates/failproofaid/src/agent_roster.rs b/crates/failproofaid/src/agent_roster.rs new file mode 100644 index 000000000..5cdb9cd08 --- /dev/null +++ b/crates/failproofaid/src/agent_roster.rs @@ -0,0 +1,452 @@ +//! Daemon-owned inventory of agent installations and profiles on this OS +//! user's machine. Paths remain local; only opaque IDs and bounded labels +//! are sent to Cloud. No process-list guesses or telemetry project IDs. + +use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +static ROSTER_WRITE: LazyLock> = LazyLock::new(|| Mutex::new(())); +const INTEGRATIONS: &[&str] = &[ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + "pi", + "hermes", + "openclaw", + "factory", + "devin", + "antigravity", + "goose", +]; +const RECENT_SIGHTING_MS: i64 = 30 * 24 * 60 * 60 * 1000; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProfile { + pub instance_id: String, + pub integration: String, + pub settings_path: String, + pub profile_label: String, + pub scope: String, + pub hook_installed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_seen_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fingerprint: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRoster { + pub schema_version: u32, + pub generation: u64, + pub agents: Vec, +} + +pub fn roster_path() -> io::Result { + crate::paths::agent_roster_path() +} + +fn safe_profile_label(label: &str) -> String { + let name: String = label + .chars() + .filter(|c| !c.is_control()) + .take(80) + .collect::() + .trim() + .to_string(); + if name.is_empty() { + "profile".into() + } else { + name + } +} + +fn profile(integration: &str, label: &str, settings: PathBuf) -> Option { + let folder = settings.parent()?; + if !folder.exists() { + return None; + } + let fingerprint = fs::metadata(folder) + .ok() + .map(|m| format!("{}:{}", m.dev(), m.ino())); + // This is only a hint. A hook-time sighting will make an installation + // active; the Cloud API never treats this string as proof of identity. + let hooked = fs::File::open(&settings) + .ok() + .and_then(|mut file| { + let mut buf = vec![0u8; 131_073]; + let size = file.read(&mut buf).ok()?; + (size <= 131_072).then(|| String::from_utf8_lossy(&buf[..size]).contains("failproofai")) + }) + .unwrap_or(false); + Some(AgentProfile { + instance_id: String::new(), + integration: integration.into(), + settings_path: settings.to_string_lossy().into_owned(), + profile_label: safe_profile_label(label), + scope: "user".into(), + hook_installed: hooked, + last_seen_at: None, + fingerprint, + }) +} + +fn profiles_at(home: &Path) -> Vec { + let mut out = Vec::new(); + for (integration, path) in [ + ("claude", ".claude/settings.json"), + ("codex", ".codex/hooks.json"), + ("copilot", ".copilot/hooks/failproofai.json"), + ("cursor", ".cursor/hooks.json"), + ("opencode", ".config/opencode/opencode.json"), + ("pi", ".pi/agent/settings.json"), + ("factory", ".factory/hooks.json"), + ("devin", ".config/devin/config.json"), + ("antigravity", ".gemini/config/hooks.json"), + ("goose", ".agents/plugins/failproofai/hooks/hooks.json"), + ] { + if let Some(item) = profile(integration, "default", home.join(path)) { + out.push(item); + } + } + let hermes = home.join(".hermes"); + if let Some(item) = profile("hermes", "default", hermes.join("config.yaml")) { + out.push(item); + } + if let Ok(profiles) = fs::read_dir(hermes.join("profiles")) { + for entry in profiles.flatten().take(64) { + if let Some(item) = profile( + "hermes", + &entry.file_name().to_string_lossy(), + entry.path().join("config.yaml"), + ) { + out.push(item); + } + } + } + // Both integrations support named sibling homes; don't traverse arbitrary + // files or walk projects from the daemon's cwd. + if let Ok(entries) = fs::read_dir(home) { + for entry in entries.flatten().take(256) { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(label) = name.strip_prefix(".hermes-") { + if !label.is_empty() + && entry.path().join("config.yaml").exists() + && let Some(item) = profile("hermes", label, entry.path().join("config.yaml")) + { + out.push(item); + } + } else if name == ".openclaw" { + if let Some(item) = + profile("openclaw", "default", entry.path().join("openclaw.json")) + { + out.push(item); + } + } else if let Some(label) = name.strip_prefix(".openclaw-") + && !label.is_empty() + && entry.path().join("openclaw.json").exists() + && let Some(item) = profile("openclaw", label, entry.path().join("openclaw.json")) + { + out.push(item); + } + } + } + out.sort_by(|a, b| (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path))); + out.truncate(64); + out +} + +fn new_instance_id() -> io::Result { + let mut bytes = [0u8; 16]; + fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(format!( + "agt_{}", + bytes.iter().map(|b| format!("{b:02x}")).collect::() + )) +} + +pub fn read(path: &Path) -> io::Result> { + match fs::read(path) { + Ok(bytes) => { + let roster: AgentRoster = serde_json::from_slice(&bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if roster.schema_version != 1 || roster.generation == 0 || roster.agents.len() > 64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported agent roster", + )); + } + Ok(Some(roster)) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } +} + +fn write(path: &Path, roster: &AgentRoster) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("roster has no parent"))?; + fs::create_dir_all(parent)?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + let bytes = serde_json::to_vec(roster).map_err(io::Error::other)?; + let tmp = parent.join(format!(".roster-{}.tmp", new_instance_id()?)); + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&tmp)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&tmp, path) + })(); + if result.is_err() { + fs::remove_file(tmp).ok(); + } + result +} + +/// Refresh discovery without changing identities for existing config paths, +/// or for renamed profile directories with the same filesystem inode. +pub fn refresh(path: &Path, home: &Path) -> io::Result { + let _guard = ROSTER_WRITE + .lock() + .map_err(|_| io::Error::other("agent roster lock poisoned"))?; + refresh_unlocked(path, home) +} + +fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { + let before = read(path)?; + let previous = before + .as_ref() + .map(|r| r.agents.as_slice()) + .unwrap_or_default(); + let mut by_path = HashMap::new(); + let mut by_inode = HashMap::new(); + for entry in previous { + by_path.insert((&entry.integration, &entry.settings_path), entry); + if let Some(fingerprint) = &entry.fingerprint { + by_inode.insert((&entry.integration, fingerprint), entry); + } + } + let mut agents = profiles_at(home); + let mut active_ids = HashSet::new(); + for entry in &mut agents { + let old = by_path + .get(&(&entry.integration, &entry.settings_path)) + .copied() + .or_else(|| { + entry + .fingerprint + .as_ref() + .and_then(|fp| by_inode.get(&(&entry.integration, fp)).copied()) + }); + entry.instance_id = match old { + Some(old) if active_ids.insert(old.instance_id.clone()) => old.instance_id.clone(), + _ => { + let id = new_instance_id()?; + active_ids.insert(id.clone()); + id + } + }; + entry.last_seen_at = old.and_then(|old| old.last_seen_at); + entry.hook_installed |= old.is_some_and(|old| { + old.hook_installed + && old.last_seen_at.is_some_and(|at| { + current_millis().is_ok_and(|now| now.saturating_sub(at) < RECENT_SIGHTING_MS) + }) + }); + } + for old in previous { + if agents.len() >= 64 { + break; + } + if active_ids.insert(old.instance_id.clone()) { + // Keep old names on the operator roster for historical + // assignments. A project profile is found on hook-time sighting, + // not by scanning arbitrary project directories; keep it hooked + // while recently active, and otherwise mark it stale. + let mut stale = old.clone(); + stale.hook_installed = old.hook_installed + && old.last_seen_at.is_some_and(|at| { + current_millis().is_ok_and(|now| now.saturating_sub(at) < RECENT_SIGHTING_MS) + }) + && Path::new(&old.settings_path) + .parent() + .is_some_and(Path::exists); + agents.push(stale); + } + } + agents.sort_by(|a, b| { + (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path)) + }); + let changed = before.as_ref().is_none_or(|r| r.agents != agents); + let roster = AgentRoster { + schema_version: 1, + generation: before + .as_ref() + .map_or(1, |r| r.generation.saturating_add(u64::from(changed))), + agents, + }; + if changed { + write(path, &roster)?; + } + Ok(roster) +} + +fn current_millis() -> io::Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(io::Error::other)?; + i64::try_from(duration.as_millis()).map_err(io::Error::other) +} + +/// A hook that actually reached this daemon is stronger evidence than a +/// settings file that merely mentions us. Only bounded, explicit config paths +/// enter the local roster. No project/transcript identifier is substituted. +pub fn record_sighting( + path: &Path, + home: &Path, + integration: &str, + settings_path: &Path, +) -> io::Result<()> { + if !INTEGRATIONS.contains(&integration) + || !settings_path.is_absolute() + || settings_path.as_os_str().len() > 4096 + || settings_path + .components() + .any(|part| part == std::path::Component::ParentDir) + { + return Ok(()); + } + let _guard = ROSTER_WRITE + .lock() + .map_err(|_| io::Error::other("agent roster lock poisoned"))?; + let mut roster = match read(path)? { + Some(roster) => roster, + None => refresh_unlocked(path, home)?, + }; + let settings = settings_path.to_string_lossy(); + let now = current_millis()?; + let record = roster + .agents + .iter_mut() + .find(|entry| entry.integration == integration && entry.settings_path == settings); + if let Some(entry) = record { + if entry.hook_installed + && entry + .last_seen_at + .is_some_and(|at| now.saturating_sub(at) < 60_000) + { + return Ok(()); + } + entry.hook_installed = true; + entry.last_seen_at = Some(now); + } else if roster.agents.len() < 64 { + let label = settings_path + .parent() + .and_then(Path::file_name) + .map(|name| safe_profile_label(&name.to_string_lossy())) + .unwrap_or_else(|| "active".into()); + let Some(mut entry) = profile(integration, &label, settings_path.to_path_buf()) else { + return Ok(()); + }; + entry.instance_id = new_instance_id()?; + entry.hook_installed = true; + entry.last_seen_at = Some(now); + entry.scope = if matches!(integration, "hermes" | "openclaw") + || profiles_at(home) + .iter() + .any(|known| known.integration == integration && known.settings_path == settings) + { + "user" + } else { + "project" + } + .into(); + roster.agents.push(entry); + roster.agents.sort_by(|a, b| { + (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path)) + }); + } else { + return Ok(()); + } + roster.generation = roster.generation.saturating_add(1); + write(path, &roster) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_ids_across_refresh_and_profile_rename() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let first = home.join(".hermes/profiles/work"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("config.yaml"), "plugins: failproofai").unwrap(); + let path = root.join("fpai/agents/roster.json"); + let initial = refresh(&path, &home).unwrap(); + let work = initial + .agents + .iter() + .find(|agent| agent.profile_label == "work") + .unwrap(); + let id = work.instance_id.clone(); + assert_eq!(initial.generation, 1); + assert!(work.hook_installed); + assert_eq!(refresh(&path, &home).unwrap().generation, 1); + fs::rename(&first, home.join(".hermes/profiles/renamed")).unwrap(); + let next = refresh(&path, &home).unwrap(); + assert_eq!( + next.agents + .iter() + .find(|agent| agent.profile_label == "renamed") + .unwrap() + .instance_id, + id + ); + assert_eq!(next.generation, 2); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn hook_sighting_records_an_active_project_profile_without_leaking_paths_to_cloud() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let project = root.join("project/.codex"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&project).unwrap(); + let path = root.join("fpai/agents/roster.json"); + let settings = project.join("hooks.json"); + record_sighting(&path, &home, "codex", &settings).unwrap(); + let first = read(&path).unwrap().unwrap(); + assert_eq!(first.agents.len(), 1); + assert_eq!(first.agents[0].scope, "project"); + assert!(first.agents[0].hook_installed); + assert!(first.agents[0].last_seen_at.is_some()); + record_sighting(&path, &home, "codex", &settings).unwrap(); + assert_eq!(read(&path).unwrap().unwrap().generation, first.generation); + assert!( + refresh(&path, &home).unwrap().agents[0].hook_installed, + "a recently active project profile is not in user-home discovery" + ); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/failproofaid/src/cloud_client.rs b/crates/failproofaid/src/cloud_client.rs index 28db6ff27..eaccc8106 100644 --- a/crates/failproofaid/src/cloud_client.rs +++ b/crates/failproofaid/src/cloud_client.rs @@ -1,18 +1,25 @@ use crate::cloud_policies::{ ActiveDeployment, DESIRED_STATE_SCHEMA_VERSION, DesiredPolicy, DesiredState, PolicyErrorEntry, - PolicyStore, ReconcileError, + PolicyStore, ReconcileError, SCOPED_DESIRED_STATE_SCHEMA_VERSION, }; use reqwest::Url; use reqwest::blocking::Client; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; +use crate::agent_roster::{self, AgentRoster}; + const DEFAULT_POLL_MS: u64 = 30_000; const MINIMUM_POLL_MS: u64 = 100; +const AGENT_INVENTORY_HEARTBEAT: Duration = Duration::from_secs(15 * 60); +static AGENT_INVENTORY_REPORTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); /// The `policyErrors` parameter's ceiling, measured URL-ENCODED — the form it /// actually travels in. Over it, whole entries are dropped from the end: the @@ -838,11 +845,12 @@ impl CloudClient { // which to upgrade. let version = raw.get("schemaVersion").and_then(serde_json::Value::as_u64); match version { - Some(v) if v == u64::from(DESIRED_STATE_SCHEMA_VERSION) => {} + Some(v) if v == u64::from(DESIRED_STATE_SCHEMA_VERSION) + || v == u64::from(SCOPED_DESIRED_STATE_SCHEMA_VERSION) => {} Some(v) => { return Err(PollFailure::payload(format!( "server sent desired-state schemaVersion {v} but this daemon \ - speaks {DESIRED_STATE_SCHEMA_VERSION} — upgrade whichever half is behind" + speaks {DESIRED_STATE_SCHEMA_VERSION} or {SCOPED_DESIRED_STATE_SCHEMA_VERSION} — upgrade whichever half is behind" ))); } None => { @@ -857,6 +865,72 @@ impl CloudClient { }) } + /// The roster is a full snapshot, independent of policy reconciliation. + /// A failed upload never discards the already-active assignment. + fn report_agent_roster(&self, roster: &AgentRoster) -> Result<(), String> { + let token_digest = Sha256::digest(self.token.as_bytes()); + let key = format!( + "{}:{}:{}", + self.base_url, + self.machine_id, + token_digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + if AGENT_INVENTORY_REPORTS.lock().is_ok_and(|reports| { + reports.get(&key).is_some_and(|(generation, at)| { + *generation == roster.generation && at.elapsed() < AGENT_INVENTORY_HEARTBEAT + }) + }) { + return Ok(()); + } + let mut url = self + .base_url + .join("enforcement/v1/machines/") + .map_err(|err| format!("invalid machine inventory URL: {err}"))?; + url.path_segments_mut() + .map_err(|()| "machine inventory URL cannot be a base".to_string())? + .pop_if_empty() + .push(&self.machine_id) + .push("agents"); + let agents: Vec<_> = roster + .agents + .iter() + .map(|agent| { + serde_json::json!({ + "instanceId": agent.instance_id, + "integration": agent.integration, + "profileLabel": agent.profile_label, + "scope": agent.scope, + "hookInstalled": agent.hook_installed, + "lastSeenAt": agent.last_seen_at, + }) + }) + .collect(); + let body = serde_json::json!({ + "schemaVersion": 1, + "generation": roster.generation, + "agents": agents, + }); + self.client + .put(url) + .bearer_auth(&self.token) + .json(&body) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|err| { + format!( + "agent inventory report failed: {}", + fpai_collect::error_chain(&err) + ) + })?; + if let Ok(mut reports) = AGENT_INVENTORY_REPORTS.lock() { + reports.insert(key, (roster.generation, Instant::now())); + } + Ok(()) + } + fn artifact(&self, policy: &DesiredPolicy) -> Result, String> { let url = self .base_url @@ -930,6 +1004,11 @@ pub fn spawn_maintenance( std::thread::spawn(move || { let mut last_state: Option = None; while !shutdown.load(Ordering::Relaxed) { + if let (Ok(path), Some(home)) = (agent_roster::roster_path(), std::env::var_os("HOME")) + && let Err(err) = agent_roster::refresh(&path, Path::new(&home)) + { + eprintln!("[failproofaid] could not refresh agent inventory: {err}"); + } let cloud = CloudClient::from_env_or_file(); // Log only on transition, so a disconnected machine does not print @@ -1052,6 +1131,14 @@ fn poll_once_guarded(store: &PolicyStore, cloud: &CloudClient, withdrawn: &dyn F .map(|errors| encode_policy_errors(&errors)); match cloud.poll_desired_state(applied, report.as_deref()) { Ok(desired) => { + // The GET created/checked-in the machine row the PUT requires. + // Do not let a roster network outage interrupt local enforcement. + if let Ok(path) = agent_roster::roster_path() + && let Ok(Some(roster)) = agent_roster::read(&path) + && let Err(err) = cloud.report_agent_roster(&roster) + { + eprintln!("[failproofaid] {err}"); + } match store.reconcile_unless( &desired, &|policy: &DesiredPolicy| cloud.artifact(policy), @@ -1826,6 +1913,7 @@ mod tests { effect: PolicyEffect::Enforce, authority: None, reviewed_by: None, + agent_targets: None, }; serve(&probe).unwrap_or_default() }; @@ -2347,6 +2435,7 @@ mod tests { effect: PolicyEffect::Enforce, authority: None, reviewed_by: None, + agent_targets: None, }; assert!(cloud.artifact(&policy).unwrap_err().contains("outside")); } @@ -2658,4 +2747,88 @@ mod tests { assert!(CloudClient::from_file().unwrap().is_none()); unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; } + + #[test] + fn inventory_upload_is_bounded_to_opaque_metadata_and_skips_unchanged_snapshots() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let root = std::env::temp_dir().join(format!( + "c11-roster-{}-{}", + std::process::id(), + address.port() + )); + let home = root.join("home"); + let settings = home.join(".hermes/config.yaml"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write(&settings, "plugins: failproofai").unwrap(); + let roster_path = root.join("agents/roster.json"); + let roster = agent_roster::refresh(&roster_path, &home).unwrap(); + let generation = roster.generation; + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut received = Vec::new(); + loop { + let mut buf = [0u8; 4096]; + let len = stream.read(&mut buf).unwrap(); + assert!(len > 0, "request closed before the JSON body arrived"); + received.extend_from_slice(&buf[..len]); + let Some(at) = received.windows(4).position(|window| window == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&received[..at]).to_ascii_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse() + .unwrap(); + if received.len() >= at + 4 + length { + break; + } + } + let request = String::from_utf8(received).unwrap(); + assert!( + request.starts_with("PUT /enforcement/v1/machines/machine/agents HTTP/1.1"), + "{request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer token") + ); + let body: serde_json::Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["schemaVersion"], 1); + assert_eq!(body["generation"], generation); + assert_eq!(body["agents"][0]["integration"], "hermes"); + assert!( + body["agents"][0]["instanceId"] + .as_str() + .unwrap() + .starts_with("agt_") + ); + assert!( + body.to_string().find("settingsPath").is_none(), + "paths stay local" + ); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .unwrap(); + }); + let client = CloudClient::new( + &format!("http://{address}"), + "token".into(), + "machine".into(), + ) + .unwrap(); + client.report_agent_roster(&roster).unwrap(); + server.join().unwrap(); + // No server is listening now. This must still succeed from the + // in-process generation cache, and must not make another PUT. + client.report_agent_roster(&roster).unwrap(); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/failproofaid/src/cloud_policies.rs b/crates/failproofaid/src/cloud_policies.rs index c0aa2bf62..3ec2657ae 100644 --- a/crates/failproofaid/src/cloud_policies.rs +++ b/crates/failproofaid/src/cloud_policies.rs @@ -26,6 +26,7 @@ use std::time::{Duration, Instant}; /// shape is precisely what a schema version exists to prevent — see the note at /// the emit site in AgentEye's `enforcement.rs`. pub const DESIRED_STATE_SCHEMA_VERSION: u32 = 2; +pub const SCOPED_DESIRED_STATE_SCHEMA_VERSION: u32 = 3; /// What this daemon ACCEPTS when reading. /// @@ -36,7 +37,11 @@ pub const DESIRED_STATE_SCHEMA_VERSION: u32 = 2; /// would silently stop enforcing cloud policy until a poll re-materialised /// everything. The field aliases on `ActiveDeployment` exist for the same /// files and the same reason. -pub const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[1, DESIRED_STATE_SCHEMA_VERSION]; +pub const SUPPORTED_SCHEMA_VERSIONS: &[u32] = &[ + 1, + DESIRED_STATE_SCHEMA_VERSION, + SCOPED_DESIRED_STATE_SCHEMA_VERSION, +]; const MANAGED_FILE_MODE: u32 = 0o600; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -141,6 +146,7 @@ impl From for DesiredPolicy { effect: old.effect, authority: None, reviewed_by: None, + agent_targets: None, } } } @@ -169,6 +175,54 @@ pub struct DesiredPolicy { /// verbatim; the CLI decides which of them this machine can actually ask. #[serde(default, skip_serializing_if = "Option::is_none")] pub reviewed_by: Option>, + /// Only schema 3 may carry this. Absent means all agents. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_targets: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentTarget { + pub integration: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance_id: Option, +} + +const INTEGRATIONS: &[&str] = &[ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + "pi", + "hermes", + "openclaw", + "factory", + "devin", + "antigravity", + "goose", +]; + +fn valid_instance_id(id: &str) -> bool { + let Some(hex) = id.strip_prefix("agt_") else { + return false; + }; + (16..=32).contains(&hex.len()) + && hex + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn validate_targets(targets: &[AgentTarget]) -> bool { + if !(1..=32).contains(&targets.len()) { + return false; + } + let mut seen = HashSet::new(); + targets.iter().all(|target| { + INTEGRATIONS.contains(&target.integration.as_str()) + && target.instance_id.as_deref().is_none_or(valid_instance_id) + && seen.insert(target) + }) } /// What an assignment does when it matches. @@ -235,6 +289,8 @@ pub struct ActivePolicy { pub authority: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub reviewed_by: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_targets: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -610,11 +666,12 @@ impl PolicyStore { path: self.relative_to_root(&artifact_path)?, authority: policy.authority.clone(), reviewed_by: policy.reviewed_by.clone(), + agent_targets: policy.agent_targets.clone(), }); } let active = ActiveDeployment { - schema_version: DESIRED_STATE_SCHEMA_VERSION, + schema_version: desired.schema_version, deployment: desired.deployment, policies: active_policies, jev_mode: desired.jev_mode.clone(), @@ -956,6 +1013,15 @@ fn validate_desired_state(desired: &DesiredState) -> Result<(), ReconcileError> } let mut ids = HashSet::new(); for policy in &desired.policies { + if let Some(targets) = &policy.agent_targets + && (desired.schema_version < SCOPED_DESIRED_STATE_SCHEMA_VERSION + || !validate_targets(targets)) + { + return Err(ReconcileError::InvalidDesiredState(format!( + "policy {} has invalid agentTargets for schema {}", + policy.id, desired.schema_version + ))); + } validate_policy_identity(&policy.id)?; validate_sha256(&policy.sha256)?; if policy.artifact_url.trim().is_empty() { @@ -1161,6 +1227,7 @@ mod tests { effect: PolicyEffect::Observe, authority: None, reviewed_by: None, + agent_targets: None, }], jev_mode: None, }; @@ -1196,6 +1263,7 @@ mod tests { effect: PolicyEffect::Enforce, authority: None, reviewed_by: None, + agent_targets: None, }], jev_mode: None, } @@ -1576,6 +1644,7 @@ pub(crate) mod cloud_jev_tests { effect: PolicyEffect::Enforce, authority: Some("reviewable".into()), reviewed_by: Some(vec!["acme-prod-db".into()]), + agent_targets: None, }], jev_mode: Some("observe".into()), } @@ -2100,9 +2169,9 @@ mod pre_rename_state_tests { ); } - /// Both schema versions are readable, and only from disk does 1 arise. + /// Legacy disk files, unscoped responses and scoped responses are readable. #[test] - fn both_schema_versions_are_accepted() { + fn supported_schema_versions_are_accepted() { assert!( SUPPORTED_SCHEMA_VERSIONS.contains(&1), "a beta daemon's files are v1" @@ -2112,6 +2181,7 @@ mod pre_rename_state_tests { "what we write must be readable" ); assert_eq!(DESIRED_STATE_SCHEMA_VERSION, 2, "the server emits 2"); + assert!(SUPPORTED_SCHEMA_VERSIONS.contains(&SCOPED_DESIRED_STATE_SCHEMA_VERSION)); // The version the server actually sends must validate. let desired: DesiredState = serde_json::from_str( @@ -2134,6 +2204,89 @@ mod pre_rename_state_tests { ); } + #[test] + fn scoped_assignments_require_schema_three_and_round_trip_to_active_manifest() { + let store = PolicyStore::new(std::env::temp_dir().join(format!( + "failproofaid-agent-scoped-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + ))); + let bytes = b"export default 'scoped';\n"; + let mut state = scoped_fixture(bytes); + state.deployment = 42; + state.policies[0].agent_targets = Some(vec![AgentTarget { + integration: "hermes".into(), + instance_id: Some("agt_1234567890abcdef".into()), + }]); + assert!( + validate_desired_state(&state).is_err(), + "schema 2 must never ignore the scope" + ); + state.schema_version = SCOPED_DESIRED_STATE_SCHEMA_VERSION; + store + .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec())) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + assert_eq!(active.schema_version, 3); + assert_eq!( + active.policies[0].agent_targets, + state.policies[0].agent_targets + ); + let text = fs::read_to_string(store.root().join("active.json")).unwrap(); + assert!(text.contains("\"agentTargets\"")); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn malformed_scopes_never_activate() { + let mut state = scoped_fixture(b"guard"); + state.schema_version = SCOPED_DESIRED_STATE_SCHEMA_VERSION; + for targets in [ + vec![], + vec![AgentTarget { + integration: "unknown".into(), + instance_id: None, + }], + vec![AgentTarget { + integration: "codex".into(), + instance_id: Some("wrong".into()), + }], + vec![ + AgentTarget { + integration: "codex".into(), + instance_id: None, + }, + AgentTarget { + integration: "codex".into(), + instance_id: None, + }, + ], + ] { + state.policies[0].agent_targets = Some(targets); + assert!(validate_desired_state(&state).is_err()); + } + state.policies[0].agent_targets = None; + assert!(validate_desired_state(&state).is_ok()); + } + + fn scoped_fixture(bytes: &[u8]) -> DesiredState { + DesiredState { + schema_version: DESIRED_STATE_SCHEMA_VERSION, + deployment: 1, + policies: vec![DesiredPolicy { + id: "guard".into(), + version: 1, + sha256: sha256_hex(bytes), + artifact_url: "/enforcement/v1/artifacts/guard".into(), + effect: PolicyEffect::Enforce, + authority: None, + reviewed_by: None, + agent_targets: None, + }], + jev_mode: None, + } + } + /// The new spelling is what we WRITE, and must keep round-tripping — an /// alias that quietly became the canonical name would be its own bug. #[test] @@ -2149,6 +2302,7 @@ mod pre_rename_state_tests { effect: PolicyEffect::Observe, authority: None, reviewed_by: None, + agent_targets: None, }], jev_mode: None, }; diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index 10e386723..37d54ad4f 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -1,3 +1,4 @@ +mod agent_roster; mod audit_lane; mod cloud_client; pub mod cloud_policies; diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs index 7b839f69f..105a994ba 100644 --- a/crates/failproofaid/src/paths.rs +++ b/crates/failproofaid/src/paths.rs @@ -214,6 +214,12 @@ pub fn failproofai_home() -> io::Result { Ok(PathBuf::from(home).join(".failproofai")) } +/// The owner-only agent/profile inventory. Matches `agentRosterFile()` in +/// `fp-home.ts`; config paths never leave this file for Cloud. +pub fn agent_roster_path() -> io::Result { + Ok(failproofai_home()?.join("agents").join("roster.json")) +} + /// The on-disk layout this binary speaks. Mirrors `LAYOUT_VERSION` in /// `src/hooks/fp-home.ts`, and the parity test below asserts the two agree — /// every path in this file is only correct for one layout, so a mismatch here is @@ -529,6 +535,11 @@ mod tests { "workerSocket()", ), ("lock_path", lock_path().unwrap(), "daemonLock()"), + ( + "agent_roster_path", + agent_roster_path().unwrap(), + "agentRosterFile()", + ), ( "cloud_managed_policy_dir", cloud_managed_policy_dir().unwrap(), diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs index a00f9ee6b..b365e8ea9 100644 --- a/crates/failproofaid/src/server.rs +++ b/crates/failproofaid/src/server.rs @@ -307,6 +307,24 @@ pub fn handle_connection(stream: UnixStream, worker: &Worker) -> io::Result<()> .map_err(|e| io::Error::other(format!("failed to write response: {e}"))) } +fn record_agent_sighting(integration: &str, settings_path: Option<&str>) { + let Some(settings_path) = settings_path else { + return; + }; + let (Ok(roster), Some(home)) = (crate::agent_roster::roster_path(), std::env::var_os("HOME")) + else { + return; + }; + if let Err(err) = crate::agent_roster::record_sighting( + &roster, + std::path::Path::new(&home), + integration, + std::path::Path::new(settings_path), + ) { + eprintln!("[failproofaid] could not record an agent hook sighting: {err}"); + } +} + fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { if request.protocol_version() != PROTOCOL_VERSION { return ServerMessage::Error { @@ -327,26 +345,38 @@ fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { cli, stdin, cwd, + agent_settings_path, .. - } => match worker.call(&hook_event, &cli, &stdin, cwd.as_deref()) { - Ok(outcome) => ServerMessage::HookResult { - protocol_version: PROTOCOL_VERSION, - exit_code: outcome.exit_code, - stdout: outcome.stdout, - stderr: outcome.stderr, - }, - Err(err) => ServerMessage::Error { - protocol_version: PROTOCOL_VERSION, - message: format!("worker call failed: {err}"), - }, - }, + } => { + record_agent_sighting(&cli, agent_settings_path.as_deref()); + match worker.call( + &hook_event, + &cli, + &stdin, + cwd.as_deref(), + agent_settings_path.as_deref(), + ) { + Ok(outcome) => ServerMessage::HookResult { + protocol_version: PROTOCOL_VERSION, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + }, + Err(err) => ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!("worker call failed: {err}"), + }, + } + } ClientMessage::PolicyEvaluation { integration, event, payload, cwd, + agent_settings_path, .. } => { + record_agent_sighting(&integration, agent_settings_path.as_deref()); let stdin = match serde_json::to_string(&payload) { Ok(value) => value, Err(err) => { @@ -356,7 +386,13 @@ fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { }; } }; - match worker.call(&event, &integration, &stdin, cwd.as_deref()) { + match worker.call( + &event, + &integration, + &stdin, + cwd.as_deref(), + agent_settings_path.as_deref(), + ) { Ok(outcome) => match outcome.evaluation { Some(evaluation) if matches!( @@ -539,6 +575,7 @@ mod tests { cli: "claude".to_string(), stdin: "{}".to_string(), cwd: None, + agent_settings_path: None, }, ) .unwrap(); @@ -566,6 +603,7 @@ mod tests { "tool_input": {"command": "echo hi"} }), cwd: None, + agent_settings_path: None, }, ) .unwrap(); @@ -635,6 +673,7 @@ mod tests { cli: "claude".to_string(), stdin, cwd: Some(project_dir.to_string_lossy().to_string()), + agent_settings_path: None, }, ) .unwrap(); @@ -671,6 +710,7 @@ mod tests { "tool_input": { "command": "sudo rm -rf /" } }), cwd: Some(project_dir.to_string_lossy().to_string()), + agent_settings_path: None, }, ) .unwrap(); diff --git a/crates/failproofaid/src/worker.rs b/crates/failproofaid/src/worker.rs index 686526746..9d001e9bd 100644 --- a/crates/failproofaid/src/worker.rs +++ b/crates/failproofaid/src/worker.rs @@ -381,6 +381,7 @@ impl Worker { cli: &str, stdin: &str, cwd: Option<&str>, + agent_settings_path: Option<&str>, ) -> Result { self.ensure_started()?; @@ -407,6 +408,7 @@ impl Worker { "cli": cli, "stdin": stdin, "cwd": cwd, + "agentSettingsPath": agent_settings_path, }); write_message(&mut stream, &request).map_err(|e| WorkerError::Io(io::Error::other(e)))?; @@ -518,7 +520,7 @@ mod tests { // only thing on disk that could be mistaken for readiness. let worker = Worker::new(socket_path.clone(), WorkerCommand::shell("exit 0")); let err = worker - .call("PreToolUse", "claude", "{}", None) + .call("PreToolUse", "claude", "{}", None, None) .expect_err("a worker that never bound must not report ready"); assert!( err.to_string() diff --git a/crates/fpai-ipc/src/envelope.rs b/crates/fpai-ipc/src/envelope.rs index 789d65517..20d93765a 100644 --- a/crates/fpai-ipc/src/envelope.rs +++ b/crates/fpai-ipc/src/envelope.rs @@ -36,6 +36,11 @@ pub enum ClientMessage { /// own `cwd` does not vary per request and must never be used to /// resolve project config or custom policies). cwd: Option, + /// Exact settings path selected by the originating integration (e.g. + /// HERMES_HOME). The daemon worker must not use its own environment + /// to guess which profile called it. + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_settings_path: Option, }, /// Structured policy evaluation for native in-process integrations. /// Unlike `Hook`, this does not expose CLI-specific stdout/stderr shapes: @@ -47,6 +52,8 @@ pub enum ClientMessage { event: String, payload: serde_json::Value, cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_settings_path: Option, }, } @@ -138,6 +145,7 @@ mod tests { cli: "claude".to_string(), stdin: "{}".to_string(), cwd: Some("/repo".to_string()), + agent_settings_path: None, }; let json = serde_json::to_value(&msg).unwrap(); assert_eq!(json["type"], "hook"); @@ -170,6 +178,7 @@ mod tests { event: "pre_tool_call".to_string(), payload: serde_json::json!({"tool_name": "terminal"}), cwd: Some("/repo".to_string()), + agent_settings_path: None, }; let json = serde_json::to_value(&msg).unwrap(); assert_eq!(json["type"], "policyEvaluation"); diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index bed8557d7..62d702353 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- `fp fleet deploy --target POLICY=INTEGRATION[/agt_ID]` scopes a machine's assignment; repeat the flag to include multiple alternatives. `--all-agents POLICY` explicitly clears its scope. Deploy plans, show, history, rollback and JSON readback retain assignment targets. - `fp policies publish --kind regex|jev|both [--source f.mjs] [--semantic f.json]`: a FailproofAI Cloud policy can carry Jev checks — `jev` (declarations only, no JavaScript) or `both` (JavaScript reviewable by exactly its own checks; the server derives the authority). diff --git a/fp-cloud-cli/README.md b/fp-cloud-cli/README.md index 97bb1c751..561c5c5ea 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -160,6 +160,25 @@ fp fleet jev-mode --all enforce # the mode alone, every machine with a deploym fp fleet show ci-runner-01 # its Jev mode, and any policy errors it reported ``` +Published policy versions do not contain agent-routing code. Each assignment +can instead target every agent (the default), an integration including future +profiles, or a particular profile reported by that machine. The same target +applies to the regex and Jev parts of a `both` policy: + +```bash +fp fleet deploy ci-runner-01 --add no-prod-db --target no-prod-db=hermes +fp fleet deploy ci-runner-01 --target no-prod-db=hermes/agt_1234567890abcdef +fp fleet deploy ci-runner-01 --all-agents no-prod-db # explicitly remove targeting +``` + +Repeat `--target` for multiple alternatives on one assignment. `fp fleet +show` and `fp fleet history` display the assigned scope; `--json` includes +`agentTargets` on each targeted policy. A machine must report agent-scoping +support before accepting a new target. When its identity cannot be resolved, +a targeted policy does not match; unscoped policy remains in force. +If multiple installed hook scopes could have produced a call, the machine +reports `agent_scope_unresolved` rather than guessing which profile ran it. + **A deploy REPLACES a machine's whole policy set.** The server takes the full list and does not merge, so `fleet deploy` reads what the machine currently runs, applies your `--add`/`--remove`, prints the complete resulting set, and writes @@ -277,4 +296,3 @@ working as an opt-out if it is ever switched back on. See cd fp-cloud-cli uv run --extra dev pytest ``` - diff --git a/fp-cloud-cli/fp_cli/commands/fleet_cmds.py b/fp-cloud-cli/fp_cli/commands/fleet_cmds.py index 5eb97637c..df61c6874 100644 --- a/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +++ b/fp-cloud-cli/fp_cli/commands/fleet_cmds.py @@ -37,6 +37,7 @@ version_kinds, ) from ..errors import ApiError, AuthError, FpCliError, NotFoundError +from ..models import PolicyRef from . import _write _KEY_MODE_REASON = ( @@ -162,6 +163,14 @@ def fleet_deploy( None, "--set", help="REPLACE the whole set with exactly these. Cannot be combined with --add/--remove.", ), + targets: Optional[List[str]] = typer.Option( + None, "--target", + help="Scope one assigned policy: POLICY=INTEGRATION or POLICY=INTEGRATION/agt_ID. Repeat to OR selectors.", + ), + all_agents: Optional[List[str]] = typer.Option( + None, "--all-agents", + help="Clear one policy's targeting back to every agent on this machine.", + ), create: bool = typer.Option( False, "--create", help="Allow deploying to a machine id that has not checked in yet (pre-staging).", @@ -221,12 +230,12 @@ def fleet_deploy( deny_in_key_mode(state, "fleet deploy", _KEY_MODE_REASON) cctx = require_auth(state) - if not add and not remove and replace is None and jev_mode is None: + if not add and not remove and replace is None and jev_mode is None and not targets and not all_agents: # Exit 2 for the same reason `--set` with `--add` is: no flag # combination was given that this command can act on. Both are the # caller's command line, not the server's answer. raise click.UsageError( - "nothing to do — pass --add, --remove, --set, or --jev-mode. " + "nothing to do — pass --add, --remove, --set, --target, --all-agents, or --jev-mode. " "`fp fleet show ` prints the current set." ) @@ -261,6 +270,8 @@ def fleet_deploy( jev_mode=jev_mode, current_jev_mode=current.jev_mode if current else None, kinds=version_kinds(published), + targets=targets or (), + all_agents=all_agents or (), ) except RefUsageError as exc: # Exit 2, like every other bad flag value in this CLI (`--since`, @@ -600,6 +611,9 @@ def fleet_rollback( mode_before = (current.jev_mode if current else None) or "local" mode_target = (target.get("jevMode") or "local") if target is not None else None consequence = "this REPLACES the machine's current set with the one from that generation" + if target is not None: + refs = [PolicyRef.from_dict(ref).label for ref in (target.get("policies") or [])] + consequence += "; assignments: " + ("; ".join(refs) if refs else "no policies") if mode_target is not None and mode_target != mode_before: consequence += f", and its Jev mode: jev mode {mode_before} → {mode_target}" if not _write.confirm_destructive( diff --git a/fp-cloud-cli/fp_cli/enforcement.py b/fp-cloud-cli/fp_cli/enforcement.py index 7a4918e91..6e0d917e0 100644 --- a/fp-cloud-cli/fp_cli/enforcement.py +++ b/fp-cloud-cli/fp_cli/enforcement.py @@ -31,7 +31,7 @@ import json import re import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace as dataclass_replace from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from .errors import ApiError @@ -49,11 +49,17 @@ #: The server's (and the machine's) cap on Jev declarations per policy version. MAX_JEV_DECLARATIONS = 24 +AGENT_INTEGRATIONS = frozenset({ + "claude", "codex", "copilot", "cursor", "opencode", "pi", + "hermes", "openclaw", "factory", "devin", "antigravity", "goose", +}) +AGENT_ID = re.compile(r"^agt_[0-9a-f]{16,32}$") #: `id`, `id@3`, `id:observe`, `id@3:observe`. The id charset mirrors the #: server's `safe_identifier`, so a ref this accepts is one the server will too #: — a rejection should come from the policy not existing, not from parsing. _REF = re.compile(r"^(?P[A-Za-z0-9._-]{1,128})(?:@(?P\d+))?(?:[:](?P[a-z]+))?$") +_POLICY_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$") class RefError(ValueError): @@ -215,7 +221,10 @@ def resolve_ref( ) if effect is None: effect = existing.effect if existing else "enforce" - return PolicyRef(id=pid, version=version, effect=effect) + return PolicyRef( + id=pid, version=version, effect=effect, + agent_targets=existing.agent_targets if existing else None, + ) def plan_deploy( @@ -231,6 +240,8 @@ def plan_deploy( jev_mode: Optional[str] = None, current_jev_mode: Optional[str] = None, kinds: Optional[Dict[Tuple[str, int], str]] = None, + targets: Sequence[str] = (), + all_agents: Sequence[str] = (), ) -> DeployPlan: """Compute the full resulting set, plus the diff to show before writing. @@ -274,6 +285,34 @@ def plan_deploy( ref = resolve_ref(token, latest=latest, current=current_map, disabled=disabled) result_map[ref.id] = ref + grouped: Dict[str, List[Dict[str, str]]] = {} + for token in targets: + if "=" not in token: + raise RefUsageError(f"--target {token!r} must be POLICY=INTEGRATION[/agt_ID]") + pid, selector = token.split("=", 1) + if not _POLICY_ID.fullmatch(pid): + raise RefUsageError(f"--target policy id {pid!r} is invalid") + integration, _, instance_id = selector.partition("/") + if integration not in AGENT_INTEGRATIONS or ("/" in selector and not AGENT_ID.fullmatch(instance_id)): + raise RefUsageError(f"--target {token!r} has an unknown integration or invalid profile ID") + entry = {"integration": integration} + if instance_id: + entry["instanceId"] = instance_id + collection = grouped.setdefault(pid, []) + if entry in collection or len(collection) == 32: + raise RefUsageError("--target has a duplicate selector or more than 32 selectors for a policy") + collection.append(entry) + clears = set(all_agents) + if clears.intersection(grouped): + raise RefUsageError("--all-agents and --target cannot name the same policy") + for pid in clears: + if not _POLICY_ID.fullmatch(pid): + raise RefUsageError(f"--all-agents policy id {pid!r} is invalid") + for pid in grouped.keys() | clears: + if pid not in result_map: + raise RefUsageError(f"{pid!r} is not in the resulting set — add or keep it before targeting") + result_map[pid] = dataclass_replace(result_map[pid], agent_targets=grouped.get(pid)) + result = sorted(result_map.values(), key=lambda p: p.id) for ref in result: if ref.effect == "observe" and (kinds or {}).get((ref.id, ref.version)) == "jev": @@ -287,7 +326,7 @@ def plan_deploy( was = current_map.get(pid) if was is None: added.append(ref) - elif (was.version, was.effect) != (ref.version, ref.effect): + elif (was.version, was.effect, was.agent_targets) != (ref.version, ref.effect, ref.agent_targets): changed.append((was, ref)) else: unchanged.append(ref) diff --git a/fp-cloud-cli/fp_cli/models.py b/fp-cloud-cli/fp_cli/models.py index 0bb81c5ac..fc2a41999 100644 --- a/fp-cloud-cli/fp_cli/models.py +++ b/fp-cloud-cli/fp_cli/models.py @@ -821,6 +821,9 @@ class PolicyRef: id: str version: int effect: str = "enforce" + #: None means every agent; a nonempty selector list narrows this + #: machine's assignment, never the immutable published policy version. + agent_targets: Optional[List[Dict[str, str]]] = None @classmethod def from_dict(cls, d: Dict[str, Any]) -> "PolicyRef": @@ -828,14 +831,23 @@ def from_dict(cls, d: Dict[str, Any]) -> "PolicyRef": id=str(d.get("id", "")), version=_as_int(d.get("version"), 0), effect=str(d.get("effect") or "enforce"), + agent_targets=[dict(target) for target in d["agentTargets"]] + if isinstance(d.get("agentTargets"), list) else None, ) def to_dict(self) -> Dict[str, Any]: - return {"id": self.id, "version": self.version, "effect": self.effect} + out: Dict[str, Any] = {"id": self.id, "version": self.version, "effect": self.effect} + if self.agent_targets is not None: + out["agentTargets"] = self.agent_targets + return out @property def label(self) -> str: - return f"{self.id}@{self.version}:{self.effect}" + scope = "" if not self.agent_targets else " → " + ", ".join( + f"{t['integration']}/{t['instanceId']}" if t.get("instanceId") else f"all {t['integration']}" + for t in self.agent_targets + ) + return f"{self.id}@{self.version}:{self.effect}{scope}" @dataclass diff --git a/fp-cloud-cli/fp_cli/output.py b/fp-cloud-cli/fp_cli/output.py index f2bf5e24f..359f98916 100644 --- a/fp-cloud-cli/fp_cli/output.py +++ b/fp-cloud-cli/fp_cli/output.py @@ -6150,6 +6150,17 @@ def _epoch_age(ms: Optional[int]) -> str: return _relative_age(datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()) +def _agent_target_label(targets: Any) -> str: + """One assignment's scope, unscoped only when the field is absent.""" + if not targets: + return "all agents" + return ", ".join( + f"{target['integration']}/{target['instanceId']}" + if target.get("instanceId") else f"all {target['integration']}" + for target in targets + ) + + def render_machine_policies(machine_id: str, dep: Any, machine: Any = None) -> None: """``fp fleet show`` — what a machine is told to run, and whether it has it. @@ -6230,13 +6241,14 @@ def field(label: str, value: Text) -> None: # padded to the header's width — otherwise the effect column steps left # by one on every row and the table reads as misaligned. vwidth = max(3, max(len(f"v{p.version}") for p in pols)) - head = Text(f" {'policy'.ljust(width)} {'ver'.ljust(vwidth)} effect", style=theme.LABEL) + head = Text(f" {'policy'.ljust(width)} {'ver'.ljust(vwidth)} effect agents", style=theme.LABEL) body.append(head) for p in pols: row = Text(" ") row.append(p.id.ljust(width), style=theme.TEXT) row.append(f" {f'v{p.version}'.ljust(vwidth)} ", style=theme.TEXT_DIM) row.append_text(_effect(p.effect)) + row.append(" " + _agent_target_label(getattr(p, "agent_targets", None)), style=theme.TEXT_DIM) body.append(row) else: body.append(Text(" no policies deployed", style=theme.FAINT)) @@ -6257,18 +6269,24 @@ def render_deploy_plan(plan: Any, *, applied: bool = False) -> None: lines = [] for p in plan.added: t = Text(" + ", style=theme.SUCCESS); t.append_text(_policy_cell(p)) - t.append(" "); t.append_text(_effect(p.effect)); lines.append(t) + t.append(" "); t.append_text(_effect(p.effect)) + t.append(" " + _agent_target_label(getattr(p, "agent_targets", None)), style=theme.TEXT_DIM) + lines.append(t) for was, now in plan.changed: t = Text(" ~ ", style=theme.AMBER); t.append_text(_policy_cell(now)) t.append(" "); t.append_text(_effect(now.effect)) - t.append(f" (was v{was.version} {was.effect})", style=theme.FAINT); lines.append(t) + t.append(" " + _agent_target_label(getattr(now, "agent_targets", None)), style=theme.TEXT_DIM) + t.append(f" (was v{was.version} {was.effect}; {_agent_target_label(getattr(was, 'agent_targets', None))})", style=theme.FAINT) + lines.append(t) for p in plan.removed: t = Text(" - ", style=theme.ERROR) t.append(p.id, style=theme.TEXT_DIM); t.append(f" v{p.version}", style=theme.FAINT) lines.append(t) for p in plan.unchanged: t = Text(" = ", style=theme.FAINT); t.append_text(_policy_cell(p)) - t.append(" "); t.append_text(_effect(p.effect)); lines.append(t) + t.append(" "); t.append_text(_effect(p.effect)) + t.append(" " + _agent_target_label(getattr(p, "agent_targets", None)), style=theme.TEXT_DIM) + lines.append(t) if not lines: lines = [Text(" (no policies)", style=theme.FAINT)] @@ -6768,16 +6786,22 @@ def render_deployment_history(machine_id: str, entries: Sequence[dict]) -> None: # that STOPPED BLOCKING, and it rendered as "no change". It also split a # version bump into a "+x" and a "-x" for the same policy, which reads # as removed-and-re-added rather than moved. - cur = {p.get("id"): (p.get("version"), p.get("effect")) + cur = {p.get("id"): (p.get("version"), p.get("effect"), + tuple(sorted( + (t.get("integration") or "", t.get("instanceId") or "") + for t in (p.get("agentTargets") or []) + ))) for p in (e.get("policies") or [])} + scope = {p.get("id"): _agent_target_label(p.get("agentTargets")) + for p in (e.get("policies") or [])} mode = e.get("jevMode") or "local" if prev is None: - diffs[e.get("deployment")] = [("+", i) for i in sorted(cur)] + diffs[e.get("deployment")] = [("+", f"{i} → {scope[i]}") for i in sorted(cur)] else: diffs[e.get("deployment")] = ( - [("+", i) for i in sorted(set(cur) - set(prev))] + [("+", f"{i} → {scope[i]}") for i in sorted(set(cur) - set(prev))] + [("-", i) for i in sorted(set(prev) - set(cur))] - + [("~", i) for i in sorted(set(cur) & set(prev)) if cur[i] != prev[i]] + + [("~", f"{i} → {scope[i]}") for i in sorted(set(cur) & set(prev)) if cur[i] != prev[i]] # A mode-only generation (`fp fleet jev-mode`) read as "no # change", which is exactly the change a rollback brings back. + ([("~", f"jev {prev_mode}→{mode}")] if mode != prev_mode else []) diff --git a/fp-cloud-cli/tests/test_enforcement_logic.py b/fp-cloud-cli/tests/test_enforcement_logic.py index a08a4283e..494cfe0df 100644 --- a/fp-cloud-cli/tests/test_enforcement_logic.py +++ b/fp-cloud-cli/tests/test_enforcement_logic.py @@ -141,6 +141,52 @@ def test_set_replaces_the_whole_set(): assert plan.result[0].version == 5 +def test_agent_targets_round_trip_and_survive_an_unrelated_add(): + scoped = PolicyRef.from_dict({ + "id": "guard", "version": 2, "effect": "enforce", + "agentTargets": [{"integration": "hermes", "instanceId": "agt_1234567890abcdef"}], + }) + assert scoped.to_dict()["agentTargets"] == [ + {"integration": "hermes", "instanceId": "agt_1234567890abcdef"}, + ] + plan = plan_deploy("machine", current=[scoped], base=2, add=["guard@3"], latest={"guard": 3}) + assert plan.result[0].agent_targets == scoped.agent_targets + assert plan.result[0].version == 3 + assert scoped.version == 2 + + +def test_target_changes_are_a_diff_and_clearing_is_explicit(): + initial = ref("guard") + narrowed = plan_deploy( + "machine", current=[initial], base=1, + targets=["guard=hermes/agt_1234567890abcdef", "guard=codex"], + ) + assert narrowed.changed == [(initial, narrowed.result[0])] + assert narrowed.result[0].agent_targets == [ + {"integration": "hermes", "instanceId": "agt_1234567890abcdef"}, + {"integration": "codex"}, + ] + cleared = plan_deploy( + "machine", current=narrowed.result, base=2, all_agents=["guard"], + ) + assert cleared.result[0].agent_targets is None + assert cleared.set_changes + + +@pytest.mark.parametrize("targets,clears", [ + (["guard=stranger"], []), + (["guard=hermes/bad"], []), + (["guard=hermes", "guard=hermes"], []), + (["guard=hermes"], ["guard"]), + (["missing=codex"], []), + (["guard@1=codex"], []), +]) +def test_invalid_agent_target_request_cannot_write(targets, clears): + with pytest.raises(RefError): + plan_deploy("machine", current=[ref("guard")], base=1, + targets=targets, all_agents=clears) + + def test_set_cannot_be_mixed_with_add_or_remove(): """"exactly these" and "these as well" have no single reading.""" with pytest.raises(RefError, match="cannot be combined"): diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py index 95cb0a0ba..3a2771e68 100644 --- a/hermes-plugin/__init__.py +++ b/hermes-plugin/__init__.py @@ -4,6 +4,7 @@ import logging import os +import re from pathlib import Path from typing import Any, Mapping @@ -39,6 +40,21 @@ def _profile_name() -> str: return "default" +def _profile_settings_path(profile: str) -> str | None: + """Evidence of the profile running this plugin, not a transcript guess.""" + configured = os.environ.get("HERMES_HOME", "").strip() + if configured: + selected = Path(configured).expanduser() / "config.yaml" + elif profile == "default": + selected = Path.home() / ".hermes" / "config.yaml" + elif re.fullmatch(r"[A-Za-z0-9._-]{1,80}", profile): + selected = Path.home() / ".hermes" / "profiles" / profile / "config.yaml" + else: + return None + # An unresolvable profile does not acquire some other profile's ID. + return str(selected.absolute()) if selected.is_file() else None + + def _string(value: object) -> str: return value if isinstance(value, str) else "" @@ -84,6 +100,7 @@ def _evaluate(self, event: str, payload: Mapping[str, Any]) -> PolicyVerdict: event=event, payload=payload, cwd=cwd, + agent_settings_path=_profile_settings_path(self.profile), connect_timeout_ms=self.connect_timeout_ms, evaluation_timeout_ms=self.evaluation_timeout_ms, ) diff --git a/hermes-plugin/client.py b/hermes-plugin/client.py index 374a89ed6..c3b2061e5 100644 --- a/hermes-plugin/client.py +++ b/hermes-plugin/client.py @@ -75,6 +75,7 @@ def evaluate_policy( event: str, payload: Mapping[str, Any], cwd: str | None, + agent_settings_path: str | None = None, connect_timeout_ms: int = 250, evaluation_timeout_ms: int = 12_000, ) -> PolicyVerdict: @@ -86,6 +87,8 @@ def evaluate_policy( "payload": dict(payload), "cwd": cwd, } + if agent_settings_path: + request["agentSettingsPath"] = agent_settings_path try: body = json.dumps( request, diff --git a/src/hooks/agent-roster.ts b/src/hooks/agent-roster.ts new file mode 100644 index 000000000..39cfb0731 --- /dev/null +++ b/src/hooks/agent-roster.ts @@ -0,0 +1,113 @@ +/** + * Read the daemon-owned profile roster. The telemetry agent_id and transcript + * path are not profile evidence. A scoped Cloud assignment is withheld unless + * this invocation resolves to one of the daemon's recorded config paths. + */ +import { lstatSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { agentRosterFile } from "./fp-home"; +import { validAgentIdentity, type AgentIdentity } from "./agent-targets"; +import type { IntegrationType } from "./types"; + +const USER_SETTINGS: Partial> = { + claude: ".claude/settings.json", + codex: ".codex/hooks.json", + copilot: ".copilot/hooks/failproofai.json", + cursor: ".cursor/hooks.json", + opencode: ".config/opencode/opencode.json", + pi: ".pi/agent/settings.json", + factory: ".factory/hooks.json", + devin: ".config/devin/config.json", + antigravity: ".gemini/config/hooks.json", + goose: ".agents/plugins/failproofai/hooks/hooks.json", +}; + +const PROJECT_SETTINGS: Partial> = { + claude: [".claude/settings.local.json", ".claude/settings.json"], + codex: [".codex/hooks.json"], + copilot: [".github/hooks/failproofai.json"], + cursor: [".cursor/hooks.json"], + opencode: [".opencode/opencode.json"], + pi: [".pi/settings.json"], + factory: [".factory/hooks.json"], + devin: [".devin/config.json"], + antigravity: [".agents/hooks.json"], + goose: [".agents/plugins/failproofai/hooks/hooks.json"], +}; + +function installedHookAt(path: string): boolean { + try { + const stat = statSync(path); + return stat.isFile() && stat.size <= 131_072 && readFileSync(path, "utf8").includes("failproofai"); + } catch { + return false; + } +} + +/** Called in the originating hook process; the worker's env may be different. */ +export function runtimeAgentSettingsPath(cli: IntegrationType, cwd?: string, userHome = homedir()): string | null { + if (cli === "hermes") { + const home = process.env.HERMES_HOME; + return resolve(home?.trim() ? home : resolve(userHome, ".hermes"), "config.yaml"); + } + if (cli === "openclaw") { + const config = process.env.OPENCLAW_CONFIG_PATH; + if (config?.trim()) return resolve(config); + const state = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME; + return resolve(state?.trim() ? state : resolve(userHome, ".openclaw"), "openclaw.json"); + } + const relative = USER_SETTINGS[cli]; + if (!relative) return null; + const override = cli === "codex" ? process.env.CODEX_HOME + : cli === "claude" ? process.env.CLAUDE_CONFIG_DIR : undefined; + const user = resolve(override?.trim() ? override : userHome, override?.trim() + ? cli === "codex" ? "hooks.json" : "settings.json" + : relative); + const matches = new Set(); + if (installedHookAt(user)) matches.add(user); + let dir = resolve(cwd ?? process.cwd()); + const userRoot = resolve(userHome); + for (let depth = 0; depth < 16 && dir !== userRoot; depth++) { + for (const candidate of PROJECT_SETTINGS[cli] ?? []) { + const path = resolve(dir, candidate); + if (installedHookAt(path)) matches.add(path); + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + // No source hint can tell WHICH installed hook fired if both user and + // project/local scopes contain us. A guessed profile would let an exact + // assignment match another installation: withhold it instead. + if (matches.size > 1) return null; + return matches.values().next().value ?? user; +} + +export function readRuntimeAgentIdentity(cli: IntegrationType, settingsPath: string | null): AgentIdentity | null { + if (!settingsPath) return null; + try { + const path = agentRosterFile(); + const stat = lstatSync(path); + if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 256_000) return null; + const roster: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!roster || typeof roster !== "object" || Array.isArray(roster)) return null; + const data = roster as Record; + if (data.schemaVersion !== 1 || !Array.isArray(data.agents) || data.agents.length > 64) return null; + const absolute = resolve(settingsPath); + const agent = data.agents.find((entry: unknown) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const record = entry as Record; + return record.integration === cli && record.settingsPath === absolute && + validAgentIdentity({ integration: record.integration, instanceId: record.instanceId }); + }); + const instanceId = (agent as Record | undefined)?.instanceId; + return validAgentIdentity({ integration: cli, instanceId }) + ? { integration: cli, instanceId: instanceId as string } + : null; + } catch { + // Unavailable, malformed or old roster: never infer a profile from cwd, + // transcript, or the display name and widen a scoped deployment. + return null; + } +} diff --git a/src/hooks/agent-targets.ts b/src/hooks/agent-targets.ts new file mode 100644 index 000000000..88b64d9cc --- /dev/null +++ b/src/hooks/agent-targets.ts @@ -0,0 +1,60 @@ +/** Agent identity for enforcement, separate from the project-derived telemetry agent_id. */ +import { INTEGRATION_TYPES, type IntegrationType } from "./types"; + +export interface AgentIdentity { + integration: IntegrationType; + instanceId: string; +} + +export interface AgentTarget { + integration: IntegrationType; + instanceId?: string; +} + +const INSTANCE_ID_RE = /^agt_[0-9a-f]{16,32}$/; +const integrations = new Set(INTEGRATION_TYPES); + +export function validAgentIdentity(value: unknown): value is AgentIdentity { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const agent = value as Record; + return Object.keys(agent).every((key) => key === "integration" || key === "instanceId") && + typeof agent.integration === "string" && integrations.has(agent.integration) && + typeof agent.instanceId === "string" && INSTANCE_ID_RE.test(agent.instanceId); +} + +/** Null/absent means all; a malformed selector is never silently treated as all. */ +export function parseAgentTargets(value: unknown, schemaVersion: number): AgentTarget[] | undefined { + if (value === undefined || value === null) return undefined; + if (schemaVersion < 3 || !Array.isArray(value) || value.length < 1 || value.length > 32) { + throw new Error("invalid agentTargets in cloud-managed active manifest"); + } + const seen = new Set(); + return value.map((raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("invalid agentTargets in cloud-managed active manifest"); + } + const entry = raw as Record; + if (Object.keys(entry).some((key) => key !== "integration" && key !== "instanceId") || + typeof entry.integration !== "string" || !integrations.has(entry.integration) || + (entry.instanceId !== undefined && + (typeof entry.instanceId !== "string" || !INSTANCE_ID_RE.test(entry.instanceId)))) { + throw new Error("invalid agentTargets in cloud-managed active manifest"); + } + const key = `${entry.integration}\0${entry.instanceId ?? ""}`; + if (seen.has(key)) throw new Error("duplicate agentTargets in cloud-managed active manifest"); + seen.add(key); + return { + integration: entry.integration as IntegrationType, + ...(entry.instanceId !== undefined ? { instanceId: entry.instanceId as string } : {}), + }; + }); +} + +/** An unknown runtime identity cannot widen any scoped assignment. */ +export function agentTargetsMatch(targets: readonly AgentTarget[] | undefined, agent: AgentIdentity | null): boolean { + if (targets === undefined) return true; + if (!validAgentIdentity(agent)) return false; + return targets.some((target) => + target.integration === agent.integration && + (target.instanceId === undefined || target.instanceId === agent.instanceId)); +} diff --git a/src/hooks/cloud-managed-policies.ts b/src/hooks/cloud-managed-policies.ts index 866292247..2edb2370b 100644 --- a/src/hooks/cloud-managed-policies.ts +++ b/src/hooks/cloud-managed-policies.ts @@ -8,6 +8,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { cloudPoliciesDir, configFile } from "./fp-home"; +import { agentTargetsMatch, parseAgentTargets, type AgentIdentity, type AgentTarget } from "./agent-targets"; import { authorityFieldsOf } from "./policy-authority"; import type { PolicyAuthority } from "./policy-types"; @@ -22,10 +23,10 @@ import type { PolicyAuthority } from "./policy-types"; * enforced while every other signal says the machine is healthy. Reproduced * exactly that way while syncing this with AgentEye#559. * - * 1 is accepted for files a pre-rename beta daemon left behind; 2 is what is - * written now. + * 1 is accepted for files a pre-rename beta daemon left behind; 2 is + * unscoped and 3 is targeted. An old hook must reject 3. */ -const ACCEPTED_ACTIVE_SCHEMA_VERSIONS: readonly number[] = [1, 2]; +const ACCEPTED_ACTIVE_SCHEMA_VERSIONS: readonly number[] = [1, 2, 3]; const SHA256_RE = /^[a-f0-9]{64}$/; const POLICY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; @@ -59,6 +60,7 @@ export interface CloudManagedPolicyArtifact { authority?: PolicyAuthority; /** The semantic policies that must all be asked and none answer `deny`; see `authority`. */ reviewedBy?: string[]; + agentTargets?: AgentTarget[]; } interface ActiveManifest { @@ -72,6 +74,7 @@ interface ActiveManifest { path: string; authority?: unknown; reviewedBy?: unknown; + agentTargets?: unknown; }>; } @@ -340,6 +343,16 @@ function readActiveRaw(): Record | null { return record; } +/** Schema 3 means at least one machine assignment is targeted, including a + * Jev-only one whose JS policies array is empty. Never reads any artifact. */ +export function readCloudAgentScopeRequired(): boolean { + try { + return readActiveRaw()?.schemaVersion === 3; + } catch { + return false; + } +} + /** * Cloud's Jev mode from `active.json`, or null — no deployment, no mode, or a * file that cannot be read (which is then simply not a mode: the local @@ -401,6 +414,7 @@ export interface CloudAuthorityInput { effect?: PolicyEffect; authority?: PolicyAuthority; reviewedBy?: string[]; + agentTargets?: AgentTarget[]; } /** A name {@link cloudReviewerName} already made (a Jev check name can contain neither `:` nor `/`). */ @@ -466,16 +480,21 @@ export function cloudReviewerNames(policies: ReadonlyArray, * reviewer set, which is rebuilt on every event. Never throws: an unreadable * manifest has no assignments here (the JS reader reports it). */ -export function readCloudAuthorityInputs(): CloudAuthorityInput[] { +export function readCloudAuthorityInputs(agent: AgentIdentity | null = null): CloudAuthorityInput[] { try { const raw = readActiveJson(); if (raw === undefined) return []; - return parseManifest(raw).policies.flatMap((policy) => - typeof policy.id === "string" + const manifest = parseManifest(raw); + return manifest.policies.flatMap((policy) => + typeof policy.id === "string" && + agentTargetsMatch(parseAgentTargets(policy.agentTargets, manifest.schemaVersion), agent) ? [ { id: policy.id, effect: policy.effect === "observe" ? "observe" : "enforce", + ...(parseAgentTargets(policy.agentTargets, manifest.schemaVersion) + ? { agentTargets: parseAgentTargets(policy.agentTargets, manifest.schemaVersion) } + : {}), ...authorityFieldsOf(policy as unknown as Record), } satisfies CloudAuthorityInput, ] @@ -486,7 +505,7 @@ export function readCloudAuthorityInputs(): CloudAuthorityInput[] { } } -export function readActiveCloudManagedPolicies(): CloudManagedPolicyArtifact[] { +export function readActiveCloudManagedPolicies(agent: AgentIdentity | null = null): CloudManagedPolicyArtifact[] { const root = cloudManagedPolicyRoot(); // Parsed once per change of the file (`readActiveJson`). Each JS artifact is @@ -500,9 +519,13 @@ export function readActiveCloudManagedPolicies(): CloudManagedPolicyArtifact[] { } if (raw === undefined) return []; const manifest = parseManifest(raw); + // Scope before reading or importing any artifact: a foreign agent must not + // execute policy code even to discover that it does not apply. + const applicable = manifest.policies.filter((policy) => + agentTargetsMatch(parseAgentTargets(policy.agentTargets, manifest.schemaVersion), agent)); const seen = new Set(); - return manifest.policies.map((policy) => { + return applicable.map((policy) => { if (!POLICY_ID_RE.test(policy.id) || policy.id === "." || policy.id === "..") { throw new Error(`unsafe cloud-managed policy id ${JSON.stringify(policy.id)}`); } @@ -536,6 +559,9 @@ export function readActiveCloudManagedPolicies(): CloudManagedPolicyArtifact[] { sha256: policy.sha256, path, deployment: manifest.deployment, + ...(parseAgentTargets(policy.agentTargets, manifest.schemaVersion) + ? { agentTargets: parseAgentTargets(policy.agentTargets, manifest.schemaVersion) } + : {}), // Unlike `effect`, a malformed authority is dropped rather than refused: // dropping it makes this policy `hard`, the default that keeps enforcing, // while refusing would take the whole deployment down over an optional diff --git a/src/hooks/cloud-policy-errors.ts b/src/hooks/cloud-policy-errors.ts index fbec79546..59cc5e538 100644 --- a/src/hooks/cloud-policy-errors.ts +++ b/src/hooks/cloud-policy-errors.ts @@ -220,6 +220,8 @@ export interface CloudPolicyErrorInputs { budgetDrops?: ReadonlyArray; /** FailproofAI Cloud Jev rate-limited or unavailable here (`semantic/cloud-jev-health.ts`). */ health?: ReadonlyArray; + /** Schema-3 deployment, but the running hook could not identify its agent. */ + agentScopeUnresolved?: boolean; } /** @@ -246,6 +248,12 @@ export function collectCloudPolicyErrors(input: CloudPolicyErrorInputs): CloudPo if (input.manifestError) { out.push({ id: "active.json", version: null, kind: "daemon", message: `Cloud policies could not be loaded: ${input.manifestError}` }); } + if (input.agentScopeUnresolved) { + out.push({ + id: "agentScope", version: null, kind: "daemon", + message: "agent_scope_unresolved: this hook's agent profile is unknown; targeted Cloud policies did not match", + }); + } for (const policy of input.jsPolicies) { const failure = input.jsFailures?.get(policy.id); if (failure) { diff --git a/src/hooks/daemon-client.ts b/src/hooks/daemon-client.ts index 6b5683168..0df815017 100644 --- a/src/hooks/daemon-client.ts +++ b/src/hooks/daemon-client.ts @@ -56,6 +56,8 @@ export interface DaemonHookRequest { * hazard. */ cwd?: string; + /** Settings path of the originating agent profile, not the daemon's. */ + agentSettingsPath?: string; } export interface DaemonHookResponse { @@ -98,6 +100,7 @@ export interface DaemonPolicyEvaluationRequest { event: string; payload: Record; cwd?: string; + agentSettingsPath?: string; } export type DaemonPolicyEvaluationAttempt = @@ -304,6 +307,7 @@ export async function attemptDaemonHook( cli: req.cli, stdin: req.stdin, cwd: req.cwd, + agentSettingsPath: req.agentSettingsPath, }, opts, ); @@ -354,6 +358,7 @@ export async function attemptDaemonPolicyEvaluation( event: req.event, payload: req.payload, cwd: req.cwd, + agentSettingsPath: req.agentSettingsPath, }, opts, ); diff --git a/src/hooks/effective-reviewers.ts b/src/hooks/effective-reviewers.ts index ecf933e22..a872663c8 100644 --- a/src/hooks/effective-reviewers.ts +++ b/src/hooks/effective-reviewers.ts @@ -46,11 +46,13 @@ import { readInstalledPacks, type ResolvedPack } from "./pack-manifest"; import { NO_REVIEWERS, SEMANTIC_REVIEWER_NAMES } from "./policy-authority"; import { cloudReviewerNames, readCloudAuthorityInputs, readCloudJevMode } from "./cloud-managed-policies"; +import type { AgentIdentity } from "./agent-targets"; let cached: ReadonlySet | null = null; let cachedContested: ReadonlyMap = new Map(); /** The agent the current registration pass is for; see {@link forgetEffectiveReviewerNames}. */ let reviewerCli: string | undefined; +let reviewerAgent: AgentIdentity | null = null; /** * The packs whose Jev checks take part for `cli`, filtered the way the regex @@ -193,7 +195,7 @@ export function effectiveReviewerNames(): ReadonlySet { try { // Both reads answer "nothing" for a file they cannot use; guarded anyway, // because a throw here would cost the registration pass — every policy. - const cloud = cloudReviewerNames(readCloudAuthorityInputs(), readCloudJevMode()); + const cloud = cloudReviewerNames(readCloudAuthorityInputs(reviewerAgent), readCloudJevMode()); if (cloud.length > 0) names = new Set([...names, ...cloud]); } catch { // No Cloud reviewers: every `both` policy stays hard. @@ -262,7 +264,8 @@ export function reviewerNamesFor( * runs before it registers anything — so a pack installed under a long-lived * warm worker is picked up on the next event rather than at the next restart. */ -export function forgetEffectiveReviewerNames(cli?: string): void { +export function forgetEffectiveReviewerNames(cli?: string, agent: AgentIdentity | null = null): void { cached = null; reviewerCli = cli; + reviewerAgent = agent; } diff --git a/src/hooks/fp-home.ts b/src/hooks/fp-home.ts index 395bfe2ae..4f9f38536 100644 --- a/src/hooks/fp-home.ts +++ b/src/hooks/fp-home.ts @@ -157,6 +157,9 @@ export const credentialsFile = (home?: string) => atHome(home, "credentials.json */ export const jevConfigFile = (home?: string) => atHome(home, "jev.json"); +/** Owner-only inventory maintained by failproofaid. Paths stay on this machine. */ +export const agentRosterFile = (home?: string) => atHome(home, "agents", "roster.json"); + // ── Daemon binaries ────────────────────────────────────────────────────────── export const binDir = (home?: string) => atHome(home, "bin"); @@ -646,6 +649,9 @@ export const HOME_CLASSES: readonly { path: (home?: string) => string; class: Da // history the user was already told about. Kept OUT of `auditSessionFile` // precisely so both survive a sign-out. { path: auditMachineFile, class: "identity" }, + // Profile IDs are stable across restarts and renames. A reset that deleted + // this file would make Cloud's exact-profile assignments match nobody. + { path: agentRosterFile, class: "identity" }, // ── May be dropped: rebuilt on demand ── // NOTE: `auditDir` itself is deliberately absent. Layout 4 made it MIXED — it diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 6350cab5e..a09747c95 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -62,6 +62,7 @@ import { readActiveCloudManagedPolicies, readCloudJevMode, readCloudJevState, + readCloudAgentScopeRequired, type CloudManagedPolicyArtifact, type CloudPolicyError, } from "./cloud-managed-policies"; @@ -78,6 +79,8 @@ import { missingGuards, packFailureReason, combinedGuardMatch, guardsCover } fro import { readActivePause, type ActivePause } from "./session-pause"; import { jevConfigFile } from "./fp-home"; import { layoutWarningForHook } from "./fp-reset"; +import { readRuntimeAgentIdentity, runtimeAgentSettingsPath } from "./agent-roster"; +import type { AgentIdentity } from "./agent-targets"; /** * Canonicalize an event name to PascalCase. Codex sends snake_case event names @@ -184,6 +187,8 @@ export interface EvaluateHookEventOptions { * written for. */ fallbackCwd?: string; + /** Config selected by the hook process (may differ from the worker's env). */ + agentSettingsPath?: string; /** * The warm worker's hook into its request queue. Called at most once, and * only on the two-tier path, at the point this evaluation stops reading the @@ -296,6 +301,7 @@ interface CloudJevRoute { machineId: string; deployment: number | null; mode: "observe" | "enforce"; + agent: AgentIdentity | null; } /** @@ -315,7 +321,7 @@ interface CloudJevRoute { * unreadable directory above it and a path that is not a file are all null * there too. */ -async function readJevConfig(): Promise<{ +async function readJevConfig(agent?: AgentIdentity | null): Promise<{ config: JevConfig; defaultMode: TwoTierReview["mode"]; /** Set when FailproofAI Cloud's mode asks: the review goes to Cloud. */ @@ -337,7 +343,7 @@ async function readJevConfig(): Promise<{ return { config: resolved.config, defaultMode: DEFAULT_JEV_MODE, - cloud: { machineId: resolved.machineId, deployment: cloud.deployment, mode: cloud.jevMode }, + cloud: { machineId: resolved.machineId, deployment: cloud.deployment, mode: cloud.jevMode, agent: agent ?? null }, }; } if (!existsSync(jevConfigFile())) return null; @@ -386,10 +392,12 @@ async function recordCloudPolicyErrors(input: { manifestError: string | null; jsPolicies: CloudManagedPolicyArtifact[]; jsFailures: LoadAllResult["cloudFailures"] | undefined; + agentScopeUnresolved?: boolean; }): Promise { try { const jev = readCloudJevState(); - const managed = input.manifestError !== null || input.jsPolicies.length > 0 || jev.jevMode !== null || jev.errors.length > 0; + const managed = input.manifestError !== null || input.jsPolicies.length > 0 || jev.jevMode !== null || + jev.errors.length > 0 || input.agentScopeUnresolved === true; // An unmanaged machine never reads further. A managed machine whose // deployment just emptied still writes, so a stale report is cleared. if (!managed && !existsSync(cloudPolicyErrorsPath())) return; @@ -425,6 +433,7 @@ async function recordCloudPolicyErrors(input: { jevProblem, budgetDrops, health, + agentScopeUnresolved: input.agentScopeUnresolved, }), ); } catch (err) { @@ -443,6 +452,7 @@ async function startTwoTier( cli: IntegrationType, opts: EvaluateHookEventOptions | undefined, activePause: ActivePause | null, + agent: AgentIdentity | null, ): Promise { if (!JEV_GATE_EVENTS.has(canonicalEventType)) return null; if (isHumanAuthoredGate(session.rawHookEventName, cli)) return null; @@ -458,7 +468,7 @@ async function startTwoTier( // must not switch off what the org assigned centrally). if (activePause && !cloudAsks) return null; if (!cloudAsks && !jevChecksInstalled()) return null; - const loaded = await readJevConfig(); + const loaded = await readJevConfig(agent); if (!loaded) return null; // Paused, and the config is not Cloud's after all (read in between): the // local path has nothing left to ask. @@ -653,6 +663,9 @@ export async function evaluateHookEvent( rawHookEventName: eventType, cli, }; + const runtimeAgent = readRuntimeAgentIdentity( + cli, opts?.agentSettingsPath ?? runtimeAgentSettingsPath(cli, session.cwd), + ); let config: HooksConfig; let customHooksList: CustomHook[] = []; @@ -699,7 +712,7 @@ export async function evaluateHookEvent( } else { // Load enabled policies (merge across project/local/global scopes) config = readMergedHooksConfig(session.cwd); - clearPolicies(cli); + clearPolicies(cli, runtimeAgent); // A session pause suspends LOCAL policy only, for a bounded time. Cloud // assignments are exempt below for the same reason `disabledCustomPolicies` @@ -764,7 +777,7 @@ export async function evaluateHookEvent( /** Why the Cloud JS half did not load at all, for `errors.json`. */ let cloudManifestError: string | null = null; try { - cloudManagedPolicies = readActiveCloudManagedPolicies(); + cloudManagedPolicies = readActiveCloudManagedPolicies(runtimeAgent); } catch (err) { const msg = err instanceof Error ? err.message : String(err); cloudManifestError = msg; @@ -1005,6 +1018,7 @@ export async function evaluateHookEvent( manifestError: cloudManifestError, jsPolicies: cloudManagedPolicies, jsFailures: loadResult.cloudFailures, + agentScopeUnresolved: runtimeAgent === null && readCloudAgentScopeRequired(), }); // Fail closed on enforcement this machine was told it had and does not. @@ -1096,7 +1110,7 @@ export async function evaluateHookEvent( // starts HERE, before any regex policy runs, and evaluatePolicies combines // the two (see semantic/combine.ts). Otherwise this is null and the call // below is exactly the regex-only evaluation it always was. - const twoTier = await startTwoTier(canonicalEventType, parsed, session, cli, opts, activePause); + const twoTier = await startTwoTier(canonicalEventType, parsed, session, cli, opts, activePause, runtimeAgent); // On the two-tier path the registry is read for the activity row BEFORE // evaluating, because evaluatePolicies may hand the registry back to the // warm worker's queue (`releaseRegistry`) while it waits on Jev, and the diff --git a/src/hooks/policy-registry.ts b/src/hooks/policy-registry.ts index fe9c08fb5..4aaca9265 100644 --- a/src/hooks/policy-registry.ts +++ b/src/hooks/policy-registry.ts @@ -8,6 +8,7 @@ import type { HookEventType } from "./types"; import type { PolicyFunction, PolicyMatcher, PolicyParamsSchema, RegisteredPolicy } from "./policy-types"; import { effectiveReviewerNames, forgetEffectiveReviewerNames } from "./effective-reviewers"; +import type { AgentIdentity } from "./agent-targets"; import { resolvePolicyAuthority, type AuthorityDeclaration } from "./policy-authority"; const REGISTRY_KEY = "__FAILPROOFAI_POLICY_REGISTRY__"; @@ -126,14 +127,14 @@ export function getPoliciesForEvent( } /** `cli`: the agent this pass registers for, which scopes the Jev reviewer set. */ -export function clearPolicies(cli?: string): void { +export function clearPolicies(cli?: string, agent: AgentIdentity | null = null): void { const g = globalThis as GlobalWithRegistry; g[REGISTRY_KEY] = []; setIndexCache(null); // The reviewer set describes the policies that are about to be registered, so // it is rebuilt with them. Dropping it here is also what keeps one read per // evaluation instead of one per policy. - forgetEffectiveReviewerNames(cli); + forgetEffectiveReviewerNames(cli, agent); } /** diff --git a/src/hooks/semantic/cloud-jev.ts b/src/hooks/semantic/cloud-jev.ts index 1677d9276..b0945029c 100644 --- a/src/hooks/semantic/cloud-jev.ts +++ b/src/hooks/semantic/cloud-jev.ts @@ -43,6 +43,7 @@ import type { SemanticPolicy, SemanticVerdict, } from "./types"; +import type { AgentIdentity } from "../agent-targets"; /** The `cloud` block's version (CONTRACT C10.3). */ export const CLOUD_BLOCK_VERSION = 1; @@ -74,8 +75,9 @@ export const REDACTED_TARGET_WORD = "\u0000redacted"; /** The `cloud` block of a request (CONTRACT C10.3). */ export interface CloudJevBlock { - v: 1; + v: 1 | 2; machineId: string; + agent?: AgentIdentity; intentMode: IntentMode; facts: Facts; targetScan: { groups: string[][]; complete: boolean }; @@ -109,6 +111,8 @@ export type CloudSemanticOutcome = export interface CloudSemanticOptions extends SemanticOptions { /** This machine's FailproofAI Cloud machine id, from `credentials.json`. */ machineId: string; + /** Explicit null = v2 request with unresolved identity (no scoped checks). */ + agent?: AgentIdentity | null; } // ── The request ────────────────────────────────────────────────────────────── @@ -247,11 +251,12 @@ function cloudFacts(facts: Facts): Facts { * clear and every softening of an overridable check — Cloud is then stricter * than the local decider, never laxer. */ -export function buildCloudBlock(prepared: PreparedCall, input: SemanticInput, machineId: string): CloudJevBlock { +export function buildCloudBlock(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): CloudJevBlock { const scan = scanTargets(input.toolInput); const block: CloudJevBlock = { - v: CLOUD_BLOCK_VERSION, + v: agent === undefined ? CLOUD_BLOCK_VERSION : 2, machineId, + ...(agent ? { agent } : {}), intentMode: prepared.intent, facts: cloudFacts(prepared.facts), targetScan: cloudTargetScan(input.toolInput, scan), @@ -313,13 +318,13 @@ function namingReadings( } /** The request sent to FailproofAI Cloud: today's, the global questions always, and the block. */ -export function buildCloudRequest(prepared: PreparedCall, input: SemanticInput, machineId: string): JevRequest { +export function buildCloudRequest(prepared: PreparedCall, input: SemanticInput, machineId: string, agent?: AgentIdentity | null): JevRequest { const own = prepared.compiled.request; return { model: own.model, state: own.state, questions: { ...own.questions, ...cloudGlobalQuestions(prepared.intent, prepared.userSaid.length) }, - cloud: buildCloudBlock(prepared, input, machineId) as unknown as Record, + cloud: buildCloudBlock(prepared, input, machineId, agent) as unknown as Record, }; } @@ -564,7 +569,7 @@ export async function evaluateCloudSemantic(input: SemanticInput, opts: CloudSem let request: JevRequest; try { prepared = prepareSemantic(input, opts); - request = buildCloudRequest(prepared, input, opts.machineId); + request = buildCloudRequest(prepared, input, opts.machineId, opts.agent); } catch (err) { return { status: "degraded", diff --git a/src/hooks/semantic/jev-review.ts b/src/hooks/semantic/jev-review.ts index 08a2a2e4b..4f4ac04d5 100644 --- a/src/hooks/semantic/jev-review.ts +++ b/src/hooks/semantic/jev-review.ts @@ -52,6 +52,7 @@ */ import { BUILTIN_POLICIES } from "../builtin-policies"; import { cloudReviewerName } from "../cloud-managed-policies"; +import type { AgentIdentity } from "../agent-targets"; import { recordJevBudgetDrops } from "../cloud-policy-errors"; import { normalizePolicyName } from "../policy-registry"; import { effectiveAuthority, type PolicyAuthority, type RegisteredPolicy } from "../policy-types"; @@ -171,7 +172,7 @@ export interface JevCallContext { * to Cloud with this machine's id, and the deployment it was made under * scopes the answer cache (a new deployment can change Cloud's checks). */ - cloud?: { machineId: string; deployment: number | null; mode: "observe" | "enforce" }; + cloud?: { machineId: string; deployment: number | null; mode: "observe" | "enforce"; agent?: AgentIdentity | null }; /** * A session pause is active. It suspends local policy, so no installed * pack's check is asked; FailproofAI Cloud's checks are exempt from a pause @@ -413,6 +414,7 @@ export function startJevReview(cfg: JevConfig, call: JevCallContext): TwoTierRev ? evaluateCloudSemantic(input, { ...options, machineId: cloud.machineId, + agent: cloud.agent, // Paused: no installed pack's check is sent, only the globals, and // Cloud's own checks are selected and decided on Cloud as ever. ...(call.localPaused ? { policies: [] } : {}), diff --git a/src/hooks/worker-server.ts b/src/hooks/worker-server.ts index 81c2e4cb0..bba1e80f0 100644 --- a/src/hooks/worker-server.ts +++ b/src/hooks/worker-server.ts @@ -36,6 +36,7 @@ interface WorkerHookRequest { cli: IntegrationType; stdin: string; cwd?: string; + agentSettingsPath?: string; } function isWorkerHookRequest(msg: unknown): msg is WorkerHookRequest { @@ -54,7 +55,9 @@ function isWorkerHookRequest(msg: unknown): msg is WorkerHookRequest { // `failproofai config` aborted with "its worker process could not be run" // against a daemon and worker that were both perfectly healthy. On a // daemonConfigured machine the same mismatch denies the tool call. - (m.cwd === undefined || m.cwd === null || typeof m.cwd === "string") + (m.cwd === undefined || m.cwd === null || typeof m.cwd === "string") && + (m.agentSettingsPath === undefined || m.agentSettingsPath === null || + typeof m.agentSettingsPath === "string") ); } @@ -278,6 +281,7 @@ function handleConnection(socket: Socket, shutdown: () => void): void { // Normalised here so no consumer has to know the wire spells // "absent" as null. fallbackCwd: request.cwd ?? undefined, + agentSettingsPath: request.agentSettingsPath ?? undefined, releaseRegistry: release, }); deliver( From 10202998c51c8a0f6e333f63bef31b6a7951b56e Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 17:21:06 +0530 Subject: [PATCH 2/8] fix(enforcement): bind agent scope to the originating hook --- CHANGELOG.md | 1 + __tests__/e2e/helpers/hook-runner.ts | 9 ++- .../hooks/agent-scoped-policies.e2e.test.ts | 78 +++++++++++++++++++ __tests__/hooks/agent-roster.test.ts | 25 ++++++ __tests__/hooks/agent-scope-hints.test.ts | 28 +++++++ __tests__/hooks/cloud-jev-policies.test.ts | 2 + __tests__/hooks/daemon-client.test.ts | 3 + __tests__/hooks/integrations.test.ts | 30 +++---- __tests__/hooks/manager.test.ts | 14 ++-- __tests__/hooks/opencode-plugin-shim.test.ts | 5 +- bin/failproofai.mjs | 20 ++++- crates/failproofaid/src/server.rs | 20 ++++- fp-cloud-cli/README.md | 6 +- hermes-plugin/client.py | 5 +- src/hooks/agent-roster.ts | 41 ++++++++-- src/hooks/handler.ts | 11 ++- src/hooks/integrations.ts | 29 ++++--- 17 files changed, 274 insertions(+), 53 deletions(-) create mode 100644 __tests__/e2e/hooks/agent-scoped-policies.e2e.test.ts create mode 100644 __tests__/hooks/agent-scope-hints.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f194c6e4e..6ef933e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Cloud-managed assignments can target integrations or individual agent profiles. The daemon keeps an owner-only, stable profile roster and reports it to FailproofAI Cloud; scoped schema-3 deployments filter JavaScript before import and send agent identity for Cloud Jev selection. `fp fleet deploy --target POLICY=INTEGRATION[/agt_ID]` narrows a machine assignment; `--all-agents POLICY` clears its scope. +- Newly installed shell hooks and OpenCode shims identify the user, project or local settings scope that launched them. When a legacy or package-level hook cannot prove its source, a scoped Cloud assignment is withheld and `agent_scope_unresolved` is reported rather than using the daemon worker's own environment as the agent's identity. - Uploads, `fp` calls and evaluator calls carry a request id; the daemon also sends batch and machine ids, and `fp` errors show a `ref` (#872) - **Jev policies deploy from FailproofAI Cloud, individually, and run there.** A Cloud policy has a kind — `regex` (JavaScript, as before), `jev` (Jev checks only) or `both` (JavaScript reviewable by its own checks). Jev checks run on FailproofAI Cloud; nothing is installed on the machine: the daemon receives a `both` policy's JavaScript (with the server-derived `authority`/`reviewedBy`) and the machine's Jev mode, and nothing else Jev-related. `failproofai policies` lists `both` policies with the Cloud checks that review them. - **FailproofAI Cloud can set a machine's Jev mode.** `off` switches Jev off whatever `jev.json` says. `observe`/`enforce` send every gated tool call to FailproofAI Cloud on the machine's Cloud Jev credential (`jev.json` is not used): the machine's own questions — the global intent questions always, plus its installed packs' checks — and a `cloud` block of tool-call metadata; Cloud asks its checks for that machine in the same request and returns their verdict, which the machine merges with its packs' (Cloud first, most severe wins) before the regex combine. A `both` policy is cleared only by its own Cloud checks' outcomes, never by a pack's check of the same name. Cloud gets 5 s to answer (a local `jev.json` keeps its own timeout); a Cloud failure or timeout is today's fallback: the regex decides alone. A session pause does not stop Cloud's checks, as it never stopped Cloud JS policies: a paused session's calls still go to FailproofAI Cloud, with no installed pack's check in them. `failproofai jev status` says "Jev checks run on FailproofAI Cloud (mode: …)" and names each `both` policy's Cloud reviewers. diff --git a/__tests__/e2e/helpers/hook-runner.ts b/__tests__/e2e/helpers/hook-runner.ts index 7252ebf43..f97901868 100644 --- a/__tests__/e2e/helpers/hook-runner.ts +++ b/__tests__/e2e/helpers/hook-runner.ts @@ -40,7 +40,12 @@ export interface HookRunResult { export function runHook( event: string, payload: Record, - 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(); @@ -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, }); diff --git a/__tests__/e2e/hooks/agent-scoped-policies.e2e.test.ts b/__tests__/e2e/hooks/agent-scoped-policies.e2e.test.ts new file mode 100644 index 000000000..bae98fae9 --- /dev/null +++ b/__tests__/e2e/hooks/agent-scoped-policies.e2e.test.ts @@ -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"), + })); + }); +}); diff --git a/__tests__/hooks/agent-roster.test.ts b/__tests__/hooks/agent-roster.test.ts index b54dc29ab..d2db7ec7a 100644 --- a/__tests__/hooks/agent-roster.test.ts +++ b/__tests__/hooks/agent-roster.test.ts @@ -37,10 +37,35 @@ describe("runtime agent identity", () => { expect(runtimeAgentSettingsPath("claude", nested, user)).toBe(projectSettings); writeFileSync(userSettings, '{"hooks":"failproofai --hook PreToolUse"}'); expect(runtimeAgentSettingsPath("claude", nested, user)).toBeNull(); + expect(runtimeAgentSettingsPath("claude", nested, user, "project")).toBe(projectSettings); + expect(runtimeAgentSettingsPath("claude", nested, user, "user")).toBe(userSettings); + const localSettings = join(project, ".claude", "settings.local.json"); + writeFileSync(localSettings, '{"hooks":"failproofai --hook PreToolUse"}'); + expect(runtimeAgentSettingsPath("claude", nested, user, "local")).toBe(localSettings); + expect(runtimeAgentSettingsPath("claude", nested, user, "project")).toBe(projectSettings); rmSync(projectSettings); + rmSync(localSettings); expect(runtimeAgentSettingsPath("claude", nested, user)).toBe(userSettings); }); + it("counts Pi's relative project extension when deciding whether two scopes are ambiguous", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-pi-scopes-")); + roots.push(root); + const user = join(root, "user"); + const project = join(root, "repo"); + const projectSettings = join(project, ".pi", "settings.json"); + const userSettings = join(user, ".pi", "agent", "settings.json"); + mkdirSync(join(project, ".pi"), { recursive: true }); + mkdirSync(join(user, ".pi", "agent"), { recursive: true }); + writeFileSync(projectSettings, '{"packages":["../pi-extension"]}'); + expect(runtimeAgentSettingsPath("pi", project, user)).toBe(projectSettings); + writeFileSync(userSettings, '{"packages":["/opt/failproofai/pi-extension"]}'); + expect(runtimeAgentSettingsPath("pi", project, user)).toBeNull(); + // A package-level Pi extension cannot stamp which settings file loaded + // it. Without that proof an exact-profile assignment matches neither. + expect(readRuntimeAgentIdentity("pi", runtimeAgentSettingsPath("pi", project, user))).toBeNull(); + }); + it("resolves a named profile from the invoking hook's config path", () => { const root = mkdtempSync(join(tmpdir(), "fpai-agent-roster-")); roots.push(root); diff --git a/__tests__/hooks/agent-scope-hints.test.ts b/__tests__/hooks/agent-scope-hints.test.ts new file mode 100644 index 000000000..9272dd1d1 --- /dev/null +++ b/__tests__/hooks/agent-scope-hints.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { getIntegration } from "../../src/hooks/integrations"; +import type { IntegrationType } from "../../src/hooks/types"; + +const SHELL_INTEGRATIONS: IntegrationType[] = [ + "claude", "codex", "copilot", "cursor", + "factory", "devin", "antigravity", "goose", +]; + +describe("installed shell hooks carry their originating scope", () => { + for (const cli of SHELL_INTEGRATIONS) { + it(`${cli}: user and project commands carry different scope hints`, () => { + const integration = getIntegration(cli); + for (const scope of ["user", "project"] as const) { + const entry = integration.buildHookEntry("/usr/local/bin/failproofai", "PreToolUse", scope); + const command = typeof entry.command === "string" ? entry.command : entry.bash; + expect(command).toContain(`--agent-scope ${scope}`); + if (cli === "copilot") expect(entry.powershell).toContain(`--agent-scope ${scope}`); + } + }); + } + + it("Claude's local hook identifies the local settings file", () => { + expect(getIntegration("claude").buildHookEntry("/bin/failproofai", "PreToolUse", "local")) + .toMatchObject({ command: expect.stringContaining("--agent-scope local") }); + }); +}); diff --git a/__tests__/hooks/cloud-jev-policies.test.ts b/__tests__/hooks/cloud-jev-policies.test.ts index 973021ff1..8ff2e9f1a 100644 --- a/__tests__/hooks/cloud-jev-policies.test.ts +++ b/__tests__/hooks/cloud-jev-policies.test.ts @@ -335,6 +335,8 @@ it("filters a targeted Cloud JS policy before import and runs it only for its se id: "agentScope", kind: "daemon", message: expect.stringContaining("agent_scope_unresolved"), })); + const ambiguous = await evaluateHookEvent("PreToolUse", "claude", input, { agentSettingsPath: "" }); + expect(ambiguous.evaluation?.decision).toBe("allow"); }); async function registeredAfterOneEvent(): Promise> { diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts index e3c78768a..68e227efb 100644 --- a/__tests__/hooks/daemon-client.test.ts +++ b/__tests__/hooks/daemon-client.test.ts @@ -88,6 +88,8 @@ describe("hooks/daemon-client", () => { expect(req.protocolVersion).toBe(1); expect(req.hookEvent).toBe("PreToolUse"); expect(req.cli).toBe("claude"); + // An unresolved source is sent explicitly, never re-inferred by the daemon. + expect(req.agentSettingsPath).toBe(""); socket.end( encodeFrame({ type: "hookResult", @@ -104,6 +106,7 @@ describe("hooks/daemon-client", () => { cli: "claude", stdin: "{}", cwd: "/repo", + agentSettingsPath: "", }); expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); }); diff --git a/__tests__/hooks/integrations.test.ts b/__tests__/hooks/integrations.test.ts index 3155f96c3..c27679d93 100644 --- a/__tests__/hooks/integrations.test.ts +++ b/__tests__/hooks/integrations.test.ts @@ -175,7 +175,7 @@ describe("Claude Code integration", () => { it("buildHookEntry omits --cli for back-compat", () => { const entry = claudeCode.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "user"); - expect(entry.command).toBe('"/usr/bin/failproofai" --hook PreToolUse'); + expect(entry.command).toBe('"/usr/bin/failproofai" --hook PreToolUse --agent-scope user'); expect(entry.command).not.toContain("--cli"); expect(entry.timeout).toBe(60); expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); @@ -183,7 +183,7 @@ describe("Claude Code integration", () => { it("project scope uses npx -y failproofai (portable)", () => { const entry = claudeCode.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project"); - expect(entry.command).toBe("npx -y failproofai --hook PreToolUse"); + expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --agent-scope project"); }); // Installed events are HOOK_EVENT_TYPES minus WorktreeCreate, which is a @@ -267,7 +267,7 @@ describe("OpenAI Codex integration", () => { it("project scope uses npx -y failproofai", () => { const entry = codex.buildHookEntry("/usr/bin/failproofai", "pre_tool_use", "project"); - expect(entry.command).toBe("npx -y failproofai --hook pre_tool_use --cli codex"); + expect(entry.command).toBe("npx -y failproofai --hook pre_tool_use --cli codex --agent-scope project"); }); it("writeHookEntries stores keys in PascalCase via CODEX_EVENT_MAP", () => { @@ -370,8 +370,8 @@ describe("GitHub Copilot integration", () => { it("buildHookEntry uses bash + powershell keys with --cli copilot", () => { const entry = copilot.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "user") as Record; expect(entry.type).toBe("command"); - expect(entry.bash).toBe('"/usr/bin/failproofai" --hook PreToolUse --cli copilot'); - expect(entry.powershell).toBe('"/usr/bin/failproofai" --hook PreToolUse --cli copilot'); + expect(entry.bash).toBe('"/usr/bin/failproofai" --hook PreToolUse --cli copilot --agent-scope user'); + expect(entry.powershell).toBe('"/usr/bin/failproofai" --hook PreToolUse --cli copilot --agent-scope user'); expect(entry.timeoutSec).toBe(60); expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); // Copilot entries do NOT use the Claude-style `command` field @@ -381,8 +381,8 @@ describe("GitHub Copilot integration", () => { it("project scope uses npx -y failproofai (portable)", () => { const entry = copilot.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project") as Record; - expect(entry.bash).toBe("npx -y failproofai --hook PreToolUse --cli copilot"); - expect(entry.powershell).toBe("npx -y failproofai --hook PreToolUse --cli copilot"); + expect(entry.bash).toBe("npx -y failproofai --hook PreToolUse --cli copilot --agent-scope project"); + expect(entry.powershell).toBe("npx -y failproofai --hook PreToolUse --cli copilot --agent-scope project"); }); it("writeHookEntries stores PascalCase event keys and version: 1", () => { @@ -468,7 +468,7 @@ describe("Cursor Agent integration", () => { it("buildHookEntry uses Claude-shaped {command,timeout} with --cli cursor", () => { const entry = cursor.buildHookEntry("/usr/bin/failproofai", "preToolUse", "user") as Record; expect(entry.type).toBe("command"); - expect(entry.command).toBe('"/usr/bin/failproofai" --hook preToolUse --cli cursor'); + expect(entry.command).toBe('"/usr/bin/failproofai" --hook preToolUse --cli cursor --agent-scope user'); expect(entry.timeout).toBe(60); expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); // Cursor entries use the Claude-style `command` field, not Copilot's bash/powershell split. @@ -478,7 +478,7 @@ describe("Cursor Agent integration", () => { it("project scope uses npx -y failproofai (portable)", () => { const entry = cursor.buildHookEntry("/usr/bin/failproofai", "preToolUse", "project") as Record; - expect(entry.command).toBe("npx -y failproofai --hook preToolUse --cli cursor"); + expect(entry.command).toBe("npx -y failproofai --hook preToolUse --cli cursor --agent-scope project"); }); it("writeHookEntries stores camelCase event keys with version: 1 in a FLAT array (no matcher wrapper)", () => { @@ -512,7 +512,7 @@ describe("Cursor Agent integration", () => { const hooks = settings.hooks as Record>>; expect(hooks.preToolUse).toHaveLength(1); // Second call's binary path should win. - expect(hooks.preToolUse[0].command).toBe('"/different/path/failproofai" --hook preToolUse --cli cursor'); + expect(hooks.preToolUse[0].command).toBe('"/different/path/failproofai" --hook preToolUse --cli cursor --agent-scope user'); }); it("removeHooksFromFile clears all failproofai entries (returns count)", () => { @@ -1611,7 +1611,7 @@ describe("Factory Droid integration", () => { it("project scope uses npx -y failproofai", () => { const entry = factory.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project"); - expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli factory"); + expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli factory --agent-scope project"); }); it("writeHookEntries stores event names at the TOP LEVEL (no `hooks` wrapper)", () => { @@ -1721,14 +1721,14 @@ describe("Devin CLI integration", () => { const entry = devin.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "user"); expect(entry.command).toContain("--cli devin"); expect(entry.command).toContain("--hook PreToolUse"); - expect(entry.command).toBe(`"/usr/bin/failproofai" --hook PreToolUse --cli devin`); + expect(entry.command).toBe(`"/usr/bin/failproofai" --hook PreToolUse --cli devin --agent-scope user`); expect(entry.timeout).toBe(60); expect(entry[FAILPROOFAI_HOOK_MARKER]).toBe(true); }); it("project scope uses npx -y failproofai", () => { const entry = devin.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project"); - expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli devin"); + expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli devin --agent-scope project"); }); it("writeHookEntries stores events under a Claude-style `hooks` wrapper", () => { @@ -1831,7 +1831,7 @@ describe("Antigravity CLI integration", () => { it("project scope uses npx -y failproofai", () => { const entry = antigravity.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project"); - expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli antigravity"); + expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli antigravity --agent-scope project"); }); it("writeHookEntries nests events under a named 'failproofai' hook key", () => { @@ -1959,7 +1959,7 @@ describe("Goose integration", () => { it("project scope uses npx -y failproofai", () => { const entry = goose.buildHookEntry("/usr/bin/failproofai", "PreToolUse", "project"); - expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli goose"); + expect(entry.command).toBe("npx -y failproofai --hook PreToolUse --cli goose --agent-scope project"); }); it("writeHookEntries writes the Open Plugins schema (top-level 'hooks' wrapper, matcher OMITTED)", () => { diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 2139903d3..393a710d7 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -175,7 +175,7 @@ describe("hooks/manager", () => { expect(hook.__failproofai_hook__).toBe(true); expect(hook.type).toBe("command"); expect(hook.timeout).toBe(60); - expect(hook.command).toBe(`"/usr/local/bin/failproofai" --hook ${eventType}`); + expect(hook.command).toBe(`"/usr/local/bin/failproofai" --hook ${eventType} --agent-scope user`); } }); @@ -327,7 +327,7 @@ describe("hooks/manager", () => { expect(written.hooks.PreToolUse).toHaveLength(1); expect(written.hooks.PreToolUse[0].hooks[0].command).toBe( - '"/usr/local/bin/failproofai" --hook PreToolUse', + '"/usr/local/bin/failproofai" --hook PreToolUse --agent-scope user', ); }); @@ -358,7 +358,7 @@ describe("hooks/manager", () => { const [, content] = vi.mocked(writeFileSync).mock.calls[0]; const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"C:\\Program Files\\failproofai\\failproofai.exe" --hook PreToolUse'); + expect(hook.command).toBe('"C:\\Program Files\\failproofai\\failproofai.exe" --hook PreToolUse --agent-scope user'); Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); }); @@ -406,7 +406,7 @@ describe("hooks/manager", () => { for (const [eventType, matchers] of Object.entries(written.hooks)) { const hook = (matchers as Array<{ hooks: Array> }>)[0].hooks[0]; - expect(hook.command).toBe(`npx -y failproofai --hook ${eventType}`); + expect(hook.command).toBe(`npx -y failproofai --hook ${eventType} --agent-scope project`); } }); @@ -421,7 +421,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse'); + expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse --agent-scope user'); }); it("local scope uses absolute binary path, not npx", async () => { @@ -435,7 +435,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); const hook = written.hooks.PreToolUse[0].hooks[0]; - expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse'); + expect(hook.command).toBe('"/usr/local/bin/failproofai" --hook PreToolUse --agent-scope local'); }); it("re-install on project scope migrates absolute-path hooks to npx format", async () => { @@ -465,7 +465,7 @@ describe("hooks/manager", () => { const written = JSON.parse(content as string); expect(written.hooks.PreToolUse[0].hooks[0].command).toBe( - "npx -y failproofai --hook PreToolUse", + "npx -y failproofai --hook PreToolUse --agent-scope project", ); }); diff --git a/__tests__/hooks/opencode-plugin-shim.test.ts b/__tests__/hooks/opencode-plugin-shim.test.ts index 291d6bf9f..1012943b0 100644 --- a/__tests__/hooks/opencode-plugin-shim.test.ts +++ b/__tests__/hooks/opencode-plugin-shim.test.ts @@ -80,6 +80,7 @@ async function loadShim(opts: { scope: "user" | "project"; binaryPath: string; c const projectSrc = readFileSync(pluginPath, "utf8"); return projectSrc .replace("USE_NPX = true", "USE_NPX = false") + .replace('AGENT_SCOPE = "project"', 'AGENT_SCOPE = "user"') .replace('FAILPROOFAI_BIN = ""', `FAILPROOFAI_BIN = ${JSON.stringify(opts.binaryPath)}`); })(); @@ -143,7 +144,9 @@ describe("OpenCode plugin shim — translation of plugin events to binary stdin" const hooks = await plugin({ client: fakeClient(), directory: "/repo" }); await hooks["tool.execute.before"]!({ tool: "bash", sessionID: "ses_1", callID: "c1" }, { args: { command: "ls" } }); expect(calls).toHaveLength(1); - expect(calls[0].args).toEqual(["-y", "failproofai", "--hook", "PreToolUse", "--cli", "opencode"]); + expect(calls[0].args).toEqual([ + "-y", "failproofai", "--hook", "PreToolUse", "--cli", "opencode", "--agent-scope", "project", + ]); const stdin = JSON.parse(calls[0].opts.input!); // Shim canonicalizes lowercase opencode tool IDs (`bash`) to Claude // PascalCase (`Bash`) before the JSON crosses to the binary, so builtin diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index 30e3eb83b..ae4825476 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -146,7 +146,22 @@ if (hookIdx >= 0) { ) ? cliArg : "claude"; + const scopeIdx = args.indexOf("--agent-scope"); + const scopeArg = scopeIdx >= 0 ? args[scopeIdx + 1] : undefined; + const scopeHint = scopeIdx < 0 + ? undefined + : scopeArg === "user" || scopeArg === "project" || scopeArg === "local" + ? scopeArg + : null; try { + const { runtimeAgentSettingsPath } = await import("../src/hooks/agent-roster"); + // An empty path is deliberate: it means the originating hook could not + // prove which installed scope fired. Forward it rather than omitting the + // field and letting the warm worker infer a different profile from its own + // environment. + const agentSettingsPath = (scopeHint === null + ? null + : runtimeAgentSettingsPath(cli, process.cwd(), undefined, scopeHint)) ?? ""; // Daemon-aware path — inert (and this whole block skipped) on every // machine until `failproofai config` has installed failproofaid AND // written the daemonConfigured marker (Stage 4). Until then this is @@ -155,7 +170,6 @@ if (hookIdx >= 0) { if (isDaemonConfigured()) { const { readStdinPayload } = await import("../src/hooks/read-stdin"); const { evaluateHookEvent } = await import("../src/hooks/handler"); - const { runtimeAgentSettingsPath } = await import("../src/hooks/agent-roster"); const stdinRead = await readStdinPayload(); const attempt = await attemptDaemonHook({ @@ -166,7 +180,7 @@ if (hookIdx >= 0) { // process is spawned fresh, at that location, by the calling agent // CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md. cwd: process.cwd(), - agentSettingsPath: runtimeAgentSettingsPath(cli, process.cwd()) ?? undefined, + agentSettingsPath, }); // On a daemon-configured machine the daemon is the ONLY evaluator. Every @@ -210,7 +224,7 @@ if (hookIdx >= 0) { } const { handleHookEvent } = await import("../src/hooks/handler"); - const exitCode = await handleHookEvent(eventType, cli); + const exitCode = await handleHookEvent(eventType, cli, agentSettingsPath); // handleHookEvent already flushes its own telemetry before returning; this // is the normal, reliable exit. await exitAfterFlush(exitCode); diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs index b365e8ea9..63d48a790 100644 --- a/crates/failproofaid/src/server.rs +++ b/crates/failproofaid/src/server.rs @@ -325,6 +325,12 @@ fn record_agent_sighting(integration: &str, settings_path: Option<&str>) { } } +/// Pre-C11 callers omit the field entirely. Their profile is UNKNOWN, not +/// whatever the daemon worker's long-lived environment happens to name. +fn worker_agent_settings_path(settings_path: Option<&str>) -> Option<&str> { + Some(settings_path.unwrap_or("")) +} + fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { if request.protocol_version() != PROTOCOL_VERSION { return ServerMessage::Error { @@ -354,7 +360,7 @@ fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { &cli, &stdin, cwd.as_deref(), - agent_settings_path.as_deref(), + worker_agent_settings_path(agent_settings_path.as_deref()), ) { Ok(outcome) => ServerMessage::HookResult { protocol_version: PROTOCOL_VERSION, @@ -391,7 +397,7 @@ fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { &integration, &stdin, cwd.as_deref(), - agent_settings_path.as_deref(), + worker_agent_settings_path(agent_settings_path.as_deref()), ) { Ok(outcome) => match outcome.evaluation { Some(evaluation) @@ -736,6 +742,16 @@ mod tests { std::fs::remove_dir_all(&project_dir).ok(); } + #[test] + fn an_older_hook_without_agent_identity_must_not_borrow_the_workers_profile() { + assert_eq!(worker_agent_settings_path(None), Some("")); + assert_eq!(worker_agent_settings_path(Some("")), Some("")); + assert_eq!( + worker_agent_settings_path(Some("/opt/agent/config.yaml")), + Some("/opt/agent/config.yaml"), + ); + } + #[test] fn mismatched_protocol_version_gets_an_explicit_error() { let socket_path = temp_socket_path("version-mismatch"); diff --git a/fp-cloud-cli/README.md b/fp-cloud-cli/README.md index 561c5c5ea..348dbf11e 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -176,8 +176,10 @@ show` and `fp fleet history` display the assigned scope; `--json` includes `agentTargets` on each targeted policy. A machine must report agent-scoping support before accepting a new target. When its identity cannot be resolved, a targeted policy does not match; unscoped policy remains in force. -If multiple installed hook scopes could have produced a call, the machine -reports `agent_scope_unresolved` rather than guessing which profile ran it. +Newly installed shell hooks carry their settings scope. Reinstall older hooks +to add the hint; without it, or for an integration that cannot report the +source of multiple installed scopes, the machine reports +`agent_scope_unresolved` rather than guessing which profile ran the call. **A deploy REPLACES a machine's whole policy set.** The server takes the full list and does not merge, so `fleet deploy` reads what the machine currently runs, diff --git a/hermes-plugin/client.py b/hermes-plugin/client.py index c3b2061e5..b89f22be3 100644 --- a/hermes-plugin/client.py +++ b/hermes-plugin/client.py @@ -86,9 +86,10 @@ def evaluate_policy( "event": event, "payload": dict(payload), "cwd": cwd, + # An unknown profile is explicit. Omitting this field would let a new + # daemon's warm worker attribute the call to its own Hermes home. + "agentSettingsPath": agent_settings_path or "", } - if agent_settings_path: - request["agentSettingsPath"] = agent_settings_path try: body = json.dumps( request, diff --git a/src/hooks/agent-roster.ts b/src/hooks/agent-roster.ts index 39cfb0731..bc5dcc5a6 100644 --- a/src/hooks/agent-roster.ts +++ b/src/hooks/agent-roster.ts @@ -8,7 +8,7 @@ import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { agentRosterFile } from "./fp-home"; import { validAgentIdentity, type AgentIdentity } from "./agent-targets"; -import type { IntegrationType } from "./types"; +import type { HookScope, IntegrationType } from "./types"; const USER_SETTINGS: Partial> = { claude: ".claude/settings.json", @@ -36,22 +36,41 @@ const PROJECT_SETTINGS: Partial> = { goose: [".agents/plugins/failproofai/hooks/hooks.json"], }; -function installedHookAt(path: string): boolean { +function installedHookAt(path: string, cli: IntegrationType): boolean { try { const stat = statSync(path); - return stat.isFile() && stat.size <= 131_072 && readFileSync(path, "utf8").includes("failproofai"); + if (!stat.isFile() || stat.size > 131_072) return false; + const text = readFileSync(path, "utf8"); + if (text.includes("failproofai")) return true; + // Pi's project entry is portable and relative to `.pi/settings.json`: + // `../pi-extension` contains no product name. Missing it here let a + // simultaneously installed user hook masquerade as this project's Pi. + if (cli !== "pi") return false; + const config: unknown = JSON.parse(text); + if (!config || typeof config !== "object" || Array.isArray(config)) return false; + const packages = (config as { packages?: unknown }).packages; + return Array.isArray(packages) && packages.some( + (entry) => typeof entry === "string" && /(?:^|\/)pi-extension\/?$/.test(entry), + ); } catch { return false; } } /** Called in the originating hook process; the worker's env may be different. */ -export function runtimeAgentSettingsPath(cli: IntegrationType, cwd?: string, userHome = homedir()): string | null { +export function runtimeAgentSettingsPath( + cli: IntegrationType, + cwd?: string, + userHome = homedir(), + hookScope?: HookScope, +): string | null { if (cli === "hermes") { + if (hookScope && hookScope !== "user") return null; const home = process.env.HERMES_HOME; return resolve(home?.trim() ? home : resolve(userHome, ".hermes"), "config.yaml"); } if (cli === "openclaw") { + if (hookScope && hookScope !== "user") return null; const config = process.env.OPENCLAW_CONFIG_PATH; if (config?.trim()) return resolve(config); const state = process.env.OPENCLAW_STATE_DIR || process.env.OPENCLAW_HOME; @@ -64,14 +83,21 @@ export function runtimeAgentSettingsPath(cli: IntegrationType, cwd?: string, use const user = resolve(override?.trim() ? override : userHome, override?.trim() ? cli === "codex" ? "hooks.json" : "settings.json" : relative); + if (hookScope === "user") return user; + + const scopeFiles = hookScope === "local" + ? cli === "claude" ? [".claude/settings.local.json"] : [] + : hookScope === "project" + ? (PROJECT_SETTINGS[cli] ?? []).filter((name) => name !== ".claude/settings.local.json") + : PROJECT_SETTINGS[cli] ?? []; const matches = new Set(); - if (installedHookAt(user)) matches.add(user); + if (hookScope === undefined && installedHookAt(user, cli)) matches.add(user); let dir = resolve(cwd ?? process.cwd()); const userRoot = resolve(userHome); for (let depth = 0; depth < 16 && dir !== userRoot; depth++) { - for (const candidate of PROJECT_SETTINGS[cli] ?? []) { + for (const candidate of scopeFiles) { const path = resolve(dir, candidate); - if (installedHookAt(path)) matches.add(path); + if (installedHookAt(path, cli)) matches.add(path); } const parent = dirname(dir); if (parent === dir) break; @@ -81,6 +107,7 @@ export function runtimeAgentSettingsPath(cli: IntegrationType, cwd?: string, use // project/local scopes contain us. A guessed profile would let an exact // assignment match another installation: withhold it instead. if (matches.size > 1) return null; + if (hookScope === "project" || hookScope === "local") return matches.values().next().value ?? null; return matches.values().next().value ?? user; } diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index a09747c95..80149a192 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -1302,7 +1302,11 @@ export async function evaluateHookEvent( * kept unchanged so nothing about the one-shot (non-daemon) path regresses. * Internally now just a thin wrapper around `evaluateHookEvent`. */ -export async function handleHookEvent(eventType: string, cli: IntegrationType = "claude"): Promise { +export async function handleHookEvent( + eventType: string, + cli: IntegrationType = "claude", + agentSettingsPath?: string, +): Promise { const MAX_STDIN_BYTES = 1_048_576; // 1 MB const stdinRead = await readStdinPayload(MAX_STDIN_BYTES); if (stdinRead.readError) { @@ -1334,7 +1338,10 @@ export async function handleHookEvent(eventType: string, cli: IntegrationType = }); } - const result = await evaluateHookEvent(eventType, cli, stdinRead.payload); + const result = await evaluateHookEvent( + eventType, cli, stdinRead.payload, + agentSettingsPath === undefined ? undefined : { agentSettingsPath }, + ); // Say it out loud, once a session, when the collector is holding batches the // server definitively refused. diff --git a/src/hooks/integrations.ts b/src/hooks/integrations.ts index bb1bf5e68..3b5a99481 100644 --- a/src/hooks/integrations.ts +++ b/src/hooks/integrations.ts @@ -153,6 +153,12 @@ function isMarkedHook(hook: unknown): boolean { return cmd.includes("failproofai") && cmd.includes("--hook"); } +/** A hook names its installing settings scope, avoiding a cwd-based guess + * when user and project/local hooks both exist for the same integration. */ +function agentScopeSuffix(scope?: HookScope): string { + return ` --agent-scope ${scope ?? "user"}`; +} + function stripLegacyVersion(settings: Record): boolean { if ("version" in settings) { delete settings.version; @@ -284,7 +290,7 @@ export const claudeCode: Integration = { : `"${binaryPath}" --hook ${eventType}`; return { type: "command", - command, + command: command + agentScopeSuffix(scope), // Claude reads `timeout` in SECONDS per https://code.claude.com/docs/en/hooks // ("Seconds before canceling. Defaults: 600 for command ...; 60 for agent"), // NOT milliseconds. 60 = 60s; the old 60000 meant ~16.7h. (#482-class unit fix) @@ -463,7 +469,7 @@ export const codex: Integration = { // Codex reads `timeout` in SECONDS (the field is literally `timeout`, // default 600 per https://developers.openai.com/codex/hooks) — same unit as // Claude/Cursor/Copilot. 60 = 60s. - command, + command: command + agentScopeSuffix(scope), timeout: 60, [FAILPROOFAI_HOOK_MARKER]: true, }; @@ -628,8 +634,8 @@ export const copilot: Integration = { : `"${binaryPath}" --hook ${eventType} --cli copilot`; return { type: "command", - bash: cmd, - powershell: cmd, + bash: cmd + agentScopeSuffix(scope), + powershell: cmd + agentScopeSuffix(scope), timeoutSec: 60, [FAILPROOFAI_HOOK_MARKER]: true, }; @@ -772,7 +778,7 @@ export const cursor: Integration = { // use 30 and 10), NOT milliseconds. 60 = 60s; the old 60000 meant ~16.7h. return { type: "command", - command, + command: command + agentScopeSuffix(scope), timeout: 60, [FAILPROOFAI_HOOK_MARKER]: true, }; @@ -965,12 +971,13 @@ function canonicalizeToolInput(canonicalToolName, args) { const FAILPROOFAI_BIN = ${escapedBin}; const USE_NPX = ${useNpx}; +const AGENT_SCOPE = ${JSON.stringify(scope)}; function runFailproofai(eventName, payload, directory) { const cmd = USE_NPX ? "npx" : FAILPROOFAI_BIN; const args = USE_NPX - ? ["-y", "failproofai", "--hook", eventName, "--cli", "opencode"] - : ["--hook", eventName, "--cli", "opencode"]; + ? ["-y", "failproofai", "--hook", eventName, "--cli", "opencode", "--agent-scope", AGENT_SCOPE] + : ["--hook", eventName, "--cli", "opencode", "--agent-scope", AGENT_SCOPE]; const r = spawnSync(cmd, args, { input: JSON.stringify(payload), encoding: "utf8", @@ -2444,7 +2451,7 @@ export const factory: Integration = { : `"${binaryPath}" --hook ${eventType} --cli factory`; return { type: "command", - command, + command: command + agentScopeSuffix(scope), // droid reads `timeout` in SECONDS (verified against droid v0.171.0). 30s. timeout: 30, [FAILPROOFAI_HOOK_MARKER]: true, @@ -2577,7 +2584,7 @@ export const devin: Integration = { : `"${binaryPath}" --hook ${eventType} --cli devin`; return { type: "command", - command, + command: command + agentScopeSuffix(scope), // Devin reads `timeout` in SECONDS like Claude. 60 = 60s. timeout: 60, [FAILPROOFAI_HOOK_MARKER]: true, @@ -2722,7 +2729,7 @@ export const antigravity: Integration = { : `"${binaryPath}" --hook ${eventType} --cli antigravity`; return { type: "command", - command, + command: command + agentScopeSuffix(scope), // Antigravity reads `timeout` in SECONDS (verified agy v1.1.2). 30s. timeout: 30, [FAILPROOFAI_HOOK_MARKER]: true, @@ -2910,7 +2917,7 @@ export const goose: Integration = { : `"${binaryPath}" --hook ${eventType} --cli goose`; // Open Plugins command entry: { type, command } only (Goose applies its own // timeout; no marker field — see isGooseFailproofaiHook). - return { type: "command", command }; + return { type: "command", command: command + agentScopeSuffix(scope) }; }, isFailproofaiHook: isGooseFailproofaiHook, From cc41294af1b663a1b074ce1332445ac0aa0d3695 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 17:29:39 +0530 Subject: [PATCH 3/8] test(enforcement): replay shared agent target selector cases --- __tests__/fixtures/agent-targets.json | 18 +++++++++++ .../hooks/agent-targets-fixtures.test.ts | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 __tests__/fixtures/agent-targets.json create mode 100644 __tests__/hooks/agent-targets-fixtures.test.ts diff --git a/__tests__/fixtures/agent-targets.json b/__tests__/fixtures/agent-targets.json new file mode 100644 index 000000000..c07733740 --- /dev/null +++ b/__tests__/fixtures/agent-targets.json @@ -0,0 +1,18 @@ +{ + "cases": [ + {"name":"unscoped", "schemaVersion":2, "targets":null, "agent":null, "valid":true, "matches":true}, + {"name":"integration-all-profiles", "schemaVersion":3, "targets":[{"integration":"claude"}], "agent":{"integration":"claude","instanceId":"agt_1111111111111111"}, "valid":true, "matches":true}, + {"name":"integration-other-agent", "schemaVersion":3, "targets":[{"integration":"claude"}], "agent":{"integration":"codex","instanceId":"agt_1111111111111111"}, "valid":true, "matches":false}, + {"name":"profile-exact", "schemaVersion":3, "targets":[{"integration":"hermes","instanceId":"agt_2222222222222222"}], "agent":{"integration":"hermes","instanceId":"agt_2222222222222222"}, "valid":true, "matches":true}, + {"name":"profile-other-instance", "schemaVersion":3, "targets":[{"integration":"hermes","instanceId":"agt_2222222222222222"}], "agent":{"integration":"hermes","instanceId":"agt_3333333333333333"}, "valid":true, "matches":false}, + {"name":"profile-other-integration", "schemaVersion":3, "targets":[{"integration":"hermes","instanceId":"agt_2222222222222222"}], "agent":{"integration":"codex","instanceId":"agt_2222222222222222"}, "valid":true, "matches":false}, + {"name":"scope-unresolved", "schemaVersion":3, "targets":[{"integration":"codex"}], "agent":null, "valid":true, "matches":false}, + {"name":"or-combination", "schemaVersion":3, "targets":[{"integration":"claude"},{"integration":"hermes","instanceId":"agt_2222222222222222"}], "agent":{"integration":"hermes","instanceId":"agt_2222222222222222"}, "valid":true, "matches":true}, + {"name":"both-halves-same-scope", "schemaVersion":3, "targets":[{"integration":"codex"}], "agent":{"integration":"codex","instanceId":"agt_4444444444444444"}, "valid":true, "matches":true}, + {"name":"empty-array", "schemaVersion":3, "targets":[], "agent":null, "valid":false}, + {"name":"duplicate-selector", "schemaVersion":3, "targets":[{"integration":"codex"},{"integration":"codex"}], "agent":null, "valid":false}, + {"name":"unknown-integration", "schemaVersion":3, "targets":[{"integration":"invented"}], "agent":null, "valid":false}, + {"name":"invalid-profile-id", "schemaVersion":3, "targets":[{"integration":"hermes","instanceId":"bad"}], "agent":null, "valid":false}, + {"name":"scoped-in-schema-two", "schemaVersion":2, "targets":[{"integration":"claude"}], "agent":null, "valid":false} + ] +} diff --git a/__tests__/hooks/agent-targets-fixtures.test.ts b/__tests__/hooks/agent-targets-fixtures.test.ts new file mode 100644 index 000000000..56efb6459 --- /dev/null +++ b/__tests__/hooks/agent-targets-fixtures.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { agentTargetsMatch, parseAgentTargets, type AgentIdentity } from "../../src/hooks/agent-targets"; + +interface FixtureCase { + name: string; + schemaVersion: number; + targets: unknown; + agent: AgentIdentity | null; + valid: boolean; + matches?: boolean; +} + +const fixtures = JSON.parse( + readFileSync(resolve(__dirname, "../fixtures/agent-targets.json"), "utf8"), +) as { cases: FixtureCase[] }; + +describe("shared Rust/TypeScript agent selector cases", () => { + for (const fixture of fixtures.cases) { + it(fixture.name, () => { + const parse = () => parseAgentTargets(fixture.targets, fixture.schemaVersion); + if (!fixture.valid) { + expect(parse).toThrow(); + } else { + expect(agentTargetsMatch(parse(), fixture.agent)).toBe(fixture.matches); + } + }); + } +}); From 2f920d70d899afb1350f862b6013ccf150870c4c Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 18:50:07 +0530 Subject: [PATCH 4/8] docs(policies): explain agent-scoped Cloud deployments --- docs/policies/deploy.mdx | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/policies/deploy.mdx b/docs/policies/deploy.mdx index 54b243b08..cbbb57b30 100644 --- a/docs/policies/deploy.mdx +++ b/docs/policies/deploy.mdx @@ -70,6 +70,47 @@ A machine appears under **Admin → enforcement** once it is connected to Cloud. +## Target an agent on a machine + +Published policy versions do not contain agent-routing rules. By default, an assignment +applies to **all agents on its machine**. When deploying a version, you can instead +target an integration (including profiles added later) or a particular profile +reported by that machine. The choice belongs to the machine's assignment: the +same published version can target different agents on different machines. + + + + 1. In **Admin → enforcement**, expand the machine and select **edit**. + 2. On the policy's **agents** control, keep **all agents**, select **all hermes profiles** (for example), or search for a reported profile by its label and `agt_` ID. You can select multiple alternatives; a call matches if it belongs to any one of them. + 3. Review the target listed for **each** assignment in the deployment plan, apply the change, then check the machine's reported deployment after its next check-in. History and rollback retain the selected targets. + + + ```bash + fp fleet show + fp fleet deploy --add no-force-push --target no-force-push=hermes + fp fleet deploy --target no-force-push=hermes/agt_1234567890abcdef + fp fleet deploy --all-agents no-force-push + ``` + + `--target POLICY=INTEGRATION[/agt_ID]` sets one policy's scope. Repeat + `--target` for more alternatives on the same assignment. Use `--all-agents POLICY` + to clear that assignment's targeting. `fp fleet show` and `fp fleet history` + display the selected scope. Review the complete replacement plan before + applying, especially when deploying from a script. + + + +The machine must have recently reported support for agent targeting before you +can set a new target. You can select an exact profile only if that machine +reported it with a hook installed and it is not stale. If the inventory is +temporarily unavailable, existing targets remain assigned; they are not +silently broadened to all agents. Reinstall older shell hooks to provide the +profile hint used by new installations. If the running agent's profile cannot +be identified (for example, when several settings scopes are installed but +the hook does not report which one ran), **targeted assignments do not match**; +unscoped policies still apply. Look for `agent_scope_unresolved` under that +machine's policy errors. + ## Deploy Jev checks A Cloud policy can carry Jev checks as well as, or instead of, JavaScript. Its kind is `jev` (Jev checks only) or `both` (JavaScript whose verdict its own checks may clear). It deploys, toggles and rolls back exactly like any other policy. @@ -87,6 +128,12 @@ A `both` policy deployed with `:observe` sends only its JavaScript, which is obs A `both` policy's JavaScript can be cleared only by its own checks' answers from FailproofAI Cloud. An installed pack's check of the same name never clears it. If FailproofAI Cloud cannot be reached, or sets no mode that asks, the policy stays **hard**: every deny of its JavaScript stands. +Agent targeting works for Jev-only policies too. For a `both` policy, the +assignment's one target governs **both** its local JavaScript and its Cloud Jev +checks; a call outside that target runs neither half. Keep the machine's Jev +mode in `observe` while trialing scoped checks, just as you would for an +unscoped Jev rollout. + A machine reports what stops it from asking, and `fp fleet show ` lists it under policy errors: - `transcripts_disabled`: the machine was connected with `--no-transcripts`. Such a machine never sends tool calls to Jev. From 5d8343a0fcb7d503ed408ad348925fd8689e418c Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 20:11:48 +0530 Subject: [PATCH 5/8] refactor(cloud): validate authority targets once per policy --- .../hooks/cloud-managed-policies.test.ts | 14 +++++++++ src/hooks/cloud-managed-policies.ts | 29 +++++++++---------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/__tests__/hooks/cloud-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts index c51390375..a5d2b2af4 100644 --- a/__tests__/hooks/cloud-managed-policies.test.ts +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -179,6 +179,20 @@ describe("agent-scoped assignments", () => { expect(() => readActiveCloudManagedPolicies(hermesWork)).toThrow(/agentTargets/); } }); + + it("a malformed target invalidates the whole manifest, including its reviewer set", () => { + const root = scoped([{ integration: "hermes" }]); + const activePath = join(root, "active.json"); + const active = JSON.parse(readFileSync(activePath, "utf8")); + active.policies.push({ + ...active.policies[0], + id: "malformed", + agentTargets: [{ integration: "hermes", instanceId: "wrong" }], + }); + writeFileSync(activePath, JSON.stringify(active)); + expect(readCloudAuthorityInputs(hermesWork)).toEqual([]); + expect(() => readActiveCloudManagedPolicies(hermesWork)).toThrow(/agentTargets/); + }); }); describe("clearActiveCloudManagedPolicies", () => { diff --git a/src/hooks/cloud-managed-policies.ts b/src/hooks/cloud-managed-policies.ts index 2edb2370b..0de9e2329 100644 --- a/src/hooks/cloud-managed-policies.ts +++ b/src/hooks/cloud-managed-policies.ts @@ -485,22 +485,21 @@ export function readCloudAuthorityInputs(agent: AgentIdentity | null = null): Cl const raw = readActiveJson(); if (raw === undefined) return []; const manifest = parseManifest(raw); - return manifest.policies.flatMap((policy) => - typeof policy.id === "string" && - agentTargetsMatch(parseAgentTargets(policy.agentTargets, manifest.schemaVersion), agent) - ? [ - { - id: policy.id, - effect: policy.effect === "observe" ? "observe" : "enforce", - ...(parseAgentTargets(policy.agentTargets, manifest.schemaVersion) - ? { agentTargets: parseAgentTargets(policy.agentTargets, manifest.schemaVersion) } - : {}), - ...authorityFieldsOf(policy as unknown as Record), - } satisfies CloudAuthorityInput, - ] - : [], - ); + return manifest.policies.flatMap((policy) => { + if (typeof policy.id !== "string") return []; + const targets = parseAgentTargets(policy.agentTargets, manifest.schemaVersion); + if (!agentTargetsMatch(targets, agent)) return []; + return [{ + id: policy.id, + effect: policy.effect === "observe" ? "observe" : "enforce", + ...(targets ? { agentTargets: targets } : {}), + ...authorityFieldsOf(policy as unknown as Record), + } satisfies CloudAuthorityInput]; + }); } catch { + // A malformed selector invalidates the entire active manifest for the JS + // loader too. Do not keep reviewers from a partial set of that manifest: + // Cloud's JS has not loaded, and a subset would claim otherwise. return []; } } From 18b5f1178d54d61bed18315563938c3e6195e0bc Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 20:48:08 +0530 Subject: [PATCH 6/8] fix(ipc): require v2 daemon for scoped agent identity --- CHANGELOG.md | 1 + .../fixtures/hermes-native-plugin-check.py | 6 +-- __tests__/hooks/daemon-client.test.ts | 47 ++++++++++++++----- __tests__/hooks/daemon-probe-race.test.ts | 10 ++-- crates/PROTOCOL.md | 20 +++++--- crates/failproofaid/src/server.rs | 31 ++++++++++++ crates/fpai-ipc/src/envelope.rs | 6 +-- docs/policies/deploy.mdx | 5 +- hermes-plugin/README.md | 10 ++-- hermes-plugin/client.py | 2 +- src/hooks/daemon-client.ts | 8 ++-- 11 files changed, 107 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef933e98..9de41f969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Cloud-managed assignments can target integrations or individual agent profiles. The daemon keeps an owner-only, stable profile roster and reports it to FailproofAI Cloud; scoped schema-3 deployments filter JavaScript before import and send agent identity for Cloud Jev selection. `fp fleet deploy --target POLICY=INTEGRATION[/agt_ID]` narrows a machine assignment; `--all-agents POLICY` clears its scope. +- The CLI, daemon and native Hermes plugin now use local daemon protocol v2 for agent-profile identity. After upgrading the CLI, reinstall/restart the daemon with `failproofai config` before resuming hook evaluation; an older daemon's response is rejected rather than silently losing the selected profile. - Newly installed shell hooks and OpenCode shims identify the user, project or local settings scope that launched them. When a legacy or package-level hook cannot prove its source, a scoped Cloud assignment is withheld and `agent_scope_unresolved` is reported rather than using the daemon worker's own environment as the agent's identity. - Uploads, `fp` calls and evaluator calls carry a request id; the daemon also sends batch and machine ids, and `fp` errors show a `ref` (#872) - **Jev policies deploy from FailproofAI Cloud, individually, and run there.** A Cloud policy has a kind — `regex` (JavaScript, as before), `jev` (Jev checks only) or `both` (JavaScript reviewable by its own checks). Jev checks run on FailproofAI Cloud; nothing is installed on the machine: the daemon receives a `both` policy's JavaScript (with the server-derived `authority`/`reviewedBy`) and the machine's Jev mode, and nothing else Jev-related. `failproofai policies` lists `both` policies with the Cloud checks that review them. diff --git a/__tests__/fixtures/hermes-native-plugin-check.py b/__tests__/fixtures/hermes-native-plugin-check.py index 08678948c..d43fffbea 100644 --- a/__tests__/fixtures/hermes-native-plugin-check.py +++ b/__tests__/fixtures/hermes-native-plugin-check.py @@ -431,7 +431,7 @@ def server() -> None: body = json.dumps( { "type": "policyResult", - "protocolVersion": 1, + "protocolVersion": 2, "decision": "instruct", "policyNames": ["custom/write-route"], "reason": "Use the approved route.", @@ -460,7 +460,7 @@ def server() -> None: self.assertEqual(verdict.decision, "instruct") self.assertEqual(verdict.tool_name, "Write") - def test_client_rejects_protocol_mismatch(self) -> None: + def test_client_rejects_a_v1_daemon_result(self) -> None: with tempfile.TemporaryDirectory() as tmp: socket_path = Path(tmp) / "daemon.sock" ready = threading.Event() @@ -478,7 +478,7 @@ def server() -> None: body = json.dumps( { "type": "policyResult", - "protocolVersion": 99, + "protocolVersion": 1, "decision": "allow", "policyNames": [], "matchedPolicies": [], diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts index 68e227efb..4077f4b29 100644 --- a/__tests__/hooks/daemon-client.test.ts +++ b/__tests__/hooks/daemon-client.test.ts @@ -85,7 +85,7 @@ describe("hooks/daemon-client", () => { await startServer(async (socket) => { const req = await readFrame(socket); expect(req.type).toBe("hook"); - expect(req.protocolVersion).toBe(1); + expect(req.protocolVersion).toBe(2); expect(req.hookEvent).toBe("PreToolUse"); expect(req.cli).toBe("claude"); // An unresolved source is sent explicitly, never re-inferred by the daemon. @@ -93,7 +93,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "hookResult", - protocolVersion: 1, + protocolVersion: 2, exitCode: 0, stdout: "", stderr: "", @@ -116,7 +116,7 @@ describe("hooks/daemon-client", () => { const req = await readFrame(socket); expect(req).toMatchObject({ type: "policyEvaluation", - protocolVersion: 1, + protocolVersion: 2, integration: "hermes", event: "on_session_start", payload: { hook_event_name: "on_session_start" }, @@ -124,7 +124,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "policyResult", - protocolVersion: 1, + protocolVersion: 2, decision: "allow", policyNames: [], reason: null, @@ -151,7 +151,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "hookResult", - protocolVersion: 1, + protocolVersion: 2, exitCode: 0, stdout: "", stderr: "", @@ -175,7 +175,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "hookResult", - protocolVersion: 1, + protocolVersion: 2, exitCode: 2, stdout: "", stderr: "blocked: sudo is not allowed", @@ -190,7 +190,7 @@ describe("hooks/daemon-client", () => { it("returns null when the daemon sends an error-type message", async () => { await startServer(async (socket) => { await readFrame(socket); - socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "daemon unreachable" })); + socket.end(encodeFrame({ type: "error", protocolVersion: 2, message: "daemon unreachable" })); }); const result = await daemonResult({ hookEvent: "Stop", cli: "codex", stdin: "{}" }); @@ -227,13 +227,13 @@ describe("hooks/daemon-client", () => { expect(attempt).toEqual({ ok: false, failure: "protocol-mismatch" }); }); - it("catches a mismatch in BOTH directions", async () => { + it("catches a mismatch when an old daemon responds to a new client", async () => { // Newer CLI against older daemon, and older CLI against newer daemon, both // land here: the daemon stamps its own version on the error it sends back, // so the versions disagree either way. await startServer(async (socket) => { await readFrame(socket); - socket.end(encodeFrame({ type: "error", protocolVersion: 2, message: "protocol version mismatch" })); + socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "protocol version mismatch" })); }); const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); @@ -241,12 +241,35 @@ describe("hooks/daemon-client", () => { expect(attempt).toEqual({ ok: false, failure: "protocol-mismatch" }); }); + it("rejects an allow from a v1 daemon that ignored the profile-scoping field", async () => { + await startServer(async (socket) => { + const request = await readFrame(socket); + expect(request).toMatchObject({ + type: "hook", + protocolVersion: 2, + agentSettingsPath: "/home/agent/claude/settings.json", + }); + // A v1 daemon ignores unknown JSON fields. Its allow is not proof that + // it identified this profile or applied schema-3 targets. + socket.end(encodeFrame({ + type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "", stderr: "", + })); + }); + const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); + await expect(attemptDaemonHook({ + hookEvent: "PreToolUse", + cli: "claude", + stdin: "{}", + agentSettingsPath: "/home/agent/claude/settings.json", + })).resolves.toEqual({ ok: false, failure: "protocol-mismatch" }); + }); + it("an error at a MATCHING protocol version is unreachable, not skew", async () => { // A daemon that answers "worker call failed" at the right version is not a // version problem — it is a broken daemon, and must keep failing closed. await startServer(async (socket) => { await readFrame(socket); - socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "worker call failed" })); + socket.end(encodeFrame({ type: "error", protocolVersion: 2, message: "worker call failed" })); }); const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); @@ -265,7 +288,7 @@ describe("hooks/daemon-client", () => { await startServer(async (socket) => { await readFrame(socket); // Right protocol version, right general shape, but missing exitCode. - socket.end(encodeFrame({ type: "hookResult", protocolVersion: 1, stdout: "", stderr: "" })); + socket.end(encodeFrame({ type: "hookResult", protocolVersion: 2, stdout: "", stderr: "" })); }); const result = await daemonResult({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); @@ -294,7 +317,7 @@ describe("hooks/daemon-client", () => { await readFrame(socket); setTimeout(() => { socket.end( - encodeFrame({ type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "ok", stderr: "" }), + encodeFrame({ type: "hookResult", protocolVersion: 2, exitCode: 0, stdout: "ok", stderr: "" }), ); }, 600); }); diff --git a/__tests__/hooks/daemon-probe-race.test.ts b/__tests__/hooks/daemon-probe-race.test.ts index 8b05a75ff..611bcb2e1 100644 --- a/__tests__/hooks/daemon-probe-race.test.ts +++ b/__tests__/hooks/daemon-probe-race.test.ts @@ -43,7 +43,7 @@ function startDaemon(opts: { answerHooks: boolean; answerPolicyEvaluations?: boo const value = opts.answerPolicyEvaluations ? { type: "policyResult", - protocolVersion: 1, + protocolVersion: 2, decision: "allow", policyNames: [], reason: null, @@ -53,7 +53,7 @@ function startDaemon(opts: { answerHooks: boolean; answerPolicyEvaluations?: boo } : { type: "error", - protocolVersion: 1, + protocolVersion: 2, message: "unknown variant `policyEvaluation`", }; const body = Buffer.from(JSON.stringify(value), "utf-8"); @@ -65,8 +65,8 @@ function startDaemon(opts: { answerHooks: boolean; answerPolicyEvaluations?: boo const body = Buffer.from( JSON.stringify( msg.type === "ping" - ? { type: "pong", protocolVersion: 1 } - : { type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "", stderr: "" }, + ? { type: "pong", protocolVersion: 2 } + : { type: "hookResult", protocolVersion: 2, exitCode: 0, stdout: "", stderr: "" }, ), "utf-8", ); @@ -137,7 +137,7 @@ describe("hooks/daemon-service — health probe startup race", () => { expect(probe).toEqual({ ok: false, reason: "worker" }); }, 40_000); - it("rejects a hook-compatible v1 daemon that lacks policyEvaluation", async () => { + it("rejects a hook-compatible daemon that lacks policyEvaluation", async () => { server = await startDaemon({ answerHooks: true, answerPolicyEvaluations: false }); const { probeDaemonEndToEnd, probeDaemonPolicyEvaluation } = await import( "../../src/hooks/daemon-service" diff --git a/crates/PROTOCOL.md b/crates/PROTOCOL.md index f7de13373..6ac9fedbf 100644 --- a/crates/PROTOCOL.md +++ b/crates/PROTOCOL.md @@ -52,16 +52,17 @@ Tagged JSON, `"type"` as the discriminant, camelCase field names. ```jsonc // Liveness/handshake check. -{ "type": "ping", "protocolVersion": 1 } +{ "type": "ping", "protocolVersion": 2 } // One hook evaluation request — one per `failproofai --hook --cli ` invocation. { "type": "hook", - "protocolVersion": 1, + "protocolVersion": 2, "hookEvent": "PreToolUse", "cli": "claude", "stdin": "", - "cwd": "/path/to/session/cwd" // optional; see the note below + "cwd": "/path/to/session/cwd", // optional; see the note below + "agentSettingsPath": "/path/to/the/agent/settings" // optional; empty means unresolved } ``` @@ -71,14 +72,21 @@ long-lived process; its own `cwd` does not vary per request and must never be used to resolve project config or custom policies (this is the "process.cwd() hazard" the TS-side plan calls out explicitly). +Protocol v2 carries the originating agent's settings path for both `hook` and +native `policyEvaluation` requests. A missing path is unresolved, never +inferred from the long-lived daemon's environment. A v1 daemon cannot be +trusted with a v2 scoped request: it may discard this field and evaluate for +the wrong profile. The v2 client rejects every v1 response and tells the +operator to reinstall/restart the daemon before evaluation resumes. + ### Daemon → client (`ServerMessage`) ```jsonc -{ "type": "pong", "protocolVersion": 1 } +{ "type": "pong", "protocolVersion": 2 } { "type": "hookResult", - "protocolVersion": 1, + "protocolVersion": 2, "exitCode": 0, "stdout": "...", "stderr": "..." @@ -89,7 +97,7 @@ hazard" the TS-side plan calls out explicitly). // hookResult so the client can // tell "ran and decided" apart from "daemon couldn't evaluate at all" — the // latter is what drives the client's fail-closed path. -{ "type": "error", "protocolVersion": 1, "message": "..." } +{ "type": "error", "protocolVersion": 2, "message": "..." } ``` ## Protocol versioning diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs index 63d48a790..2224f44f5 100644 --- a/crates/failproofaid/src/server.rs +++ b/crates/failproofaid/src/server.rs @@ -774,6 +774,37 @@ mod tests { } } + #[test] + fn old_hook_protocol_cannot_evaluate_a_scoped_agent() { + let socket_path = temp_socket_path("old-scoped-hook"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: 1, + hook_event: "PreToolUse".into(), + cli: "claude".into(), + stdin: "{}".into(), + cwd: None, + agent_settings_path: Some("/home/agent/claude/settings.json".into()), + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { + protocol_version, + message, + } => { + assert_eq!(protocol_version, PROTOCOL_VERSION); + assert!(message.contains("protocol version mismatch")); + } + other => panic!("old protocol was evaluated: {other:?}"), + } + } + #[test] fn bind_replaces_a_stale_socket_file() { let socket_path = temp_socket_path("stale"); diff --git a/crates/fpai-ipc/src/envelope.rs b/crates/fpai-ipc/src/envelope.rs index 20d93765a..83ab91e39 100644 --- a/crates/fpai-ipc/src/envelope.rs +++ b/crates/fpai-ipc/src/envelope.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; /// enum below. A daemon-configured client fails closed on a mismatch and uses /// the distinct failure category only to explain how to repair the skew. /// There is no protocol negotiation. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "camelCase")] @@ -158,7 +158,7 @@ mod tests { fn hook_request_cwd_is_optional() { let json = serde_json::json!({ "type": "hook", - "protocolVersion": 1, + "protocolVersion": PROTOCOL_VERSION, "hookEvent": "Stop", "cli": "codex", "stdin": "{}" @@ -189,7 +189,7 @@ mod tests { #[test] fn unknown_message_type_fails_to_deserialize() { - let json = serde_json::json!({ "type": "bogus", "protocolVersion": 1 }); + let json = serde_json::json!({ "type": "bogus", "protocolVersion": PROTOCOL_VERSION }); let result: Result = serde_json::from_value(json); assert!(result.is_err()); } diff --git a/docs/policies/deploy.mdx b/docs/policies/deploy.mdx index cbbb57b30..27718664e 100644 --- a/docs/policies/deploy.mdx +++ b/docs/policies/deploy.mdx @@ -105,7 +105,10 @@ can set a new target. You can select an exact profile only if that machine reported it with a hook installed and it is not stale. If the inventory is temporarily unavailable, existing targets remain assigned; they are not silently broadened to all agents. Reinstall older shell hooks to provide the -profile hint used by new installations. If the running agent's profile cannot +profile hint used by new installations. After upgrading the CLI, run +`failproofai config` to update and restart its daemon too: an older daemon +cannot evaluate new profile-scoped calls and the hook refuses the mismatch. +If the running agent's profile cannot be identified (for example, when several settings scopes are installed but the hook does not report which one ran), **targeted assignments do not match**; unscoped policies still apply. Look for `agent_scope_unresolved` under that diff --git a/hermes-plugin/README.md b/hermes-plugin/README.md index 05388a67d..41cba9657 100644 --- a/hermes-plugin/README.md +++ b/hermes-plugin/README.md @@ -94,23 +94,25 @@ decision, because otherwise corrupted retry state could block a turn forever. ## Local protocol Requests and responses use the existing length-prefixed failproofaid Unix -socket protocol, version 1. +socket protocol, version 2. A v1 daemon must be upgraded before it can safely +evaluate agent/profile-scoped policies. ```json { "type": "policyEvaluation", - "protocolVersion": 1, + "protocolVersion": 2, "integration": "hermes", "event": "pre_tool_call", "payload": {}, - "cwd": "/workspace/project" + "cwd": "/workspace/project", + "agentSettingsPath": "/path/to/the/hermes/profile" } ``` ```json { "type": "policyResult", - "protocolVersion": 1, + "protocolVersion": 2, "decision": "instruct", "policyNames": ["custom/approved-write-route"], "reason": "Use the approved write route.", diff --git a/hermes-plugin/client.py b/hermes-plugin/client.py index b89f22be3..c71341b90 100644 --- a/hermes-plugin/client.py +++ b/hermes-plugin/client.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any, Mapping, Sequence -PROTOCOL_VERSION = 1 +PROTOCOL_VERSION = 2 MAX_FRAME_BYTES = 16 * 1024 * 1024 MAX_REQUEST_BYTES = 1024 * 1024 diff --git a/src/hooks/daemon-client.ts b/src/hooks/daemon-client.ts index 0df815017..4387d5252 100644 --- a/src/hooks/daemon-client.ts +++ b/src/hooks/daemon-client.ts @@ -16,7 +16,7 @@ import { existsSync } from "node:fs"; import { daemonSocket as daemonSocketPath } from "./fp-home"; import { readConfig } from "./fp-config"; -const PROTOCOL_VERSION = 1; +const PROTOCOL_VERSION = 2; /** * Reaching the daemon and getting an answer out of it are two different @@ -342,9 +342,9 @@ function isStringArray(value: unknown): value is string[] { /** * Attempts the structured request used by native in-process integrations. - * A matching protocol version is not sufficient: pre-native v1 daemons speak - * `hook` but cannot deserialize `policyEvaluation`, so only a complete, - * well-shaped `policyResult` proves this capability exists. + * A matching protocol version is not sufficient: a broken or partial daemon + * may speak `hook` but not `policyEvaluation`, so only a complete, well-shaped + * `policyResult` proves this capability exists. */ export async function attemptDaemonPolicyEvaluation( req: DaemonPolicyEvaluationRequest, From 8c22f318f6fa142c3cdab9d97787d678c5fafce3 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Fri, 2 Oct 2026 21:17:07 +0530 Subject: [PATCH 7/8] fix(roster): preserve existing profile IDs at capacity --- crates/failproofaid/src/agent_roster.rs | 201 ++++++++++++++++++------ 1 file changed, 153 insertions(+), 48 deletions(-) diff --git a/crates/failproofaid/src/agent_roster.rs b/crates/failproofaid/src/agent_roster.rs index 5cdb9cd08..0c65f1053 100644 --- a/crates/failproofaid/src/agent_roster.rs +++ b/crates/failproofaid/src/agent_roster.rs @@ -2,7 +2,7 @@ //! user's machine. Paths remain local; only opaque IDs and bounded labels //! are sent to Cloud. No process-list guesses or telemetry project IDs. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -162,7 +162,6 @@ fn profiles_at(home: &Path) -> Vec { } } out.sort_by(|a, b| (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path))); - out.truncate(64); out } @@ -232,61 +231,95 @@ fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { .as_ref() .map(|r| r.agents.as_slice()) .unwrap_or_default(); - let mut by_path = HashMap::new(); - let mut by_inode = HashMap::new(); - for entry in previous { - by_path.insert((&entry.integration, &entry.settings_path), entry); - if let Some(fingerprint) = &entry.fingerprint { - by_inode.insert((&entry.integration, fingerprint), entry); - } - } - let mut agents = profiles_at(home); + // Match against the bounded discovery walk before imposing the 64-row + // upload cap. Truncating discovered profiles first and filling from them + // evicted an old exact-profile ID whenever an earlier-sorting config + // appeared, even while that old profile's hook was still active. + let mut discovered: Vec> = + profiles_at(home).into_iter().map(Some).collect(); + let mut agents = Vec::with_capacity(64); let mut active_ids = HashSet::new(); - for entry in &mut agents { - let old = by_path - .get(&(&entry.integration, &entry.settings_path)) - .copied() + let exact_paths: HashSet<(&str, &str)> = previous + .iter() + .map(|entry| (entry.integration.as_str(), entry.settings_path.as_str())) + .collect(); + let now = current_millis().ok(); + for old in previous { + // Exact path wins over a matching directory inode; a copied or + // hard-linked config must not steal another profile's stable ID. + let match_at = discovered + .iter() + .position(|item| { + item.as_ref().is_some_and(|candidate| { + candidate.integration == old.integration + && candidate.settings_path == old.settings_path + }) + }) .or_else(|| { - entry - .fingerprint - .as_ref() - .and_then(|fp| by_inode.get(&(&entry.integration, fp)).copied()) + old.fingerprint.as_ref().and_then(|fingerprint| { + discovered.iter().position(|item| { + item.as_ref().is_some_and(|candidate| { + candidate.integration == old.integration + && candidate.fingerprint.as_ref() == Some(fingerprint) + && !exact_paths.contains(&( + candidate.integration.as_str(), + candidate.settings_path.as_str(), + )) + }) + }) + }) }); - entry.instance_id = match old { - Some(old) if active_ids.insert(old.instance_id.clone()) => old.instance_id.clone(), - _ => { - let id = new_instance_id()?; - active_ids.insert(id.clone()); - id - } + let recent = old.hook_installed + && old.last_seen_at.is_some_and(|at| { + now.is_some_and(|now| now.saturating_sub(at) < RECENT_SIGHTING_MS) + }); + let mut entry = if let Some(index) = match_at { + let mut candidate = discovered[index] + .take() + .expect("matched profile is present"); + candidate.last_seen_at = old.last_seen_at; + candidate.hook_installed |= recent; + candidate + } else { + // A project profile is learned from hook-time sightings, not by + // traversing every project. Keep its identity even when stale: + // an existing deployment may still name this exact ID. + let mut stale = old.clone(); + stale.hook_installed = recent + && Path::new(&old.settings_path) + .parent() + .is_some_and(Path::exists); + stale }; - entry.last_seen_at = old.and_then(|old| old.last_seen_at); - entry.hook_installed |= old.is_some_and(|old| { - old.hook_installed - && old.last_seen_at.is_some_and(|at| { - current_millis().is_ok_and(|now| now.saturating_sub(at) < RECENT_SIGHTING_MS) - }) - }); + if active_ids.insert(old.instance_id.clone()) { + entry.instance_id = old.instance_id.clone(); + } else { + // Do not keep duplicate IDs in a corrupt roster. + loop { + let replacement = new_instance_id()?; + if active_ids.insert(replacement.clone()) { + entry.instance_id = replacement; + break; + } + } + } + agents.push(entry); } - for old in previous { + // New discoveries take ONLY free slots. At capacity a new profile is + // unresolved until explicitly retired capacity exists; it may never + // silently evict a profile an existing Cloud assignment still targets. + for mut entry in discovered.into_iter().flatten() { if agents.len() >= 64 { break; } - if active_ids.insert(old.instance_id.clone()) { - // Keep old names on the operator roster for historical - // assignments. A project profile is found on hook-time sighting, - // not by scanning arbitrary project directories; keep it hooked - // while recently active, and otherwise mark it stale. - let mut stale = old.clone(); - stale.hook_installed = old.hook_installed - && old.last_seen_at.is_some_and(|at| { - current_millis().is_ok_and(|now| now.saturating_sub(at) < RECENT_SIGHTING_MS) - }) - && Path::new(&old.settings_path) - .parent() - .is_some_and(Path::exists); - agents.push(stale); + loop { + let id = new_instance_id()?; + if active_ids.insert(id.clone()) { + entry.instance_id = id; + break; + } } + agents.push(entry); } agents.sort_by(|a, b| { (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path)) @@ -449,4 +482,76 @@ mod tests { ); fs::remove_dir_all(root).unwrap(); } + + #[test] + fn full_roster_preserves_an_exact_target_when_an_earlier_profile_appears() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + let now = current_millis().unwrap(); + let mut prior = Vec::new(); + for index in 0..64u32 { + let settings = root.join(format!("projects/p{index:02}/.codex/hooks.json")); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + prior.push(AgentProfile { + instance_id: format!("agt_{index:032x}"), + integration: "codex".into(), + settings_path: settings.to_string_lossy().into_owned(), + profile_label: format!("p{index:02}"), + scope: "project".into(), + hook_installed: true, + last_seen_at: Some(now - 61_000), + fingerprint: None, + }); + } + let assigned = prior[63].clone(); + write( + &path, + &AgentRoster { + schema_version: 1, + generation: 1, + agents: prior, + }, + ) + .unwrap(); + + let claude = home.join(".claude/settings.json"); + fs::create_dir_all(claude.parent().unwrap()).unwrap(); + fs::write(claude, "failproofai").unwrap(); + let refreshed = refresh(&path, &home).unwrap(); + assert_eq!(refreshed.agents.len(), 64); + assert_eq!(refreshed.generation, 1); + assert!( + refreshed + .agents + .iter() + .all(|entry| entry.integration == "codex"), + "a new earlier-sorting integration must wait for free capacity" + ); + assert_eq!( + refreshed + .agents + .iter() + .find(|entry| entry.settings_path == assigned.settings_path) + .unwrap() + .instance_id, + assigned.instance_id, + ); + + // The retained profile must still be found and refreshed at hook + // time; the old implementation dropped it and refused the sighting + // because the new discovery had filled the 64th slot. + record_sighting(&path, &home, "codex", Path::new(&assigned.settings_path)).unwrap(); + let after_hook = read(&path).unwrap().unwrap(); + let target = after_hook + .agents + .iter() + .find(|entry| entry.settings_path == assigned.settings_path) + .unwrap(); + assert_eq!(target.instance_id, assigned.instance_id); + assert!(target.hook_installed); + assert!(target.last_seen_at.unwrap() >= now); + fs::remove_dir_all(root).unwrap(); + } } From 1924794829e2a2b1e3afc2e99f5605daddab774f Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Mon, 5 Oct 2026 12:16:53 +0530 Subject: [PATCH 8/8] fix(roster): reclaim unassigned stale profiles at capacity, cap 256 A full roster (64 entries) never issued an ID to a newly installed profile, so integration-wide Cloud assignments silently skipped it. The roster upload's response now carries protectedInstanceIds: every exact profile ID any assignment uses (Jev-only ones included) and every profile Cloud would accept as a new target. While a hooked profile waits for a slot, the maintenance tick forces a report and, in the same tick, drops an entry that snapshot marked unhooked and the list did not name (no longer found on disk first, then longest unused). Since every ID Cloud could newly target is in the list, no deploy or rollback can name a dropped ID, even if Cloud's stored roster differs from ours. Unhooked configs only take free slots (no evict/re-admit churn). A hook sighting at capacity waits in memory, attached to its config, until admitted, and is restored if the roster write fails. When asking Cloud frees nothing (or Cloud predates the list), forced reports stop until the roster changes. Paths with control characters or over 4096 bytes are never admitted, so 256 worst-case entries stay well under the 4 MB bound the daemon and the hook reader share. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/hooks/agent-roster.test.ts | 31 +- crates/failproofaid/src/agent_roster.rs | 627 ++++++++++++++++++++++-- crates/failproofaid/src/cloud_client.rs | 357 +++++++++++++- docs/policies/deploy.mdx | 14 + src/hooks/agent-roster.ts | 8 +- 6 files changed, 973 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de41f969..abcd013f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features - Cloud-managed assignments can target integrations or individual agent profiles. The daemon keeps an owner-only, stable profile roster and reports it to FailproofAI Cloud; scoped schema-3 deployments filter JavaScript before import and send agent identity for Cloud Jev selection. `fp fleet deploy --target POLICY=INTEGRATION[/agt_ID]` narrows a machine assignment; `--all-agents POLICY` clears its scope. +- A machine's agent roster holds up to 256 profiles (previously 64). When it is full, a newly hooked profile takes the slot of an unhooked profile that FailproofAI Cloud confirms no assignment names and it would not accept as a target. A profile an exact target uses keeps its ID, Jev-only assignments count, and a hook sighting waits in memory until it gets a slot. (#873) - The CLI, daemon and native Hermes plugin now use local daemon protocol v2 for agent-profile identity. After upgrading the CLI, reinstall/restart the daemon with `failproofai config` before resuming hook evaluation; an older daemon's response is rejected rather than silently losing the selected profile. - Newly installed shell hooks and OpenCode shims identify the user, project or local settings scope that launched them. When a legacy or package-level hook cannot prove its source, a scoped Cloud assignment is withheld and `agent_scope_unresolved` is reported rather than using the daemon worker's own environment as the agent's identity. - Uploads, `fp` calls and evaluator calls carry a request id; the daemon also sends batch and machine ids, and `fp` errors show a `ref` (#872) diff --git a/__tests__/hooks/agent-roster.test.ts b/__tests__/hooks/agent-roster.test.ts index d2db7ec7a..bd05d8155 100644 --- a/__tests__/hooks/agent-roster.test.ts +++ b/__tests__/hooks/agent-roster.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { readRuntimeAgentIdentity, runtimeAgentSettingsPath } from "../../src/hooks/agent-roster"; +import { MAX_ROSTER_AGENTS, readRuntimeAgentIdentity, runtimeAgentSettingsPath } from "../../src/hooks/agent-roster"; import { agentTargetsMatch, parseAgentTargets } from "../../src/hooks/agent-targets"; const roots: string[] = []; @@ -95,6 +95,35 @@ describe("runtime agent identity", () => { expect(agentTargetsMatch(exact, null)).toBe(false); }); + it("resolves a profile admitted to a full roster, and only up to the daemon's ceiling", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-agent-roster-")); + roots.push(root); + process.env.FAILPROOFAI_HOME = root; + const rosterPath = join(root, "agents", "roster.json"); + mkdirSync(join(root, "agents")); + const settings = (index: number) => join(root, "projects", `p${index}`, ".codex", "hooks.json"); + const agents = Array.from({ length: MAX_ROSTER_AGENTS }, (_, index) => ({ + integration: "codex", instanceId: `agt_${index.toString(16).padStart(32, "0")}`, + settingsPath: settings(index), profileLabel: `p${index}`, scope: "project", hookInstalled: index > 0, + })); + const write = (list: unknown[]) => { + writeFileSync(rosterPath, JSON.stringify({ schemaVersion: 1, generation: 9, agents: list })); + chmodSync(rosterPath, 0o600); + }; + // The last slot went to a newly installed profile after a reclaim. + write(agents); + const latest = settings(MAX_ROSTER_AGENTS - 1); + const identity = readRuntimeAgentIdentity("codex", latest); + expect(identity).toEqual({ integration: "codex", instanceId: agents[MAX_ROSTER_AGENTS - 1].instanceId }); + expect(agentTargetsMatch(parseAgentTargets([{ integration: "codex" }], 3), identity)).toBe(true); + const exact = parseAgentTargets([{ integration: "codex", instanceId: agents[1].instanceId }], 3); + expect(agentTargetsMatch(exact, readRuntimeAgentIdentity("codex", settings(1)))).toBe(true); + expect(agentTargetsMatch(exact, identity)).toBe(false); + // Past the daemon's ceiling the file is not one the daemon wrote. + write([...agents, { ...agents[0], instanceId: "agt_ffffffffffffffff", settingsPath: settings(MAX_ROSTER_AGENTS) }]); + expect(readRuntimeAgentIdentity("codex", latest)).toBeNull(); + }); + it("withholds a scoped identity if the roster is unreadable or not owner-only", () => { const root = mkdtempSync(join(tmpdir(), "fpai-agent-roster-")); roots.push(root); diff --git a/crates/failproofaid/src/agent_roster.rs b/crates/failproofaid/src/agent_roster.rs index 0c65f1053..a681fed02 100644 --- a/crates/failproofaid/src/agent_roster.rs +++ b/crates/failproofaid/src/agent_roster.rs @@ -2,7 +2,7 @@ //! user's machine. Paths remain local; only opaque IDs and bounded labels //! are sent to Cloud. No process-list guesses or telemetry project IDs. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -13,6 +13,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; static ROSTER_WRITE: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Per roster file, in memory only: hook sightings that arrived while the +/// roster was full, and whether a hooked profile is still waiting for an ID. +/// Always taken after `ROSTER_WRITE`, never before it. +static CAPACITY: LazyLock>> = LazyLock::new(Default::default); const INTEGRATIONS: &[&str] = &[ "claude", "codex", @@ -28,6 +32,85 @@ const INTEGRATIONS: &[&str] = &[ "goose", ]; const RECENT_SIGHTING_MS: i64 = 30 * 24 * 60 * 60 * 1000; +/// Profiles one roster holds. FailproofAI Cloud (`MAX_MACHINE_AGENTS`) and the +/// hook reader (`src/hooks/agent-roster.ts`) refuse a larger roster. +pub const MAX_AGENTS: usize = 256; +/// The hook reader's file-size bound. A roster is never written past it, or +/// every hook would lose its identity at once. `profile` admits only paths of +/// at most `MAX_SETTINGS_PATH_BYTES` with no control characters, so even 256 +/// worst-case entries (every byte JSON-escaped) stay under half of it. +const MAX_ROSTER_BYTES: usize = 4_000_000; +const MAX_SETTINGS_PATH_BYTES: usize = 4096; +const MAX_PENDING_SIGHTINGS: usize = 32; + +#[derive(Default)] +struct Capacity { + pending: VecDeque, + wanted: bool, + /// The roster generation at which asking Cloud freed nothing. Forced + /// capacity reports stop until the roster changes; the heartbeat report + /// still retries the reclaim. + stalled: Option, +} + +#[derive(Clone)] +struct Sighting { + integration: String, + settings_path: PathBuf, + at: i64, +} + +/// Roster IDs FailproofAI Cloud can no longer newly target: unhooked in the +/// snapshot it just acknowledged, and named by none of this machine's +/// assignments of any kind. Cloud accepts a new exact-profile target (deploy +/// or rollback) only for a profile it holds as hooked, and only a report +/// changes what it holds, so these stay unreferenced until the next report. +/// Built only from that report's response and spent in the same maintenance +/// tick, before anything else is reported. +pub struct Reclaimable(HashSet); + +impl Reclaimable { + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn from_report(reported: &AgentRoster, protected: &HashSet) -> Self { + Self( + reported + .agents + .iter() + .filter(|agent| !agent.hook_installed && !protected.contains(&agent.instance_id)) + .map(|agent| agent.instance_id.clone()) + .collect(), + ) + } +} + +/// A hooked profile is waiting for a slot: every report answered for this +/// snapshot is followed by a reclaim in the same tick. +pub fn needs_capacity(path: &Path) -> bool { + CAPACITY + .lock() + .is_ok_and(|state| state.get(path).is_some_and(|capacity| capacity.wanted)) +} + +/// Whether to report sooner than the heartbeat to ask Cloud for capacity: a +/// profile is waiting and asking at this roster generation has not yet failed. +pub fn should_force_capacity_report(path: &Path, generation: u64) -> bool { + CAPACITY.lock().is_ok_and(|state| { + state + .get(path) + .is_some_and(|capacity| capacity.wanted && capacity.stalled != Some(generation)) + }) +} + +/// Asking Cloud freed nothing at `generation`. +pub fn capacity_stalled(path: &Path, generation: u64) { + if let Ok(mut state) = CAPACITY.lock() { + state.entry(path.to_path_buf()).or_default().stalled = Some(generation); + } +} #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -72,6 +155,10 @@ fn safe_profile_label(label: &str) -> String { } fn profile(integration: &str, label: &str, settings: PathBuf) -> Option { + let settings_path = settings.to_string_lossy().into_owned(); + if settings_path.len() > MAX_SETTINGS_PATH_BYTES || settings_path.contains(char::is_control) { + return None; + } let folder = settings.parent()?; if !folder.exists() { return None; @@ -92,7 +179,7 @@ fn profile(integration: &str, label: &str, settings: PathBuf) -> Option Vec { out } +fn unused_instance_id(active: &mut HashSet) -> io::Result { + loop { + let id = new_instance_id()?; + if active.insert(id.clone()) { + return Ok(id); + } + } +} + fn new_instance_id() -> io::Result { let mut bytes = [0u8; 16]; fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?; @@ -177,9 +273,18 @@ fn new_instance_id() -> io::Result { pub fn read(path: &Path) -> io::Result> { match fs::read(path) { Ok(bytes) => { + if bytes.len() > MAX_ROSTER_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "agent roster is too large", + )); + } let roster: AgentRoster = serde_json::from_slice(&bytes) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - if roster.schema_version != 1 || roster.generation == 0 || roster.agents.len() > 64 { + if roster.schema_version != 1 + || roster.generation == 0 + || roster.agents.len() > MAX_AGENTS + { return Err(io::Error::new( io::ErrorKind::InvalidData, "unsupported agent roster", @@ -199,6 +304,11 @@ fn write(path: &Path, roster: &AgentRoster) -> io::Result<()> { fs::create_dir_all(parent)?; fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; let bytes = serde_json::to_vec(roster).map_err(io::Error::other)?; + if bytes.len() > MAX_ROSTER_BYTES || roster.agents.len() > MAX_AGENTS { + return Err(io::Error::other( + "agent roster would exceed the hook reader's bounds", + )); + } let tmp = parent.join(format!(".roster-{}.tmp", new_instance_id()?)); let result = (|| { let mut file = OpenOptions::new() @@ -222,10 +332,23 @@ pub fn refresh(path: &Path, home: &Path) -> io::Result { let _guard = ROSTER_WRITE .lock() .map_err(|_| io::Error::other("agent roster lock poisoned"))?; - refresh_unlocked(path, home) + refresh_unlocked(path, home, None) +} + +/// `refresh`, but a hooked profile waiting for a slot may take one from the +/// oldest entry `reclaimable` names that is still unhooked. +pub fn reclaim(path: &Path, home: &Path, reclaimable: &Reclaimable) -> io::Result { + let _guard = ROSTER_WRITE + .lock() + .map_err(|_| io::Error::other("agent roster lock poisoned"))?; + refresh_unlocked(path, home, Some(reclaimable)) } -fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { +fn refresh_unlocked( + path: &Path, + home: &Path, + reclaimable: Option<&Reclaimable>, +) -> io::Result { let before = read(path)?; let previous = before .as_ref() @@ -237,8 +360,10 @@ fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { // appeared, even while that old profile's hook was still active. let mut discovered: Vec> = profiles_at(home).into_iter().map(Some).collect(); - let mut agents = Vec::with_capacity(64); + let user_paths = user_profile_paths(discovered.iter().flatten()); + let mut agents = Vec::with_capacity(MAX_AGENTS); let mut active_ids = HashSet::new(); + let mut matched_ids = HashSet::new(); let exact_paths: HashSet<(&str, &str)> = previous .iter() .map(|entry| (entry.integration.as_str(), entry.settings_path.as_str())) @@ -295,32 +420,111 @@ fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { entry.instance_id = old.instance_id.clone(); } else { // Do not keep duplicate IDs in a corrupt roster. - loop { - let replacement = new_instance_id()?; - if active_ids.insert(replacement.clone()) { - entry.instance_id = replacement; - break; - } - } + entry.instance_id = unused_instance_id(&mut active_ids)?; + } + if match_at.is_some() { + matched_ids.insert(entry.instance_id.clone()); } agents.push(entry); } - // New discoveries take ONLY free slots. At capacity a new profile is - // unresolved until explicitly retired capacity exists; it may never - // silently evict a profile an existing Cloud assignment still targets. - for mut entry in discovered.into_iter().flatten() { - if agents.len() >= 64 { - break; + + // Declared before the lock so it drops after it: on ANY early return + // from here on, the waiting sightings are put back and nothing is lost. + let mut restore = RestoreWaiting { path, saved: None }; + let mut capacity = CAPACITY + .lock() + .map_err(|_| io::Error::other("agent roster capacity lock poisoned"))?; + let state = capacity.entry(path.to_path_buf()).or_default(); + restore.saved = Some((state.pending.clone(), state.wanted)); + // Hooked newcomers: sightings that arrived while the roster was full, + // then configs carrying our hook. A sighting stays attached to its + // candidate until it is admitted: the config alone may not show our hook + // (one installed by a plugin, say), and it would then never ask again. + let mut hooked = Vec::new(); + for sighting in std::mem::take(&mut state.pending) { + let settings = sighting.settings_path.to_string_lossy(); + let same = |entry: &AgentProfile| { + entry.integration == sighting.integration && entry.settings_path == settings + }; + if let Some(entry) = agents.iter_mut().find(|entry| same(entry)) { + entry.hook_installed = true; + entry.last_seen_at = entry.last_seen_at.max(Some(sighting.at)); + continue; } - loop { - let id = new_instance_id()?; - if active_ids.insert(id.clone()) { - entry.instance_id = id; - break; - } + let candidate = match discovered + .iter_mut() + .find(|slot| slot.as_ref().is_some_and(&same)) + { + Some(slot) => slot.take().map(|mut entry| { + entry.hook_installed = true; + entry.last_seen_at = entry.last_seen_at.max(Some(sighting.at)); + entry + }), + None => sighted_profile( + &user_paths, + &sighting.integration, + &sighting.settings_path, + sighting.at, + ), + }; + if let Some(entry) = candidate { + hooked.push((Some(sighting), entry)); + } + } + let (found, unhooked): (Vec<_>, Vec<_>) = discovered + .into_iter() + .flatten() + .partition(|candidate| candidate.hook_installed); + hooked.extend(found.into_iter().map(|candidate| (None, candidate))); + + // Existing IDs are never evicted to make room, except one Cloud has just + // confirmed no assignment names and cannot newly target (`Reclaimable`), + // whose profile is still unhooked here. Not-rediscovered entries go first, + // then the longest unseen. Only a HOOKED newcomer may claim such a slot: + // an unhooked config would evict, be re-admitted and evict again. + let shortfall = hooked + .len() + .saturating_sub(MAX_AGENTS.saturating_sub(agents.len())); + if shortfall > 0 + && let Some(Reclaimable(reclaimable)) = reclaimable + { + let mut victims: Vec<(bool, i64, &str)> = agents + .iter() + .filter(|entry| !entry.hook_installed && reclaimable.contains(&entry.instance_id)) + .map(|entry| { + ( + matched_ids.contains(&entry.instance_id), + entry.last_seen_at.unwrap_or(i64::MIN), + entry.instance_id.as_str(), + ) + }) + .collect(); + victims.sort_unstable(); + let evicted: HashSet = victims + .into_iter() + .take(shortfall) + .map(|(_, _, id)| id.to_owned()) + .collect(); + agents.retain(|entry| !evicted.contains(&entry.instance_id)); + } + state.wanted = false; + for (sighting, mut entry) in hooked { + if agents.len() >= MAX_AGENTS { + state.wanted = true; + state.pending.extend(sighting); + continue; + } + entry.instance_id = unused_instance_id(&mut active_ids)?; + agents.push(entry); + } + for mut entry in unhooked { + if agents.len() >= MAX_AGENTS { + break; } + entry.instance_id = unused_instance_id(&mut active_ids)?; agents.push(entry); } + drop(capacity); agents.sort_by(|a, b| { (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path)) }); @@ -335,9 +539,27 @@ fn refresh_unlocked(path: &Path, home: &Path) -> io::Result { if changed { write(path, &roster)?; } + restore.saved = None; Ok(roster) } +/// Puts a refresh's waiting sightings back unless it completed. +struct RestoreWaiting<'a> { + path: &'a Path, + saved: Option<(VecDeque, bool)>, +} + +impl Drop for RestoreWaiting<'_> { + fn drop(&mut self) { + if let Some((pending, wanted)) = self.saved.take() + && let Ok(mut capacity) = CAPACITY.lock() + { + let state = capacity.entry(self.path.to_path_buf()).or_default(); + (state.pending, state.wanted) = (pending, wanted); + } + } +} + fn current_millis() -> io::Result { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -345,6 +567,41 @@ fn current_millis() -> io::Result { i64::try_from(duration.as_millis()).map_err(io::Error::other) } +fn user_profile_paths<'a>( + profiles: impl Iterator, +) -> HashSet<(String, String)> { + profiles + .map(|entry| (entry.integration.clone(), entry.settings_path.clone())) + .collect() +} + +/// The entry a hook sighting creates for a config the roster does not list. +/// `user_paths` is the user-home discovery (`profiles_at`). +fn sighted_profile( + user_paths: &HashSet<(String, String)>, + integration: &str, + settings_path: &Path, + at: i64, +) -> Option { + let label = settings_path + .parent() + .and_then(Path::file_name) + .map(|name| safe_profile_label(&name.to_string_lossy())) + .unwrap_or_else(|| "active".into()); + let mut entry = profile(integration, &label, settings_path.to_path_buf())?; + entry.hook_installed = true; + entry.last_seen_at = Some(at); + entry.scope = if matches!(integration, "hermes" | "openclaw") + || user_paths.contains(&(integration.to_owned(), entry.settings_path.clone())) + { + "user" + } else { + "project" + } + .into(); + Some(entry) +} + /// A hook that actually reached this daemon is stronger evidence than a /// settings file that merely mentions us. Only bounded, explicit config paths /// enter the local roster. No project/transcript identifier is substituted. @@ -368,7 +625,7 @@ pub fn record_sighting( .map_err(|_| io::Error::other("agent roster lock poisoned"))?; let mut roster = match read(path)? { Some(roster) => roster, - None => refresh_unlocked(path, home)?, + None => refresh_unlocked(path, home, None)?, }; let settings = settings_path.to_string_lossy(); let now = current_millis()?; @@ -386,33 +643,38 @@ pub fn record_sighting( } entry.hook_installed = true; entry.last_seen_at = Some(now); - } else if roster.agents.len() < 64 { - let label = settings_path - .parent() - .and_then(Path::file_name) - .map(|name| safe_profile_label(&name.to_string_lossy())) - .unwrap_or_else(|| "active".into()); - let Some(mut entry) = profile(integration, &label, settings_path.to_path_buf()) else { + } else if roster.agents.len() < MAX_AGENTS { + let user_paths = user_profile_paths(profiles_at(home).iter()); + let Some(mut entry) = sighted_profile(&user_paths, integration, settings_path, now) else { return Ok(()); }; entry.instance_id = new_instance_id()?; - entry.hook_installed = true; - entry.last_seen_at = Some(now); - entry.scope = if matches!(integration, "hermes" | "openclaw") - || profiles_at(home) - .iter() - .any(|known| known.integration == integration && known.settings_path == settings) - { - "user" - } else { - "project" - } - .into(); roster.agents.push(entry); roster.agents.sort_by(|a, b| { (&a.integration, &a.settings_path).cmp(&(&b.integration, &b.settings_path)) }); } else { + // Full: the maintenance tick admits it after Cloud confirms which IDs + // no assignment can still need (`reclaim`). Nothing is evicted here. + let mut capacity = CAPACITY + .lock() + .map_err(|_| io::Error::other("agent roster capacity lock poisoned"))?; + let state = capacity.entry(path.to_path_buf()).or_default(); + if !state + .pending + .iter() + .any(|seen| seen.integration == integration && seen.settings_path == settings_path) + { + if state.pending.len() >= MAX_PENDING_SIGHTINGS { + state.pending.pop_front(); + } + state.pending.push_back(Sighting { + integration: integration.into(), + settings_path: settings_path.to_path_buf(), + at: now, + }); + } + state.wanted = true; return Ok(()); } roster.generation = roster.generation.saturating_add(1); @@ -491,21 +753,21 @@ mod tests { fs::create_dir_all(&home).unwrap(); let now = current_millis().unwrap(); let mut prior = Vec::new(); - for index in 0..64u32 { - let settings = root.join(format!("projects/p{index:02}/.codex/hooks.json")); + for index in 0..MAX_AGENTS as u32 { + let settings = root.join(format!("projects/p{index:03}/.codex/hooks.json")); fs::create_dir_all(settings.parent().unwrap()).unwrap(); prior.push(AgentProfile { instance_id: format!("agt_{index:032x}"), integration: "codex".into(), settings_path: settings.to_string_lossy().into_owned(), - profile_label: format!("p{index:02}"), + profile_label: format!("p{index:03}"), scope: "project".into(), hook_installed: true, last_seen_at: Some(now - 61_000), fingerprint: None, }); } - let assigned = prior[63].clone(); + let assigned = prior[MAX_AGENTS - 1].clone(); write( &path, &AgentRoster { @@ -520,7 +782,7 @@ mod tests { fs::create_dir_all(claude.parent().unwrap()).unwrap(); fs::write(claude, "failproofai").unwrap(); let refreshed = refresh(&path, &home).unwrap(); - assert_eq!(refreshed.agents.len(), 64); + assert_eq!(refreshed.agents.len(), MAX_AGENTS); assert_eq!(refreshed.generation, 1); assert!( refreshed @@ -541,7 +803,7 @@ mod tests { // The retained profile must still be found and refreshed at hook // time; the old implementation dropped it and refused the sighting - // because the new discovery had filled the 64th slot. + // because the new discovery had filled the last slot. record_sighting(&path, &home, "codex", Path::new(&assigned.settings_path)).unwrap(); let after_hook = read(&path).unwrap().unwrap(); let target = after_hook @@ -554,4 +816,267 @@ mod tests { assert!(target.last_seen_at.unwrap() >= now); fs::remove_dir_all(root).unwrap(); } + fn day() -> i64 { + 24 * 60 * 60 * 1000 + } + + /// A full roster of codex project profiles. Entry `i` was last seen + /// `40 + MAX_AGENTS - i` days ago, so entry 0 is the oldest. + fn full_roster(root: &Path, path: &Path, hooked_folders: bool) -> Vec { + let now = current_millis().unwrap(); + let agents: Vec<_> = (0..MAX_AGENTS as u32) + .map(|index| { + let settings = root.join(format!("projects/p{index:03}/.codex/hooks.json")); + if hooked_folders { + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + } + AgentProfile { + instance_id: format!("agt_{index:032x}"), + integration: "codex".into(), + settings_path: settings.to_string_lossy().into_owned(), + profile_label: format!("p{index:03}"), + scope: "project".into(), + hook_installed: true, + last_seen_at: Some(now - (40 + MAX_AGENTS as i64 - i64::from(index)) * day()), + fingerprint: None, + } + }) + .collect(); + write( + path, + &AgentRoster { + schema_version: 1, + generation: 1, + agents: agents.clone(), + }, + ) + .unwrap(); + agents + } + + fn ids(roster: &AgentRoster) -> HashSet { + roster + .agents + .iter() + .map(|entry| entry.instance_id.clone()) + .collect() + } + + #[test] + fn a_full_stale_roster_admits_a_new_profile_and_keeps_every_assigned_id() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + let prior = full_roster(&root, &path, false); + // Thirty days unseen and their folders gone: no longer hooked. + let reported = refresh(&path, &home).unwrap(); + assert!(reported.agents.iter().all(|entry| !entry.hook_installed)); + assert_eq!(reported.agents.len(), MAX_AGENTS); + + // A newly installed project profile fires its hook on a full roster. + let fresh = root.join("new-project/.claude/settings.json"); + fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + record_sighting(&path, &home, "claude", &fresh).unwrap(); + assert_eq!( + read(&path).unwrap().unwrap(), + reported, + "nothing evicted at hook time" + ); + assert!(needs_capacity(&path)); + + // The two OLDEST profiles are named by exact-profile assignments. + let protected: HashSet = [&prior[0], &prior[1]] + .iter() + .map(|entry| entry.instance_id.clone()) + .collect(); + let after = reclaim( + &path, + &home, + &Reclaimable::from_report(&reported, &protected), + ) + .unwrap(); + assert_eq!(after.agents.len(), MAX_AGENTS); + assert!(after.generation > reported.generation); + let admitted = after + .agents + .iter() + .find(|entry| entry.settings_path == fresh.to_string_lossy()) + .expect("the new profile has an identity"); + assert_eq!(admitted.integration, "claude"); + assert_eq!(admitted.scope, "project"); + assert!(admitted.hook_installed); + assert!(!ids(&reported).contains(&admitted.instance_id)); + let kept = ids(&after); + assert!(protected.is_subset(&kept), "assigned IDs stay stable"); + let evicted: Vec<_> = ids(&reported).difference(&kept).cloned().collect(); + assert_eq!( + evicted, + vec![prior[2].instance_id.clone()], + "oldest unassigned only" + ); + assert!(!needs_capacity(&path)); + assert_eq!( + refresh(&path, &home).unwrap(), + after, + "stable once admitted" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_full_roster_never_reclaims_without_cloud_or_an_id_cloud_might_target() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + let prior = full_roster(&root, &path, false); + let unhooked = refresh(&path, &home).unwrap(); + let claude = home.join(".claude/settings.json"); + fs::create_dir_all(claude.parent().unwrap()).unwrap(); + fs::write(&claude, "failproofai").unwrap(); + + // No report from Cloud: the hooked config waits, nothing moves. + let waiting = refresh(&path, &home).unwrap(); + assert_eq!(waiting, unhooked); + assert!(needs_capacity(&path)); + + // Cloud last acknowledged every entry as hooked (or protects it): + // any of them could be targeted again, so none may be reclaimed even + // though this machine now sees them all unhooked. + let mut acknowledged = unhooked.clone(); + for entry in &mut acknowledged.agents { + entry.hook_installed = true; + } + acknowledged.agents[0].hook_installed = false; + let protected = HashSet::from([prior[0].instance_id.clone()]); + let after = reclaim( + &path, + &home, + &Reclaimable::from_report(&acknowledged, &protected), + ) + .unwrap(); + assert_eq!(after, unhooked); + assert!(needs_capacity(&path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn neither_an_unhooked_config_nor_a_hooked_entry_is_traded_for_a_slot() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + let prior = full_roster(&root, &path, false); + let reported = refresh(&path, &home).unwrap(); + let everything = Reclaimable::from_report(&reported, &HashSet::new()); + + // A config without our hook only ever takes a free slot; otherwise + // it would evict, be re-admitted, and evict again every refresh. + let cursor = home.join(".cursor/hooks.json"); + fs::create_dir_all(cursor.parent().unwrap()).unwrap(); + fs::write(&cursor, "{}").unwrap(); + assert_eq!(reclaim(&path, &home, &everything).unwrap(), reported); + assert!(!needs_capacity(&path)); + + // An entry whose hook fired since the report is not reclaimable. + let busy = Path::new(&prior[0].settings_path); + fs::create_dir_all(busy.parent().unwrap()).unwrap(); + record_sighting(&path, &home, "codex", busy).unwrap(); + let sighted = root.join("other/.codex/hooks.json"); + fs::create_dir_all(sighted.parent().unwrap()).unwrap(); + record_sighting(&path, &home, "codex", &sighted).unwrap(); + let after = reclaim(&path, &home, &everything).unwrap(); + let kept = ids(&after); + assert!( + kept.contains(&prior[0].instance_id), + "a live hook keeps its ID" + ); + assert!( + !kept.contains(&prior[1].instance_id), + "next oldest goes instead" + ); + assert!( + after + .agents + .iter() + .any(|entry| entry.settings_path == sighted.to_string_lossy()) + ); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_hook_on_an_unadmitted_config_keeps_waiting_until_it_gets_a_slot() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + full_roster(&root, &path, false); + let reported = refresh(&path, &home).unwrap(); + // The config does not name us (a plugin installed the hook), so + // discovery alone never treats it as hooked. + let settings = home.join(".claude/settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write(&settings, "{}").unwrap(); + record_sighting(&path, &home, "claude", &settings).unwrap(); + assert!(needs_capacity(&path)); + // Every maintenance tick starts with a plain refresh. It must not + // consume the sighting while the profile still has no slot. + assert_eq!(refresh(&path, &home).unwrap(), reported); + assert!(needs_capacity(&path)); + let after = reclaim( + &path, + &home, + &Reclaimable::from_report(&reported, &HashSet::new()), + ) + .unwrap(); + let admitted = after + .agents + .iter() + .find(|entry| entry.settings_path == settings.to_string_lossy()) + .expect("the sighted profile has an identity"); + assert_eq!( + (admitted.integration.as_str(), admitted.scope.as_str()), + ("claude", "user") + ); + assert!(admitted.hook_installed); + assert!(!needs_capacity(&path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn profiles_that_could_outgrow_the_hook_readers_bound_are_never_admitted() { + let root = std::env::temp_dir().join(format!("fpai-roster-{}", new_instance_id().unwrap())); + let home = root.join("home"); + let path = root.join("fpai/agents/roster.json"); + let control = root.join("bad\nname/.codex/hooks.json"); + fs::create_dir_all(control.parent().unwrap()).unwrap(); + fs::create_dir_all(&home).unwrap(); + record_sighting(&path, &home, "codex", &control).unwrap(); + assert!( + read(&path) + .unwrap() + .is_none_or(|roster| roster.agents.is_empty()) + ); + // 256 entries of the largest admissible path, every byte escaped. + let quoted = format!("/{}", "\"".repeat(MAX_SETTINGS_PATH_BYTES - 1)); + let worst = AgentRoster { + schema_version: 1, + generation: 1, + agents: (0..MAX_AGENTS as u32) + .map(|index| AgentProfile { + instance_id: format!("agt_{index:032x}"), + integration: "antigravity".into(), + settings_path: quoted.clone(), + profile_label: "\"".repeat(80), + scope: "project".into(), + hook_installed: true, + last_seen_at: Some(i64::MAX), + fingerprint: Some(format!("{}:{}", u64::MAX, u64::MAX)), + }) + .collect(), + }; + assert!(serde_json::to_vec(&worst).unwrap().len() < MAX_ROSTER_BYTES); + fs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/failproofaid/src/cloud_client.rs b/crates/failproofaid/src/cloud_client.rs index eaccc8106..93035dc2c 100644 --- a/crates/failproofaid/src/cloud_client.rs +++ b/crates/failproofaid/src/cloud_client.rs @@ -18,6 +18,20 @@ use crate::agent_roster::{self, AgentRoster}; const DEFAULT_POLL_MS: u64 = 30_000; const MINIMUM_POLL_MS: u64 = 100; const AGENT_INVENTORY_HEARTBEAT: Duration = Duration::from_secs(15 * 60); +/// How often a full roster may skip the heartbeat to ask Cloud which IDs it +/// still protects, while a hooked profile waits for a slot. +const AGENT_CAPACITY_REPORT: Duration = Duration::from_secs(60); +const MAX_INVENTORY_REPLY_BYTES: usize = 1 << 20; + +/// What one roster report did. +enum RosterReport { + /// Cloud already holds this snapshot recently enough; nothing was sent. + Skipped, + /// Sent. `Some` when Cloud answered for this snapshot with the IDs it + /// protects; `None` for another generation or a Cloud that predates + /// `protectedInstanceIds` (then nothing is ever reclaimed). + Sent(Option), +} static AGENT_INVENTORY_REPORTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -867,7 +881,13 @@ impl CloudClient { /// The roster is a full snapshot, independent of policy reconciliation. /// A failed upload never discards the already-active assignment. - fn report_agent_roster(&self, roster: &AgentRoster) -> Result<(), String> { + /// + /// `capacity` asks again sooner than the heartbeat. + fn report_agent_roster( + &self, + roster: &AgentRoster, + capacity: bool, + ) -> Result { let token_digest = Sha256::digest(self.token.as_bytes()); let key = format!( "{}:{}:{}", @@ -878,12 +898,17 @@ impl CloudClient { .map(|byte| format!("{byte:02x}")) .collect::() ); + let fresh_for = if capacity { + AGENT_CAPACITY_REPORT + } else { + AGENT_INVENTORY_HEARTBEAT + }; if AGENT_INVENTORY_REPORTS.lock().is_ok_and(|reports| { reports.get(&key).is_some_and(|(generation, at)| { - *generation == roster.generation && at.elapsed() < AGENT_INVENTORY_HEARTBEAT + *generation == roster.generation && at.elapsed() < fresh_for }) }) { - return Ok(()); + return Ok(RosterReport::Skipped); } let mut url = self .base_url @@ -913,7 +938,8 @@ impl CloudClient { "generation": roster.generation, "agents": agents, }); - self.client + let response = self + .client .put(url) .bearer_auth(&self.token) .json(&body) @@ -928,7 +954,25 @@ impl CloudClient { if let Ok(mut reports) = AGENT_INVENTORY_REPORTS.lock() { reports.insert(key, (roster.generation, Instant::now())); } - Ok(()) + // The reply is a generation and at most a few hundred opaque IDs. + let reply: serde_json::Value = response + .bytes() + .ok() + .filter(|body| body.len() <= MAX_INVENTORY_REPLY_BYTES) + .and_then(|body| serde_json::from_slice(&body).ok()) + .unwrap_or_default(); + if reply["generation"].as_u64() != Some(roster.generation) { + return Ok(RosterReport::Sent(None)); + } + // A malformed list reclaims nothing rather than everything. + let protected = reply["protectedInstanceIds"].as_array().and_then(|ids| { + ids.iter() + .map(|id| id.as_str().map(str::to_owned)) + .collect::>>() + }); + Ok(RosterReport::Sent(protected.map(|ids| { + agent_roster::Reclaimable::from_report(roster, &ids) + }))) } fn artifact(&self, policy: &DesiredPolicy) -> Result, String> { @@ -1097,6 +1141,36 @@ fn repair(store: &PolicyStore, withdrawn: &dyn Fn() -> bool) { } } +/// Report the roster and, while a hooked profile waits for a slot, reclaim one +/// right away: the protected list must describe the snapshot Cloud holds NOW, +/// so it is never kept for a later tick or used after another report. When +/// asking frees nothing, forced reports stop until the roster changes. +fn report_and_reclaim(cloud: &CloudClient, path: &Path, home: &Path, roster: &AgentRoster) { + let wanted = agent_roster::needs_capacity(path); + let force = agent_roster::should_force_capacity_report(path, roster.generation); + let reclaimable = match cloud.report_agent_roster(roster, force) { + Ok(RosterReport::Sent(Some(reclaimable))) if wanted => reclaimable, + Ok(RosterReport::Sent(None)) if wanted => { + agent_roster::capacity_stalled(path, roster.generation); + return; + } + Ok(_) => return, + Err(err) => { + eprintln!("[failproofaid] {err}"); + return; + } + }; + match agent_roster::reclaim(path, home, &reclaimable) { + Ok(next) if next.generation != roster.generation => { + if let Err(err) = cloud.report_agent_roster(&next, false) { + eprintln!("[failproofaid] {err}"); + } + } + Ok(_) => agent_roster::capacity_stalled(path, roster.generation), + Err(err) => eprintln!("[failproofaid] could not reclaim agent roster capacity: {err}"), + } +} + /// One poll and its reconcile. `withdrawn` is asked right before anything is /// persisted — the reconcile's writes and the daemon's error state — so a /// disconnect that lands while the request is in flight is not undone by it. @@ -1134,10 +1208,10 @@ fn poll_once_guarded(store: &PolicyStore, cloud: &CloudClient, withdrawn: &dyn F // The GET created/checked-in the machine row the PUT requires. // Do not let a roster network outage interrupt local enforcement. if let Ok(path) = agent_roster::roster_path() + && let Some(home) = std::env::var_os("HOME") && let Ok(Some(roster)) = agent_roster::read(&path) - && let Err(err) = cloud.report_agent_roster(&roster) { - eprintln!("[failproofaid] {err}"); + report_and_reclaim(cloud, &path, Path::new(&home), &roster); } match store.reconcile_unless( &desired, @@ -2814,8 +2888,20 @@ mod tests { body.to_string().find("settingsPath").is_none(), "paths stay local" ); + let reply = serde_json::json!({ + "generation": generation, + "changed": true, + "protectedInstanceIds": [], + }) + .to_string(); stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ) + .as_bytes(), + ) .unwrap(); }); let client = CloudClient::new( @@ -2824,11 +2910,260 @@ mod tests { "machine".into(), ) .unwrap(); - client.report_agent_roster(&roster).unwrap(); + let report = client.report_agent_roster(&roster, false).unwrap(); server.join().unwrap(); + assert!( + matches!(report, RosterReport::Sent(Some(r)) if r.is_empty()), + "a hooked profile is never reclaimable" + ); // No server is listening now. This must still succeed from the - // in-process generation cache, and must not make another PUT. - client.report_agent_roster(&roster).unwrap(); + // in-process generation cache, and must not make another PUT, even + // when capacity is wanted inside the shorter capacity interval. + let skipped = |capacity| { + matches!( + client.report_agent_roster(&roster, capacity), + Ok(RosterReport::Skipped) + ) + }; + assert!(skipped(false)); + assert!(skipped(true)); + fs::remove_dir_all(root).unwrap(); + } + + /// Serve one PUT with `reply` as the JSON body. + fn reply_once(reply: &'static str) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut buf = [0u8; 65_536]; + let _ = stream.read(&mut buf); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + (address, server) + } + + #[test] + fn only_a_well_formed_answer_for_this_snapshot_can_reclaim_anything() { + let roster = AgentRoster { + schema_version: 1, + generation: 7, + agents: Vec::new(), + }; + for (machine, reply, usable) in [ + ( + "m-ok", + r#"{"generation":7,"protectedInstanceIds":["agt_0123456789abcdef"]}"#, + true, + ), + // An older Cloud: no list, so nothing is ever reclaimed. + ("m-old", r#"{"generation":7,"changed":true}"#, false), + // Cloud holds another snapshot; its list describes that one. + ( + "m-gen", + r#"{"generation":8,"protectedInstanceIds":[]}"#, + false, + ), + ( + "m-bad", + r#"{"generation":7,"protectedInstanceIds":["agt_0123456789abcdef",3]}"#, + false, + ), + ] { + let (address, server) = reply_once(reply); + let client = + CloudClient::new(&format!("http://{address}"), "token".into(), machine.into()) + .unwrap(); + let report = client.report_agent_roster(&roster, true).unwrap(); + server.join().unwrap(); + assert_eq!( + matches!(report, RosterReport::Sent(Some(_))), + usable, + "{reply}" + ); + } + } + + /// Serve `count` roster PUTs, answering each for the generation it + /// carried; return their bodies. + fn roster_cloud( + count: usize, + protected: Option>, + ) -> ( + std::net::SocketAddr, + std::thread::JoinHandle>, + ) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for _ in 0..count { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut received = Vec::new(); + let body = loop { + let mut buf = [0u8; 65_536]; + let len = stream.read(&mut buf).unwrap(); + assert!(len > 0); + received.extend_from_slice(&buf[..len]); + let Some(at) = received.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&received[..at]).to_ascii_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse() + .unwrap(); + if received.len() >= at + 4 + length { + break serde_json::from_slice::( + &received[at + 4..at + 4 + length], + ) + .unwrap(); + } + }; + let mut reply = serde_json::json!({ "generation": body["generation"] }); + if let Some(ids) = &protected { + reply["protectedInstanceIds"] = serde_json::json!(ids); + } + let reply = reply.to_string(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ) + .as_bytes(), + ) + .unwrap(); + bodies.push(body); + } + bodies + }); + (address, server) + } + + /// A full roster of 256 unhooked project profiles; entry 0 is the oldest. + fn full_stale_roster(root: &Path) -> (std::path::PathBuf, std::path::PathBuf, AgentRoster) { + let home = root.join("home"); + let path = root.join("agents/roster.json"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let agents: Vec<_> = (0..agent_roster::MAX_AGENTS) + .map(|index| { + serde_json::json!({ + "instanceId": format!("agt_{index:032x}"), + "integration": "codex", + "settingsPath": root.join(format!("gone/p{index:03}/.codex/hooks.json")), + "profileLabel": format!("p{index:03}"), + "scope": "project", + "hookInstalled": true, + "lastSeenAt": now - (400 - index as i64) * 86_400_000, + }) + }) + .collect(); + fs::write( + &path, + serde_json::json!({"schemaVersion":1,"generation":1,"agents":agents}).to_string(), + ) + .unwrap(); + let roster = agent_roster::refresh(&path, &home).unwrap(); + assert!(roster.agents.iter().all(|agent| !agent.hook_installed)); + (home, path, roster) + } + + #[test] + fn a_waiting_profile_is_admitted_in_the_tick_that_asks_cloud() { + let root = std::env::temp_dir().join(format!("c11-reclaim-{}", std::process::id())); + let (home, path, roster) = full_stale_roster(&root); + let fresh = root.join("new/.claude/settings.json"); + fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + agent_roster::record_sighting(&path, &home, "claude", &fresh).unwrap(); + let protected = format!("agt_{:032x}", 0); + // Two PUTs: the forced report Cloud answers, then the reclaimed roster. + let (address, server) = roster_cloud(2, Some(vec![protected.clone()])); + let client = CloudClient::new( + &format!("http://{address}"), + "token".into(), + "m-glue".into(), + ) + .unwrap(); + report_and_reclaim(&client, &path, &home, &roster); + let bodies = server.join().unwrap(); + let after = agent_roster::read(&path).unwrap().unwrap(); + assert_eq!(bodies[0]["generation"], roster.generation); + assert_eq!(bodies[1]["generation"], after.generation); + let ids: HashSet<_> = after + .agents + .iter() + .map(|a| a.instance_id.as_str()) + .collect(); + assert!(ids.contains(protected.as_str()), "an assigned ID stays"); + assert!( + !ids.contains(format!("agt_{:032x}", 1).as_str()), + "oldest unassigned goes" + ); + assert!( + after + .agents + .iter() + .any(|a| a.settings_path == fresh.to_string_lossy()) + ); + assert!(!agent_roster::needs_capacity(&path)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_cloud_that_frees_nothing_is_not_asked_again_until_the_roster_changes() { + let root = std::env::temp_dir().join(format!("c11-stall-{}", std::process::id())); + let (home, path, roster) = full_stale_roster(&root); + let fresh = root.join("new/.claude/settings.json"); + fs::create_dir_all(fresh.parent().unwrap()).unwrap(); + agent_roster::record_sighting(&path, &home, "claude", &fresh).unwrap(); + // An older Cloud: no protectedInstanceIds, so nothing may be reclaimed. + let (address, server) = roster_cloud(1, None); + let client = CloudClient::new( + &format!("http://{address}"), + "token".into(), + "m-stall".into(), + ) + .unwrap(); + report_and_reclaim(&client, &path, &home, &roster); + server.join().unwrap(); + assert_eq!(agent_roster::read(&path).unwrap().unwrap(), roster); + assert!(agent_roster::needs_capacity(&path)); + assert!(!agent_roster::should_force_capacity_report( + &path, + roster.generation + )); + assert!(agent_roster::should_force_capacity_report( + &path, + roster.generation + 1 + )); + // Nothing is listening now: a forced report would fail; this is + // served from the heartbeat cache instead. + assert!(matches!( + client.report_agent_roster(&roster, false), + Ok(RosterReport::Skipped) + )); fs::remove_dir_all(root).unwrap(); } } diff --git a/docs/policies/deploy.mdx b/docs/policies/deploy.mdx index 27718664e..ebb5e2269 100644 --- a/docs/policies/deploy.mdx +++ b/docs/policies/deploy.mdx @@ -114,6 +114,20 @@ the hook does not report which one ran), **targeted assignments do not match**; unscoped policies still apply. Look for `agent_scope_unresolved` under that machine's policy errors. +Each machine keeps up to 256 profiles in its roster. A profile's ID never +changes while it is listed. When the roster is full and a new profile with a +FailproofAI hook appears (its hook runs, or its config carries the hook), the +daemon makes room by dropping one profile that meets all of these: + +- it has no hook installed; +- no assignment names it; +- FailproofAI Cloud would not accept it as a new target. + +The daemon prefers a profile no longer found on the machine, then the one +unused longest. It never drops a profile an exact target uses, so those +targets stay valid. If no profile can be dropped, the new profile has no ID +yet, and targeted assignments do not match it until a slot frees up. + ## Deploy Jev checks A Cloud policy can carry Jev checks as well as, or instead of, JavaScript. Its kind is `jev` (Jev checks only) or `both` (JavaScript whose verdict its own checks may clear). It deploys, toggles and rolls back exactly like any other policy. diff --git a/src/hooks/agent-roster.ts b/src/hooks/agent-roster.ts index bc5dcc5a6..ee1733254 100644 --- a/src/hooks/agent-roster.ts +++ b/src/hooks/agent-roster.ts @@ -10,6 +10,10 @@ import { agentRosterFile } from "./fp-home"; import { validAgentIdentity, type AgentIdentity } from "./agent-targets"; import type { HookScope, IntegrationType } from "./types"; +/** `MAX_AGENTS` / `MAX_ROSTER_BYTES` in the daemon's `agent_roster.rs`. */ +export const MAX_ROSTER_AGENTS = 256; +export const MAX_ROSTER_BYTES = 4_000_000; + const USER_SETTINGS: Partial> = { claude: ".claude/settings.json", codex: ".codex/hooks.json", @@ -116,11 +120,11 @@ export function readRuntimeAgentIdentity(cli: IntegrationType, settingsPath: str try { const path = agentRosterFile(); const stat = lstatSync(path); - if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > 256_000) return null; + if (!stat.isFile() || (stat.mode & 0o077) !== 0 || stat.size > MAX_ROSTER_BYTES) return null; const roster: unknown = JSON.parse(readFileSync(path, "utf8")); if (!roster || typeof roster !== "object" || Array.isArray(roster)) return null; const data = roster as Record; - if (data.schemaVersion !== 1 || !Array.isArray(data.agents) || data.agents.length > 64) return null; + if (data.schemaVersion !== 1 || !Array.isArray(data.agents) || data.agents.length > MAX_ROSTER_AGENTS) return null; const absolute = resolve(settingsPath); const agent = data.agents.find((entry: unknown) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;