failproofai 1.0.2-beta.0 → 1.0.2-beta.1

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 (125) 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 +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  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 +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  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 +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  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.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  51. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  54. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  57. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  61. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +14 -14
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0l4748x._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/_0bwu160._.js +23 -0
  73. package/.next/standalone/.next/server/chunks/ssr/{_12l4a_k._.js → _0ns6k3a._.js} +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  80. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  81. package/.next/standalone/.next/server/pages/404.html +1 -1
  82. package/.next/standalone/.next/server/pages/500.html +1 -1
  83. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  84. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  85. package/.next/standalone/.next/static/chunks/{35xg6retpe6rp.js → 01u1cf8js2yd6.js} +1 -1
  86. package/.next/standalone/.next/static/chunks/{2i2yt3hzjredm.js → 0izgu7nqh2zp-.js} +1 -1
  87. package/.next/standalone/.next/static/chunks/0u4-org_e7zde.js +1 -0
  88. package/.next/standalone/.next/static/chunks/{3y54nw0i7dsjl.js → 1tynnrrh711lr.js} +1 -1
  89. package/.next/standalone/.next/static/chunks/{0sa9v5mhc8861.js → 22rqvlr7p_1mv.js} +1 -1
  90. package/.next/standalone/.next/static/chunks/{2hs7ycvsav7v2.js → 2whxqyskpirbk.js} +1 -1
  91. package/.next/standalone/.next/static/chunks/31mwk8k0q3_3g.js +6 -0
  92. package/.next/standalone/.next/static/chunks/{2gn7ut4svofm2.js → 3ms6gxfm82t6s.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{0eqky-7n7dgdm.js → 3tbbs_d8xrvyp.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/43nlc236oo898.js +1 -0
  95. package/.next/standalone/app/actions/pack-actions.ts +21 -11
  96. package/.next/standalone/app/policies/hooks-client.tsx +3 -4
  97. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -1
  98. package/.next/standalone/package.json +10 -11
  99. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  100. package/.next/standalone/server.js +1 -1
  101. package/bin/failproofai.mjs +455 -316
  102. package/dist/cli.mjs +2296 -1736
  103. package/dist/worker.mjs +3 -12
  104. package/package.json +10 -11
  105. package/src/hooks/configure-wizard.ts +55 -368
  106. package/src/hooks/fp-reset.ts +10 -2
  107. package/src/hooks/handler.ts +2 -2
  108. package/src/hooks/manager.ts +14 -8
  109. package/src/hooks/pack-cli.ts +423 -43
  110. package/src/hooks/pack-failclosed.ts +1 -1
  111. package/src/hooks/pack-manifest.ts +14 -6
  112. package/src/hooks/pack-store.ts +89 -128
  113. package/src/hooks/tui.ts +126 -40
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0zig5sv._.js +0 -3
  115. package/.next/standalone/.next/server/chunks/ssr/_00e9tnf._.js +0 -23
  116. package/.next/standalone/.next/static/chunks/1nuqytbta44af.js +0 -1
  117. package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +0 -6
  118. package/.next/standalone/.next/static/chunks/2aawsjld9ied_.js +0 -1
  119. package/policy-pack/SHA256SUMS +0 -2
  120. package/policy-pack/failproofai-pack.json +0 -732
  121. package/policy-pack/failproofai-pack.mjs +0 -2017
  122. package/src/hooks/policy-presets.ts +0 -137
  123. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → I5kEb9kSgYS63j4iqfVLO}/_buildManifest.js +0 -0
  124. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → I5kEb9kSgYS63j4iqfVLO}/_clientMiddlewareManifest.js +0 -0
  125. /package/.next/standalone/.next/static/{nwbyWT06Ud0XdXOrVJyvS → I5kEb9kSgYS63j4iqfVLO}/_ssgManifest.js +0 -0
@@ -323,7 +323,7 @@ export async function evaluateHookEvent(
323
323
  if (legacyNames.length > 0) {
324
324
  hookLogWarn(
325
325
  `enforcing ${legacyNames.length} policies from this build because no pack is installed — ` +
326
- `run \`failproofai update\` to move them into the pack that ships with it`,
326
+ `run \`failproofai policies add core\` to move them into a real pack`,
327
327
  );
328
328
  }
329
329
  // What actually registered, for the pack-duplicate check below. A paused
@@ -432,7 +432,7 @@ export async function evaluateHookEvent(
432
432
  // A pack policy whose name is an ENABLED BUILTIN is the same guard
433
433
  // twice. The two register under different keys — `failproofai/block-sudo`
434
434
  // and `pack/<id>@<version>/block-sudo` — so nothing deduped them, and
435
- // the bundled pack carries the builtins under exactly these names.
435
+ // the core pack carries the builtins under exactly these names.
436
436
  //
437
437
  // A deny hides it (the first verdict short-circuits) but an INSTRUCT does
438
438
  // not: instructions accumulate and are joined, so the agent received the
@@ -26,7 +26,7 @@ import { CliError } from "../cli-error";
26
26
  import { hookLogWarn } from "./hook-logger";
27
27
  import { customPoliciesDir, globalPolicyConfigFile } from "./fp-home";
28
28
  import { readActiveCloudManagedPolicies } from "./cloud-managed-policies";
29
- import { installBundledPack, setPackPolicyEnabled } from "./pack-store";
29
+ import { CORE_SOURCE, addPack, setPackPolicyEnabled } from "./pack-store";
30
30
  import type { ResolvedPack } from "./pack-manifest";
31
31
  import { hasInstalledPacks, readInstalledPacks } from "./pack-manifest";
32
32
  import {
@@ -480,7 +480,7 @@ async function installHooksImpl(
480
480
  // would leave a freshly set-up machine enforcing nothing at all — which is
481
481
  // the failure mode this whole product exists to prevent.
482
482
  //
483
- // Installed from the copy vendored in the package: no network, so setup
483
+ // Fetched from the pack's GitHub release: there is no copy in this package,
484
484
  // cannot fail behind a proxy, and the machine is guarded the moment it is
485
485
  // configured rather than the moment it next reaches github.com.
486
486
  // The always-on guard is excluded: a pack may not declare `alwaysOn`, so the
@@ -489,7 +489,7 @@ async function installHooksImpl(
489
489
  const alwaysOnNames = new Set(BUILTIN_POLICIES.filter((p) => p.alwaysOn).map((p) => p.name));
490
490
  const fromPack = selectedPolicies.filter((name) => !alwaysOnNames.has(name));
491
491
  if (fromPack.length > 0) {
492
- // ONLY when there is no pack yet. Installing the bundled pack with
492
+ // ONLY when there is no pack yet. Fetching the core pack with
493
493
  // `only: <selection>` REPLACES whatever the machine had chosen, and
494
494
  // `selectedPolicies` is derived from `enabledPolicies` — which is empty on a
495
495
  // machine whose pack came from `pack add core`. `policy add block-rm-rf`
@@ -499,11 +499,17 @@ async function installHooksImpl(
499
499
  // With a pack already installed the names are switched on individually
500
500
  // instead, which is additive and touches nothing else.
501
501
  if (!hasInstalledPacks()) {
502
- const bundled = installBundledPack({ only: fromPack });
503
- if (!bundled.installed) {
502
+ // Fetched, not unpacked from this package: there is no copy in here any
503
+ // more. That makes this the one path in `policies --install` that needs
504
+ // the network, so its failure is reported rather than thrown — the names
505
+ // are already written to config, and the no-pack fallback enforces them
506
+ // until a pack arrives.
507
+ try {
508
+ await addPack(CORE_SOURCE, { only: fromPack });
509
+ } catch (err) {
504
510
  console.log(
505
- `\nWarning: could not install the policy pack (${bundled.reason}).\n` +
506
- `Nothing is enforcing yet — run \`failproofai pack add core\` once that is fixed.`,
511
+ `\nWarning: could not fetch the policy pack (${err instanceof Error ? err.message : String(err)}).\n` +
512
+ `Run \`failproofai policies add core\` once you are online.`,
507
513
  );
508
514
  }
509
515
  } else {
@@ -1034,7 +1040,7 @@ export async function listHooks(cwd?: string): Promise<void> {
1034
1040
  }
1035
1041
 
1036
1042
  // Installed packs. They enforce on this machine exactly like every section
1037
- // above, and until now the only way to see one was `failproofai pack list` —
1043
+ // above, and until now the only way to see one was `failproofai policies` —
1038
1044
  // so the command that answers "what is enforcing here?" answered it with a
1039
1045
  // subset, for the one source a person had to go out of their way to install.
1040
1046
  try {
@@ -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,413 @@ 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
+ }
217
257
 
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}`]);
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
+ }
274
+
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;
532
+ }
533
+
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);
244
566
  }
245
567
 
246
- if (!source) {
247
- return fail(["Usage: failproofai pack add <source> [--only a,b] [--category x,y] [--all]"]);
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
+ async function add(rest: string[]): Promise<PackCliResult> {
605
+ const source = packAddSource(rest);
606
+ const selection = selectionFrom(rest);
607
+
608
+ // Our own pack, by the short name. `core` is a SPELLING of a GitHub source,
609
+ // not a second delivery path: it resolves to CORE_SOURCE and is fetched,
610
+ // verified and pinned exactly like anybody else's.
611
+ //
612
+ // The package used to carry a copy so this worked with no network. It no
613
+ // longer does, on purpose. A pack that ships inside the binary is a policy set
614
+ // we picked for you and wrote to your disk before you asked for it, and it
615
+ // gave our own policies a route no third-party pack could use — which is the
616
+ // opposite of the thing this whole lane exists to make possible.
617
+ const resolvedSource =
618
+ source && CORE_ALIASES.has(source.toLowerCase()) ? CORE_SOURCE : source;
619
+
620
+ if (!resolvedSource) {
621
+ return fail(["Usage: failproofai policies add <source> [--policy a,b] [--category x,y] [--all]"]);
248
622
  }
249
623
  if (selection.only && selection.only.length === 0) {
250
624
  return fail(["--policy needs at least one policy name, comma-separated"]);
@@ -254,7 +628,7 @@ async function add(rest: string[]): Promise<PackCliResult> {
254
628
  }
255
629
 
256
630
  try {
257
- const result = await addPack(source, selection);
631
+ const result = await addPack(resolvedSource!, selection);
258
632
  const lines = [
259
633
  ...(result.replaced?.length
260
634
  ? [`Replaced ${result.replaced.join(", ")} — same policies under a new name; your selection was kept.`]
@@ -278,11 +652,17 @@ async function add(rest: string[]): Promise<PackCliResult> {
278
652
  if (skipped.length > 0) {
279
653
  lines.push(` not enabled (${skipped.length}): ${summarise(skipped)}`);
280
654
  lines.push("");
281
- lines.push(` see all: failproofai pack list`);
655
+ lines.push(` see all: failproofai policies`);
656
+ // Naming --policy here as well as the coarser two: it is the flag people
657
+ // reach for first ("just give me that one"), and it was only ever
658
+ // suggested on the branch that installed our own pack from disk — so
659
+ // every third-party pack told you about categories and everything, and
660
+ // never about taking a single policy.
661
+ lines.push(` one policy: failproofai policies add ${source} --policy ${skipped[0]}`);
282
662
  if (result.categories.length > 0) {
283
- lines.push(` by category: failproofai pack add ${source} --category ${result.categories.slice(0, 3).join(",")}`);
663
+ lines.push(` by category: failproofai policies add ${source} --category ${result.categories.slice(0, 3).join(",")}`);
284
664
  }
285
- lines.push(` everything: failproofai pack add ${source} --all`);
665
+ lines.push(` everything: failproofai policies add ${source} --all`);
286
666
  }
287
667
  return ok(lines);
288
668
  } catch (err) {
@@ -292,10 +672,10 @@ async function add(rest: string[]): Promise<PackCliResult> {
292
672
 
293
673
  function remove(rest: string[]): PackCliResult {
294
674
  const id = rest[0];
295
- if (!id) return fail(["Usage: failproofai pack remove <publisher/name>"]);
675
+ if (!id) return fail(["Usage: failproofai policies remove <publisher/name>"]);
296
676
  if (!removePack(id)) return fail([`No installed pack with id ${id}`]);
297
677
  return ok([
298
- `Removed ${id}. Its artifact is kept on disk, so re-adding it works offline.`,
678
+ `Removed ${id}. Its policies stop enforcing now; re-add it any time with the same command.`,
299
679
  ]);
300
680
  }
301
681
 
@@ -351,7 +731,7 @@ async function listRemote(source: string): Promise<PackCliResult> {
351
731
  ),
352
732
  preview.resolvedFromLatest ? note(`Newest release: ${preview.source}`, opts) : null,
353
733
  table({ head: ["", "", ""], rows }, opts),
354
- nextStep(`failproofai pack add ${source}`, "Install the defaults with:", opts),
734
+ nextStep(`failproofai policies add ${source}`, "Install the defaults with:", opts),
355
735
  note("Or take part of it: --policy <a,b>, --category <x,y>, --all", opts),
356
736
  ),
357
737
  );
@@ -362,7 +742,7 @@ async function list(): Promise<PackCliResult> {
362
742
  const opts = optsFor(process.stdout);
363
743
  const policyCount = packs.reduce((n, pack) => n + pack.policies.length, 0);
364
744
  const head = title(
365
- "failproofai pack list",
745
+ "failproofai policies",
366
746
  packs.length === 0
367
747
  ? undefined
368
748
  : `${packs.length} pack${packs.length === 1 ? "" : "s"} · ${policyCount} policies`,
@@ -377,7 +757,7 @@ async function list(): Promise<PackCliResult> {
377
757
  {
378
758
  what: "No packs installed.",
379
759
  hint: "Install one with:",
380
- cmd: "failproofai pack add github:owner/repo@tag",
760
+ cmd: "failproofai policies add github:owner/repo@tag",
381
761
  },
382
762
  opts,
383
763
  ),
@@ -448,7 +828,7 @@ async function list(): Promise<PackCliResult> {
448
828
  if (source && taken.length < pack.policies.length && slugs.length > 0) {
449
829
  groups.push(
450
830
  nextStep(
451
- `failproofai pack add ${source} --category ${slugs.slice(0, 3).join(",")}`,
831
+ `failproofai policies add ${source} --category ${slugs.slice(0, 3).join(",")}`,
452
832
  "Take a whole category with:",
453
833
  opts,
454
834
  ),
@@ -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
  }