failproofai 1.0.12-beta.1 → 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 (168) 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.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0tovk6q0miud6._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0trp3ycqv17cl._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_1ek68lnh2daur._.js +2 -2
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4vkpxxu7._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/src_hooks_0_em82s16zfym._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00l2u6j86cb8y._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08ch3staq3ejw._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bigvy9xmv2ka._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h86bl2qf0k_o._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pia5h3p8re4c._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pwj2h2efe5f-._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1-ey9fgesfbc0._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15usvs-ms-0hf._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__16gb3_92zvk5x._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1avi2kypusyb_._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1r7drlqli-ado._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0b78b0j8iyv6l._.js → [root-of-the-server]__1vofkb2oq7p7e._.js} +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_0bseae9u7g98r._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_0eoura3_9gjb-._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/_0mouhaflye1ro._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/{_1a3tubds7s7ow._.js → _0ouu886szh6yj._.js} +4 -4
  78. package/.next/standalone/.next/server/chunks/ssr/_0pedwblqdoh58._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_1djqs9i27e4kg._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_1fw0_nzkae5ao._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/_20hn4gn87nsjc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo5_vm5f.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9snixb5fd-._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47q0xq5w._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_components_project-list_tsx_1ke6anr0vt64h._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x33jqb6._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpcz0uxur._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h47jmhhd._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1i8oid1gc7s1f.js → node_modules_html-to-image_es_index_1fv03uigni8s5.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_0janj0i6dqko7._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/package_json_[json]_cjs_013tntikdft59._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0agdmptetetdt._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/src_hooks_0mdwgjisv4fub._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589gp4p60j._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me658zqmh_._.js +1 -1
  96. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  97. package/.next/standalone/.next/server/pages/404.html +1 -1
  98. package/.next/standalone/.next/server/pages/500.html +1 -1
  99. package/.next/standalone/.next/server/preview-props.json +3 -3
  100. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  101. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  102. package/.next/standalone/.next/static/chunks/{2-tf6_7u623kd.js → 0ay__-d2h1xb5.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{18433qz-8njv3.js → 0tbm3vjm62h-2.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{1b32-afqacaen.js → 1c_jf6g5wnslk.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{0_zfad4co-t2z.js → 1my3-5p47t7r-.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{0jutamwqz8pg5.js → 20py8k1gd_i1m.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/21hckpmsc4g70.js +6 -0
  108. package/.next/standalone/.next/static/chunks/2hmex93ppj1ke.js +1 -0
  109. package/.next/standalone/.next/static/chunks/{42e8zab314654.js → 2o6d37j425uci.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/40oworn_-x12p.js +1 -0
  111. package/.next/standalone/.next/static/chunks/{24w3x2da9sk49.js → 42xptz88mtq3x.js} +1 -1
  112. package/.next/standalone/app/actions/update-jev-config.ts +5 -3
  113. package/.next/standalone/app/settings/jev-panel.tsx +1 -1
  114. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +9 -18
  115. package/.next/standalone/fp-cloud-cli/README.md +7 -9
  116. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1 -27
  117. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +12 -216
  118. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +7 -53
  119. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +2 -71
  120. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +1 -1
  121. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +6 -7
  122. package/.next/standalone/fp-cloud-cli/tests/test_cloud_jev.py +32 -197
  123. package/.next/standalone/hermes-plugin/__init__.py +11 -0
  124. package/.next/standalone/hermes-plugin/plugin.yaml +1 -1
  125. package/.next/standalone/package.json +11 -10
  126. package/.next/standalone/sdk/python/uv.lock +31 -8
  127. package/.next/standalone/server.js +1 -1
  128. package/dist/cli.mjs +1686 -612
  129. package/dist/worker.mjs +354 -49
  130. package/hermes-plugin/__init__.py +11 -0
  131. package/hermes-plugin/plugin.yaml +1 -1
  132. package/package.json +11 -10
  133. package/src/audit/open-browser.ts +24 -7
  134. package/src/hooks/agent-roster.ts +9 -1
  135. package/src/hooks/agent-skill.ts +203 -0
  136. package/src/hooks/cloud-connection.ts +27 -51
  137. package/src/hooks/cloud-introspect.ts +5 -2
  138. package/src/hooks/cloud-login.ts +248 -0
  139. package/src/hooks/cloud-managed-policies.ts +54 -4
  140. package/src/hooks/codex-system-hooks.ts +10 -4
  141. package/src/hooks/config-status.ts +9 -3
  142. package/src/hooks/configure-wizard.ts +356 -32
  143. package/src/hooks/delivery-health.ts +47 -2
  144. package/src/hooks/effective-reviewers.ts +14 -0
  145. package/src/hooks/fp-config.ts +19 -0
  146. package/src/hooks/handler.ts +21 -2
  147. package/src/hooks/hook-activity-store.ts +7 -1
  148. package/src/hooks/integrations.ts +18 -3
  149. package/src/hooks/jev-activity.ts +117 -1
  150. package/src/hooks/jev-cli.ts +7 -9
  151. package/src/hooks/manager.ts +80 -46
  152. package/src/hooks/policy-evaluator.ts +17 -0
  153. package/src/hooks/policy-reviewability.ts +4 -2
  154. package/src/hooks/semantic/cloud-jev.ts +219 -28
  155. package/src/hooks/semantic/combine.ts +26 -1
  156. package/src/hooks/semantic/jev-client.ts +1 -1
  157. package/src/hooks/semantic/jev-config.ts +6 -27
  158. package/src/hooks/semantic/jev-review.ts +90 -2
  159. package/src/hooks/tui.ts +280 -0
  160. package/src/hooks/uninstall-cli.ts +12 -1
  161. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0588st-tpiuia._.js +0 -3
  162. package/.next/standalone/.next/server/chunks/ssr/_128ebxr83nk6g._.js +0 -3
  163. package/.next/standalone/.next/static/chunks/114hbolvrv94e.js +0 -1
  164. package/.next/standalone/.next/static/chunks/1p-hsi58i-6h0.js +0 -6
  165. package/.next/standalone/.next/static/chunks/3-pku9qwv1yfu.js +0 -1
  166. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_buildManifest.js +0 -0
  167. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_clientMiddlewareManifest.js +0 -0
  168. /package/.next/standalone/.next/static/{xq8UgF5yFxty1VWxY2hyn → LHCtFxVG0mdvd4lsXAOFC}/_ssgManifest.js +0 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The CLI half of "Log in to FailproofAI Cloud" — `failproofai config`'s
3
+ * browser login.
4
+ *
5
+ * The CLI asks the dashboard for a link, a person approves it in a browser
6
+ * (signing up, signing in, picking or creating an org on the way), and the CLI
7
+ * — polling with a secret only it holds — sees the approval and, once setup has
8
+ * finished, claims a freshly minted machine key. The server side is
9
+ * `server/src/routes/cli_login.rs` in AgentEye; the relay is the dashboard's
10
+ * `/api/auth/cli/*`.
11
+ *
12
+ * Three calls, each with an injectable `fetch` so tests never reach the network:
13
+ * start → { verificationUrl, pollSecret, expiresIn, interval }
14
+ * poll → pending / slow_down / approved (org + who) / denied / expired / …
15
+ * claim → the key, exactly once. Called at APPLY, after the daemon installs:
16
+ * an approval that is never claimed leaves no key behind.
17
+ *
18
+ * Every call refuses to follow a redirect, for the reason `introspectKey` gives:
19
+ * a dashboard that does not know a path can answer with a login page, and a 200
20
+ * from that must never read as success.
21
+ */
22
+
23
+ export const LOGIN_START_PATH = "/api/auth/cli/start";
24
+ export const LOGIN_POLL_PATH = "/api/auth/cli/poll";
25
+ export const LOGIN_CLAIM_PATH = "/api/auth/cli/claim";
26
+
27
+ const TIMEOUT_MS = 10_000;
28
+
29
+ export interface LoginOrg {
30
+ id?: string;
31
+ slug?: string;
32
+ name?: string;
33
+ }
34
+
35
+ export type StartResult =
36
+ | { kind: "ok"; verificationUrl: string; pollSecret: string; expiresIn: number; interval: number }
37
+ /** This cloud predates browser login. Pasting a key still works. */
38
+ | { kind: "unsupported" }
39
+ | { kind: "refused"; reason: string }
40
+ | { kind: "unreachable"; reason: string };
41
+
42
+ export type PollResult =
43
+ | { kind: "pending"; interval?: number }
44
+ | { kind: "slow_down" }
45
+ | { kind: "approved"; org: LoginOrg; approvedBy?: string; keyName?: string; permissions: string[] }
46
+ | { kind: "denied" }
47
+ | { kind: "expired" }
48
+ | { kind: "claimed" }
49
+ /** The server no longer knows this request (swept, or never existed). */
50
+ | { kind: "unknown" }
51
+ | { kind: "unreachable"; reason: string };
52
+
53
+ export type ClaimResult =
54
+ | {
55
+ kind: "ok";
56
+ key: string;
57
+ keyId?: string;
58
+ keyName?: string;
59
+ org: LoginOrg;
60
+ permissions: string[];
61
+ approvedBy?: string;
62
+ }
63
+ | { kind: "expired" }
64
+ /** The person who approved can no longer grant this key. */
65
+ | { kind: "revoked" }
66
+ | { kind: "refused"; reason: string }
67
+ | { kind: "unreachable"; reason: string };
68
+
69
+ type Fetch = typeof fetch;
70
+
71
+ async function postJson(
72
+ origin: string,
73
+ path: string,
74
+ body: unknown,
75
+ fetchImpl: Fetch,
76
+ ): Promise<{ status: number; json: Record<string, unknown> | null } | { error: string }> {
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
79
+ try {
80
+ const res = await fetchImpl(`${origin.replace(/\/+$/, "")}${path}`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify(body),
84
+ redirect: "manual",
85
+ signal: controller.signal,
86
+ });
87
+ let json: Record<string, unknown> | null = null;
88
+ try {
89
+ const parsed: unknown = await res.json();
90
+ json = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
91
+ } catch {
92
+ json = null;
93
+ }
94
+ return { status: res.status, json };
95
+ } catch (err) {
96
+ // Deliberately not the URL: setup is often run while screen-sharing.
97
+ return { error: err instanceof Error ? (err.name === "AbortError" ? "timed out" : err.message) : String(err) };
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+
103
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v.length > 0 ? v : undefined);
104
+
105
+ // Controls (a terminal executes them: clear the screen, retitle the window,
106
+ // write the clipboard), bidi overrides and isolates, and zero-width formatting.
107
+ const INVISIBLE = /[\u0000-\u001f\u007f-\u009f\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g;
108
+
109
+ /**
110
+ * A server-supplied string made safe to print. Org names are chosen by whoever
111
+ * created the org and the CLI prints the one it was approved into — the line
112
+ * that is meant to show who approved what — so none of it may reach the
113
+ * terminal as anything but text.
114
+ */
115
+ export function displayText(v: unknown, max = 200): string | undefined {
116
+ if (typeof v !== "string") return undefined;
117
+ const t = v.replace(INVISIBLE, "").trim().slice(0, max);
118
+ return t.length > 0 ? t : undefined;
119
+ }
120
+
121
+ function org(v: unknown): LoginOrg {
122
+ const o = v && typeof v === "object" ? (v as Record<string, unknown>) : {};
123
+ return { id: str(o.id), slug: displayText(o.slug), name: displayText(o.name) };
124
+ }
125
+
126
+ function permissions(v: unknown): string[] {
127
+ return Array.isArray(v) ? v.map((p) => displayText(p, 64)).filter((p): p is string => p !== undefined) : [];
128
+ }
129
+
130
+ /** A server's number, kept to a range this client can live with: a huge poll
131
+ * interval overflows `setTimeout` into a 1 ms storm, a tiny one polls flat out. */
132
+ const within = (v: unknown, lo: number, hi: number, fallback: number): number =>
133
+ typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.min(hi, Math.max(lo, v)) : fallback;
134
+
135
+ /**
136
+ * Only an http(s) link is ever handed to the browser opener. The URL comes from
137
+ * the server, and `xdg-open` / `open` will happily act on any scheme they know.
138
+ */
139
+ export function isOpenableUrl(raw: string): boolean {
140
+ try {
141
+ const u = new URL(raw);
142
+ return u.protocol === "https:" || u.protocol === "http:";
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /** What the browser page should call this machine and its OS. */
149
+ export function machineOs(platform: NodeJS.Platform = process.platform): string {
150
+ return platform === "darwin" ? "macOS" : platform === "win32" ? "windows" : platform;
151
+ }
152
+
153
+ export async function startLogin(
154
+ origin: string,
155
+ machine: { label: string; os: string; cliVersion: string },
156
+ fetchImpl: Fetch = fetch,
157
+ ): Promise<StartResult> {
158
+ const r = await postJson(
159
+ origin,
160
+ LOGIN_START_PATH,
161
+ { machine_label: machine.label, machine_os: machine.os, cli_version: machine.cliVersion },
162
+ fetchImpl,
163
+ );
164
+ if ("error" in r) return { kind: "unreachable", reason: r.error };
165
+ // No such route, the web app redirecting an unknown path, or serving a page
166
+ // (200, not JSON) in its place: a cloud from before this feature. Not an
167
+ // error — the key prompt still connects it.
168
+ if (r.status === 404 || (r.status >= 300 && r.status < 400) || (r.status === 200 && !r.json)) {
169
+ return { kind: "unsupported" };
170
+ }
171
+ if (r.status === 429) return { kind: "refused", reason: "too many login attempts from here — wait a minute" };
172
+ if (r.status !== 201 || !r.json) {
173
+ return r.status >= 500
174
+ ? { kind: "unreachable", reason: `the server answered ${r.status}` }
175
+ : { kind: "refused", reason: displayText(r.json?.error, 120) ?? `the server answered ${r.status}` };
176
+ }
177
+ const verificationUrl = str(r.json.verification_url);
178
+ const pollSecret = str(r.json.poll_secret);
179
+ if (!verificationUrl || !pollSecret || !isOpenableUrl(verificationUrl)) {
180
+ // A 201 without the fields is something other than this API.
181
+ return { kind: "unsupported" };
182
+ }
183
+ return {
184
+ kind: "ok",
185
+ // Re-serialised, never the raw string: it is printed, and the parser
186
+ // percent-encodes every control and non-ASCII character in it.
187
+ verificationUrl: new URL(verificationUrl).href,
188
+ pollSecret,
189
+ expiresIn: within(r.json.expires_in, 30, 3600, 600),
190
+ interval: within(r.json.interval, 1, 30, 2),
191
+ };
192
+ }
193
+
194
+ export async function pollLogin(origin: string, pollSecret: string, fetchImpl: Fetch = fetch): Promise<PollResult> {
195
+ const r = await postJson(origin, LOGIN_POLL_PATH, { poll_secret: pollSecret }, fetchImpl);
196
+ if ("error" in r) return { kind: "unreachable", reason: r.error };
197
+ if (r.status === 404) return { kind: "unknown" };
198
+ if (r.status !== 200 || !r.json) return { kind: "unreachable", reason: `the server answered ${r.status}` };
199
+ const j = r.json;
200
+ switch (j.status) {
201
+ case "pending":
202
+ return { kind: "pending", interval: typeof j.interval === "number" ? within(j.interval, 1, 30, 2) : undefined };
203
+ case "slow_down":
204
+ return { kind: "slow_down" };
205
+ case "approved":
206
+ return {
207
+ kind: "approved",
208
+ org: org(j.org),
209
+ approvedBy: displayText(j.approved_by),
210
+ keyName: displayText(j.key_name),
211
+ permissions: permissions(j.permissions),
212
+ };
213
+ case "denied":
214
+ return { kind: "denied" };
215
+ case "expired":
216
+ return { kind: "expired" };
217
+ case "claimed":
218
+ return { kind: "claimed" };
219
+ default:
220
+ return { kind: "unknown" };
221
+ }
222
+ }
223
+
224
+ export async function claimLogin(origin: string, pollSecret: string, fetchImpl: Fetch = fetch): Promise<ClaimResult> {
225
+ const r = await postJson(origin, LOGIN_CLAIM_PATH, { poll_secret: pollSecret }, fetchImpl);
226
+ if ("error" in r) return { kind: "unreachable", reason: r.error };
227
+ const j = r.json ?? {};
228
+ if (r.status === 201) {
229
+ const key = str(j.api_key);
230
+ if (!key) return { kind: "refused", reason: "the server answered without a key" };
231
+ return {
232
+ kind: "ok",
233
+ key,
234
+ keyId: str(j.key_id),
235
+ keyName: displayText(j.key_name),
236
+ org: org(j.org),
237
+ permissions: permissions(j.permissions),
238
+ approvedBy: displayText(j.approved_by),
239
+ };
240
+ }
241
+ if (r.status === 410) return { kind: "expired" };
242
+ if (r.status === 403 && j.error === "approval_revoked") return { kind: "revoked" };
243
+ if (r.status >= 500) return { kind: "unreachable", reason: `the server answered ${r.status}` };
244
+ return {
245
+ kind: "refused",
246
+ reason: displayText(j.status, 40) ? `the login request is ${displayText(j.status, 40)}` : `the server answered ${r.status}`,
247
+ };
248
+ }
@@ -170,10 +170,11 @@ export function resolveManagedPath(root: string, candidate: string): string {
170
170
  * Stop enforcing cloud-managed policies on this machine.
171
171
  *
172
172
  * Removes `desired-state.json` FIRST, then `active.json` — the manifest that
173
- * says which deployment is live — both error reports and the Jev budget record.
174
- * The content-addressed artifacts are left alone: they are large, hash-verified
175
- * on use, and inert once nothing points at them, so keeping them makes a
176
- * reconnect cheap.
173
+ * says which deployment is live — both error reports, the Jev budget record,
174
+ * the load-check record and the content-addressed `artifacts/` directory.
175
+ * The departed organisation's policy source is removed too; a reconnect
176
+ * downloads the artifacts its deployment names. Cleanup runs only in the
177
+ * CLI-owned default directory, never a `FAILPROOFAI_CLOUD_POLICY_DIR` override.
177
178
  *
178
179
  * The snapshot has to go, and before the pointer. It is what the daemon's
179
180
  * maintenance lane rebuilds a lost `active.json` from, and it used to be left
@@ -400,6 +401,55 @@ export function readCloudJevState(): CloudJevState {
400
401
  return { jevMode, deployment, errors };
401
402
  }
402
403
 
404
+ // ── Which Cloud Jev checks this agent has (jevPolicies) ─────────────────────
405
+
406
+ /** One `jev` / `both` assignment whose Jev checks FailproofAI Cloud asks here. */
407
+ export interface CloudJevAssignment {
408
+ id: string;
409
+ version: number;
410
+ effect: PolicyEffect;
411
+ agentTargets?: AgentTarget[];
412
+ }
413
+
414
+ /**
415
+ * `active.json`'s `jevPolicies`: the Cloud Jev assignments of this deployment
416
+ * (ids, effects and scopes, never a question). `null` when the manifest does
417
+ * not carry the list (a server that predates it) or carries one this build
418
+ * cannot read: then nothing is known, and every gated call is still sent to
419
+ * Cloud, as before. Never throws.
420
+ */
421
+ export function readCloudJevAssignments(): CloudJevAssignment[] | null {
422
+ try {
423
+ const active = readActiveRaw();
424
+ if (!active || !Array.isArray(active.jevPolicies)) return null;
425
+ const schema = active.schemaVersion as number;
426
+ const out: CloudJevAssignment[] = [];
427
+ for (const raw of active.jevPolicies as unknown[]) {
428
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
429
+ const entry = raw as Record<string, unknown>;
430
+ if (typeof entry.id !== "string" || !POLICY_ID_RE.test(entry.id)) return null;
431
+ if (!Number.isSafeInteger(entry.version) || (entry.version as number) < 0) return null;
432
+ if (entry.effect !== undefined && entry.effect !== "enforce" && entry.effect !== "observe") return null;
433
+ const targets = parseAgentTargets(entry.agentTargets, schema);
434
+ out.push({
435
+ id: entry.id,
436
+ version: entry.version as number,
437
+ effect: entry.effect === "observe" ? "observe" : "enforce",
438
+ ...(targets ? { agentTargets: targets } : {}),
439
+ });
440
+ }
441
+ return out;
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
446
+
447
+ /** The Cloud Jev assignments that apply to `agent`, or null when unknown (see {@link readCloudJevAssignments}). */
448
+ export function cloudJevAssignmentsFor(agent: AgentIdentity | null): CloudJevAssignment[] | null {
449
+ const all = readCloudJevAssignments();
450
+ return all === null ? null : all.filter((a) => agentTargetsMatch(a.agentTargets, agent));
451
+ }
452
+
403
453
  // ── `both` policies: reviewed by their OWN Cloud checks ─────────────────────
404
454
 
405
455
  /**
@@ -432,10 +432,16 @@ export type CodexHookTrust =
432
432
  /** In hooks.json and not all trusted: Codex skips the untrusted ones. */
433
433
  | { kind: "untrusted"; path: string; untrusted: number; total: number };
434
434
 
435
+ /** The Codex profile's home: `CODEX_HOME` when set, else `~/.codex`. */
436
+ export function codexHome(): string {
437
+ const dir = process.env.CODEX_HOME?.trim();
438
+ return dir ? resolve(dir) : resolve(homedir(), ".codex");
439
+ }
440
+
435
441
  export interface CodexTrustDeps extends CodexSystemDeps {
436
- /** `~/.codex/hooks.json`. */
442
+ /** `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json`). */
437
443
  hooksJsonPath?: string;
438
- /** `~/.codex/config.toml`, where Codex keeps `[hooks.state]`. */
444
+ /** `$CODEX_HOME/config.toml`, where Codex keeps `[hooks.state]`. */
439
445
  userConfigPath?: string;
440
446
  }
441
447
 
@@ -452,7 +458,7 @@ export function codexHookTrust(deps: CodexTrustDeps = {}): CodexHookTrust {
452
458
  const systemPath = resolvedPath(deps);
453
459
  if (systemPath && codexSystemHooksPresent({ path: systemPath })) return { kind: "system", path: systemPath };
454
460
 
455
- const hooksJsonPath = deps.hooksJsonPath ?? resolve(homedir(), ".codex", "hooks.json");
461
+ const hooksJsonPath = deps.hooksJsonPath ?? resolve(codexHome(), "hooks.json");
456
462
  const ours = failproofaiCodexHookIdentities(hooksJsonPath);
457
463
  if (ours.length === 0) return { kind: "none" };
458
464
 
@@ -462,7 +468,7 @@ export function codexHookTrust(deps: CodexTrustDeps = {}): CodexHookTrust {
462
468
  let trustedHashes = new Map<string, string>();
463
469
  try {
464
470
  trustedHashes = parseCodexTrustedHashes(
465
- readFileSync(deps.userConfigPath ?? resolve(homedir(), ".codex", "config.toml"), "utf8"),
471
+ readFileSync(deps.userConfigPath ?? resolve(codexHome(), "config.toml"), "utf8"),
466
472
  );
467
473
  } catch {
468
474
  // No user config: nothing is trusted.
@@ -27,7 +27,7 @@ import { readCloudCredentials } from "./cloud-enrollment";
27
27
  import { CODEX_UNTRUSTED_NOTICE, codexHookTrust, type CodexHookTrust } from "./codex-system-hooks";
28
28
  import { describeMachine } from "./cloud-enrollment-cli";
29
29
  import { cloudBaseFor } from "./cloud-connection";
30
- import { readActiveCloudManagedPolicies } from "./cloud-managed-policies";
30
+ import { listActiveCloudManagedPolicies } from "./cloud-managed-policies";
31
31
  import { readIngestCredential } from "./collector-config";
32
32
  import { discoverPolicyFiles } from "./custom-hooks-loader";
33
33
  import { daemonSocketPresent, isDaemonConfigured } from "./daemon-client";
@@ -259,7 +259,10 @@ export function summarizePolicySources(cwd?: string): StatusPolicies {
259
259
  }
260
260
 
261
261
  try {
262
- for (const artifact of readActiveCloudManagedPolicies()) {
262
+ // Every assignment, whatever agent it targets: a hook's agent scope applied
263
+ // with no agent hid each targeted policy ("None installed" beside a
264
+ // deployment that enforces), the status twin of B4.
265
+ for (const artifact of listActiveCloudManagedPolicies()) {
263
266
  if (artifact.effect === "observe") summary.observing++;
264
267
  else summary.cloudOn++;
265
268
  }
@@ -525,7 +528,10 @@ export function renderStatusScreen(facts: StatusFacts, opts: ScreenKitOpts & { c
525
528
  return kit.caution(`No report from failproofaid for ${formatDuration(now - health.writtenAt)}`);
526
529
  }
527
530
  if (!health.delivering) return `${kit.off} Not delivering, failproofaid has no key`;
528
- const backlog = queued === 0 ? "nothing queued" : `${many(queued, "batch", "batches")} queued`;
531
+ // Batches an earlier key had refused wait for the daemon to re-send them
532
+ // with the key that works now: queued, not refused.
533
+ const pending = queued + (refused.resending ?? 0);
534
+ const backlog = pending === 0 ? "nothing queued" : `${many(pending, "batch", "batches")} queued`;
529
535
  const stuck =
530
536
  queued > 0 &&
531
537
  (oldestQueuedMs ?? 0) > STATUS_QUEUE_STUCK_MS &&