From 98eabb064410fc2c785ce7dd06959d4dba2ca692 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Wed, 19 Aug 2026 14:29:51 +0530 Subject: [PATCH 01/19] feat(fp-cli): fp policies, fp fleet and fp guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the dashboard's three cloud-managed-policy pages to the CLI, so a person or an agent can do from a terminal what previously needed a browser: write a policy, put it on machines, and see what it blocked. Three commands because they are three jobs, split the way the dashboard splits them — `/policies` authors a version, `/enforcement` decides which machines run it, `/guardrails` reports what happened. Folding them into one would merge "what we intended" with "what occurred", which is the distinction the pages exist to keep. ## The dangerous part, and what the CLI does about it `PUT /enforcement/deployments/{id}` REPLACES a machine's whole policy set. No merge, no server-side lock. The dashboard has no deploy form precisely because of this — it edits the machine's own current set, since a form that asks you to re-tick policies silently drops whatever you forget. So `fleet deploy` is a read-modify-write: it reads what the machine runs, applies `--add`/`--remove`, shows the FULL resulting set, and writes that. `--set` is the only way to drop what you did not name, and is refused alongside `--add`. Three further guards, each for a way this loses work silently: * A bare `--add` of a policy the machine already runs keeps its PINNED version rather than moving to the newest. A pin is deliberate; upgrading a fleet on a command whose author was reordering is not. * The diff shows unchanged rows. The write replaces everything, so the set on screen is the set that will exist — hiding untouched rows hides exactly the ones a mistake drops. * The generation read before the write must come back as `base + 1`. Anything else means somebody deployed in between, and a replace does not merge, so their change is already gone. The CLI refuses instead of reporting success. (`lib/enforcementFleet.ts`'s `staleness()` does the same check, after the fact; doing it before is the difference between a warning and a save.) ## Session-only, deliberately Every route here is ROOT-ONLY on the server — absent from `/v1` because `/v1` is internet-facing and these are operator writes. The commands refuse `--api-key` up front via `deny_in_key_mode` rather than translating a path that would 404, and `enforcement` is classified in `_V1_NO_EQUIVALENT` so the anti-drift test that guards that table stays honest. ## Input and output Policy source arrives as a path, `@path`, a pipe, `-`, or an interactive paste when stdin is a terminal — five shapes because that is where people keep a file they are about to publish, and refusing the clipboard means "save it first" for the most common one-off. Every command supports `--json`, in the SERVER's shape plus what the CLI computed (the deploy plan, the drift flag). Model `to_dict()` rather than `vars()`: the latter leaks Python snake_case into a contract that is camelCase everywhere else, which a harness discovers at runtime rather than in review. Tests: 42 covering the planner, the race check and source resolution — the pure logic, because that is where a wrong answer destroys a fleet's policy set. 836 pass overall. Co-Authored-By: Claude Opus 5 (1M context) --- fp-cli/fp_cli/app.py | 6 + fp-cli/fp_cli/client.py | 138 +++++++++ fp-cli/fp_cli/commands/fleet_cmds.py | 328 ++++++++++++++++++++++ fp-cli/fp_cli/commands/guardrails_cmds.py | 140 +++++++++ fp-cli/fp_cli/commands/policies_cmds.py | 206 ++++++++++++++ fp-cli/fp_cli/enforcement.py | 269 ++++++++++++++++++ fp-cli/fp_cli/models.py | 170 +++++++++++ fp-cli/fp_cli/output.py | 235 ++++++++++++++++ fp-cli/tests/test_enforcement_logic.py | 244 ++++++++++++++++ 9 files changed, 1736 insertions(+) create mode 100644 fp-cli/fp_cli/commands/fleet_cmds.py create mode 100644 fp-cli/fp_cli/commands/guardrails_cmds.py create mode 100644 fp-cli/fp_cli/commands/policies_cmds.py create mode 100644 fp-cli/fp_cli/enforcement.py create mode 100644 fp-cli/tests/test_enforcement_logic.py diff --git a/fp-cli/fp_cli/app.py b/fp-cli/fp_cli/app.py index 75986afa0..b603d4648 100644 --- a/fp-cli/fp_cli/app.py +++ b/fp-cli/fp_cli/app.py @@ -24,10 +24,13 @@ errors_cmds, evals_cmds, events_cmds, + fleet_cmds, + guardrails_cmds, incidents_cmds, keys_cmds, list_cmds, orgs_cmds, + policies_cmds, queries_cmds, sessions_cmds, settings_cmds, @@ -393,6 +396,9 @@ def help_cmd(ctx: typer.Context) -> None: audits_cmds.register(app) incidents_cmds.register(app) agent_cmds.register(app) +policies_cmds.register(app) +fleet_cmds.register(app) +guardrails_cmds.register(app) def _elapsed_ms(start: float) -> int: diff --git a/fp-cli/fp_cli/client.py b/fp-cli/fp_cli/client.py index 448499463..43ea87f95 100644 --- a/fp-cli/fp_cli/client.py +++ b/fp-cli/fp_cli/client.py @@ -42,11 +42,15 @@ AuditFinding, AuditRun, DashboardUser, + Deployment, Evaluation, Incident, IncidentComment, IncidentSubscriber, + Machine, Page, + PolicyRef, + PolicyVersion, QueryResult, SavedQuery, Session, @@ -158,6 +162,15 @@ class ClientContext: "the assistant is implemented by the dashboard, not the API — there is no /v1 " "route behind it" ), + # ROOT-ONLY on the server, and deliberately so: `/v1` is published on the + # dashboard host by the ingress, and publish/deploy/rollback are operator + # writes gated on `policies:write`. Exposing them there would put fleet + # mutation on the open internet. See the ROOT-ONLY block in + # `server/src/routes/mod.rs`. + "enforcement": ( + "cloud-managed policies are an operator surface — the fleet routes are " + "deliberately absent from /v1, which is internet-facing" + ), } @@ -1393,3 +1406,128 @@ def paginate( return seen.add(key) cursor = next_cursor + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── +# +# Every path here is ROOT-ONLY on the server: deliberately absent from `/v1`, +# because `/v1` is published on the dashboard host by the ingress and these are +# operator WRITE paths (publish, deploy, rollback). The commands therefore refuse +# API-key mode up front via `deny_in_key_mode` rather than translating a path +# that would 404 — see `server/src/routes/mod.rs`, the ROOT-ONLY block. + + +def list_policies(ctx: ClientContext) -> List[PolicyVersion]: + """GET /api/enforcement/policies — every published policy, latest version each.""" + data = _get_json(ctx, "/api/enforcement/policies") + items = data if isinstance(data, list) else data.get("policies", []) + return [PolicyVersion.from_dict(p) for p in items] + + +def publish_policy( + ctx: ClientContext, policy_id: str, source: str, description: str = "" +) -> PolicyVersion: + """POST /api/enforcement/policies — mints a NEW VERSION; never edits in place.""" + body = {"id": policy_id, "source": source, "description": description} + return PolicyVersion.from_dict(_post_json(ctx, "/api/enforcement/policies", body) or {}) + + +def set_policy_enabled(ctx: ClientContext, policy_id: str, enabled: bool) -> Dict[str, Any]: + """POST /api/enforcement/policies/{id}/{enable|disable}.""" + verb = "enable" if enabled else "disable" + path = f"/api/enforcement/policies/{policy_id}/{verb}" + return _post_json(ctx, path) or {} + + +def delete_policy(ctx: ClientContext, policy_id: str) -> Dict[str, Any]: + """DELETE /api/enforcement/policies/{id} — archives it; machines keep what they hold.""" + return _request_json(ctx, "DELETE", f"/api/enforcement/policies/{policy_id}") or {} + + +def list_machines(ctx: ClientContext) -> List[Machine]: + """GET /api/enforcement/machines — every host that has ever checked in.""" + data = _get_json(ctx, "/api/enforcement/machines") + items = data if isinstance(data, list) else data.get("machines", []) + return [Machine.from_dict(m) for m in items] + + +def rename_machine(ctx: ClientContext, machine_id: str, label: str) -> Dict[str, Any]: + """PATCH /api/enforcement/machines/{id} — a human label, not the id.""" + path = f"/api/enforcement/machines/{machine_id}" + return _request_json(ctx, "PATCH", path, json_body={"label": label}) or {} + + +def list_deployments(ctx: ClientContext) -> List[Deployment]: + """GET /api/enforcement/deployments — what every machine is told to run.""" + data = _get_json(ctx, "/api/enforcement/deployments") + items = data if isinstance(data, list) else data.get("deployments", []) + return [Deployment.from_dict(d) for d in items] + + +def get_deployment(ctx: ClientContext, machine_id: str) -> Optional[Deployment]: + """One machine's deployment, or None when nothing has been deployed to it. + + The read half of every read-modify-write. `deploy` is a FULL REPLACE, so a + caller that skips this and sends only what it wants ADDED silently removes + everything else. + """ + for dep in list_deployments(ctx): + if dep.machine_id == machine_id: + return dep + return None + + +def deploy_policies( + ctx: ClientContext, machine_id: str, policies: Sequence[PolicyRef] +) -> Deployment: + """PUT /api/enforcement/deployments/{id} — REPLACES the machine's whole set.""" + path = f"/api/enforcement/deployments/{machine_id}" + body = {"policies": [p.to_dict() for p in policies]} + return Deployment.from_dict(_request_json(ctx, "PUT", path, json_body=body) or {}) + + +def deployment_history(ctx: ClientContext, machine_id: str) -> List[Dict[str, Any]]: + """GET /api/enforcement/deployments/{id}/history — every generation, newest first.""" + path = f"/api/enforcement/deployments/{machine_id}/history" + data = _get_json(ctx, path) + return data if isinstance(data, list) else data.get("history", []) + + +def rollback_deployment(ctx: ClientContext, machine_id: str, deployment: int) -> Deployment: + """POST /api/enforcement/deployments/{id}/rollback — reinstate a past generation. + + Note this mints a NEW generation carrying the old set rather than rewinding + the counter, so the history stays append-only. + """ + path = f"/api/enforcement/deployments/{machine_id}/rollback" + body = {"deployment": deployment} + return Deployment.from_dict(_post_json(ctx, path, body) or {}) + + +def enforcement_summary( + ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None +) -> Dict[str, Any]: + """GET /api/enforcement/summary — coverage from Postgres, decisions from ClickHouse.""" + params = {"hours": hours} + if machine_id: + params["machineId"] = machine_id + return _get_json(ctx, "/api/enforcement/summary", params=params) or {} + + +def decision_timeline( + ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None +) -> Dict[str, Any]: + """GET /api/enforcement/decisions/timeline — hourly deny/instruct/paused bins.""" + params = {"hours": hours} + if machine_id: + params["machineId"] = machine_id + return _get_json(ctx, "/api/enforcement/decisions/timeline", params=params) or {} + + +def compose_policy(ctx: ClientContext, prompt: str) -> Dict[str, Any]: + """POST /api/agent/compose-policy — the assistant drafts a policy from a prompt. + + Dashboard-only, like the rest of the assistant: it is implemented by the + dashboard rather than the API, so there is no `/v1` route behind it. + """ + return _post_json(ctx, "/api/agent/compose-policy", {"prompt": prompt}) or {} diff --git a/fp-cli/fp_cli/commands/fleet_cmds.py b/fp-cli/fp_cli/commands/fleet_cmds.py new file mode 100644 index 000000000..74d47292a --- /dev/null +++ b/fp-cli/fp_cli/commands/fleet_cmds.py @@ -0,0 +1,328 @@ +"""The fleet: fleet list / show / deploy / diff / history / rollback / rename. + +What each machine is TOLD to enforce. Authoring the policies is `fp policies`; +what they actually did is `fp guardrails`. + +## The one thing to understand before reading `deploy` + +`PUT /enforcement/deployments/{id}` REPLACES a machine's whole policy set. There +is no merge and no server-side lock. The dashboard deliberately has no deploy +form for this reason — it edits the machine's own current set instead, because a +form that asks you to re-tick policies silently drops whatever you forget. + +So `deploy` here defaults to a read-modify-write: it reads what the machine runs, +applies `--add`/`--remove`, shows the resulting FULL set, and writes that. +`--set` is the escape hatch for the declarative case and is the only way to say +"exactly these, drop the rest". +""" +from __future__ import annotations + +from typing import List, Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from ..enforcement import RefError, check_race, latest_versions, plan_deploy +from ..errors import ApiError, NotFoundError +from . import _write + +_KEY_MODE_REASON = ( + "the fleet is an operator surface and is not exposed on the versioned API an " + "key authenticates against" +) + + +def fleet_list(ctx: typer.Context) -> None: + """List machines and how many policies each is told to run. + + Shows `machine · label · policies · deployment · last seen`. A machine + appears from its very first check-in, including the poll that finds nothing + deployed — that is exactly the machine you are usually looking for. + Needs `policies:read`. With `--json`: `{machines, deployments}`. + + Example: + + * `fp fleet list` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet", _KEY_MODE_REASON) + cctx = require_auth(state) + machines = api.list_machines(cctx) + deployments = api.list_deployments(cctx) + if output.is_json(): + output.emit_json({ + "machines": [m.to_dict() for m in machines], + "deployments": [d.to_dict() for d in deployments], + }) + return + output.render_fleet(machines, deployments) + + +def fleet_show( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), +) -> None: + """Show exactly what one machine is told to enforce. + + The set shown is the set that exists — read this before a `--set`, because + that flag replaces all of it. Needs `policies:read`. + + Example: + + * `fp fleet show ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet show", _KEY_MODE_REASON) + cctx = require_auth(state) + dep = api.get_deployment(cctx, machine_id) + if output.is_json(): + output.emit_json( + dep.to_dict() if dep + else {"machineId": machine_id, "deployment": None, "policies": []} + ) + return + if dep is None: + output.info(f"{machine_id} has no deployment yet") + return + output.render_machine_policies(machine_id, dep) + + +def fleet_deploy( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + add: Optional[List[str]] = typer.Option( + None, "--add", + help="Add or update a policy: `id`, `id@version`, `id:effect` or `id@version:effect`.", + ), + remove: Optional[List[str]] = typer.Option(None, "--remove", help="Remove a policy by id."), + replace: Optional[List[str]] = typer.Option( + None, "--set", + help="REPLACE the whole set with exactly these. Cannot be combined with --add/--remove.", + ), + create: bool = typer.Option( + False, "--create", + help="Allow deploying to a machine id that has not checked in yet (pre-staging).", + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Change what a machine enforces, showing the full resulting set first. + + `--add`/`--remove` read the machine's current set and apply a delta, so + nothing you did not mention is disturbed. A bare `--add` on a policy the + machine already runs keeps its pinned version rather than silently + upgrading; pass `id@version` to move it. + + `--set` replaces everything — the only way to drop policies you do not name. + + **Concurrency.** The write is a full replace with no server-side lock, so the + CLI records the generation it read and refuses if the result is not exactly + one higher: that means somebody else deployed in between and a replace does + not merge. Needs `policies:write`. With `--json`: the plan plus the resulting + deployment. + + Examples: + + * `fp fleet deploy ci-runner-01 --add no-force-push` + * `fp fleet deploy ci-runner-01 --add prod-guard@1:observe --remove old-rule` + * `fp fleet deploy ci-runner-01 --set no-force-push --set no-secret-echo` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet deploy", _KEY_MODE_REASON) + cctx = require_auth(state) + + if not add and not remove and replace is None: + raise ApiError( + "nothing to do — pass --add, --remove, or --set", + hint="`fp fleet show ` prints the current set", + ) + + # The server accepts a deploy to ANY id — that is how a machine can be + # pre-staged before it ever polls. It also means a typo does not fail: it + # mints a machine nobody owns, carrying policies nobody will collect, and + # the only sign is an extra row in `fleet list`. The dashboard cannot hit + # this because it deploys to a machine picked from a list; a CLI takes free + # text, so the check has to be here. + known = {m.machine_id for m in api.list_machines(cctx)} + if machine_id not in known and not create: + raise NotFoundError( + f"no machine {machine_id!r} has checked in — deploying would create it " + "as a new machine id" + ) + + current = api.get_deployment(cctx, machine_id) + latest = latest_versions(api.list_policies(cctx)) + try: + plan = plan_deploy( + machine_id, + current=current.policies if current else None, + base=current.deployment if current else None, + add=add or (), + remove=remove or (), + replace=replace, + latest=latest, + ) + except RefError as exc: + raise ApiError(str(exc)) + + if plan.is_noop: + if output.is_json(): + output.emit_json({"plan": plan.to_dict(), "deployment": None, "applied": False}) + return + output.info(f"{machine_id} already matches — nothing to deploy") + return + + if not output.is_json(): + output.render_deploy_plan(plan) + _write.confirm( + state, + f"replace {machine_id}'s policy set with the {len(plan.result)} above", + assume_yes=yes, + destructive=bool(plan.removed), + ) + + result = api.deploy_policies(cctx, machine_id, plan.result) + check_race(plan.base, result.deployment) + + if output.is_json(): + output.emit_json({ + "plan": plan.to_dict(), + "deployment": result.to_dict(), + "applied": True, + }) + return + output.success(f"{machine_id} is now on deployment {result.deployment}") + + +def fleet_diff( + ctx: typer.Context, + machine_id: Optional[str] = typer.Argument(None, help="Machine id. Omit for the whole fleet."), +) -> None: + """Show intent vs delivery — what a machine is told to run vs what it last pulled. + + The gap is the interesting part: a machine that has not collected its latest + deployment is not enforcing what the dashboard says it is, and nothing else + surfaces that as a single number. Needs `policies:read`. + + Example: + + * `fp fleet diff` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet diff", _KEY_MODE_REASON) + cctx = require_auth(state) + machines = api.list_machines(cctx) + rows = [] + for m in sorted(machines, key=lambda x: x.machine_id): + if machine_id and m.machine_id != machine_id: + continue + rows.append({ + "machineId": m.machine_id, + "intended": m.deployment, + "delivered": m.applied_deployment, + "drifted": m.drifted, + }) + if output.is_json(): + output.emit_json({"machines": rows}) + return + drifted = [r for r in rows if r["drifted"]] + for r in rows: + mark = "drifted" if r["drifted"] else "in sync" + output.info( + f"{r['machineId']}: intended #{r['intended']} · delivered " + f"#{r['delivered'] if r['delivered'] is not None else '-'} · {mark}" + ) + if drifted: + output.warn(f"{len(drifted)} machine(s) have not collected their latest deployment") + + +def fleet_history( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), +) -> None: + """List a machine's deployment generations, newest first. Needs `policies:read`.""" + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet history", _KEY_MODE_REASON) + cctx = require_auth(state) + entries = api.deployment_history(cctx, machine_id) + if output.is_json(): + output.emit_json({"machineId": machine_id, "history": entries}) + return + if not entries: + output.info(f"{machine_id} has no deployment history") + return + for e in entries: + pols = ", ".join( + f"{p.get('id')}@{p.get('version')}" for p in (e.get("policies") or []) + ) or "(none)" + output.info(f"#{e.get('deployment')} {e.get('updatedAt', '')} {pols}") + + +def fleet_rollback( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + deployment: int = typer.Argument(..., help="The generation to reinstate."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Reinstate a past generation's policy set. + + This mints a NEW generation carrying the old set rather than rewinding the + counter, so history stays append-only. Needs `policies:write`. + + Example: + + * `fp fleet rollback ci-runner-01 3` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet rollback", _KEY_MODE_REASON) + cctx = require_auth(state) + current = api.get_deployment(cctx, machine_id) + _write.confirm( + state, + f"reinstate deployment {deployment} on {machine_id} — this replaces its current set", + assume_yes=yes, + destructive=True, + ) + result = api.rollback_deployment(cctx, machine_id, deployment) + check_race(current.deployment if current else None, result.deployment) + if output.is_json(): + output.emit_json(result.to_dict()) + return + output.success(f"{machine_id} rolled back to the set from #{deployment} (now #{result.deployment})") + + +def fleet_rename( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), + label: str = typer.Argument(..., help="Human-readable label."), +) -> None: + """Give a machine a human label. The id itself never changes. + + Needs `policies:write`. + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet rename", _KEY_MODE_REASON) + cctx = require_auth(state) + res = api.rename_machine(cctx, machine_id, label) + if output.is_json(): + output.emit_json(res) + return + output.success(f"{machine_id} is now labelled {label!r}") + + +def register(app: typer.Typer) -> None: + fleet_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="The fleet and what each machine enforces (list / show / deploy / diff / history / rollback / rename).", + ) + fleet_app.command("list", epilog=GLOBALS_EPILOG)(fleet_list) + fleet_app.command("show", epilog=GLOBALS_EPILOG)(fleet_show) + fleet_app.command("deploy", epilog=GLOBALS_EPILOG)(fleet_deploy) + fleet_app.command("diff", epilog=GLOBALS_EPILOG)(fleet_diff) + fleet_app.command("history", epilog=GLOBALS_EPILOG)(fleet_history) + fleet_app.command("rollback", epilog=GLOBALS_EPILOG)(fleet_rollback) + fleet_app.command("rename", epilog=GLOBALS_EPILOG)(fleet_rename) + app.add_typer(fleet_app, name="fleet") diff --git a/fp-cli/fp_cli/commands/guardrails_cmds.py b/fp-cli/fp_cli/commands/guardrails_cmds.py new file mode 100644 index 000000000..49afbe4ad --- /dev/null +++ b/fp-cli/fp_cli/commands/guardrails_cmds.py @@ -0,0 +1,140 @@ +"""Guardrails: what enforcement actually did. + +The counterpart to `fp fleet`. That command says what the control plane +INTENDED; this says what happened — whether the fleet is really covered, what +got blocked, and which policies earn their place. + +The two halves come from different stores and that is worth knowing when a +number looks wrong: coverage is Postgres (the deployments), while the decision +counts are ClickHouse (hook telemetry the machines reported). A machine can be +deployed-to and silent, or reporting and undeployed, and only the first half +moves when you run `fp fleet deploy`. +""" +from __future__ import annotations + +from typing import Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth + +_KEY_MODE_REASON = ( + "guardrails reads an operator surface that is not exposed on the versioned " + "API an key authenticates against" +) + + +def _hours(since: str) -> int: + """`24h`/`7d`/`60m` → hours. The CLI's `--since` vocabulary, one window only.""" + table = {"15m": 1, "1h": 1, "6h": 6, "24h": 24, "7d": 168} + if since in table: + return table[since] + # A usage error, not a runtime one: exit 2 like every other bad flag value, + # rather than the exit 1 an uncaught ValueError would produce. + raise typer.BadParameter( + f"invalid --since value {since!r}; choose one of {', '.join(table)}" + ) + + +def guardrails_summary( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), +) -> None: + """Coverage, blocks, and the per-policy table for a window. + + Shows evaluated/blocked totals, how many machines are enforcing versus + merely reporting, a 24-bin sparkline of denies, and each policy's + fired/blocked/instructed/p95. + + A `(no policy)` row is normal, not a gap: most evaluations are allows that + no policy objected to, and the row keeps the denominator on screen — "14 + blocked" means little without the 933 it came from. + + Needs `policies:read`. With `--json`: the server summary plus the timeline. + + Examples: + + * `fp guardrails` + * `fp guardrails --since 7d --machine ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "guardrails", _KEY_MODE_REASON) + cctx = require_auth(state) + hours = _hours(since) + summary = api.enforcement_summary(cctx, hours=hours, machine_id=machine) + timeline = api.decision_timeline(cctx, hours=hours, machine_id=machine) + if output.is_json(): + output.emit_json({"summary": summary, "timeline": timeline}) + return + output.render_guardrails(summary, timeline) + + +def guardrails_timeline( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), +) -> None: + """The deny/instruct/paused series on its own, one row per bin. + + Needs `policies:read`. With `--json`: the server's timeline verbatim. + + Example: + + * `fp guardrails timeline --since 24h` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "guardrails timeline", _KEY_MODE_REASON) + cctx = require_auth(state) + data = api.decision_timeline(cctx, hours=_hours(since), machine_id=machine) + if output.is_json(): + output.emit_json(data) + return + points = (data.get("series") or [{}])[0].get("points") or [] + if not points: + output.info("no decisions recorded in this window") + return + output.info("denies " + output.sparkline([p.get("deny", 0) for p in points])) + output.info("total " + output.sparkline([p.get("total", 0) for p in points])) + + +def guardrails_policies( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), +) -> None: + """Just the per-policy decision table. Needs `policies:read`.""" + state: AppState = ctx.obj + deny_in_key_mode(state, "guardrails policies", _KEY_MODE_REASON) + cctx = require_auth(state) + summary = api.enforcement_summary(cctx, hours=_hours(since), machine_id=machine) + if output.is_json(): + output.emit_json({"policies": summary.get("policies") or []}) + return + output.render_guardrails(summary, None) + + +def register(app: typer.Typer) -> None: + def _default( + ctx: typer.Context, + since: str = typer.Option("24h", "--since", help="Window: 15m, 1h, 6h, 24h, 7d."), + machine: Optional[str] = typer.Option(None, "--machine", help="Scope to one machine id."), + ) -> None: + """What enforcement actually did. Bare `fp guardrails` is the summary.""" + if ctx.invoked_subcommand is None: + guardrails_summary(ctx, since=since, machine=machine) + + guardrails_app = typer.Typer( + no_args_is_help=False, + invoke_without_command=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="What enforcement actually did (summary / timeline / policies).", + ) + guardrails_app.callback(invoke_without_command=True)(_default) + guardrails_app.command("summary", epilog=GLOBALS_EPILOG)(guardrails_summary) + guardrails_app.command("timeline", epilog=GLOBALS_EPILOG)(guardrails_timeline) + guardrails_app.command("policies", epilog=GLOBALS_EPILOG)(guardrails_policies) + app.add_typer(guardrails_app, name="guardrails") diff --git a/fp-cli/fp_cli/commands/policies_cmds.py b/fp-cli/fp_cli/commands/policies_cmds.py new file mode 100644 index 000000000..b7838c1f4 --- /dev/null +++ b/fp-cli/fp_cli/commands/policies_cmds.py @@ -0,0 +1,206 @@ +"""Cloud-managed policies: policies list / show / publish / enable / disable / delete. + +Where a policy VERSION is written. Deploying one to a machine is `fp fleet`, and +seeing what it actually did is `fp guardrails` — three commands because they are +three jobs, done by different people at different times, exactly as the +dashboard splits them across three pages. + +Publishing mints a new version and changes nothing on any machine. That is the +single most surprising thing here, so every success path says so. +""" +from __future__ import annotations + +from typing import Optional + +import typer + +from .. import client as api +from .. import output +from .._context import GLOBALS_EPILOG, AppState, deny_in_key_mode, require_auth +from ..enforcement import RefError, read_source +from ..errors import ApiError, NotFoundError +from . import _write + +#: Every command here is session-only. These endpoints are ROOT-ONLY on the +#: server — deliberately absent from `/v1`, because `/v1` is internet-facing and +#: publish/deploy/rollback are operator writes. Failing here beats translating a +#: path that would 404 with no explanation. +_KEY_MODE_REASON = ( + "cloud-managed policies are an operator surface and are not exposed on the " + "versioned API an key authenticates against" +) + + +def policies_list(ctx: typer.Context) -> None: + """List published policies — newest version of each, and its state. + + Shows `policy · version · state · description`. `state` is active, disabled + (kept but not enforced) or archived (deleted; machines already carrying it + keep it until redeployed). Needs `policies:read`. With `--json`: the server's + policy list verbatim. + + Example: + + * `fp policies list` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies", _KEY_MODE_REASON) + cctx = require_auth(state) + items = api.list_policies(cctx) + if output.is_json(): + output.emit_json({"policies": [p.to_dict() for p in items]}) + return + output.render_policies(items) + + +def policies_show( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), +) -> None: + """Show one policy, including its full source. + + Needs `policies:read`. With `--json`: the policy object with `source`. + + Example: + + * `fp policies show no-force-push` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies show", _KEY_MODE_REASON) + cctx = require_auth(state) + match = next((p for p in api.list_policies(cctx) if p.id == policy_id), None) + if match is None: + raise NotFoundError(f"no policy named {policy_id}") + if output.is_json(): + output.emit_json(match.to_dict()) + return + output.render_policy_published(match) + if match.source: + output.info(match.source) + + +def policies_publish( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id (letters, numbers, '.', '_', '-')."), + source: Optional[str] = typer.Argument( + None, + help="Path to the policy source, @path, or - for stdin. Omit to paste it.", + ), + description: str = typer.Option("", "--description", help="One-line description."), +) -> None: + """Publish a policy — mints a NEW VERSION; it never edits one in place. + + Source can come from a path, `@path`, a pipe, `-`, or an interactive paste + when you give none and stdin is a terminal. + + **Publishing deploys nothing.** A new version sits unused until + `fp fleet deploy` puts it on a machine. Needs `policies:write`. + With `--json`: the created version `{id, version, sha256, ...}`. + + Examples: + + * `fp policies publish no-force-push ./rule.mjs` + * `cat rule.mjs | fp policies publish no-force-push` + * `fp policies publish no-force-push -` — read stdin explicitly + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies publish", _KEY_MODE_REASON) + cctx = require_auth(state) + + def _paste_prompt() -> None: + output.hint("paste the policy source, then press Ctrl-D") + + try: + text = read_source(source, prompt=_paste_prompt) + except RefError as exc: + raise ApiError(str(exc)) + if not text.strip(): + raise ApiError("policy source is empty — nothing to publish") + + created = api.publish_policy(cctx, policy_id, text, description) + if output.is_json(): + output.emit_json(created.to_dict()) + return + output.render_policy_published(created, deployed_to=1) + + +def policies_enable( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), +) -> None: + """Re-enable a disabled policy. Needs `policies:write`.""" + state: AppState = ctx.obj + deny_in_key_mode(state, "policies enable", _KEY_MODE_REASON) + cctx = require_auth(state) + res = api.set_policy_enabled(cctx, policy_id, True) + if output.is_json(): + output.emit_json(res) + return + output.success(f"enabled {policy_id}") + + +def policies_disable( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Disable a policy — machines stop enforcing it, the versions are kept. + + Reversible with `policies enable`. Needs `policies:write`. + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies disable", _KEY_MODE_REASON) + cctx = require_auth(state) + _write.confirm(state, f"disable {policy_id} — machines stop enforcing it", + assume_yes=yes) + res = api.set_policy_enabled(cctx, policy_id, False) + if output.is_json(): + output.emit_json(res) + return + output.success(f"disabled {policy_id}") + + +def policies_delete( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Archive a policy. This cannot be undone from the CLI. + + Archiving hides it from `policies list` and from future deployments. A + machine already carrying it keeps enforcing it until something redeploys — + deleting is not a way to stop enforcement everywhere, and `disable` is. + + Needs `policies:write`. + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies delete", _KEY_MODE_REASON) + cctx = require_auth(state) + _write.confirm( + state, + f"archive {policy_id} — machines already carrying it keep enforcing until " + "redeployed, and `fp policies disable` is what stops enforcement", + assume_yes=yes, + destructive=True, + ) + res = api.delete_policy(cctx, policy_id) + if output.is_json(): + output.emit_json(res) + return + output.success(f"archived {policy_id}") + + +def register(app: typer.Typer) -> None: + policies_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="Write and manage cloud-managed policies (list / show / publish / enable / disable / delete).", + ) + policies_app.command("list", epilog=GLOBALS_EPILOG)(policies_list) + policies_app.command("show", epilog=GLOBALS_EPILOG)(policies_show) + policies_app.command("publish", epilog=GLOBALS_EPILOG)(policies_publish) + policies_app.command("enable", epilog=GLOBALS_EPILOG)(policies_enable) + policies_app.command("disable", epilog=GLOBALS_EPILOG)(policies_disable) + policies_app.command("delete", epilog=GLOBALS_EPILOG)(policies_delete) + app.add_typer(policies_app, name="policies") diff --git a/fp-cli/fp_cli/enforcement.py b/fp-cli/fp_cli/enforcement.py new file mode 100644 index 000000000..9ce6b123d --- /dev/null +++ b/fp-cli/fp_cli/enforcement.py @@ -0,0 +1,269 @@ +"""The logic behind `fp policies` and `fp fleet`, with no HTTP in it. + +Everything here is pure so it can be tested without a server, because the two +things most likely to lose someone's work are decided here rather than in a +handler: what a deploy's resulting policy set is, and whether somebody else +wrote while we were deciding. + +## Why a diff at all + +`PUT /enforcement/deployments/{id}` is a FULL REPLACE. Send `{"policies": [a]}` +to a machine running `[a, b, c]` and it now runs `[a]` — permanently, with a +200 and no warning. The dashboard never exposes that as a form for exactly this +reason (`app/(dashboard)/[org]/enforcement/page.tsx`: "a form that asks you to +re-pick a machine and re-tick its policies silently drops whatever you forget +to tick"). It edits the machine's own current set instead. + +So `--add`/`--remove` are the CLI's equivalent: read the current set, apply the +delta, write the whole thing back. `--set` remains for the declarative case, +and is the only way to express "exactly these, drop the rest". + +## Why the race check + +There is no optimistic locking on that endpoint. The dashboard detects a +collision AFTER the fact by checking the returned generation is exactly +`base + 1` (`lib/enforcementFleet.ts`, `staleness()`). The same check here is +what stops two operators silently overwriting each other — the CLI refuses and +re-reads rather than reporting a success that erased somebody. +""" +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from .errors import ApiError +from .models import PolicyRef, PolicyVersion + +VALID_EFFECTS = ("enforce", "observe") + +#: `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]+))?$") + + +class RefError(ValueError): + """A malformed `--add` / `--remove` / `--set` token, with the reason.""" + + +def parse_ref(token: str) -> Tuple[str, Optional[int], Optional[str]]: + """``"id@2:observe"`` → ``("id", 2, "observe")``; omitted parts are None. + + Version and effect are resolved later — ``None`` means "whatever is current", + which is not the same as a default, because for an existing deployment the + current value is the deployed one rather than the newest one. + """ + token = token.strip() + if not token: + raise RefError("empty policy reference") + m = _REF.match(token) + if not m: + raise RefError( + f"{token!r} is not a policy reference — expected id, id@version, " + "id:effect or id@version:effect" + ) + effect = m.group("effect") + if effect is not None and effect not in VALID_EFFECTS: + raise RefError( + f"{token!r} has effect {effect!r}; expected one of {', '.join(VALID_EFFECTS)}" + ) + version = m.group("version") + return m.group("id"), (int(version) if version is not None else None), effect + + +@dataclass +class DeployPlan: + """The resulting set, and how it differs from what the machine runs now. + + `result` is what will be PUT — the whole set, because that is what the + endpoint takes. The three lists exist to be shown to a human before it is. + """ + + machine_id: str + base: Optional[int] + result: List[PolicyRef] + added: List[PolicyRef] + removed: List[PolicyRef] + changed: List[Tuple[PolicyRef, PolicyRef]] + unchanged: List[PolicyRef] + + @property + def is_noop(self) -> bool: + return not (self.added or self.removed or self.changed) + + def to_dict(self) -> Dict[str, object]: + return { + "machineId": self.machine_id, + "base": self.base, + "result": [p.to_dict() for p in self.result], + "added": [p.to_dict() for p in self.added], + "removed": [p.to_dict() for p in self.removed], + "changed": [{"from": a.to_dict(), "to": b.to_dict()} for a, b in self.changed], + "unchanged": [p.to_dict() for p in self.unchanged], + "noop": self.is_noop, + } + + +def latest_versions(policies: Iterable[PolicyVersion]) -> Dict[str, int]: + """`{policy_id: newest published version}`, ignoring archived policies.""" + out: Dict[str, int] = {} + for p in policies: + if p.archived: + continue + if p.version > out.get(p.id, 0): + out[p.id] = p.version + return out + + +def resolve_ref( + token: str, + *, + latest: Dict[str, int], + current: Dict[str, PolicyRef], +) -> PolicyRef: + """Turn one `--add`/`--set` token into a concrete `PolicyRef`. + + Version: explicit wins; else the version already deployed (so `--add` on a + policy the machine already runs is a no-op rather than a silent upgrade); + else the newest published. + + Effect: explicit wins; else the deployed effect; else `enforce`, matching + the server's own default for an omitted effect. + """ + pid, version, effect = parse_ref(token) + existing = current.get(pid) + if version is None: + version = existing.version if existing else latest.get(pid) + if version is None: + raise RefError( + f"no published policy named {pid!r} — run `fp policies list` to see what exists" + ) + if effect is None: + effect = existing.effect if existing else "enforce" + return PolicyRef(id=pid, version=version, effect=effect) + + +def plan_deploy( + machine_id: str, + *, + current: Optional[Sequence[PolicyRef]], + base: Optional[int], + add: Sequence[str] = (), + remove: Sequence[str] = (), + replace: Optional[Sequence[str]] = None, + latest: Optional[Dict[str, int]] = None, +) -> DeployPlan: + """Compute the full resulting set, plus the diff to show before writing. + + `replace` (`--set`) is exclusive with `add`/`remove`: mixing "these exactly" + with "these as well" has no single obvious reading, and guessing one would + be guessing about somebody's fleet. + """ + latest = latest or {} + current_list = list(current or []) + current_map = {p.id: p for p in current_list} + + if replace is not None: + if add or remove: + raise RefError("--set replaces the whole set; it cannot be combined with --add/--remove") + result_map = {} + for token in replace: + ref = resolve_ref(token, latest=latest, current=current_map) + result_map[ref.id] = ref + else: + result_map = dict(current_map) + for token in remove: + pid, _, _ = parse_ref(token) + if pid not in result_map: + raise RefError( + f"{pid!r} is not deployed to {machine_id} — nothing to remove" + ) + del result_map[pid] + for token in add: + ref = resolve_ref(token, latest=latest, current=current_map) + result_map[ref.id] = ref + + result = sorted(result_map.values(), key=lambda p: p.id) + added, removed, changed, unchanged = [], [], [], [] + for pid, ref in sorted(result_map.items()): + was = current_map.get(pid) + if was is None: + added.append(ref) + elif (was.version, was.effect) != (ref.version, ref.effect): + changed.append((was, ref)) + else: + unchanged.append(ref) + for pid, was in sorted(current_map.items()): + if pid not in result_map: + removed.append(was) + + return DeployPlan( + machine_id=machine_id, + base=base, + result=result, + added=added, + removed=removed, + changed=changed, + unchanged=unchanged, + ) + + +def check_race(base: Optional[int], returned: int) -> None: + """Raise when a deploy landed on top of somebody else's. + + `base` is the generation read before the write. A clean write is exactly + `base + 1`; anything else means another writer got in between, and their + change is already gone — a full replace does not merge. Reporting success + here is how the CLI would become the easiest way to silently overwrite a + colleague. + """ + if base is None: + return + if returned != base + 1: + raise ApiError( + f"deployment {returned} landed where {base + 1} was expected — someone " + "else deployed to this machine while this command was deciding, and a " + "deploy REPLACES the whole set rather than merging.", + hint="re-run `fp fleet show ` to see the current set, then deploy again", + ) + + +def read_source( + value: Optional[str], + *, + stdin=None, + isatty: Optional[bool] = None, + prompt=None, +) -> str: + """Resolve policy source from a path, `@path`, `-`, a pipe, or a paste. + + The five shapes exist because the thing being supplied is a file that people + have in five different places: on disk, in a pipeline, in a heredoc, or on + the clipboard. Refusing the clipboard would mean "save it to a file first" + for the most common one-off case. + + A bare `-` and a piped stdin are the same read; the difference is only + whether the user said so. On a TTY with nothing given we prompt, because + silently blocking on stdin is indistinguishable from a hang. + """ + stream = sys.stdin if stdin is None else stdin + tty = stream.isatty() if isatty is None else isatty + + if value == "-": + return stream.read() + if value: + path = value[1:] if value.startswith("@") else value + try: + with open(path, "r", encoding="utf-8") as fh: + return fh.read() + except FileNotFoundError: + raise RefError(f"no such file: {path}") + except OSError as exc: + raise RefError(f"cannot read {path}: {exc}") + if not tty: + return stream.read() + if prompt is not None: + prompt() + return stream.read() diff --git a/fp-cli/fp_cli/models.py b/fp-cli/fp_cli/models.py index 44206e9d2..a8d21240f 100644 --- a/fp-cli/fp_cli/models.py +++ b/fp-cli/fp_cli/models.py @@ -701,3 +701,173 @@ def from_dict(cls, d: Dict[str, Any]) -> "AuditFinding": assigned_to=d.get("assigned_to"), issue_id=d.get("issue_id"), ) + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── +# +# Three nouns, and keeping them apart is the whole model. A POLICY VERSION is +# written; a DEPLOYMENT says which versions a MACHINE is told to run. The +# dashboard splits them across three pages for the same reason — authoring is a +# code task, deploying is a fleet decision, and observing is neither. + + +@dataclass +class PolicyVersion: + """One published version of a policy. Versions are minted, never edited.""" + + id: str + version: int + description: str + sha256: str + source: Optional[str] + created_at: str + created_by: Optional[str] + disabled: bool + archived: bool + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PolicyVersion": + return cls( + id=str(d.get("id", "")), + version=_as_int(d.get("version"), 0), + description=str(d.get("description", "") or ""), + sha256=str(d.get("sha256", "") or ""), + source=d.get("source"), + created_at=str(d.get("createdAt", d.get("created_at", "")) or ""), + created_by=d.get("createdBy", d.get("created_by")), + disabled=bool(d.get("disabled", False)), + archived=bool(d.get("archived", False)), + ) + + def to_dict(self) -> Dict[str, Any]: + """The server's own shape. `vars()` would leak Python snake_case into a + contract that is camelCase everywhere else, which is a difference a + harness discovers at runtime rather than in review.""" + return { + "id": self.id, "version": self.version, "description": self.description, + "sha256": self.sha256, "source": self.source, "createdAt": self.created_at, + "createdBy": self.created_by, "disabled": self.disabled, + "archived": self.archived, + } + + +@dataclass +class PolicyRef: + """A policy inside a deployment: which version, and how it acts. + + ``effect`` is ``enforce`` or ``observe``. The server defaults an omitted + effect to ``enforce``; the CLI always sends it explicitly so a deployment + read back and written again cannot silently change meaning. + """ + + id: str + version: int + effect: str = "enforce" + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PolicyRef": + return cls( + id=str(d.get("id", "")), + version=_as_int(d.get("version"), 0), + effect=str(d.get("effect") or "enforce"), + ) + + def to_dict(self) -> Dict[str, Any]: + return {"id": self.id, "version": self.version, "effect": self.effect} + + @property + def label(self) -> str: + return f"{self.id}@{self.version}:{self.effect}" + + +@dataclass +class Deployment: + """What one machine is told to enforce, and which generation that is. + + ``deployment`` is the generation counter. It is the CLI's only defence + against a concurrent write: ``PUT`` is a FULL REPLACE with no server-side + lock, so a deploy that returns anything other than ``base + 1`` means + somebody else wrote between the read and the write. + """ + + machine_id: str + deployment: int + policies: List[PolicyRef] + updated_at: str + updated_by: Optional[str] + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Deployment": + return cls( + machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""), + deployment=_as_int(d.get("deployment"), 0), + policies=[PolicyRef.from_dict(p) for p in (d.get("policies") or [])], + updated_at=str(d.get("updatedAt", d.get("updated_at", "")) or ""), + updated_by=d.get("updatedBy", d.get("updated_by")), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "machineId": self.machine_id, "deployment": self.deployment, + "policies": [p.to_dict() for p in self.policies], + "updatedAt": self.updated_at, "updatedBy": self.updated_by, + } + + +@dataclass +class Machine: + """A host that has checked in. Machines enrol themselves on their first poll. + + Two generation numbers, and the gap between them is the whole point of + `fleet diff`: ``deployment`` is what the control plane INTENDED for this + machine, ``applied_deployment`` is what the machine last actually collected. + A machine can sit on an old set indefinitely and nothing else says so. + """ + + machine_id: str + label: Optional[str] + last_seen: Optional[int] # epoch ms — the server sends a number, not ISO + last_check_in: Optional[int] + deployment: Optional[int] # intended + applied_deployment: Optional[int] # delivered + applied_at: Optional[int] + deployed: bool + policy_count: int + event_count: int + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "Machine": + def _num(key: str) -> Optional[int]: + v = d.get(key) + return int(v) if isinstance(v, (int, float)) else None + + return cls( + machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""), + label=d.get("label"), + last_seen=_num("lastSeen"), + last_check_in=_num("lastCheckIn"), + deployment=_num("deployment"), + applied_deployment=_num("appliedDeployment"), + applied_at=_num("appliedAt"), + deployed=bool(d.get("deployed", False)), + policy_count=_as_int(d.get("policyCount"), 0), + event_count=_as_int(d.get("eventCount"), 0), + ) + + def to_dict(self) -> Dict[str, Any]: + """Server shape plus `drifted` — the one field the CLI computes.""" + return { + "machineId": self.machine_id, "label": self.label, + "lastSeen": self.last_seen, "lastCheckIn": self.last_check_in, + "deployment": self.deployment, "appliedDeployment": self.applied_deployment, + "appliedAt": self.applied_at, "deployed": self.deployed, + "policyCount": self.policy_count, "eventCount": self.event_count, + "drifted": self.drifted, + } + + @property + def drifted(self) -> bool: + """True when the machine has not collected what it was last told to run.""" + if self.deployment is None: + return False + return self.applied_deployment is None or self.applied_deployment < self.deployment diff --git a/fp-cli/fp_cli/output.py b/fp-cli/fp_cli/output.py index c90546b2f..27c4023e4 100644 --- a/fp-cli/fp_cli/output.py +++ b/fp-cli/fp_cli/output.py @@ -474,6 +474,7 @@ def version_banner(version: str) -> None: ("evals", "List scored agent evaluations.", "--aggregate"), ("errors", "List errored events.", "--aggregate"), ("usage", "Show current org usage for the metering window.", ""), + ("guardrails", "What enforcement actually blocked.", "summary timeline policies"), ]), ("MANAGE", [ ("orgs", "Switch and inspect the active org.", "list switch current perms"), @@ -489,6 +490,8 @@ def version_banner(version: str) -> None: ("audits", "Schedule audits and triage their findings.", "list show create edit delete run runs findings context-*"), ("issues", "Triage and resolve issues.", "list count show ack assign resolve comment subscribe open"), ("settings", "View and change org settings.", "list schema set"), + ("policies", "Write cloud-managed policies.", "list show publish enable disable delete"), + ("fleet", "Deploy policies to machines.", "list show deploy diff history rollback rename"), ]), ("TOOLS", [ ("list", "List distinct values behind the filter dropdowns.", ""), @@ -5769,3 +5772,235 @@ def _field_cell(name: str, value: Any) -> str: if isinstance(value, (dict, list)): return _json.dumps(value, ensure_ascii=False) return _cell(value) + + +# ── Cloud-managed enforcement ──────────────────────────────────────────────── + + +_EFFECT_STYLE = {"enforce": theme.SUCCESS, "observe": theme.AMBER} + +#: Eight levels is what a terminal row can show without becoming a chart. The +#: timeline is 24 hourly bins, so the whole day fits on one line beside a label. +_SPARK = "▁▂▃▄▅▆▇█" + + +def sparkline(values: Sequence[float]) -> str: + """A one-line bar strip. Flat-zero renders as the lowest block, not blank — + "nothing was blocked" and "no data" are different answers and must not look + the same.""" + vals = [max(0.0, float(v or 0)) for v in values] + if not vals: + return "" + peak = max(vals) + if peak <= 0: + return _SPARK[0] * len(vals) + return "".join(_SPARK[min(len(_SPARK) - 1, int(v / peak * (len(_SPARK) - 1)))] for v in vals) + + +def _effect(effect: str) -> Text: + return Text(effect, style=_EFFECT_STYLE.get(effect, theme.TEXT_DIM)) + + +def _policy_cell(ref: Any) -> Text: + t = Text(ref.id, style=theme.TEXT) + t.append(f" v{ref.version}", style=theme.TEXT_DIM) + return t + + +def render_policies(items: Sequence[Any]) -> None: + """``fp policies`` — every published policy, newest version of each.""" + rows = [] + for p in sorted(items, key=lambda x: x.id): + state = Text("active", style=theme.SUCCESS) + if p.archived: + state = Text("archived", style=theme.FAINT) + elif p.disabled: + state = Text("disabled", style=theme.AMBER) + rows.append([ + Text(p.id, style=theme.TEXT), + Text(f"v{p.version}", style=theme.TEXT_DIM), + state, + Text(p.description or "", style=theme.TEXT_DIM), + ]) + title = Text() + title.append("policies", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(rows)), style="bold white") + render_list_panel("policies", header=["policy", "version", "state", "description"], + rows=rows, days=set(), order=None, + empty_message="no policies published — `fp policies publish `", + last_col="ellipsis", title=title) + + +def render_policy_published(p: Any, *, deployed_to: int = 0) -> None: + """``fp policies publish`` — a new VERSION was minted, not an edit. + + The deployed-elsewhere note is the point: publishing changes nothing on any + machine until it is deployed, and an author who assumes otherwise ships a + policy that is never enforced. + """ + line1 = Text(p.id, style=f"bold {theme.TEXT}") + line1.append(f" v{p.version}", style=theme.ACCENT) + line2 = Text() + line2.append("sha256 ", style=theme.LABEL) + line2.append((p.sha256 or "")[:12] + "…", style=theme.TEXT_DIM) + body = [line1, line2] + if deployed_to: + note = Text() + note.append(f"v{p.version} is not deployed anywhere yet", style=theme.AMBER) + body.append(note) + hint_line = Text() + hint_line.append("deploy with ", style=theme.LABEL) + hint_line.append(f"fp fleet deploy --add {p.id}@{p.version}", style=theme.ACCENT) + body.append(hint_line) + card = Panel(Group(*body), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("policy published", style=f"bold {theme.SUCCESS}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_fleet(machines: Sequence[Any], deployments: Sequence[Any]) -> None: + """``fp fleet`` — every machine, what it is told to run, and whether it has it. + + ``deployment`` is intent and ``applied`` is delivery. Showing both is the + point: a machine can be deployed-to and still enforcing an older set, and + nothing else in the CLI surfaces that gap. + """ + rows = [] + for m in sorted(machines, key=lambda x: x.machine_id): + applied = Text( + f"#{m.applied_deployment}" if m.applied_deployment is not None else "—", + style=theme.AMBER if m.drifted else theme.TEXT_DIM, + ) + rows.append([ + Text(m.machine_id, style=theme.TEXT), + Text(m.label or "-", style=theme.TEXT_DIM), + Text(str(m.policy_count), style=theme.TEXT if m.policy_count else theme.FAINT), + Text(f"#{m.deployment}" if m.deployment is not None else "—", style=theme.TEXT_DIM), + applied, + Text("drifted" if m.drifted else ("ok" if m.deployed else "—"), + style=theme.AMBER if m.drifted else (theme.SUCCESS if m.deployed else theme.FAINT)), + ]) + title = Text() + title.append("fleet", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(len(rows)), style="bold white") + render_list_panel("fleet", + header=["machine", "label", "policies", "intended", "applied", "state"], + rows=rows, days=set(), order=None, + empty_message="no machines have checked in yet", + last_col="ellipsis", title=title) + + +def render_machine_policies(machine_id: str, dep: Any) -> None: + """``fp fleet show`` — the set a machine is told to run, and nothing more. + + Deliberately NOT the deploy-plan renderer: that one talks about a change + ("2 policies after this change", "first deployment"), which is a lie on a + read-only view and exactly the kind of wrong-but-plausible text this repo + keeps producing. + """ + lines = [] + for p in sorted(dep.policies, key=lambda x: x.id): + t = Text(" ", style=theme.FAINT) + t.append(p.id, style=theme.TEXT) + t.append(f" v{p.version}", style=theme.TEXT_DIM) + t.append(" ") + t.append_text(_effect(p.effect)) + lines.append(t) + if not lines: + lines = [Text(" (no policies deployed)", style=theme.FAINT)] + head = Text(machine_id, style=f"bold {theme.TEXT}") + head.append(f" · deployment #{dep.deployment}", style=theme.TEXT_DIM) + card = Panel(Group(head, Text(), *lines), box=ROUNDED, border_style=theme.ACCENT, + title=Text("deployed policies", style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_deploy_plan(plan: Any, *, applied: bool = False) -> None: + """The signature view: the FULL resulting set, with the diff marked. + + Unchanged rows are shown on purpose. The endpoint replaces everything, so + the set on screen is the set that will exist — hiding the untouched rows + would hide exactly the ones a mistake silently drops. + """ + 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) + 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) + 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) + if not lines: + lines = [Text(" (no policies)", style=theme.FAINT)] + + footer = Text() + footer.append(f"{len(plan.result)} ", style="bold white") + footer.append("policies after this change", style=theme.LABEL) + lines.append(Text()) + lines.append(footer) + + head = Text(plan.machine_id, style=f"bold {theme.TEXT}") + if plan.base is not None: + head.append(f" · deployment {plan.base} → {plan.base + 1}", style=theme.TEXT_DIM) + else: + head.append(" · first deployment", style=theme.TEXT_DIM) + border = theme.SUCCESS if applied else theme.ACCENT + card = Panel(Group(head, Text(), *lines), box=ROUNDED, border_style=border, + title=Text("deployed" if applied else "deploy plan", style=f"bold {border}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + + +def render_guardrails(summary: dict, timeline: Optional[dict] = None) -> None: + """``fp guardrails`` — what actually happened, as opposed to what was intended.""" + totals = summary.get("totals") or {} + stat = Text() + stat.append(str(totals.get("evaluated", 0)), style="bold white") + stat.append(" evaluated ", style=theme.LABEL) + stat.append(str(totals.get("blocked", 0)), style=f"bold {theme.ERROR}") + stat.append(" blocked ", style=theme.LABEL) + stat.append(f"{totals.get('enforcingMachines', 0)}/{totals.get('reportingMachines', 0)}", + style="bold white") + stat.append(" machines enforcing", style=theme.LABEL) + body = [stat] + + if timeline: + series = (timeline.get("series") or [{}])[0].get("points") or [] + denies = [p.get("deny", 0) for p in series] + if denies: + spark = Text() + spark.append("denies ", style=theme.LABEL) + spark.append(sparkline(denies), style=theme.ERROR) + body.append(spark) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text(f"guardrails · {summary.get('hours', 24)}h", + style=f"bold {theme.ACCENT}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))) + + rows = [] + for p in summary.get("policies") or []: + rows.append([ + Text(str(p.get("policy") or "-"), style=theme.TEXT), + Text(str(p.get("fired", 0)), style=theme.TEXT_DIM), + Text(str(p.get("blocked", 0)), + style=theme.ERROR if p.get("blocked") else theme.FAINT), + Text(str(p.get("instructed", 0)), + style=theme.AMBER if p.get("instructed") else theme.FAINT), + Text(f"{p.get('p95Ms', 0)}ms", style=theme.TEXT_DIM), + ]) + render_list_panel("guardrails", header=["policy", "fired", "blocked", "instructed", "p95"], + rows=rows, days=set(), order=None, + empty_message="no decisions recorded in this window", + last_col="ellipsis", title=None) diff --git a/fp-cli/tests/test_enforcement_logic.py b/fp-cli/tests/test_enforcement_logic.py new file mode 100644 index 000000000..29ffb53a7 --- /dev/null +++ b/fp-cli/tests/test_enforcement_logic.py @@ -0,0 +1,244 @@ +"""The deploy planner, the race check, and source resolution. + +These are tested hard because they are the two places this feature can destroy +something: `PUT /enforcement/deployments/{id}` is a FULL REPLACE with no +server-side lock, so a wrong resulting set is a permanent silent undeploy, and a +missed race is somebody else's change gone with a 200 on screen. +""" +from __future__ import annotations + +import io + +import pytest + +from fp_cli.enforcement import ( + DeployPlan, + RefError, + check_race, + latest_versions, + parse_ref, + plan_deploy, + read_source, + resolve_ref, +) +from fp_cli.errors import ApiError +from fp_cli.models import PolicyRef, PolicyVersion + + +def ref(pid, version=1, effect="enforce"): + return PolicyRef(id=pid, version=version, effect=effect) + + +def pv(pid, version=1, archived=False): + return PolicyVersion( + id=pid, version=version, description="", sha256="", source=None, + created_at="", created_by=None, disabled=False, archived=archived, + ) + + +# ── parsing ────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "token,expected", + [ + ("a", ("a", None, None)), + ("a@3", ("a", 3, None)), + ("a:observe", ("a", None, "observe")), + ("a@3:observe", ("a", 3, "observe")), + ("a@3:enforce", ("a", 3, "enforce")), + ("no-force-push.v2_x", ("no-force-push.v2_x", None, None)), + ], +) +def test_parse_ref_shapes(token, expected): + assert parse_ref(token) == expected + + +@pytest.mark.parametrize("token", ["", " ", "a@", "a@x", "a:", "a:enforced", "a b", "a@1:bad"]) +def test_parse_ref_rejects_junk(token): + with pytest.raises(RefError): + parse_ref(token) + + +def test_an_unknown_effect_names_the_valid_ones(): + """The message has to say what IS allowed — 'invalid effect' helps nobody.""" + with pytest.raises(RefError, match="enforce, observe"): + parse_ref("a:audit") + + +# ── version and effect resolution ──────────────────────────────────────────── + + +def test_add_of_an_already_deployed_policy_keeps_its_version(): + """`--add` must not silently upgrade. + + A machine pinned to v1 while v3 exists is pinned deliberately. Treating a + bare `--add` as "give me the newest" would roll the fleet forward on a + command whose author was only reordering. + """ + got = resolve_ref("a", latest={"a": 3}, current={"a": ref("a", 1)}) + assert (got.version, got.effect) == (1, "enforce") + + +def test_add_of_a_new_policy_takes_the_latest_version(): + got = resolve_ref("a", latest={"a": 3}, current={}) + assert got.version == 3 + + +def test_an_explicit_version_always_wins(): + got = resolve_ref("a@2", latest={"a": 3}, current={"a": ref("a", 1)}) + assert got.version == 2 + + +def test_effect_is_inherited_then_defaults_to_enforce(): + assert resolve_ref("a", latest={"a": 1}, current={"a": ref("a", 1, "observe")}).effect == "observe" + assert resolve_ref("a", latest={"a": 1}, current={}).effect == "enforce" + assert resolve_ref("a:observe", latest={"a": 1}, current={}).effect == "observe" + + +def test_an_unpublished_policy_is_refused_before_any_write(): + with pytest.raises(RefError, match="no published policy"): + resolve_ref("ghost", latest={"a": 1}, current={}) + + +def test_latest_versions_ignores_archived(): + assert latest_versions([pv("a", 1), pv("a", 3, archived=True), pv("b", 2)]) == {"a": 1, "b": 2} + + +# ── the planner: the thing that decides what gets written ──────────────────── + + +def test_add_preserves_everything_already_deployed(): + """The whole reason --add exists. A full replace built from the delta alone + would drop `b` and `c` here, permanently, with a 200.""" + plan = plan_deploy( + "m", current=[ref("b"), ref("c")], base=4, add=["a"], latest={"a": 1}, + ) + assert [p.id for p in plan.result] == ["a", "b", "c"] + assert [p.id for p in plan.added] == ["a"] + assert [p.id for p in plan.unchanged] == ["b", "c"] + assert plan.removed == [] + + +def test_remove_takes_exactly_one_out(): + plan = plan_deploy("m", current=[ref("a"), ref("b")], base=1, remove=["a"]) + assert [p.id for p in plan.result] == ["b"] + assert [p.id for p in plan.removed] == ["a"] + + +def test_removing_something_not_deployed_is_refused(): + """Silently succeeding would let a typo read as "already gone".""" + with pytest.raises(RefError, match="not deployed"): + plan_deploy("m", current=[ref("a")], base=1, remove=["b"]) + + +def test_set_replaces_the_whole_set(): + plan = plan_deploy( + "m", current=[ref("a"), ref("b")], base=2, replace=["c"], latest={"c": 5}, + ) + assert [p.id for p in plan.result] == ["c"] + assert [p.id for p in plan.removed] == ["a", "b"] + assert plan.result[0].version == 5 + + +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"): + plan_deploy("m", current=[], base=None, replace=["a"], add=["b"], latest={"a": 1, "b": 1}) + + +def test_a_version_or_effect_change_is_reported_as_changed_not_add_remove(): + plan = plan_deploy( + "m", current=[ref("a", 1, "enforce")], base=3, add=["a@2:observe"], latest={"a": 2}, + ) + assert plan.added == [] and plan.removed == [] + was, now = plan.changed[0] + assert (was.version, was.effect) == (1, "enforce") + assert (now.version, now.effect) == (2, "observe") + + +def test_a_noop_is_detectable_so_the_cli_can_skip_the_write(): + plan = plan_deploy("m", current=[ref("a")], base=1, add=["a"], latest={"a": 1}) + assert plan.is_noop is True + + +def test_deploying_to_a_machine_with_nothing_yet(): + plan = plan_deploy("m", current=None, base=None, add=["a"], latest={"a": 2}) + assert [p.label for p in plan.result] == ["a@2:enforce"] + assert plan.base is None + + +def test_the_result_is_sorted_so_two_equal_sets_serialise_identically(): + plan = plan_deploy("m", current=[], base=None, add=["c", "a", "b"], + latest={"a": 1, "b": 1, "c": 1}) + assert [p.id for p in plan.result] == ["a", "b", "c"] + + +def test_plan_json_carries_the_diff_a_harness_would_otherwise_recompute(): + plan = plan_deploy("m", current=[ref("b")], base=1, add=["a"], latest={"a": 1}) + d = plan.to_dict() + assert d["machineId"] == "m" and d["base"] == 1 and d["noop"] is False + assert [p["id"] for p in d["result"]] == ["a", "b"] + assert [p["id"] for p in d["added"]] == ["a"] + + +# ── the race check ─────────────────────────────────────────────────────────── + + +def test_a_clean_write_is_base_plus_one(): + check_race(4, 5) # no raise + + +def test_a_skipped_generation_means_someone_else_wrote(): + with pytest.raises(ApiError, match="someone else deployed"): + check_race(4, 7) + + +def test_a_repeated_generation_is_also_a_race(): + with pytest.raises(ApiError): + check_race(4, 4) + + +def test_a_first_deployment_has_no_base_to_check(): + check_race(None, 1) # no raise + + +def test_the_race_message_says_a_deploy_replaces(): + """The operator's next move depends on knowing it did not merge.""" + with pytest.raises(ApiError, match="REPLACES"): + check_race(1, 9) + + +# ── source input ───────────────────────────────────────────────────────────── + + +def test_source_from_a_path(tmp_path): + f = tmp_path / "p.mjs" + f.write_text("export default {}") + assert read_source(str(f)) == "export default {}" + + +def test_source_from_an_at_path(tmp_path): + f = tmp_path / "p.mjs" + f.write_text("x") + assert read_source(f"@{f}") == "x" + + +def test_source_from_explicit_stdin(): + assert read_source("-", stdin=io.StringIO("piped"), isatty=False) == "piped" + + +def test_source_from_a_pipe_with_no_argument(): + assert read_source(None, stdin=io.StringIO("piped"), isatty=False) == "piped" + + +def test_source_from_a_paste_prompts_first(): + """On a TTY, blocking on stdin without saying so is indistinguishable from a hang.""" + called = [] + out = read_source(None, stdin=io.StringIO("pasted"), isatty=True, prompt=lambda: called.append(1)) + assert out == "pasted" and called == [1] + + +def test_a_missing_file_names_the_path(): + with pytest.raises(RefError, match="no such file"): + read_source("/nope/definitely-not-here.mjs") From cf9e3969b265d0dae116cb4515258097fdf94200 Mon Sep 17 00:00:00 2001 From: SiddarthAA Date: Wed, 19 Aug 2026 14:33:56 +0530 Subject: [PATCH 02/19] fix(fp-cli): a typo'd machine id minted a machine instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from driving the commands against a running deployment rather than reading them. **A deploy to an unknown machine silently succeeded.** The server accepts a deploy to ANY id — that is how a machine can be pre-staged before it ever polls — so `fp fleet deploy no-such-box --add x` returned 0 and created `no-such-box`, carrying policies nothing will ever collect. The only trace is an extra row in `fleet list`. The dashboard cannot reach this state because it deploys to a machine picked from a list; a CLI takes free text, so the check belongs here. Unknown ids are now refused with exit 6, and `--create` allows the pre-staging case explicitly. **A bad `--since` exited 1, not 2.** `guardrails` raised a bare `ValueError` where every other bad flag value in the CLI is a usage error. Now `typer.BadParameter`, so it exits 2 like `--since` everywhere else. **Three key-mode refusals read "the versioned API an key authenticates against".** Grammar, but it is the message a CI job gets, so it is the sentence that has to survive being read once at 3am. Also adds the JSON-contract tests that would have caught an earlier slip in this branch: the models emitted `vars()`, which leaked Python snake_case into a contract that is camelCase everywhere else — the kind of difference a harness finds at runtime rather than in review. `to_dict()` now fixes the shape and the test asserts no key contains an underscore. Docs: the README gains a Cloud-managed policies section leading with the full-replace semantics, and the agent skill gains a `policies · fleet · guardrails` reference — the skill matters most here, because an agent reading only `--help` would meet `--set` without meeting what it drops. The enterprise CLI doc lives in FailproofAI/agenteye and is NOT updated here. 838 pass. Co-Authored-By: Claude Opus 5 (1M context) --- fp-cli/README.md | 39 +++++++++++++++++++++++ fp-cli/fp_cli/commands/fleet_cmds.py | 4 +-- fp-cli/fp_cli/commands/guardrails_cmds.py | 2 +- fp-cli/fp_cli/commands/policies_cmds.py | 2 +- fp-cli/skill/references/commands.md | 36 +++++++++++++++++++++ fp-cli/tests/test_enforcement_logic.py | 35 ++++++++++++++++++++ 6 files changed, 114 insertions(+), 4 deletions(-) diff --git a/fp-cli/README.md b/fp-cli/README.md index ae885a770..1bc51e2c2 100644 --- a/fp-cli/README.md +++ b/fp-cli/README.md @@ -127,6 +127,45 @@ fp --json events --session-id run-001 --all | jq '.events[].payload' fp --json sessions --since 7d --fields session_id,status,scores ``` +### Cloud-managed policies + +Three commands for the three jobs the dashboard splits across three pages — +`fp policies` writes a policy version, `fp fleet` decides which machines run it, +`fp guardrails` reports what it actually blocked. + +```bash +fp policies publish no-force-push ./rule.mjs # path, @path, a pipe, - or a paste +fp fleet deploy ci-runner-01 --add no-force-push +fp guardrails --since 24h +``` + +**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 +that. Use `--set` only when you mean "exactly these, drop the rest". + +```bash +fp fleet deploy ci-runner-01 \ + --add no-force-push \ # keeps its pinned version if already deployed + --add prod-guard@1:observe \ # id@version:effect + --remove old-rule +``` + +Two things worth knowing before you script it: + +* A bare `--add` of a policy the machine already runs keeps its **pinned + version**. Pass `id@version` to move it — a pin is usually deliberate. +* The endpoint has no lock. The CLI records the deployment generation it read and + **refuses** if the write does not land at exactly one higher, because that means + somebody deployed in between and a replace does not merge. + +`fp fleet diff` shows intent versus delivery: a machine can be deployed-to and +still enforcing an older set until it next polls. + +These commands are **session-only** (`fp login`). They are absent from the +versioned API an API key authenticates against, so `--api-key` exits 2 with the +reason rather than failing at the request. + ## Configuration | Setting | Flag | Env var | Default | diff --git a/fp-cli/fp_cli/commands/fleet_cmds.py b/fp-cli/fp_cli/commands/fleet_cmds.py index 74d47292a..ba1204382 100644 --- a/fp-cli/fp_cli/commands/fleet_cmds.py +++ b/fp-cli/fp_cli/commands/fleet_cmds.py @@ -29,8 +29,8 @@ from . import _write _KEY_MODE_REASON = ( - "the fleet is an operator surface and is not exposed on the versioned API an " - "key authenticates against" + "the fleet is an operator surface and is not exposed on the versioned API that " + "an API key authenticates against" ) diff --git a/fp-cli/fp_cli/commands/guardrails_cmds.py b/fp-cli/fp_cli/commands/guardrails_cmds.py index 49afbe4ad..b06ecc832 100644 --- a/fp-cli/fp_cli/commands/guardrails_cmds.py +++ b/fp-cli/fp_cli/commands/guardrails_cmds.py @@ -22,7 +22,7 @@ _KEY_MODE_REASON = ( "guardrails reads an operator surface that is not exposed on the versioned " - "API an key authenticates against" + "API that an API key authenticates against" ) diff --git a/fp-cli/fp_cli/commands/policies_cmds.py b/fp-cli/fp_cli/commands/policies_cmds.py index b7838c1f4..5031cc63a 100644 --- a/fp-cli/fp_cli/commands/policies_cmds.py +++ b/fp-cli/fp_cli/commands/policies_cmds.py @@ -27,7 +27,7 @@ #: path that would 404 with no explanation. _KEY_MODE_REASON = ( "cloud-managed policies are an operator surface and are not exposed on the " - "versioned API an key authenticates against" + "versioned API that an API key authenticates against" ) diff --git a/fp-cli/skill/references/commands.md b/fp-cli/skill/references/commands.md index fb97a0b4f..8804c0edf 100644 --- a/fp-cli/skill/references/commands.md +++ b/fp-cli/skill/references/commands.md @@ -209,3 +209,39 @@ Built-in assistant. Chats referenced by a **short chat-id** (first 8 hex; prefix - `agent ask "MESSAGE" [--chat ] [--model ]` — starts a new chat (prints its short id) or continues `--chat`. On a TTY the answer renders as Markdown; piped/non-TTY prints the raw answer to stdout. - `agent show ` — transcript. `agent rename --title "…"` · `agent delete `. - Ambiguous prefix → exit 2; unknown chat → exit 6. + +## policies · fleet · guardrails +**Session-only, all three groups** — every subcommand exits 2 under a key, with no request made. These routes are absent from the versioned API an API key authenticates against; they are an operator surface. + +Cloud-managed enforcement, split the way the dashboard splits it: `policies` writes a version, `fleet` decides which machines run it, `guardrails` reports what it blocked. Needs `policies:read` to read, `policies:write` to change anything. + +### policies +- `policies list` — newest version of each; `state` is active / disabled / archived. JSON `{policies:[…]}`. +- `policies show ` — includes the full `source`. +- `policies publish [SOURCE] [--description "…"]` — mints a **new version**; never edits one. SOURCE is a path, `@path`, `-`, a pipe, or omitted to paste on a TTY (Ctrl-D ends). **Publishing deploys nothing** — the version is unused until `fleet deploy` puts it on a machine. +- `policies enable ` · `policies disable [-y]` — disable stops enforcement and is reversible. +- `policies delete [-y]` — archives. **A machine already carrying the policy keeps enforcing it** until redeployed; `disable` is what stops enforcement everywhere. + +### fleet +- `fleet list` — `machine · label · policies · intended · applied · state`. `intended` is the generation deployed, `applied` is what the machine last collected; they differ until it polls. +- `fleet show ` — the exact set that machine is told to run. +- `fleet deploy [--add REF]… [--remove ID]… [--set REF]… [--create] [-y]` + + **A deploy REPLACES the whole set.** The endpoint takes the full list and does not merge. `--add`/`--remove` are a read-modify-write: the CLI reads the current set, applies the delta, prints the complete result, writes that. `--set` replaces everything and is refused alongside `--add`/`--remove`. + + REF is `id`, `id@version`, `id:effect`, or `id@version:effect`. Effect is `enforce` (default) or `observe`. A bare `--add` of an already-deployed policy **keeps its pinned version** — pass `id@version` to move it. + + Deploying to an id that has never checked in is refused (a typo would mint a machine); `--create` allows it for pre-staging. + + **Races.** No server-side lock. The CLI records the generation it read and exits non-zero if the write does not land at exactly one higher — somebody else deployed, and a replace does not merge. Re-read with `fleet show` and retry. + + JSON `{plan:{result,added,removed,changed,unchanged,noop}, deployment, applied}` — the plan is included so a harness does not recompute the diff. +- `fleet diff [machine]` — intent vs delivery per machine, with a `drifted` flag. +- `fleet history ` · `fleet rollback [-y]` — rollback mints a NEW generation carrying the old set; history stays append-only. +- `fleet rename "