failproofai 1.0.2-beta.5 → 1.0.2-beta.7

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 (124) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -3
  23. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  31. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  33. package/.next/standalone/.next/server/app/index.html +1 -1
  34. package/.next/standalone/.next/server/app/index.rsc +14 -14
  35. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  36. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  37. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  39. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  42. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  43. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  56. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.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/{[root-of-the-server]__1zkek3z._.js → [root-of-the-server]__1tg4pu6._.js} +1 -1
  60. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/{_12moi5-._.js → _19uwam-._.js} +1 -1
  62. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +7 -7
  63. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  64. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  65. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +12 -15
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mgrjnx._.js +10 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0stn0gx._.js → [root-of-the-server]__1jjjg6g._.js} +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tag8_-._.js → [root-of-the-server]__1opxw-7._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/{_1kesqs-._.js → _0_nsohj._.js} +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/{_1km9-gn._.js → _1uvml5y._.js} +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  88. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  89. package/.next/standalone/.next/server/pages/404.html +1 -1
  90. package/.next/standalone/.next/server/pages/500.html +1 -1
  91. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  92. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  93. package/.next/standalone/.next/static/chunks/{3_b73tc78jwsq.js → 0icodhlwr9x_e.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{21r7iw3rmixf5.js → 0tvmd0p_1i_g6.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/{33np9nrh1mvj9.js → 1d6c85605lsgp.js} +1 -1
  96. package/.next/standalone/.next/static/chunks/{26lfjhb2kzzgb.js → 1d7o3wnt3h6se.js} +1 -1
  97. package/.next/standalone/.next/static/chunks/{2_27jraat038x.js → 1vzyn9h2kvj-i.js} +1 -1
  98. package/.next/standalone/.next/static/chunks/{2hwc8e923g5df.js → 2-9lvhuto1iwo.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{36-8i_vbkh85w.js → 3jt6n16p2qpex.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0tyt-3a5rtzy8.js → 3swqstog1cd9u.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{391tpdqcas7rq.js → 3vqddiy9tvlwf.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{3jqm8ubpv3i-4.js → 44oamw33aso19.js} +1 -1
  103. package/.next/standalone/package.json +9 -9
  104. package/.next/standalone/server.js +1 -1
  105. package/bin/failproofai.mjs +853 -669
  106. package/dist/cli.mjs +4534 -4429
  107. package/dist/worker.mjs +2 -2
  108. package/package.json +9 -9
  109. package/src/audit/cli.ts +52 -39
  110. package/src/audit/index.ts +33 -2
  111. package/src/hooks/backfill-cli.ts +1 -1
  112. package/src/hooks/configure-wizard.ts +126 -18
  113. package/src/hooks/fp-reset.ts +1 -1
  114. package/src/hooks/handler.ts +13 -1
  115. package/src/hooks/manager.ts +60 -7
  116. package/src/hooks/onboarding-attempt.ts +4 -4
  117. package/src/hooks/pack-cli.ts +162 -21
  118. package/src/hooks/pack-store.ts +21 -8
  119. package/src/hooks/read-stdin.ts +78 -8
  120. package/src/hooks/tui.ts +173 -39
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15r6r7w._.js +0 -10
  122. /package/.next/standalone/.next/static/{59jsH11LoyXu9UBoHvgGM → PKwyBPAHjy3DB8qqapaH4}/_buildManifest.js +0 -0
  123. /package/.next/standalone/.next/static/{59jsH11LoyXu9UBoHvgGM → PKwyBPAHjy3DB8qqapaH4}/_clientMiddlewareManifest.js +0 -0
  124. /package/.next/standalone/.next/static/{59jsH11LoyXu9UBoHvgGM → PKwyBPAHjy3DB8qqapaH4}/_ssgManifest.js +0 -0
@@ -157,10 +157,11 @@ export function blockerCleared(attempt: OnboardingAttempt, probe: RetryProbe): b
157
157
  // command would just repeat the hard-fail. Only a new release is a reason
158
158
  // to ask again (it might add support for this platform).
159
159
  return probe.cliVersion !== attempt.cliVersion;
160
- case "not_a_tty":
161
160
  case "running_as_sudo":
162
- // Both are properties of the invocation, not of the machine, so the next
163
- // one may well be fine.
161
+ // A property of the invocation, not of the machine, so the next one may
162
+ // well be fine. (`not_a_tty` used to sit here too. It is gone because
163
+ // there is no such refusal any more: a run with no terminal has no
164
+ // questions to ask, so it applies rather than declining.)
164
165
  return true;
165
166
  default:
166
167
  return true;
@@ -174,7 +175,6 @@ export function attemptHintLines(attempt: OnboardingAttempt): string[] {
174
175
  daemon_failed: "the failproofaid service could not be started",
175
176
  cancelled: "it was cancelled",
176
177
  unsupported_platform: "failproofaid does not run on this platform",
177
- not_a_tty: "there was no terminal to ask in",
178
178
  running_as_sudo: "it was run under sudo",
179
179
  };
180
180
  const detail = why[attempt.reason] ?? "it did not finish";
@@ -20,9 +20,7 @@ import {
20
20
  PACK_MANIFEST_ASSET,
21
21
  addPack,
22
22
  checkPackArtifact,
23
- CORE_ALIASES,
24
23
  fetchPackPreview,
25
- CORE_SOURCE,
26
24
  packTagMatchesVersion,
27
25
  removePack,
28
26
  setPackPolicyEnabled,
@@ -63,34 +61,153 @@ function parseList(rest: string[], flag: string): string[] | undefined {
63
61
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
64
62
  }
65
63
 
66
- /** Flags that take a separate value, so it is never mistaken for the source. */
67
- const VALUE_FLAGS = new Set(["--only", "--policy", "--category", "--cli"]);
64
+ /**
65
+ * Flags that take a separate value, so it is never mistaken for the source.
66
+ *
67
+ * Must list every flag that can REACH this lane, not just the ones this lane
68
+ * acts on. `--scope` and `--custom` are handled by the caller and passed
69
+ * through untouched — and their values were being read as the pack to install,
70
+ * so `policies add --scope project acme/x` tried to fetch a pack called
71
+ * "project". A flag this file ignores still has a value this file must skip.
72
+ */
73
+ const VALUE_FLAGS = new Set([
74
+ "--only",
75
+ "--policy",
76
+ "--category",
77
+ "--cli",
78
+ "--scope",
79
+ "--custom",
80
+ "-c",
81
+ ]);
82
+
83
+ /**
84
+ * Every index `--cli` claims, so the source is not found among its values.
85
+ *
86
+ * `--cli` is the one flag that takes MORE than one token, because the other
87
+ * lane spells it `--cli claude codex` and somebody typing that here should not
88
+ * be quietly given a different answer. Shared with `packAddSource` so the two
89
+ * agree by construction: a token consumed as a CLI name is never also read as
90
+ * the pack to install.
91
+ */
92
+ /**
93
+ * Could this token be an agent name rather than the pack to install?
94
+ *
95
+ * `--cli` consumes several tokens, so it has to know where its own list ends,
96
+ * and "not a flag" is not enough: `--cli claude codex acme/x` would swallow the
97
+ * SOURCE and leave the command with nothing to install. A pack source always
98
+ * carries a slash — `parsePackSource` accepts nothing else — and no agent name
99
+ * has one, which separates them exactly.
100
+ *
101
+ * Deliberately NOT "is it a known agent". An unknown name has to be consumed to
102
+ * be REJECTED: stopping at `claud` would hand it to the source parser instead,
103
+ * and the reply would be about pack syntax rather than about the typo.
104
+ */
105
+ function looksLikeCliName(token: string): boolean {
106
+ return !token.startsWith("-") && !token.includes("/");
107
+ }
108
+
109
+ function cliValueIndices(rest: string[]): Set<number> {
110
+ const taken = new Set<number>();
111
+ for (let i = 0; i < rest.length; i += 1) {
112
+ if (rest[i] !== "--cli" && !rest[i].startsWith("--cli=")) continue;
113
+ if (rest[i].startsWith("--cli=")) continue; // its value is in the same token
114
+ for (let j = i + 1; j < rest.length && looksLikeCliName(rest[j]); j += 1) {
115
+ taken.add(j);
116
+ }
117
+ }
118
+ return taken;
119
+ }
68
120
 
69
121
  /** Find the positional source without mistaking a flag's separate value for it. */
70
122
  export function packAddSource(rest: string[]): string | undefined {
71
- const consumed = new Set<number>();
123
+ const consumed = cliValueIndices(rest);
72
124
  for (let i = 0; i < rest.length; i += 1) {
73
125
  if (VALUE_FLAGS.has(rest[i])) consumed.add(i + 1);
74
126
  }
75
- return rest.find((arg, index) => !arg.startsWith("--") && !consumed.has(index));
127
+ // ONE dash, not two. `-c` passed a `startsWith("--")` filter and was returned
128
+ // as the pack to install — and no GitHub owner or repo may begin with a
129
+ // hyphen, so nothing legitimate is excluded by widening it.
130
+ return rest.find((arg, index) => !arg.startsWith("-") && !consumed.has(index));
131
+ }
132
+
133
+ /**
134
+ * The agent CLIs named by `--cli`, in either spelling, all of them validated.
135
+ *
136
+ * Two separate silent failures lived here, and both reported success:
137
+ *
138
+ * - An unknown name was accepted. `--cli claud` installed the pack, printed
139
+ * "Installed", exited 0, and guarded NOTHING — the misspelling matched no
140
+ * agent, so the pack applied to none of them.
141
+ * - A space-separated list was truncated to its first entry.
142
+ * `--cli claude codex` recorded `["claude"]` and dropped codex on the floor,
143
+ * because this lane split on commas while the other split on spaces.
144
+ *
145
+ * Both now take either spelling and refuse a name that is not an agent. The set
146
+ * comes from `INTEGRATION_TYPES` rather than a list written out here, because
147
+ * the copy in `bin/failproofai.mjs` is already a second hand-maintained one and
148
+ * a third would be the one that drifts.
149
+ */
150
+ function parseCliList(rest: string[]): { clis?: string[] } | { error: string[] } {
151
+ const idx = rest.findIndex((a) => a === "--cli" || a.startsWith("--cli="));
152
+ if (idx === -1) return {};
153
+ const values: string[] = [];
154
+ if (rest[idx].startsWith("--cli=")) {
155
+ values.push(...rest[idx].slice("--cli=".length).split(","));
156
+ } else {
157
+ for (let j = idx + 1; j < rest.length && looksLikeCliName(rest[j]); j += 1) {
158
+ values.push(...rest[j].split(","));
159
+ }
160
+ }
161
+ const names = values.map((v) => v.trim()).filter(Boolean);
162
+ if (names.length === 0) return { clis: [] };
163
+
164
+ const known = new Set<string>(INTEGRATION_TYPES);
165
+ const unknown = names.filter((n) => !known.has(n));
166
+ if (unknown.length > 0) {
167
+ // Thrown, not warned. A pack scoped to an agent that does not exist is
168
+ // enforcing on nothing, and saying so afterwards is no use to the script
169
+ // that already read exit 0 and moved on.
170
+ const near = (bad: string) =>
171
+ INTEGRATION_TYPES.find((k) => k.startsWith(bad.slice(0, 3)) || bad.startsWith(k.slice(0, 3)));
172
+ const hint = unknown.map((u) => (near(u) ? `${u} (did you mean ${near(u)}?)` : u)).join(", ");
173
+ return {
174
+ error: [
175
+ `Not an agent: ${hint}`,
176
+ `--cli takes any of: ${INTEGRATION_TYPES.join(", ")}`,
177
+ ],
178
+ };
179
+ }
180
+ return { clis: names };
76
181
  }
77
182
 
78
183
  /** Names taken by our own policies, so a selection can be checked before install. */
184
+ /**
185
+ * Exported for tests: the parse is where both `--cli` bugs lived, and asserting
186
+ * it through a real `add` would need a release server for a question that is
187
+ * purely about argument shapes.
188
+ */
189
+ export function selectionFromForTest(rest: string[]) {
190
+ return selectionFrom(rest);
191
+ }
192
+
79
193
  function selectionFrom(rest: string[]): {
80
194
  only?: string[];
81
195
  categories?: string[];
82
196
  all?: boolean;
83
197
  clis?: string[];
198
+ /** Present when the selection itself is unusable — see `parseCliList`. */
199
+ error?: string[];
84
200
  } {
85
201
  // `--policy` reads right for one ("give me this policy"), `--only` for a set.
86
202
  // They are the same switch; taking both means neither is the wrong guess.
87
203
  const only = parseList(rest, "--policy") ?? parseList(rest, "--only");
88
204
  const categories = parseList(rest, "--category");
89
- const clis = parseList(rest, "--cli");
205
+ const parsedClis = parseCliList(rest);
206
+ if ("error" in parsedClis) return { error: parsedClis.error };
90
207
  return {
91
208
  ...(only ? { only } : {}),
92
209
  ...(categories ? { categories } : {}),
93
- ...(clis ? { clis } : {}),
210
+ ...(parsedClis.clis ? { clis: parsedClis.clis } : {}),
94
211
  ...(rest.includes("--all") ? { all: true } : {}),
95
212
  };
96
213
  }
@@ -175,6 +292,29 @@ async function build(rest: string[]): Promise<PackCliResult> {
175
292
  ]);
176
293
  }
177
294
 
295
+ // Two policies cannot share a name, and this is where that has to be caught.
296
+ //
297
+ // Nothing downstream can resolve the ambiguity: a name is what `--policy`
298
+ // selects, what the picker toggles, and what the enabled list stores — so a
299
+ // duplicate makes one of the two unreachable and the other's on/off state
300
+ // decide for both. Bundling several files makes it easy to reach by accident:
301
+ // a starter written by `publish --init` into a folder that already had a
302
+ // policy of the same name published as exactly this, twice over, with no
303
+ // complaint at build time.
304
+ const byName = new Map<string, number>();
305
+ for (const hook of hooks) byName.set(hook.name, (byName.get(hook.name) ?? 0) + 1);
306
+ const duplicates = [...byName.entries()].filter(([, n]) => n > 1).map(([name]) => name);
307
+ if (duplicates.length > 0) {
308
+ return fail([
309
+ duplicates.length === 1
310
+ ? `Two policies are called ${JSON.stringify(duplicates[0])}.`
311
+ : `${duplicates.length} names are used by more than one policy: ${duplicates.join(", ")}.`,
312
+ "A name is what --policy selects and what the picker toggles, so a pack",
313
+ "cannot carry the same one twice — one of them would be unreachable.",
314
+ "Rename or delete one of each pair.",
315
+ ]);
316
+ }
317
+
178
318
  const policies: unknown[] = [];
179
319
  for (const [index, hook] of hooks.entries()) {
180
320
  // `category` and `defaultEnabled` are pack-manifest fields a plain custom
@@ -274,7 +414,7 @@ export async function runPolicyPicker(
274
414
  {
275
415
  what: "No policies are installed yet.",
276
416
  hint: "Take ours, or anyone's:",
277
- cmd: "failproofai policies add core",
417
+ cmd: "failproofai policies add FailproofAI/policies",
278
418
  },
279
419
  opts,
280
420
  ),
@@ -1383,18 +1523,19 @@ async function pickFromSource(
1383
1523
  async function add(rest: string[]): Promise<PackCliResult> {
1384
1524
  const source = packAddSource(rest);
1385
1525
  const selection = selectionFrom(rest);
1386
-
1387
- // Our own pack, by the short name. `core` is a SPELLING of a GitHub source,
1388
- // not a second delivery path: it resolves to CORE_SOURCE and is fetched,
1389
- // verified and pinned exactly like anybody else's.
1390
- //
1391
- // The package used to carry a copy so this worked with no network. It no
1392
- // longer does, on purpose. A pack that ships inside the binary is a policy set
1393
- // we picked for you and wrote to your disk before you asked for it, and it
1394
- // gave our own policies a route no third-party pack could use which is the
1395
- // opposite of the thing this whole lane exists to make possible.
1396
- const resolvedSource =
1397
- source && CORE_ALIASES.has(source.toLowerCase()) ? CORE_SOURCE : source;
1526
+ // Refused before anything is fetched or written: a pack scoped to an agent
1527
+ // that does not exist enforces on nothing, and saying so afterwards is no use
1528
+ // to the script that already read exit 0 and moved on.
1529
+ if (selection.error) return fail(selection.error);
1530
+
1531
+ // No short name for our own pack any more. `core` resolved to CORE_SOURCE and
1532
+ // was fetched, verified and pinned like anybody else's but a spelling only
1533
+ // WE get to use still made our policies read as part of the tool rather than
1534
+ // as one pack among others, which is the distinction this lane exists to
1535
+ // erase. Ours is typed the same way yours is: `FailproofAI/policies`.
1536
+ // `parsePackSource` still recognises the retired spellings, so typing one
1537
+ // gets told what to type instead.
1538
+ const resolvedSource = source;
1398
1539
 
1399
1540
  if (!resolvedSource) {
1400
1541
  return fail(["Usage: failproofai policies add <source> [--policy a,b] [--category x,y] [--all]"]);
@@ -91,6 +91,15 @@ function baseUrl(): string {
91
91
  const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
92
92
  const TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
93
93
 
94
+ export const CORE_SOURCE = "FailproofAI/policies";
95
+
96
+ /**
97
+ * Spellings that USED to resolve to our own pack, kept only so typing one gets
98
+ * an answer rather than a parse error. They are not resolved any more — see the
99
+ * throw in `parsePackSource`.
100
+ */
101
+ export const RETIRED_CORE_ALIASES = new Set(["core", "failproofai", "official"]);
102
+
94
103
  /**
95
104
  * Parse a pack source into owner, repo and an optional tag.
96
105
  *
@@ -136,6 +145,17 @@ export function parsePackSpec(source: string): PackSpec {
136
145
  const tag = at > 0 ? withoutScheme.slice(at + 1) : null;
137
146
  const slash = repoPart.indexOf("/");
138
147
  if (slash <= 0 || slash === repoPart.length - 1) {
148
+ // `core` / `failproofai` / `official` used to be spellings of our own pack.
149
+ // They are gone on purpose: a short name only WE get to use makes our
150
+ // policies look like part of the tool rather than a pack like any other,
151
+ // which is exactly the distinction this whole surface exists to remove.
152
+ // Named explicitly here so the spelling that used to work says what to type
153
+ // instead of failing as an unparseable source.
154
+ if (RETIRED_CORE_ALIASES.has(repoPart.trim().toLowerCase())) {
155
+ throw new Error(
156
+ `${JSON.stringify(source)} is no longer a pack name — ours is a pack like anyone else's now. Use ${CORE_SOURCE} instead.`,
157
+ );
158
+ }
139
159
  throw new Error(
140
160
  `pack source must be owner/repo, owner/repo@tag, or a github.com URL — got ${JSON.stringify(source)}`,
141
161
  );
@@ -325,8 +345,6 @@ interface FetchedPack {
325
345
  * gives our own policies a delivery path nobody else's pack can use. One lane,
326
346
  * the same for everyone — ours are just the pack whose short name we spell.
327
347
  */
328
- export const CORE_SOURCE = "FailproofAI/policies";
329
- export const CORE_ALIASES = new Set(["core", "failproofai", "official"]);
330
348
 
331
349
  export interface PackAddOptions {
332
350
  only?: string[];
@@ -358,12 +376,7 @@ export async function addPackFromSource(
358
376
  source: string,
359
377
  opts: PackAddOptions = {},
360
378
  ): Promise<AddedPack> {
361
- // `core` is a spelling of CORE_SOURCE, not a second delivery path. Resolved
362
- // HERE rather than in the CLI so the dashboard and the CLI cannot disagree
363
- // about what the short name means — they did once, and a name the CLI could
364
- // resolve failed in the browser.
365
- const resolved = CORE_ALIASES.has(source.trim().toLowerCase()) ? CORE_SOURCE : source;
366
- const result = await addPack(resolved, opts);
379
+ const result = await addPack(source, opts);
367
380
  return {
368
381
  id: result.id,
369
382
  version: result.version,
@@ -15,34 +15,104 @@ export interface StdinRead {
15
15
  oversized: boolean;
16
16
  /** `process.stdin` emitted an `error` event before `end`. */
17
17
  readError: boolean;
18
+ /** No `end` arrived within the budget; the payload is `""`. */
19
+ timedOut: boolean;
18
20
  }
19
21
 
20
- export async function readStdinPayload(maxBytes = 1_048_576): Promise<StdinRead> {
22
+ /**
23
+ * How long to wait for the agent to finish writing the payload.
24
+ *
25
+ * Deliberately generous: the parent has the payload in hand before it spawns
26
+ * the hook, so a real read is milliseconds and this can only fire on a
27
+ * misconfiguration. It exists to bound that misconfiguration, not to police a
28
+ * slow write — a false timeout here would drop a real policy decision.
29
+ */
30
+ const STDIN_TIMEOUT_MS = 10_000;
31
+
32
+ /**
33
+ * Read the hook payload, and NEVER wait forever for it.
34
+ *
35
+ * This runs on the enforcement path — every tool call, on all eleven agent
36
+ * CLIs — and it used to have no bound at all. The only early exit was
37
+ * `readableEnded`, which helps only when stdin is ALREADY closed, so a parent
38
+ * that spawned the hook with a pipe it had not yet closed, or with an inherited
39
+ * terminal, left this promise unsettled forever. A hung hook is worse than
40
+ * either verdict: it does not fail open or closed, it freezes the agent's tool
41
+ * call, and nothing on our side ever gives up.
42
+ *
43
+ * Two bounds, because there are two ways in:
44
+ *
45
+ * - A TTY on stdin means no payload is coming, ever. Nobody types a hook
46
+ * payload at a keyboard, so waiting for EOF on a terminal is waiting for a
47
+ * person to press ctrl-D — which is exactly what a hand-run
48
+ * `failproofai --hook PreToolUse` looked like.
49
+ * - An open pipe might still deliver, so it gets a clock rather than a
50
+ * refusal.
51
+ *
52
+ * Both resolve empty and flag what happened. That matches how a read error has
53
+ * always been treated — warn, report, evaluate an empty payload — rather than
54
+ * inventing a third verdict for a case that is a malformed invocation, not a
55
+ * policy decision.
56
+ */
57
+ export async function readStdinPayload(
58
+ maxBytes = 1_048_576,
59
+ timeoutMs = STDIN_TIMEOUT_MS,
60
+ // Injectable so a test can hand in a stream that never ends — which is the
61
+ // whole condition under test — WITHOUT reassigning `process.stdin`. Doing
62
+ // that globally takes the test runner's own stdin away and kills it.
63
+ stream: NodeJS.ReadableStream & {
64
+ isTTY?: boolean;
65
+ readableEnded?: boolean;
66
+ destroy?: () => void;
67
+ } = process.stdin,
68
+ ): Promise<StdinRead> {
21
69
  let payload = "";
22
70
  let oversized = false;
23
71
  let readError = false;
72
+ let timedOut = false;
73
+
74
+ // A terminal never ends on its own. Checked before any listener is attached,
75
+ // so the hand-run case costs nothing and returns immediately.
76
+ if (stream.isTTY) {
77
+ return { payload: "", oversized: false, readError: false, timedOut: false };
78
+ }
79
+
80
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
81
  try {
25
82
  payload = await new Promise<string>((resolve, reject) => {
26
83
  const chunks: string[] = [];
27
84
  let totalBytes = 0;
28
- process.stdin.setEncoding("utf8");
29
- process.stdin.on("data", (chunk: string) => {
85
+ stream.setEncoding("utf8");
86
+ stream.on("data", (chunk: string) => {
30
87
  totalBytes += Buffer.byteLength(chunk);
31
88
  if (totalBytes > maxBytes) {
32
89
  oversized = true;
33
- process.stdin.destroy();
90
+ stream.destroy?.();
34
91
  resolve("");
35
92
  return;
36
93
  }
37
94
  chunks.push(chunk);
38
95
  });
39
- process.stdin.on("end", () => resolve(chunks.join("")));
40
- process.stdin.on("error", reject);
96
+ stream.on("end", () => resolve(chunks.join("")));
97
+ stream.on("error", reject);
41
98
  // If stdin is already closed or not piped, resolve immediately
42
- if (process.stdin.readableEnded) resolve("");
99
+ if (stream.readableEnded) resolve("");
100
+
101
+ timer = setTimeout(() => {
102
+ timedOut = true;
103
+ // Whatever arrived before the clock ran out is NOT used: a partial
104
+ // payload is not valid JSON, and half a tool call is not a thing any
105
+ // policy should be asked to judge.
106
+ stream.destroy?.();
107
+ resolve("");
108
+ }, timeoutMs);
109
+ // Nothing else keeps this process alive on account of the timer.
110
+ timer.unref?.();
43
111
  });
44
112
  } catch {
45
113
  readError = true;
114
+ } finally {
115
+ if (timer) clearTimeout(timer);
46
116
  }
47
- return { payload, oversized, readError };
117
+ return { payload, oversized, readError, timedOut };
48
118
  }