failproofai 1.0.12-beta.0 → 1.0.12-beta.2

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 (214) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +3 -3
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js +22 -22
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  31. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  32. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/index.html +1 -1
  35. package/.next/standalone/.next/server/app/index.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  37. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  43. package/.next/standalone/.next/server/app/policies/page.js +4 -4
  44. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  54. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  57. package/.next/standalone/.next/server/app/settings/page.js +7 -7
  58. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/{[externals]__0-ha5ydqq0mws._.js → [externals]__0vt9i6fqinjne._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__20xe05hm5u8_1._.js → [root-of-the-server]__0f_4g-vazrvxv._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/_0hf5q4xzyg40j._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/_19011-u44143j._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/{_0kc-piux3hady._.js → _1y590mjzpkbos._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +5 -0
  70. package/.next/standalone/.next/server/chunks/src_hooks_builtin-policies_ts_1yq5ybgx1v98e._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3h94wnn._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/src_hooks_daemon-client_ts_0il_yu1zc0291._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn_4twyh._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ts0fg5yih1c1._.js → [root-of-the-server]__00l2u6j86cb8y._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1_xbg2kx30wp-._.js → [root-of-the-server]__0naevg4bcoh54._.js} +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0vcyntk4snf0f._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/{_1gb0ifpkowwub._.js → _0ouu886szh6yj._.js} +10 -9
  91. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/{_1ve8y151ua7qx._.js → _1fw0_nzkae5ao._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_00jxj8foxqz5t.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/src_hooks_06kzv9ddabn3_._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +5 -0
  107. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +5 -0
  108. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4l1tzrm._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/src_hooks_daemon-client_ts_0k41_i7jg6ndv._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  112. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  113. package/.next/standalone/.next/server/pages/404.html +1 -1
  114. package/.next/standalone/.next/server/pages/500.html +1 -1
  115. package/.next/standalone/.next/server/preview-props.json +3 -3
  116. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  117. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  118. package/.next/standalone/.next/static/chunks/{0pjxnp927y7_j.js → 0ay__-d2h1xb5.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{2znt1784j9gd9.js → 0tbm3vjm62h-2.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/1c_jf6g5wnslk.js +1 -0
  121. package/.next/standalone/.next/static/chunks/{3tn1bzk094e8e.js → 1my3-5p47t7r-.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/20py8k1gd_i1m.js +1 -0
  123. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  124. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  125. package/.next/standalone/.next/static/chunks/2o6d37j425uci.js +1 -0
  126. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{2qz5piiir-qqm.js → 42xptz88mtq3x.js} +1 -1
  128. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  129. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  130. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +32 -0
  131. package/.next/standalone/fp-cloud-cli/README.md +40 -1
  132. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +91 -12
  133. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +78 -16
  134. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +84 -18
  135. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +124 -6
  136. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +89 -6
  137. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +189 -20
  138. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +8 -5
  139. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +2 -2
  140. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +14 -7
  141. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +527 -0
  142. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +46 -0
  143. package/.next/standalone/fp-cloud-cli/tests/test_output.py +23 -0
  144. package/.next/standalone/hermes-plugin/README.md +6 -4
  145. package/.next/standalone/hermes-plugin/__init__.py +27 -0
  146. package/.next/standalone/hermes-plugin/client.py +5 -1
  147. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  148. package/.next/standalone/package.json +11 -10
  149. package/.next/standalone/sdk/python/uv.lock +31 -8
  150. package/.next/standalone/server.js +1 -1
  151. package/bin/failproofai.mjs +17 -1
  152. package/dist/cli.mjs +3507 -1250
  153. package/dist/worker.mjs +1779 -169
  154. package/hermes-plugin/README.md +6 -4
  155. package/hermes-plugin/__init__.py +27 -0
  156. package/hermes-plugin/client.py +5 -1
  157. package/hermes-plugin/plugin.yaml +1 -1
  158. package/package.json +11 -10
  159. package/src/audit/open-browser.ts +6 -7
  160. package/src/hooks/agent-roster.ts +180 -0
  161. package/src/hooks/agent-skill.ts +203 -0
  162. package/src/hooks/agent-targets.ts +60 -0
  163. package/src/hooks/cloud-connection.ts +91 -45
  164. package/src/hooks/cloud-enrollment-cli.ts +7 -4
  165. package/src/hooks/cloud-login.ts +6 -3
  166. package/src/hooks/cloud-managed-policies.ts +430 -20
  167. package/src/hooks/cloud-policy-errors.ts +481 -0
  168. package/src/hooks/codex-system-hooks.ts +10 -4
  169. package/src/hooks/config-status.ts +10 -4
  170. package/src/hooks/configure-wizard.ts +54 -26
  171. package/src/hooks/custom-hooks-loader.ts +26 -3
  172. package/src/hooks/daemon-client.ts +9 -4
  173. package/src/hooks/daemon-service.ts +2 -1
  174. package/src/hooks/delivery-health.ts +47 -2
  175. package/src/hooks/effective-reviewers.ts +36 -1
  176. package/src/hooks/fp-config.ts +19 -0
  177. package/src/hooks/fp-home.ts +6 -0
  178. package/src/hooks/handler.ts +255 -19
  179. package/src/hooks/health-probe.ts +15 -0
  180. package/src/hooks/hook-activity-store.ts +7 -1
  181. package/src/hooks/integrations.ts +36 -14
  182. package/src/hooks/jev-activity.ts +117 -1
  183. package/src/hooks/jev-cli.ts +196 -1
  184. package/src/hooks/jev-cloud-connection.ts +92 -0
  185. package/src/hooks/manager.ts +174 -34
  186. package/src/hooks/pack-failclosed.ts +2 -0
  187. package/src/hooks/pack-manifest.ts +8 -1
  188. package/src/hooks/policy-evaluator.ts +22 -1
  189. package/src/hooks/policy-registry.ts +3 -2
  190. package/src/hooks/policy-reviewability.ts +23 -5
  191. package/src/hooks/semantic/cloud-jev-health.ts +227 -0
  192. package/src/hooks/semantic/cloud-jev.ts +868 -0
  193. package/src/hooks/semantic/combine.ts +26 -1
  194. package/src/hooks/semantic/jev-client.ts +19 -2
  195. package/src/hooks/semantic/jev-config.ts +179 -30
  196. package/src/hooks/semantic/jev-review.ts +217 -10
  197. package/src/hooks/semantic/pack-policies.ts +2 -1
  198. package/src/hooks/semantic/types.ts +8 -0
  199. package/src/hooks/session-pause-cli.ts +1 -1
  200. package/src/hooks/tui.ts +13 -4
  201. package/src/hooks/uninstall-cli.ts +12 -1
  202. package/src/hooks/worker-server.ts +5 -1
  203. package/.next/standalone/.next/server/chunks/src_hooks_1aveq0uhs2t1b._.js +0 -5
  204. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7cyqlb2._.js +0 -3
  205. package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo55j16x._.js +0 -5
  206. package/.next/standalone/.next/static/chunks/0a-ccsp3xmypz.js +0 -1
  207. package/.next/standalone/.next/static/chunks/13jv-xvabeb62.js +0 -6
  208. package/.next/standalone/.next/static/chunks/2ank2u3apy8m6.js +0 -1
  209. package/.next/standalone/.next/static/chunks/2meqb9kygrncb.js +0 -1
  210. package/.next/standalone/.next/static/chunks/2ohktp_-lkv3x.js +0 -1
  211. package/.next/standalone/.next/static/chunks/2py0eldhwz8ay.js +0 -1
  212. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  213. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  214. /package/.next/standalone/.next/static/{SNr7o_-G4kNBQTR-iLZMM → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -0,0 +1,527 @@
1
+ """FailproofAI Cloud Jev / regex / both policies in `fp` (CONTRACT C4, C6).
2
+
3
+ `fp policies publish --kind regex|jev|both [--source] [--semantic]`, list/show
4
+ carrying the kind and the Jev check names, deploys (no Jev mode: Cloud derives
5
+ it), and machines showing the policy errors they report.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import httpx
13
+ import pytest
14
+ import respx
15
+
16
+ from fp_cli import client
17
+ from fp_cli.app import app
18
+ from fp_cli.enforcement import (
19
+ RefUsageError,
20
+ parse_semantic,
21
+ plan_deploy,
22
+ resolve_kind,
23
+ version_kinds,
24
+ )
25
+ from fp_cli.models import Deployment, Machine, PolicyRef, PolicyVersion
26
+
27
+ BASE = "http://dash.test"
28
+ FIXTURES = Path(__file__).resolve().parents[2] / "__tests__" / "fixtures" / "cloud-jev"
29
+ VALID = json.loads((FIXTURES / "semantic-valid.json").read_text())
30
+
31
+ DECL = {
32
+ "name": "acme-prod-db",
33
+ "title": "Tried to write to the production database",
34
+ "appliesTo": ["shell"],
35
+ "mode": "deny",
36
+ "userCanOverride": True,
37
+ "probes": [{"id": "writes", "instructions": "It writes.", "criteria": {"true": "yes", "false": "no"}}],
38
+ "guidance": "Ask first.",
39
+ }
40
+
41
+ SERVER_BOTH = {
42
+ "id": "no-prod-db", "version": 3, "description": "prod db guard", "sha256": "a" * 64,
43
+ "source": "export default 1;", "createdAt": "2026-09-30T00:00:00Z", "createdBy": "me@test",
44
+ "disabled": False, "archived": False, "kind": "both", "semantic": [DECL],
45
+ "semanticSha256": "b" * 64, "authority": "reviewable", "reviewedBy": ["acme-prod-db"],
46
+ }
47
+ SERVER_JEV = {
48
+ "id": "secrets", "version": 1, "description": "", "sha256": "", "source": None,
49
+ "createdAt": "2026-09-30T00:00:00Z", "createdBy": "me@test", "disabled": False,
50
+ "archived": False, "kind": "jev", "semantic": [dict(DECL, name="acme-secrets")],
51
+ "semanticSha256": "c" * 64,
52
+ }
53
+ SERVER_REGEX_OLD = {
54
+ "id": "no-force-push", "version": 2, "description": "", "sha256": "d" * 64,
55
+ "source": "export default 2;", "createdAt": "2026-09-01T00:00:00Z", "createdBy": None,
56
+ "disabled": False, "archived": False,
57
+ }
58
+
59
+
60
+ # ── models ────────────────────────────────────────────────────────────────────
61
+
62
+
63
+ def test_a_policy_version_carries_its_kind_and_jev_checks():
64
+ both = PolicyVersion.from_dict(SERVER_BOTH)
65
+ assert (both.kind, both.jev_names, both.authority, both.reviewed_by) == (
66
+ "both", ["acme-prod-db"], "reviewable", ["acme-prod-db"])
67
+ out = both.to_dict()
68
+ assert out["kind"] == "both" and out["semantic"] == [DECL]
69
+ assert out["semanticSha256"] == "b" * 64 and out["reviewedBy"] == ["acme-prod-db"]
70
+
71
+
72
+ def test_a_version_from_a_server_that_predates_kinds_is_regex():
73
+ old = PolicyVersion.from_dict(SERVER_REGEX_OLD)
74
+ assert old.kind == "regex" and old.jev_names == [] and old.semantic is None
75
+ out = old.to_dict()
76
+ assert out["kind"] == "regex"
77
+ assert "semantic" not in out and "reviewedBy" not in out and "authority" not in out
78
+
79
+
80
+ def test_machines_carry_policy_errors_and_jev_mode_only_when_reported():
81
+ reported = Machine.from_dict({
82
+ "machineId": "m1", "policyErrors": [{"id": "p", "version": 1, "kind": "jev", "message": "bad"}],
83
+ "policyErrorsAt": "2026-09-30T01:00:00Z", "jevMode": "observe",
84
+ })
85
+ assert reported.policy_errors == [{"id": "p", "version": 1, "kind": "jev", "message": "bad"}]
86
+ assert reported.jev_mode == "observe"
87
+ d = reported.to_dict()
88
+ assert d["policyErrors"][0]["message"] == "bad" and d["jevMode"] == "observe"
89
+ assert d["policyErrorsAt"] == "2026-09-30T01:00:00Z"
90
+
91
+ quiet = Machine.from_dict({"machineId": "m2"})
92
+ assert quiet.policy_errors is None and quiet.jev_mode is None
93
+ assert not {"policyErrors", "policyErrorsAt", "jevMode"} & set(quiet.to_dict())
94
+
95
+
96
+ def test_a_deployment_carries_its_jev_mode():
97
+ dep = Deployment.from_dict({"machineId": "m1", "deployment": 4, "policies": [], "jevMode": "enforce"})
98
+ assert dep.jev_mode == "enforce" and dep.to_dict()["jevMode"] == "enforce"
99
+ assert "jevMode" not in Deployment.from_dict({"machineId": "m1", "deployment": 4}).to_dict()
100
+
101
+
102
+ # ── kind + semantic (pure) ────────────────────────────────────────────────────
103
+
104
+
105
+ @pytest.mark.parametrize("kind,src,sem,expected", [
106
+ (None, True, False, "regex"),
107
+ (None, False, True, "jev"),
108
+ (None, True, True, "both"),
109
+ ("regex", True, False, "regex"),
110
+ ("jev", False, True, "jev"),
111
+ ("both", True, True, "both"),
112
+ ])
113
+ def test_the_kind_follows_from_what_was_given(kind, src, sem, expected):
114
+ assert resolve_kind(kind, has_source=src, has_semantic=sem) == expected
115
+
116
+
117
+ @pytest.mark.parametrize("kind,src,sem,needle", [
118
+ ("regex", True, True, "takes no --semantic"),
119
+ ("jev", True, True, "takes no JavaScript"),
120
+ ("jev", False, False, "needs --semantic"),
121
+ ("both", True, False, "needs --semantic"),
122
+ ("hybrid", True, False, "not one of"),
123
+ ])
124
+ def test_a_kind_that_contradicts_its_inputs_is_a_usage_error(kind, src, sem, needle):
125
+ with pytest.raises(RefUsageError, match=needle):
126
+ resolve_kind(kind, has_source=src, has_semantic=sem)
127
+
128
+
129
+ def test_semantic_accepts_the_contract_fixtures_and_a_wrapper():
130
+ assert len(VALID) == 16
131
+ assert parse_semantic(json.dumps(VALID)) == VALID
132
+ assert parse_semantic(json.dumps({"semantic": VALID[:2]})) == VALID[:2]
133
+
134
+
135
+ @pytest.mark.parametrize("text,needle", [
136
+ ("{not json", "not valid JSON"),
137
+ (json.dumps({"name": "x"}), "JSON array"),
138
+ ("[]", "no Jev declarations"),
139
+ (json.dumps([DECL] * 25), "at most 24"),
140
+ (json.dumps([{"title": "no name"}]), "#0 is not an object with a name"),
141
+ ])
142
+ def test_semantic_refuses_what_is_not_a_declaration_list(text, needle):
143
+ with pytest.raises(RefUsageError, match=needle):
144
+ parse_semantic(text)
145
+
146
+
147
+ # ── deploy plan: no Jev mode ──────────────────────────────────────────────────
148
+
149
+
150
+ def _plan(**kw):
151
+ base = dict(current=[PolicyRef("a", 1)], base=5, latest={"a": 1, "j": 2})
152
+ base.update(kw)
153
+ return plan_deploy("m1", **base)
154
+
155
+
156
+ def test_a_plan_never_carries_a_jev_mode():
157
+ with pytest.raises(TypeError):
158
+ _plan(jev_mode="observe")
159
+ plan = _plan(add=["a"])
160
+ assert plan.is_noop and "jevMode" not in plan.to_dict()
161
+
162
+
163
+ def test_observe_applies_to_a_jev_policy_like_any_other():
164
+ kinds = version_kinds([PolicyVersion.from_dict(dict(SERVER_JEV, id="j", version=2))])
165
+ assert _plan(add=["j:observe"], kinds=kinds).added[0].effect == "observe"
166
+ assert _plan(add=["j"], kinds=kinds).added[0].effect == "enforce"
167
+ both = version_kinds([PolicyVersion.from_dict(dict(SERVER_BOTH, id="j", version=2))])
168
+ assert _plan(add=["j:observe"], kinds=both).added[0].effect == "observe"
169
+
170
+
171
+ # ── the wire ──────────────────────────────────────────────────────────────────
172
+
173
+
174
+ def test_publish_sends_kind_and_semantic_and_no_source_for_jev(monkeypatch):
175
+ sent = []
176
+ monkeypatch.setattr(client, "_post_json", lambda ctx, path, body=None: sent.append(body) or {})
177
+ client.publish_policy(None, "secrets", None, "d", kind="jev", semantic=[DECL])
178
+ assert sent[-1] == {"id": "secrets", "description": "d", "kind": "jev", "semantic": [DECL]}
179
+
180
+ client.publish_policy(None, "p", "export default 1;", "")
181
+ assert sent[-1] == {"id": "p", "description": "", "source": "export default 1;"}
182
+
183
+
184
+ def test_publish_still_refuses_jev_fields_in_the_javascript(monkeypatch):
185
+ from fp_cli.errors import ApiError
186
+
187
+ monkeypatch.setattr(client, "_post_json", lambda *a, **k: pytest.fail("reached the server"))
188
+ src = 'semanticPolicies.add({ name: "x" });'
189
+ with pytest.raises(ApiError) as exc:
190
+ client.publish_policy(None, "p", src, "", kind="both", semantic=[DECL])
191
+ assert "--kind both --semantic" in (exc.value.hint or "")
192
+
193
+
194
+ def test_deploy_never_sends_a_jev_mode(monkeypatch):
195
+ bodies = []
196
+ monkeypatch.setattr(client, "_request_json",
197
+ lambda ctx, method, path, json_body=None: bodies.append(json_body) or {})
198
+ client.deploy_policies(None, "m1", [PolicyRef("a", 1)])
199
+ assert "jevMode" not in bodies[0]
200
+ assert not hasattr(client, "set_jev_mode")
201
+
202
+
203
+ # ── the commands, over HTTP ───────────────────────────────────────────────────
204
+
205
+
206
+ @respx.mock
207
+ def test_publish_a_jev_policy_from_a_semantic_file(logged_in, runner, tmp_path):
208
+ checks = tmp_path / "checks.json"
209
+ checks.write_text(json.dumps([DECL]))
210
+ route = respx.post(f"{BASE}/api/enforcement/policies").mock(
211
+ return_value=httpx.Response(200, json=dict(SERVER_JEV, id="prod-db-intent", semantic=[DECL])))
212
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
213
+ result = runner.invoke(app, ["--json", "policies", "publish", "prod-db-intent", "--semantic", str(checks)])
214
+ assert result.exit_code == 0, result.output
215
+ body = json.loads(route.calls.last.request.content)
216
+ assert body == {"id": "prod-db-intent", "description": "", "kind": "jev", "semantic": [DECL]}
217
+ out = json.loads(result.stdout)
218
+ assert out["kind"] == "jev" and out["syntax"]["checked"] is False
219
+
220
+
221
+ @respx.mock
222
+ def test_publish_both_sends_the_source_and_the_declarations(logged_in, runner, tmp_path):
223
+ rule = tmp_path / "rule.mjs"
224
+ rule.write_text('import { customPolicies, allow } from "failproofai";\n')
225
+ checks = tmp_path / "checks.json"
226
+ checks.write_text(json.dumps([DECL]))
227
+ route = respx.post(f"{BASE}/api/enforcement/policies").mock(
228
+ return_value=httpx.Response(200, json=SERVER_BOTH))
229
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
230
+ result = runner.invoke(app, ["policies", "publish", "no-prod-db", "--kind", "both",
231
+ "--source", str(rule), "--semantic", str(checks), "--no-verify"])
232
+ assert result.exit_code == 0, result.output
233
+ body = json.loads(route.calls.last.request.content)
234
+ assert body["kind"] == "both" and body["semantic"] == [DECL] and "customPolicies" in body["source"]
235
+ assert "acme-prod-db" in result.stdout and "both" in result.stdout
236
+
237
+
238
+ @respx.mock
239
+ def test_publish_jev_with_source_is_a_usage_error_before_anything_is_sent(logged_in, runner, tmp_path):
240
+ rule = tmp_path / "rule.mjs"
241
+ rule.write_text("export default 1;\n")
242
+ checks = tmp_path / "checks.json"
243
+ checks.write_text(json.dumps([DECL]))
244
+ result = runner.invoke(app, ["policies", "publish", "x", "--kind", "jev",
245
+ "--source", str(rule), "--semantic", str(checks)])
246
+ assert result.exit_code == 2
247
+ assert not respx.calls
248
+
249
+
250
+ @respx.mock
251
+ def test_list_and_show_print_the_kind_and_the_jev_names(logged_in, runner):
252
+ respx.get(f"{BASE}/api/enforcement/policies").mock(
253
+ return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
254
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
255
+ listed = runner.invoke(app, ["policies", "list"])
256
+ assert listed.exit_code == 0, listed.output
257
+ cells = [ln.strip().strip("│").strip() for ln in listed.stdout.splitlines()]
258
+ rows = {c.split()[0]: c for c in cells if c[:1].isalpha()}
259
+ assert "both" in rows["no-prod-db"] and "acme-prod-db" in rows["no-prod-db"]
260
+ assert "jev" in rows["secrets"] and "acme-secrets" in rows["secrets"]
261
+ assert "regex" in rows["no-force-push"]
262
+
263
+ shown = runner.invoke(app, ["policies", "show", "secrets"])
264
+ assert shown.exit_code == 0, shown.output
265
+ assert "jev" in shown.stdout and "acme-secrets" in shown.stdout
266
+ # The declarations themselves, printed where the JavaScript source is.
267
+ assert '"name": "acme-secrets"' in shown.stdout + (shown.stderr or "")
268
+
269
+ js = json.loads(runner.invoke(app, ["--json", "policies", "show", "no-prod-db"]).stdout)
270
+ assert js["kind"] == "both" and js["reviewedBy"] == ["acme-prod-db"] and js["semantic"] == [DECL]
271
+
272
+
273
+ def test_a_version_carries_its_jev_question_chars_and_regex_has_none():
274
+ jev = PolicyVersion.from_dict(dict(SERVER_JEV, jevChars=412))
275
+ assert jev.jev_chars == 412 and jev.to_dict()["jevChars"] == 412
276
+ regex = PolicyVersion.from_dict(dict(SERVER_REGEX_OLD, jevChars=None))
277
+ assert regex.jev_chars is None and "jevChars" not in regex.to_dict()
278
+
279
+
280
+ @respx.mock
281
+ def test_list_and_show_print_the_jev_question_chars(logged_in, runner):
282
+ respx.get(f"{BASE}/api/enforcement/policies").mock(return_value=httpx.Response(
283
+ 200, json=[dict(SERVER_JEV, jevChars=18653), dict(SERVER_REGEX_OLD, jevChars=None)]))
284
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[]))
285
+ listed = runner.invoke(app, ["policies", "list"], env={"COLUMNS": "200"})
286
+ assert listed.exit_code == 0, listed.output
287
+ rows = {c.split()[0]: c for c in (ln.strip().strip("│").strip() for ln in listed.stdout.splitlines())
288
+ if c[:1].isalpha()}
289
+ assert "18,653 chars" in rows["secrets"]
290
+ assert "chars" not in rows["no-force-push"]
291
+
292
+ shown = runner.invoke(app, ["policies", "show", "secrets"], env={"COLUMNS": "200"})
293
+ assert shown.exit_code == 0, shown.output
294
+ assert "18,653 question chars" in shown.stdout
295
+
296
+
297
+ @respx.mock
298
+ def test_a_jev_budget_refusal_names_each_policys_share_largest_first(logged_in, runner):
299
+ # The server's deploy 422 (CONTRACT C9.2): the message has the total, the
300
+ # body lists every contributing version — which is what says what to drop.
301
+ _fleet_routes([])
302
+ refusal = {
303
+ "error": "the Jev checks of this deployment are over the machine's question budget "
304
+ "(37756 of 27591 characters)",
305
+ "code": "jev_budget_exceeded", "used": 37756, "budget": 27591,
306
+ "policies": [{"id": "e2e-prod-db", "version": 1, "chars": 415},
307
+ {"id": "e2e-big", "version": 1, "chars": 18653},
308
+ {"id": "e2e-big2", "version": 1, "chars": 18688}],
309
+ }
310
+ respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(422, json=refusal))
311
+ human = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets", "--yes"], env={"COLUMNS": "400"})
312
+ assert human.exit_code == 1
313
+ text = " ".join((human.output + (human.stderr or "")).split())
314
+ assert "37756 of 27591" in text
315
+ assert "e2e-big2@1 18,688 · e2e-big@1 18,653 · e2e-prod-db@1 415" in text
316
+
317
+ js = runner.invoke(app, ["--json", "fleet", "deploy", "m1", "--add", "secrets", "--yes"])
318
+ out = json.loads(js.stdout)
319
+ assert out["status"] == 422
320
+ assert out["hint"].startswith("Jev question characters per policy: e2e-big2@1 18,688")
321
+
322
+ # Any other refusal carries no such line.
323
+ respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
324
+ 422, json={"error": "bad", "code": "jev_effect_unsupported"}))
325
+ other = json.loads(runner.invoke(app, ["--json", "fleet", "deploy", "m1", "--add", "secrets", "--yes"]).stdout)
326
+ assert "hint" not in other
327
+
328
+
329
+ def _fleet_routes(current_policies, jev_mode=None, errors=None):
330
+ machine = {"machineId": "m1", "deployment": 5, "appliedDeployment": 5, "deployed": True}
331
+ if errors is not None:
332
+ machine["policyErrors"] = errors
333
+ if jev_mode:
334
+ machine["jevMode"] = jev_mode
335
+ dep = {"machineId": "m1", "deployment": 5, "policies": current_policies}
336
+ if jev_mode:
337
+ dep["jevMode"] = jev_mode
338
+ respx.get(f"{BASE}/api/enforcement/machines").mock(return_value=httpx.Response(200, json=[machine]))
339
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=[dep]))
340
+ respx.get(f"{BASE}/api/enforcement/policies").mock(
341
+ return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
342
+
343
+
344
+ @respx.mock
345
+ def test_fleet_deploy_adds_a_jev_policy_and_sends_no_mode(logged_in, runner):
346
+ _fleet_routes([], jev_mode="enforce")
347
+ put = respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
348
+ 200, json={"machineId": "m1", "deployment": 6, "jevMode": "enforce",
349
+ "policies": [{"id": "secrets", "version": 1, "effect": "enforce"}]}))
350
+ result = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets", "--yes"])
351
+ assert result.exit_code == 0, result.output
352
+ assert json.loads(put.calls.last.request.content) == {
353
+ "policies": [{"id": "secrets", "version": 1, "effect": "enforce"}]}
354
+
355
+
356
+ @respx.mock
357
+ def test_fleet_deploy_observes_a_jev_policy(logged_in, runner):
358
+ _fleet_routes([])
359
+ put = respx.put(f"{BASE}/api/enforcement/deployments/m1").mock(return_value=httpx.Response(
360
+ 200, json={"machineId": "m1", "deployment": 6,
361
+ "policies": [{"id": "secrets", "version": 1, "effect": "observe"}]}))
362
+ result = runner.invoke(app, ["fleet", "deploy", "m1", "--add", "secrets:observe", "--yes"])
363
+ assert result.exit_code == 0, result.output
364
+ assert json.loads(put.calls.last.request.content) == {
365
+ "policies": [{"id": "secrets", "version": 1, "effect": "observe"}]}
366
+
367
+
368
+ @pytest.mark.parametrize("argv", [
369
+ ["fleet", "jev-mode", "m1", "observe"],
370
+ ["fleet", "deploy", "m1", "--jev-mode", "observe"],
371
+ ])
372
+ def test_the_jev_mode_cannot_be_set_by_hand(logged_in, runner, argv):
373
+ with respx.mock(assert_all_called=False) as mock:
374
+ result = runner.invoke(app, argv)
375
+ assert result.exit_code == 2, result.output
376
+ assert not mock.calls
377
+
378
+
379
+ @respx.mock
380
+ def test_fleet_show_and_list_show_the_jev_mode_and_the_reported_errors(logged_in, runner):
381
+ errors = [{"id": "secrets", "version": 1, "kind": "jev", "message": "not loaded: failed integrity verification"},
382
+ {"id": "jevMode", "version": None, "kind": "daemon", "message": "jev_unconfigured"}]
383
+ _fleet_routes([{"id": "secrets", "version": 1, "effect": "enforce"}], jev_mode="observe", errors=errors)
384
+ shown = runner.invoke(app, ["fleet", "show", "m1"])
385
+ assert shown.exit_code == 0, shown.output
386
+ assert "jev mode" in shown.stdout and "observe" in shown.stdout and "set by FailproofAI Cloud" in shown.stdout
387
+ assert "2 policy errors" in shown.stdout and "jev_unconfigured" in shown.stdout
388
+ assert "failed integrity verification" in shown.stdout
389
+
390
+ listed = runner.invoke(app, ["fleet", "list"])
391
+ assert listed.exit_code == 0, listed.output
392
+ assert "2 errors" in listed.stdout
393
+
394
+ js = json.loads(runner.invoke(app, ["--json", "fleet", "show", "m1"]).stdout)
395
+ assert js["machine"]["policyErrors"] == errors and js["machine"]["jevMode"] == "observe"
396
+ assert js["deployment"]["jevMode"] == "observe"
397
+
398
+
399
+ # ── Rollback and history show the mode Cloud derived (m6) ────────────────────
400
+
401
+
402
+ def _fleet_many(deployments, machines=None):
403
+ """`deployments`: {machine_id: jev_mode or None}; machines default to the same ids."""
404
+ ids = machines if machines is not None else list(deployments)
405
+ respx.get(f"{BASE}/api/enforcement/machines").mock(return_value=httpx.Response(200, json=[
406
+ {"machineId": m, "deployment": 5, "appliedDeployment": 5, "deployed": m in deployments} for m in ids]))
407
+ deps = []
408
+ for m, mode in deployments.items():
409
+ d = {"machineId": m, "deployment": 5, "policies": [{"id": "no-force-push", "version": 2, "effect": "enforce"}]}
410
+ if mode:
411
+ d["jevMode"] = mode
412
+ deps.append(d)
413
+ respx.get(f"{BASE}/api/enforcement/deployments").mock(return_value=httpx.Response(200, json=deps))
414
+ respx.get(f"{BASE}/api/enforcement/policies").mock(
415
+ return_value=httpx.Response(200, json=[SERVER_BOTH, SERVER_JEV, SERVER_REGEX_OLD]))
416
+
417
+
418
+ @respx.mock
419
+ def test_fleet_rollback_says_which_jev_mode_it_restores(logged_in, runner):
420
+ _fleet_many({"m1": "off"})
421
+ respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
422
+ {"deployment": 5, "policies": [], "updatedAt": "2026-09-30T02:00:00Z", "jevMode": "off"},
423
+ {"deployment": 3, "policies": [], "updatedAt": "2026-09-29T02:00:00Z", "jevMode": "enforce"},
424
+ ]))
425
+ respx.post(f"{BASE}/api/enforcement/deployments/m1/rollback").mock(return_value=httpx.Response(
426
+ 200, json={"machineId": "m1", "deployment": 6, "rolledBackFrom": 3, "policies": [], "jevMode": "enforce"}))
427
+ human = runner.invoke(app, ["fleet", "rollback", "m1", "3", "--yes"])
428
+ assert human.exit_code == 0, human.output
429
+ text = human.output + (human.stderr or "")
430
+ assert "jev mode" in text and "off → enforce" in text
431
+
432
+ js = runner.invoke(app, ["--json", "fleet", "rollback", "m1", "3", "--yes"])
433
+ assert js.exit_code == 0, js.output
434
+ out = json.loads(js.stdout)
435
+ assert out["jevMode"] == "enforce" and out["jevModeBefore"] == "off"
436
+
437
+
438
+ @respx.mock
439
+ def test_fleet_history_shows_each_generations_jev_mode_and_a_mode_only_change(logged_in, runner):
440
+ _fleet_many({"m1": "observe"})
441
+ pols = [{"id": "no-force-push", "version": 2, "effect": "enforce"}]
442
+ respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
443
+ {"deployment": 6, "policies": pols, "updatedAt": "2026-09-30T02:00:00Z", "jevMode": "observe"},
444
+ {"deployment": 5, "policies": pols, "updatedAt": "2026-09-30T01:00:00Z"},
445
+ ]))
446
+ result = runner.invoke(app, ["fleet", "history", "m1"])
447
+ assert result.exit_code == 0, result.output
448
+ assert "jev" in result.stdout
449
+ assert "local→observe" in result.stdout
450
+ lines = [ln for ln in result.stdout.splitlines() if "#6" in ln or "#5" in ln]
451
+ assert any("observe" in ln for ln in lines if "#6" in ln)
452
+ assert any("local" in ln for ln in lines if "#5" in ln)
453
+
454
+
455
+ # ── CONTRACT C10.6: Jev checks run on FailproofAI Cloud (help text only) ──────
456
+
457
+
458
+ @pytest.mark.parametrize(
459
+ "argv",
460
+ [
461
+ ["fleet", "deploy", "--help"],
462
+ ["policies", "publish", "--help"],
463
+ ],
464
+ )
465
+ def test_the_help_says_jev_checks_run_on_cloud_and_nothing_is_installed(runner, argv):
466
+ result = runner.invoke(app, argv)
467
+ assert result.exit_code == 0, result.output
468
+ flat = " ".join(result.stdout.split())
469
+ assert "Jev checks run on FailproofAI Cloud; nothing is installed on the machine" in flat, argv
470
+ # The on-machine delivery it replaced is not described any more.
471
+ assert "puts its Jev checks on the machine" not in flat, argv
472
+ assert "AgentEye" not in flat, argv
473
+
474
+
475
+ @pytest.mark.parametrize("argv", [["fleet", "show", "--help"]])
476
+ def test_the_help_names_the_reasons_a_machine_cannot_ask(runner, argv):
477
+ flat = " ".join(runner.invoke(app, argv).stdout.split())
478
+ assert "transcripts_disabled" in flat, argv
479
+ if argv[1] == "show":
480
+ assert "jev_unconfigured" in flat
481
+ assert "jev_budget" in flat
482
+ # Review M3 and the name-clash split: the machine's own reports.
483
+ for code in ("jev_rate_limited", "jev_unavailable", "jev_name_clash"):
484
+ assert code in flat
485
+ # A machine no longer parses Jev checks, so it cannot fail to.
486
+ assert "could not parse" not in flat
487
+
488
+
489
+ # ── e2e findings: exact-profile targets read as their label (B11) ─────────────
490
+
491
+ _AGT = "agt_" + "1f" * 16
492
+ _PROFILE_POLS = [{"id": "no-force-push", "version": 2, "effect": "enforce",
493
+ "agentTargets": [{"integration": "claude", "instanceId": _AGT}]}]
494
+
495
+
496
+ def _roster_route(agents, status=200):
497
+ return respx.get(f"{BASE}/api/enforcement/machines/m1/agents").mock(return_value=httpx.Response(
498
+ status, json={"machineId": "m1", "generation": 3, "agents": agents} if status == 200
499
+ else {"error": "not found"}))
500
+
501
+
502
+ @respx.mock
503
+ def test_fleet_show_and_history_name_an_exact_profile_by_its_label(logged_in, runner):
504
+ _fleet_routes(_PROFILE_POLS)
505
+ _roster_route([{"instanceId": _AGT, "integration": "claude", "profileLabel": ".claude-work",
506
+ "scope": "user", "hookInstalled": True}])
507
+ respx.get(f"{BASE}/api/enforcement/deployments/m1/history").mock(return_value=httpx.Response(200, json=[
508
+ {"deployment": 5, "policies": _PROFILE_POLS, "updatedAt": "2026-09-30T01:00:00Z"}]))
509
+
510
+ shown = runner.invoke(app, ["fleet", "show", "m1"])
511
+ assert shown.exit_code == 0, shown.output
512
+ # The label a person recognises, with the id a selector needs beside it.
513
+ assert f"claude/.claude-work ({_AGT})" in shown.stdout
514
+
515
+ history = runner.invoke(app, ["fleet", "history", "m1"])
516
+ assert history.exit_code == 0, history.output
517
+ assert "claude/.claude-work" in history.stdout
518
+ assert _AGT not in history.stdout
519
+
520
+
521
+ @respx.mock
522
+ def test_an_exact_profile_without_a_reported_label_keeps_the_bare_id(logged_in, runner):
523
+ _fleet_routes(_PROFILE_POLS)
524
+ _roster_route([], status=404)
525
+ shown = runner.invoke(app, ["fleet", "show", "m1"])
526
+ assert shown.exit_code == 0, shown.output
527
+ assert f"claude/{_AGT}" in shown.stdout
@@ -141,6 +141,52 @@ def test_set_replaces_the_whole_set():
141
141
  assert plan.result[0].version == 5
142
142
 
143
143
 
144
+ def test_agent_targets_round_trip_and_survive_an_unrelated_add():
145
+ scoped = PolicyRef.from_dict({
146
+ "id": "guard", "version": 2, "effect": "enforce",
147
+ "agentTargets": [{"integration": "hermes", "instanceId": "agt_1234567890abcdef"}],
148
+ })
149
+ assert scoped.to_dict()["agentTargets"] == [
150
+ {"integration": "hermes", "instanceId": "agt_1234567890abcdef"},
151
+ ]
152
+ plan = plan_deploy("machine", current=[scoped], base=2, add=["guard@3"], latest={"guard": 3})
153
+ assert plan.result[0].agent_targets == scoped.agent_targets
154
+ assert plan.result[0].version == 3
155
+ assert scoped.version == 2
156
+
157
+
158
+ def test_target_changes_are_a_diff_and_clearing_is_explicit():
159
+ initial = ref("guard")
160
+ narrowed = plan_deploy(
161
+ "machine", current=[initial], base=1,
162
+ targets=["guard=hermes/agt_1234567890abcdef", "guard=codex"],
163
+ )
164
+ assert narrowed.changed == [(initial, narrowed.result[0])]
165
+ assert narrowed.result[0].agent_targets == [
166
+ {"integration": "hermes", "instanceId": "agt_1234567890abcdef"},
167
+ {"integration": "codex"},
168
+ ]
169
+ cleared = plan_deploy(
170
+ "machine", current=narrowed.result, base=2, all_agents=["guard"],
171
+ )
172
+ assert cleared.result[0].agent_targets is None
173
+ assert cleared.set_changes
174
+
175
+
176
+ @pytest.mark.parametrize("targets,clears", [
177
+ (["guard=stranger"], []),
178
+ (["guard=hermes/bad"], []),
179
+ (["guard=hermes", "guard=hermes"], []),
180
+ (["guard=hermes"], ["guard"]),
181
+ (["missing=codex"], []),
182
+ (["guard@1=codex"], []),
183
+ ])
184
+ def test_invalid_agent_target_request_cannot_write(targets, clears):
185
+ with pytest.raises(RefError):
186
+ plan_deploy("machine", current=[ref("guard")], base=1,
187
+ targets=targets, all_agents=clears)
188
+
189
+
144
190
  def test_set_cannot_be_mixed_with_add_or_remove():
145
191
  """"exactly these" and "these as well" have no single reading."""
146
192
  with pytest.raises(RefError, match="cannot be combined"):
@@ -1590,6 +1590,29 @@ def test_history_says_no_change_only_when_nothing_moved(capsys):
1590
1590
  assert "no change" in capsys.readouterr().out
1591
1591
 
1592
1592
 
1593
+ def test_history_keeps_gen_when_and_jev_on_an_80_column_terminal(capsys):
1594
+ """At 80 columns every no_wrap cell got shaved at once and `gen`, `when` and
1595
+ `jev` vanished — but `gen` is what `fp fleet rollback` takes."""
1596
+ _wide_stdout(80)
1597
+ pols = [{"id": f"long-policy-name-{i:02d}", "version": 1, "effect": "enforce",
1598
+ "agentTargets": [{"integration": "claude", "instanceId": "agt_" + "ab" * 16}]}
1599
+ for i in range(4)]
1600
+ entries = [{"deployment": g, "updatedAt": f"2026-08-{10 + g:02d}T13:49:00Z",
1601
+ "jevMode": "observe" if g % 2 else None, "policies": pols[:g]}
1602
+ for g in range(1, 5)]
1603
+ output.render_deployment_history("m", entries)
1604
+ out = capsys.readouterr().out
1605
+ assert max(len(ln) for ln in out.splitlines()) <= 80
1606
+ header = next(ln for ln in out.splitlines() if "change" in ln)
1607
+ for col in ("gen", "when", "jev"):
1608
+ assert col in header, (col, out)
1609
+ for g in range(1, 5):
1610
+ row = next((ln for ln in out.splitlines() if f"#{g} " in ln), None)
1611
+ assert row is not None, (g, out)
1612
+ assert f"08-{10 + g:02d} 13:49:00" in row, out
1613
+ assert ("observe" if g % 2 else "local") in row, out
1614
+
1615
+
1593
1616
  def test_clearing_a_label_is_not_reported_as_renaming_to_blank(capsys):
1594
1617
  """`fp fleet rename m ""` clears the override server-side, and the card
1595
1618
  said `labelled m as ` — a sentence with a hole in it, describing neither
@@ -165,23 +165,25 @@ decision, because otherwise corrupted retry state could block a turn forever.
165
165
  ## Local protocol
166
166
 
167
167
  Requests and responses use the existing length-prefixed failproofaid Unix
168
- socket protocol, version 1.
168
+ socket protocol, version 2. A v1 daemon must be upgraded before it can safely
169
+ evaluate agent/profile-scoped policies.
169
170
 
170
171
  ```json
171
172
  {
172
173
  "type": "policyEvaluation",
173
- "protocolVersion": 1,
174
+ "protocolVersion": 2,
174
175
  "integration": "hermes",
175
176
  "event": "pre_tool_call",
176
177
  "payload": {},
177
- "cwd": "/workspace/project"
178
+ "cwd": "/workspace/project",
179
+ "agentSettingsPath": "/path/to/the/hermes/profile"
178
180
  }
179
181
  ```
180
182
 
181
183
  ```json
182
184
  {
183
185
  "type": "policyResult",
184
- "protocolVersion": 1,
186
+ "protocolVersion": 2,
185
187
  "decision": "instruct",
186
188
  "policyNames": ["custom/approved-write-route"],
187
189
  "reason": "Use the approved write route.",