failproofai 1.0.2-beta.7 → 1.0.2-beta.9

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 (176) 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 +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  52. package/.next/standalone/.next/server/app/policies/page.js +6 -5
  53. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  68. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  69. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  70. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[externals]__0-9ti7z._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0sg9-3p._.js} +1 -1
  74. package/.next/standalone/.next/server/chunks/[externals]__0u3wrvq._.js +3 -0
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zlqo2t._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1anxb5k._.js +3 -0
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
  82. package/.next/standalone/.next/server/chunks/_09dz7xv._.js +27 -0
  83. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/{_19uwam-._.js → _1-akg6w._.js} +2 -2
  87. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -7
  89. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +10 -0
  94. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n_lxhg._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  104. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1jjjg6g._.js → [root-of-the-server]__1_7n3xe._.js} +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _0b6xubz._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → _1es2j7i._.js} +7 -21
  109. package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _1pj-r7q._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +17 -0
  113. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  116. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +10 -0
  118. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +3 -0
  120. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  121. package/.next/standalone/.next/server/middleware.js +1 -1
  122. package/.next/standalone/.next/server/pages/404.html +1 -1
  123. package/.next/standalone/.next/server/pages/500.html +1 -1
  124. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  125. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  126. package/.next/standalone/.next/static/chunks/{3swqstog1cd9u.js → 06zoac_7ffkus.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 0bshgqm5waivd.js} +2 -2
  128. package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 13grq7pifzvu5.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 16pmxmg9x48ob.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 1da7-a7ek66uy.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 1fg7drro5kle-.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 1opyu56l4cdfk.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 1p-o1i0lyt9un.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 2mfojtsy214sf.js} +1 -1
  135. package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 32jj4qy40hln_.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 3ww5ik9eo_1b_.js} +1 -1
  137. package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
  138. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-1gehh2qpjyj5_.js} +1 -1
  139. package/.next/standalone/app/actions/get-hooks-config.ts +8 -1
  140. package/.next/standalone/app/actions/pack-actions.ts +12 -1
  141. package/.next/standalone/app/policies/hooks-client.tsx +41 -4
  142. package/.next/standalone/fp-cloud-cli/tests/test_pagination.py +204 -0
  143. package/.next/standalone/package.json +9 -9
  144. package/.next/standalone/server.js +1 -1
  145. package/bin/failproofai.mjs +57 -13
  146. package/dist/cli.mjs +23076 -20000
  147. package/dist/worker.mjs +1252 -542
  148. package/package.json +9 -9
  149. package/scripts/build-policy-pack.mjs +28 -4
  150. package/src/audit/index.ts +24 -4
  151. package/src/hooks/builtin-policies.ts +933 -8
  152. package/src/hooks/custom-hooks-loader.ts +98 -8
  153. package/src/hooks/handler.ts +26 -14
  154. package/src/hooks/manager.ts +36 -2
  155. package/src/hooks/pack-cli.ts +1224 -121
  156. package/src/hooks/pack-failclosed.ts +100 -4
  157. package/src/hooks/pack-manifest.ts +63 -1
  158. package/src/hooks/pack-param-key.ts +36 -0
  159. package/src/hooks/pack-store.ts +309 -25
  160. package/src/hooks/policy-catalog.ts +9 -1
  161. package/src/hooks/policy-evaluator.ts +77 -1
  162. package/src/hooks/tui.ts +21 -1
  163. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +0 -3
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  165. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +0 -3
  167. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  168. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  169. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tg4pu6._.js +0 -3
  170. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  171. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  172. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +0 -10
  173. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1opxw-7._.js +0 -5
  174. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_buildManifest.js +0 -0
  175. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_clientMiddlewareManifest.js +0 -0
  176. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_ssgManifest.js +0 -0
@@ -17,11 +17,16 @@
17
17
  *
18
18
  * Asset URLs are CONSTRUCTED from an owner, repo and concrete tag. When the
19
19
  * user omits a tag, one `releases/latest` redirect resolves it before any asset
20
- * is fetched or written. There is no API lookup or rate-limit dependency, and
21
- * the installed record always names the pinned result — which has to AGREE with
22
- * the version that pack's manifest declares, or nothing is installed at all
20
+ * is fetched or written. There is no index to poison, and the installed record
21
+ * always names the pinned result — which has to AGREE with the version that
22
+ * pack's manifest declares, or nothing is installed at all
23
23
  * (`packTagMatchesVersion`).
24
24
  *
25
+ * `resolveTagForCommit` is the ONE exception, and it is opt-in: typing
26
+ * `owner/repo@a1b2c3d` asks a question no URL can answer, so that path — and no
27
+ * other — reads the releases API. It chooses WHICH release; every verification
28
+ * below runs on the result exactly as it would on a typed tag.
29
+ *
25
30
  * ## Never on the hook path
26
31
  *
27
32
  * Everything here touches the network. A hook must never wait on it. This module
@@ -30,7 +35,7 @@
30
35
  import { createHash } from "node:crypto";
31
36
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
32
37
  import { dirname, resolve } from "node:path";
33
- import { packsRoot, parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
38
+ import { PACK_COMMIT_RE, packsRoot, parsePackIdentity, parsePackPolicy, readInstalledPacks } from "./pack-manifest";
34
39
  import type { ResolvedPack } from "./pack-manifest";
35
40
  import type { InstalledPackRecord } from "./pack-manifest";
36
41
  import type { PolicyCatalogEntry } from "./policy-types";
@@ -210,6 +215,132 @@ export async function resolveLatestTag(spec: PackSpec): Promise<string> {
210
215
  return tag;
211
216
  }
212
217
 
218
+ /**
219
+ * The GitHub API, for the ONE lookup on this whole path that needs one.
220
+ *
221
+ * Read per call rather than captured at import, the way `baseUrl()` above is:
222
+ * the value has to be settable by a test that points it at a local server, and
223
+ * a module-level const would freeze whatever the environment happened to hold
224
+ * when this file was first imported.
225
+ */
226
+ function githubApiBase(): string {
227
+ return (process.env.FAILPROOFAI_GITHUB_API ?? "https://api.github.com").replace(/\/+$/, "");
228
+ }
229
+
230
+ /**
231
+ * The release tag published from a given git commit, or null when no release
232
+ * claims it.
233
+ *
234
+ * ## Why this is the only API call in the install path
235
+ *
236
+ * Everything else here CONSTRUCTS its URLs from owner, repo and tag and
237
+ * discovers nothing — that is what leaves no index to poison, and it must stay
238
+ * true for everybody who did not opt in. A commit is the one spelling that
239
+ * cannot be turned into a URL, because the mapping from commit to tag exists
240
+ * only on the server. So this runs when, and only when, the user typed a hex
241
+ * string after the `@` themselves.
242
+ *
243
+ * ## What the answer is allowed to decide
244
+ *
245
+ * WHICH release, and nothing else. The release body is publisher-controlled
246
+ * text on somebody else's repository and can say anything, so it selects a tag
247
+ * and then every downstream check — SHA256SUMS, the digest pin recorded in
248
+ * `installed.json`, `packTagMatchesVersion` — runs exactly as it would for a
249
+ * typed tag. A body that lies picks the wrong release; it cannot make an
250
+ * unverified artifact install.
251
+ *
252
+ * ## Ambiguity is a refusal, not a guess
253
+ *
254
+ * A 7-character prefix colliding across two releases is precisely why git
255
+ * itself refuses to resolve one, and picking either would install code the user
256
+ * did not ask for while reporting success. The matching tags and their full
257
+ * commits go in the message so the next command can be typed from it.
258
+ */
259
+ export async function resolveTagForCommit(spec: PackSpec, commit: string): Promise<string | null> {
260
+ const prefix = commit.toLowerCase();
261
+ const url = `${githubApiBase()}/repos/${spec.owner}/${spec.repo}/releases?per_page=100`;
262
+ let payload: unknown;
263
+ try {
264
+ // No credential is required — a pack has to be public to install at all,
265
+ // since installs are anonymous HTTPS. A token is taken when one happens to
266
+ // be in the environment purely for the rate limit (60/hour → 5000).
267
+ const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "").trim();
268
+ const response = await fetch(url, {
269
+ headers: {
270
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
271
+ Accept: "application/vnd.github+json",
272
+ "X-GitHub-Api-Version": "2022-11-28",
273
+ "User-Agent": "failproofai",
274
+ },
275
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
276
+ redirect: "follow",
277
+ });
278
+ if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
279
+ payload = await response.json();
280
+ } catch (err) {
281
+ // Deliberately NOT a fall-through to "treat it as a literal tag". A lookup
282
+ // that could not run has established nothing — the fall-through would then
283
+ // 404 on an asset URL built from a commit, and report a missing SHA256SUMS
284
+ // for a release nobody ever claimed existed. Name the thing that failed.
285
+ throw new Error(
286
+ `could not resolve commit ${commit} in ${spec.owner}/${spec.repo}: ${errText(err)}. ` +
287
+ `Name the release tag instead if you know it.`,
288
+ );
289
+ }
290
+
291
+ // Imported here rather than at the top of the file: `pack-cli` imports this
292
+ // module, so a static import would close a cycle for the sake of one format
293
+ // reader. The reader has to be the SAME one `publish` writes with, or the two
294
+ // drift silently and a listing and an install disagree about the same body.
295
+ const { parseReleaseBody } = await import("./pack-cli");
296
+ const releases = Array.isArray(payload)
297
+ ? (payload as Array<{ tag_name?: unknown; body?: unknown }>)
298
+ : [];
299
+ const matches: Array<{ tag: string; commit: string }> = [];
300
+ for (const release of releases) {
301
+ const tag = typeof release.tag_name === "string" ? release.tag_name : "";
302
+ // A tag from the API becomes part of an asset URL, so it passes the same
303
+ // check a typed one does rather than being trusted for having come back
304
+ // over HTTPS.
305
+ if (!tag || !TAG_RE.test(tag)) continue;
306
+ const facts = parseReleaseBody(typeof release.body === "string" ? release.body : "");
307
+ // The typed string is a PREFIX of the recorded commit, which is the
308
+ // direction git works in and therefore the one a person copying out of
309
+ // `git log` will have.
310
+ if (facts.commit && facts.commit.startsWith(prefix)) matches.push({ tag, commit: facts.commit });
311
+ }
312
+
313
+ if (matches.length > 1) {
314
+ throw new Error(
315
+ `commit ${commit} matches ${matches.length} releases of ${spec.owner}/${spec.repo}: ` +
316
+ matches.map((m) => `${m.tag} (${m.commit})`).join(", ") +
317
+ `. Name one of those tags, or type more of the commit.`,
318
+ );
319
+ }
320
+ return matches.length === 1 ? matches[0].tag : null;
321
+ }
322
+
323
+ /**
324
+ * The concrete tag to install, from whatever the user typed after the `@`.
325
+ *
326
+ * Three shapes, in the order they are tried:
327
+ *
328
+ * - nothing — resolved from `releases/latest`, as it always was;
329
+ * - a hex string — looked up as a COMMIT first, because typing one is how a
330
+ * person asks for that; and
331
+ * - anything else — taken literally.
332
+ *
333
+ * A hex string that matches no release falls back to being a literal tag. A
334
+ * repository may genuinely have a tag named `abc1234`, and refusing to install
335
+ * a tag that exists because it also looks like a commit would be a regression
336
+ * for somebody who never asked for commit resolution at all.
337
+ */
338
+ export async function resolveSpecTag(parsed: PackSpec): Promise<string> {
339
+ if (parsed.tag === null) return resolveLatestTag(parsed);
340
+ if (!PACK_COMMIT_RE.test(parsed.tag)) return parsed.tag;
341
+ return (await resolveTagForCommit(parsed, parsed.tag)) ?? parsed.tag;
342
+ }
343
+
213
344
  /**
214
345
  * The canonical spelling recorded in `installed.json`.
215
346
  *
@@ -324,6 +455,8 @@ interface FetchedPack {
324
455
  version: string;
325
456
  policies: PolicyCatalogEntry[];
326
457
  effect?: PolicyEffect;
458
+ /** The git commit the publisher built this from, when they had one. */
459
+ commit?: string;
327
460
  artifact: Buffer;
328
461
  artifactDigest: string;
329
462
  }
@@ -350,6 +483,18 @@ export interface PackAddOptions {
350
483
  only?: string[];
351
484
  categories?: string[];
352
485
  all?: boolean;
486
+ /**
487
+ * Add to what is already on, rather than replacing it.
488
+ *
489
+ * Set by the CLI FLAGS (`--policy`, `--category`) and deliberately NOT by the
490
+ * interactive picker: the picker's list is the complete answer — unticking
491
+ * everything means enable none — while a flag on an already-installed pack
492
+ * means "also turn these on". Without the distinction, following the pack
493
+ * README's own path (take the defaults, then add a category) silently
494
+ * switched the defaults off. No effect on a first install, where there is
495
+ * nothing to merge with.
496
+ */
497
+ merge?: boolean;
353
498
  /** Agent CLIs this pack should guard. Omitted means all of them. */
354
499
  clis?: string[];
355
500
  }
@@ -395,6 +540,8 @@ export interface PackPreview {
395
540
  id: string;
396
541
  version: string;
397
542
  effect: PolicyEffect;
543
+ /** The git commit the publisher built this from, when they had one. */
544
+ commit?: string;
398
545
  policies: PolicyCatalogEntry[];
399
546
  /** The exact source the preview was read from, tag resolved and pinned. */
400
547
  source: string;
@@ -421,10 +568,7 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
421
568
  }
422
569
  const parsed = parsePackSpec(source);
423
570
  const resolvedFromLatest = parsed.tag === null;
424
- const spec: PinnedPackSpec =
425
- parsed.tag !== null
426
- ? { ...parsed, tag: parsed.tag }
427
- : { ...parsed, tag: await resolveLatestTag(parsed) };
571
+ const spec: PinnedPackSpec = { ...parsed, tag: await resolveSpecTag(parsed) };
428
572
 
429
573
  const checksums = (await fetchBytes(packAssetUrl(spec, PACK_CHECKSUMS_ASSET))).toString("utf8");
430
574
  const manifestBytes = await fetchBytes(packAssetUrl(spec, PACK_MANIFEST_ASSET));
@@ -443,7 +587,9 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
443
587
  throw new Error(`${PACK_MANIFEST_ASSET} is not valid JSON: ${errText(err)}`);
444
588
  }
445
589
  if (!raw || typeof raw !== "object") throw new Error(`${PACK_MANIFEST_ASSET} is not an object`);
446
- const value = raw as { id?: unknown; version?: unknown; policies?: unknown; effect?: unknown };
590
+ const value = raw as {
591
+ id?: unknown; version?: unknown; policies?: unknown; effect?: unknown; commit?: unknown;
592
+ };
447
593
  const identity = parsePackIdentity(value);
448
594
  if (!Array.isArray(value.policies) || value.policies.length === 0) {
449
595
  throw new Error("pack manifest declares no policies");
@@ -452,6 +598,7 @@ export async function fetchPackPreview(source: string): Promise<PackPreview> {
452
598
  id: identity.id,
453
599
  version: identity.version,
454
600
  effect: identity.effect,
601
+ ...(identity.commit ? { commit: identity.commit } : {}),
455
602
  policies: value.policies.map((policy, i) => parsePackPolicy(identity.id, policy, i)),
456
603
  source: formatPackSpec(spec),
457
604
  resolvedFromLatest,
@@ -498,7 +645,9 @@ async function fetchPack(spec: PinnedPackSpec): Promise<FetchedPack> {
498
645
  throw new Error(`${PACK_MANIFEST_ASSET} is not valid JSON: ${errText(err)}`);
499
646
  }
500
647
  if (!parsed || typeof parsed !== "object") throw new Error(`${PACK_MANIFEST_ASSET} is not an object`);
501
- const raw = parsed as { id?: unknown; version?: unknown; policies?: unknown; effect?: unknown };
648
+ const raw = parsed as {
649
+ id?: unknown; version?: unknown; policies?: unknown; effect?: unknown; commit?: unknown;
650
+ };
502
651
  const identity = parsePackIdentity(raw);
503
652
  if (!Array.isArray(raw.policies) || raw.policies.length === 0) {
504
653
  throw new Error("pack manifest declares no policies");
@@ -513,6 +662,7 @@ async function fetchPack(spec: PinnedPackSpec): Promise<FetchedPack> {
513
662
  version: identity.version,
514
663
  policies,
515
664
  ...(raw.effect !== undefined ? { effect: identity.effect } : {}),
665
+ ...(identity.commit ? { commit: identity.commit } : {}),
516
666
  artifact,
517
667
  artifactDigest,
518
668
  };
@@ -542,7 +692,9 @@ export function slugifyCategory(category: string): string {
542
692
  */
543
693
  function resolveSelection(
544
694
  policies: PolicyCatalogEntry[],
545
- opts: { only?: string[]; categories?: string[]; all?: boolean } | undefined,
695
+ opts:
696
+ | { only?: string[]; categories?: string[]; all?: boolean; merge?: boolean }
697
+ | undefined,
546
698
  previous: string[] | null | undefined,
547
699
  previouslyInstalled: boolean,
548
700
  ): { enabled: string[] | null; reason: SelectionReason } {
@@ -578,8 +730,29 @@ function resolveSelection(
578
730
  // opposite of what was chosen, reported as "the pack's defaults". Presence of
579
731
  // the key is the signal, not its length.
580
732
  if (opts?.only !== undefined || opts?.categories?.length) {
733
+ // `merge` separates the two callers that both arrive here with a list.
734
+ //
735
+ // The PICKER's list is the complete answer: what is ticked is what should
736
+ // be on, and unticking everything means enable none — which is why an
737
+ // expressed selection replaces at all, and must keep replacing.
738
+ //
739
+ // A FLAG is an addition. `policies add <pack> --category Git` on a pack
740
+ // that is already installed means "also turn Git on", and replacing there
741
+ // switched off everything the user had. Following the pack README's own
742
+ // path — take the defaults, then add a category — left them with SIX
743
+ // policies on where they had started with ten, silently, from a command
744
+ // whose first word is `add`.
745
+ //
581
746
  // Kept in the pack's declared order, which is the order everything else
582
747
  // presents them in.
748
+ if (opts.merge && previouslyInstalled) {
749
+ // Already the whole catalog. Adding to everything is everything, and
750
+ // `null` is how "the whole pack" is recorded — materialising it into a
751
+ // list here would freeze out any policy a later version adds.
752
+ if (previous === null) return { enabled: null, reason: "carried" };
753
+ for (const name of previous ?? []) picked.add(name);
754
+ return { enabled: available.filter((n) => picked.has(n)), reason: "added" };
755
+ }
583
756
  return { enabled: available.filter((n) => picked.has(n)), reason: "selected" };
584
757
  }
585
758
 
@@ -592,7 +765,7 @@ function resolveSelection(
592
765
  return { enabled: policies.filter((p) => p.defaultEnabled).map((p) => p.name), reason: "defaults" };
593
766
  }
594
767
 
595
- export type SelectionReason = "all" | "selected" | "carried" | "defaults";
768
+ export type SelectionReason = "all" | "selected" | "carried" | "defaults" | "added";
596
769
 
597
770
  /**
598
771
  * Import the artifact and check it registers what the manifest promised.
@@ -669,9 +842,7 @@ export async function addPack(
669
842
  // Resolve BEFORE anything is written, and pin the concrete result. A tagless
670
843
  // source is a convenience for the person typing; what the machine records must
671
844
  // always name one release.
672
- const spec: PinnedPackSpec = parsed.tag
673
- ? { ...parsed, tag: parsed.tag }
674
- : { ...parsed, tag: await resolveLatestTag(parsed) };
845
+ const spec: PinnedPackSpec = { ...parsed, tag: await resolveSpecTag(parsed) };
675
846
  const resolvedFromLatest = parsed.tag === null;
676
847
  const fetched = await fetchPack(spec);
677
848
  // Checked here, before a byte is written and before the artifact is imported,
@@ -735,8 +906,15 @@ export async function addPack(
735
906
  const at = source.lastIndexOf("@");
736
907
  return at > source.indexOf(":") ? source.slice(0, at) : source;
737
908
  };
909
+ // Only when it is the SAME id. This binds an id to the origin it first came
910
+ // from, and `priorRecordFor` also answers with a same-DIGEST record under a
911
+ // different id — for which the check is not just unnecessary but wrong: a
912
+ // second pack that happens to ship identical bytes from another repository is
913
+ // not the installed one being hijacked, and refusing it said `pack id X is
914
+ // already installed from Y` while naming an id the user never asked for.
738
915
  if (
739
916
  prior &&
917
+ prior.id === fetched.id &&
740
918
  !prior.source.startsWith("bundled:") &&
741
919
  repoOf(prior.source) !== repoOf(formatPackSpec(spec))
742
920
  ) {
@@ -753,16 +931,42 @@ export async function addPack(
753
931
  entry: artifactRel,
754
932
  sha256: fetched.artifactDigest,
755
933
  policies: fetched.policies,
934
+ // Provenance, carried through from the manifest so `policies` and the
935
+ // dashboard can say which source produced what is installed. NOT part of
936
+ // verification — `sha256` above is the pin, and this is a label beside it.
937
+ ...(fetched.commit ? { commit: fetched.commit } : {}),
756
938
  ...(fetched.effect ? { effect: fetched.effect } : {}),
757
939
  ...(enabled ? { enabled } : {}),
758
940
  // Omitted, not written as the full list, when the user did not narrow it.
759
941
  // An absent `clis` means "every agent", and it keeps meaning that when a
760
942
  // thirteenth CLI is supported — whereas a materialised list of twelve would
761
943
  // silently exclude the new one from every pack installed before it existed.
762
- ...(opts?.clis ? { clis: opts.clis } : {}),
944
+ //
945
+ // A prior narrowing is CARRIED when this caller named none, for the same
946
+ // reason `enabled` is: `upsertInstalled` replaces the row wholesale, so
947
+ // writing nothing here threw the user's scope away. A pack scoped to Claude
948
+ // silently started guarding all eleven agents on the next `policies add` —
949
+ // the same shape as the bug `merge` fixes, in the widening direction, which
950
+ // is the worse one: it over-enforces, on agents nobody chose.
951
+ //
952
+ // `merge === false` is the ONE caller this must not do it for. The picker
953
+ // says "every agent" by leaving `clis` absent (pack-cli.ts, where a full
954
+ // tick deliberately writes nothing) — so for it, absent is an ANSWER, and
955
+ // carrying a prior narrowing would make widening back to all impossible.
956
+ // Everywhere else absent means no opinion was expressed.
957
+ ...(opts?.clis
958
+ ? { clis: opts.clis }
959
+ : opts?.merge !== false && prior?.clis
960
+ ? { clis: prior.clis }
961
+ : {}),
763
962
  };
764
963
 
765
- const absorbed = upsertInstalled(record);
964
+ // `--all` is an ANSWER, not a silence. `upsertInstalled` carries an absorbed
965
+ // row's narrower selection onto a record that names none — which is right for
966
+ // a rename nobody expressed an opinion about, and wrong for a caller who just
967
+ // said "everything". Left ungated, `policies add <renamed pack> --all`
968
+ // reported the whole catalog and wrote the old id's one-policy list.
969
+ const absorbed = upsertInstalled(record, reason !== "all");
766
970
  return {
767
971
  replaced: absorbed,
768
972
  id: fetched.id,
@@ -816,23 +1020,93 @@ export function setPackPolicyEnabled(
816
1020
  return { ok: true };
817
1021
  }
818
1022
 
819
- export function removePack(id: string): boolean {
1023
+ /**
1024
+ * The spellings of a pack id that `remove` accepts.
1025
+ *
1026
+ * Exactly one used to work — the stored id, byte for byte — and it was a form
1027
+ * shown nowhere on its own, so every spelling a user could SEE or had TYPED
1028
+ * failed:
1029
+ *
1030
+ * add failproofai/policies stores `FailproofAI/policies`
1031
+ * remove failproofai/policies no installed pack with id …
1032
+ * remove FailproofAI/policies@06b802b63f4f no installed pack with id …
1033
+ *
1034
+ * The first is what they installed it with — GitHub is case-insensitive, so
1035
+ * `add` accepts any case and then records the CANONICAL id off the manifest.
1036
+ * The second is the listing's own heading, copied. Both are refused, and the
1037
+ * one that works appears only as half of that heading. A pack whose owner
1038
+ * happens to be lowercase (`chhhee10/deploy-guard`) removes on the first try,
1039
+ * which is what makes this look like the core pack being unremovable rather
1040
+ * than a name-matching bug.
1041
+ *
1042
+ * So: case-insensitive, and an `@<version>` suffix is dropped. A pack id cannot
1043
+ * contain `@` — `PACK_ID_RE` forbids it — so splitting at the last one is
1044
+ * unambiguous. The version is not checked against what is installed: there is
1045
+ * only ever one row per id, and refusing `pack@1.0.0` because 1.0.1 is
1046
+ * installed would be the same unhelpfulness in a new hat.
1047
+ */
1048
+ function packIdMatches(stored: string, typed: string): boolean {
1049
+ return bareId(stored).toLowerCase() === bareId(typed).toLowerCase();
1050
+ }
1051
+
1052
+ /** The id with any `@<version>` suffix and surrounding space removed, CASE
1053
+ * INTACT — the exact-match pass needs the original spelling. */
1054
+ function bareId(value: string): string {
1055
+ const trimmed = value.trim();
1056
+ const at = trimmed.lastIndexOf("@");
1057
+ return (at > 0 ? trimmed.slice(0, at) : trimmed).trim();
1058
+ }
1059
+
1060
+ /** Two installed packs answer to one loosely-typed name. Thrown rather than
1061
+ * guessed at: the caller prints the canonical ids so the user can say which. */
1062
+ export class AmbiguousPackId extends Error {
1063
+ constructor(readonly candidates: string[], typed: string) {
1064
+ super(
1065
+ `${typed} matches ${candidates.length} installed packs: ${candidates.join(", ")}. ` +
1066
+ `Name one of them exactly.`,
1067
+ );
1068
+ this.name = "AmbiguousPackId";
1069
+ }
1070
+ }
1071
+
1072
+ /** The id actually removed — the CANONICAL one, not the spelling that was
1073
+ * typed — or null when nothing matched. Callers report it back so a user who
1074
+ * typed a different case learns the name the machine holds. */
1075
+ export function removePack(id: string): string | null {
820
1076
  const manifestPath = resolve(packsRoot(), "installed.json");
821
- if (!existsSync(manifestPath)) return false;
1077
+ if (!existsSync(manifestPath)) return null;
822
1078
  let raw: { schemaVersion?: number; packs?: InstalledPackRecord[] };
823
1079
  try {
824
1080
  raw = JSON.parse(readFileSync(manifestPath, "utf8"));
825
1081
  } catch {
826
- return false;
1082
+ return null;
827
1083
  }
828
1084
  const packs = Array.isArray(raw.packs) ? raw.packs : [];
829
- const remaining = packs.filter((p) => p?.id !== id);
830
- if (remaining.length === packs.length) return false;
1085
+ // EXACT first, and a loose match only when it is the only one.
1086
+ //
1087
+ // Ids are stored and upserted with `===`, so `Acme/guard` and `acme/guard`
1088
+ // are two rows on one machine — and a `find()` over the loose matcher then
1089
+ // removed whichever happened to sit first, which is not the pack that was
1090
+ // named. A convenience for typing a name is not worth uninstalling something
1091
+ // else, so an ambiguous name is REFUSED and the candidates are printed:
1092
+ // asking again with the exact id is a keystroke, and the alternative is
1093
+ // silently deactivating a pack the user still wanted enforcing.
1094
+ const exact = packs.find((p) => p?.id === bareId(id));
1095
+ const loose = packs.filter((p) => p?.id && packIdMatches(p.id, id));
1096
+ if (!exact && loose.length > 1) {
1097
+ throw new AmbiguousPackId(
1098
+ loose.map((p) => p.id).filter((v): v is string => typeof v === "string"),
1099
+ id,
1100
+ );
1101
+ }
1102
+ const removed = exact ?? loose[0];
1103
+ const remaining = packs.filter((p) => p !== removed);
1104
+ if (!removed || remaining.length === packs.length) return null;
831
1105
  // The artifact is left on disk deliberately: it is content-addressed and inert
832
1106
  // once nothing points at it, so keeping it makes a re-add offline-safe. Same
833
1107
  // choice the cloud reconciler makes.
834
1108
  writeAtomic(manifestPath, JSON.stringify({ schemaVersion: 1, packs: remaining }, null, 2) + "\n");
835
- return true;
1109
+ return removed.id;
836
1110
  }
837
1111
 
838
1112
  /**
@@ -855,7 +1129,17 @@ export function removePack(id: string): boolean {
855
1129
  * say so — an earlier version deleted the old row silently, taking the user's
856
1130
  * selection and the enforcement it gave them with it.
857
1131
  */
858
- function upsertInstalled(record: InstalledPackRecord): string[] {
1132
+ function upsertInstalled(
1133
+ record: InstalledPackRecord,
1134
+ /**
1135
+ * False when the record's absent `enabled` is an EXPRESSED "everything"
1136
+ * (`--all`) rather than an unexpressed one. Carrying there overrides the
1137
+ * user's own answer with a set they left behind under the old id, and the
1138
+ * returned `AddedPack` still reports the whole catalog — installed says
1139
+ * three, disk enforces one.
1140
+ */
1141
+ carryAbsorbedSelection = true,
1142
+ ): string[] {
859
1143
  const manifestPath = resolve(packsRoot(), "installed.json");
860
1144
  let packs: InstalledPackRecord[] = [];
861
1145
  if (existsSync(manifestPath)) {
@@ -876,7 +1160,7 @@ function upsertInstalled(record: InstalledPackRecord): string[] {
876
1160
  .map((p) => p.id);
877
1161
  // The selection the user had under the old id is theirs — carry it, rather
878
1162
  // than resetting them to defaults because a publisher renamed something.
879
- if (!record.enabled) {
1163
+ if (carryAbsorbedSelection && !record.enabled) {
880
1164
  const prior = packs.find((p) => absorbed.includes(p?.id));
881
1165
  const carried = prior?.enabled?.filter((n) =>
882
1166
  (record.policies as Array<{ name?: unknown }> | undefined)?.some((rp) => rp?.name === n),
@@ -169,7 +169,15 @@ export const POLICY_CATALOG: PolicyCatalogEntry[] = [
169
169
  // PermissionRequest is carried over from the merged-in `block-self-pause`.
170
170
  // It is a real enforcement point on Copilot and Devin, and dropping it would
171
171
  // have left this guard blind on both.
172
- match: { events: ["PreToolUse", "PermissionRequest"], toolNames: ["Bash"] },
172
+ // Write/Edit are here because the Bash-only surface left the shortest way
173
+ // through this guard unguarded: the agent never needs a shell to overwrite
174
+ // `~/.failproofai/policies-config.json` with `{"enabledPolicies":[]}` — it
175
+ // just writes the file. Every CLI canonicalizes its own file tool's path
176
+ // key to `file_path`, so one branch covers all of them.
177
+ match: {
178
+ events: ["PreToolUse", "PermissionRequest"],
179
+ toolNames: ["Bash", "Write", "Edit", "NotebookEdit"],
180
+ },
173
181
  defaultEnabled: true,
174
182
  alwaysOn: true,
175
183
  category: "Dangerous Commands",
@@ -4,6 +4,16 @@
4
4
  */
5
5
  import type { HookEventType, SessionMetadata } from "./types";
6
6
  import type { PolicyContext, HooksConfig } from "./policy-types";
7
+ // Read from the layer that owns it rather than restated here — restating a
8
+ // pack id is exactly how `failproofai/builtins` and `failproofai/core` both
9
+ // outlived their rename. This is on the hook path, so the cost was measured
10
+ // rather than assumed: bundling policy-evaluator with this import is 38,627
11
+ // bytes against 37,491 without it, because only the constant survives
12
+ // tree-shaking. 1.1 KB is not worth a second copy of a name that has already
13
+ // drifted twice.
14
+ import { CORE_SOURCE } from "./pack-store";
15
+ import { packPolicyParamKey, parsePackPolicyName } from "./pack-param-key";
16
+ export { packPolicyParamKey } from "./pack-param-key";
7
17
  import { DEFAULT_POLICY_NAMESPACE, getPoliciesForEvent } from "./policy-registry";
8
18
  import { hookLogInfo, hookLogWarn } from "./hook-logger";
9
19
  import { trackHookEvent } from "./hook-telemetry";
@@ -28,6 +38,61 @@ export interface EvaluationResult {
28
38
  }
29
39
 
30
40
 
41
+ /**
42
+ * The config key a PACK policy's parameters are stored under.
43
+ *
44
+ * Version-less on purpose. A pack policy registers as
45
+ * `pack/<id>@<version>/<name>`, so a key carrying the version would be orphaned
46
+ * by every republish — the parameters would read as unset the moment the pack
47
+ * was upgraded, which is the same failure `enabled` avoids by outliving the
48
+ * version it was chosen against.
49
+ *
50
+ * Exported because the DASHBOARD writes this key and the evaluator reads it.
51
+ * They disagreed before — the dashboard wrote the bare policy name while the
52
+ * evaluator looked up the fully qualified one and fell back to bare only inside
53
+ * the `failproofai/` namespace — so a parameter saved through the UI was
54
+ * displayed as saved and ignored at runtime, on every pack including our own.
55
+ * One function so the two cannot drift again.
56
+ */
57
+
58
+ /**
59
+ * The parameters a PACK policy actually runs with, resolved out of a config's
60
+ * `policyParams` map: the stable qualified key first, then the BARE name for
61
+ * our own pack alone.
62
+ *
63
+ * The bare read is the migration. Every `policyParams["block-sudo"]` already on
64
+ * disk — written by a build that predates packs, or by the dashboard before it
65
+ * qualified its keys — means the builtin under the name it always had.
66
+ *
67
+ * It is scoped to our pack because a policy name is unique only WITHIN a pack:
68
+ * two installed packs may each declare a `block-sudo`, and a bare key cannot
69
+ * say which was meant. Handing a stranger's pack our pack's configuration runs
70
+ * a third party's code on parameters chosen for ours.
71
+ *
72
+ * Exported because the DASHBOARD's read side resolves the same lookup, and it
73
+ * resolved it differently — an UNSCOPED bare fallback — so a stranger's pack
74
+ * declaring `block-sudo` was SHOWN our saved parameters while this function
75
+ * handed that policy the schema defaults. The config modal seeds its inputs
76
+ * from what was shown and Save writes them back under the stranger's own key,
77
+ * which is the point a display lie turns into configuration that really runs.
78
+ * One function so the two cannot diverge again.
79
+ */
80
+ export function readPackPolicyParams(
81
+ policyParams: Record<string, Record<string, unknown>> | undefined,
82
+ packId: string,
83
+ policyName: string,
84
+ ): Record<string, unknown> | undefined {
85
+ if (!policyParams) return undefined;
86
+ const qualified = policyParams[packPolicyParamKey(packId, policyName)];
87
+ if (qualified) return qualified;
88
+ // Case-insensitively: the id reaches here from a manifest and from a source
89
+ // somebody typed, and GitHub treats `failproofai/policies` and
90
+ // `FailproofAI/policies` as one repository.
91
+ if (packId.toLowerCase() === CORE_SOURCE.toLowerCase()) return policyParams[policyName];
92
+ return undefined;
93
+ }
94
+
95
+
31
96
  /**
32
97
  * Look up policy params for a canonical policy name in the user config,
33
98
  * tolerating either flat ("block-force-push") or qualified
@@ -35,7 +100,9 @@ export interface EvaluationResult {
35
100
  *
36
101
  * The flat-key fallback is intentionally limited to the default namespace
37
102
  * so namespace isolation is preserved: `policyParams.foo` only matches
38
- * `failproofai/foo`, never `myorg/foo` or `custom/foo`.
103
+ * `failproofai/foo`, never `myorg/foo` or `custom/foo`. A `pack/...` name gets
104
+ * the same isolation from `readPackPolicyParams` and returns before reaching
105
+ * that branch, so the two fallbacks never both apply to one name.
39
106
  */
40
107
  function getConfigParamsFor(
41
108
  config: HooksConfig | undefined,
@@ -44,6 +111,15 @@ function getConfigParamsFor(
44
111
  if (!config?.policyParams) return undefined;
45
112
  const canonicalParams = config.policyParams[canonicalName];
46
113
  if (canonicalParams) return canonicalParams;
114
+
115
+ // A pack policy: qualified key, then the bare name for our pack only. Shared
116
+ // with the dashboard's read side rather than restated, so what is displayed
117
+ // stays what runs.
118
+ const packPolicy = parsePackPolicyName(canonicalName);
119
+ if (packPolicy) {
120
+ return readPackPolicyParams(config.policyParams, packPolicy.packId, packPolicy.name);
121
+ }
122
+
47
123
  const defaultPrefix = `${DEFAULT_POLICY_NAMESPACE}/`;
48
124
  if (!canonicalName.startsWith(defaultPrefix)) return undefined;
49
125
  return config.policyParams[canonicalName.slice(defaultPrefix.length)];
package/src/hooks/tui.ts CHANGED
@@ -108,6 +108,8 @@ const RADIO_OFF = "○";
108
108
  export const CHECK_ON = "◼";
109
109
  export const CHECK_OFF = "◻";
110
110
  export const CARET = "❯";
111
+ /** The return key, as the multi-select hints already spell it. */
112
+ export const CARET_RETURN = "↵";
111
113
  const MARK = "\u25AE\u25AE"; // ▮▮ — the brand mark, per the design system
112
114
 
113
115
  // ── color ─────────────────────────────────────────────────────────────────
@@ -933,6 +935,24 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
933
935
  });
934
936
  }
935
937
 
938
+ // A default nobody can see is a default nobody uses.
939
+ //
940
+ // `defaultValue` is applied on an empty submit and is otherwise INVISIBLE, so
941
+ // every prompt carrying one had to remember to spell it into its own hint —
942
+ // and the one where it mattered most, "Where should this publish?", spelled
943
+ // out the value while never saying that return was the key that took it.
944
+ // Somebody looking at a prefilled-looking placeholder types the whole thing
945
+ // out again. Owned here so a prompt cannot be added without it.
946
+ //
947
+ // Never for a masked prompt: those hold credentials, and the entire reason
948
+ // the characters are hidden is that the screen is being shared or recorded.
949
+ const hintText = [
950
+ opts.defaultValue && !opts.mask ? `${CARET_RETURN} ${opts.defaultValue}` : "",
951
+ opts.hint ?? "",
952
+ ]
953
+ .filter(Boolean)
954
+ .join(" · ");
955
+
936
956
  return new Promise((resolve) => {
937
957
  let value = "";
938
958
  const draw = (error?: string) => {
@@ -942,7 +962,7 @@ export function promptText(opts: PromptTextOptions): Promise<string | null> {
942
962
  // steps aside as soon as there is a real answer to look at. Keeping both
943
963
  // on one line put the example and the input side by side, which is the
944
964
  // arrangement most likely to make somebody wonder which one is theirs.
945
- const hint = opts.hint && value.length === 0 ? ` ${c.dim(opts.hint)}` : "";
965
+ const hint = hintText && value.length === 0 ? ` ${c.dim(hintText)}` : "";
946
966
  // Truncate to ONE physical row. `\r\x1b[2K` erases the row the cursor is
947
967
  // on and nothing above it — so a line wider than the terminal wraps, the
948
968
  // erase reaches only its last row, and every keystroke leaves the earlier