diff --git a/fp-cli/README.md b/fp-cli/README.md index ae885a770..87a32801f 100644 --- a/fp-cli/README.md +++ b/fp-cli/README.md @@ -127,6 +127,50 @@ 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 summary` 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 summary --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 +``` + +Three 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. +* The exit code separates your mistake from the server's answer. A malformed ref, + `--set` alongside `--add`/`--remove`, or no flags at all is **2**; a ref that + parses but names a policy that does not exist is **1**; an unknown machine is + **6**. Branch on those rather than on the message. + +`fp fleet diff` shows intent versus delivery: a machine can be deployed-to and +still enforcing an older set until it next polls. It refuses a machine id nobody +has reported under rather than rendering it as an empty fleet. + +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/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..730dae211 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,159 @@ 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, intent: str) -> Dict[str, Any]: + """POST /api/agent/compose-policy — the assistant drafts a policy source. + + STREAMS. The route answers `text/event-stream`, not JSON: `delta` frames as + tokens arrive, then one `done` carrying the finished source (the dashboard + feeds those deltas into a Monaco diff). Reading it as JSON gets a parse + error on the first frame, which is how this was written the first time. + + The field is `intent`, not `prompt` — the server rejects anything else with + a 400 before the model is ever called. + + Dashboard-only, like the rest of the assistant: there is no `/v1` route + behind it. + """ + source = "" + for event in _stream_sse(ctx, "/api/agent/compose-policy", {"intent": intent}): + kind = event.get("type") + if kind == "error": + raise ApiError( + str(event.get("reason") or "the policy composer hit an error"), + hint="check `fp agent health` — the assistant may not be configured here", + ) + if kind == "done": + source = str(event.get("source") or "") + return {"source": source, "usage": event.get("usage") or {}} + # The stream ended without a `done`. Returning "" here would render as an + # empty draft; saying so is the difference between a bug and a blank file. + # + # The overwhelmingly likely cause is the composer's own 30s ceiling — + # `agent/src/server.ts` aborts the request at 30_000ms, server-side, and a + # slower model or a longer intent simply does not finish. Naming it matters + # because the obvious remedy (raise --timeout) does nothing: the cut is not + # on this side. + raise ApiError( + "the assistant stopped before returning a policy — the composer has a " + "30s server-side limit and this draft did not finish inside it", + hint="try a shorter, more specific description, or run it again", + ) 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..367e59ed6 --- /dev/null +++ b/fp-cli/fp_cli/commands/fleet_cmds.py @@ -0,0 +1,416 @@ +"""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 .. import _click_compat as click # the Click Typer is running; see _click_compat +from ..enforcement import ( + RefError, + RefUsageError, + check_race, + disabled_ids, + 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 that " + "an API key authenticates against" +) + + +def _require_machine(cctx, machine_id: str) -> None: + """Refuse an id no machine has ever reported under. + + Without this, a typo is indistinguishable from a real machine that simply + has nothing deployed: both render an empty set and exit 0. The id is also + interpolated into a URL path further down, so an id containing `/` would + address a different route entirely — the server rejects those, but a clear + "no machine" beats someone else's 404. + """ + if machine_id not in {m.machine_id for m in api.list_machines(cctx)}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + + +def fleet_list(ctx: typer.Context) -> None: + """List machines and how many policies each is told to run. + + Shows `machine · label · pol · intended · applied · seen · events · state`. + `intended` is the generation deployed, `applied` is the one the machine last + collected, and `seen` is when it last reported anything — a machine can be + in sync and dead, or alive and behind, and those are different problems. + + 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}`, where + each machine carries raw timestamps plus the computed `drifted`. + + 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) + if output.is_json(): + # Only `--json` emits the deployments, and only `--json` pays for them. + # The table is built entirely from the machine records; fetching them + # for a human render was a second request whose result was discarded. + output.emit_json({ + "machines": [m.to_dict() for m in machines], + "deployments": [d.to_dict() for d in api.list_deployments(cctx)], + }) + return + output.render_fleet(machines) + + +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. + + Also reports whether the machine has actually COLLECTED that deployment. A + machine can be told to run a policy and not yet have it; the policy list + alone cannot tell you which, and that is usually the question. + + Needs `policies:read`. With `--json`: `{machine, deployment}` — the machine + record (including `appliedDeployment`, `drifted`, `lastSeen` and both label + fields, with raw timestamps) and the deployment, or `deployment: null` when + nothing is deployed. + + 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) + # Two reads on purpose. The deployment says what the machine was TOLD to + # run; only the machine record says whether it has collected it. Showing the + # first without the second is how this view came to imply a policy was in + # force when the host had never picked it up. + machines = api.list_machines(cctx) + machine = next((m for m in machines if m.machine_id == machine_id), None) + if machine is None: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + dep = api.get_deployment(cctx, machine_id) + + if output.is_json(): + output.emit_json({ + "machine": machine.to_dict(), + "deployment": dep.to_dict() if dep else None, + }) + return + output.render_machine_policies(machine_id, dep, machine) + + +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: + # 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, or --set. " + "`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. + if not create: + try: + _require_machine(cctx, machine_id) + except NotFoundError: + raise NotFoundError( + f"no machine {machine_id!r} has checked in — deploying would create " + "it as a new machine id. Pass --create if that is deliberate." + ) + + current = api.get_deployment(cctx, machine_id) + published = api.list_policies(cctx) + latest = latest_versions(published) + 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, + disabled=disabled_ids(published), + ) + except RefUsageError as exc: + # Exit 2, like every other bad flag value in this CLI (`--since`, + # `--expect`, `--file`). These are retype-the-command mistakes; exit 1 + # says "the server refused", which is a different thing to script on. + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + + # A no-op exits 0 WITHOUT writing, which is desired-state semantics: a + # retrying harness re-running the same deploy should succeed, not error. + # Two consequences worth knowing rather than discovering: + # * `applied: false` in --json is the only way to tell "I changed it" from + # "it already matched" — the exit code is 0 either way, on purpose. + # * the short-circuit happens BEFORE the write, so a reader without + # `policies:write` also gets 0 here. They have not gained anything (the + # state already held and nothing was written), but the exit code alone + # is not proof of write access. + if plan.is_noop: + if output.is_json(): + output.emit_json({"plan": plan.to_dict(), "deployment": None, "applied": False}) + return + output.deployment_unchanged(machine_id) + return + + if not output.is_json(): + output.render_deploy_plan(plan) + dropped = len(plan.removed) + if not _write.confirm_destructive( + state, "replace the policy set on", machine_id, + consequence=(f"this REPLACES the whole set with the {len(plan.result)} shown above" + + (f"; {dropped} would be removed" if dropped else "")), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"plan": plan.to_dict(), "cancelled": True, "applied": False}) + else: + output.print_cancelled() + return + + 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.deployment_applied(machine_id, result.deployment, len(result.policies)) + + +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`. With `--json`: + `{machines:[{machineId, intended, delivered, drifted}]}` — `drifted` is the + field the CLI computes, so a harness need not derive it. + + 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) + # Every other machine-scoped command refuses an id nobody has reported + # under; this one filtered to nothing and exited 0 saying "no machines have + # checked in yet" — false, and indistinguishable from a healthy fleet. The + # list is already in hand, so the check costs no extra request. + if machine_id and machine_id not in {m.machine_id for m in machines}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + 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 + output.render_fleet_diff(rows) + + +def fleet_history( + ctx: typer.Context, + machine_id: str = typer.Argument(..., help="Machine id."), +) -> None: + """List a machine's deployment generations, newest first. + + A reissue — the server rewriting a deployment because a policy was disabled + — appears as an ordinary entry. Needs `policies:read`. With `--json`: + `{machineId, history:[{deployment, policies, updatedAt}]}`. + + Example: + + * `fp fleet history ci-runner-01` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "fleet history", _KEY_MODE_REASON) + cctx = require_auth(state) + _require_machine(cctx, machine_id) + entries = api.deployment_history(cctx, machine_id) + if output.is_json(): + output.emit_json({"machineId": machine_id, "history": entries}) + return + output.render_deployment_history(machine_id, entries) + + +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. A generation containing a policy that + has since been disabled or deleted cannot be reinstated; the server says so. + + Needs `policies:write`. With `--json`: the resulting deployment, or + `{"cancelled": true}` if you decline. + + 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) + _require_machine(cctx, machine_id) + current = api.get_deployment(cctx, machine_id) + if not _write.confirm_destructive( + state, f"reinstate deployment #{deployment} on", machine_id, + consequence="this REPLACES the machine's current set with the one from that generation", + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + 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.deployment_rolled_back(machine_id, deployment, 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`. With `--json`: `{machineId, labelOverride}` — the + server stores the label as an override beside the machine's self-asserted + one rather than replacing it. + + Example: + + * `fp fleet rename ci-runner-01 "CI runner (eu-west)"` + """ + 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.machine_renamed(machine_id, label) + + +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..78093b0fc --- /dev/null +++ b/fp-cli/fp_cli/commands/guardrails_cmds.py @@ -0,0 +1,138 @@ +"""Guardrails: what enforcement actually did — `summary` and `timeline`. + +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 +from ..errors import NotFoundError + +_KEY_MODE_REASON = ( + "guardrails reads an operator surface that is not exposed on the versioned " + "API that an API key authenticates against" +) + + +def _require_machine(cctx, machine_id: Optional[str]) -> None: + """Refuse a `--machine` id nobody has ever reported under. + + Both views answer "what happened here", and both answer an unknown machine + with an empty window — which reads as "this machine was quiet", not as "you + typed the id wrong". `fp fleet` refuses the same mistake everywhere else; + the check costs one request, and only when the flag is actually used. + """ + if machine_id is None: + return + if machine_id not in {m.machine_id for m in api.list_machines(cctx)}: + raise NotFoundError(f"no machine {machine_id!r} has checked in") + + +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) + _require_machine(cctx, machine) + 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: + """When enforcement bit, and how hard — one row per time bucket. + + Shows `time · activity · total · denied · instructed`. The bar is scaled to + the busiest bucket in the window, with the blocked share drawn in red inside + it, so a quiet hour and a heavily-blocked hour are distinguishable at a + glance rather than by reading numbers. + + Times are UTC, and the label follows the bucket size the server chose — a + clock for hourly buckets, a date for daily ones. + + 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) + hours = _hours(since) + _require_machine(cctx, machine) + data = api.decision_timeline(cctx, hours=hours, machine_id=machine) + if output.is_json(): + output.emit_json(data) + return + output.render_decision_timeline(data) + + +def register(app: typer.Typer) -> None: + # A pure container, like every other group in this CLI: bare `fp guardrails` + # prints its help rather than running something. It used to run the summary + # from a callback, which made it the only group that did — and put `--since` + # in two places, where the group-level copy silently shadowed nothing and + # taught the wrong shape. + guardrails_app = typer.Typer( + no_args_is_help=True, + rich_markup_mode="markdown", + context_settings={"help_option_names": ["-h", "--help"]}, + help="What enforcement actually did (summary / timeline).", + ) + guardrails_app.command("summary", epilog=GLOBALS_EPILOG)(guardrails_summary) + guardrails_app.command("timeline", epilog=GLOBALS_EPILOG)(guardrails_timeline) + 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..37cd59695 --- /dev/null +++ b/fp-cli/fp_cli/commands/policies_cmds.py @@ -0,0 +1,465 @@ +"""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 .. import _click_compat as click # the Click Typer is running; see _click_compat +from ..enforcement import RefError, RefUsageError, read_source +from ..policy_check import check_syntax, run_policy +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 that an API key authenticates against" +) + + +def policies_list(ctx: typer.Context) -> None: + """List every published policy version, newest of each policy first. + + Shows `policy · version · state · description`, one row per VERSION — + versions are immutable and every one stays addressable, so a policy + published three times is three rows. The title counts distinct policies and + captions the version total, the way the dashboard's library does. + + `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 — also + every version, so deduplicate on `id` if you want one row per policy. + + 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) + # Explicitly the newest version, not the first one the server happened to + # list. `next(...)` returned whichever came back first, so the source shown + # was correct only for as long as the endpoint kept returning descending + # versions — and a stale source rendered identically to a current one. + versions = [p for p in api.list_policies(cctx) if p.id == policy_id] + if not versions: + raise NotFoundError(f"no policy named {policy_id}") + match = max(versions, key=lambda p: p.version) + if output.is_json(): + output.emit_json(match.to_dict()) + return + carriers = { + d.machine_id: ref.version + for d in api.list_deployments(cctx) + for ref in d.policies + if ref.id == policy_id + } + output.render_policy_published(match, carriers=carriers, + source_bytes=len((match.source or "").encode("utf-8"))) + 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."), + no_verify: bool = typer.Option( + False, "--no-verify", help="Skip the JavaScript syntax check before publishing." + ), +) -> None: + """Publish a policy — mints a NEW VERSION; it never edits one in place. + + The source is parse-checked with node before it is sent. Nothing downstream + does this: the server validates the id and a size ceiling, and a broken + policy otherwise fails on the machine at enforcement time. `--no-verify` + skips it; a host without node publishes with a warning rather than a block. + + 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 plus `carriers` — a map of machine id to + the version of this policy it currently runs, so a harness can tell what a + publish left behind without a second call. + + 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 RefUsageError as exc: + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + if not text.strip(): + raise ApiError("policy source is empty — nothing to publish") + + # Nothing downstream parses this. The server checks the id and a size + # ceiling; the machines find out at enforcement time, which is the worst + # place for a syntax error to surface. `--no-verify` exists because a + # machine without node should still be able to publish, not because + # skipping is ever a good idea. + if not no_verify: + syn = check_syntax(text) + if not syn.ok: + raise ApiError( + f"{policy_id} is not parseable JavaScript — refusing to publish it:\n" + f"{syn.message}", + hint="fix the syntax, or pass --no-verify to publish it anyway", + ) + if not syn.checked and not output.is_json(): + output.warn(syn.message) + + created = api.publish_policy(cctx, policy_id, text, description) + + # Which machines already carry this policy, and at which version. Publishing + # deploys nothing, so this is the one thing the card must not guess at: it + # used to state "not deployed anywhere" unconditionally, which was wrong for + # every policy that already had a version in the field. + carriers = { + d.machine_id: ref.version + for d in api.list_deployments(cctx) + for ref in d.policies + if ref.id == policy_id + } + if output.is_json(): + output.emit_json({**created.to_dict(), "carriers": carriers}) + return + output.render_policy_published(created, carriers=carriers, + source_bytes=len(text.encode("utf-8"))) + + +def policies_enable( + ctx: typer.Context, + policy_id: str = typer.Argument(..., help="Policy id."), +) -> None: + """Re-enable a disabled policy, restoring it to the machines that lost it. + + The exact inverse of `disable`: the server puts the policy back into every + deployment it was removed from, advancing each machine's generation again. + Nothing needs redeploying by hand. + + Needs `policies:write`. With `--json`: `{id, disabled, archived, + machinesUpdated}` — `machinesUpdated` counts the deployments rewritten, and + matches the count the preceding `disable` reported. + + Example: + + * `fp policies enable no-force-push` + """ + 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.policy_lifecycle_changed(policy_id, "enabled") + + +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. It is removed from every deployment carrying it. + + Not just "machines stop enforcing it": the server reissues each affected + machine's deployment WITHOUT this policy, advancing that machine's + generation. `fp fleet history` shows the reissue as an ordinary entry. + + `policies enable` is the exact inverse: it puts the policy back into every + deployment it was removed from, so nothing needs redeploying by hand. + + Needs `policies:write`. With `--json`: `{id, disabled, archived, + machinesUpdated}` — `machinesUpdated` is how many deployments were rewritten + to drop it, and is the number to check if you expected this to be a no-op. + + Example: + + * `fp policies disable no-force-push --yes` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies disable", _KEY_MODE_REASON) + cctx = require_auth(state) + if not _write.confirm_destructive( + state, "disable policy", policy_id, + consequence=("it is REMOVED from every deployment carrying it, minting a new " + "generation on each; `policies enable` puts it back the same way"), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + res = api.set_policy_enabled(cctx, policy_id, False) + if output.is_json(): + output.emit_json(res) + return + output.policy_lifecycle_changed(policy_id, "disabled") + + +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`. With `--json`: `{id, disabled, archived, + machinesUpdated}`, or `{"cancelled": true}` if you decline. + + Example: + + * `fp policies delete old-rule --yes` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies delete", _KEY_MODE_REASON) + cctx = require_auth(state) + if not _write.confirm_destructive( + state, "archive policy", policy_id, + consequence=("machines already carrying it keep enforcing until redeployed — " + "`policies disable` is what stops enforcement"), + assume_yes=yes, + ): + if output.is_json(): + output.emit_json({"cancelled": True}) + else: + output.print_cancelled() + return + res = api.delete_policy(cctx, policy_id) + if output.is_json(): + output.emit_json(res) + return + output.policy_lifecycle_changed(policy_id, "archived") + + +def policies_test( + ctx: typer.Context, + source: Optional[str] = typer.Argument( + None, help="Policy file, @path, or - for stdin. Omit to paste it." + ), + tool: str = typer.Option("Bash", "--tool", help="Tool name the hook fired for."), + command: Optional[str] = typer.Option(None, "--command", help="Bash command to test against."), + file_path: Optional[str] = typer.Option(None, "--file", help="File path to test against."), + event: str = typer.Option("PreToolUse", "--event", help="Hook event type."), + expect: Optional[str] = typer.Option( + None, "--expect", + help="Assert the decision is allow/deny/instruct; exit 1 if it is not.", + ), +) -> None: + """Run a policy locally and print what it would decide. No server, no fleet. + + Executes the real file — bare `import { deny } from "failproofai"` and all — + against a context you describe, and prints allow / deny / instruct per + registered policy. Nothing is published and nothing is installed. + + Needs `node` on PATH. This is a dry run, not the enforcement path: it proves + the policy parses, registers and decides for the input given. It cannot + prove the daemon feeds it the same context. + + With `--json`: `{ok, decision, policies:[{name, decision, reason}]}` — the + overall `decision` is the strictest any policy returned. + + Examples: + + * `fp policies test ./rule.mjs --command "git push --force"` + * `fp policies test ./rule.mjs --tool Write --file .env` + """ + # No `require_auth` and no `deny_in_key_mode`: this command talks to node, + # not to the dashboard, so it works logged out and under an API key alike. + + def _paste_prompt() -> None: + output.hint("paste the policy source, then press Ctrl-D") + + try: + text = read_source(source, prompt=_paste_prompt) + except RefUsageError as exc: + raise click.UsageError(str(exc)) + except RefError as exc: + raise ApiError(str(exc)) + if not text.strip(): + raise ApiError("policy source is empty — nothing to test") + + # Checked before the syntax check runs, so a bad --expect reports itself + # rather than being masked by whatever node says about the file. A usage + # error should never depend on the content of an argument. + if expect is not None and expect not in ("allow", "deny", "instruct"): + raise typer.BadParameter( + f"invalid --expect value {expect!r}; choose one of allow, deny, instruct" + ) + + syn = check_syntax(text) + if not syn.ok: + if output.is_json(): + output.emit_json({"ok": False, "syntax": syn.to_dict(), "policies": []}) + raise typer.Exit(1) + raise ApiError(f"the policy is not parseable JavaScript:\n{syn.message}") + + run = run_policy(text, tool=tool, command=command, file_path=file_path, event=event) + + # A policy that correctly denies is a SUCCESSFUL test, so the decision does + # not set the exit code on its own — otherwise `policies test` would fail + # whenever the policy worked. `--expect` is how CI asserts instead: it turns + # "what did it decide" into "did it decide what I meant". + met = expect is None or run.decision == expect + if output.is_json(): + output.emit_json({**run.to_dict(), "syntax": syn.to_dict(), + "expected": expect, "met": met}) + raise typer.Exit(0 if (run.ok and met) else 1) + if not run.ok: + raise ApiError(run.error) + output.render_policy_test(run, tool=tool, command=command, file_path=file_path, + expected=expect) + if not met: + raise typer.Exit(1) + + +def policies_compose( + ctx: typer.Context, + prompt: str = typer.Argument(..., help="What the policy should do, in plain English."), + out: Optional[str] = typer.Option(None, "--out", help="Write the draft to this file."), + publish_as: Optional[str] = typer.Option( + None, "--publish", help="Publish the draft immediately under this policy id." + ), +) -> None: + """Draft a policy from a description, using the Cloud assistant. + + The assistant writes the source; **you** decide whether it ships. By default + the draft is printed and nothing else happens — a generated policy that + deploys itself is a generated policy nobody read. + + `--out` saves it; `--publish ` publishes it, still syntax-checked first. + Needs `agent:use`, and `policies:write` to publish. Session-only. + + With `--json`: `{prompt, source, syntax, published}`. + + Examples: + + * `fp policies compose "block force pushes to main"` + * `fp policies compose "deny reading .env" --out env.mjs` + """ + state: AppState = ctx.obj + deny_in_key_mode(state, "policies compose", _KEY_MODE_REASON) + cctx = require_auth(state) + + with output.thinking("drafting…", enabled=not output.is_json()): + res = api.compose_policy(cctx, prompt) + source = (res or {}).get("source") or (res or {}).get("policy") or "" + if not source.strip(): + raise ApiError( + "the assistant returned no policy source", + hint="check `fp agent health` — the assistant may not be configured here", + ) + + syn = check_syntax(source) + + # Saved BEFORE anything that can fail. `--out` used to run after the + # publish, so a publish that was refused — bad syntax, no `policies:write`, + # a network blip — threw away the draft the user had just paid an assistant + # to write, with no way to get that same text back. + if out: + try: + with open(out, "w", encoding="utf-8") as fh: + fh.write(source) + except OSError as exc: + raise ApiError(f"cannot write {out}: {exc.strerror or exc}") + + published = None + if publish_as: + if not syn.ok: + raise ApiError( + f"the drafted policy is not parseable JavaScript — refusing to publish:\n" + f"{syn.message}", + hint=("fix it and publish it with `fp policies publish`" + if out else "save it with --out, fix it, then publish"), + ) + published = api.publish_policy(cctx, publish_as, source, f"drafted: {prompt}"[:500]) + + if output.is_json(): + output.emit_json({ + "prompt": prompt, "source": source, "syntax": syn.to_dict(), + "published": published.to_dict() if published else None, + "savedTo": out, + }) + return + output.render_composed_policy(prompt, source, syn, saved_to=out) + if published: + output.policy_published_brief(published) + + +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) + policies_app.command("test", epilog=GLOBALS_EPILOG)(policies_test) + policies_app.command("compose", epilog=GLOBALS_EPILOG)(policies_compose) + 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..44a89fd85 --- /dev/null +++ b/fp-cli/fp_cli/enforcement.py @@ -0,0 +1,345 @@ +"""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.""" + + +class RefUsageError(RefError): + """A RefError the caller can fix by retyping the command. + + Split out so the command layer can exit 2 (usage) rather than 1 (API error) + for these, which is what the documented exit-code table promises and what + `--since` and `--expect` in these same commands already do. A malformed + token, two flags that contradict each other, or a path that is not readable + text are all "you typed it wrong" — not "the server said no". + + Subclasses ``RefError`` so every existing caller and test that catches the + base class keeps working unchanged. + """ + + +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 RefUsageError("empty policy reference") + m = _REF.match(token) + if not m: + raise RefUsageError( + 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 RefUsageError( + 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 disabled_ids(policies: Iterable[PolicyVersion]) -> set: + """Policies the server will refuse to deploy. + + The server rejects these anyway, but only after the CLI has drawn a plan and + asked the operator to confirm it — so the last thing on screen is a change + that cannot happen, under a prompt that implied it could. Everything else + the plan depends on (the machine exists, the policy exists) is already + checked before the plan is built; this was the one gap. + """ + return {p.id for p in policies if p.disabled and not p.archived} + + +def resolve_ref( + token: str, + *, + latest: Dict[str, int], + current: Dict[str, PolicyRef], + disabled: Optional[set] = None, +) -> 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) + if disabled and pid in disabled and pid not in current: + raise RefError( + f"{pid!r} is disabled — `fp policies enable {pid}` first, or the machine " + "would be sent a deployment the server refuses" + ) + 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, + disabled: Optional[set] = 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 RefUsageError( + "--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, disabled=disabled) + 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, disabled=disabled) + 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 _checked(_read_stream(stream)) + if value: + path = value[1:] if value.startswith("@") else value + try: + with open(path, "r", encoding="utf-8") as fh: + return _checked(fh.read()) + except FileNotFoundError: + raise RefUsageError(f"no such file: {path}") + except UnicodeDecodeError: + # NOT an OSError, so the handler below never saw it and the + # decode error escaped as a raw traceback. `_checked` cannot + # catch this either: it inspects text, and there is no text yet. + raise RefUsageError(_NOT_TEXT.format(what=path)) + except OSError as exc: + raise RefUsageError(f"cannot read {path}: {exc}") + if not tty: + return _checked(_read_stream(stream)) + if prompt is not None: + prompt() + return _checked(_read_stream(stream)) + + +#: Said the same way whether the bytes arrived by path or down a pipe. +_NOT_TEXT = ( + "{what} is not UTF-8 text — this looks like a binary file rather than a policy" +) + + +def _read_stream(stream) -> str: + """Read stdin, turning undecodable bytes into a sentence. + + ``sys.stdin`` decodes as it reads, so piping a binary file raises + ``UnicodeDecodeError`` here rather than returning bytes ``_checked`` could + inspect — which is how `cat rule.png | fp policies publish x` printed a + traceback instead of the NUL-byte message written for exactly that mistake. + """ + try: + return stream.read() + except UnicodeDecodeError: + raise RefUsageError(_NOT_TEXT.format(what="the input")) + + +def _checked(text: str) -> str: + """Reject bytes the store cannot hold, with a message that says what happened. + + A NUL byte in policy source reaches Postgres and comes back as a bare + "database error" — a raw internal failure shown to somebody who most likely + pointed the command at a binary file by mistake. The server ought to refuse + it; until it does, refusing here turns an unexplained 500 into a sentence. + """ + if "\x00" in text: + raise RefUsageError( + "policy source contains a NUL byte — this looks like a binary file " + "rather than a policy" + ) + return text diff --git a/fp-cli/fp_cli/models.py b/fp-cli/fp_cli/models.py index 44206e9d2..014b84669 100644 --- a/fp-cli/fp_cli/models.py +++ b/fp-cli/fp_cli/models.py @@ -701,3 +701,191 @@ 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 + #: What the machine calls itself. May be absent — plenty never report one. + label: Optional[str] + #: What an operator called it via `fleet rename`. SEPARATE from `label` on + #: the server, and the reason a rename appeared to do nothing here: reading + #: only `label` showed the machine's own (usually null) name and silently + #: ignored the override. `display_label` applies the precedence. + label_override: 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"), + label_override=d.get("labelOverride"), + 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), + ) + + @property + def display_label(self) -> Optional[str]: + """The operator's name for the machine, else its own. + + Mirrors `machinePicker.ts`: `labelOverride || label || machineId`. The + override wins because it is the deliberate one — a machine's + self-asserted label is whatever it happened to send. + """ + return (self.label_override or "").strip() or (self.label or "").strip() or None + + 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, + "labelOverride": self.label_override, + "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..ecf387690 100644 --- a/fp-cli/fp_cli/output.py +++ b/fp-cli/fp_cli/output.py @@ -475,6 +475,11 @@ def version_banner(version: str) -> None: ("errors", "List errored events.", "--aggregate"), ("usage", "Show current org usage for the metering window.", ""), ]), + ("ENFORCE", [ + ("policies", "Write cloud-managed policies.", "list show publish test compose enable disable delete"), + ("fleet", "Deploy policies to machines.", "list show deploy diff history rollback rename"), + ("guardrails", "What enforcement actually blocked.", "summary timeline"), + ]), ("MANAGE", [ ("orgs", "Switch and inspect the active org.", "list switch current perms"), ("keys", "Provision and manage API keys.", "list show create update disable regenerate"), @@ -5769,3 +5774,732 @@ 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 VERSION, newest of each policy first. + + One row per version, not per policy, because that is what the endpoint + returns and what the dashboard's own library shows. The title carries both + numbers for the same reason the dashboard does (`policies/page.tsx` counts + distinct policies and captions them "N versions"): a policy republished + twenty times is one policy and twenty rows, and a bare "policies · 21" over + that table is a number nobody can act on. + """ + rows = [] + # (id, -version): every version of a policy sits together, newest first. + # Sorting on id alone left the versions in whatever order the server + # happened to return them. + for p in sorted(items, key=lambda x: (x.id, -x.version)): + 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), + ]) + distinct = len({p.id for p in items}) + title = Text() + title.append("policies", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(str(distinct), style="bold white") + if len(rows) != distinct: + title.append(" · ", style=theme.FAINT) + title.append(f"{len(rows)} versions", style=theme.LABEL) + 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, *, carriers: Optional[dict] = None, + source_bytes: int = 0) -> None: + """``fp policies publish`` — what was written, and what now runs it. + + The first version showed the id, the version and a sha256, then claimed "not + deployed anywhere yet" from a HARDCODED argument — so it said that even when + earlier versions were deployed across the fleet. It was also the only thing + on screen that was not simply a restatement of the command, which is what + made the card hard to read: nothing told you what you had just published. + + Now it shows the description, the size, and the truth about deployment: + `carriers` maps machine id -> the version it currently runs, so this can say + which machines are on an older version and would need moving. + """ + line1 = Text(p.id, style=f"bold {theme.TEXT}") + line1.append(f" v{p.version}", style=f"bold {theme.ACCENT}") + body = [line1] + if p.description: + body.append(Text(p.description, style=theme.TEXT)) + body.append(Text()) + + meta = Text() + if source_bytes: + meta.append(f"{source_bytes:,} bytes", style=theme.LABEL) + meta.append(" · ", style=theme.FAINT) + meta.append("sha256 ", style=theme.LABEL) + meta.append((p.sha256 or "")[:12] + "…", style=theme.TEXT_DIM) + body.append(meta) + body.append(Text()) + + # Publishing changes nothing on any machine. An author who assumes otherwise + # ships a policy that is never enforced, so this line is the point of the + # card — but it has to be true, which means looking rather than assuming. + older = sorted(m for m, v in (carriers or {}).items() if v != p.version) + if not carriers: + note = Text("published, not deployed", style=theme.AMBER) + note.append(" — no machine runs this policy yet", style=theme.LABEL) + body.append(note) + cmd = Text(" fp fleet deploy --add ", style=theme.LABEL) + cmd.append(p.id, style=theme.ACCENT) + body.append(cmd) + elif older: + many = len(older) > 1 + note = Text(f"{len(older)} machine{'s' if many else ''}", style=theme.AMBER) + note.append(f" still {'run' if many else 'runs'} an older version: ", style=theme.LABEL) + note.append(", ".join(older[:3]) + ("…" if len(older) > 3 else ""), style=theme.TEXT_DIM) + body.append(note) + cmd = Text(" fp fleet deploy --add ", style=theme.LABEL) + cmd.append(f"{p.id}@{p.version}", style=theme.ACCENT) + body.append(cmd) + else: + note = Text("every machine carrying it is already on ", style=theme.LABEL) + note.append(f"v{p.version}", style=theme.SUCCESS) + body.append(note) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.SUCCESS, + title=Text("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]) -> None: + """``fp fleet`` — every machine, what it is told to run, and whether it is alive. + + `intended` is what the control plane decided and `applied` is what the + machine collected; showing both is the point, because a machine can be + deployed-to and still enforcing an older set. + + `seen` is a separate question from either, and the one the table used to + leave out: a host can be perfectly in sync and dead. Without it a machine + that last reported seven days ago rendered identically to one that reported + a minute ago. + + Takes only the machine records. It used to take the deployments as well and + never read them — everything here comes from the machine. + """ + rows = [] + for m in sorted(machines, key=lambda x: x.machine_id): + seen = _compact_age(m.last_seen) + # Stale is a judgement the table can make once, rather than every reader + # doing the subtraction: a day is generous for a host that reports on + # every hook, and quiet enough to be worth a colour. + stale = m.last_seen is None or ( + datetime.now(timezone.utc).timestamp() - m.last_seen / 1000 > 86_400 + ) + rows.append([ + Text(m.machine_id, style=theme.TEXT), + Text(m.display_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), + Text(f"#{m.applied_deployment}" if m.applied_deployment is not None else "—", + style=theme.AMBER if m.drifted else theme.TEXT_DIM), + Text(seen or "never", style=theme.FAINT if stale else theme.TEXT_DIM), + Text(f"{m.event_count:,}" if m.event_count else "—", + style=theme.TEXT_DIM if m.event_count else theme.FAINT), + 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", "pol", "intended", "applied", "seen", + "events", "state"], + rows=rows, days=set(), order=None, + empty_message="no machines have checked in yet", title=title) + + +def _compact_age(ms: Optional[int]) -> str: + """`5h`, `7d`, `just now` — the column form of `_epoch_age`. + + A table cell is not a sentence: "7 days ago" spends eleven characters saying + what "7d" says in two, and this column sits beside seven others. + """ + if not ms: + return "" + secs = max(0.0, datetime.now(timezone.utc).timestamp() - ms / 1000) + if secs < 90: + return "just now" + if secs < 3600: + return f"{int(round(secs / 60))}m" + if secs < 86_400: + return f"{int(round(secs / 3600))}h" + return f"{int(round(secs / 86_400))}d" + + +def _epoch_age(ms: Optional[int]) -> str: + """`_relative_age` for the machine record's epoch-ms timestamps. + + The deployment side of this API speaks ISO and the machine side speaks + milliseconds; rather than a second humaniser, convert and reuse the one the + errors card already uses so "2 hr ago" means the same thing everywhere. + """ + if not ms: + return "" + return _relative_age(datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()) + + +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. + + The first version printed the id, the generation and the policy list, which + was a third of what the two endpoints return and quietly implied the machine + was running them. It can be told to run a policy it has never collected — + `appliedDeployment` is the field that says so, and leaving it out made this + view confidently wrong about the only thing it is asked. + + Deliberately NOT the deploy-plan renderer: that one talks about a change + ("N policies after this change"), which is a lie on a read-only view. + """ + body = [] + if machine is not None and machine.display_label: + body.append(Text(machine.display_label, style=f"bold {theme.TEXT}")) + body.append(Text()) + + def field(label: str, value: Text) -> None: + line = Text(f"{label:<13}", style=theme.LABEL) + line.append_text(value) + body.append(line) + + if dep is not None: + gen = Text(f"#{dep.deployment}", style=f"bold {theme.TEXT}") + if machine is not None: + applied = machine.applied_deployment + if applied is None: + gen.append(" · ", style=theme.FAINT) + gen.append("not yet collected", style=theme.AMBER) + elif machine.drifted: + gen.append(" · ", style=theme.FAINT) + gen.append(f"machine is on #{applied}", style=theme.AMBER) + else: + gen.append(" · ", style=theme.FAINT) + gen.append("collected", style=theme.SUCCESS) + field("deployment", gen) + who = Text(dep.updated_by or "unknown", style=theme.TEXT_DIM) + when = _relative_age(dep.updated_at) + if when: + who.append(f" · {when}", style=theme.LABEL) + field("deployed by", who) + else: + field("deployment", Text("none", style=theme.FAINT)) + + if machine is not None: + seen = _epoch_age(machine.last_seen) or "never" + act = Text(seen, style=theme.TEXT_DIM if machine.last_seen else theme.FAINT) + if machine.event_count: + act.append(f" · {machine.event_count} events", style=theme.LABEL) + field("last seen", act) + + body.append(Text()) + pols = sorted(dep.policies, key=lambda x: x.id) if dep is not None else [] + if pols: + width = max(len(p.id) for p in pols) + # `ver` is three characters and `v1` is two, so the version cell is + # 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) + 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)) + body.append(row) + else: + body.append(Text(" no policies deployed", style=theme.FAINT)) + + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text(machine_id, 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() + n = len(plan.result) + footer.append(f"{n} ", style="bold white") + footer.append(f"polic{'y' if n == 1 else 'ies'} 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), + ]) + # An explicit title: the default one appends "newest first", which is a + # claim about ordering this table does not make — it is ranked by policy, + # not by time. + ptitle = Text() + ptitle.append("by policy", style=f"bold {theme.ACCENT}") + ptitle.append(" · ", style=theme.FAINT) + ptitle.append(str(len(rows)), style="bold white") + 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=ptitle) + + +def policy_lifecycle_changed(policy_id: str, action: str) -> None: + """``✓ disabled policy `` etc, in the shared green notice box. + + The plain ``success()`` line these used to print was the only two-step flow + in the CLI whose confirm and result did not match the boxed shape every + other destructive action uses. + """ + detail = { + # Terse on purpose: the CONFIRM box already carried the caveat and the + # reversal command. Repeating them here is what pushed this card onto a + # second line at 100 columns. + "disabled": "removed from every deployment carrying it", + "enabled": "restored to the deployments that lost it", + "archived": "carriers keep it until redeployed", + }[action] + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append(f"{action} policy ", style=theme.TEXT) + body.append(policy_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(detail, style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title=action) + + +def deployment_applied(machine_id: str, generation: int, count: int) -> None: + """``✓ deployed N policies to · now on #G``.""" + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append(f"deployed {count} polic{'y' if count == 1 else 'ies'} to ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(f"now on deployment #{generation}", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="deployed") + + +def deployment_rolled_back(machine_id: str, restored: int, generation: int) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("restored the set from ", style=theme.TEXT) + body.append(f"#{restored}", style=theme.ACCENT) + body.append(" on ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append(f"minted as deployment #{generation}", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="rolled back") + + +def machine_renamed(machine_id: str, label: str) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + if not label.strip(): + # An empty label is not a rename to nothing — the server clears the + # override, and the machine falls back to its self-asserted label or its + # id. Reporting it as `labelled as ` described neither. + body.append("cleared the label on ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" · ", style=theme.FAINT) + body.append("it now shows as its own label, or its id", style=theme.LABEL) + else: + body.append("labelled ", style=theme.TEXT) + body.append(machine_id, style=theme.ACCENT) + body.append(" as ", style=theme.TEXT) + body.append(label, style=f"bold {theme.TEXT}") + body.append(" · ", style=theme.FAINT) + body.append("the machine id itself is unchanged", style=theme.LABEL) + _notice_box(body, color=theme.SUCCESS, title="renamed") + + +def deployment_unchanged(machine_id: str) -> None: + """A no-op deploy. Calm, not a warning — the desired state already holds.""" + body = Text() + body.append("= ", style=theme.FAINT) + body.append(machine_id, style=theme.ACCENT) + body.append(" already matches", style=theme.TEXT) + body.append(" · ", style=theme.FAINT) + body.append("nothing deployed", style=theme.LABEL) + _notice_box(body, color=theme.ACCENT, title="no change") + + +def render_decision_timeline(data: dict) -> None: + """``fp guardrails timeline`` — one row per bucket, with the numbers. + + Replaces two bare sparkline strings. A sparkline is a fine *accent* beside a + headline number, which is why the summary keeps one — but on its own it has + no axis, no scale and no counts, so it cannot answer the question the command + exists for: *when* did enforcement bite, and how hard. Two rows of blocks + told you a shape and nothing you could act on. + """ + points = (data.get("series") or [{}])[0].get("points") or [] + if not points: + info("no decisions recorded in this window") + return + + bucket_ms = data.get("bucketMs") or 3_600_000 + # Label by what the bucket actually spans: hourly buckets want a clock, + # multi-day ones want a date, and printing 09:00 for a 24-hour bucket is how + # a chart lies about its own resolution. + fmt = "%H:%M" if bucket_ms < 86_400_000 else "%d %b" + peak = max((p.get("total", 0) for p in points), default=0) + width = 18 + + rows = [] + for p in points: + total = p.get("total", 0) or 0 + deny = p.get("deny", 0) or 0 + instruct = p.get("instruct", 0) or 0 + when = datetime.fromtimestamp((p.get("t") or 0) / 1000, tz=timezone.utc).strftime(fmt) + + # Denies are drawn INSIDE the total bar rather than beside it, so the + # blocked share is legible without arithmetic. + filled = 0 if peak <= 0 else max(1, round(total / peak * width)) if total else 0 + den_cells = 0 if total <= 0 else min(filled, max(1, round(deny / total * filled)) if deny else 0) + bar = Text() + bar.append("█" * den_cells, style=theme.ERROR) + bar.append("█" * (filled - den_cells), style=theme.ACCENT) + bar.append("·" * (width - filled), style=theme.BAR_EMPTY) + + rows.append([ + Text(when, style=theme.TEXT_DIM), + bar, + Text(str(total) if total else "—", style=theme.TEXT if total else theme.FAINT), + Text(str(deny) if deny else "—", style=theme.ERROR if deny else theme.FAINT), + Text(str(instruct) if instruct else "—", style=theme.AMBER if instruct else theme.FAINT), + ]) + + totals = sum(p.get("total", 0) or 0 for p in points) + denies = sum(p.get("deny", 0) or 0 for p in points) + title = Text() + title.append("decisions", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{data.get('hours', 24)}h", style="bold white") + title.append(" · ", style=theme.FAINT) + title.append(f"{totals} evaluated", style=theme.LABEL) + title.append(" · ", style=theme.FAINT) + title.append(f"{denies} blocked", style=theme.ERROR if denies else theme.LABEL) + render_list_panel("timeline", header=["time", "activity", "total", "denied", "instructed"], + rows=rows, days=set(), order=None, + empty_message="no decisions recorded in this window", title=title) + hint("red is the blocked share of each bar · times are UTC") + + +_DECISION_STYLE = {"deny": theme.ERROR, "instruct": theme.AMBER, "allow": theme.SUCCESS} +_DECISION_GLYPH = {"deny": "✗", "instruct": "!", "allow": "✓"} + + +def render_policy_test(run: Any, *, tool: str, command: Optional[str] = None, + file_path: Optional[str] = None, + expected: Optional[str] = None) -> None: + """``fp policies test`` — the verdict, and which policy produced it. + + Leads with the overall decision because that is the question asked. The + per-policy rows follow, since a file may register several and only one of + them refusing is what matters. + """ + overall = run.decision + colour = _DECISION_STYLE.get(overall, theme.TEXT) + head = Text() + head.append(f"{_DECISION_GLYPH.get(overall, '·')} ", style=f"bold {colour}") + head.append(overall.upper(), style=f"bold {colour}") + subject = command or file_path or "(no input)" + ctx_line = Text() + ctx_line.append(f"{tool} ", style=theme.LABEL) + ctx_line.append(subject, style=theme.TEXT) + + rows = [] + for r in run.results: + if "error" in r: + rows.append(Text(f" ✗ {r.get('name')}: {r['error']}", style=theme.ERROR)) + continue + d = r.get("decision", "allow") + line = Text(" ") + line.append(_DECISION_GLYPH.get(d, "·"), style=_DECISION_STYLE.get(d, theme.TEXT)) + line.append(f" {r.get('name')}", style=theme.TEXT) + line.append(f" {d}", style=_DECISION_STYLE.get(d, theme.TEXT_DIM)) + if r.get("reason"): + line.append(f" · {r['reason']}", style=theme.LABEL) + rows.append(line) + + if expected is not None: + rows.append(Text()) + verdict = Text(" ") + if run.decision == expected: + verdict.append("✓ ", style=theme.SUCCESS) + verdict.append(f"matched --expect {expected}", style=theme.LABEL) + else: + verdict.append("✗ ", style=theme.ERROR) + verdict.append(f"expected {expected}, got {run.decision}", style=theme.ERROR) + rows.append(verdict) + + card = Panel(Group(head, ctx_line, Text(), *rows), box=ROUNDED, border_style=colour, + title=Text("policy test", style=f"bold {colour}"), + title_align="left", padding=(0, 1), expand=False) + _stdout.print(); _stdout.print(Padding(card, (0, 0, 0, 2))); _stdout.print() + hint("this is a dry run — it does not prove the daemon sends the same context") + + +def render_composed_policy(prompt: str, source: str, syntax: Any, + *, saved_to: Optional[str] = None) -> None: + """``fp policies compose`` — the draft, and whether it even parses. + + Prints the source rather than publishing it. A generated policy that + deploys itself is a generated policy nobody read. + """ + head = Text() + head.append("drafted from ", style=theme.LABEL) + head.append(prompt, style=theme.TEXT) + status = Text() + if syntax.ok and syntax.checked: + status.append("✓ ", style=theme.SUCCESS) + status.append("parses as JavaScript", style=theme.LABEL) + elif not syntax.checked: + status.append("· ", style=theme.FAINT) + status.append("not syntax-checked (node not found)", style=theme.LABEL) + else: + status.append("✗ ", style=theme.ERROR) + status.append("does NOT parse — review before publishing", style=theme.ERROR) + body = [head, status] + if saved_to: + saved = Text() + saved.append("saved to ", style=theme.LABEL) + saved.append(saved_to, style=theme.ACCENT) + body.append(saved) + card = Panel(Group(*body), box=ROUNDED, border_style=theme.ACCENT, + title=Text("draft policy", 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() + _stdout.print(source) + _stdout.print() + hint("review it, then `fp policies publish ` — or re-run with --publish ") + + +def policy_published_brief(p: Any) -> None: + body = Text() + body.append("✓ ", style=theme.SUCCESS) + body.append("published ", style=theme.TEXT) + body.append(p.id, style=theme.ACCENT) + body.append(f" v{p.version}", style=theme.TEXT_DIM) + _notice_box(body, color=theme.SUCCESS, title="published") + + +def render_deployment_history(machine_id: str, entries: Sequence[dict]) -> None: + """``fp fleet history`` — one row per generation, newest first. + + Was a bare `print` per line, which put an unaligned wall of timestamps and + comma-joined ids on stdout while every other list in the CLI is a panel. + + The `change` column is the point of reading history at all: what moved + between this generation and the one below it. A reissue — the server + rewriting a deployment because a policy was disabled or re-enabled — shows + up as an ordinary +/- and is otherwise indistinguishable from an operator + deploy, which is worth being able to see. + """ + rows = [] + prev = None + # oldest first so each row can be diffed against the one before it, then + # reversed for display — newest first is how you read a history. + ordered = sorted(entries, key=lambda e: e.get("deployment") or 0) + diffs = {} + for e in ordered: + # Keyed by id, comparing (version, effect). Keying by `id@version` + # instead made an effect flip invisible: enforce → observe is a policy + # 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")) + for p in (e.get("policies") or [])} + if prev is None: + diffs[e.get("deployment")] = [("+", i) for i in sorted(cur)] + else: + diffs[e.get("deployment")] = ( + [("+", 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]] + ) + prev = cur + + newest_first = sorted(entries, key=lambda e: e.get("deployment") or 0, reverse=True) + # The shared time column: clock time, with the date folded in only when the + # rows span more than a day. Generations land seconds apart, so a date-only + # cell made twenty-one of them look identical. + tcells, days = _row_times([_parse_iso(e.get("updatedAt", "") or "") for e in newest_first]) + + for e, tcell in zip(newest_first, tcells): + gen = e.get("deployment") + pols = sorted(f"{p.get('id')}" for p in (e.get("policies") or [])) + change = Text() + for i, (sign, ref) in enumerate(diffs.get(gen) or []): + if i: + change.append(" ") + # Same vocabulary as the deploy plan: + added, ~ changed, - removed. + change.append(sign, style={"+": theme.SUCCESS, "~": theme.AMBER}.get( + sign, theme.ERROR)) + change.append(ref, style=theme.TEXT_DIM) + if not change.plain: + change = Text("no change", style=theme.FAINT) + rows.append([ + Text(f"#{gen}", style=theme.TEXT), + Text(tcell or (e.get("updatedAt", "") or "-"), style=theme.TEXT_DIM), + Text(str(len(pols)), style=theme.TEXT_DIM if pols else theme.FAINT), + change, + Text(", ".join(pols) or "(none)", style=theme.TEXT_DIM if pols else theme.FAINT), + ]) + title = Text() + title.append(machine_id, style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{len(rows)} generations", style=theme.LABEL) + render_list_panel("history", header=["gen", "when", "n", "change", "policies"], + rows=rows, days=days, order=None, + empty_message="no deployment history", last_col="ellipsis", title=title) + + +def render_fleet_diff(rows: Sequence[dict]) -> None: + """``fp fleet diff`` — intent vs delivery, per machine. + + Was one `info()` line per machine plus a warning, which is fine for two + machines and unreadable for twenty. The drifted rows are the whole reason to + run it, so they carry the colour and the summary counts them. + """ + out = [] + for r in rows: + drifted = bool(r.get("drifted")) + intended = r.get("intended") + delivered = r.get("delivered") + out.append([ + Text(str(r.get("machineId") or "-"), style=theme.TEXT), + Text(f"#{intended}" if intended is not None else "—", + style=theme.TEXT_DIM if intended is not None else theme.FAINT), + Text(f"#{delivered}" if delivered is not None else "—", + style=theme.AMBER if drifted else (theme.TEXT_DIM if delivered is not None else theme.FAINT)), + Text("behind" if drifted else ("in sync" if intended is not None else "nothing deployed"), + style=theme.AMBER if drifted else (theme.SUCCESS if intended is not None else theme.FAINT)), + ]) + drifted_n = sum(1 for r in rows if r.get("drifted")) + title = Text() + title.append("drift", style=f"bold {theme.ACCENT}") + title.append(" · ", style=theme.FAINT) + title.append(f"{drifted_n}", style=f"bold {theme.AMBER if drifted_n else theme.SUCCESS}") + title.append(f" of {len(rows)} behind", style=theme.LABEL) + render_list_panel("diff", header=["machine", "intended", "applied", "state"], + rows=out, days=set(), order=None, + empty_message="no machines have checked in yet", title=title) + if drifted_n: + hint("a machine is 'behind' until it next polls — it is still enforcing its previous set") diff --git a/fp-cli/fp_cli/policy_check.py b/fp-cli/fp_cli/policy_check.py new file mode 100644 index 000000000..3c295e67f --- /dev/null +++ b/fp-cli/fp_cli/policy_check.py @@ -0,0 +1,241 @@ +"""Check a policy before it reaches a fleet. + +Nothing between an author and a machine validates policy source today. The CLI +rejects a NUL byte, the server checks the id charset and a 1 MiB ceiling — and +neither looks at whether the file is parseable JavaScript at all. So this +publishes, deploys, and reaches every machine in the fleet: + + echo 'this is not javascript {{{' | fp policies publish broken + +It then fails at enforcement time, on the machine, where nobody is watching. +That is the worst available place for a syntax error to surface, which is what +these two checks exist to move. + +``check_syntax`` is the cheap one and runs before every publish. ``run_policy`` +is the deliberate one behind ``fp policies test``: it actually executes the +policy against a context you describe, so an author — human or agent — can see +allow/deny/instruct before anyone's machine does. + +Both shell out to ``node``. Neither makes it a hard dependency: a machine +without node still publishes, with a stated reason rather than a silent skip. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import re +import tempfile +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +#: Long enough for a cold node start on a loaded laptop, short enough that a +#: policy with an accidental infinite loop fails the command instead of hanging +#: it. A policy that cannot decide in five seconds cannot sit on a hook either. +_TIMEOUT_SECS = 5 + +#: SGR escapes node emits around its stack frames. +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + + +def node_available() -> bool: + return shutil.which("node") is not None + + +@dataclass +class SyntaxResult: + ok: bool + #: None when the check could not run at all (no node). Distinct from `ok`, + #: because "we did not look" must never render as "we looked and it passed". + checked: bool + message: str = "" + + def to_dict(self) -> Dict[str, Any]: + return {"ok": self.ok, "checked": self.checked, "message": self.message} + + +def check_syntax(source: str) -> SyntaxResult: + """Parse-check policy source with ``node --check``. + + Written to a ``.mjs`` file so node parses it as a module: policies are ESM + (`import { deny } from "failproofai"`), and checking that as a script would + reject every real policy for using `import`. + """ + if not node_available(): + return SyntaxResult( + ok=True, checked=False, + message="node was not found on PATH, so the policy was not syntax-checked", + ) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "policy.mjs") + with open(path, "w", encoding="utf-8") as fh: + fh.write(source) + try: + proc = subprocess.run( + ["node", "--check", path], + capture_output=True, text=True, timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return SyntaxResult(ok=False, checked=True, + message="the syntax check timed out") + except OSError as exc: + return SyntaxResult(ok=True, checked=False, + message=f"could not run node ({exc}); source not checked") + if proc.returncode == 0: + return SyntaxResult(ok=True, checked=True) + # node prints the offending line, a caret, the SyntaxError — and then its own + # internal stack and version banner. The first three are the whole value of + # the check; the rest is node talking about itself inside an error box about + # the user's policy. + raw = (proc.stderr or proc.stdout or "").strip().replace(path, "") + # node colourises its stack frames, so the marker lines arrive with ANSI + # prefixes and a plain startswith() sails straight past them. Strip escapes + # before matching, and from the kept text too — this is going inside a box + # the CLI is already styling. + raw = _ANSI.sub("", raw) + keep = [] + for line in raw.splitlines(): + stripped = line.strip() + if stripped.startswith("at ") or stripped.startswith("Node.js v"): + break + keep.append(line) + detail = "\n".join(keep).strip() or raw + return SyntaxResult(ok=False, checked=True, message=detail) + + +#: A stand-in for the `failproofai` package a policy imports. Policies are +#: authored against the real one; this provides the same three helpers and +#: collects what `customPolicies.add` registers, so a policy can be executed +#: without installing anything. +_SHIM = """\ +export const allow = (reason) => reason ? { decision: "allow", reason } : { decision: "allow" }; +export const deny = (reason) => ({ decision: "deny", reason }); +export const instruct = (reason) => ({ decision: "instruct", reason }); +export const registered = []; +export const customPolicies = { add: (p) => { registered.push(p); } }; +export default { allow, deny, instruct, customPolicies }; +""" + +_RUNNER = """\ +import { registered } from "failproofai"; +import "./policy.mjs"; + +const ctx = JSON.parse(process.argv[2]); +const out = []; +for (const p of registered) { + try { + const r = await p.fn(ctx); + out.push({ name: p.name ?? "(unnamed)", description: p.description ?? null, + decision: r?.decision ?? "allow", reason: r?.reason ?? null }); + } catch (e) { + out.push({ name: p.name ?? "(unnamed)", error: String(e && e.message || e) }); + } +} +process.stdout.write(JSON.stringify({ policies: out })); +""" + + +@dataclass +class PolicyRun: + ok: bool + results: List[Dict[str, Any]] + error: str = "" + + @property + def decision(self) -> str: + """The strictest decision any policy returned. + + deny beats instruct beats allow, because that is how a fleet of policies + composes: one refusal is a refusal regardless of what the others said. + """ + decisions = [r.get("decision") for r in self.results if "decision" in r] + for level in ("deny", "instruct"): + if level in decisions: + return level + return "allow" + + def to_dict(self) -> Dict[str, Any]: + return {"ok": self.ok, "decision": self.decision, + "policies": self.results, "error": self.error} + + +def run_policy( + source: str, + *, + tool: str = "Bash", + command: Optional[str] = None, + file_path: Optional[str] = None, + event: str = "PreToolUse", + tool_input: Optional[Dict[str, Any]] = None, +) -> PolicyRun: + """Execute a policy against one synthetic context and report each verdict. + + Runs in a temp directory with the shim beside it, so the policy's + `import ... from "failproofai"` resolves without a node_modules anywhere. + Nothing is installed and nothing outside the temp directory is written. + + This is a DRY RUN, not the enforcement path: it proves the policy parses, + registers, and returns a decision for the input described. It cannot prove + the daemon will feed it the same context. + """ + if not node_available(): + return PolicyRun(ok=False, results=[], + error="node was not found on PATH, so the policy could not be run") + + payload: Dict[str, Any] = dict(tool_input or {}) + if command is not None: + payload.setdefault("command", command) + if file_path is not None: + payload.setdefault("file_path", file_path) + ctx = {"eventType": event, "toolName": tool, "toolInput": payload, "payload": payload} + + with tempfile.TemporaryDirectory() as tmp: + # The shim goes in `node_modules/failproofai/` rather than beside the + # policy, so the bare specifier a real policy writes — + # `import { deny } from "failproofai"` — resolves by node's ordinary + # lookup. The policy under test is then byte-identical to the one that + # gets published; an import map would have meant testing a rewritten + # file, and import-map support also varies by node version. + pkg = os.path.join(tmp, "node_modules", "failproofai") + os.makedirs(pkg) + with open(os.path.join(pkg, "package.json"), "w", encoding="utf-8") as fh: + fh.write(json.dumps({"name": "failproofai", "version": "0.0.0", + "type": "module", "main": "index.mjs", + "exports": "./index.mjs"})) + with open(os.path.join(pkg, "index.mjs"), "w", encoding="utf-8") as fh: + fh.write(_SHIM) + for name, body in (("policy.mjs", source), ("run.mjs", _RUNNER)): + with open(os.path.join(tmp, name), "w", encoding="utf-8") as fh: + fh.write(body) + with open(os.path.join(tmp, "package.json"), "w", encoding="utf-8") as fh: + fh.write(json.dumps({"type": "module"})) + try: + proc = subprocess.run( + ["node", "run.mjs", json.dumps(ctx)], + cwd=tmp, capture_output=True, text=True, timeout=_TIMEOUT_SECS, + ) + except subprocess.TimeoutExpired: + return PolicyRun(ok=False, results=[], + error=f"the policy did not finish within {_TIMEOUT_SECS}s") + except OSError as exc: + return PolicyRun(ok=False, results=[], error=f"could not run node: {exc}") + + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip().splitlines() + return PolicyRun(ok=False, results=[], + error="\n".join(detail[:6]) or "the policy failed to run") + try: + data = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + return PolicyRun(ok=False, results=[], error="the policy produced no readable result") + + results = data.get("policies") or [] + if not results: + return PolicyRun( + ok=False, results=[], + error=("the file registered no policies — a policy calls " + "`customPolicies.add({...})`; check it does, and that the call runs " + "at import time"), + ) + return PolicyRun(ok=True, results=results) diff --git a/fp-cli/skill/references/commands.md b/fp-cli/skill/references/commands.md index fb97a0b4f..d51b66485 100644 --- a/fp-cli/skill/references/commands.md +++ b/fp-cli/skill/references/commands.md @@ -57,6 +57,7 @@ Exactly one credential is in play per invocation, chosen in this order: - **`--yes` / `-y`** explicitly skips a confirm prompt. (Confirms are also auto-skipped on a non-TTY — i.e. whenever Claude runs it — so always confirm with the user yourself first.) - **`--all` + `--limit`**: `--limit` (`-n`) defaults to **50**; `--all` auto-paginates (client chunks of 200) **up to `--limit`**, NOT without bound. So a bare `--all` still stops at 50 rows. For a full sweep on `events/sessions/evals/errors`, pass a high explicit cap: **`--all --limit 1000`** (or higher). To just get window totals, use `--aggregate` (covers the whole window regardless of row caps). - **`--fields a,b,c`** projects only those keys (where supported: sessions/evals, keys, query list). +- **Policy source** (`policies publish` / `policies test`) comes from a path, `@path`, a pipe, `-`, or an interactive paste. A path that is not readable UTF-8 text — a binary file pointed at by mistake — is refused by name (**exit 2**), as is a missing path; neither reaches the server. - **`--since `** relative window — one of `15m`, `1h`, `6h`, `24h`, `7d`, `all` (any other value is a usage error, exit 2). `--from`/`--to` take ISO timestamps **with `T` and a timezone** (e.g. `2026-06-01T00:00:00Z`) — space-separated or tz-less is a usage error (exit 2). - **`--file payload.json`** (or `--file -` for stdin) supplies a full JSON request body on `alerts`, `settings set`, and `users create/update` — mutually exclusive with the discrete flags. Saved-query SQL uses `--sql @file.sql`. - **Multi-value filters** are CSV → `IN (...)` (union within a filter, AND across filters): `--event-type tool_use,tool_result`. `--search` is repeated/OR (matches ANY term), payload-only. @@ -209,3 +210,45 @@ 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` — one row per published VERSION (versions are immutable and all stay addressable), newest of each policy first; the title counts distinct policies and captions the version total. `state` is active / disabled / archived. JSON `{policies:[…]}` — also every version, so deduplicate on `id` for one row per policy. +- `policies show ` — the NEWEST version, including the full `source`. +- `policies publish [SOURCE] [--description "…"] [--no-verify]` — mints a **new version**; never edits one. SOURCE is a path, `@path`, `-`, a pipe, or omitted to paste on a TTY (Ctrl-D ends). The source is **parse-checked with node** before it is sent; nothing downstream does this, and a broken policy otherwise fails on the machine at enforcement time. `--no-verify` skips it, and a host without node publishes with a warning rather than a block. **Publishing deploys nothing** — the version is unused until `fleet deploy` puts it on a machine. +- `policies enable ` · `policies disable [-y]` — **disable REMOVES the policy from every deployment carrying it**, reissuing each affected machine at a new generation (visible in `fleet history`). `enable` is the exact inverse — it puts the policy back into every deployment it was removed from, reissuing those machines again. Nothing needs redeploying by hand, and `machinesUpdated` in the JSON reports the count for both directions. +- `policies test [SOURCE] [--tool Bash] [--command "…"] [--file PATH] [--event PreToolUse] [--expect allow|deny|instruct]` — run a policy LOCALLY and print what it decides. Executes the real file (bare `import { deny } from "failproofai"` and all) against a synthetic context; nothing is published, nothing installed. Needs `node`. `--expect` asserts the decision and exits 1 when it differs — a correct `deny` is a PASSING test, so the decision alone never sets the exit code. JSON `{ok, decision, policies:[{name,decision,reason}], expected, met}`; `decision` is the strictest any registered policy returned. +- `policies compose "" [--out FILE] [--publish ID]` — the assistant drafts policy source from plain English. Prints it and stops by default: a generated policy that deploys itself is one nobody read. `--publish` still syntax-checks first. Needs `agent:use`. The composer has a **30s server-side limit** — a long or vague description simply does not finish, and raising `--timeout` does not help because the cut is not client-side. Retry with something shorter and more specific. +- `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 · pol · intended · applied · seen · events · state`. `intended` is the generation deployed, `applied` what the machine last collected (they differ until it polls), and `seen` how long since it last reported anything — a machine can be in sync and dead, or alive and behind, which are different problems. JSON `{machines, deployments}` with raw epoch-ms timestamps plus the computed `drifted`. +- `fleet show ` — the set the machine is told to run, **and whether it has collected it**. Reads both the deployment and the machine record, so it reports `not yet collected` / `machine is on #N` / `collected` alongside who deployed it, when, and last-seen. A machine can be told to run a policy it has never picked up; the policy list alone cannot tell you which. JSON `{machine, deployment}` with raw timestamps and both label fields; `deployment: null` when nothing is deployed. +- `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. + + **Idempotent.** Re-running the same deploy is a no-op that exits 0 without writing — desired-state semantics, so a retrying harness succeeds rather than errors. `applied` in the JSON is the only way to tell "changed it" from "already matched"; the exit code is 0 for both. The no-op short-circuits before the write, so a reader without `policies:write` also gets 0 there — exit 0 from a no-op is not proof of write access. + + **Exit codes.** A malformed ref (`bad ref!!`, `id:banana`, empty), or `--set` combined with `--add`/`--remove`, is a usage error → **exit 2**, like every other bad flag value. A ref that parses but names something that does not exist (`--add ghost-policy`, an unpublished `@version`) is **exit 1**; an unknown machine is **exit 6**. Branch on these rather than on the message. + + 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. A machine id nobody has reported under is refused (exit 6), not rendered as an empty fleet. +- `fleet history ` · `fleet rollback [-y]` — rollback mints a NEW generation carrying the old set; history stays append-only. The `change` column uses the deploy plan's vocabulary: `+` added, `-` removed, `~` same policy at a different version or effect (an enforce → observe flip is a policy that stopped blocking, so it is never "no change"). +- `fleet rename "