diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc98b995..9de41f969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### 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. - **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__/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__/fixtures/hermes-native-plugin-check.py b/__tests__/fixtures/hermes-native-plugin-check.py index 76503424e..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.", @@ -451,14 +451,16 @@ 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") - 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() @@ -476,7 +478,7 @@ def server() -> None: body = json.dumps( { "type": "policyResult", - "protocolVersion": 99, + "protocolVersion": 1, "decision": "allow", "policyNames": [], "matchedPolicies": [], diff --git a/__tests__/hooks/agent-roster.test.ts b/__tests__/hooks/agent-roster.test.ts new file mode 100644 index 000000000..d2db7ec7a --- /dev/null +++ b/__tests__/hooks/agent-roster.test.ts @@ -0,0 +1,116 @@ +// @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(); + 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); + 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/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/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); + } + }); + } +}); diff --git a/__tests__/hooks/cloud-jev-policies.test.ts b/__tests__/hooks/cloud-jev-policies.test.ts index 7a1f53357..8ff2e9f1a 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,49 @@ 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"), + })); + const ambiguous = await evaluateHookEvent("PreToolUse", "claude", input, { agentSettingsPath: "" }); + expect(ambiguous.evaluation?.decision).toBe("allow"); +}); + async function registeredAfterOneEvent(): Promise> { await hook(); const { getAllPolicies } = await import("@/src/hooks/policy-registry"); @@ -358,10 +403,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..a5d2b2af4 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,72 @@ 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/); + } + }); + + 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", () => { it("stops enforcement while leaving the verified artifacts on disk", () => { // `--disconnect` cleared the credential, which ends POLLING. Every artifact diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts index e3c78768a..4077f4b29 100644 --- a/__tests__/hooks/daemon-client.test.ts +++ b/__tests__/hooks/daemon-client.test.ts @@ -85,13 +85,15 @@ 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. + expect(req.agentSettingsPath).toBe(""); socket.end( encodeFrame({ type: "hookResult", - protocolVersion: 1, + protocolVersion: 2, exitCode: 0, stdout: "", stderr: "", @@ -104,6 +106,7 @@ describe("hooks/daemon-client", () => { cli: "claude", stdin: "{}", cwd: "/repo", + agentSettingsPath: "", }); expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); }); @@ -113,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" }, @@ -121,7 +124,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "policyResult", - protocolVersion: 1, + protocolVersion: 2, decision: "allow", policyNames: [], reason: null, @@ -148,7 +151,7 @@ describe("hooks/daemon-client", () => { socket.end( encodeFrame({ type: "hookResult", - protocolVersion: 1, + protocolVersion: 2, exitCode: 0, stdout: "", stderr: "", @@ -172,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", @@ -187,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: "{}" }); @@ -224,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"); @@ -238,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"); @@ -262,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: "{}" }); @@ -291,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/__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 9367cd3b9..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 @@ -165,6 +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, }); // On a daemon-configured machine the daemon is the ONLY evaluator. Every @@ -208,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/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/agent_roster.rs b/crates/failproofaid/src/agent_roster.rs new file mode 100644 index 000000000..0c65f1053 --- /dev/null +++ b/crates/failproofaid/src/agent_roster.rs @@ -0,0 +1,557 @@ +//! 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::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 +} + +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(); + // 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(); + 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(|| { + 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(), + )) + }) + }) + }) + }); + 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 + }; + 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); + } + // 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; + } + 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)) + }); + 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(); + } + + #[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(); + } +} 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..2224f44f5 100644 --- a/crates/failproofaid/src/server.rs +++ b/crates/failproofaid/src/server.rs @@ -307,6 +307,30 @@ 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}"); + } +} + +/// 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 { @@ -327,26 +351,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(), + worker_agent_settings_path(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 +392,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(), + worker_agent_settings_path(agent_settings_path.as_deref()), + ) { Ok(outcome) => match outcome.evaluation { Some(evaluation) if matches!( @@ -539,6 +581,7 @@ mod tests { cli: "claude".to_string(), stdin: "{}".to_string(), cwd: None, + agent_settings_path: None, }, ) .unwrap(); @@ -566,6 +609,7 @@ mod tests { "tool_input": {"command": "echo hi"} }), cwd: None, + agent_settings_path: None, }, ) .unwrap(); @@ -635,6 +679,7 @@ mod tests { cli: "claude".to_string(), stdin, cwd: Some(project_dir.to_string_lossy().to_string()), + agent_settings_path: None, }, ) .unwrap(); @@ -671,6 +716,7 @@ mod tests { "tool_input": { "command": "sudo rm -rf /" } }), cwd: Some(project_dir.to_string_lossy().to_string()), + agent_settings_path: None, }, ) .unwrap(); @@ -696,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"); @@ -718,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/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..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")] @@ -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"); @@ -150,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": "{}" @@ -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"); @@ -180,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 54b243b08..27718664e 100644 --- a/docs/policies/deploy.mdx +++ b/docs/policies/deploy.mdx @@ -70,6 +70,50 @@ 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. 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 +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 +131,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. 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..348dbf11e 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -160,6 +160,27 @@ 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. +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, applies your `--add`/`--remove`, prints the complete resulting set, and writes @@ -277,4 +298,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/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/__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..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 @@ -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: @@ -85,6 +86,9 @@ 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 "", } try: body = json.dumps( diff --git a/src/hooks/agent-roster.ts b/src/hooks/agent-roster.ts new file mode 100644 index 000000000..bc5dcc5a6 --- /dev/null +++ b/src/hooks/agent-roster.ts @@ -0,0 +1,140 @@ +/** + * 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 { HookScope, 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, cli: IntegrationType): boolean { + try { + const stat = statSync(path); + 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(), + 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; + 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); + 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 (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 scopeFiles) { + const path = resolve(dir, candidate); + if (installedHookAt(path, cli)) 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; + if (hookScope === "project" || hookScope === "local") return matches.values().next().value ?? 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..0de9e2329 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,27 +480,31 @@ 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" - ? [ - { - id: policy.id, - effect: policy.effect === "observe" ? "observe" : "enforce", - ...authorityFieldsOf(policy as unknown as Record), - } satisfies CloudAuthorityInput, - ] - : [], - ); + const manifest = parseManifest(raw); + 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 []; } } -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 +518,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 +558,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..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 @@ -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, ); @@ -338,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, @@ -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..80149a192 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 @@ -1288,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) { @@ -1320,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, 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(