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
package/dist/worker.mjs CHANGED
@@ -2559,7 +2559,7 @@ var init_builtin_policies = __esm(() => {
2559
2559
  });
2560
2560
 
2561
2561
  // package.json
2562
- var version = "1.0.2-beta.5";
2562
+ var version = "1.0.2-beta.7";
2563
2563
  var init_package = () => {};
2564
2564
 
2565
2565
  // src/posthog-key.ts
@@ -9408,7 +9408,7 @@ async function evaluateHookEvent(eventType, cli = "claude", stdinPayload, opts)
9408
9408
  const legacyNames = activePause || packsInstalledHere ? [] : config.enabledPolicies;
9409
9409
  registerBuiltinPolicies(activePause ? [] : legacyNames);
9410
9410
  if (legacyNames.length > 0) {
9411
- hookLogWarn(`enforcing ${legacyNames.length} policies from this build because no pack is installed — ` + `run \`failproofai policies add core\` to move them into a real pack`);
9411
+ hookLogWarn(`enforcing ${legacyNames.length} policies from this build because no pack is installed — ` + `run \`failproofai policies add FailproofAI/policies\` to move them into a real pack`);
9412
9412
  }
9413
9413
  const enabledBuiltinNames = new Set(legacyNames);
9414
9414
  let cloudManagedPolicies = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.2-beta.5",
3
+ "version": "1.0.2-beta.7",
4
4
  "description": "Observability and enforcement for AI agent harnesses. 39 built-in policies hooked into 12 of them — Claude Code, Codex, Cursor, Hermes, OpenClaw and more — blocking the tool call before it runs. Local dashboard included, no account needed.",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -120,15 +120,15 @@
120
120
  "sharp": "0.35.0"
121
121
  },
122
122
  "optionalDependencies": {
123
- "@failproofai/failproofaid-linux-x64": "1.0.2-beta.5",
124
- "@failproofai/failproofaid-linux-arm64": "1.0.2-beta.5",
125
- "@failproofai/failproofaid-darwin-x64": "1.0.2-beta.5",
126
- "@failproofai/failproofaid-darwin-arm64": "1.0.2-beta.5"
123
+ "@failproofai/failproofaid-linux-x64": "1.0.2-beta.7",
124
+ "@failproofai/failproofaid-linux-arm64": "1.0.2-beta.7",
125
+ "@failproofai/failproofaid-darwin-x64": "1.0.2-beta.7",
126
+ "@failproofai/failproofaid-darwin-arm64": "1.0.2-beta.7"
127
127
  },
128
128
  "failproofaidBinaries": {
129
- "linux-x64": "e19a1cf9a254df830f205fac024b8a229c162c93ae73851b985d2794e09da035",
130
- "linux-arm64": "fafcff01e4cce00809b5cd9ff2822705d7373acbab79444e0384cb058b6da921",
131
- "darwin-x64": "d7bd609387e13f5e75287674d948c69d739bac8d6963aa731b93111d1bdd2ea2",
132
- "darwin-arm64": "9a57c8eb0f23ed25328e0f7b06638a6ab7f51e1deebf479bac2c779bf9912941"
129
+ "linux-x64": "75a8e231e057986f2b357e1fcb596641afd82a4b58f9271bb4b342951a364c2d",
130
+ "linux-arm64": "47d2933d06e70faefa78fe06fdd4e06bc03e94a319d13a8837145d192cd84d8c",
131
+ "darwin-x64": "6eae79c75746efe6922ca84e7dd899d01a50e651879abc8f90de69cd51c403f3",
132
+ "darwin-arm64": "ddca616decc3d2415ce6a58ae5fe15786bd2cfa5a5319803f4207a01d40e4a0a"
133
133
  }
134
134
  }
package/src/audit/cli.ts CHANGED
@@ -25,6 +25,8 @@
25
25
  * an exit code. See `runScheduledAudit`.
26
26
  */
27
27
  import { runAudit } from "./index";
28
+ import { hasInstalledPacks } from "../hooks/pack-manifest";
29
+ import { CORE_SOURCE } from "../hooks/pack-store";
28
30
  import { acquireAuditLock, type AuditLockInfo } from "./audit-lock";
29
31
  import { writeDashboardCache } from "./dashboard-cache";
30
32
  import type { AuditResult, RunAuditOptions } from "./types";
@@ -33,7 +35,8 @@ import { getInstanceId } from "../../lib/telemetry-id";
33
35
  import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize";
34
36
  import { openWhenReady } from "./open-browser";
35
37
  import { describeOutcome, reportHarm } from "./report-harm";
36
- import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM } from "../hooks/tui";
38
+ import { brandAnsi, ANSI_RESET, ANSI_BOLD, ANSI_DIM, helpScreen, helpOptsFor } from "../hooks/tui";
39
+ import { version } from "../../package.json";
37
40
 
38
41
  /** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default
39
42
  * for `start` mode, which `failproofai` (bare) already uses. */
@@ -62,9 +65,6 @@ export const AUDIT_STAGES: ReadonlyArray<{ label: string; detail: string }> = [
62
65
  { label: "aggregating results", detail: "counting hits, ranking by frequency" },
63
66
  ];
64
67
 
65
- /** Column the description text starts at. The widest command line is 37 wide. */
66
- const HELP_DESC_COL = 40;
67
-
68
68
  /**
69
69
  * `audit --help`.
70
70
  *
@@ -80,41 +80,44 @@ const HELP_DESC_COL = 40;
80
80
  * one. It still works, and still refuses every argument it always refused.
81
81
  */
82
82
  export function helpText(): string {
83
- const row = (command: string, lines: string[]): string => {
84
- // Padding is measured on the RAW command `c()` adds escape bytes that
85
- // occupy no columns, so colouring first would misalign every row.
86
- const pad = " ".repeat(Math.max(1, HELP_DESC_COL - 2 - command.length));
87
- return lines
88
- .map((line, i) =>
89
- i === 0 ? ` ${c(CYAN, command)}${pad}${line}` : `${" ".repeat(HELP_DESC_COL)}${line}`,
90
- )
91
- .join("\n");
92
- };
93
-
94
- return [
95
- `${c(BOLD, "failproofai audit")} — review your agent CLIs for risky and wasteful patterns.`,
96
- c(DIM, "Everything runs on this machine; only a scheduled digest ever leaves it."),
97
- "",
98
- c(BOLD, "USAGE"),
99
- row("failproofai audit", [
100
- "Scan your session history, then open",
101
- `http://localhost:${DASHBOARD_PORT}/audit`,
102
- ]),
103
- row("failproofai audit --schedule [days]", [
104
- "Scan on a timer and email the findings.",
105
- "Default 7 days, range 1-90.",
106
- "Signs you in the first time; add",
107
- "--email <address> to skip a prompt.",
108
- ]),
109
- row("failproofai audit --no-schedule", ["Stop the timer. Leaves you signed in."]),
110
- row("failproofai audit --status", [
111
- "Whether scheduling is on, where reports",
112
- "go, the daemon's state, and when the",
113
- "next scan is due.",
114
- ]),
115
- row("failproofai audit -h, --help", ["Show this help."]),
116
- "",
117
- ].join("\n");
83
+ // Drawn by the same renderer as `config --help` and `policies --help`, from
84
+ // the same kit `audit`'s output already uses for its palette. It used to
85
+ // build its own rows against a hand-set HELP_DESC_COL, which is how `audit`
86
+ // and `config` ended up with their descriptions in different columns.
87
+ //
88
+ // The `failproofai audit` prefix is dropped from the rows: the heading two
89
+ // lines above already carries it, and repeating it cost 17 of the 80 columns
90
+ // on every row — which is what forced the four-word-per-line descriptions.
91
+ const lines = helpScreen(
92
+ {
93
+ command: "audit",
94
+ version,
95
+ tagline: "review your agent CLIs for risky and wasteful patterns",
96
+ sections: [
97
+ {
98
+ label: "usage",
99
+ entries: [
100
+ ["(bare)", `Scan your session history, then open http://localhost:${DASHBOARD_PORT}/audit`],
101
+ ["--schedule [days]", "Scan on a timer and email the findings. Default 7 days, range 1-90. Signs you in the first time."],
102
+ // Its own row now, rather than a clause inside --schedule's. It
103
+ // does not stand alone, which is why it used to be a clause — but
104
+ // a clause wraps, and `--email <address>` landing with the flag at
105
+ // the end of one line and its placeholder at the start of the next
106
+ // is not a flag anybody can read or copy.
107
+ ["--email <address>", "With --schedule, skips the sign-in prompt."],
108
+ ["--no-schedule", "Stop the timer. Leaves you signed in."],
109
+ ["--status", "Whether scheduling is on, where reports go, the daemon's state, and when the next scan is due."],
110
+ ["-h, --help", "Show this help."],
111
+ ],
112
+ },
113
+ ],
114
+ footer: ["Everything runs on this machine; only a scheduled digest ever leaves it."],
115
+ },
116
+ helpOptsFor(process.stdout),
117
+ );
118
+ // The margins `printBlock` would add, spelled out because this one returns
119
+ // its text for a caller to write rather than writing it.
120
+ return ["", ...lines, ""].join("\n") + "\n";
118
121
  }
119
122
 
120
123
  // ── ANSI helpers ────────────────────────────────────────────────────────────
@@ -504,6 +507,16 @@ export async function runPostSetupAudit(): Promise<void> {
504
507
  writeDashboardCache({}, result);
505
508
  printSummary(result);
506
509
  process.stdout.write("\n");
510
+ // The audit says what ALREADY happened; nothing here says how to stop it
511
+ // happening again. This is the first thing a new machine runs, and setup
512
+ // installs no policies by design, so without this the whole first session
513
+ // ends on a count of findings and no way to act on it.
514
+ if (!hasInstalledPacks()) {
515
+ process.stdout.write(
516
+ ` ${c(DIM, "none of this is being enforced yet. take ours, or anyone's:")}\n` +
517
+ ` ${c(CYAN, `failproofai policies add ${CORE_SOURCE}`)}\n\n`,
518
+ );
519
+ }
507
520
  } finally {
508
521
  attempt.lock.release();
509
522
  }
@@ -10,6 +10,7 @@
10
10
  import { batchAll } from "../../lib/concurrency";
11
11
  import { BUILTIN_POLICIES } from "../hooks/builtin-policies";
12
12
  import { readMergedHooksConfig } from "../hooks/hooks-config";
13
+ import { readInstalledPacks } from "../hooks/pack-manifest";
13
14
  import { normalizePolicyName } from "../hooks/policy-registry";
14
15
  import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
15
16
  import { ADAPTERS } from "./cli-adapters";
@@ -379,12 +380,42 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
379
380
  const clis = (opts.clis ?? Array.from(INTEGRATION_TYPES)) as IntegrationType[];
380
381
  const sinceMs = parseSinceOpt(opts.since);
381
382
 
382
- // Snapshot which builtin policies the user currently has enabled — drives
383
- // the "already protected" vs "slipping through" split in the report.
383
+ // What the user actually has switched on — drives the "already protected"
384
+ // vs "slipping through" split.
385
+ //
386
+ // Read from BOTH places it can live, because it moved and this only followed
387
+ // it half way. `enabledPolicies` is the pre-packs key, still written by
388
+ // `policies add <name>` and still true on a machine running the migration
389
+ // shim. Everything installed as a PACK records its selection in
390
+ // `installed.json` instead, and `pack-store` never writes `enabledPolicies`
391
+ // at all — so reading that key alone meant installing a pack could not move
392
+ // this split. Every policy read as slipping through no matter what was
393
+ // enforcing, and the number the audit leads with never changed however many
394
+ // policies you installed. Verified: same 302 hits, same 17 findings, before
395
+ // and after installing all 38.
396
+ //
397
+ // The split, not the REPLAY. What gets replayed stays the compiled builtins
398
+ // on purpose — see `initReplay`: an audit is a fixed yardstick, and one that
399
+ // changed shape with whatever pack a machine happened to have could not be
400
+ // compared against its own history. A pack policy sharing a builtin's name
401
+ // marks that builtin protected, which is exactly right: the same rule is
402
+ // enforcing, whichever artifact carries it.
384
403
  const userConfig = readMergedHooksConfig();
385
404
  const enabledBuiltins = new Set(
386
405
  (userConfig.enabledPolicies ?? []).map((n) => normalizePolicyName(n)),
387
406
  );
407
+ try {
408
+ for (const pack of readInstalledPacks().packs) {
409
+ // `enabled: null` means the whole pack was taken, so every policy in its
410
+ // catalog counts — the field records a SELECTION, and its absence is not
411
+ // an empty one.
412
+ const taken = pack.enabled ?? pack.policies.map((entry) => entry.name);
413
+ for (const name of taken) enabledBuiltins.add(normalizePolicyName(name));
414
+ }
415
+ } catch {
416
+ // An unreadable manifest must not fail the audit. It costs the split its
417
+ // pack half, which is the same answer this gave for every machine before.
418
+ }
388
419
 
389
420
  // 1. Discover transcripts across all selected CLIs.
390
421
  const allTranscripts: TranscriptMetadata[] = [];
@@ -149,7 +149,7 @@ export function runBackfillCommand(opts: BackfillOptions = {}): BackfillResult {
149
149
  exitCode: 1,
150
150
  lines: [
151
151
  "This machine is not connected, so there is nowhere to send history.",
152
- "Connect first: `failproofai config --connect <url> --token <key>`.",
152
+ "Connect first: `failproofai config --token <key>`.",
153
153
  ],
154
154
  };
155
155
  }
@@ -30,6 +30,8 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import { dirname, resolve, sep } from "node:path";
32
32
 
33
+ import { CORE_SOURCE } from "./pack-store";
34
+
33
35
  import {
34
36
  selectOne,
35
37
  promptText,
@@ -109,6 +111,31 @@ export interface WizardIO {
109
111
  stdout?: TTYOut;
110
112
  }
111
113
 
114
+ /**
115
+ * Answers supplied up front, so the run needs nobody at the keyboard.
116
+ *
117
+ * This is the whole of headless setup, and it is small for a reason nobody
118
+ * planned: the wizard stopped DECIDING most of what it used to. Scope, agents,
119
+ * policies and the custom-policy toggle are all hardcoded now (see the
120
+ * constants further down), so there is no decision surface left to expose —
121
+ * only the three questions that remain, and each has one sensible unattended
122
+ * answer.
123
+ *
124
+ * None of this skips sudo. Installing a system service needs root and nothing
125
+ * here changes that: elevation goes through `sudo -n`, which never prompts, and
126
+ * a machine that cannot elevate is told exactly what to run as root.
127
+ */
128
+ export interface WizardAnswers {
129
+ /** Cloud API key. Its presence IS the request to connect. */
130
+ token?: string;
131
+ /** Where to connect. Defaults to the same URL the wizard infers. */
132
+ url?: string;
133
+ machineId?: string;
134
+ machineLabel?: string;
135
+ /** Record decisions only, no session transcripts. */
136
+ noTranscripts?: boolean;
137
+ }
138
+
112
139
  /**
113
140
  * Why a wizard run ended without applying. Distinguished so the caller can
114
141
  * pick an exit code — a user who pressed Esc did nothing wrong (exit 0), a
@@ -120,8 +147,9 @@ export type WizardAbort =
120
147
  | "needs_root"
121
148
  | "daemon_failed"
122
149
  | "unsupported_platform"
123
- | "not_a_tty"
124
- | "running_as_sudo";
150
+ | "running_as_sudo"
151
+ /** A cloud key was supplied and the server refused it. Unattended only. */
152
+ | "cloud_unverified";
125
153
 
126
154
  export interface WizardResult {
127
155
  applied: boolean;
@@ -695,19 +723,37 @@ export async function maybeFirstRunConfigure(
695
723
 
696
724
  // ── The wizard ───────────────────────────────────────────────────────────────
697
725
 
698
- export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResult> {
726
+ export async function runConfigureWizard(
727
+ io: WizardIO = {},
728
+ answers: WizardAnswers = {},
729
+ ): Promise<WizardResult> {
699
730
  const stdin: TTYIn = io.stdin ?? process.stdin;
700
731
  const stdout: TTYOut = io.stdout ?? process.stdout;
701
732
  const cwd = process.cwd();
733
+ // No terminal means no questions — not a refusal.
734
+ //
735
+ // `failproofai config` IS the authorisation: somebody typed the command whose
736
+ // entire job is to configure this machine. Requiring a flag on top of that to
737
+ // permit what the command already means is asking the same question twice,
738
+ // and this used to refuse outright — so a CI job, a container or an agent
739
+ // could not set a machine up at all, which is the gap this closes.
740
+ //
741
+ // Safe to do here because the IMPLICIT path is guarded separately:
742
+ // `maybeFirstRunConfigure` has its own TTY check and returns before ever
743
+ // reaching this function, so setup never runs off the back of some other
744
+ // command on a headless box.
745
+ const unattended = !stdin.isTTY || !stdout.isTTY;
746
+ // A key on the command line settles the whole run, not just the question it
747
+ // literally answers.
748
+ //
749
+ // Somebody typing `failproofai config --token <key>` has said: set this
750
+ // machine up, connect it, send its data. There is nothing left to confirm —
751
+ // so asking "Connect to Cloud?", then the key, then "Ready to apply?" made
752
+ // the flag look like it had not been read. The only thing still worth
753
+ // stopping for is the sudo password, which is a CREDENTIAL rather than a
754
+ // question, and no flag can supply it.
755
+ const preAnswered = Boolean(answers.token);
702
756
 
703
- if (!stdin.isTTY || !stdout.isTTY) {
704
- stdout.write(
705
- "failproofai config needs an interactive terminal.\n" +
706
- "Use the flag form instead, e.g.:\n" +
707
- " failproofai policies --install --scope user --cli claude\n",
708
- );
709
- return { applied: false };
710
- }
711
757
 
712
758
  // Running the wizard itself under sudo configures the WRONG ACCOUNT, and
713
759
  // does it silently: homedir() becomes /root, so the hooks land in root's
@@ -724,7 +770,10 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
724
770
  "The one step that needs root (installing the service) asks for your password\n" +
725
771
  "on its own.\n",
726
772
  );
727
- return { applied: false };
773
+ // Same reason as above: this configured nothing, and it is not a
774
+ // cancellation. `running_as_sudo` has been in `WizardAbort` since it was
775
+ // written and was never once assigned.
776
+ return { applied: false, abort: "running_as_sudo" };
728
777
  }
729
778
 
730
779
  // Fire-and-forget: never block the wizard's first paint on telemetry.
@@ -847,7 +896,13 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
847
896
  ? "failproofaid can't evaluate — every tool call is denied. Rebuilding needs sudo.\n\n"
848
897
  : "Installing failproofaid — needs sudo once.\n\n",
849
898
  );
850
- if (!primeElevation()) {
899
+ // `primeElevation` runs `sudo -v`, which PROMPTS. An unattended run has
900
+ // nobody to type a password, so it goes straight to the `sudo -n` that
901
+ // `runPrivileged` uses anyway — exactly what `failproofai update` already
902
+ // does — and a machine that cannot elevate gets the commands below instead
903
+ // of a hung terminal. This is the same answer for both: sudo cannot be
904
+ // conjured away, only asked for at a moment somebody can answer.
905
+ if (!(unattended ? canElevate() : primeElevation())) {
851
906
  // Required means required: write nothing at all, so a machine that could
852
907
  // not be set up is left exactly as it was found rather than carrying
853
908
  // half a configuration. The commands are printed so an admin can do the
@@ -866,7 +921,7 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
866
921
  "failproofaid is running, but from a service definition written by an older\n" +
867
922
  "version — it cannot start a scheduled audit. Refreshing it needs root once.\n\n",
868
923
  );
869
- if (!primeElevation()) {
924
+ if (!(unattended ? canElevate() : primeElevation())) {
870
925
  // NOT an abort, unlike the install branch above. There is a working
871
926
  // daemon here and hooks are enforcing; only the scheduled audit is out
872
927
  // of reach. Stopping setup over that would make an upgrade the thing
@@ -914,7 +969,7 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
914
969
  //
915
970
  // Setup no longer asks which policies to enable, and that is deliberate.
916
971
  // failproofai ships no policies of its own any more: they arrive as packs —
917
- // from inside this package (`pack add core`) or from anyone's GitHub release.
972
+ // from inside this package (`policies add FailproofAI/policies`) or from anyone's GitHub release.
918
973
  // A wizard that pre-ticks OUR list makes a product decision on behalf of
919
974
  // somebody who has not seen the list yet, and not everyone wants the set we
920
975
  // would have chosen. So setup wires the hooks, and choosing what they
@@ -971,7 +1026,19 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
971
1026
  let connect: { url: string; token: string; machineId: string; machineLabel: string } | null = null;
972
1027
 
973
1028
  {
974
- const choice = await selectOne<"key" | "local">({
1029
+ // Supplying a key IS asking to connect — there is no other reason to pass
1030
+ // one — so an unattended run with a token skips straight past the question,
1031
+ // and one without a token stays local, which is the same "Not now" branch a
1032
+ // person picks. Neither needs a separate flag to say so.
1033
+ const choice = answers.token
1034
+ // A key on the command line IS the answer to this question. Gating it on
1035
+ // there being no terminal was wrong: `--token` means "use this key", not
1036
+ // "use this key only if nobody is watching", and a run that asked anyway
1037
+ // made the flag look broken to the person who had just typed it.
1038
+ ? "key"
1039
+ : unattended
1040
+ ? "local"
1041
+ : await selectOne<"key" | "local">({
975
1042
  message: "Connect this machine to FailproofAI Cloud?",
976
1043
  // The hint says what you GET; the body says what LEAVES.
977
1044
  //
@@ -1054,7 +1121,16 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1054
1121
  let machineId = resolveMachineId();
1055
1122
  let machineLabel = existing?.machineLabel ?? resolveMachineLabel();
1056
1123
 
1057
- if (existing) {
1124
+ if (existing && unattended && !answers.url) {
1125
+ // Already enrolled, and this run did not name a different place to
1126
+ // enrol. Reusing is what the person picks here too, and re-verifying a
1127
+ // working credential is the one thing an unattended re-run must not
1128
+ // turn into a failure.
1129
+ url = existing.url;
1130
+ token = answers.token ?? existing.token;
1131
+ machineId = answers.machineId ?? existing.machineId;
1132
+ machineLabel = answers.machineLabel ?? existing.machineLabel ?? machineLabel;
1133
+ } else if (existing && !unattended) {
1058
1134
  const reuse = await selectOne<"reuse" | "other">({
1059
1135
  message: `Use this machine's existing connection to ${existing.url}?`,
1060
1136
  choices: [
@@ -1126,6 +1202,10 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1126
1202
  }
1127
1203
  }
1128
1204
 
1205
+ // Supplied on the command line, so nothing is asked and nothing is
1206
+ // echoed — terminal or not. Prompting for a value the caller already gave
1207
+ // is the failure this whole flag exists to avoid.
1208
+ if (token === null) token = answers.token ?? null;
1129
1209
  if (token === null) {
1130
1210
  token = await promptText({
1131
1211
  // The destination is in the question now that it is no longer a
@@ -1149,6 +1229,14 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1149
1229
  // persisted here.
1150
1230
  stdout.write("\nChecking the key… ");
1151
1231
  const probe = await validateIngestKey({ url: ingestUrlFor(url), key: token });
1232
+ if (!probe.ok && unattended) {
1233
+ // A key that does not work is a FAILED setup, not a prompt. The
1234
+ // interactive path offers "save it anyway" because a person can weigh
1235
+ // an outage against their own impatience; a script cannot, and one that
1236
+ // exited 0 here would leave a fleet believing it was reporting.
1237
+ stdout.write(`\nThat key did not work: ${probe.reason}\n`);
1238
+ return { applied: false, abort: "cloud_unverified" };
1239
+ }
1152
1240
  if (!probe.ok) {
1153
1241
  stdout.write(`\nThat did not work: ${probe.reason}\n`);
1154
1242
  const retry = await selectOne<"skip" | "anyway">({
@@ -1173,7 +1261,15 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1173
1261
  }
1174
1262
  }
1175
1263
  // 5 — Review & apply
1176
- const decision = await selectOne<"apply" | "cancel">({
1264
+ //
1265
+ // The last question, and the only one a headless run skips outright rather
1266
+ // than answering from a flag. There is nothing to confirm when nobody is
1267
+ // watching, and the command itself was the confirmation.
1268
+ //
1269
+ // NOT the same call as `uninstall`, which does require --yes: that one
1270
+ // REMOVES things, so silence there would destroy on a signal as weak as a
1271
+ // missing terminal. This one does exactly what its name says.
1272
+ const decision = unattended || preAnswered ? "apply" : await selectOne<"apply" | "cancel">({
1177
1273
  message: "Ready to apply?",
1178
1274
  body: reviewLines({
1179
1275
  target,
@@ -1463,6 +1559,18 @@ export async function runConfigureWizard(io: WizardIO = {}): Promise<WizardResul
1463
1559
  { ok: true },
1464
1560
  stdout,
1465
1561
  );
1562
+ // Setup wires the hooks and deliberately chooses NO policies, so a machine
1563
+ // that has just finished it enforces almost nothing — and the summary line
1564
+ // says "0 policies" without saying what to do about it. Anyone else's pack is
1565
+ // typed the same way, which is the point: ours is named in full rather than
1566
+ // by a short name only we could use.
1567
+ if (policies.length === 0) {
1568
+ stdout.write(
1569
+ `\n Nothing is enforcing yet. Take ours, or anyone's:\n` +
1570
+ ` failproofai policies add ${CORE_SOURCE}\n` +
1571
+ ` failproofai policies show <owner>/<repo> (look first)\n\n`,
1572
+ );
1573
+ }
1466
1574
  return {
1467
1575
  applied: true,
1468
1576
  target,
@@ -973,7 +973,7 @@ export function resetHome(from: number, to: number = LAYOUT_VERSION): ResetOutco
973
973
  //
974
974
  // Nothing needs carrying either: `enabledPolicies` is left exactly where it
975
975
  // is, and the no-pack fallback reads it, so a machine mid-upgrade keeps
976
- // enforcing precisely what it enforced before. `failproofai policies add core`
976
+ // enforcing precisely what it enforced before. `failproofai policies add FailproofAI/policies`
977
977
  // turns those names into a real pack, carrying the same selection into it.
978
978
  // The step's OWN target, not LAYOUT_VERSION.
979
979
  //
@@ -323,7 +323,7 @@ export async function evaluateHookEvent(
323
323
  if (legacyNames.length > 0) {
324
324
  hookLogWarn(
325
325
  `enforcing ${legacyNames.length} policies from this build because no pack is installed — ` +
326
- `run \`failproofai policies add core\` to move them into a real pack`,
326
+ `run \`failproofai policies add FailproofAI/policies\` to move them into a real pack`,
327
327
  );
328
328
  }
329
329
  // What actually registered, for the pack-duplicate check below. A paused
@@ -786,6 +786,18 @@ export async function handleHookEvent(eventType: string, cli: IntegrationType =
786
786
  error_type: "oversized",
787
787
  });
788
788
  }
789
+ if (stdinRead.timedOut) {
790
+ // Reported distinctly from a read error because the remedy is different and
791
+ // the cause is upstream: the agent spawned the hook and did not close its
792
+ // stdin. Before this was bounded it was not reported at all — the process
793
+ // simply never returned, and the tool call it was gating never resolved.
794
+ hookLogWarn(`stdin never closed for ${eventType}; evaluated nothing`);
795
+ void trackHookEvent(getInstanceId(), "hook_stdin_error", {
796
+ event_type: eventType,
797
+ cli,
798
+ error_type: "timeout",
799
+ });
800
+ }
789
801
 
790
802
  const result = await evaluateHookEvent(eventType, cli, stdinRead.payload);
791
803
 
@@ -15,7 +15,6 @@ import { INTEGRATION_TYPES,
15
15
  type IntegrationType,
16
16
  } from "./types";
17
17
  import { claudeCode, getIntegration, settingsPathsFor, type Integration } from "./integrations";
18
- import { promptPolicySelection } from "./install-prompt";
19
18
  import { configuredCustomPolicyPaths, readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig, syncConventionPolicies, findProjectConfigDir } from "./hooks-config";
20
19
  import type { HooksConfig, ConventionPolicyRecord } from "./policy-types";
21
20
  import { BUILTIN_POLICIES } from "./builtin-policies";
@@ -462,9 +461,25 @@ async function installHooksImpl(
462
461
  ? [...new Set(incoming)]
463
462
  : [...new Set([...previousConfig.enabledPolicies, ...incoming])];
464
463
  } else {
465
- // Interactive pre-load current config if it exists
466
- const preSelected = previousConfig.enabledPolicies.length > 0 ? previousConfig.enabledPolicies : undefined;
467
- selectedPolicies = await promptPolicySelection(preSelected, { includeBeta });
464
+ // NOT a policy picker. `--install` wires hooks; choosing what they enforce
465
+ // is `policies add` / `policies remove`.
466
+ //
467
+ // It used to open a second picker here, over `BUILTIN_POLICIES` — the
468
+ // COMPILED catalog, which is no longer how policies arrive. So it offered a
469
+ // list that did not match what was installed, in an older prompt engine
470
+ // that looks nothing like the rest, and wrote its answer to
471
+ // `enabledPolicies` while every pack records its selection in
472
+ // `installed.json`.
473
+ //
474
+ // Two independent enabled-sets, and this one silently overwrote the other:
475
+ // `policies remove block-env-files` followed by `policies --install` put
476
+ // block-env-files straight back on, because the picker pre-ticked from the
477
+ // legacy key and saved all of it again. A command whose job is wiring must
478
+ // not undo a policy decision made somewhere else.
479
+ //
480
+ // Whatever was enabled stays enabled. Explicit names still work
481
+ // (`--install block-sudo`) for the migration shim, which has only this key.
482
+ selectedPolicies = previousConfig.enabledPolicies;
468
483
  }
469
484
 
470
485
  // Preserve existing config fields when updating. New writes use the plural
@@ -556,7 +571,21 @@ async function installHooksImpl(
556
571
  // pack does not carry it and asking for it by name is a selection the pack
557
572
  // cannot satisfy. It ships compiled in and registers regardless.
558
573
  const alwaysOnNames = new Set(BUILTIN_POLICIES.filter((p) => p.alwaysOn).map((p) => p.name));
559
- const fromPack = selectedPolicies.filter((name) => !alwaysOnNames.has(name));
574
+ // ONLY names the caller actually asked for.
575
+ //
576
+ // This used to take whatever sat in `enabledPolicies` and switch those names
577
+ // on in the pack. It is additive — it can turn a policy on and never off — so
578
+ // a stale entry in that key resurrected a policy the user had deliberately
579
+ // removed, on the next `policies --install`, with no mention of it.
580
+ // `remove block-env-files` then `--install` put it straight back.
581
+ //
582
+ // And the key goes stale by design now: nothing writes pack selections there,
583
+ // so anything left in it is a leftover from before packs, kept only for the
584
+ // migration shim. Re-applying a leftover as if it were a decision is how a
585
+ // machine ends up enforcing something its owner switched off.
586
+ const fromPack = policyNames === undefined
587
+ ? []
588
+ : selectedPolicies.filter((name) => !alwaysOnNames.has(name));
560
589
  if (fromPack.length > 0) {
561
590
  // ONLY when there is no pack yet. Fetching the core pack with
562
591
  // `only: <selection>` REPLACES whatever the machine had chosen, and
@@ -578,7 +607,7 @@ async function installHooksImpl(
578
607
  } catch (err) {
579
608
  console.log(
580
609
  `\nWarning: could not fetch the policy pack (${err instanceof Error ? err.message : String(err)}).\n` +
581
- `Run \`failproofai policies add core\` once you are online.`,
610
+ `Run \`failproofai policies add FailproofAI/policies\` once you are online.`,
582
611
  );
583
612
  }
584
613
  } else {
@@ -592,7 +621,13 @@ async function installHooksImpl(
592
621
  }
593
622
  }
594
623
  }
595
- console.log(`\nEnabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}\n`);
624
+ // Only when this run actually changed something. `--install` with no names
625
+ // wires hooks and touches no policy, and announcing "Enabled 0 policy(ies):"
626
+ // with an empty list described work it had not done — or worse, re-stated a
627
+ // stale key as though it were this run's decision.
628
+ if (policyNames !== undefined && selectedPolicies.length > 0) {
629
+ console.log(`\nEnabled ${selectedPolicies.length} policy(ies): ${selectedPolicies.join(", ")}\n`);
630
+ }
596
631
  if (removeCustomHooks) {
597
632
  console.log("Custom hooks path cleared.");
598
633
  } else if (configToWrite.customPoliciesPaths?.length || configToWrite.customPoliciesPath) {
@@ -991,6 +1026,24 @@ export async function listHooks(cwd?: string): Promise<void> {
991
1026
  // a reader scrolling back from their prompt actually sees.
992
1027
  const footer: Array<string[] | null> = [note(`Config: ${globalPolicyConfigFile()}`, opts)];
993
1028
 
1029
+ // A machine with hooks wired and no policies to run is the normal state right
1030
+ // after setup — setup installs the hooks and deliberately chooses nothing —
1031
+ // and this listing said NOTHING about how to leave it. A reader who had just
1032
+ // finished `failproofai config` saw a header, a config path, and no next
1033
+ // step. Ours is named in full, the same way anyone else's pack is typed,
1034
+ // because it IS one.
1035
+ if (packCount === 0) {
1036
+ footer.unshift(
1037
+ nextStep(
1038
+ `failproofai policies add ${CORE_SOURCE}`,
1039
+ "Nothing is enforcing yet. Take ours, or anyone's:",
1040
+ opts,
1041
+ ),
1042
+ note("Someone else's: failproofai policies add <owner>/<repo>", opts),
1043
+ note("Look first: failproofai policies show <owner>/<repo>", opts),
1044
+ );
1045
+ }
1046
+
994
1047
  if (installedScopes.length > 1) {
995
1048
  footer.push(
996
1049
  warning(