fp-cloud-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. fp_cli/__init__.py +10 -0
  2. fp_cli/__main__.py +4 -0
  3. fp_cli/_click_compat.py +87 -0
  4. fp_cli/_context.py +332 -0
  5. fp_cli/_version.py +1 -0
  6. fp_cli/analytics.py +432 -0
  7. fp_cli/analytics_config.py +77 -0
  8. fp_cli/analytics_registry.py +83 -0
  9. fp_cli/app.py +492 -0
  10. fp_cli/auth.py +160 -0
  11. fp_cli/client.py +1738 -0
  12. fp_cli/commands/__init__.py +0 -0
  13. fp_cli/commands/_write.py +214 -0
  14. fp_cli/commands/agent_cmds.py +407 -0
  15. fp_cli/commands/alerts_cmds.py +445 -0
  16. fp_cli/commands/audits_cmds.py +1054 -0
  17. fp_cli/commands/auth_cmds.py +512 -0
  18. fp_cli/commands/errors_cmds.py +190 -0
  19. fp_cli/commands/evals_cmds.py +161 -0
  20. fp_cli/commands/events_cmds.py +159 -0
  21. fp_cli/commands/fleet_cmds.py +416 -0
  22. fp_cli/commands/guardrails_cmds.py +148 -0
  23. fp_cli/commands/incidents_cmds.py +693 -0
  24. fp_cli/commands/keys_cmds.py +407 -0
  25. fp_cli/commands/list_cmds.py +63 -0
  26. fp_cli/commands/orgs_cmds.py +319 -0
  27. fp_cli/commands/policies_cmds.py +499 -0
  28. fp_cli/commands/queries_cmds.py +378 -0
  29. fp_cli/commands/sessions_cmds.py +151 -0
  30. fp_cli/commands/settings_cmds.py +150 -0
  31. fp_cli/commands/usage_cmds.py +35 -0
  32. fp_cli/commands/users_cmds.py +404 -0
  33. fp_cli/config.py +330 -0
  34. fp_cli/dates.py +78 -0
  35. fp_cli/enforcement.py +345 -0
  36. fp_cli/errors.py +98 -0
  37. fp_cli/models.py +902 -0
  38. fp_cli/orgs.py +30 -0
  39. fp_cli/output.py +6660 -0
  40. fp_cli/permissions.py +209 -0
  41. fp_cli/policy_check.py +290 -0
  42. fp_cli/py.typed +0 -0
  43. fp_cli/select.py +322 -0
  44. fp_cli/theme.py +53 -0
  45. fp_cloud_cli-0.0.1.dist-info/METADATA +335 -0
  46. fp_cloud_cli-0.0.1.dist-info/RECORD +50 -0
  47. fp_cloud_cli-0.0.1.dist-info/WHEEL +5 -0
  48. fp_cloud_cli-0.0.1.dist-info/entry_points.txt +2 -0
  49. fp_cloud_cli-0.0.1.dist-info/licenses/LICENSE +42 -0
  50. fp_cloud_cli-0.0.1.dist-info/top_level.txt +1 -0
fp_cli/models.py ADDED
@@ -0,0 +1,902 @@
1
+ """Plain dataclasses mirroring the FailproofAI Cloud API shapes.
2
+
3
+ These are deliberately free of any I/O or framework dependency so the client
4
+ layer (and a future MCP server) can return them directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, Generic, List, Optional, TypeVar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass
16
+ class Page(Generic[T]):
17
+ """A single page of cursor-paginated results."""
18
+
19
+ items: List[T]
20
+ next_cursor: Optional[int] = None
21
+
22
+
23
+ @dataclass
24
+ class OrgMembership:
25
+ """One org the operator belongs to, with the grants resolved for that org.
26
+
27
+ Mirrors the dashboard's ``OrgMembership`` (``dashboard/lib/types.ts``):
28
+ permissions are now *per org*, replacing the old flat global list.
29
+ """
30
+
31
+ org_id: str
32
+ org_slug: str
33
+ org_name: str
34
+ permissions: List[str] = field(default_factory=list)
35
+ permission_set: Optional[str] = None
36
+ #: Operator-managed per-org feature flags (e.g. ``demo``). Empty when the org
37
+ #: has none, and also empty against a server predating the field — the two are
38
+ #: indistinguishable here by design, because every consumer treats "no flags"
39
+ #: and "flags unknown" the same way.
40
+ feature_flags: List[str] = field(default_factory=list)
41
+
42
+ @classmethod
43
+ def from_dict(cls, d: Dict[str, Any]) -> "OrgMembership":
44
+ return cls(
45
+ org_id=str(d.get("org_id", "")),
46
+ org_slug=str(d.get("org_slug", "")),
47
+ org_name=str(d.get("org_name", "")),
48
+ permissions=list(d.get("permissions") or []),
49
+ permission_set=d.get("permission_set"),
50
+ # from_dict is an explicit allowlist: a field absent from it is
51
+ # dropped silently, so `fp whoami --json` would omit the key
52
+ # entirely rather than report an empty set.
53
+ feature_flags=list(d.get("feature_flags") or []),
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class SessionUser:
59
+ """The authenticated operator. Multi-tenant: permissions live per-membership.
60
+
61
+ The server dropped the flat ``permissions`` field (``dashboard/lib/session.ts``);
62
+ a user's effective grants depend on the *active org*. ``is_instance_admin`` may
63
+ browse orgs without a membership but gets no data permissions there.
64
+ """
65
+
66
+ id: str
67
+ email: str
68
+ is_instance_admin: bool = False
69
+ memberships: List[OrgMembership] = field(default_factory=list)
70
+
71
+ @classmethod
72
+ def from_dict(cls, d: Dict[str, Any]) -> "SessionUser":
73
+ return cls(
74
+ id=str(d.get("id", "")),
75
+ email=str(d.get("email", "")),
76
+ is_instance_admin=bool(d.get("is_instance_admin", False)),
77
+ memberships=[OrgMembership.from_dict(m) for m in (d.get("memberships") or [])],
78
+ )
79
+
80
+ def membership(self, org_slug: Optional[str]) -> Optional["OrgMembership"]:
81
+ if not org_slug:
82
+ return None
83
+ for m in self.memberships:
84
+ if m.org_slug == org_slug:
85
+ return m
86
+ return None
87
+
88
+ def permissions_for(self, org_slug: Optional[str]) -> List[str]:
89
+ m = self.membership(org_slug)
90
+ return list(m.permissions) if m else []
91
+
92
+ @property
93
+ def org_slugs(self) -> List[str]:
94
+ return [m.org_slug for m in self.memberships if m.org_slug]
95
+
96
+
97
+ @dataclass
98
+ class AgentEvent:
99
+ """One event row. Two server sources feed this model:
100
+
101
+ * ``GET /api/events`` (full) — carries the fat ``payload`` column. Used only by the
102
+ opt-in heavy path (``events --full`` / ``--fields payload``).
103
+ * ``GET /api/events/summary`` (light) — payload-FREE. Carries the server-precomputed
104
+ ``summary`` / ``is_error`` plus the promoted ``error_type`` / ``output_tokens``
105
+ columns. The default ``events`` view and all of ``errors`` use this, so their
106
+ responses never include the fat payload (a free-text search may still scan it
107
+ server-side).
108
+
109
+ Fields absent on one source default cleanly (``payload`` → ``{}`` on light rows;
110
+ ``summary``/``is_error``/… → empty on full rows), so a single model serves both.
111
+ """
112
+
113
+ id: int
114
+ session_id: str
115
+ agent_id: str
116
+ event_type: str
117
+ ts: str
118
+ payload: Dict[str, Any] = field(default_factory=dict)
119
+ environment: str = ""
120
+ # Light-feed columns (GET /events/summary) — server-precomputed, never derived from
121
+ # payload client-side.
122
+ summary: str = ""
123
+ is_error: bool = False
124
+ error_type: Optional[str] = None
125
+ output_tokens: Optional[int] = None
126
+ # Context-window checker: present on BOTH feeds (null for non-model events / unknown
127
+ # models). Now surfaced in --json instead of being silently dropped.
128
+ context_window: Optional[int] = None
129
+ context_fill: Optional[float] = None
130
+
131
+ @classmethod
132
+ def from_dict(cls, d: Dict[str, Any]) -> "AgentEvent":
133
+ try:
134
+ event_id = int(d.get("id", 0))
135
+ except (TypeError, ValueError):
136
+ event_id = 0 # tolerate a null/non-numeric id rather than crashing the render
137
+ return cls(
138
+ id=event_id,
139
+ session_id=str(d.get("session_id", "")),
140
+ agent_id=str(d.get("agent_id", "")),
141
+ event_type=str(d.get("event_type", "")),
142
+ ts=str(d.get("ts", "")),
143
+ payload=d.get("payload") or {},
144
+ environment=str(d.get("environment", "")),
145
+ summary=str(d.get("summary") or ""),
146
+ is_error=bool(d.get("is_error", False)),
147
+ error_type=d.get("error_type"),
148
+ output_tokens=d.get("output_tokens"),
149
+ context_window=d.get("context_window"),
150
+ context_fill=d.get("context_fill"),
151
+ )
152
+
153
+
154
+ @dataclass
155
+ class Evaluation:
156
+ id: str # evaluation_id is a UUID (Postgres), not an integer
157
+ session_id: str
158
+ agent_id: str
159
+ environment: str
160
+ status: str
161
+ scores: Optional[Dict[str, float]] = None
162
+ reasoning: Optional[Dict[str, str]] = None
163
+ summary: Optional[str] = None
164
+ error: Optional[str] = None
165
+ attempt_count: int = 0
166
+ duration_ms: Optional[int] = None
167
+ completed_at: str = ""
168
+ created_at: str = ""
169
+
170
+ @classmethod
171
+ def from_dict(cls, d: Dict[str, Any]) -> "Evaluation":
172
+ return cls(
173
+ id=str(d.get("id", "")),
174
+ session_id=str(d.get("session_id", "")),
175
+ agent_id=str(d.get("agent_id", "")),
176
+ environment=str(d.get("environment", "")),
177
+ status=str(d.get("status", "")),
178
+ scores=d.get("scores"),
179
+ reasoning=d.get("reasoning"),
180
+ summary=d.get("summary"),
181
+ error=d.get("error"),
182
+ attempt_count=int(d.get("attempt_count", 0)),
183
+ duration_ms=d.get("duration_ms"),
184
+ completed_at=str(d.get("completed_at", "")),
185
+ created_at=str(d.get("created_at", "")),
186
+ )
187
+
188
+
189
+ @dataclass
190
+ class Session:
191
+ """One agent run — a row from the dashboard ``/api/sessions`` endpoint (the same
192
+ source the dashboard's sessions page uses), so the CLI's filters match it exactly.
193
+
194
+ A session's terminal **evaluation** (if any) arrives nested under
195
+ ``latest_evaluation``. For backward compatibility with the eval-shaped renderer and
196
+ with ``--json`` / ``--fields`` consumers, ``status`` and ``scores`` are **flattened
197
+ up** to the top level from that nested object (the full nested object is preserved as
198
+ ``latest_evaluation`` for completeness). A session that was never evaluated has an
199
+ empty ``status``/``scores`` and ``latest_evaluation = None``.
200
+ """
201
+
202
+ session_id: str
203
+ agent_id: str
204
+ environment: str
205
+ # Flattened up from latest_evaluation (back-compat: top-level status/scores).
206
+ status: str = ""
207
+ scores: Optional[Dict[str, float]] = None
208
+ # Full roster of every agent that ran in this session, server-sorted by
209
+ # event_count desc — ``[{"agent_id": str, "event_count": int}, ...]``.
210
+ # ``agent_id`` above is the root (first agent_start); ``agents`` is the whole
211
+ # cast. ``None`` on older servers that predate the multi-agent roster.
212
+ agents: Optional[List[Dict[str, Any]]] = None
213
+ # Session-level fields.
214
+ event_count: int = 0
215
+ started_at: str = ""
216
+ last_event_at: str = ""
217
+ first_event_id: Optional[int] = None
218
+ last_event_id: Optional[int] = None
219
+ # The full terminal evaluation (or None if the session was never evaluated).
220
+ latest_evaluation: Optional[Dict[str, Any]] = None
221
+
222
+ @classmethod
223
+ def from_dict(cls, d: Dict[str, Any]) -> "Session":
224
+ le = d.get("latest_evaluation") or {}
225
+ return cls(
226
+ session_id=str(d.get("session_id", "")),
227
+ agent_id=str(d.get("agent_id", "")),
228
+ environment=str(d.get("environment", "")),
229
+ status=str(le.get("status", "")), # flattened up for the renderer + back-compat
230
+ scores=le.get("scores"), # flattened up
231
+ agents=d.get("agents"), # full agent roster (list of {agent_id, event_count})
232
+ event_count=int(d.get("event_count", 0)),
233
+ started_at=str(d.get("started_at", "")),
234
+ last_event_at=str(d.get("last_event_at", "")),
235
+ first_event_id=d.get("first_event_id"),
236
+ last_event_id=d.get("last_event_id"),
237
+ latest_evaluation=d.get("latest_evaluation"),
238
+ )
239
+
240
+
241
+ @dataclass
242
+ class ApiKey:
243
+ id: str
244
+ name: str
245
+ permissions: List[str] = field(default_factory=list)
246
+ created_at: str = ""
247
+ revoked_at: Optional[str] = None
248
+ # The server sends this; `from_dict` is an allowlist, so omitting it here
249
+ # silently dropped it and `fp keys list` showed an expired key as
250
+ # active. None = never expires.
251
+ expires_at: Optional[str] = None
252
+
253
+ @classmethod
254
+ def from_dict(cls, d: Dict[str, Any]) -> "ApiKey":
255
+ return cls(
256
+ id=str(d.get("id", "")),
257
+ name=str(d.get("name", "")),
258
+ permissions=list(d.get("permissions") or []),
259
+ created_at=str(d.get("created_at", "")),
260
+ revoked_at=d.get("revoked_at"),
261
+ expires_at=d.get("expires_at"),
262
+ )
263
+
264
+
265
+ @dataclass
266
+ class SavedQuery:
267
+ id: str
268
+ name: str
269
+ description: str = ""
270
+ sql_text: str = ""
271
+ params: List[Dict[str, Any]] = field(default_factory=list)
272
+ created_by: Optional[str] = None
273
+ created_at: str = ""
274
+ updated_at: str = ""
275
+
276
+ @classmethod
277
+ def from_dict(cls, d: Dict[str, Any]) -> "SavedQuery":
278
+ return cls(
279
+ id=str(d.get("id", "")),
280
+ name=str(d.get("name", "")),
281
+ description=str(d.get("description", "")),
282
+ sql_text=str(d.get("sql_text", "")),
283
+ params=list(d.get("params") or []),
284
+ created_by=d.get("created_by"),
285
+ created_at=str(d.get("created_at", "")),
286
+ updated_at=str(d.get("updated_at", "")),
287
+ )
288
+
289
+
290
+ @dataclass
291
+ class QueryResult:
292
+ columns: List[Dict[str, str]] = field(default_factory=list)
293
+ rows: List[List[Any]] = field(default_factory=list)
294
+ truncated: bool = False
295
+ elapsed_ms: int = 0
296
+
297
+ @classmethod
298
+ def from_dict(cls, d: Dict[str, Any]) -> "QueryResult":
299
+ return cls(
300
+ columns=list(d.get("columns") or []),
301
+ rows=list(d.get("rows") or []),
302
+ truncated=bool(d.get("truncated", False)),
303
+ elapsed_ms=int(d.get("elapsed_ms", 0)),
304
+ )
305
+
306
+
307
+ @dataclass
308
+ class DashboardUser:
309
+ id: str
310
+ email: str
311
+ permissions: List[str] = field(default_factory=list)
312
+ permission_set: Optional[str] = None
313
+ permission_added: List[str] = field(default_factory=list)
314
+ permission_removed: List[str] = field(default_factory=list)
315
+ disabled_at: Optional[str] = None
316
+ is_protected: bool = False
317
+ created_at: str = ""
318
+ updated_at: str = ""
319
+
320
+ @classmethod
321
+ def from_dict(cls, d: Dict[str, Any]) -> "DashboardUser":
322
+ return cls(
323
+ id=str(d.get("id", "")),
324
+ email=str(d.get("email", "")),
325
+ permissions=list(d.get("permissions") or []),
326
+ permission_set=d.get("permission_set"),
327
+ permission_added=list(d.get("permission_added") or []),
328
+ permission_removed=list(d.get("permission_removed") or []),
329
+ disabled_at=d.get("disabled_at"),
330
+ is_protected=bool(d.get("is_protected", False)),
331
+ created_at=str(d.get("created_at", "")),
332
+ updated_at=str(d.get("updated_at", "")),
333
+ )
334
+
335
+
336
+ @dataclass
337
+ class SettingRow:
338
+ key: str
339
+ value: Any = None
340
+ updated_at: str = ""
341
+ updated_by: Optional[str] = None
342
+ scope: Optional[str] = None
343
+ schema: Optional[Dict[str, Any]] = None
344
+
345
+ @classmethod
346
+ def from_dict(cls, d: Dict[str, Any]) -> "SettingRow":
347
+ return cls(
348
+ key=str(d.get("key", "")),
349
+ value=d.get("value"),
350
+ updated_at=str(d.get("updated_at", "")),
351
+ updated_by=d.get("updated_by"),
352
+ scope=d.get("scope"),
353
+ schema=d.get("schema"),
354
+ )
355
+
356
+
357
+ @dataclass
358
+ class Alert:
359
+ id: str
360
+ name: str
361
+ description: Optional[str] = None
362
+ enabled: bool = True
363
+ trigger_kind: str = ""
364
+ trigger_spec: Dict[str, Any] = field(default_factory=dict)
365
+ min_breaches: int = 1
366
+ eval_window: int = 1
367
+ eval_interval_secs: int = 0
368
+ severity: str = ""
369
+ channels: List[Dict[str, Any]] = field(default_factory=list)
370
+ created_by: str = ""
371
+ created_at: str = ""
372
+ updated_at: str = ""
373
+ last_attempted_at: Optional[str] = None
374
+ open_incidents: int = 0
375
+
376
+ @classmethod
377
+ def from_dict(cls, d: Dict[str, Any]) -> "Alert":
378
+ return cls(
379
+ id=str(d.get("id", "")),
380
+ name=str(d.get("name", "")),
381
+ description=d.get("description"),
382
+ enabled=bool(d.get("enabled", True)),
383
+ trigger_kind=str(d.get("trigger_kind", "")),
384
+ trigger_spec=d.get("trigger_spec") or {},
385
+ min_breaches=int(d.get("min_breaches", 1)),
386
+ eval_window=int(d.get("eval_window", 1)),
387
+ eval_interval_secs=int(d.get("eval_interval_secs", 0)),
388
+ severity=str(d.get("severity", "")),
389
+ channels=list(d.get("channels") or []),
390
+ created_by=str(d.get("created_by", "")),
391
+ created_at=str(d.get("created_at", "")),
392
+ updated_at=str(d.get("updated_at", "")),
393
+ last_attempted_at=d.get("last_attempted_at"),
394
+ open_incidents=int(d.get("open_incidents", 0)),
395
+ )
396
+
397
+
398
+ @dataclass
399
+ class Incident:
400
+ id: str
401
+ #: Short identifying line. Present on every issue since the issues redesign;
402
+ #: `alert_name` is only set for the minority that have a parent alert, so
403
+ #: this is the column that actually distinguishes rows.
404
+ title: Optional[str] = None
405
+ #: How the issue came to exist: 'manual' | 'alert' | 'audit'.
406
+ source: Optional[str] = None
407
+ #: For source='audit', the audit finding this issue was opened from.
408
+ source_finding_id: Optional[str] = None
409
+ alert_id: Optional[str] = None
410
+ alert_name: Optional[str] = None
411
+ alert_severity: str = ""
412
+ trigger_kind: Optional[str] = None
413
+ state: str = ""
414
+ opened_at: str = ""
415
+ last_breach_at: str = ""
416
+ acknowledged_at: Optional[str] = None
417
+ acknowledged_by: Optional[str] = None
418
+ assignees: List[str] = field(default_factory=list)
419
+ resolved_at: Optional[str] = None
420
+ #: When the issue was CLOSED (won't-fix) rather than resolved (fixed). At most
421
+ #: one of the two is ever set. Both absent on a live issue.
422
+ closed_at: Optional[str] = None
423
+ #: When the issue was hidden from the board. Orthogonal to ``state`` — an
424
+ #: archived issue keeps whatever state it ended in.
425
+ archived_at: Optional[str] = None
426
+ breach_value: Optional[float] = None
427
+ breach_summary: Optional[str] = None
428
+ evidence: Optional[Dict[str, Any]] = None
429
+ notifications: Optional[List[Dict[str, Any]]] = None
430
+ subscribers: Optional[List[Dict[str, Any]]] = None
431
+ comments: Optional[List[Dict[str, Any]]] = None
432
+ activity: Optional[List[Dict[str, Any]]] = None
433
+
434
+ @classmethod
435
+ def from_dict(cls, d: Dict[str, Any]) -> "Incident":
436
+ return cls(
437
+ id=str(d.get("id", "")),
438
+ title=d.get("title"),
439
+ source=d.get("source"),
440
+ source_finding_id=d.get("source_finding_id"),
441
+ alert_id=d.get("alert_id"),
442
+ alert_name=d.get("alert_name"),
443
+ alert_severity=str(d.get("alert_severity", "")),
444
+ trigger_kind=d.get("trigger_kind"),
445
+ state=str(d.get("state", "")),
446
+ opened_at=str(d.get("opened_at", "")),
447
+ last_breach_at=str(d.get("last_breach_at", "")),
448
+ acknowledged_at=d.get("acknowledged_at"),
449
+ acknowledged_by=d.get("acknowledged_by"),
450
+ assignees=list(d.get("assignees") or []),
451
+ resolved_at=d.get("resolved_at"),
452
+ # `from_dict` is an ALLOWLIST: a key missing here is dropped with no
453
+ # error, so a column added server-side renders as a blank column here
454
+ # and nowhere complains. That is why these two lines exist at all.
455
+ closed_at=d.get("closed_at"),
456
+ archived_at=d.get("archived_at"),
457
+ breach_value=d.get("breach_value"),
458
+ breach_summary=d.get("breach_summary"),
459
+ evidence=d.get("evidence"),
460
+ notifications=d.get("notifications"),
461
+ subscribers=d.get("subscribers"),
462
+ comments=d.get("comments"),
463
+ activity=d.get("activity"),
464
+ )
465
+
466
+
467
+ @dataclass
468
+ class IncidentComment:
469
+ id: str
470
+ incident_id: str = ""
471
+ author_email: str = ""
472
+ body: Optional[str] = None
473
+ created_at: str = ""
474
+ edited_at: Optional[str] = None
475
+ deleted_at: Optional[str] = None
476
+
477
+ @classmethod
478
+ def from_dict(cls, d: Dict[str, Any]) -> "IncidentComment":
479
+ return cls(
480
+ id=str(d.get("id", "")),
481
+ incident_id=str(d.get("incident_id", "")),
482
+ author_email=str(d.get("author_email", "")),
483
+ body=d.get("body"),
484
+ created_at=str(d.get("created_at", "")),
485
+ edited_at=d.get("edited_at"),
486
+ deleted_at=d.get("deleted_at"),
487
+ )
488
+
489
+
490
+ @dataclass
491
+ class IncidentSubscriber:
492
+ email: str
493
+ source: str = ""
494
+ subscribed_at: str = ""
495
+ unsubscribed_at: Optional[str] = None
496
+
497
+ @classmethod
498
+ def from_dict(cls, d: Dict[str, Any]) -> "IncidentSubscriber":
499
+ return cls(
500
+ email=str(d.get("email", "")),
501
+ source=str(d.get("source", "")),
502
+ subscribed_at=str(d.get("subscribed_at", "")),
503
+ unsubscribed_at=d.get("unsubscribed_at"),
504
+ )
505
+
506
+
507
+ def _as_int(value: Any, default: int = 0) -> int:
508
+ """A JSON value → int, falling back to ``default`` on null/garbage.
509
+
510
+ ``int(d.get(k, default))`` raises on an explicit ``null`` (``int(None)``) or a
511
+ non-numeric string, which would crash a whole render over one bad row. The audit
512
+ models use this so ``from_dict`` can never raise.
513
+ """
514
+ try:
515
+ return int(value)
516
+ except (TypeError, ValueError):
517
+ return default
518
+
519
+
520
+ def _as_float(value: Any, default: float = 0.0) -> float:
521
+ """A JSON value → float, falling back to ``default`` on null/garbage (see :func:`_as_int`)."""
522
+ try:
523
+ return float(value)
524
+ except (TypeError, ValueError):
525
+ return default
526
+
527
+
528
+ @dataclass
529
+ class Audit:
530
+ """One audit definition — a scheduled sweep over a window of agent activity that
531
+ produces **findings**.
532
+
533
+ The definition columns (schedule, window, scope, signals, LLM settings, channels) are
534
+ what ``audits create``/``edit`` write; the trailing fields are server-derived read-only
535
+ state the list/get endpoints join in (``open_findings``, the last run's status/time, and
536
+ the queue row's attempt timestamps). Timestamps stay raw ISO strings — the renderers
537
+ humanize them, ``--json`` passes them through untouched.
538
+ """
539
+
540
+ id: str
541
+ name: str
542
+ description: Optional[str] = None
543
+ enabled: bool = True
544
+ schedule_interval_secs: int = 86400
545
+ # Fixed phase for the schedule: runs land on `anchor + N * interval`, so a slow
546
+ # run or a manual trigger can't drift the cadence. Raw ISO string, like the
547
+ # other timestamps. Server defaults it to the next 09:00 UTC when omitted;
548
+ # None only for legacy rows written before the column existed.
549
+ schedule_anchor: Optional[str] = None
550
+ window_mode: str = "since_last" # 'fixed' | 'since_last'
551
+ lookback_window_secs: int = 604800
552
+ scope: Dict[str, Any] = field(default_factory=dict)
553
+ ignore_error_types: List[str] = field(default_factory=list)
554
+ llm_enabled: bool = True
555
+ top_k: int = 50
556
+ sensitivity: str = "medium" # 'low' | 'medium' | 'high'
557
+ channels: List[Dict[str, Any]] = field(default_factory=list)
558
+ created_by: str = ""
559
+ created_at: str = ""
560
+ updated_at: str = ""
561
+ # Server-derived, read-only (never sent back on a write).
562
+ open_findings: int = 0
563
+ last_run_status: Optional[str] = None
564
+ last_run_finished_at: Optional[str] = None
565
+ last_attempted_at: Optional[str] = None
566
+ next_attempt_at: Optional[str] = None
567
+ last_error: Optional[str] = None
568
+ # Operator brief appended to the analysis prompt. READ-ONLY on this model:
569
+ # it is written through `fp audits context set`, never as part of a
570
+ # definition body, so a flag-only `audits edit` (which read-merges from
571
+ # _audit_to_body) can never wipe it.
572
+ additional_context: str = ""
573
+ reference_url_count: int = 0
574
+
575
+ @classmethod
576
+ def from_dict(cls, d: Dict[str, Any]) -> "Audit":
577
+ return cls(
578
+ id=str(d.get("id", "")),
579
+ name=str(d.get("name", "")),
580
+ description=d.get("description"),
581
+ enabled=bool(d.get("enabled", True)),
582
+ schedule_interval_secs=_as_int(d.get("schedule_interval_secs"), 86400),
583
+ schedule_anchor=d.get("schedule_anchor"),
584
+ window_mode=str(d.get("window_mode", "") or "since_last"),
585
+ lookback_window_secs=_as_int(d.get("lookback_window_secs"), 604800),
586
+ scope=d.get("scope") or {},
587
+ ignore_error_types=list(d.get("ignore_error_types") or []),
588
+ llm_enabled=bool(d.get("llm_enabled", True)),
589
+ top_k=_as_int(d.get("top_k"), 50),
590
+ sensitivity=str(d.get("sensitivity", "") or "medium"),
591
+ channels=list(d.get("channels") or []),
592
+ created_by=str(d.get("created_by", "")),
593
+ created_at=str(d.get("created_at", "")),
594
+ updated_at=str(d.get("updated_at", "")),
595
+ open_findings=_as_int(d.get("open_findings"), 0),
596
+ last_run_status=d.get("last_run_status"),
597
+ last_run_finished_at=d.get("last_run_finished_at"),
598
+ last_attempted_at=d.get("last_attempted_at"),
599
+ next_attempt_at=d.get("next_attempt_at"),
600
+ last_error=d.get("last_error"),
601
+ additional_context=str(d.get("additional_context", "") or ""),
602
+ reference_url_count=_as_int(d.get("reference_url_count"), 0),
603
+ )
604
+
605
+
606
+ @dataclass
607
+ class AuditRun:
608
+ """One execution of an audit — the window it swept, how it ended, and what it produced.
609
+
610
+ ``stats`` is an opaque per-run counter object and ``report`` the rendered summary text
611
+ (both may be absent on a run that failed early), so neither is parsed here.
612
+ """
613
+
614
+ id: str
615
+ audit_id: str = ""
616
+ status: str = "" # 'running' | 'succeeded' | 'failed'
617
+ trigger_kind: str = ""
618
+ window_from: str = ""
619
+ window_to: str = ""
620
+ started_at: str = ""
621
+ finished_at: Optional[str] = None
622
+ stats: Dict[str, Any] = field(default_factory=dict)
623
+ findings_count: int = 0
624
+ new_findings_count: int = 0
625
+ report: Optional[str] = None
626
+ error: Optional[str] = None
627
+
628
+ @classmethod
629
+ def from_dict(cls, d: Dict[str, Any]) -> "AuditRun":
630
+ return cls(
631
+ id=str(d.get("id", "")),
632
+ audit_id=str(d.get("audit_id", "")),
633
+ status=str(d.get("status", "")),
634
+ trigger_kind=str(d.get("trigger_kind", "")),
635
+ window_from=str(d.get("window_from", "")),
636
+ window_to=str(d.get("window_to", "")),
637
+ started_at=str(d.get("started_at", "")),
638
+ finished_at=d.get("finished_at"),
639
+ stats=d.get("stats") or {},
640
+ findings_count=_as_int(d.get("findings_count"), 0),
641
+ new_findings_count=_as_int(d.get("new_findings_count"), 0),
642
+ report=d.get("report"),
643
+ error=d.get("error"),
644
+ )
645
+
646
+
647
+ @dataclass
648
+ class AuditFinding:
649
+ """One finding — a recurring pattern an audit surfaced, carried across runs by its
650
+ ``fingerprint`` and triaged through ``status``.
651
+
652
+ ``priority`` is the server's ranking score (findings arrive priority-desc);
653
+ ``evidence``/``evidence_queries``/``scope`` are opaque blobs shown verbatim.
654
+ """
655
+
656
+ id: str
657
+ audit_id: str = ""
658
+ audit_name: str = ""
659
+ fingerprint: str = ""
660
+ title: str = ""
661
+ category: Optional[str] = None
662
+ failure_type: str = ""
663
+ description: Optional[str] = None
664
+ root_cause_hypothesis: Optional[str] = None
665
+ severity: str = "" # 'info' | 'warning' | 'critical'
666
+ magnitude: Optional[str] = None # 'small' | 'medium' | 'big'
667
+ priority: float = 0.0
668
+ status: str = "" # 'open' | 'recurring' | 'resolved' | 'dismissed' | 'muted'
669
+ occurrences: int = 0
670
+ first_seen_at: str = ""
671
+ last_seen_at: str = ""
672
+ recommendation: Optional[str] = None
673
+ expected_impact: Optional[str] = None
674
+ effort: Optional[str] = None
675
+ evidence: Dict[str, Any] = field(default_factory=dict)
676
+ evidence_queries: List[Any] = field(default_factory=list)
677
+ scope: Dict[str, Any] = field(default_factory=dict)
678
+ kind: str = "" # 'improvement' | 'policy' | 'failure'
679
+ assigned_to: Optional[str] = None
680
+ # The issue this finding graduated into, or None if it never linked.
681
+ # Rising nulls on open/recurring findings is how you see issue_sync
682
+ # degrading; `from_dict` is an allowlist, so omitting it here silently
683
+ # drops the field rather than erroring.
684
+ issue_id: Optional[str] = None
685
+
686
+ @classmethod
687
+ def from_dict(cls, d: Dict[str, Any]) -> "AuditFinding":
688
+ return cls(
689
+ id=str(d.get("id", "")),
690
+ audit_id=str(d.get("audit_id", "")),
691
+ audit_name=str(d.get("audit_name", "")),
692
+ fingerprint=str(d.get("fingerprint", "")),
693
+ title=str(d.get("title", "")),
694
+ category=d.get("category"),
695
+ failure_type=str(d.get("failure_type", "")),
696
+ description=d.get("description"),
697
+ root_cause_hypothesis=d.get("root_cause_hypothesis"),
698
+ severity=str(d.get("severity", "")),
699
+ magnitude=d.get("magnitude"),
700
+ priority=_as_float(d.get("priority"), 0.0),
701
+ status=str(d.get("status", "")),
702
+ occurrences=_as_int(d.get("occurrences"), 0),
703
+ first_seen_at=str(d.get("first_seen_at", "")),
704
+ last_seen_at=str(d.get("last_seen_at", "")),
705
+ recommendation=d.get("recommendation"),
706
+ expected_impact=d.get("expected_impact"),
707
+ effort=d.get("effort"),
708
+ evidence=d.get("evidence") or {},
709
+ evidence_queries=list(d.get("evidence_queries") or []),
710
+ scope=d.get("scope") or {},
711
+ kind=str(d.get("kind", "")),
712
+ assigned_to=d.get("assigned_to"),
713
+ issue_id=d.get("issue_id"),
714
+ )
715
+
716
+
717
+ # ── Cloud-managed enforcement ────────────────────────────────────────────────
718
+ #
719
+ # Three nouns, and keeping them apart is the whole model. A POLICY VERSION is
720
+ # written; a DEPLOYMENT says which versions a MACHINE is told to run. The
721
+ # dashboard splits them across three pages for the same reason — authoring is a
722
+ # code task, deploying is a fleet decision, and observing is neither.
723
+
724
+
725
+ @dataclass
726
+ class PolicyVersion:
727
+ """One published version of a policy. Versions are minted, never edited."""
728
+
729
+ id: str
730
+ version: int
731
+ description: str
732
+ sha256: str
733
+ source: Optional[str]
734
+ created_at: str
735
+ created_by: Optional[str]
736
+ disabled: bool
737
+ archived: bool
738
+
739
+ @classmethod
740
+ def from_dict(cls, d: Dict[str, Any]) -> "PolicyVersion":
741
+ return cls(
742
+ id=str(d.get("id", "")),
743
+ version=_as_int(d.get("version"), 0),
744
+ description=str(d.get("description", "") or ""),
745
+ sha256=str(d.get("sha256", "") or ""),
746
+ source=d.get("source"),
747
+ created_at=str(d.get("createdAt", d.get("created_at", "")) or ""),
748
+ created_by=d.get("createdBy", d.get("created_by")),
749
+ disabled=bool(d.get("disabled", False)),
750
+ archived=bool(d.get("archived", False)),
751
+ )
752
+
753
+ def to_dict(self) -> Dict[str, Any]:
754
+ """The server's own shape. `vars()` would leak Python snake_case into a
755
+ contract that is camelCase everywhere else, which is a difference a
756
+ harness discovers at runtime rather than in review."""
757
+ return {
758
+ "id": self.id, "version": self.version, "description": self.description,
759
+ "sha256": self.sha256, "source": self.source, "createdAt": self.created_at,
760
+ "createdBy": self.created_by, "disabled": self.disabled,
761
+ "archived": self.archived,
762
+ }
763
+
764
+
765
+ @dataclass
766
+ class PolicyRef:
767
+ """A policy inside a deployment: which version, and how it acts.
768
+
769
+ ``effect`` is ``enforce`` or ``observe``. The server defaults an omitted
770
+ effect to ``enforce``; the CLI always sends it explicitly so a deployment
771
+ read back and written again cannot silently change meaning.
772
+ """
773
+
774
+ id: str
775
+ version: int
776
+ effect: str = "enforce"
777
+
778
+ @classmethod
779
+ def from_dict(cls, d: Dict[str, Any]) -> "PolicyRef":
780
+ return cls(
781
+ id=str(d.get("id", "")),
782
+ version=_as_int(d.get("version"), 0),
783
+ effect=str(d.get("effect") or "enforce"),
784
+ )
785
+
786
+ def to_dict(self) -> Dict[str, Any]:
787
+ return {"id": self.id, "version": self.version, "effect": self.effect}
788
+
789
+ @property
790
+ def label(self) -> str:
791
+ return f"{self.id}@{self.version}:{self.effect}"
792
+
793
+
794
+ @dataclass
795
+ class Deployment:
796
+ """What one machine is told to enforce, and which generation that is.
797
+
798
+ ``deployment`` is the generation counter. It is the CLI's only defence
799
+ against a concurrent write: ``PUT`` is a FULL REPLACE with no server-side
800
+ lock, so a deploy that returns anything other than ``base + 1`` means
801
+ somebody else wrote between the read and the write.
802
+ """
803
+
804
+ machine_id: str
805
+ deployment: int
806
+ policies: List[PolicyRef]
807
+ updated_at: str
808
+ updated_by: Optional[str]
809
+
810
+ @classmethod
811
+ def from_dict(cls, d: Dict[str, Any]) -> "Deployment":
812
+ return cls(
813
+ machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""),
814
+ deployment=_as_int(d.get("deployment"), 0),
815
+ policies=[PolicyRef.from_dict(p) for p in (d.get("policies") or [])],
816
+ updated_at=str(d.get("updatedAt", d.get("updated_at", "")) or ""),
817
+ updated_by=d.get("updatedBy", d.get("updated_by")),
818
+ )
819
+
820
+ def to_dict(self) -> Dict[str, Any]:
821
+ return {
822
+ "machineId": self.machine_id, "deployment": self.deployment,
823
+ "policies": [p.to_dict() for p in self.policies],
824
+ "updatedAt": self.updated_at, "updatedBy": self.updated_by,
825
+ }
826
+
827
+
828
+ @dataclass
829
+ class Machine:
830
+ """A host that has checked in. Machines enrol themselves on their first poll.
831
+
832
+ Two generation numbers, and the gap between them is the whole point of
833
+ `fleet diff`: ``deployment`` is what the control plane INTENDED for this
834
+ machine, ``applied_deployment`` is what the machine last actually collected.
835
+ A machine can sit on an old set indefinitely and nothing else says so.
836
+ """
837
+
838
+ machine_id: str
839
+ #: What the machine calls itself. May be absent — plenty never report one.
840
+ label: Optional[str]
841
+ #: What an operator called it via `fleet rename`. SEPARATE from `label` on
842
+ #: the server, and the reason a rename appeared to do nothing here: reading
843
+ #: only `label` showed the machine's own (usually null) name and silently
844
+ #: ignored the override. `display_label` applies the precedence.
845
+ label_override: Optional[str]
846
+ last_seen: Optional[int] # epoch ms — the server sends a number, not ISO
847
+ last_check_in: Optional[int]
848
+ deployment: Optional[int] # intended
849
+ applied_deployment: Optional[int] # delivered
850
+ applied_at: Optional[int]
851
+ deployed: bool
852
+ policy_count: int
853
+ event_count: int
854
+
855
+ @classmethod
856
+ def from_dict(cls, d: Dict[str, Any]) -> "Machine":
857
+ def _num(key: str) -> Optional[int]:
858
+ v = d.get(key)
859
+ return int(v) if isinstance(v, (int, float)) else None
860
+
861
+ return cls(
862
+ machine_id=str(d.get("machineId", d.get("machine_id", "")) or ""),
863
+ label=d.get("label"),
864
+ label_override=d.get("labelOverride"),
865
+ last_seen=_num("lastSeen"),
866
+ last_check_in=_num("lastCheckIn"),
867
+ deployment=_num("deployment"),
868
+ applied_deployment=_num("appliedDeployment"),
869
+ applied_at=_num("appliedAt"),
870
+ deployed=bool(d.get("deployed", False)),
871
+ policy_count=_as_int(d.get("policyCount"), 0),
872
+ event_count=_as_int(d.get("eventCount"), 0),
873
+ )
874
+
875
+ @property
876
+ def display_label(self) -> Optional[str]:
877
+ """The operator's name for the machine, else its own.
878
+
879
+ Mirrors `machinePicker.ts`: `labelOverride || label || machineId`. The
880
+ override wins because it is the deliberate one — a machine's
881
+ self-asserted label is whatever it happened to send.
882
+ """
883
+ return (self.label_override or "").strip() or (self.label or "").strip() or None
884
+
885
+ def to_dict(self) -> Dict[str, Any]:
886
+ """Server shape plus `drifted` — the one field the CLI computes."""
887
+ return {
888
+ "machineId": self.machine_id, "label": self.label,
889
+ "labelOverride": self.label_override,
890
+ "lastSeen": self.last_seen, "lastCheckIn": self.last_check_in,
891
+ "deployment": self.deployment, "appliedDeployment": self.applied_deployment,
892
+ "appliedAt": self.applied_at, "deployed": self.deployed,
893
+ "policyCount": self.policy_count, "eventCount": self.event_count,
894
+ "drifted": self.drifted,
895
+ }
896
+
897
+ @property
898
+ def drifted(self) -> bool:
899
+ """True when the machine has not collected what it was last told to run."""
900
+ if self.deployment is None:
901
+ return False
902
+ return self.applied_deployment is None or self.applied_deployment < self.deployment