diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0fb3fd..ea9dd2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## 0.0.14-beta.1 — 2026-07-14 ### Features +- Brand the dashboard launch splash (bare `failproofai`) to match the `configure` wizard: the plain emoji title/version block is replaced with the half-block logomark, the `failproof ai` wordmark (pink "il"), the tagline, and a tidy teal-labelled version/links column (24-bit color where advertised, monochrome shape otherwise, plain text off a TTY). The logomark rendering is now shared via `renderBrandLogo` / `renderLaunchBanner` in `tui.ts`, so the wizard intro and the launch banner stay in lockstep. (#516) +- Add an "Everything available" row to the wizard's assistants step that protects every supported CLI (detected + set-up-ahead) in one tick — it wins over the individual boxes, mirroring the policies "Everything" option. Detected CLIs stay pre-selected, so the default (hit ↵) is unchanged. (#516) +- Make the wizard's "What should we guard against?" step a multi-select, so bundles combine: tick any mix of Secrets & data / Git safety / Ship discipline / Cloud & infra and the enabled set is the **union** of their policies (deduped), or tick **Everything** for the full set (it wins over any presets). Dropped only the "Custom…" entry — an action-as-checkbox that didn't fit the list (the full searchable picker stays available via `failproofai policies --install`). Replaces the single-choice radio; `resolvePolicySource` → `resolvePresetSelection`. (#516) +- First-run onboarding: bare `failproofai` now flows setup → audit → dashboard. On the first invocation it runs the `config` wizard, then the post-setup audit, and **then boots the dashboard** (previously it exited after the wizard); every later `failproofai` goes straight to the dashboard. So a fresh install is one command: `failproofai`. The audit runs **only** on this first-run onboarding path — the explicit `failproofai config` command applies and exits without auditing. (#516) +- Run the audit pipeline automatically at the end of first-run onboarding, right before the dashboard boots. After the fresh-install setup applies, it kicks off the same scan `failproofai audit` runs — walking the agent-CLI transcript history, replaying it through the builtin policies, and pre-warming the dashboard cache (`~/.failproofai/audit-dashboard.json`) — so the user immediately sees "N patterns slipping through · M already blocked" and the dashboard renders instantly. Prints "failproofai audit now running · ctrl+c to stop" with the animated stages; the scan runs to completion and Ctrl+C interrupts it the usual way. Best-effort (never blocks or breaks a completed setup), opt-out via `FAILPROOFAI_NO_AUTO_AUDIT=1`. New exported `runPostSetupAudit()` in the audit CLI, invoked from the first-run path only. (#516) +- Add `failproofai config` (aliases `configure`, `setup`), an interactive setup launcher that replaces flag-juggling with a guided 4-step wizard: **① Where** (global vs this project) → **② Assistants** (multi-select of detected + install-ahead agent CLIs, sourced dynamically from `INTEGRATION_TYPES` so it lists all 12 current CLIs) → **③ Policies** (themed presets — Secrets & data / Git safety / Ship discipline / Cloud & infra — plus Everything or a Custom picker) → **④ Review** (shows the exact files it will change, then applies). One flow writes both the agent hook registration and the enabled-policy config at the chosen scope. Selections **replace** the enabled set at that scope (new opt-in `replace` flag on `installHooks`; existing callers keep their additive behavior). The wizard wears the befailproof.ai identity — it opens with the pixel logomark (the teal policy-flower + pink cross and bar, downscaled from the real artwork into half-block characters) over the wordmark, and the palette is pink-forward like the site: pink drives selection/enabled (caret, checkboxes, active rows, outro), teal stays the flower and the step spine (24-bit color where `COLORTERM` advertises it, basic-ANSI fallback otherwise). The clack-style flow threads a left gutter through step nodes, collapses answered steps into a persistent log (many selections summarize to a count, e.g. `10 assistants · …`), and ends on a pink └ outro. The checklist now windows to a fixed viewport with a teal `❯` caret so all 12 CLIs fit any terminal, hints ellipsize instead of hard-cutting mid-word, and choice hints align into a second column. The searchable custom-policy picker (`promptPolicySelection`, also used by `policies --install`) shares the same brand palette and layout: aligned name/description columns, dim category dividers, a live selected-count, and Enter-to-save (Space toggles). A bare `failproofai` on first run redirects into the wizard and keeps doing so until setup is completed: the `~/.failproofai/.launcher-configured` marker is written **only on a finished apply**, after which bare `failproofai` opens the dashboard (replacing and removing the old `first-run-nudge`). `postinstall` now prints a clean "run `failproofai config`" prompt. New modules `src/hooks/{configure-wizard,policy-presets,tui}.ts` and tests. (#516) - Add Goose (codename goose, Block) as the 12th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory/Devin/Antigravity: real-time policy enforcement **and** offline audit + dashboard. Goose is a **local, MCP-based** dev-agent; enforcement uses its **"hooks" system** (the cross-agent **Open Plugins** spec) — an auto-discovered plugin dir at `~/.agents/plugins/failproofai/hooks/hooks.json` (user) / `/.agents/plugins/failproofai/hooks/hooks.json` (project) whose `command` runs `failproofai --hook --cli goose`. The entire contract was verified **live against goose v1.43.0**: (1) Deny is `{"decision":"block","reason"}` JSON on stdout at exit 0 (also exit 2), honored on **`PreToolUse` ONLY** (shipped goose ≥ **v1.37.0**, PR block/goose#9304); any other error fails **open**. `PreToolUse` fires for the shell tool AND **inside delegated subagents**, so it is the single sufficient deny point. Goose has **no `Stop` event** (the 5 `require-*-before-stop` builtins are inapplicable, like Hermes) and does not honor deny on `UserPromptSubmit`/`PostToolUse`. (2) Event names are already PascalCase (no `GOOSE_EVENT_MAP`, no handler branch), but the stdin payload uses `event`/`working_dir` — the handler normalizes `working_dir`→`cwd`. (3) Tool names arrive **both** bare (`shell`, `write`, `edit`, `view`, `read_image`, `tree`, `delegate`) **and** `__` namespaced (`todo__todo_write`); `GOOSE_TOOL_MAP` covers both, and `GOOSE_TOOL_INPUT_MAP` maps path-bearing tools' `path`/`source` → `file_path`. `instruct()` degrades to allow + stderr note (no additional-context channel). The installer just **drops the plugin dir** — Goose auto-discovers it and self-registers it into `config.yaml` (no config edit needed). Install via `failproofai policies --install --cli goose` (user + project scope). Also surfaces Goose sessions in the dashboard's audit + history browser: reads the SQLite DB at `~/.local/share/goose/sessions/sessions.db` (schema_version 15; `sessions` rows carry a real `working_dir` → per-project cwd grouping like Devin; `messages.content_json` is a Claude-style typed-block array parsed via the same block model) — `session_type='hidden'` (`--no-session`) scratch runs are filtered — via `lib/goose-sessions.ts` (pure, unit-tested parser) and `lib/goose-projects.ts`, wired through `src/audit/cli-adapters/goose.ts`, `lib/cli-registry.ts` (lime badge), `lib/projects.ts`, `lib/download-session.ts` (SQLite→JSONL export), and the `app/project/[name]` routes. `GOOSE_HOME` / `GOOSE_DB_PATH` override the data dir for tests. (#508) - Add Antigravity CLI (`agy`) as the 11th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory/Devin: real-time policy enforcement **and** offline audit + dashboard. Unlike Factory/Devin, Antigravity has its **OWN** hook contract (NOT a Claude-clone), verified **live against agy v1.1.2**. (1) `hooks.json` uses a **NAMED-hook schema** — the top-level key is a hook *name* (`"failproofai"`) whose value is an event→handlers map; tool events (`PreToolUse`/`PostToolUse`) wrap handlers in `{matcher:"*", hooks:[…]}`, while `PreInvocation`/`Stop` are **flat** handler arrays. Config at `~/.gemini/config/hooks.json` (user) / `/.agents/hooks.json` (project); no `local` scope. (2) The stdin payload is **camelCase protojson** (`toolCall:{name,args}`, `conversationId`, `workspacePaths`, `transcriptPath`) — the handler normalizes it to canonical snake_case before policies run; `run_command`'s args are PascalCase (`CommandLine`/`Cwd`) → canonicalized via `ANTIGRAVITY_TOOL_INPUT_MAP`. (3) Response shapes are **Antigravity's own**: deny → `{decision:"deny", reason}` (exit 0); deny/instruct on `Stop` → `{decision:"continue", reason}` (re-enters the loop, so the 5 `require-*-before-stop` builtins enforce); instruct on `PreInvocation` (→ `UserPromptSubmit`) → `{injectSteps:[{ephemeralMessage}]}`. Adds `ANTIGRAVITY_EVENT_MAP` (`PreInvocation→UserPromptSubmit`) + `ANTIGRAVITY_TOOL_MAP` (`run_command→Bash`, `view_file→Read`, …). Install via `failproofai policies --install --cli antigravity` (user + project scope). Also surfaces Antigravity sessions in the dashboard's audit + history browser: reads the plain-JSONL transcripts at `~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl` (pairing `PLANNER_RESPONSE` tool_calls with their following result step) via `lib/antigravity-sessions.ts` (pure, unit-tested parser) and the SQLite conversation index (`conversation_summaries.db`) via `lib/antigravity-projects.ts`, wired through `src/audit/cli-adapters/antigravity.ts`, `lib/cli-registry.ts` (cyan badge), `lib/projects.ts`, `lib/download-session.ts` (real-file download), and the `app/project/[name]` routes. `ANTIGRAVITY_HOME` overrides the data dir for tests. (#508) - Add Devin CLI (`devin`, Cognition) as the 10th CLI/agent integration — **dual-pillar** like Hermes/OpenClaw/Factory: real-time policy enforcement **and** offline audit + dashboard. Devin is a **pure Claude-clone** verified **live against devin v3000.1.27** — same PascalCase event names (no `DEVIN_EVENT_MAP`, no handler branch), same snake_case stdin payload (no normalization), and the standard Claude `"hooks"`-wrapper config schema. Config lives under the `"hooks"` key of `~/.config/devin/config.json` (user) / `/.devin/config.json` (project) — merge-preserving so the file's other keys (`org_id`, `theme_mode`, …) survive. Deny is `{"decision":"block","reason"}` JSON on stdout at exit 0 for **every** event (verified — the block overrode `--permission-mode dangerous`); on `Stop` the reason carries the MANDATORY-ACTION force-retry wording, so the 5 `require-*-before-stop` builtins enforce. Adds `DEVIN_TOOL_MAP` (`exec→Bash`; `tool_input.command` already canonical). Install via `failproofai policies --install --cli devin` (user + project scope). Also surfaces Devin sessions in the dashboard's audit + history browser: reads the SQLite DB at `~/.local/share/devin/cli/sessions.db` (`sessions` table carries a real `working_directory` → per-project cwd grouping like Claude; `message_nodes.chat_message` is OpenAI-style JSON) via `lib/devin-sessions.ts` (pure, unit-tested parser) and `lib/devin-projects.ts`, wired through `src/audit/cli-adapters/devin.ts`, `lib/cli-registry.ts` (violet badge), `lib/projects.ts`, `lib/download-session.ts` (SQLite→JSONL export), and the `app/project/[name]` routes. `DEVIN_HOME` / `DEVIN_DB_PATH` override the data dir for tests. (#508) @@ -13,14 +19,18 @@ - Surface OpenClaw sessions in the dashboard's audit + history browser: reads the real JSONL transcripts at `~/.openclaw/agents//sessions/.jsonl` (skipping the heavy `.trajectory.jsonl` OTel traces) via `lib/openclaw-sessions.ts` (a pure, unit-tested type-discriminated parser that pairs assistant `toolCall` blocks with their `toolResult` by `toolCallId`) and `lib/openclaw-projects.ts` (reads the `sessions.json` index, groups by agentId into `openclaw-` projects, parses channel metadata from the sessionKey). Wired through `src/audit/cli-adapters/openclaw.ts`, `lib/cli-registry.ts` (teal badge), `lib/projects.ts`, `lib/download-session.ts` (real-file download — no synthesis needed), and the `app/project/[name]` routes. (#489) ### Fixes +- Fix Copilot CLI file policies silently allowing file access (re-verified live against Copilot CLI 1.0.71, whose hook contract drifted since the 1.0.41 verification). Copilot's snake_case hook events deliver canonical tool names but its own input keys — Read `{path}`, Write `{path, file_text}`, Edit `{path, old_str, new_str}` — so path/content builtins like `block-env-files` never fired (a live `.env` read was observed passing). Adds `COPILOT_TOOL_INPUT_MAP` mapping them to `file_path`/`content`/`old_string`/`new_string`. Also normalizes the `permissionRequest` event's camelCase payload (`toolName`/`toolInput`/`sessionId`, lowercase tool names) in the handler so PermissionRequest-matched policies (e.g. `block-sudo`'s escalation guard) fire instead of seeing a null tool name. Verified end-to-end: the previously-passing `.env` read replay is now denied, and Copilot 1.0.71 honors the deny (`code:"denied"`, tool never runs). (#516) - Make documentation translation publishing atomic: require every locale and cache artifact, pin the Mintlify validator, validate the final overlaid PR tree, and emit the canonical `pt-BR` Mintlify locale while preserving `pt-br` paths. +- Make the dashboard header responsive. On narrow windows the single-row header crushed its children: the active nav tab clipped mid-word ("policie…"), the version string wrapped mid-token ("V0.0.14-/BETA.1"), and "Reach Us" broke onto two lines. The version + section cluster now never wraps mid-token and sheds entirely below 900px (it duplicates the active-tab highlight), tab/header padding tightens, and below 620px the actions row wraps under the nav right-aligned instead of crushing it. Verified headless at 1500/760/480px. (#516) - Fix the Pi live `pi list` roundtrip e2e tests against pi-coding-agent ≥0.80: newer Pi no longer trusts project-local `.pi/settings.json` by default, so the tests now detect the installed Pi version and pass `pi list --approve` on ≥0.80 (older Pi trusts project settings without the flag and would reject it) to include the project-scope package failproofai's installer writes (previously `pi list` printed "No packages installed." — failing the install roundtrip and making the uninstall roundtrip pass vacuously). Gated behind `detectPiVersion()`, so it only runs where `pi` is installed. (#491) +- Isolate `HOME` in the e2e CLI runner so config-mutating commands no longer touch the developer's real config during `bun run test:e2e`. `cli-args.e2e.test.ts` exercises `policies --install/--uninstall` via `runCli`, which spawned the binary with the real `HOME` — so every e2e run wrote to the real `~/.failproofai/policies-config.json`. `runCli` now points `HOME`/`USERPROFILE` at a throwaway temp dir, matching how `hook-runner` and the integration-test spawns already isolate. (#516) - Fix Antigravity file writes slipping past path/content policies. `ANTIGRAVITY_TOOL_MAP` had best-effort tool names; the real ones (verified against the `agy` binary + live transcripts) are `write_to_file` (not `write_file`), `list_dir` (not `list_directory`), and `find_by_name` (not `find_filepath`), and there was **no** input-key map for file tools — so `write_to_file`'s path (delivered as `TargetFile`) never became `file_path` and `block-env-files` / `block-secrets-write` silently allowed `.env` writes on Antigravity. Corrected the tool names and added `Write`/`Edit`/`Read` input maps (`TargetFile`→`file_path`, `CodeContent`→`content`). Verified live: `agy` now denies a `.env` write. (#508) - Keep informational allow-notes out of the agent-facing hook stdout on events with no additional-context channel (`Stop`, `SubagentStop`, `Session*`, `PreCompact`, …). Previously a *passing* `Stop` emitted its allow-reasons as `{reason}` on stdout, so agents like droid rendered a "…skipping commit check…skipping PR check…" wall on a perfectly fine turn. Those notes now go to stderr + the activity store only; stdout stays clean on channel-less events. (#508) - Fix duplicated Devin session transcripts in the dashboard. Devin stores a session's messages as a **forest** (`node_id`/`parent_node_id`) and replays earlier context under fresh roots each turn, so a 10-message conversation is stored as 26-31 nodes — the parser read every node and rendered each message 2-4×. Now reconstructs the real conversation (walk `parent_node_id` from the newest leaf to its root, reversed) via `devinActiveConversationPath`. (#508) ### Docs - Restructure the docs-site navigation so the two products are top-level tabs — **Agent Observability** (AgentEye) and **Agent Enforcement** (FailproofAI) — instead of a product dropdown; each product's Docs/Examples groups move into the sidebar and the tab icons are dropped. Verified idempotent against the `translate-docs` nav regenerator and safe from the AgentEye doc-sync pipeline (which never touches `docs.json`). (#517) +- Make the README's 12-CLI logo grid responsive: replace the two inline-image paragraphs (which re-wrapped into ragged 5+1 rows on narrow windows) with a 6-column table that stays 2×6 at every width, scrolling horizontally on very narrow screens instead of collapsing. Keeps every logo's link and light/dark `` variant. (#516) - Backfill **OpenClaw** into the user-facing supported-CLI docs (README + `configuration` / `getting-started` / `introduction`) — it shipped as a wired integration but was never added to those lists. (#508) - Add all 12 supported-CLI logos to the README (added openclaw/factory/devin/antigravity/goose with light+dark variants), link each logo to its official site, and lay them out as 2 rows of 6. (#508) - Document that **VS Code Copilot agent mode** (Preview) is already covered by the `copilot` / `claude` integrations: its agent hooks load from `.github/hooks/*.json`, `~/.copilot/hooks/*.json`, and `~/.claude/settings.json` — the exact paths failproofai already writes — so `--cli copilot` (or `--cli claude`) enforces in VS Code agent-mode sessions with no dedicated `vscode` id. (#508) @@ -36,6 +46,8 @@ ## 0.0.13-beta.3 — 2026-07-13 ### Fixes +- Fix hook telemetry being dropped on the common allow path. The binary is short-lived — `bin/failproofai.mjs` calls `process.exit()` the moment `handleHookEvent` returns — so events fired with un-awaited `void trackHookEvent(...)` (`custom_hooks_loaded`, `convention_policies_loaded`, the `*_error` events) were killed mid-flight and never reached PostHog; they only survived when a `deny`/`instruct` decision happened to add a trailing `await`. `hook-telemetry.ts` now tracks every in-flight POST and exposes `flushHookTelemetry()`, which `handleHookEvent` awaits before returning (and `bin`'s hook error path drains on throw), so all fired events are delivered reliably regardless of decision. No change to *which* events fire — allow decisions still send nothing by design. (#516) +- Include the failure reason in audit failure telemetry. `cli_audit_failed` (CLI) and `audit_run_failed` (dashboard) previously carried only `error_type` (the error's class name, e.g. `"TypeError"`); they now also send `error_message` — the actual failure text, home-directory-stripped to `~` and length-capped via a shared `sanitizeErrorMessage()` helper (`lib/telemetry-sanitize.ts`) so no local paths leak. (#516) - Fix the Pi live `pi list` roundtrip e2e tests against pi-coding-agent ≥0.80: newer Pi no longer trusts project-local `.pi/settings.json` by default, so the tests now detect the installed Pi version and pass `pi list --approve` on ≥0.80 (older Pi trusts project settings without the flag and would reject it) to include the project-scope package failproofai's installer writes (previously `pi list` printed "No packages installed." — failing the install roundtrip and making the uninstall roundtrip pass vacuously). Gated behind `detectPiVersion()`, so it only runs where `pi` is installed. (#491) ### Docs diff --git a/CLAUDE.md b/CLAUDE.md index 384737ec..ec5d2914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,35 @@ subscribe to it for parity with `agentStop`). Ref: +**1.0.71 contract re-verification** (2026-07-16, live recorder-hook captures — +Copilot's schema drifted since the 1.0.41 verification above): + +- **Config**: `hooks` must be an **object** keyed by event. Older installed + files were rejected wholesale (`[ERROR] Invalid hook configuration …: hooks + must be an object`) and the session ran **unhooked** — silently, no user-visible + warning. Reinstalling with the current `writeHookEntries` schema fixes it. +- **Payloads**: the snake_case events (PreToolUse/PostToolUse/Stop/…) are + Claude-shaped and deliver `tool_name` **already canonical** (`Bash`, `Read`, + `Write`, `Edit`, `Grep`) — but the file tools use Copilot's own input keys: + Read `{path}`, Write `{path, file_text}`, Edit `{path, old_str, new_str}`. + `COPILOT_TOOL_INPUT_MAP` (keyed by canonical name) maps them to + `file_path`/`content`/`old_string`/`new_string`; without it a live `.env` + read passed block-env-files. Bash `{command}` and Grep `{pattern}` are canonical. +- **`permissionRequest` is the camelCase exception**: `{hookName, sessionId, + timestamp:number, cwd, toolName:"bash" (lowercase), toolInput, + permissionSuggestions}`. `handler.ts` has a `cli === "copilot"` branch + normalizing `toolName`/`toolInput`/`sessionId`; the lowercase names go + through `COPILOT_TOOL_MAP` as before. +- **Deny still works**: the Claude `{hookSpecificOutput:{permissionDecision: + "deny"}}` shape is honored on PreToolUse — verified live (`success:false, + code:"denied"` in session events; the sudo never ran). Stop gates + (`require-*-before-stop`) also verified firing + forcing retries on 1.0.71. +- **Caveat**: in headless `copilot -p` runs from a fresh directory, the + project-scope `.github/hooks/*.json` file was NOT loaded (git repo or not); + the user-scope `~/.copilot/hooks/*.json` always loaded. Likely a + trusted-folder gate — treat user scope as the reliable enforcement point for + headless/CI usage until verified otherwise. + ### Cursor hooks (`.cursor/hooks.json`) This repo also ships a `.cursor/hooks.json` for Cursor Agent CLI sessions, diff --git a/README.md b/README.md index 7d909470..543dff7f 100644 --- a/README.md +++ b/README.md @@ -25,80 +25,99 @@ before they become incidents. Zero latency. Runs locally. ## Supported agent CLIs -

- - Claude Code - -      - - - - OpenAI Codex - - -      - - - - GitHub Copilot - - -      - - - - Cursor Agent - - -      - - - - OpenCode - - -      - - - - Pi - - -

-

- - - - Hermes - - -      - - OpenClaw - -      - - - - Factory Droid - - -      - - Devin CLI - -      - - Antigravity CLI - -      - - - - Goose - - -

+ + + + + + + + + + + + + + + + + + +
+ + Claude Code + + + + + + OpenAI Codex + + + + + + + GitHub Copilot + + + + + + + Cursor Agent + + + + + + + OpenCode + + + + + + + Pi + + +
+ + + + Hermes + + + + + OpenClaw + + + + + + Factory Droid + + + + + Devin CLI + + + + Antigravity CLI + + + + + + Goose + + +
> Install hooks for one or any combination: `failproofai policies --install --cli opencode pi` (or `--cli claude codex copilot cursor opencode pi hermes openclaw factory devin antigravity goose`). Omit `--cli` to auto-detect installed CLIs and prompt. > diff --git a/__tests__/api/audit-run-route.test.ts b/__tests__/api/audit-run-route.test.ts index 3bf89ff8..01dee652 100644 --- a/__tests__/api/audit-run-route.test.ts +++ b/__tests__/api/audit-run-route.test.ts @@ -95,6 +95,10 @@ describe("POST /api/audit/run (fire-and-forget)", () => { expect(s.error).toBe("scan blew up"); expect(writeCacheMock).not.toHaveBeenCalled(); expect(trackedNames()).toContain("audit_run_failed"); + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_failed", + expect.objectContaining({ error_type: "Error", error_message: "scan blew up" }), + ); }); it("writes the cache, tracks audit_run_completed with metrics, and clears the lock on success", async () => { diff --git a/__tests__/audit/audit-cli-telemetry.test.ts b/__tests__/audit/audit-cli-telemetry.test.ts index a865cfe6..f000423c 100644 --- a/__tests__/audit/audit-cli-telemetry.test.ts +++ b/__tests__/audit/audit-cli-telemetry.test.ts @@ -122,6 +122,7 @@ describe("failproofai audit telemetry", () => { expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_failed", { source: "cli", error_type: "TypeError", + error_message: "disk exploded", }); expect(exitInfo?.code).toBe(1); // The fix: failed must have RESOLVED before the exit fired. diff --git a/__tests__/e2e/helpers/cli-runner.ts b/__tests__/e2e/helpers/cli-runner.ts index d40f5379..fcb74595 100644 --- a/__tests__/e2e/helpers/cli-runner.ts +++ b/__tests__/e2e/helpers/cli-runner.ts @@ -7,11 +7,30 @@ import { spawnSync } from "node:child_process"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { existsSync } from "node:fs"; -import { expect } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { afterAll, expect } from "vitest"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +// Isolated HOME so config-mutating commands (`policies --install/--uninstall`, +// `policy add/remove`, `configure`) resolve `~/.failproofai` and `~/.claude` +// under a throwaway dir instead of the developer's real home. Without this, any +// runCli call that reaches installHooks/removeHooks clobbers the real user +// config (Bun honors the spawn-env HOME, so overriding it here fully isolates). +// Created once per test module; the OS reclaims it from tmp. +const ISOLATED_HOME = mkdtempSync(resolve(tmpdir(), "failproofai-cli-e2e-")); + +// Remove the throwaway HOME after the importing suite finishes so repeated e2e +// runs don't accumulate config artifacts in tmp (mkdtempSync leaves it behind). +afterAll(() => { + try { + rmSync(ISOLATED_HOME, { recursive: true, force: true }); + } catch { + // best-effort — the OS reclaims tmp regardless + } +}); + function getBinaryPath(): string { return resolve(REPO_ROOT, "bin/failproofai.mjs"); } @@ -37,6 +56,8 @@ export function runCli(...args: string[]): CliRunResult { const result = spawnSync("bun", [binaryPath, ...args], { env: { ...process.env, + HOME: ISOLATED_HOME, + USERPROFILE: ISOLATED_HOME, FAILPROOFAI_TELEMETRY_DISABLED: "1", }, encoding: "utf8", diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts new file mode 100644 index 00000000..91e358af --- /dev/null +++ b/__tests__/hooks/configure-wizard.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +// The interactive prompts, the install manager, telemetry and CLI detection are +// mocked so we can drive the wizard head-lessly and assert the exact side effect +// (the installHooks call) without touching the filesystem or a real TTY. +vi.mock("../../src/hooks/tui", async (importOriginal) => { + const actual = await importOriginal(); + // Keep the pure helpers (summarize, ellipsize) real; stub only the interactive prompts. + return { ...actual, selectOne: vi.fn(), multiSelect: vi.fn(), intro: vi.fn(), outro: vi.fn() }; +}); +vi.mock("../../src/hooks/manager", () => ({ installHooks: vi.fn(async () => {}) })); +// The wizard kicks off the audit pipeline after a completed apply; stub it so +// tests never scan real history. +vi.mock("../../src/audit/cli", () => ({ runPostSetupAudit: vi.fn(async () => {}) })); +vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: vi.fn(async () => {}) })); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-id") })); +vi.mock("../../src/hooks/integrations", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, detectInstalledClis: vi.fn(() => ["claude"]) }; +}); + +import { selectOne, multiSelect, type TTYIn, type TTYOut } from "../../src/hooks/tui"; +import { installHooks } from "../../src/hooks/manager"; +import { + buildScopeChoices, + buildAgentChoices, + buildPresetChoices, + resolvePresetSelection, + reviewLines, + runConfigureWizard, + maybeFirstRunConfigure, + hasSeenLauncher, + markLauncherSeen, +} from "../../src/hooks/configure-wizard"; +import { resolvePreset, resolveEverything } from "../../src/hooks/policy-presets"; +import { INTEGRATION_TYPES } from "../../src/hooks/types"; +import { runPostSetupAudit } from "../../src/audit/cli"; + +const mkTtyStdin = (): TTYIn => ({ isTTY: true }) as unknown as TTYIn; +const mkTtyStdout = (): TTYOut => + ({ isTTY: true, write: vi.fn(() => true), columns: 80 }) as unknown as TTYOut; +const ttyIO = () => ({ stdin: mkTtyStdin(), stdout: mkTtyStdout() }); + +// The wizard's apply path calls markLauncherSeen(), which writes under +// homedir()/.failproofai — isolate HOME for the whole file so no test ever +// touches the developer's real config. +let fileHome: string; +let realHome: string | undefined; +beforeAll(() => { + realHome = process.env.HOME; + fileHome = mkdtempSync(resolve(tmpdir(), "fpai-cfg-")); + process.env.HOME = fileHome; +}); +afterAll(() => { + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; + try { + rmSync(fileHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +beforeEach(() => { + vi.mocked(selectOne).mockReset(); + vi.mocked(multiSelect).mockReset(); + vi.mocked(installHooks).mockClear(); + vi.mocked(runPostSetupAudit).mockClear(); +}); + +describe("configure-wizard pure builders", () => { + it("buildScopeChoices offers global (user) and project only", () => { + const choices = buildScopeChoices("/tmp/proj"); + expect(choices.map((c) => c.value)).toEqual(["user", "project"]); + }); + + it("buildPresetChoices lists the presets plus Everything (no Custom)", () => { + const values = buildPresetChoices().map((c) => c.value); + expect(values).toEqual(["secrets", "git", "ship", "infra", "__everything__"]); + }); + + it("resolvePresetSelection returns a single preset's policies", () => { + expect(resolvePresetSelection(["git"])).toEqual(resolvePreset("git")); + }); + + it("resolvePresetSelection unions multiple selected presets (deduped)", () => { + const combined = resolvePresetSelection(["secrets", "git"]); + // Concrete behavior, not a re-derivation of the implementation: one known + // policy from each bundle is present, and nothing is duplicated. + expect(combined).toContain("sanitize-api-keys"); // from "secrets" + expect(combined).toContain("block-force-push"); // from "git" + expect(new Set(combined).size).toBe(combined.length); + }); + + it("resolvePresetSelection returns the full set when Everything is ticked (wins over presets)", () => { + expect(resolvePresetSelection(["__everything__"])).toEqual(resolveEverything()); + expect(resolvePresetSelection(["git", "__everything__"])).toEqual(resolveEverything()); + }); + + it("buildAgentChoices pre-checks detected CLIs and sections the rest", () => { + const choices = buildAgentChoices("user", "/tmp/proj"); + const claude = choices.find((c) => c.value === "claude"); + expect(claude?.checked).toBe(true); + expect(claude?.section).toBe("Detected"); + const codex = choices.find((c) => c.value === "codex"); + expect(codex?.section).toBe("Not installed · set up ahead of time"); + // every integration is represented (sourced dynamically from INTEGRATION_TYPES) + expect(choices.length).toBe(INTEGRATION_TYPES.length); + }); + + it("reviewLines summarizes scope, assistants, policy count and target files", () => { + const lines = reviewLines({ + scope: "user", + clis: ["claude"], + policies: ["block-sudo", "block-rm-rf"], + cwd: "/tmp/proj", + }).join("\n"); + expect(lines).toContain("Everywhere (global)"); + expect(lines).toContain("Claude Code"); + expect(lines).toContain("2 enabled"); + expect(lines).toContain("policies-config.json"); + expect(lines).toContain("settings.json"); + }); +}); + +describe("configure-wizard orchestration", () => { + it("applies the union of selected presets, REPLACING the enabled set", async () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("apply"); // review + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) // assistants + .mockResolvedValueOnce(["secrets", "git"]); // policy sources (multi-select) + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + const call = vi.mocked(installHooks).mock.calls[0]; + const policies = call[0] as string[]; + expect(policies).toContain("sanitize-api-keys"); // from "secrets" + expect(policies).toContain("block-force-push"); // from "git" + expect(new Set(policies).size).toBe(policies.length); // deduped union + expect(call[1]).toBe("user"); // scope + expect(call[4]).toBe("configure-wizard"); // source tag + expect(call[7]).toEqual(["claude"]); // clis + expect(call[8]).toEqual({ replace: true, quiet: true }); // options + }); + + it("'Everything available' protects every supported CLI", async () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("apply"); // review + vi.mocked(multiSelect) + .mockResolvedValueOnce(["__all_clis__"]) // assistants → Everything available + .mockResolvedValueOnce(["git"]); // policy sources + await runConfigureWizard(ttyIO()); + const call = vi.mocked(installHooks).mock.calls[0]; + expect(call[7]).toEqual([...INTEGRATION_TYPES]); // all CLIs, regardless of detection + }); + + it("cancelling at the review step makes no changes", async () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("cancel"); // review → cancel + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) // assistants + .mockResolvedValueOnce(["git"]); // policy sources + const result = await runConfigureWizard(ttyIO()); + expect(result.applied).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + }); + + it("cancelling at the scope step makes no changes", async () => { + vi.mocked(selectOne).mockResolvedValueOnce(null); // scope → quit + const result = await runConfigureWizard(ttyIO()); + expect(result.applied).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + }); + + it("returns guidance and does nothing in a non-TTY context", async () => { + const stdout = mkTtyStdout(); + const result = await runConfigureWizard({ + stdin: { isTTY: false } as unknown as TTYIn, + stdout, + }); + expect(result.applied).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + }); +}); + +describe("first-run redirect", () => { + let origHome: string | undefined; + let tmp: string; + + beforeEach(() => { + origHome = process.env.HOME; + delete process.env.FAILPROOFAI_NO_FIRST_RUN; + tmp = mkdtempSync(resolve(tmpdir(), "fpai-firstrun-")); + process.env.HOME = tmp; + }); + + afterEach(() => { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + it("does nothing when FAILPROOFAI_NO_FIRST_RUN=1", async () => { + process.env.FAILPROOFAI_NO_FIRST_RUN = "1"; + const handled = await maybeFirstRunConfigure(ttyIO()); + expect(handled).toBe(false); + expect(selectOne).not.toHaveBeenCalled(); + delete process.env.FAILPROOFAI_NO_FIRST_RUN; + }); + + it("prints a hint but does not redirect in a non-TTY context", async () => { + const stdout = mkTtyStdout(); + const handled = await maybeFirstRunConfigure({ + stdin: { isTTY: false } as unknown as TTYIn, + stdout, + }); + expect(handled).toBe(false); + const written = vi.mocked(stdout.write).mock.calls.map((c) => String(c[0])).join(""); + expect(written).toContain("failproofai config"); + expect(hasSeenLauncher()).toBe(false); // not marked in non-TTY + }); + + it("runs the wizard on a fresh first run but does NOT mark seen if cancelled", async () => { + vi.mocked(selectOne).mockResolvedValueOnce(null); // wizard cancels immediately + const handled = await maybeFirstRunConfigure(ttyIO()); + expect(handled).toBe(true); // it took over the turn (no dashboard) + expect(hasSeenLauncher()).toBe(false); // cancelled → not marked → redirects again next time + expect(existsSync(resolve(tmp, ".failproofai", ".launcher-configured"))).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + expect(runPostSetupAudit).not.toHaveBeenCalled(); // no apply → no auto-audit + }); + + it("marks the launcher seen only after a completed apply", async () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("apply"); // review → apply + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) // assistants + .mockResolvedValueOnce(["git"]); // policy sources + const handled = await maybeFirstRunConfigure(ttyIO()); + expect(handled).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(hasSeenLauncher()).toBe(true); + expect(runPostSetupAudit).toHaveBeenCalledTimes(1); // completed apply → auto-audit handoff + }); + + it("does not redirect again once the launcher has been seen", async () => { + markLauncherSeen(); // simulate a prior completed setup + const handled = await maybeFirstRunConfigure(ttyIO()); + expect(handled).toBe(false); + expect(selectOne).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/copilot-canonicalize.test.ts b/__tests__/hooks/copilot-canonicalize.test.ts new file mode 100644 index 00000000..16293d5a --- /dev/null +++ b/__tests__/hooks/copilot-canonicalize.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node +// +// Locks in Copilot CLI tool-INPUT canonicalization (verified live against +// Copilot CLI 1.0.71). The snake_case hook events deliver `tool_name` already +// canonical (`Bash`, `Read`, `Write`, `Edit`, `Grep`), but the file tools' +// input keys are Copilot's own: `path` instead of `file_path`, Write's content +// as `file_text`, Edit's strings as `old_str`/`new_str`. Without the map, a +// live `.env` read was observed sailing past block-env-files. +// +// Fixtures below are verbatim captures from a real 1.0.71 session +// (recorder hook, 2026-07-16). +import { describe, it, expect } from "vitest"; +import { canonicalizeToolName, canonicalizeToolInput } from "@/src/hooks/tool-name-canonicalize"; +import { COPILOT_TOOL_INPUT_MAP } from "@/src/hooks/types"; + +describe("Copilot tool-input canonicalization (verified 1.0.71 captures)", () => { + it("maps Read's `path` to `file_path` (the .env-read bypass)", () => { + const captured = { path: "/home/user/project/.env" }; + expect(canonicalizeToolInput("Read", captured, "copilot")).toEqual({ + file_path: "/home/user/project/.env", + }); + }); + + it("maps Write's `path`/`file_text` to `file_path`/`content`", () => { + const captured = { path: "/home/user/project/demo.txt", file_text: "hello" }; + expect(canonicalizeToolInput("Write", captured, "copilot")).toEqual({ + file_path: "/home/user/project/demo.txt", + content: "hello", + }); + }); + + it("maps Edit's `path`/`old_str`/`new_str` to canonical keys", () => { + const captured = { path: "/home/user/project/demo.txt", old_str: "hello", new_str: "world" }; + expect(canonicalizeToolInput("Edit", captured, "copilot")).toEqual({ + file_path: "/home/user/project/demo.txt", + old_string: "hello", + new_string: "world", + }); + }); + + it("leaves Bash input untouched (`command` is already canonical)", () => { + const captured = { command: "sudo whoami", description: "Run sudo whoami" }; + expect(canonicalizeToolInput("Bash", captured, "copilot")).toEqual(captured); + }); + + it("leaves Grep input untouched (`pattern` is already canonical)", () => { + const captured = { pattern: "world", output_mode: "files_with_matches" }; + expect(canonicalizeToolInput("Grep", captured, "copilot")).toEqual(captured); + }); + + it("passes canonical PreToolUse tool names through unchanged", () => { + // 1.0.71's snake_case events send canonical names — COPILOT_TOOL_MAP's + // lowercase entries must not mangle them. + for (const name of ["Bash", "Read", "Write", "Edit", "Grep"]) { + expect(canonicalizeToolName(name, "copilot")).toBe(name); + } + }); + + it("still canonicalizes lowercase names (permissionRequest + older CLIs)", () => { + // permissionRequest delivers `toolName: "bash"` (lowercase) even on 1.0.71. + expect(canonicalizeToolName("bash", "copilot")).toBe("Bash"); + expect(canonicalizeToolName("view", "copilot")).toBe("Read"); + }); + + it("only maps the three file tools (no accidental key rewrites elsewhere)", () => { + expect(Object.keys(COPILOT_TOOL_INPUT_MAP).sort()).toEqual(["Edit", "Read", "Write"]); + }); +}); diff --git a/__tests__/hooks/first-run-nudge.test.ts b/__tests__/hooks/first-run-nudge.test.ts deleted file mode 100644 index e5008bd9..00000000 --- a/__tests__/hooks/first-run-nudge.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -// @vitest-environment node -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { PassThrough } from "node:stream"; - -vi.mock("../../src/hooks/integrations", () => ({ - detectInstalledClis: vi.fn(), - getIntegration: vi.fn(), -})); - -vi.mock("../../src/hooks/manager", () => ({ - installHooks: vi.fn(async () => undefined), -})); - -vi.mock("../../src/hooks/hook-telemetry", () => ({ - trackHookEvent: vi.fn(async () => undefined), -})); - -vi.mock("../../lib/telemetry-id", () => ({ - getInstanceId: vi.fn(() => "test-distinct-id"), -})); - -function makeIntegration(displayName: string, scopes: readonly string[], installed: boolean) { - return { - id: displayName.toLowerCase(), - displayName, - scopes, - eventTypes: [], - getSettingsPath: vi.fn(), - readSettings: vi.fn(), - writeSettings: vi.fn(), - buildHookEntry: vi.fn(), - isFailproofaiHook: vi.fn(), - writeHookEntries: vi.fn(), - removeHooksFromFile: vi.fn(), - hooksInstalledInSettings: vi.fn(() => installed), - detectInstalled: vi.fn(), - }; -} - -interface IO { - stdin: PassThrough & { isTTY?: boolean }; - stdout: PassThrough & { isTTY?: boolean }; - output: string; -} - -function makeIO(isTTY: boolean): IO { - const stdin = new PassThrough() as PassThrough & { isTTY?: boolean }; - const stdout = new PassThrough() as PassThrough & { isTTY?: boolean }; - stdin.isTTY = isTTY; - stdout.isTTY = isTTY; - let output = ""; - stdout.on("data", (chunk) => { - output += chunk.toString(); - }); - const io = { stdin, stdout } as IO; - Object.defineProperty(io, "output", { get: () => output }); - return io; -} - -async function importModule() { - return await import("../../src/hooks/first-run-nudge"); -} - -async function importMocks() { - const integrations = await import("../../src/hooks/integrations"); - const manager = await import("../../src/hooks/manager"); - const telemetry = await import("../../src/hooks/hook-telemetry"); - return { integrations, manager, telemetry }; -} - -describe("hooks/first-run-nudge", () => { - let exitSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - delete process.env.FAILPROOFAI_NO_FIRST_RUN; - exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`exit:${code ?? 0}`); - }) as never); - }); - - afterEach(() => { - exitSpy.mockRestore(); - }); - - it("returns immediately when FAILPROOFAI_NO_FIRST_RUN=1 (no detection, no telemetry)", async () => { - process.env.FAILPROOFAI_NO_FIRST_RUN = "1"; - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - - await maybeRunFirstRunNudge(makeIO(true)); - - expect(integrations.detectInstalledClis).not.toHaveBeenCalled(); - expect(manager.installHooks).not.toHaveBeenCalled(); - expect(telemetry.trackHookEvent).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it("returns when no CLIs are detected", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue([]); - - await maybeRunFirstRunNudge(makeIO(true)); - - expect(manager.installHooks).not.toHaveBeenCalled(); - expect(telemetry.trackHookEvent).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it("returns when any detected CLI already has hooks installed in any scope", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude", "codex"] as never); - const claudeInt = makeIntegration("Claude Code", ["user", "project", "local"], false); - const codexInt = makeIntegration("Codex", ["user", "project"], true); - vi.mocked(integrations.getIntegration).mockImplementation( - (id: string) => (id === "claude" ? claudeInt : codexInt) as never, - ); - - await maybeRunFirstRunNudge(makeIO(true)); - - expect(manager.installHooks).not.toHaveBeenCalled(); - expect(telemetry.trackHookEvent).not.toHaveBeenCalled(); - }); - - it("non-TTY: prints hint and fires _skipped_noninteractive", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never); - vi.mocked(integrations.getIntegration).mockReturnValue( - makeIntegration("Claude Code", ["user"], false) as never, - ); - - const io = makeIO(false); - await maybeRunFirstRunNudge(io); - - expect(io.output).toContain("No policies are installed"); - expect(io.output).toContain("Launching dashboard"); - expect(manager.installHooks).not.toHaveBeenCalled(); - expect(telemetry.trackHookEvent).toHaveBeenCalledWith( - "test-distinct-id", - "first_run_nudge_skipped_noninteractive", - { detected_clis: ["claude"], detected_count: 1 }, - ); - }); - - it("TTY accept (Y): fires _shown then _accepted, calls installHooks with detected CLIs, exits 0", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude", "codex"] as never); - const intMap: Record> = { - claude: makeIntegration("Claude Code", ["user"], false), - codex: makeIntegration("Codex", ["user"], false), - }; - vi.mocked(integrations.getIntegration).mockImplementation( - (id: string) => intMap[id] as never, - ); - - const io = makeIO(true); - setTimeout(() => io.stdin.write("y\n"), 10); - - await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:0"); - - expect(io.output).toContain("Failproof AI — first-run setup"); - expect(io.output).toContain("Claude Code, Codex"); - - const calls = vi.mocked(telemetry.trackHookEvent).mock.calls; - const events = calls.map((c) => c[1]); - expect(events).toEqual(["first_run_nudge_shown", "first_run_nudge_accepted"]); - expect(calls[1][2]).toMatchObject({ - detected_clis: ["claude", "codex"], - detected_count: 2, - target_scope: "user", - source: "first-run-nudge", - }); - - expect(manager.installHooks).toHaveBeenCalledWith( - undefined, - "user", - undefined, - false, - "first-run-nudge", - undefined, - false, - ["claude", "codex"], - ); - }); - - it("TTY accept on empty Enter (default Y): runs installHooks", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never); - vi.mocked(integrations.getIntegration).mockReturnValue( - makeIntegration("Claude Code", ["user"], false) as never, - ); - - const io = makeIO(true); - setTimeout(() => io.stdin.write("\n"), 10); - - await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:0"); - expect(manager.installHooks).toHaveBeenCalled(); - }); - - it("TTY decline (n): fires _declined with reason user_no, does NOT call installHooks, does NOT exit", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never); - vi.mocked(integrations.getIntegration).mockReturnValue( - makeIntegration("Claude Code", ["user"], false) as never, - ); - - const io = makeIO(true); - setTimeout(() => io.stdin.write("n\n"), 10); - - await maybeRunFirstRunNudge(io); - - const events = vi.mocked(telemetry.trackHookEvent).mock.calls.map((c) => c[1]); - expect(events).toEqual(["first_run_nudge_shown", "first_run_nudge_declined"]); - const declined = vi - .mocked(telemetry.trackHookEvent) - .mock.calls.find((c) => c[1] === "first_run_nudge_declined")?.[2]; - expect(declined).toMatchObject({ reason: "user_no" }); - expect(manager.installHooks).not.toHaveBeenCalled(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it("TTY SIGINT: fires _declined with reason sigint and exits 130", async () => { - // Mock readline so we can trigger the SIGINT handler directly — emulating - // ^C through a PassThrough is brittle across Node versions. - vi.doMock("node:readline", () => ({ - createInterface: () => { - const handlers: Record void> = {}; - return { - on: (ev: string, cb: () => void) => { - handlers[ev] = cb; - }, - question: (_q: string, _cb: () => void) => { - setImmediate(() => handlers["SIGINT"]?.()); - }, - close: () => {}, - }; - }, - })); - - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager, telemetry } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never); - vi.mocked(integrations.getIntegration).mockReturnValue( - makeIntegration("Claude Code", ["user"], false) as never, - ); - - const io = makeIO(true); - await expect(maybeRunFirstRunNudge(io)).rejects.toThrow("exit:130"); - - const declined = vi - .mocked(telemetry.trackHookEvent) - .mock.calls.find((c) => c[1] === "first_run_nudge_declined")?.[2]; - expect(declined).toMatchObject({ reason: "sigint" }); - expect(manager.installHooks).not.toHaveBeenCalled(); - vi.doUnmock("node:readline"); - }); - - it("survives a broken integration.hooksInstalledInSettings (treats it as not-installed)", async () => { - const { maybeRunFirstRunNudge } = await importModule(); - const { integrations, manager } = await importMocks(); - vi.mocked(integrations.detectInstalledClis).mockReturnValue(["claude"] as never); - const broken = makeIntegration("Claude Code", ["user"], false); - broken.hooksInstalledInSettings = vi.fn(() => { - throw new Error("boom"); - }); - vi.mocked(integrations.getIntegration).mockReturnValue(broken as never); - - const io = makeIO(true); - setTimeout(() => io.stdin.write("n\n"), 10); - - await maybeRunFirstRunNudge(io); - - expect(manager.installHooks).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 41be695b..32b9c11d 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -36,6 +36,7 @@ vi.mock("../../src/hooks/hook-activity-store", () => ({ vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), })); vi.mock("../../lib/telemetry-id", () => ({ @@ -147,6 +148,60 @@ describe("hooks/handler", () => { ); }); + it("normalizes Copilot's camelCase permissionRequest payload (verified 1.0.71 capture)", async () => { + // Copilot's snake_case events are Claude-shaped, but permissionRequest + // alone pipes camelCase with a lowercase tool name. Without normalization + // the handler sees a null tool name and PermissionRequest policies no-op. + mockStdin( + JSON.stringify({ + hookName: "permissionRequest", + sessionId: "e5740815-966c-4c4e-8c51-153a8f1fa466", + timestamp: 1784186747245, + cwd: "/home/user/project", + toolName: "bash", + toolInput: { command: "sudo whoami" }, + permissionSuggestions: [], + }), + ); + const { persistHookActivity } = await import("../../src/hooks/hook-activity-store"); + + await handleHookEvent("PermissionRequest", "copilot"); + + expect(persistHookActivity).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "PermissionRequest", + integration: "copilot", + toolName: "Bash", + sessionId: "e5740815-966c-4c4e-8c51-153a8f1fa466", + }), + ); + }); + + it("flushes pending telemetry before returning, even on the allow path", async () => { + // The allow path fires no awaited event, so without an explicit flush the + // caller's process.exit() would drop un-awaited (`void`) load/error events. + mockStdin(); + const { flushHookTelemetry } = await import("../../src/hooks/hook-telemetry"); + + await handleHookEvent("PreToolUse"); + + expect(flushHookTelemetry).toHaveBeenCalled(); + }); + + it("flushes pending telemetry even when policy evaluation throws (finally path)", async () => { + // A throw before the happy-path flush (e.g. policy eval / custom-hook load) + // must still drain pending error telemetry — the handler's try/finally + // guarantees it, so the caller's process.exit() can't drop in-flight POSTs. + mockStdin(); + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockRejectedValueOnce(new Error("policy eval boom")); + const { flushHookTelemetry } = await import("../../src/hooks/hook-telemetry"); + + await expect(handleHookEvent("PreToolUse")).rejects.toThrow("policy eval boom"); + + expect(flushHookTelemetry).toHaveBeenCalled(); + }); + it("persists deny decision when evaluator returns deny", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/__tests__/hooks/hook-telemetry.test.ts b/__tests__/hooks/hook-telemetry.test.ts index 81a05ecb..071d3705 100644 --- a/__tests__/hooks/hook-telemetry.test.ts +++ b/__tests__/hooks/hook-telemetry.test.ts @@ -5,7 +5,7 @@ * fetch body here guarantees `product: failproofai-oss` is stamped on all of them. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { trackHookEvent } from "../../src/hooks/hook-telemetry"; +import { trackHookEvent, flushHookTelemetry } from "../../src/hooks/hook-telemetry"; describe("hook-telemetry trackHookEvent", () => { let fetchSpy: ReturnType; @@ -48,3 +48,61 @@ describe("hook-telemetry trackHookEvent", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + +describe("flushHookTelemetry", () => { + let fetchSpy: ReturnType; + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.FAILPROOFAI_TELEMETRY_DISABLED; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + process.env = { ...originalEnv }; + }); + + it("resolves instantly when nothing is pending", async () => { + fetchSpy = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchSpy); + await expect(flushHookTelemetry()).resolves.toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("awaits an un-awaited (void) event so the POST completes before exit", async () => { + // Control when the fetch settles to prove flush actually waits for it. + let resolveFetch!: (r: Response) => void; + fetchSpy = vi.fn( + () => new Promise((res) => { resolveFetch = res; }), + ); + vi.stubGlobal("fetch", fetchSpy); + + // Fire WITHOUT awaiting — this is exactly how the handler's load/error + // events are sent (`void trackHookEvent(...)`). + void trackHookEvent("inst-id", "custom_hooks_loaded", { n: 1 }); + await Promise.resolve(); // let the pending promise register + expect(fetchSpy).toHaveBeenCalledOnce(); + + let flushed = false; + const flushP = flushHookTelemetry().then(() => { flushed = true; }); + + // Fetch is still in flight → flush must not have resolved yet. + await Promise.resolve(); + expect(flushed).toBe(false); + + // Once the POST settles, flush drains and resolves. + resolveFetch(new Response("{}", { status: 200 })); + await flushP; + expect(flushed).toBe(true); + }); + + it("does not throw when an in-flight POST rejects", async () => { + fetchSpy = vi.fn().mockRejectedValue(new Error("network down")); + vi.stubGlobal("fetch", fetchSpy); + + void trackHookEvent("inst-id", "custom_hooks_loaded"); + await Promise.resolve(); + + await expect(flushHookTelemetry()).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/hooks/policy-presets.test.ts b/__tests__/hooks/policy-presets.test.ts new file mode 100644 index 00000000..96279e3c --- /dev/null +++ b/__tests__/hooks/policy-presets.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { BUILTIN_POLICIES } from "../../src/hooks/builtin-policies"; +import { + POLICY_PRESETS, + resolvePreset, + resolveEverything, +} from "../../src/hooks/policy-presets"; + +describe("policy-presets", () => { + it("exposes the four themed presets in wizard order", () => { + expect(POLICY_PRESETS.map((p) => p.id)).toEqual(["secrets", "git", "ship", "infra"]); + }); + + it("every preset resolves to at least one real builtin policy", () => { + const known = new Set(BUILTIN_POLICIES.map((p) => p.name)); + for (const preset of POLICY_PRESETS) { + const resolved = resolvePreset(preset.id); + expect(resolved.length).toBeGreaterThan(0); + for (const name of resolved) expect(known.has(name)).toBe(true); + } + }); + + it("secrets preset covers Sanitize + Environment + block-secrets-write, not git", () => { + const r = resolvePreset("secrets"); + expect(r).toContain("sanitize-api-keys"); + expect(r).toContain("protect-env-vars"); + expect(r).toContain("block-env-files"); + expect(r).toContain("block-read-outside-cwd"); + expect(r).toContain("block-secrets-write"); + expect(r).not.toContain("block-force-push"); + }); + + it("git preset is exactly the Git category", () => { + const gitNames = BUILTIN_POLICIES.filter((p) => !p.beta && p.category === "Git").map((p) => p.name); + expect(new Set(resolvePreset("git"))).toEqual(new Set(gitNames)); + }); + + it("ship preset is the require-*-before-stop workflow policies", () => { + const r = resolvePreset("ship"); + expect(r).toContain("require-commit-before-stop"); + expect(r).toContain("require-push-before-stop"); + expect(r).toContain("require-ci-green-before-stop"); + }); + + it("infra preset blocks the cloud/infra CLIs", () => { + const r = resolvePreset("infra"); + expect(r).toContain("block-kubectl"); + expect(r).toContain("block-terraform"); + expect(r).toContain("block-aws-cli"); + }); + + it("resolveEverything returns all non-beta builtins", () => { + const expected = BUILTIN_POLICIES.filter((p) => !p.beta).map((p) => p.name); + expect(resolveEverything().length).toBe(expected.length); + expect(new Set(resolveEverything())).toEqual(new Set(expected)); + }); + + it("unknown preset id resolves to empty", () => { + expect(resolvePreset("does-not-exist")).toEqual([]); + }); +}); diff --git a/__tests__/hooks/tui.test.ts b/__tests__/hooks/tui.test.ts new file mode 100644 index 00000000..0115f5ae --- /dev/null +++ b/__tests__/hooks/tui.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import { + selectOne, + multiSelect, + ellipsize, + summarize, + type TTYIn, + type TTYOut, +} from "../../src/hooks/tui"; + +const mkStdin = (): TTYIn => ({ isTTY: false }) as unknown as TTYIn; +const mkStdout = (): TTYOut => + ({ isTTY: false, write: vi.fn(() => true), columns: 80 }) as unknown as TTYOut; + +describe("tui non-TTY fallbacks", () => { + it("selectOne returns the first choice value when not a TTY", async () => { + const value = await selectOne({ + message: "t", + choices: [ + { label: "A", value: "a" }, + { label: "B", value: "b" }, + ], + stdin: mkStdin(), + stdout: mkStdout(), + }); + expect(value).toBe("a"); + }); + + it("selectOne returns null with no choices and no TTY", async () => { + const value = await selectOne({ + message: "t", + choices: [], + stdin: mkStdin(), + stdout: mkStdout(), + }); + expect(value).toBeNull(); + }); + + it("multiSelect returns the pre-checked values when not a TTY", async () => { + const value = await multiSelect({ + message: "t", + choices: [ + { label: "X", value: "x", checked: true }, + { label: "Y", value: "y" }, + { label: "Z", value: "z", checked: true }, + ], + stdin: mkStdin(), + stdout: mkStdout(), + }); + expect(value).toEqual(["x", "z"]); + }); + + it("multiSelect returns [] when nothing pre-checked and no TTY", async () => { + const value = await multiSelect({ + message: "t", + choices: [ + { label: "X", value: "x" }, + { label: "Y", value: "y" }, + ], + stdin: mkStdin(), + stdout: mkStdout(), + }); + expect(value).toEqual([]); + }); +}); + +describe("tui text helpers", () => { + it("ellipsize leaves short text untouched", () => { + expect(ellipsize("hello", 10)).toBe("hello"); + expect(ellipsize("hello", 5)).toBe("hello"); + }); + + it("ellipsize ends on a single ellipsis instead of a mid-word cut", () => { + const out = ellipsize("Redact secrets in tool output", 12); + expect(out).toHaveLength(12); + expect(out.endsWith("…")).toBe(true); + expect(out).not.toContain("……"); + }); + + it("ellipsize handles degenerate widths", () => { + expect(ellipsize("anything", 0)).toBe(""); + expect(ellipsize("anything", 1)).toBe("…"); + }); + + it("summarize joins a few labels verbatim", () => { + expect(summarize([], "assistants")).toBe("none"); + expect(summarize(["Claude Code"], "assistants")).toBe("Claude Code"); + expect(summarize(["A", "B", "C"], "assistants")).toBe("A, B, C"); + }); + + it("summarize collapses many labels to a count plus a head", () => { + const out = summarize(["A", "B", "C", "D", "E"], "assistants"); + expect(out).toBe("5 assistants · A, B, C +2"); + }); +}); diff --git a/__tests__/lib/telemetry-sanitize.test.ts b/__tests__/lib/telemetry-sanitize.test.ts new file mode 100644 index 00000000..1d82ca95 --- /dev/null +++ b/__tests__/lib/telemetry-sanitize.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment node +import { describe, it, expect, vi, afterEach } from "vitest"; +import os from "node:os"; +import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize"; + +describe("sanitizeErrorMessage", () => { + afterEach(() => vi.restoreAllMocks()); + + it("returns the message for a plain Error", () => { + expect(sanitizeErrorMessage(new Error("disk exploded"))).toBe("disk exploded"); + }); + + it("stringifies a non-Error throw", () => { + expect(sanitizeErrorMessage("raw string failure")).toBe("raw string failure"); + }); + + it("collapses the user's home directory to ~", () => { + vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); + expect(sanitizeErrorMessage(new Error("cannot read /home/alice/.codex/x.jsonl"))).toBe( + "cannot read ~/.codex/x.jsonl", + ); + }); + + it("replaces every occurrence of the home path", () => { + vi.spyOn(os, "homedir").mockReturnValue("/home/alice"); + expect( + sanitizeErrorMessage(new Error("/home/alice/a vs /home/alice/b")), + ).toBe("~/a vs ~/b"); + }); + + it("does not mangle messages when homedir is root", () => { + vi.spyOn(os, "homedir").mockReturnValue("/"); + expect(sanitizeErrorMessage(new Error("/etc/passwd missing"))).toBe("/etc/passwd missing"); + }); + + it("truncates long messages to 300 chars + ellipsis", () => { + const long = "x".repeat(500); + const out = sanitizeErrorMessage(new Error(long)); + expect(out.length).toBe(301); // 300 + the "…" marker + expect(out.endsWith("…")).toBe(true); + }); + + it("returns empty string for an empty message", () => { + expect(sanitizeErrorMessage(new Error(""))).toBe(""); + }); +}); diff --git a/__tests__/scripts/postinstall.test.ts b/__tests__/scripts/postinstall.test.ts index ce0ae9eb..b956f553 100644 --- a/__tests__/scripts/postinstall.test.ts +++ b/__tests__/scripts/postinstall.test.ts @@ -118,10 +118,10 @@ describe("postinstall script", () => { }); describe("brand-new user (no config, no settings)", () => { - it("prints the Next steps block", async () => { + it("prints the configure setup prompt", async () => { await runPostinstall({ hooksConfigExists: false }); - expect(allLogs()).toContain("Next steps"); - expect(allLogs()).toContain("failproofai policies --install"); + expect(allLogs()).toContain("Installed"); + expect(allLogs()).toContain("failproofai config"); expect(allLogs()).toContain("FAILPROOFAI_NO_FIRST_RUN=1"); }); diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts index 28e69cbe..85b125fa 100644 --- a/app/api/audit/run/route.ts +++ b/app/api/audit/run/route.ts @@ -18,6 +18,7 @@ import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; import type { RunAuditOptions } from "@/src/audit/types"; import { finishRun, tryAcquireRun } from "../_state"; import { initTelemetry, trackEvent } from "@/lib/telemetry"; +import { sanitizeErrorMessage } from "@/lib/telemetry-sanitize"; export const dynamic = "force-dynamic"; @@ -129,6 +130,7 @@ export async function POST(request: NextRequest): Promise { source: "dashboard", duration_ms: Date.now() - startedAt, error_type: err instanceof Error ? err.name : "unknown", + error_message: sanitizeErrorMessage(err), }); finishRun(err instanceof Error ? err.message : String(err)); } diff --git a/app/globals.css b/app/globals.css index 737305ba..a9d2082e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -243,7 +243,31 @@ input[type="date"] { color-scheme: dark; } text-transform: uppercase; color: var(--accent-green); } -.h-actions { display: flex; align-items: center; gap: 8px; } +.h-actions { display: flex; align-items: center; gap: 8px; flex: none; } + +/* header meta cluster (version + section label) — never wrap mid-token */ +.h-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; } +.h-version { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--dim); + white-space: nowrap; +} + +/* Header responsiveness: progressively shed informational chrome instead of + crushing it. The version/section cluster duplicates the active tab, so it + goes first; below that the actions row wraps under the nav cleanly. */ +@media (max-width: 900px) { + .app-header { padding: 12px 16px; gap: 12px; } + .h-meta { display: none; } + .app-header .tab { padding: 12px 10px; } +} +@media (max-width: 620px) { + .app-header { flex-wrap: wrap; row-gap: 8px; } + .h-actions { margin-left: auto; } +} .btn { display: inline-flex; align-items: center; gap: 8px; diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index b7490cc0..ac69f942 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -36,6 +36,9 @@ const args = process.argv.slice(2); // Normalize 'p' → 'policies' (shorthand alias) if (args[0] === "p") args[0] = "policies"; +// Normalize 'configure' / 'setup' → 'config' (aliases), so every later check +// (SUBCOMMANDS, dispatch) mentions only the canonical name. +if (args[0] === "configure" || args[0] === "setup") args[0] = "config"; // Lightweight telemetry helper for CLI lifecycle events. Lazy-loads to avoid // pulling in the hook-telemetry / telemetry-id modules on the fast --hook path. @@ -93,6 +96,8 @@ if (hookIdx >= 0) { try { const { handleHookEvent } = await import("../src/hooks/handler"); const exitCode = await handleHookEvent(eventType, cli); + // handleHookEvent already flushes its own telemetry before returning; this + // is the normal, reliable exit. process.exit(exitCode); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -101,6 +106,13 @@ if (hookIdx >= 0) { cli, error_type: err instanceof Error ? err.name : "unknown", }); + // handleHookEvent threw before its own flush ran, so any events it fired + // with `void trackHookEvent(...)` are still in flight — drain them (plus the + // hook_dispatch_error above) before exiting so they aren't dropped. + try { + const { flushHookTelemetry } = await import("../src/hooks/hook-telemetry"); + await flushHookTelemetry(); + } catch {} console.error(`Unexpected error: ${msg}`); process.exit(2); } @@ -113,7 +125,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "policy", "auth", "audit"]; + const SUBCOMMANDS = ["policies", "policy", "auth", "audit", "config"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -127,6 +139,7 @@ USAGE COMMANDS (no args) Launch the policy dashboard + config Interactive setup — pick scope, agents & policies policy add Enable a single policy (see \`policy --help\`) policy remove Disable a single policy @@ -657,6 +670,42 @@ EXAMPLES process.exit(0); } + // config — the interactive setup launcher (scope, agents, policies). + // `configure` and `setup` are canonicalized to "config" up top. Running it + // explicitly does NOT run the post-setup audit (that only fires on first-run + // onboarding via bare `failproofai`). + if (args[0] === "config") { + if (args.includes("--help") || args.includes("-h")) { + console.log(` +failproofai config — interactive setup + +USAGE + failproofai config Guided setup: choose scope, agents, and policies + failproofai configure Alias for config + failproofai setup Alias for config + +WHAT IT DOES + Walks you through 4 quick steps and writes everything for you: + 1. Where — global (all projects) or just this project + 2. Assistants — which agent CLIs to protect (Claude, Codex, ...) + 3. Policies — presets (combine any), Everything, or a custom pick + 4. Review — confirms the exact files it will change, then applies + + Prefer flags? See \`failproofai policies --help\`. +`.trimStart()); + process.exit(0); + } + lastSubcommand = "config"; + const { runConfigureWizard } = await import("../src/hooks/configure-wizard"); + const result = await runConfigureWizard(); + await track("cli_configure_invoked", { + applied: result.applied, + scope: result.scope ?? null, + cli_count: result.clis?.length ?? 0, + }); + process.exit(0); + } + // Unknown flag guard — must appear after all known-flag branches const knownFlags = ["--version", "-v", "--help", "-h", "--hook"]; const unknownFlag = args.find(a => a.startsWith("-") && !knownFlags.includes(a)); @@ -698,18 +747,22 @@ EXAMPLES ); } - // First-run nudge — only on truly bare `failproofai` invocations. Best-effort: - // any thrown error must not block the dashboard from launching. + // First-run onboarding — on the first bare `failproofai` invocation, run the + // configure wizard (which also fires the post-setup audit) BEFORE the + // dashboard. Unlike before, we then fall through to launch the dashboard, so a + // fresh user gets: setup → audit → dashboard, and every later `failproofai` + // goes straight to the dashboard. Best-effort: any error must not block launch. if (args.length === 0) { try { - const { maybeRunFirstRunNudge } = await import("../src/hooks/first-run-nudge"); - await maybeRunFirstRunNudge(); + const { maybeFirstRunConfigure } = await import("../src/hooks/configure-wizard"); + await maybeFirstRunConfigure(); } catch { - // Nudge is non-critical; fall through to dashboard. + // First-run onboarding is non-critical; fall through to the dashboard. } } - // Dashboard launch — always production mode + // Dashboard launch — always production mode. Runs on every bare `failproofai` + // (after first-run onboarding, if any). const { launch } = await import("../scripts/launch"); launch("start"); } diff --git a/components/navbar.tsx b/components/navbar.tsx index a9a01b57..eeeea097 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -109,19 +109,13 @@ export const Navbar: React.FC<{ {/* Spacer pushes version/section + actions to the right */}
- {/* Version + section label — swapped to right of nav */} + {/* Version + section label — swapped to right of nav. `.h-meta` never + wraps mid-token and is hidden entirely on narrow viewports (it + duplicates the active tab highlight). */} {(process.env.NEXT_PUBLIC_APP_VERSION || sectionLabel) && ( -
+
{process.env.NEXT_PUBLIC_APP_VERSION && ( - + v{process.env.NEXT_PUBLIC_APP_VERSION} )} diff --git a/components/reach-developers.tsx b/components/reach-developers.tsx index a62d5a2a..32af376e 100644 --- a/components/reach-developers.tsx +++ b/components/reach-developers.tsx @@ -66,7 +66,7 @@ export const ReachDevelopers: React.FC = () => { className="relative z-50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground" > - Reach Us + Reach Us diff --git a/lib/telemetry-sanitize.ts b/lib/telemetry-sanitize.ts new file mode 100644 index 00000000..a4900fe1 --- /dev/null +++ b/lib/telemetry-sanitize.ts @@ -0,0 +1,43 @@ +import os from "node:os"; + +/** Max length of a telemetry error message; longer strings are truncated. */ +const MAX_ERROR_MESSAGE_LEN = 300; + +/** + * Turn an error into a telemetry-safe message string for the `error_message` + * property on failure events (e.g. `cli_audit_failed`, `audit_run_failed`). + * + * Two protections keep this consistent with failproofai's telemetry philosophy + * (no raw local paths / PII on the wire): + * 1. The user's home directory is collapsed to `~`, so an error that embeds a + * path like `/home/alice/.codex/…` is reported as `~/.codex/…`. + * 2. The message is truncated to a bounded length (with an ellipsis marker). + * + * Never throws — falls back to `"unknown"` if the error can't be stringified. + */ +export function sanitizeErrorMessage(err: unknown): string { + let msg: string; + try { + msg = err instanceof Error ? err.message : String(err); + } catch { + return "unknown"; + } + if (!msg) return ""; + + let home = ""; + try { + home = os.homedir(); + } catch { + /* homedir unavailable — skip path stripping */ + } + // Only strip a real, non-root home to avoid mangling messages when homedir is + // "/" or empty. + if (home && home.length > 1) { + msg = msg.split(home).join("~"); + } + + if (msg.length > MAX_ERROR_MESSAGE_LEN) { + msg = msg.slice(0, MAX_ERROR_MESSAGE_LEN) + "…"; + } + return msg; +} diff --git a/scripts/launch.ts b/scripts/launch.ts index df1a164d..456a3543 100644 --- a/scripts/launch.ts +++ b/scripts/launch.ts @@ -9,19 +9,15 @@ import { createInterface } from "node:readline"; import { parseScriptArgs } from "./parse-script-args"; import { diagnoseShadow } from "./install-diagnosis.mjs"; import { makeSkewLogFilter } from "./skew-log-filter"; +import { renderLaunchBanner } from "../src/hooks/tui"; import { version } from "../package.json"; export function launch(mode: "dev" | "start"): void { const { loggingLevel, disableTelemetry, allowedDevOrigins, remainingArgs } = parseScriptArgs(process.argv.slice(2)); - // Plain-text title + a labeled `Version` line that lines up with the - // `Star us` / `Docs` / `Discord` lines below (all four labels pad to the - // same column so the values form a clean right-hand column). - console.log(`\n failproof ai\n`); - console.log(` 📦 Version: ${version}`); - console.log(` ⭐ Star us: https://github.com/failproofai/failproofai`); - console.log(` 📖 Docs: https://docs.befailproof.ai/introduction`); - console.log(` 💬 Discord: https://discord.gg/2zjBZP7yQJ\n`); + // Branded splash — the same logomark + palette as the `configure` wizard, then + // a tidy version/links column. Falls back to plain text off a TTY. + for (const line of renderLaunchBanner(version)) console.log(line); let cmd: string; let cmdArgs: string[]; diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index 7cb0df8e..60280eda 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -144,10 +144,9 @@ try { if (!hooksResult.configured && !hooksResult.registered) { console.log( - `\n[failproofai] Installed. Next steps:\n` + - ` 1. Run \`failproofai policies --install\` to enable safety policies.\n` + - ` 2. Run \`failproofai\` to open the dashboard (or just \`failproofai\` to start now — it'll offer to set up policies for you).\n` + - ` Disable first-run prompt: FAILPROOFAI_NO_FIRST_RUN=1\n` + `\n[failproofai] Installed ✓\n` + + ` 👋 Hi! Run \`failproofai config\` to set it up.\n` + + ` (Disable this prompt: FAILPROOFAI_NO_FIRST_RUN=1)\n` ); } diff --git a/src/audit/cli.ts b/src/audit/cli.ts index 66653c9c..a4ed2855 100644 --- a/src/audit/cli.ts +++ b/src/audit/cli.ts @@ -20,6 +20,7 @@ import { writeDashboardCache } from "./dashboard-cache"; import type { AuditResult, RunAuditOptions } from "./types"; import { trackHookEvent } from "../hooks/hook-telemetry"; import { getInstanceId } from "../../lib/telemetry-id"; +import { sanitizeErrorMessage } from "../../lib/telemetry-sanitize"; import { openWhenReady } from "./open-browser"; /** Port the bundled dashboard binds to. Matches `scripts/launch.ts`'s default @@ -236,6 +237,47 @@ function printSummary(result: AuditResult): void { for (const line of buildSummary(result)) process.stdout.write(` ${line}\n`); } +// ── Post-setup background audit ──────────────────────────────────────────────── + +/** + * Run the audit *pipeline* (scan + cache write + summary) once the setup flow + * completes, right before the dashboard boots. Pre-warms + * `~/.failproofai/audit-dashboard.json` so the dashboard renders instantly, and + * immediately shows the user what's slipping through. + * + * Shows the same animated stages as `failproofai audit`. The scan runs to + * completion; Ctrl+C interrupts it the usual way (default SIGINT). Best-effort: + * never throws, never exits the process; the caller boots the dashboard + * afterward. Opt out with `FAILPROOFAI_NO_AUTO_AUDIT=1`. + */ +export async function runPostSetupAudit(): Promise { + if (process.env.FAILPROOFAI_NO_AUTO_AUDIT === "1") return; + + process.stdout.write( + `\n ${c(PINK, "✦")} ${c(BOLD, "failproofai audit now running")} ${c(DIM, "· ctrl+c to stop")}\n\n`, + ); + + let result: AuditResult; + try { + result = await runWithProgress({}); + } catch { + process.stdout.write( + ` ${c(PINK, "!")} ${c(DIM, "audit couldn't finish — run")} ${c(CYAN, "failproofai audit")} ${c(DIM, "later.")}\n\n`, + ); + return; + } + + if (result.eventsScanned === 0) { + process.stdout.write( + `\n ${c(DIM, "no agent sessions to audit yet — come back after using your agent.")}\n\n`, + ); + return; + } + writeDashboardCache({}, result); + printSummary(result); + process.stdout.write("\n"); +} + // ── Entry point ────────────────────────────────────────────────────────────── export async function runAuditCli(args: string[]): Promise { @@ -275,6 +317,7 @@ export async function runAuditCli(args: string[]): Promise { await trackHookEvent(instanceId, "cli_audit_failed", { source: "cli", error_type: err instanceof Error ? err.name : "unknown", + error_message: sanitizeErrorMessage(err), }); die(`Audit failed: ${err instanceof Error ? err.message : String(err)}`); } diff --git a/src/hooks/configure-wizard.ts b/src/hooks/configure-wizard.ts new file mode 100644 index 00000000..69288e7c --- /dev/null +++ b/src/hooks/configure-wizard.ts @@ -0,0 +1,356 @@ +/** + * `failproofai config` — the interactive setup launcher. + * + * A single guided flow that sets up the whole failproofai ecosystem, hiding the + * scope / cli / two-layer machinery behind three plain questions: + * 1. Where? global (user) vs this project + * 2. Assistants? multi-select of agent CLIs (detected + install-ahead) + * 3. Policies? multi-select of themed presets (combine any) or Everything + * …then a Review screen that shows exactly which files change, and Apply. + * + * Selections REPLACE the enabled set at the chosen scope (the picker pre-checks + * whatever is already enabled, so unticking removes). Reuses the tested + * install/uninstall manager and the existing searchable policy picker. + */ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve, sep } from "node:path"; + +import { selectOne, multiSelect, intro, outro, summarize, type TTYIn, type TTYOut } from "./tui"; +import { + detectInstalledClis, + getIntegration, +} from "./integrations"; +import { INTEGRATION_TYPES, type IntegrationType, type HookScope } from "./types"; +import { installHooks } from "./manager"; +import { getConfigPathForScope } from "./hooks-config"; +import { POLICY_PRESETS, resolvePreset, resolveEverything } from "./policy-presets"; +import { trackHookEvent } from "./hook-telemetry"; +import { getInstanceId } from "../../lib/telemetry-id"; + +export interface WizardIO { + stdin?: TTYIn; + stdout?: TTYOut; +} + +export interface WizardResult { + applied: boolean; + scope?: HookScope; + clis?: IntegrationType[]; + policies?: string[]; +} + +async function emit(event: string, props: Record): Promise { + try { + await trackHookEvent(getInstanceId(), event, props); + } catch { + // best-effort — never break the wizard + } +} + +/** Replace ~ prefix with the literal home dir path for readable review output. */ +function homeify(p: string): string { + const home = homedir(); + // Require a path boundary so `/home/alice-work` isn't collapsed to `~-work` + // for a home of `/home/alice`. + if (p === home) return "~"; + if (p.startsWith(home + sep)) return "~" + p.slice(home.length); + return p; +} + +// ── Pure builders (exported for tests) ─────────────────────────────────────── + +export function buildScopeChoices(cwd: string) { + return [ + { + label: "Everywhere I code", + value: "user" as HookScope, + hint: "global · applies in every project on this machine", + }, + { + label: "Just this project", + value: "project" as HookScope, + hint: homeify(cwd), + }, + ]; +} + +export function buildAgentChoices(scope: HookScope, cwd: string) { + const detected = new Set(detectInstalledClis()); + // Detected first, then the rest as "install ahead of time". + const ordered = [ + ...INTEGRATION_TYPES.filter((id) => detected.has(id)), + ...INTEGRATION_TYPES.filter((id) => !detected.has(id)), + ]; + return ordered.map((id) => { + const integration = getIntegration(id); + const isDetected = detected.has(id); + let installedHere = false; + try { + installedHere = integration.hooksInstalledInSettings(scope, cwd); + } catch { + installedHere = false; + } + return { + label: integration.displayName, + value: id, + checked: isDetected || installedHere, + section: isDetected ? "Detected" : "Not installed · set up ahead of time", + hint: installedHere ? "already configured" : isDetected ? undefined : "not on PATH", + }; + }); +} + +const EVERYTHING = "__everything__"; +const ALL_CLIS = "__all_clis__"; + +/** The themed preset bundles for the wizard's multi-select, plus an "Everything" + * option that enables the full builtin policy set. */ +export function buildPresetChoices() { + const choices: Array<{ label: string; value: string; hint: string }> = POLICY_PRESETS.map( + (p) => ({ label: p.label, value: p.id, hint: p.description }), + ); + choices.push({ + label: "Everything", + value: EVERYTHING, + hint: `all ${resolveEverything().length} policies`, + }); + return choices; +} + +/** + * Resolve the ticked options to a concrete policy set. Presets are additive — + * the deduped union of every selected preset's policies — while "Everything" + * enables the full policy set and wins over any presets. + */ +export function resolvePresetSelection(values: string[]): string[] { + if (values.includes(EVERYTHING)) return resolveEverything(); + return [...new Set(values.flatMap((id) => resolvePreset(id)))]; +} + +export function reviewLines( + state: { scope: HookScope; clis: IntegrationType[]; policies: string[]; cwd: string }, +): string[] { + const { scope, clis, policies, cwd } = state; + const where = + scope === "project" ? `This project (${homeify(cwd)})` : "Everywhere (global)"; + const lines: string[] = []; + const assistantNames = clis.map((c) => getIntegration(c).displayName); + lines.push(` Where : ${where}`); + lines.push(` Assistants : ${assistantNames.length ? summarize(assistantNames, "assistants") : "(none)"}`); + lines.push(` Policies : ${policies.length} enabled`); + lines.push(""); + lines.push(" This will update:"); + for (const cli of clis) { + const integration = getIntegration(cli); + lines.push(` ${homeify(integration.getSettingsPath(scope, cwd))} ${integration.displayName} hooks`); + } + lines.push(` ${homeify(getConfigPathForScope(scope, cwd))} ${policies.length} policies`); + return lines; +} + +// ── First-run redirect ─────────────────────────────────────────────────────── + +function firstRunMarkerPath(): string { + return resolve(homedir(), ".failproofai", ".launcher-configured"); +} + +export function hasSeenLauncher(): boolean { + return existsSync(firstRunMarkerPath()); +} + +export function markLauncherSeen(): void { + try { + mkdirSync(resolve(homedir(), ".failproofai"), { recursive: true }); + writeFileSync(firstRunMarkerPath(), "1", "utf8"); + } catch { + // best-effort + } +} + +/** + * Whether failproofai is already set up GLOBALLY (user scope) for any agent. + * Deliberately ignores project scope: project-scoped hooks in whatever repo the + * user happens to be in shouldn't suppress the one-time global welcome. The + * marker file is the primary "seen" gate; this is the "already set up" shortcut. + */ +function anyHooksInstalledGlobally(): boolean { + for (const id of INTEGRATION_TYPES) { + try { + if (getIntegration(id).hooksInstalledInSettings("user")) return true; + } catch { + // ignore broken settings files + } + } + return false; +} + +/** + * On the FIRST bare `failproofai` invocation, redirect the user into the + * configure wizard instead of the dashboard. Returns true when it handled the + * turn (caller should exit rather than launch the dashboard). + * + * • FAILPROOFAI_NO_FIRST_RUN=1 → never redirect + * • already seen the launcher → never redirect again + * • hooks already installed → mark seen, go to dashboard (already set up) + * • non-TTY (CI/pipe) → print a one-line hint, go to dashboard + * • fresh + TTY → mark seen, run the wizard, done + */ +export async function maybeFirstRunConfigure(io: WizardIO = {}): Promise { + if (process.env.FAILPROOFAI_NO_FIRST_RUN === "1") return false; + if (hasSeenLauncher()) return false; + + const stdin: TTYIn = io.stdin ?? process.stdin; + const stdout: TTYOut = io.stdout ?? process.stdout; + + if (anyHooksInstalledGlobally()) { + markLauncherSeen(); + return false; + } + + if (!stdin.isTTY || !stdout.isTTY) { + stdout.write( + `\n[failproofai] Not set up yet — run \`failproofai config\` to get started.\n\n`, + ); + return false; + } + + // Fire-and-forget: never block the wizard's first paint on telemetry. + void emit("first_run_configure_shown", {}); + // runConfigureWizard marks the launcher as seen only if the user completes an + // apply — so cancelling keeps redirecting here on the next bare run, and only + // a finished setup sends the user to the dashboard afterwards. + const result = await runConfigureWizard(io); + + // Onboarding-only: after a completed first-run setup, run the audit pipeline + // (scan + cache warm) before the caller boots the dashboard. The explicit + // `failproofai config` command does NOT do this — only this first-run path. + // Lazy-imported + best-effort; opt out with FAILPROOFAI_NO_AUTO_AUDIT=1. + if (result.applied) { + try { + const { runPostSetupAudit } = await import("../audit/cli"); + await runPostSetupAudit(); + } catch { + // the audit is a bonus — never let it break onboarding or the dashboard. + } + } + return true; +} + +// ── The wizard ─────────────────────────────────────────────────────────────── + +export async function runConfigureWizard(io: WizardIO = {}): Promise { + const stdin: TTYIn = io.stdin ?? process.stdin; + const stdout: TTYOut = io.stdout ?? process.stdout; + const cwd = process.cwd(); + + if (!stdin.isTTY || !stdout.isTTY) { + stdout.write( + "failproofai config needs an interactive terminal.\n" + + "Use the flag form instead, e.g.:\n" + + " failproofai policies --install --scope user --cli claude\n", + ); + return { applied: false }; + } + + // Fire-and-forget: never block the wizard's first paint on telemetry. + void emit("configure_started", {}); + intro("let's set up your safety net", stdout); + + const cancel = (): WizardResult => { + outro("Cancelled — nothing was changed.", { ok: false }, stdout); + return { applied: false }; + }; + + // 1 — Where? + const scope = await selectOne({ + message: "Where should this apply?", + choices: buildScopeChoices(cwd), + stdin, + stdout, + }); + if (scope === null) return cancel(); + + // 2 — Which assistants? An "Everything available" row protects every supported + // CLI (detected + set-up-ahead); when ticked it wins over the individual boxes. + const clisSel = await multiSelect({ + message: "Which AI assistants should it protect?", + choices: [ + { + label: "Everything available", + value: ALL_CLIS, + hint: `protect all ${INTEGRATION_TYPES.length} supported CLIs`, + }, + ...buildAgentChoices(scope, cwd), + ], + minSelected: 1, + summaryNoun: "assistants", + hint: "detected CLIs are pre-selected · space toggles · ctrl+a all · ↵ confirm", + stdin, + stdout, + }); + if (clisSel === null) return cancel(); + const clis: IntegrationType[] = clisSel.includes(ALL_CLIS) + ? [...INTEGRATION_TYPES] + : (clisSel.filter((v) => v !== ALL_CLIS) as IntegrationType[]); + + // 3 — Which policies? Multi-select of themed presets — additive, so the + // enabled set is the union of every ticked bundle. + const presets = await multiSelect({ + message: "What should we guard against?", + choices: buildPresetChoices(), + minSelected: 1, + summaryNoun: "bundles", + hint: "space toggles · combine presets · ↵ confirm", + stdin, + stdout, + }); + if (presets === null) return cancel(); + const policies = resolvePresetSelection(presets); + + // 4 — Review & apply + const decision = await selectOne<"apply" | "cancel">({ + message: "Ready to apply?", + body: reviewLines({ scope, clis, policies, cwd }), + choices: [ + { label: "Yes, apply now", value: "apply", hint: "write the config" }, + { label: "Cancel", value: "cancel", hint: "quit, no changes" }, + ], + stdin, + stdout, + }); + if (decision !== "apply") return cancel(); + + // Apply — REPLACE the enabled set at this scope. + // Telemetry runs concurrently with the install (never rejects, 5s-bounded) so + // it doesn't add dead time between "apply" and the config actually writing, + // while still being awaited before the process can exit. + const applied = emit("configure_applied", { + scope, + cli: clis, + cli_count: clis.length, + policy_count: policies.length, + source: presets.join("+"), + }); + // quiet: the wizard renders its own outro; replace: the chosen set becomes + // the full enabled set at this scope (unticking removes). + await installHooks( + policies, + scope, + cwd, + /* includeBeta */ false, + "configure-wizard", + /* customPoliciesPath */ undefined, + /* removeCustomHooks */ false, + clis, + { replace: true, quiet: true }, + ); + await applied; + // Only now — a completed apply — is the launcher considered "seen", so the + // first-run bare invocation stops redirecting here and opens the dashboard. + markLauncherSeen(); + + const agentNames = clis.map((c) => getIntegration(c).displayName).join(", "); + outro(`Setup complete — ${policies.length} policies guarding ${agentNames}.`, { ok: true }, stdout); + return { applied: true, scope, clis, policies }; +} diff --git a/src/hooks/first-run-nudge.ts b/src/hooks/first-run-nudge.ts deleted file mode 100644 index 9000a525..00000000 --- a/src/hooks/first-run-nudge.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * First-run nudge for `failproofai` (no-args invocation). - * - * Fires when a user runs the bare CLI without having installed any policies on - * any detected agent CLI. PostHog data showed only ~10% of npm-installed users - * ran `failproofai policies --install`; this prompt closes the awareness gap - * by offering to run that install inline. - * - * The hooks themselves are the sentinel — if any are installed for any - * detected CLI, we never prompt again. No separate state file needed. - * - * Honors `FAILPROOFAI_NO_FIRST_RUN=1` for opt-out, falls back to a short - * stderr hint in non-TTY contexts (CI, piped invocations). - */ -import * as readline from "node:readline"; - -import { detectInstalledClis, getIntegration } from "./integrations"; -import { installHooks } from "./manager"; -import { trackHookEvent } from "./hook-telemetry"; -import { getInstanceId } from "../../lib/telemetry-id"; -import type { IntegrationType } from "./types"; - -type TTYIn = NodeJS.ReadableStream & { isTTY?: boolean }; -type TTYOut = NodeJS.WritableStream & { isTTY?: boolean }; - -export interface FirstRunNudgeOptions { - stdin?: TTYIn; - stdout?: TTYOut; -} - -async function emit(event: string, props: Record): Promise { - try { - await trackHookEvent(getInstanceId(), event, props); - } catch { - // Telemetry must never break first-run UX. - } -} - -function anyHooksInstalled(detected: IntegrationType[]): boolean { - for (const id of detected) { - const integration = getIntegration(id); - for (const scope of integration.scopes) { - try { - if (integration.hooksInstalledInSettings(scope)) return true; - } catch { - // A broken settings file shouldn't suppress the nudge. - } - } - } - return false; -} - -function clisLabel(detected: IntegrationType[]): string { - return detected.map((id) => getIntegration(id).displayName).join(", "); -} - -async function promptYesNo( - stdin: TTYIn, - stdout: TTYOut, -): Promise<"yes" | "no" | "sigint"> { - return new Promise((resolve) => { - const rl = readline.createInterface({ input: stdin, output: stdout }); - let settled = false; - const finish = (answer: "yes" | "no" | "sigint") => { - if (settled) return; - settled = true; - rl.close(); - resolve(answer); - }; - rl.on("SIGINT", () => finish("sigint")); - rl.question("Install policies now? [Y/n] ", (raw) => { - const a = (raw ?? "").trim().toLowerCase(); - if (a === "" || a === "y" || a === "yes") finish("yes"); - else finish("no"); - }); - }); -} - -export async function maybeRunFirstRunNudge(opts: FirstRunNudgeOptions = {}): Promise { - if (process.env.FAILPROOFAI_NO_FIRST_RUN === "1") return; - - const stdin: TTYIn = opts.stdin ?? process.stdin; - const stdout: TTYOut = opts.stdout ?? process.stdout; - - let detected: IntegrationType[]; - try { - detected = detectInstalledClis(); - } catch { - return; - } - if (detected.length === 0) return; - - if (anyHooksInstalled(detected)) return; - - const detectedProps = { detected_clis: detected, detected_count: detected.length }; - - if (!stdin.isTTY || !stdout.isTTY) { - stdout.write( - `\n[failproofai] No policies are installed. Run \`failproofai policies --install\` to set them up.\n` + - `[failproofai] Launching dashboard…\n\n`, - ); - await emit("first_run_nudge_skipped_noninteractive", detectedProps); - return; - } - - stdout.write( - `\n┌─ Failproof AI — first-run setup ────────────────────────────────────\n` + - `│ Detected agent CLI(s): ${clisLabel(detected)}\n` + - `│ Policies block unsafe actions (sudo, rm -rf /, secret leaks, …)\n` + - `│ before your agent runs them. Nothing is installed yet.\n` + - `└──────────────────────────────────────────────────────────────────────\n\n` + - ` Disable this prompt anytime: FAILPROOFAI_NO_FIRST_RUN=1\n\n`, - ); - - await emit("first_run_nudge_shown", detectedProps); - - const answer = await promptYesNo(stdin, stdout); - - if (answer === "sigint") { - await emit("first_run_nudge_declined", { ...detectedProps, reason: "sigint" }); - process.exit(130); - } - - if (answer === "no") { - await emit("first_run_nudge_declined", { ...detectedProps, reason: "user_no" }); - return; - } - - await emit("first_run_nudge_accepted", { - ...detectedProps, - target_scope: "user", - source: "first-run-nudge", - }); - - await installHooks( - undefined, - "user", - undefined, - false, - "first-run-nudge", - undefined, - false, - detected, - ); - process.exit(0); -} diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 7ee80770..a7856bab 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -33,7 +33,7 @@ import { clearPolicies, registerPolicy } from "./policy-registry"; import { loadAllCustomHooks } from "./custom-hooks-loader"; import type { CustomHook } from "./policy-types"; import { persistHookActivity } from "./hook-activity-store"; -import { trackHookEvent } from "./hook-telemetry"; +import { trackHookEvent, flushHookTelemetry } from "./hook-telemetry"; import { resolveCwd } from "./resolve-cwd"; import { resolvePermissionMode } from "./resolve-permission-mode"; import { resolveTranscriptPath } from "./resolve-transcript-path"; @@ -92,269 +92,295 @@ export async function handleHookEvent( cli: IntegrationType = "claude", ): Promise { const startTime = performance.now(); - - // Read stdin payload (Claude passes JSON) - const MAX_STDIN_BYTES = 1_048_576; // 1 MB - let payload = ""; - let stdinOversized = false; try { - payload = await new Promise((resolve, reject) => { - const chunks: string[] = []; - let totalBytes = 0; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk: string) => { - totalBytes += Buffer.byteLength(chunk); - if (totalBytes > MAX_STDIN_BYTES) { - hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`); - stdinOversized = true; - process.stdin.destroy(); - resolve(""); - return; - } - chunks.push(chunk); - }); - process.stdin.on("end", () => resolve(chunks.join(""))); - process.stdin.on("error", reject); - // If stdin is already closed or not piped, resolve immediately - if (process.stdin.readableEnded) resolve(""); - }); - } catch (err) { - hookLogWarn(`stdin read failed for ${eventType}`); - void trackHookEvent(getInstanceId(), "hook_stdin_error", { - event_type: eventType, - cli, - error_type: err instanceof Error ? err.name : "unknown", - }); - } - if (stdinOversized) { - void trackHookEvent(getInstanceId(), "hook_stdin_error", { - event_type: eventType, - cli, - error_type: "oversized", - }); - } - let parsed: Record = {}; - if (payload) { + // Read stdin payload (Claude passes JSON) + const MAX_STDIN_BYTES = 1_048_576; // 1 MB + let payload = ""; + let stdinOversized = false; try { - parsed = JSON.parse(payload) as Record; - } catch { - hookLogWarn(`payload parse failed for ${eventType} (${payload.length} bytes)`); - void trackHookEvent(getInstanceId(), "hook_payload_parse_error", { + payload = await new Promise((resolve, reject) => { + const chunks: string[] = []; + let totalBytes = 0; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk: string) => { + totalBytes += Buffer.byteLength(chunk); + if (totalBytes > MAX_STDIN_BYTES) { + hookLogWarn(`stdin payload exceeds 1 MB for ${eventType}, discarding`); + stdinOversized = true; + process.stdin.destroy(); + resolve(""); + return; + } + chunks.push(chunk); + }); + process.stdin.on("end", () => resolve(chunks.join(""))); + process.stdin.on("error", reject); + // If stdin is already closed or not piped, resolve immediately + if (process.stdin.readableEnded) resolve(""); + }); + } catch (err) { + hookLogWarn(`stdin read failed for ${eventType}`); + void trackHookEvent(getInstanceId(), "hook_stdin_error", { event_type: eventType, cli, - payload_size: payload.length, + error_type: err instanceof Error ? err.name : "unknown", + }); + } + if (stdinOversized) { + void trackHookEvent(getInstanceId(), "hook_stdin_error", { + event_type: eventType, + cli, + error_type: "oversized", }); } - } - // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields - // the handler downstream reads to canonical snake_case BEFORE any - // canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`, - // `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`, - // `transcriptPath` → `transcript_path`. Verified against agy v1.1.2. - if (cli === "antigravity") { - const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined; - if (tc && typeof tc === "object") { - if (tc.name !== undefined) parsed.tool_name = tc.name; - if (tc.args !== undefined) parsed.tool_input = tc.args; + let parsed: Record = {}; + if (payload) { + try { + parsed = JSON.parse(payload) as Record; + } catch { + hookLogWarn(`payload parse failed for ${eventType} (${payload.length} bytes)`); + void trackHookEvent(getInstanceId(), "hook_payload_parse_error", { + event_type: eventType, + cli, + payload_size: payload.length, + }); + } } - if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId; - if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") { - parsed.cwd = parsed.workspacePaths[0]; + + // Antigravity (agy) pipes a camelCase protojson payload; normalize the fields + // the handler downstream reads to canonical snake_case BEFORE any + // canonicalization runs. `toolCall.{name,args}` → `tool_name`/`tool_input`, + // `conversationId` → `session_id`, `workspacePaths[0]` → `cwd`, + // `transcriptPath` → `transcript_path`. Verified against agy v1.1.2. + if (cli === "antigravity") { + const tc = parsed.toolCall as { name?: string; args?: unknown } | undefined; + if (tc && typeof tc === "object") { + if (tc.name !== undefined) parsed.tool_name = tc.name; + if (tc.args !== undefined) parsed.tool_input = tc.args; + } + if (typeof parsed.conversationId === "string") parsed.session_id = parsed.conversationId; + if (Array.isArray(parsed.workspacePaths) && typeof parsed.workspacePaths[0] === "string") { + parsed.cwd = parsed.workspacePaths[0]; + } + if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath; } - if (typeof parsed.transcriptPath === "string") parsed.transcript_path = parsed.transcriptPath; - } - // Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` / - // `cwd` (its `tool_name` / `tool_input` are already canonical field names). - // Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the - // round-tripped event name is available. The --hook arg is already PascalCase, - // so canonicalizeEventType needs no goose branch. Verified goose v1.43.0. - if (cli === "goose") { - if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir; - if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) { - parsed.hook_event_name = parsed.event; + // Copilot's snake_case events (PreToolUse/PostToolUse/Stop/…) are already + // Claude-shaped, but `permissionRequest` alone pipes a camelCase payload + // (`hookName`, `sessionId`, `toolName` in lowercase, `toolInput`) — verified + // live against Copilot CLI 1.0.71. Normalize the fields the handler reads so + // PermissionRequest-matched policies (e.g. block-sudo's Codex-escalation + // guard) fire instead of seeing a null tool name. + if (cli === "copilot") { + if (typeof parsed.toolName === "string" && parsed.tool_name === undefined) { + parsed.tool_name = parsed.toolName; + } + if (parsed.toolInput !== undefined && parsed.tool_input === undefined) { + parsed.tool_input = parsed.toolInput; + } + if (typeof parsed.sessionId === "string" && parsed.session_id === undefined) { + parsed.session_id = parsed.sessionId; + } } - } - // Canonicalize event name (Codex sends snake_case; internals expect PascalCase) - const canonicalEventType = canonicalizeEventType(eventType, cli); + // Goose pipes `event` / `working_dir` instead of Claude's `hook_event_name` / + // `cwd` (its `tool_name` / `tool_input` are already canonical field names). + // Normalize both so resolveCwd keeps its cwd (block-read-outside-cwd) and the + // round-tripped event name is available. The --hook arg is already PascalCase, + // so canonicalizeEventType needs no goose branch. Verified goose v1.43.0. + if (cli === "goose") { + if (typeof parsed.working_dir === "string") parsed.cwd = parsed.working_dir; + if (typeof parsed.event === "string" && parsed.hook_event_name === undefined) { + parsed.hook_event_name = parsed.event; + } + } - // Canonicalize tool name in place so both the policy-registry tool-name - // filter and policy bodies (`ctx.toolName === "Bash"`) see the canonical - // form. Mutating `parsed.tool_name` keeps the activity store + telemetry - // tagging consistent (they read from `parsed.tool_name`). - const rawToolName = parsed.tool_name as string | undefined; - const canonicalToolName = canonicalizeToolName(rawToolName, cli); - if (canonicalToolName !== rawToolName) { - parsed.tool_name = canonicalToolName; - } + // Canonicalize event name (Codex sends snake_case; internals expect PascalCase) + const canonicalEventType = canonicalizeEventType(eventType, cli); - // Canonicalize tool-input keys for OpenCode + Pi (no-op for other CLIs). - // Defense-in-depth against stale shims that still pass camelCase / - // Pi-shape keys to the binary. The per-CLI shim ALSO canonicalizes; both - // passes are idempotent because the camelCase keys won't match a - // snake_case input. - const rawInput = parsed.tool_input; - const canonicalInput = canonicalizeToolInput(canonicalToolName, rawInput, cli); - if (canonicalInput !== rawInput) { - parsed.tool_input = canonicalInput; - } + // Canonicalize tool name in place so both the policy-registry tool-name + // filter and policy bodies (`ctx.toolName === "Bash"`) see the canonical + // form. Mutating `parsed.tool_name` keeps the activity store + telemetry + // tagging consistent (they read from `parsed.tool_name`). + const rawToolName = parsed.tool_name as string | undefined; + const canonicalToolName = canonicalizeToolName(rawToolName, cli); + if (canonicalToolName !== rawToolName) { + parsed.tool_name = canonicalToolName; + } - // Extract session metadata from payload - const sessionId = parsed.session_id as string | undefined; - const session: SessionMetadata = { - sessionId, - transcriptPath: resolveTranscriptPath(cli, parsed, sessionId), - cwd: resolveCwd(cli, parsed), - permissionMode: resolvePermissionMode(cli, parsed, sessionId), - hookEventName: parsed.hook_event_name as string | undefined, - // Preserve the raw CLI-side event name (eventType arg) before - // canonicalization. Response shapes that round-trip the agent-emitted - // event name prefer this over the canonicalized form when stdin omits - // hook_event_name. - rawHookEventName: eventType, - cli, - }; + // Canonicalize tool-input keys for OpenCode + Pi (no-op for other CLIs). + // Defense-in-depth against stale shims that still pass camelCase / + // Pi-shape keys to the binary. The per-CLI shim ALSO canonicalizes; both + // passes are idempotent because the camelCase keys won't match a + // snake_case input. + const rawInput = parsed.tool_input; + const canonicalInput = canonicalizeToolInput(canonicalToolName, rawInput, cli); + if (canonicalInput !== rawInput) { + parsed.tool_input = canonicalInput; + } - // Load enabled policies (merge across project/local/global scopes) - const config = readMergedHooksConfig(session.cwd); - clearPolicies(); - registerBuiltinPolicies(config.enabledPolicies); + // Extract session metadata from payload + const sessionId = parsed.session_id as string | undefined; + const session: SessionMetadata = { + sessionId, + transcriptPath: resolveTranscriptPath(cli, parsed, sessionId), + cwd: resolveCwd(cli, parsed), + permissionMode: resolvePermissionMode(cli, parsed, sessionId), + hookEventName: parsed.hook_event_name as string | undefined, + // Preserve the raw CLI-side event name (eventType arg) before + // canonicalization. Response shapes that round-trip the agent-emitted + // event name prefer this over the canonicalized form when stdin omits + // hook_event_name. + rawHookEventName: eventType, + cli, + }; - // Load and register custom hooks (layer 2, after builtins) - const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { sessionCwd: session.cwd }); - const customHooksList = loadResult.hooks; - const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames)); + // Load enabled policies (merge across project/local/global scopes) + const config = readMergedHooksConfig(session.cwd); + clearPolicies(); + registerBuiltinPolicies(config.enabledPolicies); - for (const hook of customHooksList) { - const hookName = hook.name; - const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope; - const isConvention = !!conventionScope; - const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom"; - const fn: PolicyFunction = async (ctx): Promise => { - try { - const result = await Promise.race([ - hook.fn(ctx), - new Promise((_, reject) => - setTimeout(() => reject(new Error("timeout")), 10_000), - ), - ]); - return result; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const isTimeout = msg === "timeout"; - hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`); - void trackHookEvent(getInstanceId(), "custom_hook_error", { - hook_name: hookName, - error_type: isTimeout ? "timeout" : "exception", - event_type: eventType, - cli, - is_convention_policy: isConvention, - convention_scope: conventionScope ?? null, - }); - return { decision: "allow" }; - } - }; - registerPolicy( - `${prefix}/${hookName}`, - hook.description ?? "", - fn, - hook.match ?? {}, - -1, // Custom hooks run after builtins (priority 0) - ); - } + // Load and register custom hooks (layer 2, after builtins) + const loadResult = await loadAllCustomHooks(config.customPoliciesPath, { sessionCwd: session.cwd }); + const customHooksList = loadResult.hooks; + const conventionHookNames = new Set(loadResult.conventionSources.flatMap((s) => s.hookNames)); - // Fire telemetry once per invocation for custom hook loads - if (customHooksList.length > 0) { - void trackHookEvent(getInstanceId(), "custom_hooks_loaded", { - cli, - custom_hooks_count: customHooksList.length, - custom_hook_names: customHooksList.map((h) => h.name), - event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))], - }); - } + for (const hook of customHooksList) { + const hookName = hook.name; + const conventionScope = (hook as CustomHook & { __conventionScope?: string }).__conventionScope; + const isConvention = !!conventionScope; + const prefix = isConvention ? `.failproofai-${conventionScope}` : "custom"; + const fn: PolicyFunction = async (ctx): Promise => { + try { + const result = await Promise.race([ + hook.fn(ctx), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 10_000), + ), + ]); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const isTimeout = msg === "timeout"; + hookLogWarn(`${prefix} hook "${hookName}" failed: ${msg}`); + void trackHookEvent(getInstanceId(), "custom_hook_error", { + hook_name: hookName, + error_type: isTimeout ? "timeout" : "exception", + event_type: eventType, + cli, + is_convention_policy: isConvention, + convention_scope: conventionScope ?? null, + }); + return { decision: "allow" }; + } + }; + registerPolicy( + `${prefix}/${hookName}`, + hook.description ?? "", + fn, + hook.match ?? {}, + -1, // Custom hooks run after builtins (priority 0) + ); + } - // Fire telemetry for convention-based policy discovery - if (loadResult.conventionSources.length > 0) { - void trackHookEvent(getInstanceId(), "convention_policies_loaded", { - event_type: canonicalEventType, - cli, - project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length, - user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length, - convention_hook_count: conventionHookNames.size, - convention_hook_names: [...conventionHookNames], - }); - } + // Fire telemetry once per invocation for custom hook loads + if (customHooksList.length > 0) { + void trackHookEvent(getInstanceId(), "custom_hooks_loaded", { + cli, + custom_hooks_count: customHooksList.length, + custom_hook_names: customHooksList.map((h) => h.name), + event_types_covered: [...new Set(customHooksList.flatMap((h) => h.match?.events ?? []))], + }); + } - hookLogInfo(`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`); + // Fire telemetry for convention-based policy discovery + if (loadResult.conventionSources.length > 0) { + void trackHookEvent(getInstanceId(), "convention_policies_loaded", { + event_type: canonicalEventType, + cli, + project_file_count: loadResult.conventionSources.filter((s) => s.scope === "project").length, + user_file_count: loadResult.conventionSources.filter((s) => s.scope === "user").length, + convention_hook_count: conventionHookNames.size, + convention_hook_names: [...conventionHookNames], + }); + } - // Evaluate policies (use canonical PascalCase event type) - const result = await evaluatePolicies(canonicalEventType, parsed, session, config); - const durationMs = Math.round(performance.now() - startTime); - hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`); + hookLogInfo(`event=${canonicalEventType} cli=${cli} policies=${config.enabledPolicies.length} custom=${customHooksList.length} convention=${conventionHookNames.size}`); - if (result.stdout) { - process.stdout.write(result.stdout); - } - if (result.stderr) { - process.stderr.write(result.stderr); - } + // Evaluate policies (use canonical PascalCase event type) + const result = await evaluatePolicies(canonicalEventType, parsed, session, config); + const durationMs = Math.round(performance.now() - startTime); + hookLogInfo(`result=${result.decision} policy=${result.policyName ?? "none"} duration=${durationMs}ms`); - // Persist activity to disk (visible in /policies activity tab) - const activityEntry = { - timestamp: Date.now(), - eventType: canonicalEventType, - integration: cli, - toolName: (parsed.tool_name as string) ?? null, - policyName: result.policyName, - policyNames: result.policyNames, - decision: result.decision, - reason: result.reason, - durationMs, - sessionId: session.sessionId, - transcriptPath: session.transcriptPath, - cwd: session.cwd, - permissionMode: session.permissionMode, - hookEventName: session.hookEventName, - }; - try { - persistHookActivity(activityEntry); - } catch { - hookLogWarn("activity persistence failed"); - } + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } - // Fire PostHog telemetry for decisions that affect Claude's behavior - if (result.decision === "deny" || result.decision === "instruct") { + // Persist activity to disk (visible in /policies activity tab) + const activityEntry = { + timestamp: Date.now(), + eventType: canonicalEventType, + integration: cli, + toolName: (parsed.tool_name as string) ?? null, + policyName: result.policyName, + policyNames: result.policyNames, + decision: result.decision, + reason: result.reason, + durationMs, + sessionId: session.sessionId, + transcriptPath: session.transcriptPath, + cwd: session.cwd, + permissionMode: session.permissionMode, + hookEventName: session.hookEventName, + }; try { - const isCustomHook = result.policyName?.startsWith("custom/") ?? false; - const isConventionPolicy = result.policyName?.startsWith(".failproofai-") ?? false; - const conventionScope = isConventionPolicy - ? result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null - : null; - const hasCustomParams = - !isCustomHook && !isConventionPolicy && !!(result.policyName && config.policyParams?.[result.policyName]); - const paramKeysOverridden = hasCustomParams - ? Object.keys(config.policyParams![result.policyName!]) - : []; - const distinctId = getInstanceId(); - await trackHookEvent(distinctId, "hook_policy_triggered", { - event_type: canonicalEventType, - cli, - tool_name: (parsed.tool_name as string) ?? null, - policy_name: result.policyName, - decision: result.decision, - is_custom_hook: isCustomHook, - is_convention_policy: isConventionPolicy, - convention_scope: conventionScope, - has_custom_params: hasCustomParams, - param_keys_overridden: paramKeysOverridden, - }); + persistHookActivity(activityEntry); } catch { - // Telemetry is best-effort — never block the hook + hookLogWarn("activity persistence failed"); } - } - return result.exitCode; + // Fire PostHog telemetry for decisions that affect Claude's behavior + if (result.decision === "deny" || result.decision === "instruct") { + try { + const isCustomHook = result.policyName?.startsWith("custom/") ?? false; + const isConventionPolicy = result.policyName?.startsWith(".failproofai-") ?? false; + const conventionScope = isConventionPolicy + ? result.policyName!.match(/^\.failproofai-(project|user)\//)?.[1] ?? null + : null; + const hasCustomParams = + !isCustomHook && !isConventionPolicy && !!(result.policyName && config.policyParams?.[result.policyName]); + const paramKeysOverridden = hasCustomParams + ? Object.keys(config.policyParams![result.policyName!]) + : []; + const distinctId = getInstanceId(); + await trackHookEvent(distinctId, "hook_policy_triggered", { + event_type: canonicalEventType, + cli, + tool_name: (parsed.tool_name as string) ?? null, + policy_name: result.policyName, + decision: result.decision, + is_custom_hook: isCustomHook, + is_convention_policy: isConventionPolicy, + convention_scope: conventionScope, + has_custom_params: hasCustomParams, + param_keys_overridden: paramKeysOverridden, + }); + } catch { + // Telemetry is best-effort — never block the hook + } + } + return result.exitCode; + } finally { + // Await any un-awaited (`void trackHookEvent(...)`) events fired during + // this invocation. bin/failproofai.mjs calls process.exit() the moment we + // return OR throw, which would otherwise drop in-flight POSTs — notably on + // the allow path (no trailing awaited event) and on any early throw + // (custom-hook load / policy eval) before the happy path is reached. + await flushHookTelemetry(); + } } diff --git a/src/hooks/hook-telemetry.ts b/src/hooks/hook-telemetry.ts index d4240cb7..59e0a5b1 100644 --- a/src/hooks/hook-telemetry.ts +++ b/src/hooks/hook-telemetry.ts @@ -11,13 +11,23 @@ import { POSTHOG_API_KEY, POSTHOG_PRODUCT } from "../posthog-key"; const API_KEY = POSTHOG_API_KEY; const CAPTURE_URL = "https://us.i.posthog.com/capture/"; -export async function trackHookEvent( +/** + * In-flight telemetry POSTs. The hook binary is short-lived — `bin/failproofai.mjs` + * calls `process.exit()` the moment `handleHookEvent` returns, which kills any + * fetch that a caller fired with `void trackHookEvent(...)` (i.e. did not await). + * We track every send here so `flushHookTelemetry()` can await them all at the + * process-exit boundary, making delivery reliable regardless of whether the + * individual call site awaited. Without this, un-awaited events (custom_hooks_loaded, + * convention_policies_loaded, the *_error events) are dropped on the common + * allow path, since no trailing await holds the event loop open. + */ +const pending = new Set>(); + +async function sendEvent( distinctId: string, event: string, properties?: Record, ): Promise { - if (process.env.FAILPROOFAI_TELEMETRY_DISABLED === "1") return; - const body = JSON.stringify({ api_key: process.env.FAILPROOFAI_POSTHOG_KEY ?? API_KEY, event, @@ -41,3 +51,33 @@ export async function trackHookEvent( // Telemetry is best-effort — never fail the hook } } + +export async function trackHookEvent( + distinctId: string, + event: string, + properties?: Record, +): Promise { + if (process.env.FAILPROOFAI_TELEMETRY_DISABLED === "1") return; + + const p = sendEvent(distinctId, event, properties); + pending.add(p); + // Deregister on settle so the set doesn't grow unbounded in long-lived + // callers (tests, the dashboard server). `sendEvent` never rejects, but + // `.finally` is defensive. + void p.finally(() => pending.delete(p)); + await p; +} + +/** + * Await every in-flight telemetry POST. Call this immediately before + * `process.exit()` on any short-lived path so `void trackHookEvent(...)` events + * are delivered instead of being cut off by the exit. No-op (resolves instantly) + * when nothing is pending. Never throws — `sendEvent` swallows its own errors. + */ +export async function flushHookTelemetry(): Promise { + // Loop in case a settling promise's `.finally` races a newly-added send; + // in practice one pass suffices because no send spawns another. + while (pending.size > 0) { + await Promise.allSettled([...pending]); + } +} diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index 93c3f915..7390aa13 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -10,6 +10,17 @@ * Keybindings: ↑↓ navigate · Space toggle · Ctrl+A all · Ctrl+S save · Esc clear search */ import * as readline from "node:readline"; +// Brand palette, ANSI-aware truncation, and glyphs shared with the configure +// wizard (tui.ts) so every failproofai menu reads as one system. +import { + paint, + truncate as truncateLine, + BAR, + STEP_ACTIVE as STEP, + STEP_DONE as DONE, + CHECK_ON as BOX_ON, + CHECK_OFF as BOX_OFF, +} from "./tui"; import { BUILTIN_POLICIES } from "./builtin-policies"; import { detectInstalledClis, getIntegration, listInstallableIds } from "./integrations"; import { type IntegrationType } from "./types"; @@ -246,79 +257,49 @@ async function promptCliTargetSelection( } } - function truncateLine(line: string, width: number): string { - let visual = 0; - let result = ""; - let i = 0; - while (i < line.length) { - if (line[i] === "\x1B" && line[i + 1] === "[") { - let j = i + 2; - while (j < line.length && !/[A-Za-z]/.test(line[j])) j++; - j++; - result += line.slice(i, j); - i = j; - } else { - if (visual >= width) break; - result += line[i]; - visual++; - i++; - } - } - return result; - } + // Shared brand painter from tui.ts (NO_COLOR-gated like the rest of this file). + const { dim, bold, guide: teal, pink, pinkBold } = paint(!process.env.NO_COLOR); - const heading = action === "uninstall" ? "Remove Hooks" : "Install Hooks"; + const heading = + action === "uninstall" ? "Remove failproofai hooks" : "Install failproofai hooks"; function render(): void { - const cols = process.stdout.columns || 120; + const cols = process.stdout.columns || 100; hideCursor(); - const lines: string[] = []; - lines.push(` \x1B[1mFailproof AI\x1B[0m \x1B[2m—\x1B[0m ${heading}`); - lines.push(""); + const g = dim(BAR); + const lines: string[] = [g, `${teal(STEP)} ${bold(heading)}`]; for (const row of buildDisplayRows()) { if (row.kind === "blank") { - lines.push(""); + lines.push(g); continue; } if (row.kind === "header") { - const hintVisible = row.hint ? ` · ${row.hint}` : ""; - // Line shape: " " + "── " + title + hintVisible + " " + dashes → cols - const dashLen = Math.max(2, cols - 2 - 3 - row.title.length - hintVisible.length - 1); - const suffix = row.hint ? ` \x1B[2m· ${row.hint}\x1B[0m` : ""; - lines.push( - ` \x1B[2m── \x1B[0m\x1B[1m${row.title}\x1B[0m${suffix} \x1B[2m${"─".repeat(dashLen)}\x1B[0m`, - ); + const hint = row.hint ? ` ${dim("· " + row.hint)}` : ""; + lines.push(`${g} ${dim(row.title)}${hint}`); continue; } const opt = row.option; const isActive = row.itemIndex === cursor; - const pointer = isActive ? "\x1B[36m❯\x1B[0m" : " "; - const marker = opt.isAll - ? "\x1B[33m★\x1B[0m" - : opt.detected - ? "\x1B[32m●\x1B[0m" - : "\x1B[2m○\x1B[0m"; + const caret = isActive ? pink("❯") : " "; + const marker = opt.isAll ? teal("★") : opt.detected ? pink("●") : dim("○"); const label = isActive - ? `\x1B[1;36m${opt.label}\x1B[0m` + ? pinkBold(opt.label) : opt.detected ? opt.label - : `\x1B[2m${opt.label}\x1B[0m`; - lines.push(` ${pointer} ${marker} ${label}`); + : dim(opt.label); + lines.push(`${g} ${caret} ${marker} ${label}`); } - lines.push(""); - lines.push(" \x1B[2m" + "─".repeat(Math.max(2, cols - 2)) + "\x1B[0m"); - lines.push(" [↑↓] Move [Enter] Select [^C] Quit"); + lines.push(g); + lines.push(`${g} ${dim("↑/↓ move · ↵ select · esc cancel")}`); if (lastLineCount > 0) { process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); } - const output = - lines.map((l) => (l === "" ? l : truncateLine(l, cols))).join("\n") + "\n"; - process.stdout.write(output); + process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); lastLineCount = lines.length; } @@ -340,7 +321,7 @@ async function promptCliTargetSelection( function onKey(_str: string | undefined, key: readline.Key): void { if (!key) return; - if (key.ctrl && (key.name === "c" || key.name === "d")) { + if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { cleanup(); process.stdout.write("\n"); process.exit(130); // SIGINT-equivalent @@ -394,7 +375,19 @@ export async function promptPolicySelection( })); const total = items.length; - const WINDOW_SIZE = 8; + const WINDOW_SIZE = 10; + + // Shared brand painter from tui.ts, bound to role names so a reader never has + // to decode which hue a role happens to use today (glyphs imported above). + const useColor = !process.env.NO_COLOR; + const c = paint(useColor); + const { dim, bold } = c; + const stepMark = c.guide; // ◆ step marker + live selected-count + const activeName = c.pinkBold; // row under the cursor + const selectedMark = c.pink; // checked boxes / selected names + const betaTag = c.softPink; // "beta" pill + // Fixed name column so descriptions align into a clean, scannable second column. + const NAME_COL = Math.min(34, Math.max(10, ...items.map((i) => i.name.length))); let cursor = 0; let search = ""; @@ -415,28 +408,6 @@ export async function promptPolicySelection( } } - // Truncate a line to `width` visual columns, skipping ANSI CSI sequences. - function truncateLine(line: string, width: number): string { - let visual = 0; - let result = ""; - let i = 0; - while (i < line.length) { - if (line[i] === "\x1B" && line[i + 1] === "[") { - let j = i + 2; - while (j < line.length && !/[A-Za-z]/.test(line[j])) j++; - j++; - result += line.slice(i, j); - i = j; - } else { - if (visual >= width) break; - result += line[i]; - visual++; - i++; - } - } - return result; - } - function getFiltered(): SelectItem[] { if (!search) return items; const q = search.toLowerCase(); @@ -483,44 +454,36 @@ export async function promptPolicySelection( } function render(): void { - const cols = process.stdout.columns || 120; + const cols = process.stdout.columns || 100; hideCursor(); const filtered = getFiltered(); const shown = filtered.length; - - // Clamp cursor to filtered list bounds if (shown > 0 && cursor >= shown) cursor = shown - 1; + const selectedCount = items.filter((i) => i.selected).length; + const g = dim(BAR); const lines: string[] = []; - // ── Title ──────────────────────────────────────────────────── - lines.push(" Failproof AI \u2014 Policy Manager"); - lines.push(""); - - // ── Bordered search box ────────────────────────────────────── - const innerWidth = Math.max(20, cols - 6); - const topBorder = " \u250c" + "\u2500".repeat(innerWidth + 2) + "\u2510"; - const botBorder = " \u2514" + "\u2500".repeat(innerWidth + 2) + "\u2518"; - const cursorChar = "\x1B[7m \x1B[0m"; // reverse-video block cursor - const countPart = search - ? ` \x1B[2m(${shown}/${total})\x1B[0m` - : ` \x1B[2m(${total} policies)\x1B[0m`; - const searchContent = `\x1B[1mSearch:\x1B[0m ${search}${cursorChar}${countPart}`; - lines.push(topBorder); - lines.push(` \u2502 ${searchContent}`); - lines.push(botBorder); - lines.push(""); - - // ── Content area ───────────────────────────────────────────── + // header + lines.push(g); + lines.push( + `${stepMark(STEP)} ${bold("Choose the policies to enable")} ${dim("·")} ` + + `${stepMark(String(selectedCount))} ${dim("selected")}`, + ); + + // search + const blockCursor = useColor ? "\x1B[7m \x1B[0m" : "_"; + const countLabel = search ? dim(`${shown}/${total} shown`) : dim(`${total} policies`); + lines.push(`${g} ${dim("filter ›")} ${search}${blockCursor} ${countLabel}`); + lines.push(g); + if (shown === 0) { - lines.push(" \x1B[2mNo policies match \u201c" + search + "\u201d\x1B[0m"); - // Pad to stable height: 1 (scroll-up) + WINDOW_SIZE (rows) + 1 (scroll-down) - for (let i = 0; i < WINDOW_SIZE + 1; i++) lines.push(""); + lines.push(`${g} ${dim(`no policies match “${search}”`)}`); + for (let i = 0; i < WINDOW_SIZE + 1; i++) lines.push(g); } else { const displayRows = buildDisplayRows(filtered); - // Find the display row index that corresponds to the current cursor let cursorDisplayRow = 0; for (let i = 0; i < displayRows.length; i++) { const row = displayRows[i]; @@ -529,89 +492,42 @@ export async function promptPolicySelection( break; } } - - // Viewport: keep cursor row roughly centred let windowStart = cursorDisplayRow - Math.floor(WINDOW_SIZE / 2); windowStart = Math.max(0, windowStart); windowStart = Math.min(windowStart, Math.max(0, displayRows.length - WINDOW_SIZE)); const windowEnd = Math.min(displayRows.length, windowStart + WINDOW_SIZE); - // Scroll-up indicator (always reserve this line for stable height) - const aboveItems = displayRows - .slice(0, windowStart) - .filter((r) => r.kind === "item").length; - if (aboveItems > 0) { - lines.push(` \x1B[2m \u2191 ${aboveItems} more above\x1B[0m`); - } else { - lines.push(""); - } + const aboveItems = displayRows.slice(0, windowStart).filter((r) => r.kind === "item").length; + lines.push(aboveItems > 0 ? `${g} ${dim(`↑ ${aboveItems} more`)}` : g); - // Visible display rows for (let i = windowStart; i < windowEnd; i++) { const row = displayRows[i]; if (row.kind === "header") { - // ── CATEGORY NAME (enabled/total) ───── - const label = ` ${row.category.toUpperCase()} (${row.enabledCount}/${row.totalCount}) `; - const prefix = "\u2500\u2500 "; - const prefixLen = 3 + label.length; - const dashLen = Math.max(2, cols - 2 - prefixLen); - lines.push( - ` \x1B[2m${prefix}${label}${"\u2500".repeat(dashLen)}\x1B[0m`, - ); + lines.push(`${g} ${dim(row.category.toUpperCase())} ${dim(`${row.enabledCount}/${row.totalCount}`)}`); } else { const item = row.item; - const isActive = row.filteredIndex === cursor; - const pointer = isActive ? "\x1B[36m\u276f\x1B[0m" : " "; - const check = item.selected ? "\x1B[32m[\u2713]\x1B[0m" : "[ ]"; - const namePart = isActive - ? `\x1B[1;36m${item.name}\x1B[0m` - : item.name; - const betaPart = item.beta ? " \x1B[35m[beta]\x1B[0m" : ""; - const pad = " ".repeat(Math.max(1, 28 - item.name.length)); - const desc = `\x1B[2m${item.description}\x1B[0m`; - lines.push(` ${pointer} ${check} ${namePart}${betaPart}${pad}${desc}`); + const active = row.filteredIndex === cursor; + const box = item.selected ? selectedMark(BOX_ON) : dim(BOX_OFF); + const rawName = item.name.padEnd(NAME_COL); + const name = active ? activeName(rawName) : item.selected ? rawName : dim(rawName); + const beta = item.beta ? ` ${betaTag("beta")}` : ""; + const descWidth = Math.max(8, cols - NAME_COL - 8 - (item.beta ? 5 : 0)); + const desc = dim(truncateLine(item.description, descWidth)); + lines.push(`${g} ${box} ${name}${beta} ${desc}`); } } + for (let i = windowEnd - windowStart; i < WINDOW_SIZE; i++) lines.push(g); - // Pad window to fixed WINDOW_SIZE rows for stable height - for (let i = windowEnd - windowStart; i < WINDOW_SIZE; i++) { - lines.push(""); - } - - // Scroll-down indicator (always reserve this line for stable height) - const belowItems = displayRows - .slice(windowEnd) - .filter((r) => r.kind === "item").length; - if (belowItems > 0) { - lines.push(` \x1B[2m \u2193 ${belowItems} more below\x1B[0m`); - } else { - lines.push(""); - } + const belowItems = displayRows.slice(windowEnd).filter((r) => r.kind === "item").length; + lines.push(belowItems > 0 ? `${g} ${dim(`↓ ${belowItems} more`)}` : g); } - // ── Footer ─────────────────────────────────────────────────── - lines.push(""); - lines.push(" \x1B[2m" + "\u2500".repeat(cols - 2) + "\x1B[0m"); - lines.push( - " [\u2191\u2193] Move [Space] Toggle [Ctrl+A] All [Ctrl+S] Save [Esc] Clear [^C] Quit", - ); - lines.push(""); - lines.push( - " \x1B[2mTip: `policies` for a flat list \u00b7 `policies --install ` to skip prompt\x1B[0m", - ); - if (!includeBeta) { - lines.push( - " \x1B[2mTip: `policies --install --beta` to include beta policies\x1B[0m", - ); - } + // footer + lines.push(g); + lines.push(`${g} ${dim("↑/↓ move · space select · ctrl+a all · ↵ save · type to filter · esc clear")}`); - // ── Repaint: cursor-up by previous line count, clear to end, redraw ── - if (lastLineCount > 0) { - process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - } - const output = - lines.map((l) => (l === "" ? l : truncateLine(l, cols))).join("\n") + "\n"; - process.stdout.write(output); + if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); + process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); lastLineCount = lines.length; } @@ -645,10 +561,19 @@ export async function promptPolicySelection( cursor = cursor < filtered.length - 1 ? cursor + 1 : 0; } render(); - } else if (key.name === "return" || key.name === "space") { + } else if (key.name === "space") { const item = filtered[cursor]; if (item) item.selected = !item.selected; render(); + } else if (key.name === "return" || (key.ctrl && key.name === "s")) { + // Save — collapse the picker to a one-line summary, then resolve. + cleanup(); + const selected = items.filter((i) => i.selected).map((i) => i.name); + if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); + process.stdout.write( + `${dim(BAR)}\n${dim(DONE)} Policies\n${dim(BAR)} ${dim(`${selected.length} selected`)}\n`, + ); + resolve(selected); } else if (key.name === "escape") { // Clear search filter search = ""; @@ -659,12 +584,6 @@ export async function promptPolicySelection( const allSelected = filtered.length > 0 && filtered.every((i) => i.selected); for (const item of filtered) item.selected = !allSelected; render(); - } else if (key.ctrl && key.name === "s") { - // Submit - cleanup(); - const selected = items.filter((i) => i.selected).map((i) => i.name); - process.stdout.write("\n"); - resolve(selected); } else if (key.name === "backspace" || key.name === "delete") { if (search.length > 0) { search = search.slice(0, -1); diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 0bc24528..3cc5d1e8 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -86,6 +86,14 @@ export function hooksInstalledInSettings(scope: HookScope, cwd?: string): boolea return claudeCode.hooksInstalledInSettings(scope, cwd); } +export interface InstallHooksOptions { + /** Replace the enabled set at this scope instead of unioning (default: additive). */ + replace?: boolean; + /** Suppress this module's installation logging (for callers that render their + * own UI, like the configure wizard). Errors still surface via console.error. */ + quiet?: boolean; +} + /** * Install hooks into Claude Code settings. * @@ -104,6 +112,38 @@ export async function installHooks( customPoliciesPath?: string, removeCustomHooks = false, cli?: IntegrationType[], + options: InstallHooksOptions = {}, +): Promise { + const { replace = false, quiet = false } = options; + if (!quiet) { + return installHooksImpl( + policyNames, scope, cwd, includeBeta, source, customPoliciesPath, removeCustomHooks, cli, replace, + ); + } + // Quiet mode: this module logs exclusively via console.log, so muting it for + // the duration of the call silences installation output at its owner rather + // than at every call site. console.error (real failures) still flows. + const origLog = console.log; + console.log = () => {}; + try { + return await installHooksImpl( + policyNames, scope, cwd, includeBeta, source, customPoliciesPath, removeCustomHooks, cli, replace, + ); + } finally { + console.log = origLog; + } +} + +async function installHooksImpl( + policyNames?: string[], + scope: HookScope = "user", + cwd?: string, + includeBeta = false, + source?: string, + customPoliciesPath?: string, + removeCustomHooks = false, + cli?: IntegrationType[], + replace = false, ): Promise { // Validate user input first before any system checks if (policyNames !== undefined && policyNames.length > 0) { @@ -159,8 +199,12 @@ export async function installHooks( } else { incoming = policyNames; } - // Additive: union with whatever was already enabled, deduplicated. - selectedPolicies = [...new Set([...previousConfig.enabledPolicies, ...incoming])]; + // Default is additive (union with whatever was already enabled). The + // configure wizard passes replace=true so the chosen set becomes the full + // enabled set at this scope (unticking a policy actually removes it). + selectedPolicies = replace + ? [...new Set(incoming)] + : [...new Set([...previousConfig.enabledPolicies, ...incoming])]; } else { // Interactive — pre-load current config if it exists const preSelected = previousConfig.enabledPolicies.length > 0 ? previousConfig.enabledPolicies : undefined; diff --git a/src/hooks/policy-presets.ts b/src/hooks/policy-presets.ts new file mode 100644 index 00000000..5d515be5 --- /dev/null +++ b/src/hooks/policy-presets.ts @@ -0,0 +1,71 @@ +/** + * Curated policy bundles for the `failproofai config` wizard. + * + * Presets are resolved against BUILTIN_POLICIES by category (+ optional extras) + * so that adding a new builtin to an existing category automatically flows into + * the matching preset — the wizard stays in sync with the policy catalog with no + * manual list maintenance. Order here is the order shown in the wizard (before + * the "Everything" and "Custom…" entries). + */ +import { BUILTIN_POLICIES } from "./builtin-policies"; + +export interface PolicyPreset { + id: string; + label: string; + description: string; + /** Categories whose (non-beta) policies are included in this preset. */ + categories: string[]; + /** Extra policy names to include beyond the category members. */ + extra?: string[]; +} + +export const POLICY_PRESETS: PolicyPreset[] = [ + { + id: "secrets", + label: "Secrets & data", + description: + "Redact secrets in tool output, block .env & secret-file writes, keep reads inside the repo", + categories: ["Sanitize", "Environment"], + extra: ["block-secrets-write"], + }, + { + id: "git", + label: "Git safety", + description: "Block force-push & pushes to main, warn on history-rewriting git ops", + categories: ["Git"], + }, + { + id: "ship", + label: "Ship discipline", + description: + "Don't let the agent finish until changes are committed, pushed, PR'd and CI is green", + categories: ["Workflow"], + }, + { + id: "infra", + label: "Cloud & infra", + description: "Block kubectl / terraform / aws / gcloud / az / helm / gh pipeline commands", + categories: ["Infra Commands"], + }, +]; + +/** Resolve a preset id to the concrete list of non-beta builtin policy names it + * enables. (Beta policies are wizard-excluded by design; reintroduce an + * includeBeta flag here if a `--beta` wizard path ever lands.) */ +export function resolvePreset(id: string): string[] { + const preset = POLICY_PRESETS.find((p) => p.id === id); + if (!preset) return []; + const cats = new Set(preset.categories); + const fromCategories = BUILTIN_POLICIES.filter( + (p) => !p.beta && cats.has(p.category), + ).map((p) => p.name); + const extras = (preset.extra ?? []).filter((name) => + BUILTIN_POLICIES.some((p) => p.name === name && !p.beta), + ); + return [...new Set([...fromCategories, ...extras])]; +} + +/** Every non-beta builtin policy (the "Everything" option). */ +export function resolveEverything(): string[] { + return BUILTIN_POLICIES.filter((p) => !p.beta).map((p) => p.name); +} diff --git a/src/hooks/tool-name-canonicalize.ts b/src/hooks/tool-name-canonicalize.ts index 94985332..b784d5d9 100644 --- a/src/hooks/tool-name-canonicalize.ts +++ b/src/hooks/tool-name-canonicalize.ts @@ -9,6 +9,7 @@ import type { IntegrationType } from "./types"; import { CODEX_TOOL_MAP, COPILOT_TOOL_MAP, + COPILOT_TOOL_INPUT_MAP, CURSOR_TOOL_MAP, OPENCODE_TOOL_MAP, OPENCODE_TOOL_INPUT_MAP, @@ -76,7 +77,11 @@ export function canonicalizeToolInput( return rawInput; } let perToolMap: Record | undefined; - if (cli === "opencode") perToolMap = OPENCODE_TOOL_INPUT_MAP[toolName]; + // Copilot file tools deliver `path` (+ Write's `file_text`, Edit's + // `old_str`/`new_str`); map to canonical keys so path/content builtins fire + // (verified live against Copilot CLI 1.0.71). + if (cli === "copilot") perToolMap = COPILOT_TOOL_INPUT_MAP[toolName]; + else if (cli === "opencode") perToolMap = OPENCODE_TOOL_INPUT_MAP[toolName]; else if (cli === "pi") perToolMap = PI_TOOL_INPUT_MAP[toolName]; // Hermes read_file/write_file/patch deliver the file path as `path`; map it to // `file_path` so path/content builtins fire (verified against a live state.db). diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts new file mode 100644 index 00000000..e2debc4b --- /dev/null +++ b/src/hooks/tui.ts @@ -0,0 +1,578 @@ +/** + * Minimal clack-style TUI primitives for the `failproofai config` launcher, + * dressed in the befailproof.ai identity: the pixel logomark opens the flow, and + * the palette is pink-forward like the site — pink drives selection/enabled, + * teal stays the flower on the mark and the step spine. + * + * A single continuous flow with a left gutter (│) threading through step nodes: + * the active step shows as ◆, answered steps collapse to a persistent ◇ log + * line, and the run ends on a └ outro. Two interactive prompts — `selectOne` + * (radio) and `multiSelect` (checklist, windowed with a caret) — plus + * `intro` / `outro`. + * + * Each prompt owns only its own render region (cursor-up + clear-to-end + * repaint) and, on resolve, collapses that region to a one-line summary that + * stays on screen — so the next prompt simply appends below, building the log. + * No external dependencies. Honors NO_COLOR and non-TTY (returns the default + * without drawing), and uses 24-bit color where COLORTERM advertises it, + * falling back to the nearest basic ANSI hue otherwise. + */ +import * as readline from "node:readline"; + +export type TTYIn = NodeJS.ReadableStream & { + isTTY?: boolean; + setRawMode?: (mode: boolean) => void; + isRaw?: boolean; +}; +export type TTYOut = NodeJS.WritableStream & { isTTY?: boolean; columns?: number }; + +export interface SelectChoice { + label: string; + value: T; + hint?: string; + section?: string; +} + +export interface SelectOneOptions { + message: string; + choices: SelectChoice[]; + /** Static info lines rendered under the question (e.g. a review summary). */ + body?: string[]; + stdin?: TTYIn; + stdout?: TTYOut; +} + +export interface MultiChoice { + label: string; + value: T; + hint?: string; + checked?: boolean; + section?: string; +} + +export interface MultiSelectOptions { + message: string; + choices: MultiChoice[]; + minSelected?: number; + hint?: string; + /** Noun used when collapsing many selections to a count (e.g. "assistants"). */ + summaryNoun?: string; + stdin?: TTYIn; + stdout?: TTYOut; +} + +const ESC = "\x1B"; + +// ── glyphs ──────────────────────────────────────────────────────────────── +// Exported so the other branded prompts (install-prompt.ts) share the exact +// same set instead of hand-syncing copies. +export const BAR = "│"; +const BAR_END = "└"; +export const STEP_ACTIVE = "◆"; +export const STEP_DONE = "◇"; +const RADIO_ON = "●"; +const RADIO_OFF = "○"; +export const CHECK_ON = "◼"; +export const CHECK_OFF = "◻"; +export const CARET = "❯"; +const FLOWER = "❋"; + +// ── color ───────────────────────────────────────────────────────────────── +// The single source of truth for the brand palette — exported (via `paint`) +// so the other branded prompts (install-prompt.ts) reuse it instead of +// re-deriving their own copies. +interface Hue { + rgb: [number, number, number]; + basic: string; +} +const HUES = { + guide: { rgb: [102, 209, 181], basic: "36" }, // teal — the policy flower / step spine + pink: { rgb: [255, 46, 136], basic: "95" }, // hot pink — selection, enabled, the brand + logoPink: { rgb: [228, 88, 124], basic: "95" }, // the softer artwork pink of the logomark + warn: { rgb: [227, 179, 65], basic: "33" }, + dim: { rgb: [107, 118, 132], basic: "2" }, +} satisfies Record; + +export function colorsEnabled(out: TTYOut): boolean { + return !!out.isTTY && !process.env.NO_COLOR; +} +function truecolorEnabled(): boolean { + return /truecolor|24bit/i.test(process.env.COLORTERM || ""); +} + +/** Brand painter: role-named color functions, truecolor where advertised, + * basic-ANSI fallback otherwise, identity when `on` is false. */ +export function paint(on: boolean) { + const tc = on && truecolorEnabled(); + const mk = + (h: Hue, bold = false) => + (s: string): string => { + if (!on) return s; + const code = tc ? `38;2;${h.rgb[0]};${h.rgb[1]};${h.rgb[2]}` : h.basic; + return `${ESC}[${bold ? "1;" : ""}${code}m${s}${ESC}[0m`; + }; + return { + bold: (s: string) => (on ? `${ESC}[1m${s}${ESC}[0m` : s), + dim: mk(HUES.dim), + guide: mk(HUES.guide), + pink: mk(HUES.pink), + pinkBold: mk(HUES.pink, true), + softPink: mk(HUES.logoPink), + warn: mk(HUES.warn), + }; +} + +// ── brand logo (befailproof.ai logomark) ──────────────────────────────────── +// Half-block rendition of the real site mark — the teal flower, the pink cross, +// and the tall bar joined at the base — downscaled from the actual artwork so it +// stays faithful at terminal size. Each character cell packs two vertical pixels +// (▀ top, ▄ bottom); `t` = teal, `p` = pink, `.` = transparent. Shown when +// there's room, else a compact one-liner. +const LOGO_MIN_COLS = 22; +const TAGLINE = "end-to-end failure layer for ai agents"; + +const LOGO_GRID = [ + "...tt...........", + "..tttt..........", + ".tttttt.........", + ".tttttt.....ppp.", + "..tttt......ppp.", + "...tt.......ppp.", + "............ppp.", + "............ppp.", + "..pppp......ppp.", + "..pppp......ppp.", + "..pppp......ppp.", + ".pppppp.....ppp.", + ".pppppp.....ppp.", + ".pppppp.....ppp.", + ".pppppp.....ppp.", + "..pppp......ppp.", + "..pppp......ppp.", + "..pppp......ppp.", + "..ppppppppppppp.", + "..ppppppppppppp.", + "..ppppppppppppp.", + "..ppppppppppppp.", +]; +// Derived from the shared HUES table so a palette tweak needs one edit. +const LOGO_TEAL: [number, number, number] = HUES.guide.rgb; +const LOGO_PINK: [number, number, number] = HUES.logoPink.rgb; + +/** Render the logomark as half-block art. When `colorize` is false (no truecolor + * / NO_COLOR) the shape still prints, just monochrome. */ +function renderLogo(colorize: boolean): string[] { + const pad = ".".repeat(LOGO_GRID[0]?.length ?? 0); + const rgb = (ch: string): [number, number, number] | null => + ch === "t" ? LOGO_TEAL : ch === "p" ? LOGO_PINK : null; + const lines: string[] = []; + for (let r = 0; r < LOGO_GRID.length; r += 2) { + const top = LOGO_GRID[r]; + const bot = LOGO_GRID[r + 1] ?? pad; + let line = ""; + for (let x = 0; x < top.length; x++) { + const t = rgb(top[x]); + const b = rgb(bot[x]); + if (!t && !b) { + line += " "; + } else if (!colorize) { + line += t && b ? "█" : t ? "▀" : "▄"; + } else if (t && b) { + line += + t === b + ? `${ESC}[38;2;${t.join(";")}m█${ESC}[0m` + : `${ESC}[38;2;${t.join(";")};48;2;${b.join(";")}m▀${ESC}[0m`; + } else if (t) { + line += `${ESC}[38;2;${t.join(";")}m▀${ESC}[0m`; + } else { + line += `${ESC}[38;2;${b!.join(";")}m▄${ESC}[0m`; + } + } + lines.push(line); + } + return lines; +} + +// ── text helpers ───────────────────────────────────────────────────────────── + +/** Truncate a line to `width` visual columns, skipping ANSI CSI sequences. + * Exported so install-prompt.ts shares it instead of keeping local copies. */ +export function truncate(line: string, width: number): string { + let visual = 0; + let out = ""; + let i = 0; + while (i < line.length) { + if (line[i] === ESC && line[i + 1] === "[") { + let j = i + 2; + while (j < line.length && !/[A-Za-z]/.test(line[j])) j++; + j++; + out += line.slice(i, j); + i = j; + } else { + if (visual >= width) break; + out += line[i]; + visual++; + i++; + } + } + return out; +} + +/** Truncate PLAIN text to `width`, ending on a single ellipsis rather than a + * mid-word hard cut. Assumes no ANSI inside `text` (hints/labels are plain). */ +export function ellipsize(text: string, width: number): string { + if (width <= 0) return ""; + if (text.length <= width) return text; + return text.slice(0, width - 1).trimEnd() + "…"; +} + +/** Collapse many labels to a readable summary: a full join for a few, a + * `N noun · a, b, c +K` count for many. */ +export function summarize(labels: string[], noun = "selected"): string { + if (labels.length === 0) return "none"; + if (labels.length <= 3) return labels.join(", "); + const head = labels.slice(0, 3).join(", "); + return `${labels.length} ${noun} · ${head} +${labels.length - 3}`; +} + +function writeLines(out: TTYOut, lines: string[]): void { + const cols = out.columns || 80; + out.write(lines.map((l) => (l === "" ? l : truncate(l, cols))).join("\n") + "\n"); +} + +// ── framing ───────────────────────────────────────────────────────────────── + +/** The logomark + wordmark + tagline block (with a 2-space margin), color-aware. + * Shared by the wizard intro and the dashboard launch banner. On a too-narrow + * target it collapses to a single compact line. */ +export function renderBrandLogo(stdout: TTYOut = process.stdout): string[] { + const c = paint(colorsEnabled(stdout)); + const cols = stdout.columns || 80; + if (cols < LOGO_MIN_COLS) { + return [`${c.guide(FLOWER)} fa${c.pink("il")}proof ai ${c.dim("· " + TAGLINE)}`]; + } + const tc = colorsEnabled(stdout) && truecolorEnabled(); + const lines = renderLogo(tc).map((l) => ` ${l}`); + lines.push(""); + lines.push(` fa${c.pink("il")}proof ai`); + lines.push(` ${c.dim(TAGLINE)}`); + return lines; +} + +/** Print the flow header — the brand logo, tagline, and the opening step. */ +export function intro(message: string, stdout: TTYOut = process.stdout): void { + if (!stdout.isTTY) return; + const c = paint(colorsEnabled(stdout)); + const lines: string[] = ["", ...renderBrandLogo(stdout)]; + lines.push(c.dim(BAR)); + lines.push(`${c.guide(STEP_ACTIVE)} ${c.bold(message)}`); + writeLines(stdout, lines); +} + +/** The branded splash the dashboard prints on launch — logomark, wordmark, and a + * tidy version/links column. Returned as ready-to-print lines (caller writes + * them). Degrades to plain text off a TTY so it stays clean in piped logs. */ +export function renderLaunchBanner(version: string, stdout: TTYOut = process.stdout): string[] { + // paint() is identity when colors are off, so the link rows are built once + // and only the header block differs between TTY and piped output. + const c = paint(colorsEnabled(stdout)); + const row = (label: string, value: string) => ` ${c.guide(label.padEnd(9))}${value}`; + const header = stdout.isTTY + ? renderBrandLogo(stdout) + : [" failproof ai", ` ${TAGLINE}`]; + return [ + "", + ...header, + "", + row("version", c.pink(version)), + row("star", c.dim("https://github.com/failproofai/failproofai")), + row("docs", c.dim("https://docs.befailproof.ai/introduction")), + row("discord", c.dim("https://discord.gg/2zjBZP7yQJ")), + "", + ]; +} + +/** Close the flow with a terminating └ line — pink on success, dim on cancel. */ +export function outro( + message: string, + opts: { ok?: boolean } = {}, + stdout: TTYOut = process.stdout, +): void { + const c = paint(colorsEnabled(stdout)); + const ok = opts.ok !== false; + if (!stdout.isTTY) { + stdout.write(message + "\n"); + return; + } + const end = ok ? c.pink(BAR_END) : c.dim(BAR_END); + const text = ok ? c.pink(message) : c.dim(message); + writeLines(stdout, [c.dim(BAR), `${end} ${text}`]); +} + +// ── shared render engine ───────────────────────────────────────────────────── + +type Region = { lastCount: number }; + +const WINDOW = 8; // visible rows before the checklist scrolls + +function repaint(out: TTYOut, region: Region, lines: string[]): void { + if (region.lastCount > 0) out.write(`${ESC}[${region.lastCount}A${ESC}[J`); + writeLines(out, lines); + region.lastCount = lines.length; +} + +function hideCursor(out: TTYOut): void { + out.write(`${ESC}[?25l`); +} +function showCursor(out: TTYOut): void { + out.write(`${ESC}[?25h`); +} + +/** Shared width for the label column so hints align into a second column. */ +function nameWidth(labels: string[]): number { + return Math.min(24, Math.max(6, ...labels.map((l) => l.length))); +} + +type DisplayRow = { kind: "header"; text: string } | { kind: "item"; index: number }; + +/** Flatten choices into section-header + item display rows. */ +function displayRows(choices: Array<{ section?: string }>): DisplayRow[] { + const rows: DisplayRow[] = []; + let lastSection: string | undefined; + choices.forEach((choice, index) => { + if (choice.section && choice.section !== lastSection) { + lastSection = choice.section; + rows.push({ kind: "header", text: choice.section }); + } + rows.push({ kind: "item", index }); + }); + return rows; +} + +/** Compute a viewport window over display rows, centred on the cursor row. */ +function viewport(rows: DisplayRow[], cursorRow: number, window: number) { + if (rows.length <= window) return { start: 0, end: rows.length }; + let start = cursorRow - Math.floor(window / 2); + start = Math.max(0, Math.min(start, rows.length - window)); + return { start, end: start + window }; +} + +// ── shared prompt engine ────────────────────────────────────────────────────── +// One copy of the raw-mode keypress loop, viewport frame, and ◇ collapse shared +// by selectOne and multiSelect — the two prompts differ only in row glyphs and +// non-navigation key handling. + +interface PromptSpec { + stdin: TTYIn; + stdout: TTYOut; + message: string; + c: ReturnType; + /** Static info lines rendered under the question. */ + body?: string[]; + choices: Array<{ label: string; section?: string }>; + /** Row content after the gutter, e.g. "● Label hint". */ + renderRow: (index: number, active: boolean, budget: number) => string; + /** Extra line(s) above the footer (e.g. a min-selected warning). */ + warnLine?: () => string | null; + footer: string; + /** Handle non-navigation keys. `{done}` finishes, `"redraw"` repaints. */ + onKey: (key: readline.Key, cursor: number) => { done: R } | "redraw" | undefined; + /** One-line ◇ summary for the collapsed log entry. */ + summaryFor: (result: R | null) => string; +} + +function runPrompt(p: PromptSpec): Promise { + const { stdin, stdout, c, choices } = p; + const region: Region = { lastCount: 0 }; + const nameCol = nameWidth(choices.map((ch) => ch.label)); + let cursor = 0; + + const build = (): string[] => { + const cols = stdout.columns || 80; + const lines: string[] = [c.dim(BAR), `${c.guide(STEP_ACTIVE)} ${c.bold(p.message)}`]; + for (const b of p.body ?? []) lines.push(`${c.dim(BAR)} ${c.dim(b)}`); + + const rows = displayRows(choices); + let cursorRow = 0; + rows.forEach((r, ri) => { + if (r.kind === "item" && r.index === cursor) cursorRow = ri; + }); + const { start, end } = viewport(rows, cursorRow, WINDOW); + const above = rows.slice(0, start).filter((r) => r.kind === "item").length; + const below = rows.slice(end).filter((r) => r.kind === "item").length; + if (above > 0) lines.push(`${c.dim(BAR)} ${c.dim(`↑ ${above} more`)}`); + + const budget = Math.max(6, cols - nameCol - 10); + for (let ri = start; ri < end; ri++) { + const row = rows[ri]; + if (row.kind === "header") { + lines.push(`${c.dim(BAR)} ${c.dim(row.text)}`); + } else { + lines.push(`${c.dim(BAR)} ${p.renderRow(row.index, row.index === cursor, budget)}`); + } + } + if (below > 0) lines.push(`${c.dim(BAR)} ${c.dim(`↓ ${below} more`)}`); + + const warn = p.warnLine?.(); + if (warn) lines.push(`${c.dim(BAR)} ${warn}`); + lines.push(`${c.dim(BAR)} ${c.dim(p.footer)}`); + return lines; + }; + + const collapse = (result: R | null): void => { + repaint(stdout, region, [ + c.dim(BAR), + `${c.dim(STEP_DONE)} ${p.message}`, + `${c.dim(BAR)} ${c.dim(p.summaryFor(result))}`, + ]); + }; + + return new Promise((resolve) => { + hideCursor(stdout); + repaint(stdout, region, build()); + readline.emitKeypressEvents(stdin); + const wasRaw = stdin.isRaw; + stdin.setRawMode?.(true); + stdin.resume(); + + const cleanup = () => { + stdin.removeListener("keypress", onKey); + stdin.setRawMode?.(wasRaw ?? false); + stdin.pause(); + showCursor(stdout); + }; + const finish = (result: R | null) => { + cleanup(); + collapse(result); + resolve(result); + }; + + function onKey(_s: string | undefined, key: readline.Key): void { + if (!key) return; + if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { + finish(null); + } else if (key.name === "up") { + cursor = cursor > 0 ? cursor - 1 : choices.length - 1; + repaint(stdout, region, build()); + } else if (key.name === "down") { + cursor = cursor < choices.length - 1 ? cursor + 1 : 0; + repaint(stdout, region, build()); + } else { + const outcome = p.onKey(key, cursor); + if (outcome === "redraw") repaint(stdout, region, build()); + else if (outcome) finish(outcome.done); + } + } + + stdin.on("keypress", onKey); + }); +} + +// ── selectOne (radio) ───────────────────────────────────────────────────────── + +export function selectOne(opts: SelectOneOptions): Promise { + const stdin: TTYIn = opts.stdin ?? process.stdin; + const stdout: TTYOut = opts.stdout ?? process.stdout; + const choices = opts.choices; + + // Guard empty choices before the TTY branch too — otherwise the Enter handler + // dereferences choices[0].value on a TTY. Matches the non-TTY null behavior. + if (!choices.length) return Promise.resolve(null); + + if (!stdin.isTTY || !stdout.isTTY) { + return Promise.resolve(choices[0].value); + } + + const c = paint(colorsEnabled(stdout)); + const nameCol = nameWidth(choices.map((ch) => ch.label)); + + return runPrompt({ + stdin, + stdout, + message: opts.message, + c, + body: opts.body, + choices, + renderRow: (index, active, budget) => { + const choice = choices[index]; + const dot = active ? c.pink(RADIO_ON) : c.dim(RADIO_OFF); + const rawLabel = choice.label.padEnd(nameCol); + const label = active ? c.pinkBold(rawLabel) : rawLabel; + const hint = choice.hint ? ` ${c.dim(ellipsize(choice.hint, budget))}` : ""; + return `${dot} ${label}${hint}`; + }, + footer: "↑/↓ navigate · enter to select · esc to cancel", + onKey: (key, cursor) => + key.name === "return" ? { done: choices[cursor].value } : undefined, + summaryFor: (value) => + value === null + ? "cancelled" + : (choices.find((ch) => ch.value === value)?.label ?? String(value)), + }); +} + +// ── multiSelect (checklist) ──────────────────────────────────────────────────── + +export function multiSelect(opts: MultiSelectOptions): Promise { + const stdin: TTYIn = opts.stdin ?? process.stdin; + const stdout: TTYOut = opts.stdout ?? process.stdout; + const choices = opts.choices; + const minSelected = opts.minSelected ?? 0; + const noun = opts.summaryNoun ?? "selected"; + const checked = choices.map((ch) => !!ch.checked); + + if (!stdin.isTTY || !stdout.isTTY) { + return Promise.resolve(choices.filter((_, i) => checked[i]).map((ch) => ch.value)); + } + + const c = paint(colorsEnabled(stdout)); + const nameCol = nameWidth(choices.map((ch) => ch.label)); + let warn = false; + + return runPrompt({ + stdin, + stdout, + message: opts.message, + c, + choices, + renderRow: (index, active, budget) => { + const choice = choices[index]; + const caret = active ? c.pink(CARET) : " "; + const box = checked[index] ? c.pink(CHECK_ON) : c.dim(CHECK_OFF); + const rawLabel = choice.label.padEnd(nameCol); + const label = active ? c.pinkBold(rawLabel) : checked[index] ? rawLabel : c.dim(rawLabel); + const hint = choice.hint ? ` ${c.dim(ellipsize(choice.hint, budget))}` : ""; + return `${caret} ${box} ${label}${hint}`; + }, + warnLine: () => (warn ? c.warn(`Select at least ${minSelected}.`) : null), + footer: opts.hint ?? "↑/↓ move · space select · ctrl+a all · enter confirm", + onKey: (key, cursor) => { + if (key.name === "space") { + checked[cursor] = !checked[cursor]; + warn = false; + return "redraw"; + } + if (key.ctrl && key.name === "a") { + const allOn = checked.every(Boolean); + for (let i = 0; i < checked.length; i++) checked[i] = !allOn; + return "redraw"; + } + if (key.name === "return") { + const selected = choices.filter((_, i) => checked[i]).map((ch) => ch.value); + if (selected.length < minSelected) { + warn = true; + return "redraw"; + } + return { done: selected }; + } + return undefined; + }, + summaryFor: (values) => + values === null + ? "cancelled" + : summarize( + choices.filter((ch) => values.includes(ch.value)).map((ch) => ch.label), + noun, + ), + }); +} diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 31636f26..9a851469 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -237,6 +237,24 @@ export const COPILOT_TOOL_MAP: Record = { web_fetch: "WebFetch", }; +/** + * Copilot CLI tool-input key canonicalization, keyed by CANONICAL tool name. + * + * Verified live against Copilot CLI 1.0.71: the snake_case hook events + * (PreToolUse/PostToolUse) deliver `tool_name` already canonical (`Bash`, + * `Read`, `Write`, `Edit`, `Grep`) but the file tools' input keys are + * Copilot's own — `path` instead of `file_path`, Write's content as + * `file_text`, Edit's strings as `old_str`/`new_str`. Without this map, + * path/content builtins (block-env-files, block-secrets-write) silently + * allowed Copilot file access (a live `.env` read was observed passing). + * Bash's `command` and Grep's `pattern` are already canonical. + */ +export const COPILOT_TOOL_INPUT_MAP: Record> = { + Read: { path: "file_path" }, + Write: { path: "file_path", file_text: "content" }, + Edit: { path: "file_path", old_str: "old_string", new_str: "new_string" }, +}; + // ── Cursor Agent CLI ─────────────────────────────────────────────────────── // // Cursor delivers events under camelCase keys (`preToolUse`, `postToolUse`,