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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -3
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +6 -5
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0-9ti7z._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0sg9-3p._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0u3wrvq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zlqo2t._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1anxb5k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1p-qi2t._.js +5 -0
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +27 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{_19uwam-._.js → _1-akg6w._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -7
- 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
- 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
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0iu54mz._.js +3 -0
- package/.next/standalone/.next/server/chunks/src_hooks_custom-hooks-loader_ts_0lnb3n3._.js +10 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n_lxhg._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1jjjg6g._.js → [root-of-the-server]__1_7n3xe._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1uvml5y._.js → _0b6xubz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07gm3zl._.js → _1es2j7i._.js} +7 -21
- package/.next/standalone/.next/server/chunks/ssr/{_0_nsohj._.js → _1pj-r7q._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +17 -0
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1cv9_c4._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_pack-cli_ts_0t7me65._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
- package/.next/standalone/.next/static/chunks/{3swqstog1cd9u.js → 06zoac_7ffkus.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0icodhlwr9x_e.js → 0bshgqm5waivd.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2-9lvhuto1iwo.js → 13grq7pifzvu5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3i8rg2fv9nd6e.js → 16pmxmg9x48ob.js} +1 -1
- package/.next/standalone/.next/static/chunks/{44oamw33aso19.js → 1da7-a7ek66uy.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d6c85605lsgp.js → 1fg7drro5kle-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0tvmd0p_1i_g6.js → 1opyu56l4cdfk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1d7o3wnt3h6se.js → 1p-o1i0lyt9un.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vqddiy9tvlwf.js → 2mfojtsy214sf.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3jt6n16p2qpex.js → 32jj4qy40hln_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1vzyn9h2kvj-i.js → 3ww5ik9eo_1b_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1mfzsja6ftim5.css → 40nr6ehj4p3cw.css} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-1gehh2qpjyj5_.js} +1 -1
- package/.next/standalone/app/actions/get-hooks-config.ts +8 -1
- package/.next/standalone/app/actions/pack-actions.ts +12 -1
- package/.next/standalone/app/policies/hooks-client.tsx +41 -4
- package/.next/standalone/fp-cloud-cli/tests/test_pagination.py +204 -0
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +57 -13
- package/dist/cli.mjs +23076 -20000
- package/dist/worker.mjs +1252 -542
- package/package.json +9 -9
- package/scripts/build-policy-pack.mjs +28 -4
- package/src/audit/index.ts +24 -4
- package/src/hooks/builtin-policies.ts +933 -8
- package/src/hooks/custom-hooks-loader.ts +98 -8
- package/src/hooks/handler.ts +26 -14
- package/src/hooks/manager.ts +36 -2
- package/src/hooks/pack-cli.ts +1224 -121
- package/src/hooks/pack-failclosed.ts +100 -4
- package/src/hooks/pack-manifest.ts +63 -1
- package/src/hooks/pack-param-key.ts +36 -0
- package/src/hooks/pack-store.ts +309 -25
- package/src/hooks/policy-catalog.ts +9 -1
- package/src/hooks/policy-evaluator.ts +77 -1
- package/src/hooks/tui.ts +21 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__06cuf1y._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0jnjf2t._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tg4pu6._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +0 -10
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1opxw-7._.js +0 -5
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{PKwyBPAHjy3DB8qqapaH4 → BFSuuqOY0YU7NpThROqre}/_ssgManifest.js +0 -0
package/src/hooks/pack-store.ts
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 {
|
|
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 {
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1082
|
+
return null;
|
|
827
1083
|
}
|
|
828
1084
|
const packs = Array.isArray(raw.packs) ? raw.packs : [];
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|