failproofai 1.0.2-beta.0 → 1.0.2-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 (133) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  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/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  32. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/index.html +1 -1
  36. package/.next/standalone/.next/server/app/index.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  38. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  42. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  46. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  58. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  63. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  67. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +14 -14
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__090sxxw._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/_0wb8osu._.js +23 -0
  82. package/.next/standalone/.next/server/chunks/ssr/{_12l4a_k._.js → _1oggjel._.js} +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  89. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  90. package/.next/standalone/.next/server/pages/404.html +1 -1
  91. package/.next/standalone/.next/server/pages/500.html +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  93. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  94. package/.next/standalone/.next/static/chunks/{3y54nw0i7dsjl.js → 0nnbtp0w4sxrr.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{0sa9v5mhc8861.js → 0ykd_go4oahan.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{0eqky-7n7dgdm.js → 140ip21rqmelt.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2i2yt3hzjredm.js → 1da5a_a-3btas.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{1oml7c7env4-g.js → 1fac8krph-zgp.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{2hs7ycvsav7v2.js → 1nzy5jc_vf9e0.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{1sl2xmn9inx3y.js → 1yw71gn8vqco1.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{1nuqytbta44af.js → 29eh4k77otcw6.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{2aawsjld9ied_.js → 2axi5ehjbld9h.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/{2gn7ut4svofm2.js → 2fzxl7n717pbc.js} +1 -1
  104. package/.next/standalone/.next/static/chunks/{35xg6retpe6rp.js → 3jjmdqqzem21x.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{turbopack-3kjgdgwci4tqf.js → turbopack-2vqpa-dts1gy4.js} +1 -1
  106. package/.next/standalone/app/actions/pack-actions.ts +21 -11
  107. package/.next/standalone/app/policies/hooks-client.tsx +3 -4
  108. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -1
  109. package/.next/standalone/package.json +10 -11
  110. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  111. package/.next/standalone/server.js +1 -1
  112. package/bin/failproofai.mjs +455 -316
  113. package/dist/cli.mjs +2374 -1873
  114. package/dist/worker.mjs +3 -13
  115. package/package.json +10 -11
  116. package/src/hooks/configure-wizard.ts +67 -444
  117. package/src/hooks/fp-reset.ts +10 -19
  118. package/src/hooks/handler.ts +2 -2
  119. package/src/hooks/manager.ts +58 -24
  120. package/src/hooks/pack-cli.ts +488 -43
  121. package/src/hooks/pack-failclosed.ts +1 -1
  122. package/src/hooks/pack-manifest.ts +14 -6
  123. package/src/hooks/pack-store.ts +89 -128
  124. package/src/hooks/tui.ts +150 -48
  125. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0zig5sv._.js +0 -3
  126. package/.next/standalone/.next/server/chunks/ssr/_00e9tnf._.js +0 -23
  127. package/policy-pack/SHA256SUMS +0 -2
  128. package/policy-pack/failproofai-pack.json +0 -732
  129. package/policy-pack/failproofai-pack.mjs +0 -2017
  130. package/src/hooks/policy-presets.ts +0 -137
  131. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → A7czO1PZdTcNzLGa_H1sm}/_buildManifest.js +0 -0
  132. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → A7czO1PZdTcNzLGa_H1sm}/_clientMiddlewareManifest.js +0 -0
  133. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → A7czO1PZdTcNzLGa_H1sm}/_ssgManifest.js +0 -0
@@ -1,10 +1,11 @@
1
1
  /**
2
- * `failproofai pack add | remove | list`.
2
+ * `failproofai policies add | remove | list`.
3
3
  *
4
4
  * Presentation only — every rule about what a pack may be lives in
5
5
  * `pack-manifest.ts` (what may load) and `pack-store.ts` (what may install).
6
6
  * This layer decides nothing; it formats.
7
7
  */
8
+ import { execFileSync } from "node:child_process";
8
9
  import { createHash } from "node:crypto";
9
10
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
11
  import { resolve } from "node:path";
@@ -17,15 +18,19 @@ import {
17
18
  checkPackArtifact,
18
19
  CORE_ALIASES,
19
20
  fetchPackPreview,
20
- installBundledPack,
21
+ CORE_SOURCE,
22
+ packTagMatchesVersion,
21
23
  removePack,
24
+ setPackPolicyEnabled,
22
25
  slugifyCategory,
23
26
  } from "./pack-store";
24
27
  import type { PolicyEffect } from "./cloud-managed-policies";
25
28
  import { loadCustomHooks } from "./custom-hooks-loader";
29
+ import type { MultiChoice, TTYIn, TTYOut } from "./tui";
26
30
  import {
27
31
  chip,
28
32
  emptyState,
33
+ multiSelect,
29
34
  note,
30
35
  nextStep,
31
36
  optsFor,
@@ -120,7 +125,7 @@ async function build(rest: string[]): Promise<PackCliResult> {
120
125
 
121
126
  if (!entry || !id || !version) {
122
127
  return fail([
123
- "Usage: failproofai pack build <entry.mjs> --id <publisher/name> --version <version>",
128
+ "Usage: failproofai publish <entry.mjs> --repo <owner>/<repo> --version <version>",
124
129
  " [--out <dir>] [--effect enforce|observe]",
125
130
  ]);
126
131
  }
@@ -207,44 +212,453 @@ async function build(rest: string[]): Promise<PackCliResult> {
207
212
  ` ${outDir}/${PACK_CHECKSUMS_ASSET}`,
208
213
  "",
209
214
  `Publish: attach all three to a GitHub release tagged ${identity.version}, then anyone runs:`,
210
- ` failproofai pack add <owner>/<repo>`,
215
+ ` failproofai policies add <owner>/<repo>`,
211
216
  ]);
212
217
  }
213
218
 
214
- async function add(rest: string[]): Promise<PackCliResult> {
215
- const source = packAddSource(rest);
216
- const selection = selectionFrom(rest);
219
+ /**
220
+ * The list, with what is already on ticked, for `failproofai policies add` with
221
+ * nothing after it.
222
+ *
223
+ * Naming nothing used to be an error — "Missing policy name", followed by an
224
+ * instruction to go and read a list somewhere else and come back. That is the
225
+ * command telling the user to do the work it is for. The same objection applies
226
+ * to `pack add` taking a publisher's defaults and only afterwards printing what
227
+ * it decided: a default is a suggestion, and a suggestion the user never saw is
228
+ * just a decision taken on their behalf.
229
+ *
230
+ * So this is one screen showing every policy on the machine, grouped by pack and
231
+ * category, with the current state pre-ticked. It edits the enabled SET — there
232
+ * is no separate "remove" screen, because choosing what is on and choosing what
233
+ * is off are the same act.
234
+ *
235
+ * Refuses rather than guesses when there is no terminal to draw on. `multiSelect`
236
+ * degrades by returning its pre-checked set, which here would mean "confirm
237
+ * exactly what is already true" — a silent no-op reported as a success. A script
238
+ * that lands in this branch asked the wrong question and should be told so.
239
+ */
240
+ export async function runPolicyPicker(
241
+ action: "add" | "remove",
242
+ io: { stdin?: TTYIn; stdout?: TTYOut } = {},
243
+ ): Promise<PackCliResult> {
244
+ const stdin = io.stdin ?? process.stdin;
245
+ const stdout = io.stdout ?? process.stdout;
246
+ const opts = optsFor(stdout as NodeJS.WriteStream);
247
+ const { packs } = readInstalledPacks();
248
+
249
+ if (!stdin.isTTY || !stdout.isTTY) {
250
+ return fail([
251
+ `\`policies ${action}\` with no name needs a terminal to show you the list.`,
252
+ "From a script, name what you mean:",
253
+ ` failproofai policies ${action} <policy-name>`,
254
+ ` failproofai policies ${action} <owner>/<repo> [--policy a,b] [--category x,y] [--all]`,
255
+ ]);
256
+ }
257
+
258
+ if (packs.length === 0) {
259
+ return ok(
260
+ stack(
261
+ emptyState(
262
+ {
263
+ what: "No policies are installed yet.",
264
+ hint: "Take ours, or anyone's:",
265
+ cmd: "failproofai policies add core",
266
+ },
267
+ opts,
268
+ ),
269
+ note("Someone else's: failproofai policies add <owner>/<repo>", opts),
270
+ note("Look first: failproofai policies show <owner>/<repo>", opts),
271
+ ),
272
+ );
273
+ }
217
274
 
218
- // Our own pack, by the short name and from the copy inside this package, so
219
- // it is instant and works with no network at all.
220
- if ((source && CORE_ALIASES.has(source.toLowerCase())) || rest.includes("--bundled")) {
221
- const result = installBundledPack(selection);
222
- if (!result.installed) {
223
- return fail([`Could not install the Failproof AI policies: ${result.reason}`]);
275
+ // One row per policy across every pack. The pack id leads the section heading
276
+ // because two packs may legitimately ship a category of the same name, and a
277
+ // row that says only "Data" would not tell you whose Data it is.
278
+ const rows: MultiChoice<string>[] = [];
279
+ const owner = new Map<string, string>();
280
+ for (const pack of packs) {
281
+ const taken = new Set(pack.enabled ?? pack.policies.map((pol) => pol.name));
282
+ const categories = [...new Set(pack.policies.map((pol) => pol.category))];
283
+ for (const category of categories) {
284
+ for (const pol of pack.policies.filter((x) => x.category === category)) {
285
+ // Keyed by name, not by pack+name: a name collision across two packs is
286
+ // already impossible to enforce unambiguously, and silently editing the
287
+ // wrong pack's copy would be worse than the collision.
288
+ owner.set(pol.name, pack.id);
289
+ rows.push({
290
+ label: pol.name,
291
+ value: pol.name,
292
+ hint: pol.description,
293
+ checked: taken.has(pol.name),
294
+ section: `${pack.id} · ${category}`,
295
+ });
296
+ }
224
297
  }
225
- const enabled = result.enabled ?? [];
226
- const available = result.available ?? [];
227
- const skipped = available.filter((n) => !enabled.includes(n));
228
- const lines = [
229
- `Installed ${result.id}@${result.version} from this package — no network needed.`,
230
- ...(result.replaced?.length
231
- ? [` replaced ${result.replaced.join(", ")} — same policies, renamed; your selection was kept`]
232
- : []),
233
- ` enabled (${enabled.length}/${available.length}): ${summarise(enabled)}`,
234
- ];
235
- if (skipped.length > 0) {
236
- lines.push(` not enabled (${skipped.length}): ${summarise(skipped)}`);
237
- lines.push("");
238
- lines.push(" one policy: failproofai pack add core --policy block-rm-rf");
239
- lines.push(" a category: failproofai pack add core --category dangerous-commands");
240
- lines.push(" everything: failproofai pack add core --all");
241
- lines.push(" see them all: failproofai pack list");
298
+ }
299
+
300
+ const before = new Set(rows.filter((r) => r.checked).map((r) => r.value));
301
+ const picked = await multiSelect<string>({
302
+ message: "Which policies should be on?",
303
+ choices: rows,
304
+ summaryNoun: "policies",
305
+ hint: "space toggles · ctrl+a all · ↵ confirm · what is on now is ticked",
306
+ stdin,
307
+ stdout,
308
+ });
309
+ if (picked === null) return ok(["Nothing changed."]);
310
+
311
+ const after = new Set(picked);
312
+ const turnedOn = [...after].filter((n) => !before.has(n));
313
+ const turnedOff = [...before].filter((n) => !after.has(n));
314
+ if (turnedOn.length === 0 && turnedOff.length === 0) {
315
+ return ok([`Nothing changed — ${after.size} of ${rows.length} still on.`]);
316
+ }
317
+
318
+ const failures: string[] = [];
319
+ for (const name of [...turnedOn, ...turnedOff]) {
320
+ const packId = owner.get(name);
321
+ if (!packId) continue;
322
+ const result = setPackPolicyEnabled(packId, name, after.has(name));
323
+ if (!result.ok) failures.push(`${name}: ${result.reason ?? "could not be written"}`);
324
+ }
325
+ if (failures.length > 0) {
326
+ return fail(["Some policies could not be changed:", ...failures.map((f) => ` ${f}`)]);
327
+ }
328
+
329
+ const lines = [`${after.size} of ${rows.length} policies on.`];
330
+ if (turnedOn.length > 0) lines.push(` turned on (${turnedOn.length}): ${summarise(turnedOn)}`);
331
+ if (turnedOff.length > 0) lines.push(` turned off (${turnedOff.length}): ${summarise(turnedOff)}`);
332
+ return ok(lines);
333
+ }
334
+
335
+
336
+ // ── publish ───────────────────────────────────────────────────────────────
337
+
338
+ const GITHUB_API = process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com";
339
+ const GITHUB_UPLOADS = process.env.FAILPROOFAI_GITHUB_UPLOADS ?? "https://uploads.github.com";
340
+
341
+ /**
342
+ * The credential, from the places a developer already keeps one.
343
+ *
344
+ * `gh auth token` is READ-ONLY and is deliberately not one of the subcommands
345
+ * `block-gh-pipeline` matches — which is exactly why the release itself goes
346
+ * over REST rather than through `gh release create`: that one IS matched, so a
347
+ * user with our own policy switched on could not publish with a gh-shaped
348
+ * implementation. Shipping a publish path our own guardrail blocks is not a
349
+ * thing to do.
350
+ *
351
+ * Never returned in any message. A token that reaches stdout reaches CI logs.
352
+ */
353
+ function githubToken(): string | null {
354
+ const fromEnv = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
355
+ if (fromEnv) return fromEnv.trim();
356
+ try {
357
+ const out = execFileSync("gh", ["auth", "token"], {
358
+ encoding: "utf8",
359
+ stdio: ["ignore", "pipe", "ignore"],
360
+ timeout: 5_000,
361
+ });
362
+ return out.trim() || null;
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ async function gh(
369
+ url: string,
370
+ token: string,
371
+ init: { method?: string; body?: BodyInit; contentType?: string } = {},
372
+ ): Promise<{ status: number; json: Record<string, unknown> | null; text: string }> {
373
+ const response = await fetch(url, {
374
+ method: init.method ?? "GET",
375
+ headers: {
376
+ Authorization: `Bearer ${token}`,
377
+ Accept: "application/vnd.github+json",
378
+ "X-GitHub-Api-Version": "2022-11-28",
379
+ "User-Agent": "failproofai",
380
+ ...(init.contentType ? { "Content-Type": init.contentType } : {}),
381
+ },
382
+ ...(init.body ? { body: init.body } : {}),
383
+ signal: AbortSignal.timeout(60_000),
384
+ });
385
+ const text = await response.text();
386
+ let json: Record<string, unknown> | null = null;
387
+ try {
388
+ json = text ? (JSON.parse(text) as Record<string, unknown>) : null;
389
+ } catch {
390
+ json = null;
391
+ }
392
+ return { status: response.status, json, text };
393
+ }
394
+
395
+ /** GitHub's error bodies are shaped `{message, errors:[{code,field}]}`; the bare
396
+ * `message` alone is frequently just "Validation Failed". */
397
+ function ghError(res: { status: number; json: Record<string, unknown> | null; text: string }): string {
398
+ const message = typeof res.json?.message === "string" ? res.json.message : res.text.slice(0, 200);
399
+ const errors = Array.isArray(res.json?.errors)
400
+ ? (res.json.errors as Array<Record<string, unknown>>)
401
+ .map((e) => [e.field, e.code].filter(Boolean).join(" "))
402
+ .filter(Boolean)
403
+ : [];
404
+ return errors.length > 0 ? `${message} (${errors.join("; ")})` : message;
405
+ }
406
+
407
+ /**
408
+ * Build the assets AND put them on a GitHub release, in one command.
409
+ *
410
+ * Publishing used to be four commands, and two of them did nothing: `git init`,
411
+ * `git add`, `git commit`, `gh repo create`, `gh release create`. Only the last
412
+ * one publishes — installs read `releases/download/<tag>/<asset>` and never
413
+ * touch the git tree, so pushing source is for humans reading it, not for the
414
+ * install to work. A publisher had to discover that by reading `pack-store.ts`.
415
+ *
416
+ * With no `--repo` this is exactly the old `pack build`: write the three assets
417
+ * and stop. That is what `pack build` now resolves to, so the old command keeps
418
+ * working and means the same thing.
419
+ *
420
+ * Two failures the manual flow let through silently, both refused here:
421
+ * - a tag that does not describe the manifest version, which installs and then
422
+ * reports a version matching no URL;
423
+ * - a private repository, which publishes to nobody, because installs are
424
+ * anonymous HTTPS with no credential to offer.
425
+ */
426
+ async function publish(rest: string[]): Promise<PackCliResult> {
427
+ const flag = (name: string): string | undefined => {
428
+ const i = rest.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
429
+ if (i === -1) return undefined;
430
+ return rest[i].includes("=") ? rest[i].split("=").slice(1).join("=") : rest[i + 1];
431
+ };
432
+
433
+ const repo = flag("repo");
434
+ const version = flag("version");
435
+ const entry = packAddSource(rest.filter((a) => a !== "--dry-run")) ?? flag("entry");
436
+ // The id and the repo are usually the same words, and requiring both is asking
437
+ // the same question twice. Either one alone answers for the other.
438
+ const id = flag("id") ?? repo;
439
+ const tag = flag("tag") ?? version;
440
+ const dryRun = rest.includes("--dry-run") || !repo;
441
+
442
+ if (!entry || !version || !id) {
443
+ return fail([
444
+ "Usage: failproofai publish <entry.mjs> --repo <owner>/<repo> --version <version>",
445
+ " [--id <publisher/name>] [--tag <tag>] [--notes <text>]",
446
+ " [--out <dir>] [--effect enforce|observe] [--dry-run]",
447
+ "",
448
+ "With no --repo it writes the three release assets and stops.",
449
+ ]);
450
+ }
451
+
452
+ // Build first, always. There is no point authenticating against GitHub to
453
+ // discover the pack does not load.
454
+ const built = await build([entry, "--id", id, "--version", version, ...outFlagFrom(rest), ...effectFlagFrom(rest)]);
455
+ if (built.exitCode !== 0) return built;
456
+
457
+ const outDir = resolve(flag("out") ?? "dist-pack");
458
+ if (dryRun) {
459
+ return ok([
460
+ ...built.lines.slice(0, 4),
461
+ "",
462
+ ...(repo
463
+ ? ["Dry run — nothing was published.", `Drop --dry-run to release it on ${repo}.`]
464
+ : [
465
+ "Nothing was published: name a repository to release it on.",
466
+ ` failproofai publish ${entry} --repo <owner>/<repo> --version ${version}`,
467
+ ]),
468
+ ]);
469
+ }
470
+
471
+ if (!packTagMatchesVersion(tag!, version)) {
472
+ return fail([
473
+ `Tag ${tag} does not describe version ${version}, so nobody could install it.`,
474
+ `A pack is fetched from releases/download/${tag}/… and then reports ${version} —`,
475
+ `which names no release. Use --tag ${version} (a leading v is fine), or build`,
476
+ "the pack at the version the tag says.",
477
+ ]);
478
+ }
479
+
480
+ const [owner, name] = repo!.split("/");
481
+ if (!owner || !name || repo!.split("/").length !== 2) {
482
+ return fail([`--repo must be <owner>/<repo>, got ${JSON.stringify(repo)}`]);
483
+ }
484
+
485
+ const token = githubToken();
486
+ if (!token) {
487
+ return fail([
488
+ "No GitHub credential found.",
489
+ "Set GITHUB_TOKEN (or GH_TOKEN), or sign in once with `gh auth login`.",
490
+ "It needs write access to releases on that repository, and nothing else.",
491
+ ]);
492
+ }
493
+
494
+ const repoInfo = await gh(`${GITHUB_API}/repos/${owner}/${name}`, token);
495
+ if (repoInfo.status === 404) {
496
+ return fail([
497
+ `${repo} does not exist, or that credential cannot see it.`,
498
+ `Create it first: gh repo create ${repo} --public`,
499
+ ]);
500
+ }
501
+ if (repoInfo.status >= 400) return fail([`Could not read ${repo}: ${ghError(repoInfo)}`]);
502
+ // A private repo is not a smaller audience, it is no audience: `pack add`
503
+ // sends no Authorization header at all, by design, so every install 404s.
504
+ const isPrivate = repoInfo.json?.private === true;
505
+
506
+ // Reuse a release on this tag rather than failing on it. Re-publishing a
507
+ // corrected artifact under an existing tag is a normal thing to need, and the
508
+ // digest change is visible to anyone who reinstalls.
509
+ let releaseId: number | null = null;
510
+ const existing = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/tags/${encodeURIComponent(tag!)}`, token);
511
+ if (existing.status === 200 && typeof existing.json?.id === "number") {
512
+ releaseId = existing.json.id;
513
+ } else {
514
+ const created = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases`, token, {
515
+ method: "POST",
516
+ contentType: "application/json",
517
+ body: JSON.stringify({
518
+ tag_name: tag,
519
+ name: `${id} ${version}`,
520
+ body: flag("notes") ?? `${id}@${version}`,
521
+ // A prerelease is invisible to `releases/latest`, which is how a tagless
522
+ // `policies add owner/repo` resolves a version — so publishing one would
523
+ // make the pack installable only by people who already knew its tag.
524
+ prerelease: false,
525
+ draft: false,
526
+ }),
527
+ });
528
+ if (created.status >= 400 || typeof created.json?.id !== "number") {
529
+ return fail([`Could not create the release on ${repo}: ${ghError(created)}`]);
242
530
  }
243
- return ok(lines);
531
+ releaseId = created.json.id;
244
532
  }
245
533
 
246
- if (!source) {
247
- return fail(["Usage: failproofai pack add <source> [--only a,b] [--category x,y] [--all]"]);
534
+ const assets = [PACK_MANIFEST_ASSET, PACK_ENTRY_ASSET, PACK_CHECKSUMS_ASSET];
535
+ const uploaded: string[] = [];
536
+ const listed = await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/${releaseId}/assets?per_page=100`, token);
537
+ const already = Array.isArray(listed.json)
538
+ ? (listed.json as unknown as Array<{ id: number; name: string }>)
539
+ : [];
540
+ for (const asset of assets) {
541
+ // Asset names are fixed and the URL is constructed from them, so a stale
542
+ // copy under the same name is what an installer would fetch. Replace, never
543
+ // append.
544
+ const prior = already.find((a) => a.name === asset);
545
+ if (prior) {
546
+ await gh(`${GITHUB_API}/repos/${owner}/${name}/releases/assets/${prior.id}`, token, { method: "DELETE" });
547
+ }
548
+ const bytes = readFileSync(resolve(outDir, asset));
549
+ const upload = await gh(
550
+ `${GITHUB_UPLOADS}/repos/${owner}/${name}/releases/${releaseId}/assets?name=${encodeURIComponent(asset)}`,
551
+ token,
552
+ {
553
+ method: "POST",
554
+ contentType: asset.endsWith(".json") ? "application/json" : "application/octet-stream",
555
+ body: new Uint8Array(bytes),
556
+ },
557
+ );
558
+ if (upload.status >= 400) {
559
+ return fail([
560
+ `Uploaded ${uploaded.length} of ${assets.length} assets, then ${asset} failed: ${ghError(upload)}`,
561
+ `The release exists but is INCOMPLETE — an install would fail on the missing asset.`,
562
+ `Re-run the same command; existing assets are replaced, not duplicated.`,
563
+ ]);
564
+ }
565
+ uploaded.push(asset);
566
+ }
567
+
568
+ const lines = [
569
+ `Published ${id}@${version} to ${repo} at tag ${tag}.`,
570
+ ` ${assets.length} assets attached`,
571
+ "",
572
+ "Anyone can now install it:",
573
+ ` failproofai policies add ${repo}`,
574
+ ` failproofai policies show ${repo} (look first, without running it)`,
575
+ ];
576
+ if (isPrivate) {
577
+ lines.push(
578
+ "",
579
+ `WARNING: ${repo} is PRIVATE, so nobody can install this.`,
580
+ "Installs are anonymous HTTPS with no credential to offer — every one will 404.",
581
+ `Make it public: gh repo edit ${repo} --visibility public`,
582
+ );
583
+ }
584
+ return ok(lines);
585
+ }
586
+
587
+ /** Pass --out through to build without re-parsing it. */
588
+ function outFlagFrom(rest: string[]): string[] {
589
+ const i = rest.findIndex((a) => a === "--out" || a.startsWith("--out="));
590
+ if (i === -1) return [];
591
+ return rest[i].includes("=") ? [rest[i]] : [rest[i], rest[i + 1]];
592
+ }
593
+ function effectFlagFrom(rest: string[]): string[] {
594
+ const i = rest.findIndex((a) => a === "--effect" || a.startsWith("--effect="));
595
+ if (i === -1) return [];
596
+ return rest[i].includes("=") ? [rest[i]] : [rest[i], rest[i + 1]];
597
+ }
598
+
599
+ /** `failproofai publish` — exported for bin/failproofai.mjs and the tests. */
600
+ export async function runPublishCommand(rest: string[]): Promise<PackCliResult> {
601
+ return publish(rest);
602
+ }
603
+
604
+ /**
605
+ * The pack's own list, defaults pre-ticked, before anything is installed.
606
+ *
607
+ * Reads the MANIFEST only — `fetchPackPreview` never downloads the entry
608
+ * artifact — so the code of a pack you are still deciding about is never
609
+ * fetched, let alone imported.
610
+ *
611
+ * Returns null when the user cancelled, which must not be confused with an
612
+ * empty selection: one means "install nothing", the other means "do nothing".
613
+ */
614
+ async function pickFromSource(
615
+ source: string,
616
+ io: { stdin: TTYIn; stdout: TTYOut },
617
+ ): Promise<string[] | null> {
618
+ const preview = await fetchPackPreview(source);
619
+ const categories = [...new Set(preview.policies.map((p) => p.category))];
620
+ const rows: MultiChoice<string>[] = [];
621
+ for (const category of categories) {
622
+ for (const policy of preview.policies.filter((p) => p.category === category)) {
623
+ rows.push({
624
+ label: policy.name,
625
+ value: policy.name,
626
+ hint: policy.description,
627
+ checked: policy.defaultEnabled,
628
+ section: `${category} · ${slugifyCategory(category)}`,
629
+ });
630
+ }
631
+ }
632
+ const on = preview.policies.filter((p) => p.defaultEnabled).length;
633
+ const picked = await multiSelect<string>({
634
+ message: `${preview.id}@${preview.version} — which of these should be on?`,
635
+ choices: rows,
636
+ summaryNoun: "policies",
637
+ hint: `space toggles · ctrl+a all · ↵ confirm · ${on} of ${rows.length} are the publisher's defaults`,
638
+ stdin: io.stdin,
639
+ stdout: io.stdout,
640
+ });
641
+ return picked;
642
+ }
643
+
644
+ async function add(rest: string[]): Promise<PackCliResult> {
645
+ const source = packAddSource(rest);
646
+ const selection = selectionFrom(rest);
647
+
648
+ // Our own pack, by the short name. `core` is a SPELLING of a GitHub source,
649
+ // not a second delivery path: it resolves to CORE_SOURCE and is fetched,
650
+ // verified and pinned exactly like anybody else's.
651
+ //
652
+ // The package used to carry a copy so this worked with no network. It no
653
+ // longer does, on purpose. A pack that ships inside the binary is a policy set
654
+ // we picked for you and wrote to your disk before you asked for it, and it
655
+ // gave our own policies a route no third-party pack could use — which is the
656
+ // opposite of the thing this whole lane exists to make possible.
657
+ const resolvedSource =
658
+ source && CORE_ALIASES.has(source.toLowerCase()) ? CORE_SOURCE : source;
659
+
660
+ if (!resolvedSource) {
661
+ return fail(["Usage: failproofai policies add <source> [--policy a,b] [--category x,y] [--all]"]);
248
662
  }
249
663
  if (selection.only && selection.only.length === 0) {
250
664
  return fail(["--policy needs at least one policy name, comma-separated"]);
@@ -253,8 +667,33 @@ async function add(rest: string[]): Promise<PackCliResult> {
253
667
  return fail(["--category needs at least one category, comma-separated"]);
254
668
  }
255
669
 
670
+ // CHOOSE, then install. A pack's `defaultEnabled` flags are the publisher's
671
+ // recommendation, and taking them silently turns a recommendation into a
672
+ // decision made on the user's behalf — announced only afterwards, by which
673
+ // point the policies are already on their machine.
674
+ //
675
+ // So a human at a terminal who named no flags gets the list first, with the
676
+ // publisher's defaults pre-ticked. The manifest is read WITHOUT downloading
677
+ // the entry artifact, so deciding about a stranger's pack still never runs a
678
+ // stranger's code.
679
+ //
680
+ // Skipped entirely when a selection was passed (`--policy`, `--category`,
681
+ // `--all`) or when there is no terminal: a script asked a precise question and
682
+ // must get a precise answer, not a prompt it cannot see.
683
+ const io = { stdin: process.stdin as TTYIn, stdout: process.stdout as TTYOut };
684
+ const chose = !selection.only && !selection.categories && !selection.all;
685
+ if (chose && io.stdin.isTTY && io.stdout.isTTY) {
686
+ const picked = await pickFromSource(resolvedSource!, io);
687
+ if (picked === null) return ok(["Nothing installed."]);
688
+ selection.only = picked;
689
+ // An empty pick is a real answer — install the pack, enable none of it —
690
+ // but `{only: []}` is indistinguishable from "no selection" downstream, so
691
+ // it is carried as an explicit empty list the resolver can see.
692
+ if (picked.length === 0) selection.only = [];
693
+ }
694
+
256
695
  try {
257
- const result = await addPack(source, selection);
696
+ const result = await addPack(resolvedSource!, selection);
258
697
  const lines = [
259
698
  ...(result.replaced?.length
260
699
  ? [`Replaced ${result.replaced.join(", ")} — same policies under a new name; your selection was kept.`]
@@ -278,11 +717,17 @@ async function add(rest: string[]): Promise<PackCliResult> {
278
717
  if (skipped.length > 0) {
279
718
  lines.push(` not enabled (${skipped.length}): ${summarise(skipped)}`);
280
719
  lines.push("");
281
- lines.push(` see all: failproofai pack list`);
720
+ lines.push(` see all: failproofai policies`);
721
+ // Naming --policy here as well as the coarser two: it is the flag people
722
+ // reach for first ("just give me that one"), and it was only ever
723
+ // suggested on the branch that installed our own pack from disk — so
724
+ // every third-party pack told you about categories and everything, and
725
+ // never about taking a single policy.
726
+ lines.push(` one policy: failproofai policies add ${source} --policy ${skipped[0]}`);
282
727
  if (result.categories.length > 0) {
283
- lines.push(` by category: failproofai pack add ${source} --category ${result.categories.slice(0, 3).join(",")}`);
728
+ lines.push(` by category: failproofai policies add ${source} --category ${result.categories.slice(0, 3).join(",")}`);
284
729
  }
285
- lines.push(` everything: failproofai pack add ${source} --all`);
730
+ lines.push(` everything: failproofai policies add ${source} --all`);
286
731
  }
287
732
  return ok(lines);
288
733
  } catch (err) {
@@ -292,10 +737,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
292
737
 
293
738
  function remove(rest: string[]): PackCliResult {
294
739
  const id = rest[0];
295
- if (!id) return fail(["Usage: failproofai pack remove <publisher/name>"]);
740
+ if (!id) return fail(["Usage: failproofai policies remove <publisher/name>"]);
296
741
  if (!removePack(id)) return fail([`No installed pack with id ${id}`]);
297
742
  return ok([
298
- `Removed ${id}. Its artifact is kept on disk, so re-adding it works offline.`,
743
+ `Removed ${id}. Its policies stop enforcing now; re-add it any time with the same command.`,
299
744
  ]);
300
745
  }
301
746
 
@@ -351,7 +796,7 @@ async function listRemote(source: string): Promise<PackCliResult> {
351
796
  ),
352
797
  preview.resolvedFromLatest ? note(`Newest release: ${preview.source}`, opts) : null,
353
798
  table({ head: ["", "", ""], rows }, opts),
354
- nextStep(`failproofai pack add ${source}`, "Install the defaults with:", opts),
799
+ nextStep(`failproofai policies add ${source}`, "Install the defaults with:", opts),
355
800
  note("Or take part of it: --policy <a,b>, --category <x,y>, --all", opts),
356
801
  ),
357
802
  );
@@ -362,7 +807,7 @@ async function list(): Promise<PackCliResult> {
362
807
  const opts = optsFor(process.stdout);
363
808
  const policyCount = packs.reduce((n, pack) => n + pack.policies.length, 0);
364
809
  const head = title(
365
- "failproofai pack list",
810
+ "failproofai policies",
366
811
  packs.length === 0
367
812
  ? undefined
368
813
  : `${packs.length} pack${packs.length === 1 ? "" : "s"} · ${policyCount} policies`,
@@ -377,7 +822,7 @@ async function list(): Promise<PackCliResult> {
377
822
  {
378
823
  what: "No packs installed.",
379
824
  hint: "Install one with:",
380
- cmd: "failproofai pack add github:owner/repo@tag",
825
+ cmd: "failproofai policies add github:owner/repo@tag",
381
826
  },
382
827
  opts,
383
828
  ),
@@ -448,7 +893,7 @@ async function list(): Promise<PackCliResult> {
448
893
  if (source && taken.length < pack.policies.length && slugs.length > 0) {
449
894
  groups.push(
450
895
  nextStep(
451
- `failproofai pack add ${source} --category ${slugs.slice(0, 3).join(",")}`,
896
+ `failproofai policies add ${source} --category ${slugs.slice(0, 3).join(",")}`,
452
897
  "Take a whole category with:",
453
898
  opts,
454
899
  ),
@@ -183,7 +183,7 @@ export function packFailureReason(guards: MissingGuard[]): string {
183
183
  return (
184
184
  `Blocked because a policy pack this machine is configured to enforce is not running — ${named}. ` +
185
185
  `Enforcement you were told you had is missing, so this is refused rather than allowed silently. ` +
186
- `A human needs to run: failproofai pack list, then failproofai pack add <source> (or failproofai config). ` +
186
+ `A human needs to run: failproofai policies, then failproofai policies add <source> (or failproofai config). ` +
187
187
  `The agent cannot run those itself.`
188
188
  );
189
189
  }
@@ -2,7 +2,7 @@
2
2
  * Reads the installed policy packs a machine has on disk.
3
3
  *
4
4
  * A pack is the delivery unit for policies that did not ship compiled into this
5
- * build: `failproofai pack add` writes the artifact and records it here, and the
5
+ * build: `failproofai policies add` writes the artifact and records it here, and the
6
6
  * loader imports it through the same path custom policies already use.
7
7
  *
8
8
  * ## Why this is not `cloud-managed-policies.ts`
@@ -24,11 +24,19 @@
24
24
  * ## Fail-open, and the condition that makes it sound
25
25
  *
26
26
  * An unreadable manifest, a bad digest or a corrupt artifact yields zero packs
27
- * and a recorded error — it does not deny. That is only defensible **while the
28
- * builtin policies still ship compiled into the package and keep enforcing
29
- * underneath**. The day builtins become a fetched pack, this exact behaviour
30
- * becomes "zero enforcement on a machine reporting healthy", and this comment is
31
- * the reason that must be revisited rather than inherited.
27
+ * and a recorded error — this READER does not deny.
28
+ *
29
+ * That used to be defensible because the builtin policies shipped compiled in
30
+ * and kept enforcing underneath. They no longer do: the builtins ARE a pack now,
31
+ * so a silent zero here would be zero enforcement at exit 0 on a machine that
32
+ * reports healthy — exactly what the comment that stood here warned about.
33
+ *
34
+ * What makes it sound now is that the denying moved rather than disappeared:
35
+ * `pack-failclosed.ts` reads these same `errors` and refuses the events the
36
+ * missing policies declared. So this stays a pure reader that reports what it
37
+ * found, and the layer above decides what a failure means. Do not add a throw
38
+ * here — per-pack isolation is the reason one bad third-party pack cannot
39
+ * switch off every other pack on the machine.
32
40
  */
33
41
  import { createHash } from "node:crypto";
34
42
  import { existsSync, readFileSync } from "node:fs";