failproofai 1.0.2-beta.7 → 1.0.2-beta.8

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 (116) 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 +15 -15
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  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 +15 -15
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  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 +5 -4
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__1lt47k4._.js} +1 -1
  58. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +3 -3
  61. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1jjjg6g._.js → [root-of-the-server]__1-wzs7f._.js} +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → [root-of-the-server]__1n2ywfz._.js} +5 -19
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1opxw-7._.js → [root-of-the-server]__1wlvpe5._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _0l7zv8b._.js} +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _1h33neq._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +17 -0
  81. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +3 -0
  86. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  87. package/.next/standalone/.next/server/middleware.js +1 -1
  88. package/.next/standalone/.next/server/pages/404.html +1 -1
  89. package/.next/standalone/.next/server/pages/500.html +1 -1
  90. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  91. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  92. package/.next/standalone/.next/static/chunks/{3swqstog1cd9u.js → 0i3a6wf-f_yky.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 0u2ojcw25c_np.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 11a_m8yksthtf.js} +2 -2
  95. package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 1ch5l9t2p16y4.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 1n04wzsaes73g.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 2iryw37qfqy5y.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 34j7nmidlcuni.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 3927pu4ss_l4k.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 3r0udrtpn78-e.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
  102. package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 416zumre3fvfw.js} +1 -1
  103. package/.next/standalone/package.json +9 -9
  104. package/.next/standalone/server.js +1 -1
  105. package/bin/failproofai.mjs +47 -12
  106. package/dist/cli.mjs +6070 -4131
  107. package/dist/worker.mjs +6 -3
  108. package/package.json +9 -9
  109. package/scripts/build-policy-pack.mjs +28 -4
  110. package/src/hooks/pack-cli.ts +827 -97
  111. package/src/hooks/pack-manifest.ts +34 -1
  112. package/src/hooks/pack-store.ts +208 -16
  113. package/src/hooks/tui.ts +21 -1
  114. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_buildManifest.js +0 -0
  115. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_clientMiddlewareManifest.js +0 -0
  116. /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → NgD5HvuyPxwgrpeIiafJL}/_ssgManifest.js +0 -0
@@ -54,6 +54,24 @@ const SHA256_RE = /^[a-f0-9]{64}$/;
54
54
  export const PACK_ID_RE = /^[A-Za-z0-9._-]{1,64}\/[A-Za-z0-9._-]{1,64}$/;
55
55
  /** A version string, kept loose enough for semver and a tag. */
56
56
  export const PACK_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
57
+ /**
58
+ * The git commit a pack was built from — PROVENANCE, and deliberately not the
59
+ * same thing as `sha256`.
60
+ *
61
+ * `sha256` is the digest of the entry artifact: the security pin, re-verified
62
+ * before every import, and the reason a pack cannot change under a machine
63
+ * after it was installed. This answers a different question — WHICH SOURCE
64
+ * produced that artifact — which no digest can, because the digest is of the
65
+ * bundled output and the output names no input.
66
+ *
67
+ * Always optional. A pack published from a directory that is not a git checkout
68
+ * has no commit, and that is a supported way to publish, so every surface that
69
+ * reads this must render its absence rather than treat it as a defect.
70
+ *
71
+ * Abbreviated forms are accepted from 7 characters, which is what `git log`
72
+ * prints and therefore what somebody copying one will have.
73
+ */
74
+ export const PACK_COMMIT_RE = /^[0-9a-f]{7,40}$/;
57
75
  /**
58
76
  * A pack policy's own name. `/` is REFUSED, and that is the important character:
59
77
  * `normalizePolicyName` passes any name containing one straight through, and
@@ -72,6 +90,8 @@ export interface InstalledPackRecord {
72
90
  /** Path to the single entry artifact, relative to `packsDir()`. */
73
91
  entry: string;
74
92
  sha256: string;
93
+ /** The git commit this pack was built from, when its publisher had one. */
94
+ commit?: string;
75
95
  effect?: string;
76
96
  policies?: unknown;
77
97
  /**
@@ -106,6 +126,8 @@ export interface ResolvedPack {
106
126
  /** Absolute, real, verified-to-be-inside-the-root path to the entry module. */
107
127
  path: string;
108
128
  sha256: string;
129
+ /** The git commit this pack was built from, when its publisher had one. */
130
+ commit?: string;
109
131
  effect: PolicyEffect;
110
132
  /** The pack's own catalog, in declared order. */
111
133
  policies: PolicyCatalogEntry[];
@@ -152,7 +174,8 @@ export function parsePackIdentity(value: {
152
174
  id?: unknown;
153
175
  version?: unknown;
154
176
  effect?: unknown;
155
- }): { id: string; version: string; effect: PolicyEffect } {
177
+ commit?: unknown;
178
+ }): { id: string; version: string; effect: PolicyEffect; commit?: string } {
156
179
  if (typeof value.id !== "string" || !PACK_ID_RE.test(value.id)) {
157
180
  throw new Error(`unsafe pack id ${JSON.stringify(value.id)}`);
158
181
  }
@@ -162,10 +185,20 @@ export function parsePackIdentity(value: {
162
185
  if (value.effect !== undefined && value.effect !== "enforce" && value.effect !== "observe") {
163
186
  throw new Error(`unknown effect ${JSON.stringify(value.effect)} for pack ${value.id}`);
164
187
  }
188
+ // Malformed provenance is DROPPED, never fatal. It is a label on the artifact,
189
+ // not part of what makes it safe to run — so a publisher who wrote something
190
+ // odd here gets a pack that installs and simply says nothing about its
191
+ // origin, rather than a pack nobody can install. The digest still decides
192
+ // whether the bytes are the ones that were published.
193
+ const commit =
194
+ typeof value.commit === "string" && PACK_COMMIT_RE.test(value.commit.trim().toLowerCase())
195
+ ? value.commit.trim().toLowerCase()
196
+ : undefined;
165
197
  return {
166
198
  id: value.id,
167
199
  version: value.version,
168
200
  effect: (value.effect as PolicyEffect | undefined) ?? "enforce",
201
+ ...(commit ? { commit } : {}),
169
202
  };
170
203
  }
171
204
 
@@ -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,
@@ -753,13 +924,34 @@ export async function addPack(
753
924
  entry: artifactRel,
754
925
  sha256: fetched.artifactDigest,
755
926
  policies: fetched.policies,
927
+ // Provenance, carried through from the manifest so `policies` and the
928
+ // dashboard can say which source produced what is installed. NOT part of
929
+ // verification — `sha256` above is the pin, and this is a label beside it.
930
+ ...(fetched.commit ? { commit: fetched.commit } : {}),
756
931
  ...(fetched.effect ? { effect: fetched.effect } : {}),
757
932
  ...(enabled ? { enabled } : {}),
758
933
  // Omitted, not written as the full list, when the user did not narrow it.
759
934
  // An absent `clis` means "every agent", and it keeps meaning that when a
760
935
  // thirteenth CLI is supported — whereas a materialised list of twelve would
761
936
  // silently exclude the new one from every pack installed before it existed.
762
- ...(opts?.clis ? { clis: opts.clis } : {}),
937
+ //
938
+ // A prior narrowing is CARRIED when this caller named none, for the same
939
+ // reason `enabled` is: `upsertInstalled` replaces the row wholesale, so
940
+ // writing nothing here threw the user's scope away. A pack scoped to Claude
941
+ // silently started guarding all eleven agents on the next `policies add` —
942
+ // the same shape as the bug `merge` fixes, in the widening direction, which
943
+ // is the worse one: it over-enforces, on agents nobody chose.
944
+ //
945
+ // `merge === false` is the ONE caller this must not do it for. The picker
946
+ // says "every agent" by leaving `clis` absent (pack-cli.ts, where a full
947
+ // tick deliberately writes nothing) — so for it, absent is an ANSWER, and
948
+ // carrying a prior narrowing would make widening back to all impossible.
949
+ // Everywhere else absent means no opinion was expressed.
950
+ ...(opts?.clis
951
+ ? { clis: opts.clis }
952
+ : opts?.merge !== false && prior?.clis
953
+ ? { clis: prior.clis }
954
+ : {}),
763
955
  };
764
956
 
765
957
  const absorbed = upsertInstalled(record);
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