Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
98eabb0
feat(fp-cli): fp policies, fp fleet and fp guardrails
SiddarthAA Aug 19, 2026
cf9e396
fix(fp-cli): a typo'd machine id minted a machine instead of failing
SiddarthAA Aug 19, 2026
c2a0ccc
fix(fp-cli): two renderer strings that stated things that were not true
SiddarthAA Aug 19, 2026
22b3268
docs(fp-cli): a no-op deploy exits 0, and that is not proof of write …
SiddarthAA Aug 19, 2026
ccebc5d
fix(fp-cli): a typo'd machine read as "nothing deployed", and a binar…
SiddarthAA Aug 19, 2026
3665df6
feat(fp-cli): group policies, fleet and guardrails under ENFORCE
SiddarthAA Aug 19, 2026
1366765
feat(fp-cli): the new commands confirm and report like the rest of th…
SiddarthAA Aug 19, 2026
df7153a
feat(fp-cli): guardrails timeline says when enforcement bit, and how …
SiddarthAA Aug 19, 2026
6f963b0
feat(fp-cli): validate policies, run them locally, and draft them fro…
SiddarthAA Aug 19, 2026
3fb29fd
fix(fp-cli): refuse a disabled policy before drawing a plan, and say …
SiddarthAA Aug 19, 2026
2c824f5
docs(fp-cli): nine subcommands did not document their --json shape
SiddarthAA Aug 19, 2026
79977cb
fix(fp-cli): enable RESTORES a policy to its deployments — I document…
SiddarthAA Aug 19, 2026
020dc4c
refactor(fp-cli): guardrails is summary and timeline, and a container…
SiddarthAA Aug 19, 2026
ccc638f
fix(fp-cli): rename appeared to do nothing, and two views printed raw…
SiddarthAA Aug 19, 2026
12f185b
feat(fp-cli): fleet show says whether the machine actually has the de…
SiddarthAA Aug 19, 2026
159ccd3
feat(fp-cli): fleet list shows liveness, and stops fetching what it d…
SiddarthAA Aug 19, 2026
0e7708a
fix(fp-cli): the publish card claimed "not deployed anywhere" from a …
SiddarthAA Aug 19, 2026
4794901
fix(fp-cli): six commands that described the wrong thing, and a trace…
SiddarthAA Aug 19, 2026
7d66acd
docs(fp-cli): the deploy exit codes a script has to branch on
SiddarthAA Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions fp-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions fp-cli/fp_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
169 changes: 169 additions & 0 deletions fp-cli/fp_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@
AuditFinding,
AuditRun,
DashboardUser,
Deployment,
Evaluation,
Incident,
IncidentComment,
IncidentSubscriber,
Machine,
Page,
PolicyRef,
PolicyVersion,
QueryResult,
SavedQuery,
Session,
Expand Down Expand Up @@ -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"
),
}


Expand Down Expand Up @@ -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",
)
Loading