diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffbb088..47099dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,43 @@ # Changelog -## 0.0.11-beta.3 — 2026-05-28 - -### Fixes -- Treat GitHub `neutral` check-run conclusions as non-failing in the `require-ci-green-before-stop` policy (e.g. Socket Security: Pull Request Alerts when the head branch is from an outside contributor and Socket can't process it). Previously the policy treated anything other than `success` / `skipped` / `cancelled` as failing, producing false-positive Stop blocks on PRs whose only "non-green" check was an explicit `neutral` (#410). -- Fix the `bump-platform-submodule.yml` workflow's first post-merge push, which failed with `fatal: could not read Username for 'https://github.com'`. The `persist-credentials: false` hardening from #394 left the cross-repo `git push`/`fetch` unauthenticated, and the inline `Authorization: bearer …` extraheader only authenticates GitHub's REST API — git-over-HTTPS smart-protocol expects Basic auth with `x-access-token:`. Switch to a base64-encoded Basic header (matching `actions/checkout`'s own internal extraheader format) so the push and the rebase-and-retry fetch in the loop both authenticate (#395). - -### Docs -- Add `docs/.vale.ini` and a `Mintlify` Vocab accept-list to suppress noisy `Mintlify Validation (exosphere) - vale-spellcheck` CI failures. Disables `Vale.Spelling` on the 14 translated language subdirs (`ar/`, `de/`, …, `zh/`) and `i18n/`, since running an English dictionary over auto-translated content produces only noise; keeps spellcheck active on the canonical English `*.{md,mdx}` files with a project Vocab covering brand names (`failproofai`, `Claude`, `Codex`, …), CLI tooling (`npx`, `bunx`, `gcloud`, `systemctl`, …), and Claude Code event names (`PreToolUse`, `SessionStart`, …) (#410). +## 0.0.11-beta.3 — 2026-06-06 ### Features +- Point the default api-server base URL at the hosted endpoint `https://api.befailproof.ai` instead of `http://localhost:8080`. `lib/auth/api-server-client.ts:DEFAULT_API_BASE` flipped; CLI help text in `src/auth/cli.ts`, the "could not reach" CliError, the dashboard's `auth-dialog.tsx` error copy ("is it running on :8080?" → "check your network"), `docs/cli/auth.mdx`'s env-var table, and `docs/cli/environment-variables.mdx`'s authentication-section row all updated to name the new default. Local-dev contributors and self-hosted users continue to override with `FAILPROOF_API_URL=http://localhost:8080` (or whatever host they want). No behavior change for anyone who already had the env var set. +- Close five funnel gaps in audit-page telemetry. (1) `audit_dashboard_viewed` fires once when the report renders with `{score, grade, archetype, secondary, missing, transcripts_scanned, results_count, detectors_triggered}` — the existing `audit_page_viewed` only carried `state` + `has_cache`, so click-through rates against share / download / rerun events were impossible to compute without joining server logs. (2) `audit_reminder_cta_shown` (after the `/api/auth/status` probe resolves) and `audit_reminder_cta_clicked` (on press) in `return-section.tsx` close the front of the funnel `shown → clicked → AuthDialog → reminder_set` — previously we only saw the terminal `audit_reminder_set` event. (3) `auth-dialog.tsx` now emits `audit_auth_dialog_opened`, `audit_auth_dialog_dismissed` (with the step the user gave up on), and `audit_auth_dialog_succeeded`, all tagged with a new `source` prop (`"return_section"` from `ReturnSection`); combined with the existing OTP-level events we can now see exact dropoff at email entry vs OTP entry. (4) `audit_rerun_failed` fires from both `rerun-button.tsx` and `return-section.tsx`'s `handleRerun` with `kind` (`post_failed | network | timeout`) from `RerunError` — alertable on rerun reliability without parsing `/api/audit/run` server logs. (5) `api_server_unreachable` is incremented from `fetchWithTimeout` in `lib/auth/api-server-client.ts` (kind = `timeout | network`, plus the request path + method) so "the api-server is down" is one PostHog count instead of a server-log scrape; the call is a no-op on the CLI side when telemetry has not been initialised. +- Close two telemetry gaps surfaced during the audit/auth review. (1) `src/auth/cli.ts` now emits `audit_user_identity_linked` on a successful OTP verify with `source: "cli"`, carrying `user_id`, `user_email`, and `local_random_id` (= `getInstanceId()`). The dashboard's `/api/auth/login-verify` already emits the same event with `source: "audit_set_reminder_auth_dialog"`; this is the CLI sibling — without it, anyone who signed in via `failproofai auth login` stayed unjoined to their pre-auth instance events in PostHog. (2) `bin/failproofai.mjs` `policy add|remove` was emitting `cli_policy_add_success` / `cli_policy_remove_success` on the happy path but the failure path fell through to the generic `cli_parse_error` / `cli_unexpected_error` events, blocking conversion-rate analysis. The dispatch now stashes the action in `lastPolicyAction` and the top-level catch emits `cli_policy_${action}_failure` (CliError or unexpected internal error) with `error_type` + `exit_code`, mirroring the existing `cli_install_failure` / `cli_uninstall_failure` pattern. +- Instrument the auth + reminder surface with PostHog telemetry and wire the dashboard reminder set/clear flow to the api-server scheduler. Dashboard routes `app/api/auth/{login-request,login-verify,logout,reminder}/route.ts` now emit `audit_otp_requested`, `audit_otp_verified`, `audit_user_logged_out`, `audit_reminder_set`, and `audit_reminder_cleared` events (status + error_code on failure, SHA-256 email hash on the OTP-request path so we can count distinct senders without storing PII); `src/auth/cli.ts` mirrors the same OTP/login/logout events plus CLI-only `audit_cli_auth_{login_started,login_completed,logout_completed,whoami}` with attempt counters and `had_session`/`upstream` outcomes. Two new client helpers `scheduleReminder` / `cancelReminder` in `lib/auth/api-server-client.ts` POST/DELETE `/v0/reminders` so the dashboard POST/DELETE `/api/auth/reminder` now forwards to the api-server scheduler (which delivers the audit nudge via SES) while keeping the local `~/.failproofai/next-audit.json` file as the dashboard/CLI source-of-truth; upstream failure is captured into the telemetry event as `upstream: "failed"` + `upstream_error` but does not fail the request. Also restores literal `\x1b[` escape sequences in the CLI color constants that had been collapsed to raw control bytes in the prior commit. +- Add email-OTP auth wired to the Rust `failproof-api-server` (`/v0/auth/login/request`, `/login/verify`, `/token/refresh`, `/logout`, `/me`). New `failproofai auth --login | --logout | --whoami` CLI subcommand (`src/auth/cli.ts`, dispatched from `bin/failproofai.mjs`) persists tokens to `~/.failproofai/auth.json` at mode `0600` via a shared store (`lib/auth/auth-store.ts` + `lib/auth/api-server-client.ts`); the store auto-refreshes the access token within a 60s leeway window and treats refresh-token reuse / 401 as "wipe local session". Four Next.js API routes (`app/api/auth/{status,login-request,login-verify,logout}/route.ts`) proxy the same flow for the dashboard so the refresh token never reaches the browser — only `{authenticated, user}` does. The "set a reminder" CTA in `/audit`'s `return-section.tsx` now probes `/api/auth/status` on mount and, for un-authed visitors, opens a new `AuthDialog` (`app/audit/_components/auth-dialog.tsx`, styled to match the audit aesthetic: pink corner-glyphs, dashed-frame backdrop, terminal mono inputs, masked OTP entry, live resend countdown, ESC-to-close) that walks email → OTP → "you are " inline; signed-in users get a green "signed in as …" pill under the CTA. Configurable via `FAILPROOF_API_URL` (defaults to `http://localhost:8080`) and `FAILPROOFAI_AUTH_DIR` (defaults to `~/.failproofai`). +- `/audit` polish pass: simplify the "next audit" CTA to `[ install policies ]` copying the bare `failproofai policies --install` command (no longer appends per-slipping-policy short names); fix the `[ share → ]` header button to scroll to the Show-off section reliably by accounting for the sticky in-page `.app-header` height with a manual y-coord scroll + a `scroll-margin-top` fallback on `.showoff`; harden the "make poster" PNG export so the captured archetype frame no longer collides with the sigil / tagline — `show-off-cta.tsx` now `await document.fonts.ready` before capture, applies a `.capturing` class that locks every viewport-clamped font-size and grid column to an absolute value tuned for the 1100px capture width, drops `text-shadow` / `box-shadow` that html2canvas crops unpredictably, and captures with a 12px bleed on each side so the frame's corner accents and box-shadow survive the crop; and expand every archetype in `src/audit/archetypes.ts` from a single hand-written copy block to a multi-variant catalog (4–6 taglines, keyword sets, descriptions, signature blocks, "common in" / "primary risk" / closing lines per archetype, all 8 archetypes covered). A new `pickArchetypeVariant(key, seed)` picker deterministically selects one variant from each list via a djb2-seeded per-field hash mixed with a per-field axis, so the persona blurb stays stable across renders for a given seed but two different projects landing on the same archetype see different copy. `IdentitySection` consumes the resolved variant; the seed flows in from `audit-dashboard.tsx` as the inferred project name. +- Add an in-app `/audit` dashboard that turns the existing `failproofai audit` data into a personality-driven report. The page classifies every audited agent into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) via a weighted classifier (`src/audit/archetypes.ts`) that maps every builtin policy + every audit-only detector (47/47 coverage) to an archetype with a tuned weight. A scoring module (`src/audit/scoring.ts`) derives a 0-100 score with S/A/B/C/D/F grade thresholds, a projected-score uplift if every recommended policy were enabled, and a stable synthetic cohort rank. The page composes six sections — Identity (archetype hero with 8x8 pixel sigil + meta grid), Show-off CTA, Strengths (real numbers derived from the audit), Score + cohort leaderboard with distribution histogram, Findings (per-policy cards with what happened / cost / evidence / fix), Prescribed Policies (with projected-score callout), and a "re-audit in 7 days" return loop. Every audit-only detector is now mapped to its closest real-time builtin policy as the prescribed fix (`findings.ts:DETECTOR_TO_POLICY`) so the report never carries an "audit-only — no real-time policy" framing. New dashboard cache at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, helper at `src/audit/dashboard-cache.ts`); `AuditResult` schema bumped to version 2 with new fields `eventsScanned`, `projectsScanned`, `enabledBuiltinNames`. New routes `app/audit/page.tsx`, `app/api/audit/run/route.ts` (POST, in-process `runAudit()` call, module-scoped run lock that 409s on concurrent clicks), `app/api/audit/status/route.ts` (GET, drives client polling), and server action `app/actions/get-audit-result.ts` (cache read, mirrors `getHooksConfigAction`'s read-only contract). "Make poster" downloads a 2x PNG of the archetype frame via html2canvas. Navbar gains an Audit entry between Policies and Projects with a slipping-through count chip. Existing runtime policy enforcement is untouched — `policy-registry.ts` gets two additive exports (`getAllPolicies` / `setAllPolicies`) used only by the new `replay.ts:restoreReplay()` snapshot/restore so embedding `runAudit()` in a long-running process no longer wipes pre-existing registrations. Ports the brand team's design kit verbatim from `assets/audit/styles.css` (1235 lines, JetBrains Mono + VT323 via Google Fonts, Architype Stedelijk shipped locally under `public/audit/fonts/`). +- Polish pass across `/audit`, `/policies`, and `/projects`: bump base font from `13px → 14.5px` and widen `.report` from `1180px → 1380px` (with `40px` side padding) in `globals.css` so default-zoom readability stops requiring a browser zoom-in; restore `.section` vertical padding to `64px` to match the audit reference. Remove the second in-page audit `
` (`app/audit/_components/app-header.tsx` deleted) and all three of its mount sites in `audit-dashboard.tsx` — the global navbar plus per-section masts cover the same chrome without the duplicate `failproof_ai / AUDIT [share →]` strip. Rewrite `score-section.tsx` end-to-end: drop the synthetic cohort leaderboard and replace with a single dashed-frame `.panel` (the new `.score-share-card`) split into two columns — left is the audit score (big tier-colored number, tier badge, progress bar to the next grade band, three stat boxes for missing policies / pts-to-next / est. days-to-fix, plus a top-N policy-status chip strip), right is share (pre-written X / Twitter and LinkedIn templates derived from `score + archetype + missing-count`, `[share on X]`, `[share on LinkedIn]`, and `[download audit card]` that html2canvas-captures the whole panel as a PNG named `failproofai-card--.png`). `audit-dashboard.tsx` drops the now-unused `syntheticRank` import / `rank` prop and threads `result` into the score section. Replace `empty-state.tsx` and `run-progress.tsx` with audit-pixel-craft versions: a `.empty-panel` with a pixel-grid sigil, Architype Stedelijk headline, and `.btn-press` CTA replaces the shadcn `Button` + `lucide-react` icon center-card; the running view becomes a terminal-style `.running-panel` (`$ failproofai audit --since 30d ▮` header with a blinking pink cursor, stage list with `✓` / `▮▮` / `○` markers and a per-stage braille spinner, and a marquee `audit-bar-fill` progress bar). Persistent **next-audit reminder** added — new `~/.failproofai/next-audit.json` (mode 0600, separate file from `auth.json` so the reminder is independent of token refresh), new `lib/auth/auth-store.ts` helpers (`readReminder` / `writeReminder` / `deleteReminder` / `getReminderFilePath` + `StoredReminder` type), new `app/api/auth/reminder/route.ts` (GET / POST / DELETE, defaults to a 7-day offset, scoped to the active session so a reminder for `a@x.com` is invisible to a CLI-authed `b@x.com`), and `/api/auth/status` now returns `reminder: { next_audit_at, user_email, set_at } | null` alongside the user. `return-section.tsx` flips behavior accordingly: signed in + reminder set → status panel ("next audit set for ` · in 7 days`" + "signed in as ``" + a `[re-audit now]` button next to `[install policies]` and a tiny "clear reminder" link); anon → `[set a reminder]` opens the existing AuthDialog and on successful sign-in writes the reminder automatically; signed in + no reminder → `[set a reminder]` writes it directly with no dialog. The `[re-audit now]` button (also shown to anon users with audit data) reuses the existing `triggerRun` poller and reloads the page once the run completes. No new dependencies; the deleted `app-header.tsx` was a 38-line component with no callers other than the three audit-dashboard mounts. +- Unify the dashboard design system around the brutalist pixel-craft aesthetic that previously lived only in `/audit`. The audit token set (`--bg`, `--ink`, `--accent-pink`, `--accent-green`, `--font-mono` → JetBrains Mono, `--font-display` → Architype Stedelijk / VT323) is now declared once in `app/globals.css`, and every shadcn-style Tailwind alias (`--background`, `--card`, `--foreground`, `--primary`, `--border`, `--radius: 0`, …) is repointed at the audit palette so existing utility classes like `bg-card` / `text-foreground` / `border-border` produce audit visuals across the whole app without rewriting any component markup. The `:root` block, body cross-hatch + grain overlays, JetBrains Mono import, and all canonical chrome classes (`.app-header`, `.h-brand*`, `.btn`, `.btn-press`, `.tabs`, `.tab`, `.section`, `.section-mast`, `.section-h`, `.report`, plus a new reusable `.panel` with pink corner brackets) are promoted to `globals.css`. `app/audit/audit-styles.css` keeps only the audit-page-only widgets (archetype frame, sigil, score grade, leaderboard, findings cards, return hook, auth dialog), so the styles loaded specifically by `/audit` no longer leak into `/policies` or `/projects` on client-side navigation. `app/layout.tsx` drops the `next/font/google` Geist Mono import — fonts now ship via the single CSS `@import url('…JetBrains+Mono…')` in `globals.css`. `components/navbar.tsx` is rewritten around `.app-header` with the pink `▮▮` mark, lowercase Architype wordmark, optional version chip, a current-section eyebrow, and `.tab` links with sharp pink underline on the active route (lucide icons in the bar removed). `app/projects/page.tsx` and its `loading.tsx` are wrapped in the `.report` + `.section` + `.panel` chrome with a green-eyebrow masthead and "your agent footprint." section heading; the inner `ProjectList` component is unchanged and picks up the unified palette automatically. `app/policies/hooks-client.tsx` swaps its outer `
` for a `.report` + `.section` shell with audit masthead copy ("what your agents tried." / "what to stop them doing."), replaces the rounded-pill `TabBar` with the global `.tabs` / `.tab` underline tabs, and drops the now-redundant "Back to /projects" link (the new navbar covers cross-page navigation). No functional changes — all 1701 tests pass and the production `next build` succeeds. - Add a `bump-platform-submodule.yml` workflow that pushes a matching `failproofai/oss` gitlink bump to `FailproofAI/platform` `main` on every merge into this repo's `main`, so the monorepo's pinned submodule commit tracks upstream automatically. Uses a `PLATFORM_BUMP_TOKEN` repo secret (fine-grained PAT, contents: read & write on `FailproofAI/platform`) for cross-repo auth, a concurrency group to serialize back-to-back merges, and a rebase-and-retry loop to stay race-safe against humans pushing to platform `main` between checkout and push (#394). - Add a supply-chain security CI gate: OSV-Scanner (`.github/workflows/osv-scanner.yml`) scans the resolved `bun.lock` tree against OSV.dev (GitHub/npm advisories + the OpenSSF malicious-packages feed) on every PR (incl. Dependabot bumps), on pushes to `main`, and weekly, and **blocks on any known-vulnerable or malicious dependency**. Adds a Socket GitHub App behavioral early-warning layer, an `osv-scanner.toml` allow-list for unfixable advisories, a README supply-chain status badge, and a `SECURITY.md` policy/runbook. Remediates the 18 pre-existing transitive advisories surfaced by the new gate (brace-expansion, flatted, minimatch, picomatch, postcss, vite, ws) by refreshing `bun.lock` within range, with `overrides` pinning `postcss` to the patched 8.5.x line (Next.js pins the vulnerable 8.4.31) and holding `eslint-plugin-react-hooks` at main's 7.0.1 so the refresh doesn't also bump the linter (#391). - Stamp `product: "failproofai-oss"` on every PostHog event across all four telemetry channels — hooks/audit (`trackHookEvent`), server (`trackEvent`), web UI (`captureClientEvent`), and npm-lifecycle install/uninstall (`trackInstallEvent`) — so OSS events stay distinguishable from any future hosted surface. The value lives in a single `POSTHOG_PRODUCT` constant in `src/posthog-key.ts`, reused by the three TypeScript channels; the standalone `scripts/install-telemetry.mjs` inlines the same literal because it can't import the TS module at install time. Honors `FAILPROOFAI_TELEMETRY_DISABLED=1` like all other telemetry (#380). +### Fixes +- Tier-C polish + efficiency pass from the deferred-review plan. **`app/audit/_components/audit-dashboard.tsx`** — `detectorsTriggered` + `missing` were two independent O(N) scans over `result.results` per render; merged into a single `useMemo` keyed on `result`. The scroll handler now coalesces events through `requestAnimationFrame` so reading `scrollHeight` (a layout-reflow trigger) fires at most once per frame instead of dozens per second during a fast scroll. **`app/audit/_components/policies-section.tsx`** — wrapped `buildPolicyCards(result)` in `useMemo`; previously it rebuilt a `Map + Set + sort` aggregation on every parent re-render. **`app/audit/_components/identity-section.tsx`** — wrapped `pickArchetypeVariant(archetypeKey, seed)` in `useMemo`; previously re-hashed the seed string and ran four `xmur3` mix passes per axis on every IdentitySection state change (the share buttons toggle `downloadState` which rerenders us 4× per click). **`app/audit/_components/return-section.tsx`** — added a 5s throttle to the focus + visibilitychange handlers' `refreshStatus()` calls so rapid alt-tabbing doesn't thrash `/api/auth/status` (two disk reads each); also extracted a `` helper that takes a `showSetReminder` slot so the authed and anon branches stop duplicating the `[ re-audit now ]` + `[ install policies ]` buttons (they had already drifted on `marginTop` styling). **`bin/failproofai.mjs`** — `policy remove ` no longer threads `--beta` into the manager call as `betaOnly`; the manager only used `betaOnly` for telemetry tagging (`removal_mode: "beta_policies"`), so passing `--beta` to `policy remove` was a mislabel that produced ghost "beta removal" events in PostHog without affecting which policy was actually removed. The flag is dropped from this path so `beta_only: false` is emitted unconditionally — match the actual semantics. No tests changed; 1769 still pass. + +- Tier-B refactor pass from the deferred-review plan. Consolidates the duplication the prior review surfaced before it can cause another drift (the prior PR already shipped `REQUEST_TIMEOUT_MS=15s` on the client and `=10s` on the server with no comment tying them together). **`lib/fetch-with-timeout.ts`** — new shared module exporting `fetchWithTimeout(input, init, timeoutMs)` and an `isAbortError(err)` predicate. Replaces three byte-equivalent implementations: the client-side helpers in `app/audit/_components/auth-dialog.tsx` + `rerun-button.tsx` (15s default) and the inline `err.name === "AbortError" || err.name === "TimeoutError"` check inside `lib/auth/api-server-client.ts`'s server-side wrapper (which keeps its `trackEvent`-on-timeout side-effect but delegates the predicate). Also pulls `app/audit/_components/return-section.tsx` onto the same `isAbortError` predicate. **`lib/atomic-write.ts`** — new shared module exporting `writeJsonAtomically(filePath, value, { mode, dirMode })`. Replaces the near-identical temp-file-then-rename dances in `lib/auth/auth-store.ts` (for `auth.json` + `next-audit.json`) and the inline version in `src/audit/dashboard-cache.ts` (added in the prior PR). Single helper means any future on-disk JSON writer gets the same crash-safety + perm-reassertion logic without copy-pasting. **`app/audit/_components/rerun-button.tsx`** — deleted the unused `` React component (exported but never rendered by anyone — the rerun UI is integrated into `return-section.tsx` and `empty-state.tsx` which call `triggerRun` directly). The deletion also drops the stale `lucide-react` / `usePostHog` / `cn` imports and a duplicate `audit_rerun_failed` capture branch that would never fire from this file. **`lib/telemetry.ts`** — `initTelemetry()` now wraps its **entire** body in a single outer try/catch with a "never throws" guarantee documented on the function. Removed the now-redundant per-route `try { await initTelemetry(); } catch {}` wrapper from `app/api/auth/login-verify/route.ts` (and the file comment now points future readers at the helper-level contract so they don't re-add a defensive wrapper). Net delta: ~30 LOC deleted across the touched files plus 2 new shared modules. + +- Tier-A correctness pass from the deferred-review plan. **`lib/auth/auth-store.ts`** — `getValidAccessToken` and `whoAmI`'s post-401 retry now dedup in-flight refresh-token exchanges through a `Map>`. Without it, two concurrent callers (the `/api/auth/status` poll + an in-flight `/api/auth/reminder` POST is the canonical case) could each call `refreshAccessToken` with the same refresh token; the api-server treats the second as token-replay and revokes every session for the user (silent logout). **`app/api/audit/_state.ts`** — the run-lock now auto-expires after 5 min (matches the rerun-button's `MAX_POLL_MS`), so a SIGKILL / OOM / uncaught throw between `tryAcquireRun` and `releaseRun` can no longer 409 every subsequent POST until process restart. The header comment honestly calls out the remaining multi-worker limitation (cross-process locking needs external storage; the OSS dashboard expects a single worker). **`src/audit/cache.ts`** — added a `CACHE_SCHEMA_VERSION = 2` constant included in the cache-key check; pre-PR per-transcript cache entries are now rejected on read so the new v2 `TranscriptAuditResult` fields (`cwd`, `eventsScanned`) get populated correctly instead of silently rendering as `cwd: undefined` / `eventsScanned: 0`. **`lib/auth/api-server-client.ts`** — `decodeJwt` now strictly validates header and payload against `/^[A-Za-z0-9_-]+={0,2}$/` before calling `Buffer.from(s, "base64url")`; the legacy `Buffer.from` silently truncates illegal chars rather than throwing, so a corrupted JWT could decode to garbage that happens to parse as JSON with a numeric `exp` field and produce synthetic "valid" claims. Also added a manual `AbortSignal.any` fallback in `timeoutSignal()` so the caller-supplied `extra` signal isn't dropped on runtimes without native `AbortSignal.any` (Node < 20.3, older Bun). **`docs/docs.json`** — added `cli/auth` and `cli/audit` to the Mintlify CLI nav group so both the new auth subcommand doc and the rewritten audit doc are reachable from the sidebar (the docs existed on disk but weren't discoverable). **+10 tests:** `__tests__/lib/auth-store-refresh.test.ts` (3 — concurrent refresh dedup, retry on failure, sequential calls), `__tests__/api/audit-state.test.ts` (5 — acquire/release contract, auto-expiry, multi-release no-op), `__tests__/lib/api-server-client.test.ts` (2 — illegal-base64url rejection, empty-payload rejection). 1769 tests pass total. + +- Max-effort code-review hardening pass on the same branch. **`src/audit/findings.ts:293`** — corrected `failproof policy add ${slug}` → `failproofai policy add ${slug}` so the "fix" install command on every finding card is actually copy-pasteable. **`app/layout.tsx:25`** — fixed `icons.icon` pointer from the deleted `/icon.png` to the live `/public/icon.svg` so the dashboard favicon stops 404'ing. **`lib/auth/auth-store.ts:246`** — `whoAmI()`'s 401-retry catch now only wipes `auth.json` on an unambiguous 401, matching `getValidAccessToken`'s contract; a transient timeout / 5xx during the post-refresh `/me` no longer throws away the freshly-written valid tokens. **`app/api/auth/{login-request,login-verify}/route.ts`** — `AuthApiError` from a client-side timeout has `status: 0`, which `NextResponse.json(..., { status: 0 })` rejects with `RangeError`; both routes now map any out-of-range status to 504 so the browser sees a real status code instead of a 500 stack trace. **`src/audit/index.ts:311`** — the per-transcript scan error fallback now emits the v2-required `cwd: ""` and `eventsScanned: 0` fields so errored transcripts don't silently drop from `projectsScanned` / `eventsScanned` aggregates. **`src/audit/dashboard-cache.ts:73`** — `writeDashboardCache` now writes atomically (temp file → rename) so a concurrent `readDashboardCache` from the 1s status poll can't observe a torn JSON file; the directory is created with mode `0700` and `DASHBOARD_CACHE_SCHEMA_VERSION` is bumped to `2` (matching the `AuditResult.version 1→2` bump) so stale v1 caches are properly rejected to the empty state instead of rendering as "0 tool calls". **`app/api/auth/reminder/route.ts:117`** — added an upper-bound guard on `body.at` (rejects values > `now + MAX_OFFSET_DAYS`) that catches the common `Date.now()` (ms) vs unix-seconds foot-gun — would otherwise persist a year-55000 reminder. **`lib/auth/api-server-client.ts:97`** — `parseError` now clamps `Retry-After` to `[0, 86400]` so a misbehaving server can't tell the dashboard to "wait -3600s" or "wait 1e20s". **`app/audit/_components/auth-dialog.tsx:144`** — `requestCode` accepts an `{ isResend: true }` opt; on resend failures it now shows the error inline on the OTP step instead of bouncing the dialog back to the email step (the previously-sent code may still be usable). **`app/audit/_components/return-section.tsx:175`** — removed a duplicate `audit_set_reminder_clicked` capture that fired one line after `audit_reminder_cta_clicked` with the same property bag (was splitting funnels). **`app/audit/_components/audit-dashboard.tsx:226`** — renamed `const window = inferWindow(params)` → `scopeWindow` so future maintenance can't accidentally hit `"30d".location` via the shadowed global. **`__tests__/lib/api-server-client.test.ts:122`** — the `cancelReminder` test now also asserts the `Authorization: Bearer ` header so a regression there can't ship silently. **`docs/cli/audit.mdx`** — replaced the docs for the removed `failproofai audit` CLI subcommand with a description of the `/audit` dashboard page (the audit functionality moved to the dashboard in this PR but the doc still told users to run `failproofai audit`). **`docs/cli/auth.mdx`** — updated the canonical examples from the legacy `auth --login` / `--logout` / `--whoami` flag form to the current subcommand form (`auth login` / `logout` / `whoami`), with a note that the legacy form is still accepted as an alias. + +- CodeRabbit-flagged hardening pass across `/audit` + `/auth`. `app/api/auth/login-verify/route.ts` wraps `initTelemetry()` in its own `try/catch` so a telemetry-init failure can no longer turn a valid OTP verify into a 500. Both the dashboard route and `src/auth/cli.ts` drop `user_email` from the `audit_user_identity_linked` event payload — `user_id` + `local_random_id` are sufficient for anon→authed session stitching and shipping raw PII to analytics was avoidable. `bin/failproofai.mjs`'s typo-suggestion `primary` array now includes `policy` alongside `policies` / `auth` / `--*` so closest-match no longer steers users at the wrong subcommand. `src/audit/dashboard-cache.ts` explicitly rejects `params: null` / `result: null` during structural validation (`typeof null === "object"` was letting those slip through to the renderer). `app/audit/_components/return-section.tsx`'s `persistReminder()` now (1) flips local auth state back to `anon` on a 401 from `/api/auth/reminder` so the UI doesn't get stuck on an authed panel whose actions silently no-op, and (2) wraps the fetch in a 10s `AbortController` so a hung route can't permanently disable the `[ set a reminder ]` CTA. `app/audit/_components/rerun-button.tsx`'s `triggerRun` + status poll now use a `fetchWithTimeout(15s)` wrapper so a single stalled request can't hang the poll loop indefinitely; per-request timeouts surface as `RerunError.kind === "timeout"` distinct from `"network"`. `app/audit/_components/auth-dialog.tsx`'s `requestCode` + `verifyCode` both wrap their fetches in `fetchWithTimeout(15s)` and surface `AbortError` as a user-readable "request timed out" string so a hung api-server can no longer wedge the modal in `busy` state. + +- Audit + auth hardening sweep across the dashboard and CLI surfaces. `lib/auth/auth-store.ts` now writes `auth.json` and `next-audit.json` atomically (temp-file-then-rename with the same `0600` perm enforcement on both temp and final paths) so a concurrent write or crash can no longer leave a half-written / truncated session file behind. `lib/auth/api-server-client.ts` puts a 10s `AbortSignal.timeout` on every `/v0/auth/*` and `/v0/reminders` fetch — a wedged DNS resolver, a hung api-server, or a stalled refresh no longer pins the CLI or a dashboard request indefinitely; timeouts now surface as `AuthApiError(code: "timeout")` so callers can render the same "could not reach the api-server" copy already wired for transport failures. `src/auth/cli.ts` `runLogin()` now treats `auth.json` as stale when its `refresh_expires_at` claim has lapsed locally — instead of bouncing the user with "already signed in" against a file the server would reject on the first /me, it wipes the stale file and walks through the OTP flow again (telemetry now carries `replaced_stale: true` on the resulting `_login_started` event). `app/api/auth/reminder/route.ts` distinguishes empty body (defaults to a 7-day offset) from malformed JSON / non-object body (now 400 with a `validation_error` code instead of silently coercing to `{}` and writing a default-offset reminder). `app/api/audit/run/route.ts` likewise rejects `null`, arrays, and primitives in the request body with a 400 instead of letting `sanitize(null)` 500 — guards both the JSON.parse path and the post-parse shape check. `app/audit/_components/rerun-button.tsx` now `throw`s a `RerunError` on POST failure / network failure / poll-loop timeout so the button can render a distinct "rerun failed — retry" pink-border state for 4s instead of pretending the run completed; `triggerRun` is now typed as `Promise` that explicitly throws, so the EmptyState CTA can adopt the same UX. `app/audit/_components/run-progress.tsx` caps the fake-progress bar at 90% and swaps the last-stage detail to "finishing up…" so a run that genuinely takes 30s no longer paints 4/4 + 100% at the 16s mark. `app/audit/_components/identity-section.tsx` LinkedIn-share copy "every key policy is live" now requires both `grade === "A"` AND `missing === 0` (previously any A-grade triggered the verbatim "every key policy is live" copy even when there were unenabled prescribed policies). `src/audit/dashboard-cache.ts` adds an explicit `schemaVersion` field on the cached entry; entries written by older code versions are now rejected as null instead of being rendered against the wrong shape. `assets/audit/archetypes.jsx` `Sigil()` normalizes an unknown archetypeKey once at the top so the index lookup uses the same safe key as the sigil grid (previously the sigil lookup had a fallback but `ARCHETYPES[archetypeKey].index` crashed on unknown keys). `app/audit/_components/score-section.tsx` drops the `useMemo` around `pointsToNext` that tripped `react-hooks/preserve-manual-memoization` — replaced with a plain `pointsToNextFor(score)` scan of 5 thresholds. +- Treat GitHub `neutral` check-run conclusions as non-failing in the `require-ci-green-before-stop` policy (e.g. Socket Security: Pull Request Alerts when the head branch is from an outside contributor and Socket can't process it). Previously the policy treated anything other than `success` / `skipped` / `cancelled` as failing, producing false-positive Stop blocks on PRs whose only "non-green" check was an explicit `neutral` (#410). +- Fix the `bump-platform-submodule.yml` workflow's first post-merge push, which failed with `fatal: could not read Username for 'https://github.com'`. The `persist-credentials: false` hardening from #394 left the cross-repo `git push`/`fetch` unauthenticated, and the inline `Authorization: bearer …` extraheader only authenticates GitHub's REST API — git-over-HTTPS smart-protocol expects Basic auth with `x-access-token:`. Switch to a base64-encoded Basic header (matching `actions/checkout`'s own internal extraheader format) so the push and the rebase-and-retry fetch in the loop both authenticate (#395). + +### Tests +- Add coverage for previously untested audit + auth modules: `__tests__/audit/archetypes.test.ts` (zero-signal → precision, broad-spread → goldfish, secondary ≥40% promotion vs authored fallback, deterministic variant picker), `__tests__/audit/findings.test.ts` (ranking, zero-hit drop, detector→policy remapping, `alsoCoveredBy`, `alreadyEnabled` enable-set + builtin-config heuristics, relative-time + missing `lastSeen` fallback), `__tests__/audit/strengths.test.ts` (clean-rate headline, credential / retry / push-to-main absence gates, 5-item cap, fallback row when too few qualify), and `__tests__/lib/auth-store.test.ts` (round-trip, mode 0600, atomic write leaves no `.tmp` siblings, shape-mismatch rejection, reminder scoping, atomic overwrite). +40 tests; full suite at 1741 passing. + ### Docs +- Extend `docs/cli/auth.mdx` with a "Persistent re-audit reminder" section covering the new `~/.failproofai/next-audit.json` file and the `GET / POST / DELETE /api/auth/reminder` dashboard endpoint that backs the `/audit` `[ set a reminder ]` CTA — including the file shape, the per-email scoping rule, and the 7-day default offset. +- Document the new `failproofai auth --login | --logout | --whoami` subcommand in a dedicated `docs/cli/auth.mdx` page (mirrors the style of `cli/audit.mdx`: usage block, sign-in / sign-out / whoami sections, on-disk `auth.json` shape, env-var table, and a short troubleshooting list for the common `Could not reach the api-server` / `Rate limited` / `Code rejected` cases). Add an Authentication section to `docs/cli/environment-variables.mdx` covering `FAILPROOF_API_URL` (override the api-server base URL) and `FAILPROOFAI_AUTH_DIR` (override where `auth.json` is stored). i18n mirrors left for the translation-sync workflow. +- Add `docs/.vale.ini` and a `Mintlify` Vocab accept-list to suppress noisy `Mintlify Validation (exosphere) - vale-spellcheck` CI failures. Disables `Vale.Spelling` on the 14 translated language subdirs (`ar/`, `de/`, …, `zh/`) and `i18n/`, since running an English dictionary over auto-translated content produces only noise; keeps spellcheck active on the canonical English `*.{md,mdx}` files with a project Vocab covering brand names (`failproofai`, `Claude`, `Codex`, …), CLI tooling (`npx`, `bunx`, `gcloud`, `systemctl`, …), and Claude Code event names (`PreToolUse`, `SessionStart`, …) (#410). - Update the README logo (EN + 14 translated READMEs) from `logo-wordmark.png` to the new `fa_updated_full.svg` wordmark served on befailproof.ai (#387). - Change the README supply-chain badge from the live OSV-Scanner workflow-status badge (`supply chain: passing`) to a static `supply chain: secure` badge, still linked to the workflow runs (#393). diff --git a/__tests__/api/audit-state.test.ts b/__tests__/api/audit-state.test.ts new file mode 100644 index 00000000..ffaf4bc8 --- /dev/null +++ b/__tests__/api/audit-state.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { tryAcquireRun, releaseRun, getRunState } from "../../app/api/audit/_state"; + +const LOCK_MAX_AGE_MS = 5 * 60_000; + +describe("audit run-lock state", () => { + beforeEach(() => { + // Belt-and-suspenders: tests share module state, so always reset first. + releaseRun(); + vi.useRealTimers(); + }); + + afterEach(() => { + releaseRun(); + vi.useRealTimers(); + }); + + it("the first tryAcquireRun wins and the second fails", () => { + expect(tryAcquireRun()).toBe(true); + expect(tryAcquireRun()).toBe(false); + expect(getRunState().running).toBe(true); + }); + + it("releaseRun lets the next caller acquire", () => { + expect(tryAcquireRun()).toBe(true); + releaseRun(); + expect(tryAcquireRun()).toBe(true); + }); + + it("a lock older than LOCK_MAX_AGE_MS auto-expires", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-06T00:00:00Z")); + expect(tryAcquireRun()).toBe(true); + // Jump past the expiry window. + vi.setSystemTime(new Date(Date.now() + LOCK_MAX_AGE_MS + 1000)); + expect(getRunState().running).toBe(false); + expect(tryAcquireRun()).toBe(true); + }); + + it("a lock younger than LOCK_MAX_AGE_MS stays held", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-06T00:00:00Z")); + expect(tryAcquireRun()).toBe(true); + vi.setSystemTime(new Date(Date.now() + LOCK_MAX_AGE_MS - 1000)); + expect(getRunState().running).toBe(true); + expect(tryAcquireRun()).toBe(false); + }); + + it("releaseRun on an unheld lock is a no-op", () => { + releaseRun(); + releaseRun(); + expect(getRunState().running).toBe(false); + }); +}); diff --git a/__tests__/audit/archetypes.test.ts b/__tests__/audit/archetypes.test.ts new file mode 100644 index 00000000..d257b4f6 --- /dev/null +++ b/__tests__/audit/archetypes.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { ARCHETYPES, classifyAgent, pickArchetypeVariant } from "../../src/audit/archetypes"; +import type { AuditCount, AuditResult } from "../../src/audit/types"; + +function mkRow(name: string, hits: number, opts: Partial = {}): AuditCount { + return { + name, + source: "builtin", + category: "test", + severity: "warn", + hits, + projects: 1, + examples: [], + displayTitle: name, + impact: "", + enabledInConfig: false, + installHint: "", + ...opts, + }; +} + +function mkResult(rows: AuditCount[]): AuditResult { + return { + version: 2, + scannedAt: "2026-06-01T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 }, + results: rows, + totals: { hits: rows.reduce((s, r) => s + r.hits, 0), projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 0, + enabledBuiltinNames: [], + }; +} + +describe("classifyAgent", () => { + it("returns precision when there is no signal at all", () => { + const cls = classifyAgent(mkResult([])); + expect(cls.archetype).toBe("precision"); + expect(cls.secondary).toBe(ARCHETYPES.precision.secondary); + expect(cls.totalSignal).toBe(0); + }); + + it("returns precision when every row is zero hits", () => { + const cls = classifyAgent(mkResult([mkRow("failproofai/block-rm-rf", 0)])); + expect(cls.archetype).toBe("precision"); + }); + + it("returns goldfish for broad spread (≥5 archetypes, top-3 share < 60%)", () => { + // Hand-built spread: 8 archetypes hit roughly evenly so top-3 ≤ 60%. + const cls = classifyAgent(mkResult([ + mkRow("failproofai/block-rm-rf", 5), // cowboy x2.0 = 10 + mkRow("failproofai/block-read-outside-cwd", 8), // explorer x1.2 = 9.6 + mkRow("failproofai/warn-large-file-write", 9), // ghost x1.0 = 9 + mkRow("redundant-cd-cwd", 9, { source: "audit-detector" }), // optimist x1.0 = 9 + mkRow("failproofai/warn-repeated-tool-calls", 6), // hammer x1.5 = 9 + mkRow("failproofai/reread-after-edit", 11), // architect x0.8 = 8.8 + ])); + expect(cls.archetype).toBe("goldfish"); + // Secondary should be the strongest signal so the UI can hint at it. + expect(cls.secondary).toBeDefined(); + }); + + it("promotes secondary when ≥40% of primary", () => { + const cls = classifyAgent(mkResult([ + mkRow("failproofai/block-rm-rf", 5), // cowboy x2.0 = 10 + mkRow("failproofai/block-env-files", 6), // explorer x1.5 = 9 (>= 40% of 10) + ])); + expect(cls.archetype).toBe("cowboy"); + expect(cls.secondary).toBe("explorer"); + }); + + it("falls back to authored secondary when runner-up < 40% of primary", () => { + const cls = classifyAgent(mkResult([ + mkRow("failproofai/block-rm-rf", 10), // cowboy x2.0 = 20 + mkRow("failproofai/block-env-files", 1), // explorer x1.5 = 1.5 (< 40% of 20) + ])); + expect(cls.archetype).toBe("cowboy"); + expect(cls.secondary).toBe(ARCHETYPES.cowboy.secondary); + }); + + it("ignores rows whose policy name doesn't map to a signal", () => { + const cls = classifyAgent(mkResult([ + mkRow("failproofai/some-future-unmapped-policy", 50), + ])); + // No mapped signal → still treated as the clean baseline. + expect(cls.archetype).toBe("precision"); + }); + + it("weights detector hits by hits × weight", () => { + const cls = classifyAgent(mkResult([ + mkRow("sleep-polling-loop", 5, { source: "audit-detector" }), // hammer x1.2 = 6 + ])); + expect(cls.archetype).toBe("hammer"); + expect(cls.weights.hammer).toBe(6); + }); +}); + +describe("pickArchetypeVariant", () => { + it("returns the same variant for the same seed", () => { + const a = pickArchetypeVariant("optimist", "my-project"); + const b = pickArchetypeVariant("optimist", "my-project"); + expect(a).toEqual(b); + }); + + it("can return different variants for different seeds", () => { + const variants = new Set( + ["a", "b", "c", "d", "e", "f"].map((s) => pickArchetypeVariant("optimist", s).tagline), + ); + // Out of 6 seeds we expect at least 2 distinct taglines — the picker + // would otherwise be effectively constant. + expect(variants.size).toBeGreaterThan(1); + }); +}); diff --git a/__tests__/audit/dashboard-cache.test.ts b/__tests__/audit/dashboard-cache.test.ts new file mode 100644 index 00000000..282d3fa1 --- /dev/null +++ b/__tests__/audit/dashboard-cache.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readDashboardCache, + writeDashboardCache, + isCacheStale, +} from "../../src/audit/dashboard-cache"; +import type { AuditResult } from "../../src/audit/types"; + +const FAKE_RESULT: AuditResult = { + version: 2, + scannedAt: "2026-05-26T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 5, skipped: 0, errors: 0, durationMs: 100 }, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: ["/home/u/a", "/home/u/b"], + eventsScanned: 42, + enabledBuiltinNames: ["block-failproofai-commands"], +}; + +describe("dashboard cache", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + // Redirect homedir() to a tmp directory by overriding HOME — os.homedir() + // reads it on every call on POSIX, so the dashboard-cache module sees + // our tmp path without needing module mocks. + tmpHome = mkdtempSync(join(tmpdir(), "fpa-audit-cache-test-")); + originalHome = process.env.HOME; + process.env.HOME = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("returns null when no cache file exists", () => { + expect(readDashboardCache()).toBeNull(); + }); + + it("round-trips a written entry", () => { + writeDashboardCache({ since: "7d" }, FAKE_RESULT); + const entry = readDashboardCache(); + expect(entry).not.toBeNull(); + expect(entry?.params).toEqual({ since: "7d" }); + expect(entry?.result.transcripts.scanned).toBe(5); + expect(entry?.result.projectsScanned).toEqual(["/home/u/a", "/home/u/b"]); + expect(typeof entry?.cachedAt).toBe("string"); + }); + + it("writes mode 0600 on the file", () => { + writeDashboardCache({}, FAKE_RESULT); + const cachePath = join(tmpHome, ".failproofai", "audit-dashboard.json"); + expect(existsSync(cachePath)).toBe(true); + const mode = statSync(cachePath).mode & 0o777; + // Some filesystems (FAT, etc.) can't honor mode bits perfectly — just + // assert no world-readable bit is set. + expect(mode & 0o004).toBe(0); + }); + + it("returns null for a corrupt JSON cache file", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), "{ not json", "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("returns null when shape is wrong", () => { + const dir = join(tmpHome, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "audit-dashboard.json"), JSON.stringify({ foo: 1 }), "utf-8"); + expect(readDashboardCache()).toBeNull(); + }); + + it("isCacheStale returns true past the threshold", () => { + const old = new Date(Date.now() - 60 * 60_000).toISOString(); // 1 hour ago + expect(isCacheStale(old, 30)).toBe(true); + }); + + it("isCacheStale returns false within the threshold", () => { + const recent = new Date(Date.now() - 10 * 60_000).toISOString(); // 10 min ago + expect(isCacheStale(recent, 30)).toBe(false); + }); + + it("isCacheStale treats unparseable timestamps as stale", () => { + expect(isCacheStale("not-a-date")).toBe(true); + }); +}); diff --git a/__tests__/audit/findings.test.ts b/__tests__/audit/findings.test.ts new file mode 100644 index 00000000..f5fc925c --- /dev/null +++ b/__tests__/audit/findings.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { deriveFindings } from "../../src/audit/findings"; +import type { AuditCount, AuditResult } from "../../src/audit/types"; + +function mkRow(name: string, hits: number, opts: Partial = {}): AuditCount { + return { + name, + source: "builtin", + category: "test", + severity: "warn", + hits, + projects: 1, + examples: [], + displayTitle: name, + impact: "", + enabledInConfig: false, + installHint: "", + ...opts, + }; +} + +function mkResult(rows: AuditCount[], extras: Partial = {}): AuditResult { + return { + version: 2, + scannedAt: "2026-06-01T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 }, + results: rows, + totals: { hits: rows.reduce((s, r) => s + r.hits, 0), projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 0, + enabledBuiltinNames: [], + ...extras, + }; +} + +describe("deriveFindings", () => { + it("ranks by hits desc and drops zero-hit rows", () => { + const cards = deriveFindings(mkResult([ + mkRow("failproofai/block-rm-rf", 3), + mkRow("failproofai/block-sudo", 0), // dropped + mkRow("failproofai/block-curl-pipe-sh", 9), + ])); + expect(cards.map((c) => c.sourceSlug)).toEqual([ + "block-curl-pipe-sh", + "block-rm-rf", + ]); + expect(cards[0].num).toBe("01"); + expect(cards[1].num).toBe("02"); + }); + + it("remaps a detector to its prescribed-fix policy slug", () => { + const [card] = deriveFindings(mkResult([ + mkRow("redundant-cd-cwd", 4, { source: "audit-detector" }), + ])); + expect(card.sourceSlug).toBe("redundant-cd-cwd"); + expect(card.policy).toBe("warn-repeated-tool-calls"); + expect(card.fix.slug).toBe("warn-repeated-tool-calls"); + expect(card.fix.install).toContain("warn-repeated-tool-calls"); + }); + + it("attaches `alsoCoveredBy` when the detector mapping carries an extra policy", () => { + const [card] = deriveFindings(mkResult([ + mkRow("prefer-write-over-heredoc", 2, { source: "audit-detector" }), + ])); + expect(card.fix.alsoCoveredBy).toBe("block-secrets-write"); + }); + + it("marks the fix as already-enabled when the policy is in the enabled set", () => { + const cards = deriveFindings(mkResult( + [mkRow("redundant-cd-cwd", 4, { source: "audit-detector" })], + { enabledBuiltinNames: ["warn-repeated-tool-calls"] }, + )); + expect(cards[0].alreadyEnabled).toBe(true); + }); + + it("marks already-enabled when a builtin row reports enabledInConfig", () => { + const [card] = deriveFindings(mkResult([ + mkRow("failproofai/block-rm-rf", 1, { enabledInConfig: true }), + ])); + expect(card.alreadyEnabled).toBe(true); + }); + + it("falls back to displayTitle/impact copy when no hand-written copy exists", () => { + const [card] = deriveFindings(mkResult([ + mkRow("failproofai/some-unknown-policy", 2, { + displayTitle: "Some unknown policy", + impact: "explains the impact", + }), + ])); + expect(card.body).toBe("explains the impact"); + expect(card.cost).toBe("explains the impact"); + }); + + it("injects a placeholder evidence entry when no examples were captured", () => { + const [card] = deriveFindings(mkResult([ + mkRow("failproofai/block-rm-rf", 1, { examples: [] }), + ])); + expect(card.evidence).toHaveLength(1); + expect(card.evidence[0].kind).toBe("comment"); + }); + + it("renders a relative-time lastSeen", () => { + // 2h ago + const iso = new Date(Date.now() - 2 * 60 * 60_000).toISOString(); + const [card] = deriveFindings(mkResult([ + mkRow("failproofai/block-rm-rf", 1, { lastSeen: iso }), + ])); + expect(card.lastSeen).toMatch(/^\d+h ago$/); + }); + + it("returns em-dash when lastSeen is missing", () => { + const [card] = deriveFindings(mkResult([ + mkRow("failproofai/block-rm-rf", 1), + ])); + expect(card.lastSeen).toBe("—"); + }); +}); diff --git a/__tests__/audit/replay.test.ts b/__tests__/audit/replay.test.ts index 18e7dfea..ca377b0b 100644 --- a/__tests__/audit/replay.test.ts +++ b/__tests__/audit/replay.test.ts @@ -1,6 +1,12 @@ // @vitest-environment node import { describe, it, expect, beforeEach } from "vitest"; -import { resetReplay, replayEvent } from "../../src/audit/replay"; +import { resetReplay, replayEvent, initReplay, restoreReplay } from "../../src/audit/replay"; +import { + clearPolicies, + getAllPolicies, + registerPolicy, +} from "../../src/hooks/policy-registry"; +import { allow } from "../../src/hooks/policy-helpers"; import type { NormalizedToolEvent } from "../../src/audit/types"; function bash(command: string): NormalizedToolEvent { @@ -50,3 +56,48 @@ describe("replay engine", () => { expect(hits.some((h) => h.eventType === "PostToolUse")).toBe(true); }); }); + +describe("replay registry snapshot/restore", () => { + beforeEach(() => { + resetReplay(); + clearPolicies(); + }); + + it("restoreReplay puts back the pre-init registry", () => { + registerPolicy( + "test/custom-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + const before = getAllPolicies().map((p) => p.name).sort(); + expect(before).toContain("test/custom-marker"); + + initReplay(); + const duringInit = getAllPolicies().map((p) => p.name); + expect(duringInit).not.toContain("test/custom-marker"); + expect(duringInit.length).toBeGreaterThan(10); // builtins are loaded + + restoreReplay(); + const after = getAllPolicies().map((p) => p.name).sort(); + expect(after).toEqual(before); + }); + + it("restoreReplay is idempotent when called twice", () => { + registerPolicy( + "test/another-marker", + "test policy", + async () => allow(), + { events: ["PreToolUse"] }, + ); + initReplay(); + restoreReplay(); + restoreReplay(); // second call should be a no-op + expect(getAllPolicies().map((p) => p.name)).toContain("test/another-marker"); + }); + + it("restoreReplay before initReplay is a no-op", () => { + expect(() => restoreReplay()).not.toThrow(); + expect(getAllPolicies()).toEqual([]); + }); +}); diff --git a/__tests__/audit/strengths.test.ts b/__tests__/audit/strengths.test.ts new file mode 100644 index 00000000..ccd87591 --- /dev/null +++ b/__tests__/audit/strengths.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { deriveStrengths } from "../../src/audit/strengths"; +import type { AuditCount, AuditResult } from "../../src/audit/types"; + +function mkRow(name: string, hits: number, opts: Partial = {}): AuditCount { + return { + name, + source: "builtin", + category: "test", + severity: "warn", + hits, + projects: 1, + examples: [], + displayTitle: name, + impact: "", + enabledInConfig: false, + installHint: "", + ...opts, + }; +} + +function mkResult(rows: AuditCount[], extras: Partial = {}): AuditResult { + return { + version: 2, + scannedAt: "2026-06-01T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 1, skipped: 0, errors: 0, durationMs: 0 }, + results: rows, + totals: { hits: rows.reduce((s, r) => s + r.hits, 0), projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 0, + enabledBuiltinNames: [], + ...extras, + }; +} + +describe("deriveStrengths", () => { + it("never returns more than 5 strengths", () => { + // A truly clean audit hits every absence-style strength. + const out = deriveStrengths(mkResult([], { eventsScanned: 100 })); + expect(out.length).toBeLessThanOrEqual(5); + }); + + it("leads with a clean-rate headline when there were events", () => { + const out = deriveStrengths(mkResult([], { eventsScanned: 200 })); + expect(out[0].unit).toBe("clean tool calls"); + expect(out[0].metric).toBe("100%"); + }); + + it("computes clean-rate from events - hits", () => { + const out = deriveStrengths(mkResult( + [mkRow("failproofai/block-rm-rf", 5)], + { eventsScanned: 100 }, + )); + // 95 / 100 = 95% clean + expect(out[0].metric).toBe("95%"); + }); + + it("gates the credential strength on every credential-class policy being silent", () => { + const out = deriveStrengths(mkResult( + [mkRow("failproofai/block-env-files", 2)], + { eventsScanned: 50 }, + )); + expect(out.some((s) => s.unit === "credential leaks")).toBe(false); + }); + + it("includes the credential strength when every credential-class policy is silent", () => { + const out = deriveStrengths(mkResult([], { eventsScanned: 50 })); + expect(out.some((s) => s.unit === "credential leaks")).toBe(true); + }); + + it("gates retry-storm strength on warn-repeated-tool-calls + sleep-polling-loop being silent", () => { + const out = deriveStrengths(mkResult( + [mkRow("failproofai/warn-repeated-tool-calls", 4)], + { eventsScanned: 30 }, + )); + expect(out.some((s) => s.unit === "retry storms")).toBe(false); + }); + + it("gates push-to-main strength on every git-mistake policy being silent", () => { + const out = deriveStrengths(mkResult( + [mkRow("failproofai/block-push-master", 1)], + { eventsScanned: 30 }, + )); + expect(out.some((s) => s.unit === "push-to-main attempts")).toBe(false); + }); + + it("surfaces a fallback 'audit complete' row when too few strengths qualified", () => { + // Hits in every absence category so every absence-strength is gated out. + const out = deriveStrengths(mkResult([ + mkRow("failproofai/block-env-files", 1), + mkRow("failproofai/warn-repeated-tool-calls", 1), + mkRow("failproofai/block-push-master", 1), + mkRow("failproofai/reread-after-edit", 1), + ], { eventsScanned: 0, transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 } })); + expect(out.length).toBeGreaterThanOrEqual(1); + expect(out.some((s) => s.headline === "audit complete.")).toBe(true); + }); +}); diff --git a/__tests__/lib/api-server-client.test.ts b/__tests__/lib/api-server-client.test.ts new file mode 100644 index 00000000..999e0025 --- /dev/null +++ b/__tests__/lib/api-server-client.test.ts @@ -0,0 +1,246 @@ +// @vitest-environment node +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const trackEventMock = vi.fn(); + +vi.mock("@/lib/telemetry", () => ({ + trackEvent: (...args: unknown[]) => trackEventMock(...args), +})); + +import { + AuthApiError, + cancelReminder, + decodeJwt, + requestLoginCode, + scheduleReminder, +} from "@/lib/auth/api-server-client"; + +describe("api-server-client fetchWithTimeout telemetry", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + trackEventMock.mockClear(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("fires api_server_unreachable with kind=timeout on AbortError", async () => { + globalThis.fetch = vi.fn(async () => { + const err = new Error("aborted"); + err.name = "AbortError"; + throw err; + }) as unknown as typeof fetch; + + await expect(requestLoginCode("a@b.co")).rejects.toBeInstanceOf(AuthApiError); + expect(trackEventMock).toHaveBeenCalledWith( + "api_server_unreachable", + expect.objectContaining({ + kind: "timeout", + path: "/v0/auth/login/request", + method: "POST", + }), + ); + }); + + it("fires api_server_unreachable with kind=network on connection error", async () => { + globalThis.fetch = vi.fn(async () => { + throw new TypeError("fetch failed"); + }) as unknown as typeof fetch; + + await expect(requestLoginCode("a@b.co")).rejects.toBeInstanceOf(TypeError); + expect(trackEventMock).toHaveBeenCalledWith( + "api_server_unreachable", + expect.objectContaining({ + kind: "network", + path: "/v0/auth/login/request", + method: "POST", + }), + ); + }); + + it("does NOT fire api_server_unreachable on a successful response", async () => { + globalThis.fetch = vi.fn(async () => + new Response( + JSON.stringify({ status: "code_sent", expires_in: 600, resend_available_in: 30 }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + + const out = await requestLoginCode("a@b.co"); + expect(out.status).toBe("code_sent"); + expect(trackEventMock).not.toHaveBeenCalled(); + }); +}); + +describe("scheduleReminder", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + trackEventMock.mockClear(); + }); + + it("POSTs /v0/reminders with the access token and returns the unwrapped reminder", async () => { + const reminder = { user_id: "u", email: "a@b.co", fire_at: 1, set_at: 0 }; + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ reminder }), { status: 200 }), + ) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const out = await scheduleReminder("at-1", { in_days: 7 }); + expect(out).toEqual(reminder); + const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]; + expect(init.method).toBe("POST"); + expect((init.headers as Record).authorization).toBe("Bearer at-1"); + }); + + it("throws AuthApiError on non-OK responses", async () => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ code: "rate_limited", message: "slow down" }), { status: 429 }), + ) as unknown as typeof fetch; + await expect(scheduleReminder("at-1", { in_days: 7 })).rejects.toBeInstanceOf(AuthApiError); + }); +}); + +describe("cancelReminder", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + trackEventMock.mockClear(); + }); + + it("DELETEs /v0/reminders with the access token and resolves on 204", async () => { + const fetchMock = vi.fn(async () => + new Response(null, { status: 204 }), + ) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + await expect(cancelReminder("at-1")).resolves.toBeUndefined(); + const [, init] = (fetchMock as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0]; + expect(init.method).toBe("DELETE"); + expect((init.headers as Record).authorization).toBe("Bearer at-1"); + }); + + it("throws AuthApiError on non-OK responses", async () => { + globalThis.fetch = vi.fn(async () => + new Response(JSON.stringify({ code: "unauthorized", message: "no" }), { status: 401 }), + ) as unknown as typeof fetch; + await expect(cancelReminder("at-1")).rejects.toBeInstanceOf(AuthApiError); + }); +}); + +describe("retry-after parsing in parseError", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + trackEventMock.mockClear(); + }); + + it("reads retry_after_secs from the body when present", async () => { + globalThis.fetch = vi.fn(async () => + new Response( + JSON.stringify({ code: "rate_limited", message: "slow", retry_after_secs: 42 }), + { status: 429, headers: { "content-type": "application/json" } }, + ), + ) as unknown as typeof fetch; + try { + await requestLoginCode("a@b.co"); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(AuthApiError); + expect((err as AuthApiError).retryAfterSecs).toBe(42); + } + }); + + it("falls back to the Retry-After header (numeric seconds) when body omits it", async () => { + globalThis.fetch = vi.fn(async () => + new Response( + JSON.stringify({ code: "rate_limited", message: "slow" }), + { status: 429, headers: { "content-type": "application/json", "retry-after": "17" } }, + ), + ) as unknown as typeof fetch; + try { + await requestLoginCode("a@b.co"); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(AuthApiError); + expect((err as AuthApiError).retryAfterSecs).toBe(17); + } + }); + + it("leaves retry_after undefined when the header is unparseable", async () => { + globalThis.fetch = vi.fn(async () => + new Response( + JSON.stringify({ code: "rate_limited", message: "slow" }), + { status: 429, headers: { "content-type": "application/json", "retry-after": "not-a-number" } }, + ), + ) as unknown as typeof fetch; + try { + await requestLoginCode("a@b.co"); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(AuthApiError); + expect((err as AuthApiError).retryAfterSecs).toBeUndefined(); + } + }); +}); + +describe("decodeJwt", () => { + function makeJwt(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.sig`; + } + + it("returns the parsed payload for a well-formed token", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const token = makeJwt({ sub: "user-1", email: "a@b.co", exp }); + const out = decodeJwt(token); + expect(out).not.toBeNull(); + expect(out?.sub).toBe("user-1"); + expect(out?.email).toBe("a@b.co"); + expect(out?.exp).toBe(exp); + }); + + it("returns the parsed payload even for an expired token (validation is the caller's job)", () => { + const past = Math.floor(Date.now() / 1000) - 3600; + const token = makeJwt({ sub: "user-1", email: "a@b.co", exp: past }); + expect(decodeJwt(token)?.exp).toBe(past); + }); + + it("returns null when the token doesn't have 3 parts", () => { + expect(decodeJwt("only.two")).toBeNull(); + expect(decodeJwt("just-one")).toBeNull(); + }); + + it("returns null when the payload isn't valid JSON", () => { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const bogus = Buffer.from("not-json").toString("base64url"); + expect(decodeJwt(`${header}.${bogus}.sig`)).toBeNull(); + }); + + it("returns null when exp is missing or non-numeric", () => { + const noExp = makeJwt({ sub: "u", email: "a@b.co" }); + expect(decodeJwt(noExp)).toBeNull(); + const stringExp = makeJwt({ sub: "u", email: "a@b.co", exp: "soon" }); + expect(decodeJwt(stringExp)).toBeNull(); + }); + + it("rejects payloads with illegal base64url characters instead of silently truncating", () => { + // `+` and `/` are valid base64 but not base64url; the legacy + // Buffer.from(..., 'base64url') happily truncated, which could + // produce synthetic claims that JSON.parse accepted. + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const exp = Math.floor(Date.now() / 1000) + 3600; + const goodPayload = Buffer.from(JSON.stringify({ sub: "u", email: "a@b.co", exp })).toString("base64url"); + // Inject illegal chars into the payload. + const tampered = `${header}.${goodPayload.slice(0, 4)}+/${goodPayload.slice(4)}.sig`; + expect(decodeJwt(tampered)).toBeNull(); + }); + + it("rejects an empty payload segment", () => { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + expect(decodeJwt(`${header}..sig`)).toBeNull(); + }); +}); diff --git a/__tests__/lib/auth-store-refresh.test.ts b/__tests__/lib/auth-store-refresh.test.ts new file mode 100644 index 00000000..41bfe343 --- /dev/null +++ b/__tests__/lib/auth-store-refresh.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Mock the api-server-client so refresh requests are observable + controllable. +const refreshAccessTokenMock = vi.fn(); +vi.mock("@/lib/auth/api-server-client", async () => { + const actual = await vi.importActual( + "@/lib/auth/api-server-client", + ); + return { + ...actual, + refreshAccessToken: (...args: unknown[]) => refreshAccessTokenMock(...args), + fetchMe: vi.fn(async (token: string) => ({ + id: "u", + email: "a@b.co", + status: "active", + created_at: "0", + })), + }; +}); + +import { + getValidAccessToken, + writeAuth, + type StoredAuth, +} from "../../lib/auth/auth-store"; + +function fakeAuth(overrides: Partial = {}): StoredAuth { + // Default to "needs refresh" — access expires now. + const now = Math.floor(Date.now() / 1000); + return { + access_token: "old.access", + refresh_token: "rt-1", + access_expires_at: now, + refresh_expires_at: now + 86400, + user: { id: "u", email: "a@b.co" }, + ...overrides, + }; +} + +describe("getValidAccessToken — in-flight refresh dedup", () => { + let dir: string; + let originalAuthDir: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpa-refresh-test-")); + originalAuthDir = process.env.FAILPROOFAI_AUTH_DIR; + process.env.FAILPROOFAI_AUTH_DIR = dir; + refreshAccessTokenMock.mockReset(); + }); + + afterEach(() => { + if (originalAuthDir === undefined) delete process.env.FAILPROOFAI_AUTH_DIR; + else process.env.FAILPROOFAI_AUTH_DIR = originalAuthDir; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it("two concurrent callers share one refreshAccessToken call", async () => { + writeAuth(fakeAuth()); + // Slow refresh so both callers definitely overlap. + let resolveRefresh!: (v: unknown) => void; + refreshAccessTokenMock.mockReturnValueOnce( + new Promise((res) => { resolveRefresh = res; }), + ); + + const p1 = getValidAccessToken(); + const p2 = getValidAccessToken(); + // Give both calls a tick to enter the refresh. + await new Promise((res) => setTimeout(res, 0)); + resolveRefresh({ + token_type: "Bearer", + access_token: "new.access", + access_expires_in: 3600, + refresh_token: "rt-2", + refresh_expires_in: 86400, + }); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(1); + expect(r1?.access_token).toBe("new.access"); + expect(r2?.access_token).toBe("new.access"); + }); + + it("clears the in-flight slot when refresh fails so the next call retries", async () => { + writeAuth(fakeAuth()); + refreshAccessTokenMock.mockRejectedValueOnce(new Error("upstream down")); + refreshAccessTokenMock.mockResolvedValueOnce({ + token_type: "Bearer", + access_token: "new.access", + access_expires_in: 3600, + refresh_token: "rt-2", + refresh_expires_in: 86400, + }); + + const first = await getValidAccessToken(); + expect(first).toBeNull(); + + // Re-prime the auth file (the failed call shouldn't have deleted it, + // since deletion only happens on a 401 from the AuthApiError branch). + writeAuth(fakeAuth()); + const second = await getValidAccessToken(); + expect(second?.access_token).toBe("new.access"); + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + }); + + it("a later caller after the in-flight refresh completes starts a fresh refresh", async () => { + writeAuth(fakeAuth()); + refreshAccessTokenMock.mockResolvedValue({ + token_type: "Bearer", + access_token: "new.access", + access_expires_in: 3600, + refresh_token: "rt-2", + refresh_expires_in: 86400, + }); + + await getValidAccessToken(); + // Re-flip the auth to needs-refresh so the next call hits the refresh path + // instead of short-circuiting on REFRESH_LEEWAY_SECS. + writeAuth(fakeAuth({ refresh_token: "rt-2" })); + await getValidAccessToken(); + // First call + second call = 2; dedup only applies WHILE the first is in flight. + expect(refreshAccessTokenMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/__tests__/lib/auth-store.test.ts b/__tests__/lib/auth-store.test.ts new file mode 100644 index 00000000..04ef3b69 --- /dev/null +++ b/__tests__/lib/auth-store.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + deleteAuth, + deleteReminder, + getAuthFilePath, + getReminderFilePath, + readAuth, + readReminder, + writeAuth, + writeReminder, + type StoredAuth, + type StoredReminder, +} from "../../lib/auth/auth-store"; + +function fakeAuth(overrides: Partial = {}): StoredAuth { + const now = Math.floor(Date.now() / 1000); + return { + access_token: "access.jwt.token", + refresh_token: "refresh.jwt.token", + access_expires_at: now + 3600, + refresh_expires_at: now + 86400, + user: { id: "user-1", email: "alice@example.com" }, + ...overrides, + }; +} + +function fakeReminder(overrides: Partial = {}): StoredReminder { + return { + next_audit_at: Math.floor(Date.now() / 1000) + 7 * 86400, + user_email: "alice@example.com", + set_at: Math.floor(Date.now() / 1000), + ...overrides, + }; +} + +describe("auth-store", () => { + let dir: string; + let originalAuthDir: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpa-auth-test-")); + originalAuthDir = process.env.FAILPROOFAI_AUTH_DIR; + process.env.FAILPROOFAI_AUTH_DIR = dir; + }); + + afterEach(() => { + if (originalAuthDir === undefined) delete process.env.FAILPROOFAI_AUTH_DIR; + else process.env.FAILPROOFAI_AUTH_DIR = originalAuthDir; + try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + describe("auth", () => { + it("returns null when no auth file exists", () => { + expect(readAuth()).toBeNull(); + }); + + it("round-trips a written auth file", () => { + const auth = fakeAuth(); + writeAuth(auth); + const out = readAuth(); + expect(out).not.toBeNull(); + expect(out?.user).toEqual(auth.user); + expect(out?.access_token).toBe(auth.access_token); + }); + + it("rejects shape mismatches as null", () => { + writeFileSync(getAuthFilePath(), JSON.stringify({ foo: 1 }), "utf-8"); + expect(readAuth()).toBeNull(); + }); + + it("returns null on corrupt JSON", () => { + writeFileSync(getAuthFilePath(), "{ not json", "utf-8"); + expect(readAuth()).toBeNull(); + }); + + it("writes mode 0600 on the file", () => { + writeAuth(fakeAuth()); + const mode = statSync(getAuthFilePath()).mode & 0o777; + // World-readable bit must be cleared. + expect(mode & 0o004).toBe(0); + // Group-read also cleared. + expect(mode & 0o040).toBe(0); + }); + + it("atomic write leaves no .tmp siblings behind on success", () => { + writeAuth(fakeAuth()); + const leftover = readdirSync(dir).filter((f) => f.includes(".tmp")); + expect(leftover).toEqual([]); + }); + + it("deleteAuth removes the file", () => { + writeAuth(fakeAuth()); + expect(existsSync(getAuthFilePath())).toBe(true); + deleteAuth(); + expect(existsSync(getAuthFilePath())).toBe(false); + }); + + it("backfills refresh_expires_at when omitted from the legacy file", () => { + const now = Math.floor(Date.now() / 1000); + writeFileSync(getAuthFilePath(), JSON.stringify({ + access_token: "a", + refresh_token: "r", + access_expires_at: now + 100, + user: { id: "u", email: "e@e.com" }, + }), "utf-8"); + const out = readAuth(); + // Falls back to access_expires_at when the file pre-dated the field. + expect(out?.refresh_expires_at).toBe(now + 100); + }); + }); + + describe("reminder", () => { + it("returns null when no reminder file exists", () => { + expect(readReminder()).toBeNull(); + }); + + it("round-trips a written reminder", () => { + const r = fakeReminder(); + writeReminder(r); + const out = readReminder(); + expect(out).toEqual(r); + }); + + it("scopes by user_email — the consumer enforces this", () => { + writeReminder(fakeReminder({ user_email: "bob@example.com" })); + const out = readReminder(); + expect(out?.user_email).toBe("bob@example.com"); + }); + + it("rejects shape mismatches as null", () => { + writeFileSync(getReminderFilePath(), JSON.stringify({ next_audit_at: "string" }), "utf-8"); + expect(readReminder()).toBeNull(); + }); + + it("deleteReminder removes the file", () => { + writeReminder(fakeReminder()); + expect(existsSync(getReminderFilePath())).toBe(true); + deleteReminder(); + expect(existsSync(getReminderFilePath())).toBe(false); + }); + + it("overwrites the existing reminder atomically", () => { + writeReminder(fakeReminder({ next_audit_at: 1 })); + writeReminder(fakeReminder({ next_audit_at: 2 })); + expect(readReminder()?.next_audit_at).toBe(2); + }); + + it("writes mode 0600 on the reminder file", () => { + writeReminder(fakeReminder()); + const mode = statSync(getReminderFilePath()).mode & 0o777; + // World- and group-read bits must be cleared — next-audit.json stores + // the user_email scoping key and gets the same hardening as auth.json. + expect(mode & 0o004).toBe(0); + expect(mode & 0o040).toBe(0); + }); + + it("atomic write leaves no .tmp siblings behind on success", () => { + writeReminder(fakeReminder()); + const leftover = readdirSync(dir).filter((f) => f.includes(".tmp")); + expect(leftover).toEqual([]); + }); + }); +}); diff --git a/app/actions/get-audit-result.ts b/app/actions/get-audit-result.ts new file mode 100644 index 00000000..4e8e6210 --- /dev/null +++ b/app/actions/get-audit-result.ts @@ -0,0 +1,24 @@ +"use server"; + +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; + +export type AuditResultPayload = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +/** + * Read the dashboard cache. Never triggers a run — `/audit` shows the empty + * state when there's no cache and lets the user opt in to scanning. Mirrors + * the read-only ergonomics of `getHooksConfigAction()`. + */ +export async function getAuditResultAction(): Promise { + const entry = readDashboardCache(); + if (!entry) return { status: "empty" }; + return { + status: "cached", + cachedAt: entry.cachedAt, + params: entry.params, + result: entry.result, + }; +} diff --git a/app/api/audit/_state.ts b/app/api/audit/_state.ts new file mode 100644 index 00000000..39170548 --- /dev/null +++ b/app/api/audit/_state.ts @@ -0,0 +1,72 @@ +/** + * Shared in-memory state between `/api/audit/run` and `/api/audit/status`. + * + * A single audit can take 10-30 seconds; the client UI needs to know whether + * one is in flight (to disable the re-run button and show a progress UI). + * Both API routes import the same module-level state from here so they + * agree on what "running" means. + * + * Caveats this lock does NOT cover: + * + * - **Next.js dev-mode HMR** can reset module state mid-run; the status + * endpoint will then report `running: false` while the original POST + * handler is still resolving. Production is unaffected. + * + * - **Multi-worker (`next start` with PM2 cluster mode, or any + * multi-process Node deploy)**: the lock is per-process. Two POSTs + * that land on different workers will both succeed, both invoke + * `runAudit()`, and one result will overwrite the other in the cache + * file. A correct cross-worker lock needs external storage (Redis, + * DB row, filesystem lock) and is out of scope for the OSS dashboard, + * which expects a single worker process by default. + * + * - **Process death mid-run** (OOM, SIGKILL, uncaught throw past the + * route handler's `try/finally`) would wedge the lock forever in a + * long-lived worker. The `LOCK_MAX_AGE_MS` auto-expiry below treats + * a lock older than 5 minutes as released so the next caller can + * take over. 5 minutes matches the rerun-button poll cap, so a real + * in-flight run that exceeds it is also stuck and worth pre-empting. + */ +export interface RunState { + /** True while a `runAudit()` call is in flight (and the lock hasn't + * expired — see `LOCK_MAX_AGE_MS`). */ + running: boolean; + /** ms timestamp the current run was kicked off, if `running`. */ + startedAt?: number; +} + +/** Auto-expire a wedged lock after this many ms. Matches the + * rerun-button's `MAX_POLL_MS`. */ +const LOCK_MAX_AGE_MS = 5 * 60_000; + +const state: RunState = { running: false }; + +function expiredIfStale(): void { + if (!state.running) return; + if (state.startedAt === undefined) return; + if (Date.now() - state.startedAt > LOCK_MAX_AGE_MS) { + state.running = false; + state.startedAt = undefined; + } +} + +export function getRunState(): RunState { + expiredIfStale(); + return { ...state }; +} + +/** Atomically attempt to take the run lock. Returns true if the caller + * acquired it; false if a run is already in progress. */ +export function tryAcquireRun(): boolean { + expiredIfStale(); + if (state.running) return false; + state.running = true; + state.startedAt = Date.now(); + return true; +} + +/** Release the run lock. Safe to call even when not held. */ +export function releaseRun(): void { + state.running = false; + state.startedAt = undefined; +} diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts new file mode 100644 index 00000000..9737de1f --- /dev/null +++ b/app/api/audit/run/route.ts @@ -0,0 +1,89 @@ +/** + * POST /api/audit/run — kick off a `runAudit()` call and write the dashboard + * cache on success. Returns the full `AuditResult` in the response. + * + * Concurrency: a module-level singleton in `_state.ts` guards against + * overlapping runs — the second concurrent POST gets a 409. The client + * (rerun-button.tsx) then just falls back to polling /status. + */ +import { NextRequest, NextResponse } from "next/server"; +import { runAudit } from "@/src/audit"; +import { writeDashboardCache } from "@/src/audit/dashboard-cache"; +import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; +import type { RunAuditOptions } from "@/src/audit/types"; +import { releaseRun, tryAcquireRun } from "../_state"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +interface RunBody { + since?: string; + cli?: string[]; + project?: string[]; + policy?: string[]; + noCache?: boolean; +} + +const VALID_CLIS = new Set(INTEGRATION_TYPES); + +function sanitize(body: RunBody): RunAuditOptions { + const opts: RunAuditOptions = {}; + if (typeof body.since === "string" && body.since.trim()) { + opts.since = body.since.trim(); + } + if (Array.isArray(body.cli) && body.cli.length > 0) { + const valid = body.cli.filter((c): c is IntegrationType => + typeof c === "string" && VALID_CLIS.has(c) + ); + if (valid.length > 0) opts.clis = valid; + } + if (Array.isArray(body.project) && body.project.length > 0) { + opts.projects = body.project.filter((p) => typeof p === "string"); + } + if (Array.isArray(body.policy) && body.policy.length > 0) { + opts.policies = body.policy.filter((p) => typeof p === "string"); + } + if (body.noCache === true) opts.noCache = true; + return opts; +} + +export async function POST(request: NextRequest): Promise { + let body: RunBody = {}; + try { + const raw = await request.text(); + if (raw.trim().length > 0) { + const parsed: unknown = JSON.parse(raw); + // JSON.parse("null") returns null and JSON.parse("[]") returns an + // array — both pass the catch but break sanitize()'s field access. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 }, + ); + } + body = parsed as RunBody; + } + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const opts = sanitize(body); + + if (!tryAcquireRun()) { + return NextResponse.json( + { error: "Audit already running", status: "already-running" }, + { status: 409 }, + ); + } + + try { + const result = await runAudit(opts); + writeDashboardCache(opts, result); + return NextResponse.json({ status: "ok", result }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message, status: "error" }, { status: 500 }); + } finally { + releaseRun(); + } +} diff --git a/app/api/audit/status/route.ts b/app/api/audit/status/route.ts new file mode 100644 index 00000000..7dfbacf0 --- /dev/null +++ b/app/api/audit/status/route.ts @@ -0,0 +1,23 @@ +/** + * GET /api/audit/status — lightweight poll endpoint. Client polls this at + * 1s while a run is in flight; switches off polling once `running: false`. + * + * Also returns the cache's `cachedAt` so the client can detect that a new + * result has landed (older `cachedAt` value in client → refetch via the + * server action). + */ +import { NextResponse } from "next/server"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { getRunState } from "../_state"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + const state = getRunState(); + const cache = readDashboardCache(); + return NextResponse.json({ + running: state.running, + startedAt: state.startedAt ?? null, + cachedAt: cache?.cachedAt ?? null, + }); +} diff --git a/app/api/auth/login-request/route.ts b/app/api/auth/login-request/route.ts new file mode 100644 index 00000000..2b3f42c7 --- /dev/null +++ b/app/api/auth/login-request/route.ts @@ -0,0 +1,99 @@ +/** + * POST /api/auth/login-request + * + * Browser-facing proxy for the api-server's /v0/auth/login/request. Keeps the + * api-server URL server-side so the browser only ever talks to the local + * dashboard. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, requestLoginCode } from "@/lib/auth/api-server-client"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; + +export const dynamic = "force-dynamic"; + +interface RequestBody { + email?: unknown; +} + +/** SHA-256 of the lowercased email; lets us count distinct senders without storing PII. */ +async function hashEmail(email: string): Promise { + const data = new TextEncoder().encode(email.trim().toLowerCase()); + const buf = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .slice(0, 32); +} + +export async function POST(req: NextRequest): Promise { + await initTelemetry(); + let body: RequestBody = {}; + try { + body = (await req.json()) as RequestBody; + } catch { + trackEvent("audit_otp_requested", { status: "validation_error", reason: "invalid_json" }); + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + trackEvent("audit_otp_requested", { status: "validation_error", reason: "missing_email" }); + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + const emailHash = await hashEmail(body.email); + try { + const r = await requestLoginCode(body.email); + trackEvent("audit_otp_requested", { + status: "success", + email_hash: emailHash, + source: "dashboard", + expires_in: r.expires_in, + resend_available_in: r.resend_available_in, + }); + return NextResponse.json( + { + status: r.status, + expires_in: r.expires_in, + resend_available_in: r.resend_available_in, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + trackEvent("audit_otp_requested", { + status: "failed", + email_hash: emailHash, + source: "dashboard", + error_code: err.code, + http_status: err.status, + retry_after_secs: err.retryAfterSecs ?? null, + }); + // AuthApiError uses `status: 0` for client-side timeouts; NextResponse + // (the Response constructor) rejects any status < 200 with a + // RangeError. Surface the timeout as 504 so the browser sees a real + // status code, not a 500 stack trace. + const httpStatus = err.status >= 200 && err.status < 600 ? err.status : 504; + return NextResponse.json( + { + code: err.code, + message: err.message, + ...(err.retryAfterSecs !== undefined ? { retry_after_secs: err.retryAfterSecs } : {}), + }, + { status: httpStatus }, + ); + } + const message = err instanceof Error ? err.message : String(err); + trackEvent("audit_otp_requested", { + status: "failed", + email_hash: emailHash, + source: "dashboard", + error_code: "upstream_unreachable", + error_message: message.slice(0, 200), + }); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/login-verify/route.ts b/app/api/auth/login-verify/route.ts new file mode 100644 index 00000000..15c4ced3 --- /dev/null +++ b/app/api/auth/login-verify/route.ts @@ -0,0 +1,91 @@ +/** + * POST /api/auth/login-verify + * + * Browser-facing proxy: verifies the OTP with the api-server, persists the + * resulting tokens to ~/.failproofai/auth.json on the local dashboard host, + * and returns *only* the user identity to the browser. The refresh token + * never leaves the local filesystem. + */ +import { NextRequest, NextResponse } from "next/server"; +import { AuthApiError, verifyLoginCode } from "@/lib/auth/api-server-client"; +import { authFromTokenResponse, writeAuth } from "@/lib/auth/auth-store"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; +import { getInstanceId } from "@/lib/telemetry-id"; + +export const dynamic = "force-dynamic"; + +interface VerifyBody { + email?: unknown; + code?: unknown; +} + +export async function POST(req: NextRequest): Promise { + let body: VerifyBody = {}; + try { + body = (await req.json()) as VerifyBody; + } catch { + return NextResponse.json({ code: "validation_error", message: "Invalid JSON body" }, { status: 400 }); + } + if (typeof body.email !== "string" || !body.email.trim()) { + return NextResponse.json( + { code: "validation_error", message: "email is required" }, + { status: 400 }, + ); + } + if (typeof body.code !== "string" || !body.code.trim()) { + return NextResponse.json( + { code: "validation_error", message: "code is required" }, + { status: 400 }, + ); + } + // `initTelemetry` never throws — its internal try/catch is total. + await initTelemetry(); + try { + const tokens = await verifyLoginCode(body.email, body.code); + writeAuth(authFromTokenResponse(tokens)); + trackEvent("audit_user_identity_linked", { + source: "audit_set_reminder_auth_dialog", + user_id: tokens.user.id, + local_random_id: getInstanceId(), + }); + trackEvent("audit_otp_verified", { + status: "success", + source: "dashboard", + user_id: tokens.user.id, + }); + return NextResponse.json( + { + authenticated: true, + user: { id: tokens.user.id, email: tokens.user.email }, + }, + { status: 200 }, + ); + } catch (err) { + if (err instanceof AuthApiError) { + trackEvent("audit_otp_verified", { + status: "failed", + source: "dashboard", + error_code: err.code, + http_status: err.status, + }); + // AuthApiError uses `status: 0` for client-side timeouts; map those to + // 504 so NextResponse.json doesn't reject with a RangeError. + const httpStatus = err.status >= 200 && err.status < 600 ? err.status : 504; + return NextResponse.json( + { code: err.code, message: err.message }, + { status: httpStatus }, + ); + } + const message = err instanceof Error ? err.message : String(err); + trackEvent("audit_otp_verified", { + status: "failed", + source: "dashboard", + error_code: "upstream_unreachable", + error_message: message.slice(0, 200), + }); + return NextResponse.json( + { code: "upstream_unreachable", message: `api-server unreachable: ${message}` }, + { status: 502 }, + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 00000000..78171640 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,48 @@ +/** + * POST /api/auth/logout + * + * Reads the locally-stored session, asks the api-server to revoke it, and + * deletes ~/.failproofai/auth.json regardless of upstream success — local + * intent to log out takes precedence. + */ +import { NextResponse } from "next/server"; +import { AuthApiError, logoutSession } from "@/lib/auth/api-server-client"; +import { deleteAuth, readAuth } from "@/lib/auth/auth-store"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; + +export const dynamic = "force-dynamic"; + +export async function POST(): Promise { + await initTelemetry(); + const existing = readAuth(); + if (!existing) { + trackEvent("audit_user_logged_out", { + source: "dashboard", + had_session: false, + upstream: "noop", + }); + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + let upstream: "revoked" | "skipped" | "failed" = "skipped"; + let upstreamError: string | null = null; + try { + await logoutSession(existing.access_token, existing.refresh_token); + upstream = "revoked"; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + upstream = "revoked"; // token already invalid server-side + } else { + upstream = "failed"; + upstreamError = err instanceof Error ? err.message.slice(0, 200) : String(err).slice(0, 200); + } + } + deleteAuth(); + trackEvent("audit_user_logged_out", { + source: "dashboard", + had_session: true, + upstream, + upstream_error: upstreamError, + user_id: existing.user.id, + }); + return NextResponse.json({ authenticated: false, upstream }, { status: 200 }); +} diff --git a/app/api/auth/reminder/route.ts b/app/api/auth/reminder/route.ts new file mode 100644 index 00000000..d8a45201 --- /dev/null +++ b/app/api/auth/reminder/route.ts @@ -0,0 +1,213 @@ +/** + * /api/auth/reminder + * + * GET — current reminder state (if any, scoped to the signed-in user) + * POST — set or update the next-audit reminder; requires an active session + * DELETE — clear the reminder + * + * Reminder timestamp lives in ~/.failproofai/next-audit.json. The dashboard + * AND the CLI can read it later (we just persist intent here; the actual + * email send is wired separately when the scheduler is built). + */ +import { NextRequest, NextResponse } from "next/server"; +import { + deleteReminder, + readReminder, + whoAmI, + writeReminder, +} from "@/lib/auth/auth-store"; +import { + AuthApiError, + cancelReminder, + scheduleReminder, +} from "@/lib/auth/api-server-client"; +import { initTelemetry, trackEvent } from "@/lib/telemetry"; + +export const dynamic = "force-dynamic"; + +const DEFAULT_OFFSET_DAYS = 7; +const MAX_OFFSET_DAYS = 365; + +export async function GET(): Promise { + const who = await whoAmI(); + const reminder = readReminder(); + if (!reminder) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + // If the reminder belongs to a different user (or no one is signed in), + // surface it as null so the UI doesn't show "next audit set for alice" + // when bob is the current session. + if (!who || who.me.email !== reminder.user_email) { + return NextResponse.json({ authenticated: !!who, reminder: null }); + } + return NextResponse.json({ + authenticated: true, + reminder: { + next_audit_at: reminder.next_audit_at, + user_email: reminder.user_email, + set_at: reminder.set_at, + }, + }); +} + +interface SetBody { + /** Days from now until the reminder fires. Default: 7. */ + in_days?: unknown; + /** Absolute unix-seconds timestamp. Wins over in_days when both are sent. */ + at?: unknown; +} + +export async function POST(req: NextRequest): Promise { + await initTelemetry(); + const who = await whoAmI(); + if (!who) { + trackEvent("audit_reminder_set", { status: "unauthorized", source: "dashboard" }); + return NextResponse.json( + { code: "unauthorized", message: "Sign in before setting a reminder." }, + { status: 401 }, + ); + } + let body: SetBody = {}; + // Distinguish three cases: + // 1. empty body → defaults (7d from now) + // 2. malformed JSON → 400 Bad Request (don't silently swap to {}) + // 3. valid JSON, not obj → 400 Bad Request (arrays/primitives are not SetBody) + const raw = await req.text(); + if (raw.trim().length > 0) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + trackEvent("audit_reminder_set", { + status: "validation_error", + source: "dashboard", + reason: "malformed_json", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "Request body is not valid JSON." }, + { status: 400 }, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + trackEvent("audit_reminder_set", { + status: "validation_error", + source: "dashboard", + reason: "not_an_object", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "Request body must be a JSON object." }, + { status: 400 }, + ); + } + body = parsed as SetBody; + } + const nowSecs = Math.floor(Date.now() / 1000); + const maxAt = nowSecs + MAX_OFFSET_DAYS * 86400; + let nextAuditAt: number; + if (typeof body.at === "number" && Number.isFinite(body.at)) { + nextAuditAt = Math.floor(body.at); + } else { + const offsetDays = + typeof body.in_days === "number" && Number.isFinite(body.in_days) + ? Math.max(1, Math.min(MAX_OFFSET_DAYS, Math.floor(body.in_days))) + : DEFAULT_OFFSET_DAYS; + nextAuditAt = nowSecs + offsetDays * 86400; + } + if (nextAuditAt <= nowSecs) { + trackEvent("audit_reminder_set", { + status: "validation_error", + source: "dashboard", + reason: "in_the_past", + user_id: who.me.id, + }); + return NextResponse.json( + { code: "validation_error", message: "Reminder must be in the future." }, + { status: 400 }, + ); + } + // Upper-bound guard: catches the common foot-gun where a caller passes + // `Date.now()` (ms) instead of unix-seconds — would otherwise persist a + // year-55000 reminder, render "in 19000000 days", and send nonsense + // fire_at to the upstream scheduler. + if (nextAuditAt > maxAt) { + trackEvent("audit_reminder_set", { + status: "validation_error", + source: "dashboard", + reason: "too_far_in_future", + user_id: who.me.id, + }); + return NextResponse.json( + { + code: "validation_error", + message: `Reminder must be within ${MAX_OFFSET_DAYS} days. Did you pass milliseconds instead of seconds?`, + }, + { status: 400 }, + ); + } + const reminder = { + next_audit_at: nextAuditAt, + user_email: who.me.email, + set_at: nowSecs, + }; + writeReminder(reminder); + // Forward to the api-server scheduler so it can deliver via SES. The local + // file is the dashboard/CLI source-of-truth; the api-server holds the + // delivery slot. We tolerate upstream failure — the local write already + // succeeded and the user gets a usable response. + let upstream: "scheduled" | "failed" | "skipped" = "skipped"; + let upstreamError: string | null = null; + try { + await scheduleReminder(who.auth.access_token, { at: nextAuditAt }); + upstream = "scheduled"; + } catch (err) { + upstream = "failed"; + upstreamError = + err instanceof AuthApiError + ? `${err.code}: ${err.message}`.slice(0, 200) + : err instanceof Error + ? err.message.slice(0, 200) + : String(err).slice(0, 200); + } + trackEvent("audit_reminder_set", { + status: "success", + source: "dashboard", + user_id: who.me.id, + offset_days: Math.round((nextAuditAt - nowSecs) / 86400), + upstream, + upstream_error: upstreamError, + }); + return NextResponse.json({ authenticated: true, reminder }); +} + +export async function DELETE(): Promise { + await initTelemetry(); + const who = await whoAmI(); + const existing = readReminder(); + deleteReminder(); + let upstream: "cancelled" | "failed" | "skipped" = "skipped"; + let upstreamError: string | null = null; + if (who) { + try { + await cancelReminder(who.auth.access_token); + upstream = "cancelled"; + } catch (err) { + upstream = "failed"; + upstreamError = + err instanceof AuthApiError + ? `${err.code}: ${err.message}`.slice(0, 200) + : err instanceof Error + ? err.message.slice(0, 200) + : String(err).slice(0, 200); + } + } + trackEvent("audit_reminder_cleared", { + source: "dashboard", + had_local_reminder: existing !== null, + user_id: who?.me.id ?? null, + upstream, + upstream_error: upstreamError, + }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts new file mode 100644 index 00000000..9cdeb642 --- /dev/null +++ b/app/api/auth/status/route.ts @@ -0,0 +1,42 @@ +/** + * GET /api/auth/status + * + * Returns the currently signed-in identity by reading the local + * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the + * file is the source of truth, same as the CLI's `failproofai auth whoami`. + * This keeps the dashboard UI and the CLI consistent regardless of whether + * the api-server is reachable. + * + * Also returns the user's persisted re-audit reminder (if any). The reminder + * lives in ~/.failproofai/next-audit.json and is only surfaced when its + * `user_email` matches the active session — so swapping accounts via CLI + * does not leak a previous user's reminder into the dashboard. + */ +import { NextResponse } from "next/server"; +import { readAuth, readReminder } from "@/lib/auth/auth-store"; + +export const dynamic = "force-dynamic"; + +export async function GET(): Promise { + const auth = readAuth(); + if (!auth) { + return NextResponse.json({ authenticated: false, reminder: null }, { status: 200 }); + } + const reminderRaw = readReminder(); + const reminder = + reminderRaw && reminderRaw.user_email === auth.user.email + ? { + next_audit_at: reminderRaw.next_audit_at, + user_email: reminderRaw.user_email, + set_at: reminderRaw.set_at, + } + : null; + return NextResponse.json( + { + authenticated: true, + user: { id: auth.user.id, email: auth.user.email }, + reminder, + }, + { status: 200 }, + ); +} diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx new file mode 100644 index 00000000..5c2797e0 --- /dev/null +++ b/app/audit/_components/audit-dashboard.tsx @@ -0,0 +1,356 @@ +"use client"; + +/** + * Top-level client wrapper for /audit. + * + * Composes the personality report: classify the agent into one of 8 + * archetypes, derive a score + tier, render the IdentitySection + + * ShowOff + Strengths + Score (with leaderboard) + Findings + Policies + * + Return-loop CTA. + * + * Empty / running states fall back to the existing EmptyState and + * RunProgress components. + */ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getAuditResultAction } from "@/app/actions/get-audit-result"; +import type { AuditResult, RunAuditOptions } from "@/src/audit/types"; +import { classifyAgent } from "@/src/audit/archetypes"; +import { COHORT_SIZE, deriveScore, gradeFor, projectedScore, type Grade } from "@/src/audit/scoring"; +import { deriveStrengths } from "@/src/audit/strengths"; +import { deriveFindings } from "@/src/audit/findings"; +import { usePostHog } from "@/contexts/PostHogContext"; + +import { IdentitySection } from "./identity-section"; +import { StrengthsSection } from "./strengths-section"; +import { ScoreSection } from "./score-section"; +import { FindingsSection } from "./findings-section"; +import { PoliciesSection } from "./policies-section"; +import { ReturnSection } from "./return-section"; +import { ReportFooter } from "./report-footer"; +import { EmptyState } from "./empty-state"; +import { RunProgress } from "./run-progress"; + +// IMPORTANT: do NOT import BUILTIN_POLICIES or AUDIT_DETECTORS here. +// Both pull in node:fs and execSync (workflow policies), which Next.js +// refuses to bundle for the client. The total catalog size is computed +// server-side in page.tsx and passed in as a plain number prop. + +type Initial = + | { status: "cached"; cachedAt: string; params: RunAuditOptions; result: AuditResult } + | { status: "empty" }; + +interface Props { + initial: Initial; + /** ?p=... URL param override for the project name in the leaderboard + * row. Defaults to whichever cwd has the most hits, falling back to + * "your agent". */ + projectFromUrl?: string; + /** Total number of detectors + builtin policies. Computed server-side + * in page.tsx — the modules can't ship to the client. */ + totalCatalogSize: number; +} + +function inferWindow(params: RunAuditOptions | undefined): string { + if (!params?.since) return "all time"; + return params.since; +} + +function inferProjectName(result: AuditResult, override?: string): string { + if (override && override.trim()) return override; + // Pick the cwd that appears in the most examples — proxy for "your + // most-active project". Falls back to "your agent". + const counts = new Map(); + for (const row of result.results) { + for (const ex of row.examples) { + if (!ex.cwd) continue; + counts.set(ex.cwd, (counts.get(ex.cwd) ?? 0) + 1); + } + } + let bestCwd = ""; + let bestCount = 0; + for (const [cwd, n] of counts) { + if (n > bestCount) { bestCwd = cwd; bestCount = n; } + } + if (!bestCwd) return "your agent"; + const segs = bestCwd.replace(/\/+$/, "").split(/[\\/]/); + // Use last two path segments — like "blrnow / api-coder". + if (segs.length >= 2) return `${segs[segs.length - 2]} / ${segs[segs.length - 1]}`; + return segs[segs.length - 1] ?? "your agent"; +} + +export function AuditDashboard({ initial, projectFromUrl, totalCatalogSize }: Props) { + const [cache, setCache] = useState(initial); + const [running, setRunning] = useState(false); + const { capture } = usePostHog(); + const pageViewStateRef = useRef(null); + const scrollTrackedRef = useRef(false); + const transcriptsScanned = cache.status === "cached" ? cache.result.transcripts.scanned : 0; + const resultsCount = cache.status === "cached" ? cache.result.results.length : 0; + + const refreshFromCache = useCallback(async () => { + const payload = await getAuditResultAction(); + if (payload.status === "cached") setCache(payload); + }, []); + + // Body class for audit-only background + grain texture. Applied once on + // mount so the body bg switches from the global #0a0a0a to the audit + // #131316 only on this route. + useEffect(() => { + document.body.classList.add("audit-body"); + return () => document.body.classList.remove("audit-body"); + }, []); + + useEffect(() => { + const viewState = + running + ? "running" + : cache.status === "empty" + ? "empty" + : transcriptsScanned === 0 + ? "zero_sessions" + : "report"; + if (pageViewStateRef.current === viewState) return; + pageViewStateRef.current = viewState; + capture("audit_page_viewed", { + state: viewState, + has_cache: cache.status === "cached", + }); + }, [cache.status, capture, running, transcriptsScanned]); + + useEffect(() => { + scrollTrackedRef.current = false; + }, [cache.status, running, transcriptsScanned]); + + useEffect(() => { + if (running || cache.status !== "cached" || transcriptsScanned === 0) return; + let raf = 0; + const measure = () => { + raf = 0; + if (scrollTrackedRef.current) return; + const maxScroll = document.documentElement.scrollHeight - window.innerHeight; + if (maxScroll <= 0) return; + const reachedBottom = window.scrollY >= maxScroll - 24; + if (!reachedBottom) return; + scrollTrackedRef.current = true; + capture("audit_page_scrolled_to_end", { + results_count: resultsCount, + transcripts_scanned: transcriptsScanned, + }); + }; + // Coalesce scroll events to one rAF — reading scrollHeight forces a + // layout reflow, which we don't want firing 60+ times per second. + const onScroll = () => { + if (raf !== 0) return; + raf = requestAnimationFrame(measure); + }; + measure(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", onScroll); + if (raf !== 0) cancelAnimationFrame(raf); + }; + }, [cache.status, capture, resultsCount, running, transcriptsScanned]); + + /* ---- empty / first-run ----------------------------------------- */ + if (cache.status === "empty" && !running) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + if (cache.status === "empty" && running) { + return ( + {}} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + // cache.status === "cached" + const result = cache.status === "cached" ? cache.result : null; + if (!result) return null; + const cachedAt = cache.status === "cached" ? cache.cachedAt : null; + const params = cache.status === "cached" ? cache.params : undefined; + + /* ---- scanned but zero sessions --------------------------------- */ + if (result.transcripts.scanned === 0) { + return ( + setRunning(true)} + onCompleted={async () => { setRunning(false); await refreshFromCache(); }} + /> + ); + } + + /* ---- in-flight re-run ------------------------------------------ */ + if (running) { + return ( +
+
+
+
+ +
+ +
+
+ ); + } + + /* ---- main report ----------------------------------------------- */ + return ( + + ); +} + +interface MainReportProps { + result: AuditResult; + cachedAt: string | null; + params: RunAuditOptions | undefined; + projectFromUrl?: string; + totalCatalogSize: number; +} + +function MainReport({ result, cachedAt, params, projectFromUrl, totalCatalogSize }: MainReportProps) { + const { capture } = usePostHog(); + const classification = useMemo(() => classifyAgent(result), [result]); + const score = useMemo(() => deriveScore(result), [result]); + const projected = useMemo(() => projectedScore(result, score), [result, score]); + const grade = gradeFor(score); + const projectedGrade = gradeFor(projected); + const strengths = useMemo(() => deriveStrengths(result), [result]); + const findings = useMemo(() => deriveFindings(result), [result]); + const project = useMemo(() => inferProjectName(result, projectFromUrl), [result, projectFromUrl]); + // Renamed from `window` to avoid shadowing the browser global — any + // future `window.*` reference added inside MainReport would silently + // bind to a string and crash at runtime. + const scopeWindow = inferWindow(params); + + // One pass over result.results derives both counts; score-section and + // return-section also need `missing` but they take it from us via props + // / `result` shape, so deduplicate the scan here. + const { detectorsTriggered, missing } = useMemo(() => { + let detectorsTriggered = 0; + let missing = 0; + for (const r of result.results) { + if (r.hits > 0) detectorsTriggered++; + if (r.source === "builtin" && !r.enabledInConfig && r.hits > 0) missing++; + } + return { detectorsTriggered, missing }; + }, [result]); + + // Fire-once dashboard-rendered event so we can compute click-through + // rates against the share/download/rerun click events we already track. + const dashboardViewedRef = useRef(false); + useEffect(() => { + if (dashboardViewedRef.current) return; + dashboardViewedRef.current = true; + capture("audit_dashboard_viewed", { + score, + grade, + archetype: classification.archetype, + secondary: classification.secondary ?? null, + missing, + transcripts_scanned: result.transcripts.scanned, + results_count: result.results.length, + detectors_triggered: detectorsTriggered, + }); + }, [ + capture, + score, + grade, + classification.archetype, + classification.secondary, + missing, + result.transcripts.scanned, + result.results.length, + detectorsTriggered, + ]); + + /** Identity hero ref — captured to PNG by the share buttons. */ + const identityFrameRef = useRef(null); + + return ( +
+
+
+
+ + + + + + +
+ +
+
+ ); +} + +interface ShellEmptyProps { + running: boolean; + mode?: "no-cache" | "zero-sessions"; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +function ShellEmpty({ running, mode = "no-cache", onStarted, onCompleted }: ShellEmptyProps) { + // Use the archetype "optimist" sigil for the empty-state visual so the + // page doesn't render with a dead box. EmptyState itself is unchanged + // from the previous build. + return ( +
+
+
+
+ {running ? ( + + ) : ( + + )} +
+ +
+
+ ); +} diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx new file mode 100644 index 00000000..c0555637 --- /dev/null +++ b/app/audit/_components/auth-dialog.tsx @@ -0,0 +1,401 @@ +"use client"; + +/** + * Auth dialog — modal overlay shown when an unauthenticated user clicks + * "[ set a reminder ]". Two-step flow: + * + * 1. Email entry → POST /api/auth/login-request + * 2. OTP entry → POST /api/auth/login-verify + * + * Styled to match the rest of the /audit page: pixel brackets, sharp pink + * accent, terminal-style frame. The dialog never sees the refresh token — + * the dashboard's API route writes it to ~/.failproofai/auth.json. + */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { fetchWithTimeout, isAbortError } from "@/lib/fetch-with-timeout"; + +export interface AuthedUser { + id: string; + email: string; +} + +interface Props { + open: boolean; + /** Copy shown above the title, e.g. "oops — you are unknown." */ + headline?: string; + /** Copy under the title explaining why we need auth right now. */ + reason?: string; + onClose: () => void; + /** Fired after successful verify. Caller decides what to do next. */ + onAuthed: (user: AuthedUser) => void; + /** Telemetry tag identifying which CTA opened the dialog. Defaults to + * "unknown" so existing call sites continue to compile. */ + source?: string; +} + +type Step = + | { kind: "email"; error: string | null } + | { kind: "code"; email: string; error: string | null; expiresIn: number; resendIn: number } + | { kind: "done"; user: AuthedUser }; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function describeFetchError(err: unknown): string { + if (isAbortError(err)) { + return "request timed out. check your network and try again."; + } + const message = err instanceof Error ? err.message : String(err); + return `network error: ${message}`; +} + +export function AuthDialog({ + open, + headline = "oops — you are unknown.", + reason = "verify yourself to continue.", + onClose, + onAuthed, + source = "unknown", +}: Props): React.ReactElement | null { + const { capture } = usePostHog(); + const [step, setStep] = useState({ kind: "email", error: null }); + const [busy, setBusy] = useState(false); + const succeededRef = useRef(false); + const emailInputRef = useRef(null); + const codeInputRef = useRef(null); + + // Reset internal state every time the dialog opens. Also fire the + // funnel-opened event so we can measure dismissal vs verification rates. + useEffect(() => { + if (open) { + setStep({ kind: "email", error: null }); + setBusy(false); + succeededRef.current = false; + capture("audit_auth_dialog_opened", { source }); + } + }, [capture, open, source]); + + // Fire dismissed when the dialog closes WITHOUT a successful verify. + // We piggyback on `open` flipping false instead of intercepting every + // close path so resend / step transitions don't double-count. + const wasOpenRef = useRef(false); + useEffect(() => { + if (open) { + wasOpenRef.current = true; + return; + } + if (wasOpenRef.current && !succeededRef.current) { + capture("audit_auth_dialog_dismissed", { + source, + step: step.kind, + }); + } + wasOpenRef.current = false; + }, [capture, open, source, step.kind]); + + // Autofocus the right input as the step changes. + useEffect(() => { + if (!open) return; + const t = setTimeout(() => { + if (step.kind === "email") emailInputRef.current?.focus(); + else if (step.kind === "code") codeInputRef.current?.focus(); + }, 50); + return () => clearTimeout(t); + }, [open, step.kind]); + + // ESC to close. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape" && !busy) onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, busy, onClose]); + + // Resend countdown ticker. + const resendActive = step.kind === "code" && step.resendIn > 0; + useEffect(() => { + if (!resendActive) return; + const id = setInterval(() => { + setStep((s) => + s.kind === "code" ? { ...s, resendIn: Math.max(0, s.resendIn - 1) } : s, + ); + }, 1000); + return () => clearInterval(id); + }, [resendActive]); + + const requestCode = useCallback( + async (email: string, opts: { isResend?: boolean } = {}): Promise => { + const { isResend = false } = opts; + // Show resend errors inline on the OTP step — the previously sent + // code is still usable. Only the first-send error path bounces back + // to the email step. + const setError = (msg: string) => { + if (isResend) { + setStep((s) => (s.kind === "code" ? { ...s, error: msg } : s)); + } else { + setStep({ kind: "email", error: msg }); + } + }; + setBusy(true); + try { + const res = await fetchWithTimeout("/api/auth/login-request", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + const body = (await res.json().catch(() => ({}))) as { + code?: string; + message?: string; + expires_in?: number; + resend_available_in?: number; + retry_after_secs?: number; + }; + if (!res.ok) { + let msg = body.message ?? "could not send code."; + if (body.code === "rate_limited" && body.retry_after_secs !== undefined) { + msg = `too many tries. wait ${body.retry_after_secs}s and try again.`; + } else if (body.code === "upstream_unreachable") { + msg = "api-server unreachable. check your network."; + } + setError(msg); + return; + } + setStep({ + kind: "code", + email, + error: null, + expiresIn: body.expires_in ?? 600, + resendIn: body.resend_available_in ?? 30, + }); + } catch (err) { + setError(describeFetchError(err)); + } finally { + setBusy(false); + } + }, + [], + ); + + const verifyCode = useCallback( + async (email: string, code: string): Promise => { + setBusy(true); + try { + const res = await fetchWithTimeout("/api/auth/login-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, code }), + }); + const body = (await res.json().catch(() => ({}))) as { + authenticated?: boolean; + user?: AuthedUser; + code?: string; + message?: string; + }; + if (!res.ok || !body.authenticated || !body.user) { + let msg = body.message ?? "invalid code."; + if (body.code === "invalid_code") msg = "wrong code, or it expired. try again."; + setStep((s) => + s.kind === "code" ? { ...s, error: msg } : s, + ); + return; + } + succeededRef.current = true; + capture("audit_auth_dialog_succeeded", { source }); + setStep({ kind: "done", user: body.user }); + onAuthed(body.user); + } catch (err) { + const msg = describeFetchError(err); + setStep((s) => + s.kind === "code" ? { ...s, error: msg } : s, + ); + } finally { + setBusy(false); + } + }, + [capture, onAuthed, source], + ); + + const onEmailSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "email") return; + const fd = new FormData(e.currentTarget); + const email = String(fd.get("email") ?? "").trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + setStep({ kind: "email", error: "that doesn't look like an email." }); + return; + } + await requestCode(email); + }, + [busy, step, requestCode], + ); + + const onCodeSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (busy || step.kind !== "code") return; + const fd = new FormData(e.currentTarget); + const code = String(fd.get("code") ?? "").trim(); + if (code.length < 4 || code.length > 12) { + setStep((s) => + s.kind === "code" ? { ...s, error: "code is 4–12 characters." } : s, + ); + return; + } + await verifyCode(step.email, code); + }, + [busy, step, verifyCode], + ); + + const onResend = useCallback(async () => { + if (step.kind !== "code" || step.resendIn > 0 || busy) return; + await requestCode(step.email, { isResend: true }); + }, [step, busy, requestCode]); + + if (!open) return null; + + return ( +
{ + if (!busy && e.target === e.currentTarget) onClose(); + }} + > +
+ + + + + + + +
━━ identity check
+

+ {headline} +

+ + {step.kind === "email" && ( + <> +

{reason}

+
+ + + {step.error &&
{step.error}
} +
+ + +
+
+ + )} + + {step.kind === "code" && ( + <> +

+ we sent a code to {step.email}. +
+ check your inbox — it expires in {Math.ceil(step.expiresIn / 60)} min. +

+
+ + + {step.error &&
{step.error}
} +
+ + +
+ +
+ + )} + + {step.kind === "done" && ( + <> +

+ you are{" "} + {step.user.email}. +

+

+ session saved locally. +

+
+ +
+ + )} +
+
+ ); +} diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx new file mode 100644 index 00000000..81045d73 --- /dev/null +++ b/app/audit/_components/empty-state.tsx @@ -0,0 +1,146 @@ +"use client"; + +/** + * Two-mode empty state for /audit, styled to the audit pixel-craft system: + * + * - "no-cache" — first time the user visits /audit. CTA to run. + * - "zero-sessions" — ran a scan but no transcripts were found. Likely the + * user hasn't installed hooks for any CLI yet. + * + * Both modes use the shared `.panel` chrome with pink corner brackets, a + * green section eyebrow, an Architype Stedelijk display headline, and a + * sharp `.btn-press` action button. Sized so it occupies the same vertical + * space as the loaded dashboard does on its hero — no more cramped popover. + */ +import React from "react"; +import { triggerRun } from "./rerun-button"; +import { usePostHog } from "@/contexts/PostHogContext"; + +interface Props { + mode: "no-cache" | "zero-sessions"; + running: boolean; + onStarted: () => void; + onCompleted: () => Promise | void; +} + +export function EmptyState({ mode, running, onStarted, onCompleted }: Props) { + const { capture } = usePostHog(); + const handleRun = async () => { + capture("audit_first_run_clicked", { + source: "empty_state", + mode, + }); + onStarted(); + try { + await triggerRun({ cli: [], since: "30d" }); + } finally { + await onCompleted(); + } + }; + + if (mode === "no-cache") { + return ( +
+
+
+ ━━ audit{" "} + · first run +
+
+ no cache yet +
+
+

scan and see.

+ +
+ + +

run your first audit.

+

+ we'll walk every transcript across your installed CLIs — Claude Code, + Codex, Copilot, Cursor, OpenCode, Pi, Gemini — and count every wasteful + or risky action. you'll get a tier, a score, and a punch-list. +

+ +
+ + + scans the last 30 days · all installed CLIs · 10–30s + +
+
+
+ ); + } + + // mode === "zero-sessions" + return ( +
+
+
+ ━━ audit{" "} + · zero transcripts +
+
+ hooks not installed +
+
+

nothing to read.

+ +
+ + +

install hooks first.

+

+ failproofai couldn't find any transcripts to scan on this machine. + install the hooks for at least one CLI and come back. +

+ + +
+
+ ); +} diff --git a/app/audit/_components/findings-section.tsx b/app/audit/_components/findings-section.tsx new file mode 100644 index 00000000..1a28f09c --- /dev/null +++ b/app/audit/_components/findings-section.tsx @@ -0,0 +1,135 @@ +"use client"; + +/** + * Section 04 — FINDINGS. "your agent has some quirks." + * + * Per-finding cards with four blocks: what happened / what this costs / + * evidence sample / the fix. Data sourced from `src/audit/findings.ts`. + */ +import React, { useState } from "react"; +import type { FindingCard } from "@/src/audit/findings"; +import { usePostHog } from "@/contexts/PostHogContext"; + +interface Props { + findings: FindingCard[]; +} + +export function FindingsSection({ findings }: Props) { + if (findings.length === 0) return null; + + return ( +
+
+
+ ━━ findings{" "} + · ranked by impact +
+
+ {findings.length} detector{findings.length === 1 ? "" : "s"} triggered +
+
+

your agent has some quirks.

+ +
+ {findings.map((f) => )} +
+
+ ); +} + +function Finding({ f }: { f: FindingCard }) { + const { capture } = usePostHog(); + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(f.fix.install); + setCopied(true); + capture("audit_copy_clicked", { + source: "findings_section", + item_type: "single_policy_install_command", + policy_name: f.fix.slug, + finding_slug: f.sourceSlug, + }); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
№{f.num}
+
{f.title}
+
+ {f.count}× + occurrences +
+
+
+ + policy{" "} + {f.policy} + + · + {f.projects} {f.projects === 1 ? "project" : "projects"} + · + last seen {f.lastSeen} + {f.alreadyEnabled && ( + <> + · + enforced + + )} +
+
+
+
what happened
+
{f.body}
+
+
+
what this costs
+
{f.cost}
+
+
+
evidence · sample
+
+ {f.evidence.map((e, i) => { + if (e.kind === "comment") { + return
{e.text}
; + } + if (e.kind === "err") { + return
{e.text}
; + } + return ( +
+ + {e.text} +
+ ); + })} +
+
+
+
the fix
+
+ {f.fix.slug} +
{f.fix.desc}
+ {f.fix.alsoCoveredBy && ( +
+ also covered by{" "} + {f.fix.alsoCoveredBy} +
+ )} + + ${f.fix.install}{" "} + + {copied ? "copied" : "click to copy"} + + +
+
+
+
+ ); +} diff --git a/app/audit/_components/identity-section.tsx b/app/audit/_components/identity-section.tsx new file mode 100644 index 00000000..390dff4d --- /dev/null +++ b/app/audit/_components/identity-section.tsx @@ -0,0 +1,279 @@ +"use client"; + +/** + * Section 01 — IDENTITY. The hero. Big archetype name with hard-offset + * stamp shadow, sigil to the right, keywords strip, "common in / primary + * risk" meta grid, and the closing one-liner. + * + * Layout uses the ported `.archetype-frame` / `.arch-mast` / `.arch-body` + * classes from audit-styles.css. Data sources from `src/audit/archetypes.ts`. + * + * The variant copy (tagline / keywords / common / risk / closing) is + * picked deterministically from a multi-variant catalog using the `seed` + * prop — typically the inferred project name. Same seed → same persona + * blurb across renders; different seeds → different copy. So two users + * who both land on "the optimist" see different language for it. + * + * Exposes a `frameRef` forwarded onto the `.archetype-frame` element so + * the ShowOff "make poster" action can capture it via html2canvas. + */ +import React, { forwardRef, useMemo, useState } from "react"; +import { ARCHETYPES, pickArchetypeVariant, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { Sigil } from "./sigil"; + +const SITE_URL = "https://failproof.ai"; +const X_INTENT = (text: string) => + `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`; +const LI_INTENT = (text: string) => + `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(SITE_URL)}&summary=${encodeURIComponent(text)}`; + +function buildXTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + const gradeLines: Record = { + S: "every prescribed policy live. running at peak. this is what secure looks like.", + A: `${missing} polic${missing === 1 ? "y" : "ies"} from elite tier. almost there.`, + B: `solid baseline. ${missing} policy gap${missing === 1 ? "" : "s"} to close before i'm comfortable.`, + C: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} between here and the next tier. they're named. they're waiting.`, + D: `${missing} prescribed polic${missing === 1 ? "y" : "ies"} unaddressed. agents without guardrails aren't ready for prod.`, + F: `exposure is real. ${missing} polic${missing === 1 ? "y" : "ies"} away from stable ground — starting today.`, + }; + return `just audited my AI agent with failproofai ✦\n\narchetype: ${archetypeName.toLowerCase()} · ${score}/100 · ${grade} tier\n${gradeLines[grade]}\n\nrun yours → ${SITE_URL}`; +} + +function buildLinkedInTemplate(score: number, archetypeName: string, grade: Grade, missing: number): string { + // "every key policy is live" is only true when the audit returned no + // unenabled prescribed policies. A-grade with a non-zero `missing` count + // is the "almost there but still has gaps" state — softer copy. + const cleanRun = grade === "S" || (grade === "A" && missing === 0); + const verdict = cleanRun + ? `${score}/100 — ${grade} tier. every key policy is live. the audit confirmed what good looks like.` + : `${score}/100 — ${grade} tier. ${missing} prescribed polic${missing === 1 ? "y" : "ies"} uncovered — each one is a real attack surface.`; + return `We ran a failproofai security audit on our AI agent stack.\n\n${verdict}\n\nArchetype: ${archetypeName.toLowerCase()}. failproofai maps your agent\'s behavior pattern, identifies the exposure, and prescribes the exact policies to close it.\n\nFree. Open-source. 30 seconds to run: ${SITE_URL}`; +} + +interface Props { + archetypeKey: ArchetypeKey; + secondaryKey: ArchetypeKey; + toolCalls: number; + sessions: number; + /** "30d", "7d", etc. shown in the target line; "all time" otherwise. */ + window: string; + /** Stable seed for variant selection (project name is the natural fit). */ + seed: string; + score: number; + grade: Grade; + missing: number; +} + +export const IdentitySection = forwardRef(function IdentitySection( + { archetypeKey, secondaryKey, toolCalls, sessions, window, seed, score, grade, missing }: Props, + frameRef, +) { + // `pickArchetypeVariant` re-hashes the seed string via djb2 + 4 mix + // passes per axis. Deterministic over (archetypeKey, seed) so memoize + // — the share buttons toggle `downloadState` which rerenders us 4×. + const archetype = useMemo( + () => pickArchetypeVariant(archetypeKey, seed), + [archetypeKey, seed], + ); + const secondary = secondaryKey !== archetypeKey ? ARCHETYPES[secondaryKey] : null; + const { capture } = usePostHog(); + const [downloadState, setDownloadState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const captureCard = async (): Promise => { + const node = typeof frameRef === "function" ? null : frameRef?.current; + if (!node) return false; + node.classList.add("capturing"); + try { + if (typeof document !== "undefined" && document.fonts?.ready) await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => r())); + const html2canvas = (await import("html2canvas")).default; + const canvas = await html2canvas(node, { + backgroundColor: "#0e0e11", + scale: 2, + logging: false, + useCORS: true, + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `failproofai-identity-${grade.toLowerCase()}-${score}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + return true; + } finally { + node.classList.remove("capturing"); + } + }; + + const handleDownload = async () => { + if (downloadState === "busy") return; + capture("audit_card_download_clicked", { + score, + grade, + missing_policies: missing, + }); + setDownloadState("busy"); + try { + const captured = await captureCard(); + capture("audit_card_capture_completed", { + trigger: "download", + status: captured ? "success" : "no_frame", + }); + setDownloadState("done"); + setTimeout(() => setDownloadState("idle"), 2000); + } catch { + capture("audit_card_capture_completed", { + trigger: "download", + status: "error", + }); + setDownloadState("error"); + setTimeout(() => setDownloadState("idle"), 2000); + } + }; + + const handleShareX = async () => { + const text = buildXTemplate(score, archetype.name, grade, missing); + capture("audit_card_share_clicked", { + channel: "x", + score, + grade, + missing_policies: missing, + }); + const captured = await captureCard().catch(() => false); + capture("audit_card_capture_completed", { + trigger: "share_x", + status: captured ? "success" : "error", + }); + globalThis.open(X_INTENT(text), "_blank", "noopener,noreferrer"); + }; + + const handleShareLI = async () => { + const text = buildLinkedInTemplate(score, archetype.name, grade, missing); + capture("audit_card_share_clicked", { + channel: "linkedin", + score, + grade, + missing_policies: missing, + }); + const captured = await captureCard().catch(() => false); + capture("audit_card_capture_completed", { + trigger: "share_linkedin", + status: captured ? "success" : "error", + }); + globalThis.open(LI_INTENT(text), "_blank", "noopener,noreferrer"); + }; + + return ( +
+
+ ┌ identity + v1.0 ┐ + └ № {archetype.index} / 08 + archetype ┘ + +
+
+
+ ━━ identity · your agent's archetype +
+
+ detected from{" "} + {toolCalls.toLocaleString()} + {" "}tool calls + / + {sessions} + {" "}sessions + / + {window} + + live + +
+
+
+
+ № {archetype.index} of 08 +
+
archetype
+
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+ + {secondary && ( +
+ with + {secondary.name.replace("the ", "")} + tendencies +
+ )} + +
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ( + · + )} + + ))} +
+ +
+
+ common in + {archetype.common} +
+
+ primary risk + {archetype.risk} +
+
+ +
— {archetype.closing}
+
+ + +
+ +
+ + + +
+
+
+ ); +}); diff --git a/app/audit/_components/policies-section.tsx b/app/audit/_components/policies-section.tsx new file mode 100644 index 00000000..b118f248 --- /dev/null +++ b/app/audit/_components/policies-section.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * Section 05 — PRESCRIBED POLICIES. "enable these. close the gap." + * + * Grid of unenabled-builtin cards with install commands + projected + * score uplift callout. + * + * Sources two layers of "hits": + * 1. Unenabled builtin policies that fired on their own + * 2. Audit detectors → mapped via DETECTOR_TO_POLICY in findings.ts. + * The detector's hits get attributed to its primary policy so the + * report frames everything as failproofai-coverable. + * + * Same policy can collect hits from multiple sources; we sum them and + * render one card per policy. + */ +import React, { useMemo, useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { type Grade, tierName } from "@/src/audit/scoring"; +import { usePostHog } from "@/contexts/PostHogContext"; + +interface Props { + result: AuditResult; + projected: number; + projectedGrade: Grade; +} + +// Mirror of DETECTOR_TO_POLICY in findings.ts. Could re-export but keep +// the dependency tree shallow — both modules are stable. +const DETECTOR_TO_PRIMARY_POLICY: Record = { + "redundant-cd-cwd": "warn-repeated-tool-calls", + "prefer-edit-over-read-cat": "block-read-outside-cwd", + "prefer-edit-over-sed-awk": "warn-repeated-tool-calls", + "prefer-write-over-heredoc": "block-env-files", + "sleep-polling-loop": "warn-background-process", + "find-from-root": "block-read-outside-cwd", + "git-commit-no-verify": "warn-git-amend", + "reread-after-edit": "warn-repeated-tool-calls", +}; + +const POLICY_DESC: Record = { + "warn-repeated-tool-calls": "warns when the same tool is called 3+ times with identical parameters — catches the loops before they spiral.", + "block-read-outside-cwd": "denies any file read whose absolute path falls outside the project root, including symlinks.", + "block-env-files": "blocks reads and writes of `.env` files at the tool layer.", + "block-secrets-write": "blocks writes to .pem, id_rsa, credentials.json, and other secret-key files.", + "warn-background-process": "warns before starting nohup / & / screen / tmux / disown processes that get forgotten about.", + "warn-git-amend": "warns before amending git commits — dangerous-commit-flag class.", + "require-ci-green-before-stop": "requires CI checks to pass on HEAD before the agent declares the task done.", +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +interface PolicyCard { + name: string; // short slug + desc: string; // displayTitle (low-res) or impact + catches: string; // "would have caught X occurrences..." copy + hits: number; +} + +function buildPolicyCards(result: AuditResult): PolicyCard[] { + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + // policyName → aggregated counts + const buckets = new Map }>(); + + for (const row of result.results) { + if (row.hits === 0) continue; + + let target: string; + let isFromDetector = false; + if (row.source === "audit-detector") { + const mapped = DETECTOR_TO_PRIMARY_POLICY[shortName(row.name)]; + if (!mapped) continue; + target = mapped; + isFromDetector = true; + } else if (row.source === "builtin" && !row.enabledInConfig) { + target = shortName(row.name); + } else { + continue; // already-enabled builtins don't need to be prescribed + } + + // Skip if the target policy is already in the user's enabled set + // (detector hits would land there in production already). + if (enabledSet.has(target)) continue; + + const bucket = buckets.get(target) ?? { hits: 0, projects: 0, sources: new Set() }; + bucket.hits += row.hits; + bucket.projects = Math.max(bucket.projects, row.projects); + bucket.sources.add(isFromDetector ? shortName(row.name) : "self"); + buckets.set(target, bucket); + } + + return [...buckets.entries()] + .sort((a, b) => b[1].hits - a[1].hits) + .map(([name, b]) => { + const viaList = [...b.sources].filter((s) => s !== "self"); + const viaCopy = viaList.length > 0 + ? ` (via ${viaList.join(", ")})` + : ""; + const catches = `would have caught ${b.hits} occurrence${b.hits === 1 ? "" : "s"} across ${b.projects} project${b.projects === 1 ? "" : "s"}${viaCopy}.`; + return { + name, + desc: POLICY_DESC[name] ?? "enable this builtin policy to close the gap.", + catches, + hits: b.hits, + }; + }); +} + +export function PoliciesSection({ result, projected, projectedGrade }: Props) { + // Builds a Map+Set aggregation + sort over result.results — non-trivial. + // Memoize so unrelated parent re-renders don't recompute every frame. + const policies = useMemo(() => buildPolicyCards(result), [result]); + + if (policies.length === 0) return null; + + return ( +
+
+
+ ━━ policies{" "} + · prescribed +
+
+ {policies.length} polic{policies.length === 1 ? "y" : "ies"}{" "} + ·{" "} + covers your slipping-through hits +
+
+

enable these. close the gap.

+ +
+ + enable all {policies.length === 1 ? "one" : policies.length} + + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {policies.map((p, i) => ( + + ))} +
+
+ ); +} + +function PolicyTile({ policy, idx }: { policy: PolicyCard; idx: number }) { + const { capture } = usePostHog(); + const [copied, setCopied] = useState(false); + const install = `failproof policy add ${policy.name}`; + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(install); + setCopied(true); + capture("audit_copy_clicked", { + source: "policies_section", + item_type: "single_policy_install_command", + policy_name: policy.name, + policy_rank: idx + 1, + }); + setTimeout(() => setCopied(false), 1500); + } catch { /* ignore */ } + }; + + return ( +
+
+
{policy.name}
+
№{String(idx + 1).padStart(2, "0")}
+
+
{policy.desc}
+
+ {policy.catches} +
+
+ $ + {install} + + {copied ? "copied" : "copy"} + +
+
+ ); +} diff --git a/app/audit/_components/report-footer.tsx b/app/audit/_components/report-footer.tsx new file mode 100644 index 00000000..a8147c87 --- /dev/null +++ b/app/audit/_components/report-footer.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; + +interface Props { + cachedAt: string | null; +} + +function formatUtcShort(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + const day = d.getUTCDate().toString().padStart(2, "0"); + const monthNames = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; + const m = monthNames[d.getUTCMonth()]; + const y = d.getUTCFullYear(); + const hh = d.getUTCHours().toString().padStart(2, "0"); + const mm = d.getUTCMinutes().toString().padStart(2, "0"); + return `${day} ${m} ${y}, ${hh}:${mm} utc`; +} + +export function ReportFooter({ cachedAt }: Props) { + return ( +
+ failproof_ai + · + audit v1.0 + · + generated {formatUtcShort(cachedAt)} + · + auto-healing for your agents. +
+ ); +} diff --git a/app/audit/_components/rerun-button.tsx b/app/audit/_components/rerun-button.tsx new file mode 100644 index 00000000..cf502421 --- /dev/null +++ b/app/audit/_components/rerun-button.tsx @@ -0,0 +1,81 @@ +"use client"; + +/** + * `triggerRun` — POST /api/audit/run, then poll /api/audit/status until the + * server reports the run finished. Used by: + * - the audit empty-state CTA (`empty-state.tsx`), + * - the return-section's `[ re-audit now ]` button (`return-section.tsx`). + * + * The original `` React component lived here too, but no + * caller ever rendered it (the rerun UI is integrated into the two + * sections above). Dropped in this refactor to remove dead code and the + * stale `lucide-react`/`usePostHog`/`cn` imports it dragged in. + * + * `triggerRun` throws `RerunError` on POST failure / network failure / + * poll-loop timeout — callers should catch and render a distinct + * "rerun failed" state. The `kind` discriminates timeout vs other + * network failures so the UI can show different copy. + */ +import { fetchWithTimeout, isAbortError } from "@/lib/fetch-with-timeout"; + +export interface ScanParams { + /** Empty array = all CLIs. */ + cli: string[]; + /** "7d" | "30d" | "90d" | "all" (or any value accepted by parseSinceOpt). */ + since: string; +} + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_MS = 5 * 60_000; // 5 min hard cap + +function paramsToBody(p: ScanParams) { + return { + cli: p.cli.length > 0 ? p.cli : undefined, + since: p.since === "all" ? undefined : p.since, + }; +} + +export class RerunError extends Error { + readonly kind: "post_failed" | "network" | "timeout"; + constructor(kind: RerunError["kind"], message: string) { + super(message); + this.kind = kind; + this.name = "RerunError"; + } +} + +export async function triggerRun(scanParams: ScanParams): Promise { + // Kick off the run. 409 (already running) is OK — we'll just poll. + try { + const res = await fetchWithTimeout("/api/audit/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(paramsToBody(scanParams)), + }); + if (!res.ok && res.status !== 409) { + const text = await res.text().catch(() => ""); + console.error("audit run failed:", res.status, text); + throw new RerunError("post_failed", `audit run failed (${res.status})`); + } + } catch (err) { + if (err instanceof RerunError) throw err; + console.error("audit run request failed:", err); + throw new RerunError(isAbortError(err) ? "timeout" : "network", "audit run request failed"); + } + + // Poll status until running flips false. + const startedAt = Date.now(); + while (Date.now() - startedAt < MAX_POLL_MS) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const sres = await fetchWithTimeout("/api/audit/status", { cache: "no-store" }); + if (!sres.ok) continue; + const s = await sres.json() as { running: boolean }; + if (!s.running) return; + } catch { + // Transient (including per-request timeout) — keep polling until the + // outer MAX_POLL_MS budget runs out. + } + } + throw new RerunError("timeout", "audit poll loop timed out"); +} diff --git a/app/audit/_components/return-section.tsx b/app/audit/_components/return-section.tsx new file mode 100644 index 00000000..c39eb4ff --- /dev/null +++ b/app/audit/_components/return-section.tsx @@ -0,0 +1,428 @@ +"use client"; + +/** + * Section 06 — NEXT AUDIT / "come back better." Re-audit loop CTA. + * + * Behavior matrix: + * - unknown (probe in flight) → buttons disabled + * - anon (no session) → [ set a reminder ] opens AuthDialog, + * on success we flip to the authed panel + * below and persist the 7-day reminder. + * - authed (any) → consolidated status panel: "signed in as + * …" + either the persisted "next audit in + * X days" line OR a "no reminder set yet" + * line with an inline [ set a reminder ] + * button. The reminder persists across + * reloads via ~/.failproofai/next-audit.json + * — same as the CLI's auth.json. + * + * Also exposes [ re-audit now ] next to [ install policies ] so the user + * can trigger a fresh scan inline without leaving the page. The button + * fires POST /api/audit/run (same backend the empty-state CTA uses). + */ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { isAbortError } from "@/lib/fetch-with-timeout"; +import { AuthDialog, type AuthedUser } from "./auth-dialog"; +import { RerunError, triggerRun } from "./rerun-button"; + +interface Props { + result: AuditResult; +} + +const BULK_INSTALL_CMD = "failproofai policies --install"; +const DEFAULT_REMINDER_DAYS = 7; + +type AuthStatus = + | { kind: "unknown" } + | { kind: "anon" } + | { kind: "authed"; user: { id: string; email: string } }; + +interface Reminder { + next_audit_at: number; // unix seconds + user_email: string; + set_at: number; +} + +function daysUntil(unixSecs: number): number { + const nowSecs = Math.floor(Date.now() / 1000); + return Math.max(0, Math.ceil((unixSecs - nowSecs) / 86400)); +} + +function formatNextAudit(unixSecs: number): string { + const d = new Date(unixSecs * 1000); + return d.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +export function ReturnSection({ result }: Props) { + const { capture } = usePostHog(); + const hasUnenabled = result.results.some( + (r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0, + ); + + const [copied, setCopied] = useState(false); + const [authStatus, setAuthStatus] = useState({ kind: "unknown" }); + const [reminder, setReminder] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [reminderBusy, setReminderBusy] = useState(false); + const [rerunBusy, setRerunBusy] = useState(false); + const ctaShownRef = useRef(false); + /** Throttle gate for focus/visibility-triggered refreshStatus calls — + * rapid alt-tabbing would otherwise hit /api/auth/status (two disk + * reads each) once per event. */ + const lastRefreshAtRef = useRef(0); + + // Probe /api/auth/status on mount — also returns the persisted reminder + // when one exists and belongs to the active session. + const refreshStatus = useCallback(async () => { + lastRefreshAtRef.current = Date.now(); + try { + const res = await fetch("/api/auth/status", { cache: "no-store" }); + const body = (await res.json()) as { + authenticated?: boolean; + user?: { id: string; email: string }; + reminder?: Reminder | null; + }; + if (body.authenticated && body.user) { + setAuthStatus({ kind: "authed", user: body.user }); + setReminder(body.reminder ?? null); + } else { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + } catch { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + }, []); + + useEffect(() => { + void refreshStatus(); + // Re-probe whenever the tab regains focus or visibility — picks up + // CLI `failproofai auth login` / `logout` and api-server restarts + // without the user having to hit reload manually. Throttled to 5s so + // rapid alt-tabbing (a focus + a visibility event for one switch) + // doesn't double-fire. + const REFRESH_MIN_INTERVAL_MS = 5_000; + const maybeRefresh = () => { + if (Date.now() - lastRefreshAtRef.current < REFRESH_MIN_INTERVAL_MS) return; + void refreshStatus(); + }; + const onFocus = () => maybeRefresh(); + const onVisibility = () => { + if (document.visibilityState === "visible") maybeRefresh(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refreshStatus]); + + // Fire-once "user saw the reminder CTA" event so we can compute the + // funnel shown → clicked → auth → saved. We wait until the auth probe + // finishes — before that the buttons are disabled and the CTA isn't + // really "shown". + useEffect(() => { + if (ctaShownRef.current) return; + if (authStatus.kind === "unknown") return; + ctaShownRef.current = true; + capture("audit_reminder_cta_shown", { + auth_state: authStatus.kind, + has_existing_reminder: reminder !== null, + source: "return_section", + }); + }, [authStatus, capture, reminder]); + + const persistReminder = useCallback(async (): Promise => { + // 10s ceiling so a hung route can't permanently disable the CTA. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + setReminderBusy(true); + const res = await fetch("/api/auth/reminder", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ in_days: DEFAULT_REMINDER_DAYS }), + signal: controller.signal, + }); + if (!res.ok) { + // A 401 here means our local "authed" view of the world is out of + // date — the server-side session was revoked or expired between the + // status probe and this write. Flip back to anon so the user can + // re-authenticate instead of leaving them on a panel whose actions + // silently no-op. + if (res.status === 401) { + setAuthStatus({ kind: "anon" }); + setReminder(null); + } + capture("audit_reminder_saved", { + status: `http_${res.status}`, + source: "return_section", + }); + return null; + } + const body = (await res.json()) as { reminder?: Reminder }; + capture("audit_reminder_saved", { + status: body.reminder ? "success" : "empty", + source: "return_section", + }); + return body.reminder ?? null; + } catch (err) { + const kind = isAbortError(err) ? "timeout" : "error"; + capture("audit_reminder_saved", { + status: kind, + source: "return_section", + }); + return null; + } finally { + clearTimeout(timer); + setReminderBusy(false); + } + }, [capture]); + + const handleInstall = async () => { + capture("audit_install_policies_clicked", { + source: "return_section", + }); + try { + await navigator.clipboard.writeText(BULK_INSTALL_CMD); + setCopied(true); + capture("audit_copy_clicked", { + source: "return_section_install_policies", + item_type: "bulk_install_command", + }); + setTimeout(() => setCopied(false), 1500); + } catch { + /* ignore */ + } + }; + + const handleSetReminder = useCallback(async () => { + if (authStatus.kind === "unknown") return; + capture("audit_reminder_cta_clicked", { + auth_state: authStatus.kind, + has_existing_reminder: reminder !== null, + source: "return_section", + }); + if (authStatus.kind === "authed") { + const next = await persistReminder(); + if (next) setReminder(next); + return; + } + setDialogOpen(true); + }, [authStatus, capture, persistReminder, reminder]); + + const handleAuthed = useCallback( + async (user: AuthedUser) => { + setAuthStatus({ kind: "authed", user }); + capture("audit_auth_completed", { + source: "return_section", + }); + // The dialog opened because the user wanted a reminder → persist + // immediately, no second click required. + const next = await persistReminder(); + if (next) setReminder(next); + }, + [capture, persistReminder], + ); + + const handleRerun = useCallback(async () => { + if (rerunBusy) return; + capture("audit_rerun_clicked", { + source: "return_section", + since: "30d", + }); + setRerunBusy(true); + try { + await triggerRun({ cli: [], since: "30d" }); + // Reload the page after the run so the cached result + dashboard cache + // get re-hydrated against the new scan. Cheaper than threading state. + window.location.reload(); + } catch (err) { + const kind = err instanceof RerunError ? err.kind : "network"; + capture("audit_rerun_failed", { + kind, + source: "return_section", + since: "30d", + cli_filter: "all", + }); + } finally { + setRerunBusy(false); + } + }, [capture, rerunBusy]); + + const authed = authStatus.kind === "authed"; + const hasReminder = authed && reminder !== null; + const days = reminder ? daysUntil(reminder.next_audit_at) : 0; + const authedEmail = + authStatus.kind === "authed" ? authStatus.user.email : ""; + + return ( +
+
+
+ ━━ next audit{" "} + · improvement +
+
+ recommended in 7d +
+
+

come back better.

+ +
+
━━ the loop
+

re-audit in 7 days.

+

+ after the prescribed policies have been live for a week, we'll show + your before/after score and which detectors went quiet. +

+

+ most agents move from C to B in one session. some make it in a day. +

+ + {/* Once authed, the section stays in the consolidated status panel — + with the reminder line if one is set, or a "no reminder yet" line + + inline [ set a reminder ] button otherwise. The anonymous CTA + layout only shows for genuinely-unauthed sessions. The action + buttons ([ set a reminder ] / [ re-audit now ] / [ install + policies ]) are identical in both branches — extracted into + below to keep them in sync. */} + {authed ? ( +
+ {hasReminder && reminder ? ( +
+
+ ) : ( +
+
+ )} +
+
+ +
+ ) : ( + + )} +
+ + setDialogOpen(false)} + onAuthed={(u) => { + setDialogOpen(false); + void handleAuthed(u); + }} + /> +
+ ); +} + +interface ReturnActionsProps { + /** Whether to render the `[ set a reminder ]` button. False when the + * user already has a reminder set (the authed-with-reminder case + * shows the panel meta instead). */ + showSetReminder: boolean; + setReminderDisabled: boolean; + reminderBusy: boolean; + rerunBusy: boolean; + hasUnenabled: boolean; + copied: boolean; + onSetReminder: () => void; + onRerun: () => void; + onInstall: () => void; + style?: React.CSSProperties; +} + +/** Action button strip shared by the authed and anon branches. Extracted + * to keep the three buttons in sync — the prior inline copies had + * already drifted on margin-top styling. */ +function ReturnActions(props: ReturnActionsProps): React.ReactElement { + const { + showSetReminder, + setReminderDisabled, + reminderBusy, + rerunBusy, + hasUnenabled, + copied, + onSetReminder, + onRerun, + onInstall, + style, + } = props; + return ( +
+ {showSetReminder && ( + + )} + + {hasUnenabled && ( + + )} +
+ ); +} diff --git a/app/audit/_components/run-progress.tsx b/app/audit/_components/run-progress.tsx new file mode 100644 index 00000000..0b9397a5 --- /dev/null +++ b/app/audit/_components/run-progress.tsx @@ -0,0 +1,120 @@ +"use client"; + +/** + * Fake-progress UI shown while /api/audit/run is in flight. runAudit() does + * not emit granular progress events, so we animate through 4 plausible + * stages on a fixed 4s interval. The user sees motion + a clear "this is + * still working" signal. + * + * Real runs take up to 30 seconds. The 4 stages would otherwise march + * straight to 4/4 and 100% well before the run actually resolves, so we: + * - hold on the last stage in a "finishing up" label + * - cap the visual bar at 90% until the parent unmounts this component + * + * Visual: audit pixel-craft. A `.panel` with pink corner brackets, a + * scanline-style spinner header, a stack of stages with green "✓" / + * pink "▮▮" / dim "○" markers, and a marquee progress bar at the bottom + * filling pink-on-dark as the run advances. + */ +import React, { useEffect, useState } from "react"; + +const STAGES = [ + { label: "discovering transcripts", detail: "walking ~/.claude, ~/.codex, ~/.cursor, …" }, + { label: "parsing session logs", detail: "reading JSONL + sqlite session stores" }, + { label: "running policy checks", detail: "replaying through 30 builtin policies" }, + { label: "aggregating results", detail: "counting hits, ranking by frequency" }, +]; + +const STAGE_DURATION_MS = 4000; +/** Visual cap until the actual run resolves. The component is unmounted + * by the parent on completion — there is no "hit 100%" frame from here. */ +const MAX_DISPLAY_PROGRESS = 0.9; + +export function RunProgress() { + const [stage, setStage] = useState(0); + const [tick, setTick] = useState(0); + + useEffect(() => { + const stageTimer = setInterval(() => { + setStage((s) => Math.min(s + 1, STAGES.length - 1)); + }, STAGE_DURATION_MS); + const tickTimer = setInterval(() => setTick((t) => (t + 1) % 4), 350); + return () => { + clearInterval(stageTimer); + clearInterval(tickTimer); + }; + }, []); + + const dots = ".".repeat(tick + 1); + const onLastStage = stage === STAGES.length - 1; + const barRatio = Math.min(MAX_DISPLAY_PROGRESS, ((stage + 1) / STAGES.length) * MAX_DISPLAY_PROGRESS); + const barPercent = Math.round(barRatio * 100); + + return ( +
+
+
+ ━━ audit{" "} + · in progress +
+
+ scanning +
+
+

scanning sessions{dots}

+ +
+
+ $ + failproofai audit --since 30d + +
+ +
    + {STAGES.map((s, i) => { + const done = i < stage; + const active = i === stage; + return ( +
  • + +
    +
    {s.label}
    + {active && ( +
    + {onLastStage ? "finishing up…" : s.detail} +
    + )} +
    + {active && ( + + )} +
  • + ); + })} +
+ +
+ progress + {barPercent}% +
+
+
+
+ +

+ this usually takes 10–30 seconds depending on how much session history you have. +

+
+
+ ); +} diff --git a/app/audit/_components/score-section.tsx b/app/audit/_components/score-section.tsx new file mode 100644 index 00000000..0ee5b814 --- /dev/null +++ b/app/audit/_components/score-section.tsx @@ -0,0 +1,179 @@ +"use client"; + +/** + * Section 03 — SCORE CARD. + * + * Left column only: YOUR AUDIT SCORE (big number, tier badge, progress + * bar, 3 stat boxes, prescribed-policies chip strip). + * + * Share actions have moved to IdentitySection below the archetype sigil. + */ +import React, { useMemo } from "react"; +import type { AuditResult } from "@/src/audit/types"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { type Grade } from "@/src/audit/scoring"; + +const GRADE_THRESHOLDS: { g: Grade; t: number }[] = [ + { g: "S", t: 90 }, { g: "A", t: 80 }, { g: "B", t: 71 }, + { g: "C", t: 55 }, { g: "D", t: 40 }, +]; + +function pointsToNextFor(score: number): { next: Grade; delta: number } { + for (const { g, t } of GRADE_THRESHOLDS) { + if (score < t) return { next: g, delta: t - score }; + } + return { next: "S", delta: 0 }; +} + +interface Props { + result: AuditResult; + score: number; + grade: Grade; + archetypeKey: ArchetypeKey; + /** Display name shown in the card header. */ + project: string; +} + +export function ScoreSection({ result, score, grade, archetypeKey, project }: Props) { + const archetype = ARCHETYPES[archetypeKey]; + // Cheap scan of 5 thresholds — plain computation. React Compiler memoizes + // the surrounding render anyway, and `useMemo` here tripped the + // preserve-manual-memoization rule. + const pointsToNext = pointsToNextFor(score); + + /** Slipping-through builtin policies (the same heuristic ReturnSection uses + * for its [install policies] CTA). Used as the "policies missing" stat. */ + const missing = useMemo( + () => result.results.filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0).length, + [result], + ); + + /** Rough "days to fix" — capped 1..14. One day per slipping policy, with a + * baseline of 3d on any non-S grade. */ + const daysToFix = useMemo(() => { + if (grade === "S" || missing === 0) return 0; + return Math.max(1, Math.min(14, missing + 1)); + }, [grade, missing]); + + /** % of 0–100 bar to fill — simply score/100. */ + const progressPct = score; + + /** Top-N slipping policies → chip strip on the left card. Capped at 6. */ + const policyChips = useMemo(() => { + const slipping = result.results + .filter((r) => r.source === "builtin" && !r.enabledInConfig && r.hits > 0) + .sort((a, b) => b.hits - a.hits) + .slice(0, 6) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: true as const })); + const enabled = result.results + .filter((r) => r.source === "builtin" && r.enabledInConfig) + .slice(0, Math.max(0, 6 - slipping.length)) + .map((r) => ({ name: shortPolicyLabel(r.name), missing: false as const })); + return [...slipping, ...enabled]; + }, [result]); + + return ( +
+
+
+ ━━ score + · SEE HOW YOUR AGENT IS PERFORMING +
+
+

your audit score.

+ +
+
+ {project} + · + {archetype.name.toLowerCase()} +
+ +
+ {score} + /100 +
+ +
+ {grade} tier + {archetype.name.toLowerCase()} +
+ +
+ score + {pointsToNext.delta > 0 ? ( + + +{pointsToNext.delta} pts to {pointsToNext.next} tier + + ) : ( + top tier ✓ + )} +
+
+ {[40, 55, 71, 80, 90].map((t) => ( +
+ ))} +
+
+
+ {(["D", "C", "B", "A", "S"] as Grade[]).map((g, i) => { + const pos = [40, 55, 71, 80, 90][i]; + return ( + {g} + ); + })} +
+ +
+
+
{missing}
+
policies
missing
+
+
+
+ +{pointsToNext.delta} +
+
pts to
{pointsToNext.next} tier
+
+
+
+ {daysToFix === 0 ? "—" : `~${daysToFix}d`} +
+
est.
to fix
+
+
+ + {policyChips.length > 0 && ( + <> +
policy status
+
+ {policyChips.map((p, i) => ( + + + ))} +
+ + )} +
+
+ ); +} + +/** Drop the "failproofai/" namespace prefix builtin policies carry so chips + * stay compact (`block-sudo` reads better than `failproofai/block-sudo`). */ +function shortPolicyLabel(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} diff --git a/app/audit/_components/show-off-cta.tsx b/app/audit/_components/show-off-cta.tsx new file mode 100644 index 00000000..57a638d6 --- /dev/null +++ b/app/audit/_components/show-off-cta.tsx @@ -0,0 +1,135 @@ +"use client"; + +/** + * Section 01b — SHOW OFF CTA. Big bordered strip directly after the + * identity card. Sigil on the left, "show off your agent." headline + + * sub on the middle, "→ MAKE POSTER" action button on the right. + * + * Clicking the action captures the IdentitySection's archetype-frame + * DOM via html2canvas and triggers a PNG download. The capture target + * is passed in via a ref (avoids querying the DOM by class). + */ +import React, { useState } from "react"; +import { ARCHETYPES, type ArchetypeKey } from "@/src/audit/archetypes"; +import { usePostHog } from "@/contexts/PostHogContext"; +import { Sigil } from "./sigil"; + +interface Props { + archetypeKey: ArchetypeKey; + /** Ref to the IdentitySection's `.archetype-frame` div — captured to PNG. */ + identityFrameRef: React.RefObject; +} + +function buildFilename(archetypeKey: ArchetypeKey): string { + const date = new Date().toISOString().slice(0, 10); + return `failproofai-${archetypeKey}-${date}.png`; +} + +export function ShowOffCTA({ archetypeKey, identityFrameRef }: Props) { + const archetype = ARCHETYPES[archetypeKey]; + const { capture } = usePostHog(); + const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle"); + + const handleMakePoster = async () => { + const node = identityFrameRef.current; + if (!node || state === "busy") return; + capture("audit_poster_clicked", { + archetype: archetypeKey, + }); + setState("busy"); + /** Add a capture-only class that locks font sizes, the grid layout, + * and disables clamp()/text-shadow rules html2canvas renders + * unreliably. CSS lives in audit-styles.css under `.capturing`. */ + node.classList.add("capturing"); + try { + // Wait for the display font (Architype Stedelijk) to load — otherwise + // html2canvas captures a fallback that has different metrics and the + // archetype name overlaps the tagline / sigil column. + if (typeof document !== "undefined" && document.fonts?.ready) { + await document.fonts.ready; + } + // Force a single rAF so the .capturing class is applied to layout + // before html2canvas reads computed styles. + await new Promise((r) => requestAnimationFrame(() => r())); + + const html2canvas = (await import("html2canvas")).default; + // Bleed: include the frame's 8px box-shadow in the capture rect. + const bleed = 12; + const canvas = await html2canvas(node, { + backgroundColor: "#131316", + scale: 2, + logging: false, + useCORS: true, + x: -bleed, + y: -bleed, + width: node.offsetWidth + bleed * 2, + height: node.offsetHeight + bleed * 2, + windowWidth: Math.max(1100, node.offsetWidth + bleed * 2), + }); + await new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { resolve(); return; } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = buildFilename(archetypeKey); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + resolve(); + }, "image/png"); + }); + capture("audit_poster_completed", { + status: "success", + archetype: archetypeKey, + }); + setState("done"); + setTimeout(() => setState("idle"), 2000); + } catch (err) { + console.error("poster capture failed:", err); + capture("audit_poster_completed", { + status: "error", + archetype: archetypeKey, + }); + setState("error"); + setTimeout(() => setState("idle"), 2000); + } finally { + node.classList.remove("capturing"); + } + }; + + const actionLabel = + state === "busy" ? "rendering…" + : state === "done" ? "downloaded ✓" + : state === "error" ? "render failed" + : "make poster"; + + return ( +
+ +
+ ); +} diff --git a/app/audit/_components/sigil.tsx b/app/audit/_components/sigil.tsx new file mode 100644 index 00000000..0dc23b18 --- /dev/null +++ b/app/audit/_components/sigil.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * Pixel sigil — renders an 8x8 grid from the SIGILS table. + * + * Each archetype has an 8x8 character grid where: + * . = empty cell o = ink (foreground) + * p = pink accent g = green accent d = dim + * + * Wrapped in the `.sigil-wrap` / `.sigil` / `.sigil-label` CSS classes + * from the ported audit-styles.css. The `hideLabel` prop is used when the + * sigil appears inside the ShowOff CTA, which hides the "№ 0X SIGIL" caption. + */ +import React from "react"; +import { ARCHETYPES, SIGILS, type ArchetypeKey } from "@/src/audit/archetypes"; + +interface Props { + archetypeKey: ArchetypeKey; + hideLabel?: boolean; +} + +export function Sigil({ archetypeKey, hideLabel }: Props) { + const grid = SIGILS[archetypeKey] ?? SIGILS.optimist; + const archetype = ARCHETYPES[archetypeKey]; + const cells: React.ReactElement[] = []; + + for (let y = 0; y < 8; y++) { + const row = grid[y] ?? "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] ?? "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + + return ( +
+
{cells}
+ {!hideLabel && ( +
+ №{archetype.index} + sigil +
+ )} +
+ ); +} diff --git a/app/audit/_components/strengths-section.tsx b/app/audit/_components/strengths-section.tsx new file mode 100644 index 00000000..d80d8371 --- /dev/null +++ b/app/audit/_components/strengths-section.tsx @@ -0,0 +1,57 @@ +"use client"; + +/** + * Section 02 — STRENGTHS. "your agent does this right." A leaderboard + * of green-checked behaviors derived from the AuditResult (see + * `src/audit/strengths.ts`). + */ +import React from "react"; +import type { Strength } from "@/src/audit/strengths"; + +interface Props { + strengths: Strength[]; + totalDetectorsTriggered: number; + totalDetectorsAvailable: number; +} + +export function StrengthsSection({ + strengths, totalDetectorsTriggered, totalDetectorsAvailable, +}: Props) { + if (strengths.length === 0) return null; + + return ( +
+
+
+ ━━ strengths + {" "}·{" "} + what your agent has figured out +
+
+ {" "} + {totalDetectorsAvailable - totalDetectorsTriggered} of {totalDetectorsAvailable} clean +
+
+

your agent does this right.

+ +
+ {strengths.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
+ — these are your agent's defaults. keep them. +
+
+ ); +} diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css new file mode 100644 index 00000000..13b24ed3 --- /dev/null +++ b/app/audit/audit-styles.css @@ -0,0 +1,1717 @@ +/* ============================================================ + failproof_ai — audit-page-specific styles + Brutalist pixel-craft, /audit-only widgets. + Site-wide tokens, fonts, body atmosphere, .app-header / .btn / + .tab / .section / .panel / .report all moved to globals.css + so they apply everywhere (and no longer leak when navigating + away from /audit back to /policies or /projects). + ============================================================ */ + +/* legacy scanline overlay used by audit-dashboard */ +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +/* ───────────────────────── 00 EMPTY + RUNNING (full-page states) ───────────────────────── */ + +.empty-section, .running-section { padding-top: 80px; padding-bottom: 96px; } + +.empty-panel, .running-panel { + padding: 48px 56px; + display: flex; flex-direction: column; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); +} + +.empty-glyph { align-self: center; text-align: center; margin-bottom: 28px; } +.empty-glyph-grid { + display: grid; + grid-template-columns: repeat(6, 14px); + grid-template-rows: repeat(6, 14px); + gap: 3px; + padding: 16px; + border: 1px solid var(--line-2); + background: var(--bg); + margin: 0 auto 14px; + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.empty-glyph-grid .px { background: transparent; } +.empty-glyph-grid .px.on { background: var(--accent-pink); } +.empty-glyph-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} + +.empty-headline { + font-family: var(--font-display); + font-size: clamp(32px, 4.6vw, 48px); + letter-spacing: 0.1em; line-height: 1.05; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 16px; + text-wrap: balance; + text-align: center; +} +.empty-sub { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.65; color: var(--ink-2); + max-width: 580px; + margin: 0 auto 32px; + text-align: center; +} + +.empty-actions { + display: flex; flex-direction: column; align-items: center; + gap: 12px; +} +.empty-cta { + padding: 12px 24px; + font-size: 14px; + letter-spacing: 0.08em; + text-decoration: none; +} +.empty-meta { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} + +.running-panel { padding: 36px 40px; } +.running-header { + display: flex; align-items: center; gap: 10px; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; + font-family: var(--font-mono); font-size: 13px; +} +.running-prompt { color: var(--accent-green); } +.running-cmd { color: var(--ink); letter-spacing: 0.02em; } +.running-cursor { + color: var(--accent-pink); + margin-left: 4px; + animation: cursor-blink 900ms steps(2, end) infinite; +} +@keyframes cursor-blink { 50% { opacity: 0; } } + +.running-stages { + list-style: none; padding: 0; margin: 0 0 28px; + display: flex; flex-direction: column; +} +.running-stage { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 14px; align-items: start; + padding: 12px 0; + border-bottom: 1px dashed var(--line); + font-family: var(--font-mono); +} +.running-stage:last-child { border-bottom: none; } +.running-marker { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: -1px; + margin-top: 1px; +} +.running-stage.queued { color: var(--dim); } +.running-stage.queued .running-marker { color: var(--line-2); } +.running-stage.active { color: var(--ink); } +.running-stage.active .running-marker { color: var(--accent-pink); } +.running-stage.done { color: var(--ink-2); } +.running-stage.done .running-marker { color: var(--accent-green); } +.running-stage.done .running-stage-label { + text-decoration: line-through; + text-decoration-color: var(--line-2); +} +.running-stage-label { font-size: 13px; letter-spacing: 0.04em; } +.running-stage-detail { + font-size: 11px; color: var(--ink-2); + letter-spacing: 0.02em; + margin-top: 4px; +} +.running-stage-spin { + font-family: var(--font-mono); font-size: 16px; + color: var(--accent-pink); + align-self: center; + animation: spin-step 700ms steps(4, end) infinite; +} +@keyframes spin-step { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + +.running-bar-label { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 8px; +} +.running-bar-track { + position: relative; + height: 6px; background: var(--bg); + border: 1px solid var(--line); + overflow: hidden; +} +.running-bar-fill { + position: relative; + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #e89aaf 100%); + transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1); +} +.running-bar-fill::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, transparent 40%, + rgba(255,255,255,0.35) 50%, + transparent 60%, transparent 100% + ); + animation: bar-shine 1600ms linear infinite; +} +@keyframes bar-shine { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +.running-foot { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--dim); + text-align: center; +} + +@media (max-width: 720px) { + .empty-panel, .running-panel { padding: 32px 24px; } +} + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────── poster capture mode (applied during html2canvas) ───────────── + The live layout uses clamp()/vw font sizes, soft grid columns, and a + stamp text-shadow. html2canvas does NOT support clamp() or text-shadow + reliably — it picks a fallback that misaligns metrics and the giant + archetype name ends up overlapping the tagline + sigil column. + + `.capturing` is added by show-off-cta.tsx right before capture and + removed in the finally block. It locks every viewport-relative size to + an absolute value tuned for the ~1100px capture width, fixes the grid + columns, and clears the stamp shadow + box shadow that html2canvas + would otherwise crop. */ +.archetype-frame.capturing { + min-width: 1080px; + max-width: 1180px; + padding: 52px 52px 44px; + box-shadow: none; +} +.archetype-frame.capturing .arch-name { + font-size: 88px; + line-height: 1; + margin: 0 0 24px; + text-shadow: none; + letter-spacing: 0.06em; +} +.archetype-frame.capturing .arch-tagline { + font-size: 16px; + max-width: 560px; + margin: 0 0 32px; +} +.archetype-frame.capturing .arch-secondary { + margin-bottom: 32px; +} +.archetype-frame.capturing .arch-keywords { + font-size: 24px; + letter-spacing: 0.09em; + padding: 16px 0 12px; + gap: 14px; + max-width: 560px; +} +.archetype-frame.capturing .arch-body { + grid-template-columns: minmax(0, 1.6fr) minmax(220px, 1fr); + gap: 56px; + align-items: start; +} +.archetype-frame.capturing .arch-meta-grid { + margin-top: 32px; + padding-top: 26px; + gap: 28px; +} +.archetype-frame.capturing .arch-closing { + font-size: 22px; + margin-top: 32px; + padding-top: 26px; +} +.archetype-frame.capturing .sigil-wrap { + position: static; + align-self: center; + justify-self: center; + padding-top: 0; +} +.archetype-frame.capturing .sigil { + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + +/* identity share buttons (inside .archetype-frame, hidden during capture) */ +.identity-share-btns { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; + padding-top: 16px; + border-top: 1px dashed var(--line); +} +.identity-share-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--bg); + border: 1px solid var(--line); + color: var(--ink-2); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 120ms, border-color 120ms, color 120ms; + text-transform: lowercase; +} +.identity-share-btn:hover { + background: var(--accent-pink-bg); + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.identity-share-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.isb-glyph { + display: grid; place-items: center; + width: 20px; height: 20px; + border: 1px solid var(--line); + font-family: var(--font-mono); font-size: 10px; + text-transform: uppercase; + color: var(--accent-pink); + font-weight: 600; + flex-shrink: 0; +} +.identity-share-btn:hover .isb-glyph { border-color: var(--accent-pink); } + +/* hide during html2canvas capture */ +.archetype-frame.capturing .identity-share-btns { display: none; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE CARD ───────────────────────── */ + +.score-share-card { + padding: 22px 24px 20px; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} + +.score-card-header { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 16px; +} + +.ss-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} + +/* — left column ------------------------------------------------------ */ +.ss-score-row { + display: flex; align-items: baseline; gap: 10px; + margin: 0 0 10px; +} +.ss-score { + font-family: var(--font-display); + font-size: clamp(52px, 7vw, 76px); + line-height: 0.9; + letter-spacing: 0.04em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.ss-score.g-S, .ss-score.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.ss-score.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.ss-score-of { + font-family: var(--font-mono); font-size: 18px; + color: var(--dim); letter-spacing: 0.08em; +} + +.ss-tier-row { + display: flex; align-items: center; gap: 12px; + margin-bottom: 16px; +} +.ss-tier-badge { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + padding: 5px 10px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.ss-tier-badge.g-S, .ss-tier-badge.g-A { + border-color: var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); +} +.ss-tier-badge.g-B { + border-color: #d3e1a8; + background: rgba(211, 225, 168, 0.10); + color: #d3e1a8; +} +.ss-arch { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.06em; +} + +.ss-progress-label { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.06em; + margin-bottom: 6px; +} +.ss-progress-track { + position: relative; + height: 10px; background: var(--bg); + border: 1px solid var(--line); + margin-bottom: 6px; + overflow: visible; +} +.ss-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-pink) 0%, #f472b6 60%, #a78bfa 100%); +} +.ss-progress-tick { + position: absolute; + top: -4px; bottom: -4px; + width: 1px; + background: var(--line-2); + pointer-events: none; +} +.ss-grade-stops { + position: relative; + height: 16px; + margin-bottom: 16px; +} +.ss-grade-stop { + position: absolute; + transform: translateX(-50%); + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--dim); + top: 0; +} +.ss-grade-stop.active { + color: var(--accent-pink); + font-weight: 700; +} + +.ss-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 16px; +} +.ss-stat { + border: 1px solid var(--line-2); + background: var(--bg); + padding: 10px 12px; + text-align: left; +} +.ss-stat-n { + font-family: var(--font-display); + font-size: 24px; line-height: 1; + letter-spacing: 0.04em; + margin-bottom: 6px; +} +.ss-stat-l { + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--dim); + line-height: 1.4; +} + +.ss-policy-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); + margin-top: 14px; + padding-top: 14px; + border-top: 1px dashed var(--line); + margin-bottom: 10px; +} +.ss-policy-chips { + display: flex; flex-wrap: wrap; gap: 6px; +} +.ss-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; + border-radius: 0; + border: 1px solid var(--line-2); + background: var(--bg); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-chip .dot { + width: 5px; height: 5px; + border-radius: 0; + background: var(--dim); +} +.ss-chip.missing { + border-color: rgba(228, 88, 125, 0.6); + color: var(--accent-pink); + background: rgba(228, 88, 125, 0.06); +} +.ss-chip.missing .dot { background: var(--accent-pink); } +.ss-chip.enabled { + border-color: rgba(102, 209, 181, 0.5); + color: var(--accent-green); + background: rgba(102, 209, 181, 0.05); +} +.ss-chip.enabled .dot { background: var(--accent-green); } + +/* — right column ----------------------------------------------------- */ +.ss-templates { + display: flex; flex-direction: column; gap: 10px; + margin-bottom: 16px; +} +.ss-template { + border: 1px solid var(--line-2); + background: var(--bg); + padding: 14px 16px; +} +.ss-template-head { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 8px; +} +.ss-template-head .dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent-pink); +} +.ss-template-body { + font-family: var(--font-mono); font-size: 12.5px; + line-height: 1.55; color: var(--ink-2); + margin: 0; +} + +.ss-actions { + display: flex; flex-direction: column; gap: 8px; +} +.ss-action-btn { + display: grid; + grid-template-columns: 28px 1fr; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--line-2); + background: transparent; + color: var(--ink); + text-align: left; + font-family: var(--font-mono); font-size: 12px; + cursor: pointer; + transition: all 120ms ease; + text-decoration: none; +} +.ss-action-btn:hover { + border-color: var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.ss-action-btn:disabled { + opacity: 0.55; cursor: wait; +} +.ss-action-glyph { + display: grid; place-items: center; + width: 24px; height: 24px; + border: 1px solid var(--ink-2); + font-family: var(--font-mono); font-size: 11px; + text-transform: uppercase; + color: var(--ink-2); +} +.ss-action-btn:hover .ss-action-glyph { + border-color: var(--accent-pink); + color: var(--accent-pink); +} +.ss-action-text { + display: flex; flex-direction: column; +} +.ss-action-title { + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + color: var(--ink); +} +.ss-action-btn:hover .ss-action-title { color: var(--accent-pink); } +.ss-action-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.05em; + color: var(--dim); + margin-top: 2px; +} + +.ss-foot { + display: flex; justify-content: space-between; gap: 16px; + flex-wrap: wrap; + padding-top: 18px; margin-top: 26px; + border-top: 1px dashed var(--line); + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-2); +} +.ss-foot-link { + color: var(--accent-green); + text-decoration: none; +} +.ss-foot-link:hover { text-decoration: underline; text-underline-offset: 3px; } + +@media (max-width: 880px) { + .score-share-card { padding: 16px 16px 16px; } +} + + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; + /* Clear the sticky .app-header (≈52px tall) when scroll-anchored. */ + scroll-margin-top: 80px; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; align-items: center; } + +/* persistent reminder status (authed + reminder saved) */ +.return-status { + margin-top: 24px; + padding: 18px 20px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); +} +.return-status .rs-row { + display: flex; align-items: center; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); + margin: 6px 0; +} +.return-status .rs-row-primary { + font-size: 15px; + color: var(--ink); + letter-spacing: 0.02em; +} +.return-status .rs-strong { color: var(--accent-pink); } +.return-status .rs-email { color: var(--accent-green); } +.rs-dot { + width: 8px; height: 8px; + display: inline-block; + flex-shrink: 0; +} +.rs-dot-pink { + background: var(--accent-pink); + box-shadow: 0 0 8px rgba(228,88,125,0.55); + animation: pulseDot 1.6s ease-in-out infinite; +} +.rs-dot-green { + background: var(--accent-green); + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.rs-clear { + background: transparent; + border: none; + padding: 4px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--dim); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + transition: color 120ms; +} +.rs-clear:hover:not(:disabled) { color: var(--accent-pink); } +.rs-clear:disabled { opacity: 0.5; cursor: not-allowed; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* ───────────────────────── auth dialog (set-a-reminder gate) ───────────────────────── */ + +.auth-dialog-backdrop { + position: fixed; inset: 0; z-index: 10000; + display: grid; place-items: center; + padding: 32px 16px; + background: + radial-gradient(ellipse 1000px 700px at 30% 20%, rgba(228,88,125,0.08) 0%, transparent 60%), + rgba(8,8,10,0.78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: authFadeIn 140ms ease-out; +} +@keyframes authFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.auth-dialog { + position: relative; + width: 100%; + max-width: 460px; + padding: 32px 32px 28px; + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + var(--bg-2); + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); + font-family: var(--font-mono); + color: var(--ink); + animation: authPop 160ms ease-out; +} +@keyframes authPop { + from { transform: translateY(8px) scale(0.985); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.auth-dialog .corner { + position: absolute; + font-family: var(--font-mono); + font-size: 14px; line-height: 1; + color: var(--accent-pink); opacity: 0.85; +} +.auth-dialog .corner.tl { top: 6px; left: 8px; } +.auth-dialog .corner.tr { top: 6px; right: 8px; } +.auth-dialog .corner.bl { bottom: 6px; left: 8px; } +.auth-dialog .corner.br { bottom: 6px; right: 8px; } + +.auth-close { + position: absolute; top: 12px; right: 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); + background: transparent; border: none; padding: 4px 6px; + cursor: pointer; + transition: color 120ms; +} +.auth-close:hover { color: var(--accent-pink); } +.auth-close:disabled { color: var(--line-2); cursor: not-allowed; } + +.auth-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} + +.auth-headline { + font-family: var(--font-display); + font-size: clamp(26px, 3.6vw, 34px); + letter-spacing: 0.09em; line-height: 1.1; + text-transform: lowercase; + color: var(--ink); + margin: 0 0 12px; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + text-wrap: balance; +} + +.auth-sub { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); + margin: 0 0 22px; +} +.auth-sub .auth-email { + color: var(--accent-pink); +} +.auth-sub .auth-ok { + color: var(--accent-green); + margin-right: 6px; +} + +.auth-form { display: flex; flex-direction: column; gap: 10px; } + +.auth-field-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} + +.auth-input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--line-2); + color: var(--ink); + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.03em; + outline: none; + transition: border-color 120ms, box-shadow 120ms; +} +.auth-input:focus { + border-color: var(--accent-pink); + box-shadow: 0 0 0 1px var(--accent-pink-soft); +} +.auth-input:disabled { + opacity: 0.55; cursor: not-allowed; +} +.auth-input-code { + letter-spacing: 0.5em; + text-align: center; + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.auth-input::placeholder { color: var(--dim); } + +.auth-error { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-pink); + padding: 8px 12px; + background: var(--accent-pink-bg); + border: 1px solid var(--accent-pink); + border-left-width: 3px; + letter-spacing: 0.02em; + margin-top: 4px; +} + +.auth-actions { + display: flex; gap: 10px; flex-wrap: wrap; + margin-top: 14px; +} + +.auth-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 10px 16px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.06em; + background: transparent; + border: 1px solid var(--line-2); + color: var(--ink); + cursor: pointer; + transition: all 120ms ease; +} +.auth-btn:hover:not(:disabled) { + border-color: var(--ink); background: rgba(255,255,255,0.04); +} +.auth-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.auth-btn.primary { + border-color: var(--accent-pink); + color: var(--accent-pink); + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.auth-btn.primary:hover:not(:disabled) { + background: var(--accent-pink); color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} + +.auth-back { + align-self: flex-start; + margin-top: 4px; + background: transparent; border: none; padding: 6px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; color: var(--dim); + cursor: pointer; + transition: color 120ms; +} +.auth-back:hover:not(:disabled) { color: var(--ink-2); } +.auth-back:disabled { opacity: 0.45; cursor: not-allowed; } + +@media (max-width: 520px) { + .auth-dialog { padding: 26px 22px 22px; } + .auth-actions { flex-direction: column; align-items: stretch; } + .auth-btn { justify-content: center; } +} + +/* status pill in the return CTA: shows current logged-in email */ +.auth-status-pill { + display: inline-flex; align-items: center; gap: 8px; + margin-top: 16px; + padding: 6px 10px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; + color: var(--ink-2); +} +.auth-status-pill .dot { + width: 7px; height: 7px; + background: var(--accent-green); display: inline-block; + box-shadow: 0 0 6px rgba(102,209,181,0.55); +} +.auth-status-pill .email { color: var(--accent-green); } + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/app/audit/loading.tsx b/app/audit/loading.tsx new file mode 100644 index 00000000..43ca6018 --- /dev/null +++ b/app/audit/loading.tsx @@ -0,0 +1,24 @@ +/** Suspense fallback for /audit while the server component reads the cache. + * Renders a minimal skeleton — the cache read itself is cheap so this + * rarely flashes, but Next.js requires loading.tsx for route Suspense to + * work cleanly. */ +export default function AuditLoading() { + return ( +
+
+
+
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/audit/page.tsx b/app/audit/page.tsx new file mode 100644 index 00000000..0dfefb48 --- /dev/null +++ b/app/audit/page.tsx @@ -0,0 +1,53 @@ +/** + * /audit — server entry. Reads the dashboard cache, parses URL params + * (?p=project), and hands off to the client dashboard. + * + * Imports audit-styles.css globally for this route only — the existing + * site-wide globals continue to load via the root layout. Audit styles + * override where they clash (dark canvas, JetBrains Mono everywhere, etc.). + */ +import { Suspense } from "react"; +import { notFound } from "next/navigation"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { BUILTIN_POLICIES } from "@/src/hooks/builtin-policies"; +import { AUDIT_DETECTORS } from "@/src/audit/detectors"; +import { AuditDashboard } from "./_components/audit-dashboard"; +import "./audit-styles.css"; + +// Computed server-side: shipping these modules to the client would pull +// in node:fs / execSync from the workflow policies. +const TOTAL_CATALOG_SIZE = BUILTIN_POLICIES.length + AUDIT_DETECTORS.length; + +export const dynamic = "force-dynamic"; + +interface PageProps { + searchParams: Promise<{ p?: string }>; +} + +export default async function AuditPage({ searchParams }: PageProps) { + const disabled = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") + .split(",").map((s) => s.trim()).filter(Boolean); + if (disabled.includes("audit")) notFound(); + + const { p } = await searchParams; + + const cache = readDashboardCache(); + const initial = cache + ? { + status: "cached" as const, + cachedAt: cache.cachedAt, + params: cache.params, + result: cache.result, + } + : { status: "empty" as const }; + + return ( + + + + ); +} diff --git a/app/globals.css b/app/globals.css index 9b34a006..bc4b5383 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,80 +1,161 @@ +/* ============================================================ + failproof_ai — unified design system + Single source of truth for fonts, color palette, and every + class that used to live in app/audit/audit-styles.css for the + /audit page only. After this change those classes (.section, + .share-btn, .btn-press, …) are available on every route, and + navigating between /audit and /policies no longer leaks + one-off resets in either direction. + + IMPORTANT: every `@import` MUST come before any other rule, + per the CSS spec. `@import "tailwindcss"` inlines thousands + of utility rules at its position, so font @imports go above + it — putting them after silently breaks the build with a + PostCSS "must precede all rules" error. + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=VT323&display=swap'); @import "tailwindcss"; +@font-face { + font-family: 'Architype Stedelijk'; + src: url('/audit/fonts/architype-stedelijk.woff2') format('woff2'), + url('/audit/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + @custom-variant dark (&:is(.dark *)); :root { - --radius: 0.5rem; - - /* Near-black canvas + brand pink accent — failproofai brand: pink primary - (#e4587d, from docs/docs.json colors.light) with green status reserved - for chart-2 / success indicators (the leaf gradient family). */ - --background: #0a0a0a; - --foreground: #fafafa; - --card: #141416; - --card-foreground: #fafafa; - --popover: #141416; - --popover-foreground: #fafafa; - --primary: #e4587d; - --primary-foreground: #0a0a0a; - --secondary: #1f1f22; - --secondary-foreground: #fafafa; - --muted: #1f1f22; - --muted-foreground: #a1a1aa; - --accent: #e4587d; + /* ── audit-native tokens (used by .section / .share-btn / etc.) ── */ + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; + + /* ── shadcn-compatible aliases (used by Tailwind utilities + the + hooks-client component tree). All point at the audit palette so + `bg-card`, `text-foreground`, `border-border`, etc. produce + audit visuals everywhere without rewriting any class names. ── */ + --radius: 0; + --background: var(--bg); + --foreground: var(--ink); + --card: var(--bg-2); + --card-foreground: var(--ink); + --popover: var(--bg-2); + --popover-foreground: var(--ink); + --primary: var(--accent-pink); + --primary-foreground: var(--bg); + --secondary: var(--bg-3); + --secondary-foreground: var(--ink); + --muted: var(--bg-3); + --muted-foreground: var(--ink-2); + --accent: var(--accent-pink); --accent-light: #f08aa6; --accent-lighter: #f7b3c5; --accent-lightest: #fbd5de; - --accent-foreground: #0a0a0a; - --destructive: #ef4444; - --border: #27272a; - --input: #27272a; - --ring: #e4587d; - --chart-1: #e4587d; - --chart-2: #4ade80; - --chart-3: #fbbf24; + --accent-foreground: var(--bg); + --destructive: var(--accent-pink); + --border: var(--line); + --input: var(--line-2); + --ring: var(--accent-pink); + --chart-1: var(--accent-pink); + --chart-2: var(--accent-green); + --chart-3: var(--amber); --chart-4: #f87171; --chart-5: #a78bfa; - --sidebar: #141416; - --sidebar-foreground: #fafafa; - --sidebar-primary: #e4587d; - --sidebar-primary-foreground: #0a0a0a; - --sidebar-accent: #1f1f22; - --sidebar-accent-foreground: #fafafa; - --sidebar-border: #27272a; - --sidebar-ring: #e4587d; + --sidebar: var(--bg-2); + --sidebar-foreground: var(--ink); + --sidebar-primary: var(--accent-pink); + --sidebar-primary-foreground: var(--bg); + --sidebar-accent: var(--bg-3); + --sidebar-accent-foreground: var(--ink); + --sidebar-border: var(--line); + --sidebar-ring: var(--accent-pink); } -/* Custom Scrollbar Styling */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} +* { box-sizing: border-box; } -::-webkit-scrollbar-track { - background: var(--muted); - border-radius: var(--radius); +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14.5px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; } -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: var(--radius); - transition: background 0.2s ease; +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; + position: relative; } -::-webkit-scrollbar-thumb:hover { - background: var(--primary); -} +button { font-family: inherit; cursor: pointer; } +a { color: inherit; } -::-webkit-scrollbar-corner { - background: var(--muted); +/* engineering-plate cross-hatch + grain — site-wide atmosphere */ +body::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +body::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.4; + mix-blend-mode: overlay; } -/* Firefox scrollbar styling */ -* { - scrollbar-width: thin; - scrollbar-color: var(--border) var(--muted); +body > * { + position: relative; + z-index: 3; } +/* shrink scrollbars without breaking the dark theme */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg-2); } +::-webkit-scrollbar-thumb { background: var(--line-2); } +::-webkit-scrollbar-thumb:hover { background: var(--accent-pink); } +* { scrollbar-width: thin; scrollbar-color: var(--line-2) var(--bg-2); } + +input[type="checkbox"] { accent-color: var(--accent-pink); } +select { color-scheme: dark; } +select option { background-color: var(--bg-2); color: var(--ink); } +input[type="date"] { color-scheme: dark; } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -82,8 +163,8 @@ --color-accent-light: var(--accent-light); --color-accent-lighter: var(--accent-lighter); --color-accent-lightest: var(--accent-lightest); - --font-sans: var(--font-sans, system-ui, -apple-system, sans-serif); - --font-mono: var(--font-mono, ui-monospace, monospace); + --font-sans: var(--font-mono); + --font-mono: var(--font-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -112,10 +193,10 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; } @layer base { @@ -123,95 +204,165 @@ @apply border-border outline-ring/50; } html { - font-size: 120%; + font-size: 100%; } - body { - @apply bg-background text-foreground font-sans; +} - position: relative; - } - /* Faint pink vignette at the top — atmosphere without ornament. */ - body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - z-index: 0; - background: - radial-gradient(ellipse 90% 60% at 50% -10%, rgba(228, 88, 125, 0.07), transparent 65%); - } - body > * { - position: relative; - z-index: 1; - } - input[type="checkbox"] { - accent-color: var(--primary); - } +/* ───────────────────────── app chrome (shared) ───────────────────────── */ - select { - color-scheme: dark; - } +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } - select option { - background-color: var(--popover); - color: var(--popover-foreground); - } +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { display: inline-flex; align-items: baseline; gap: 10px; flex: 1; min-width: 0; color: var(--ink); text-decoration: none; } +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } - /* Date Input Styling */ - input[type="date"] { - position: relative; - overflow: visible; - color-scheme: dark; - } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } - input[type="date"]::-webkit-calendar-picker-indicator { - display: none; - opacity: 0; - position: absolute; - right: 0; - width: 0; - height: 0; - cursor: pointer; - } +/* primary tab strip — used by both the audit page and the policies/projects nav rows */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; + background: transparent; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } - input[type="date"]::-webkit-datetime-edit { - display: inline-flex; - align-items: center; - width: 100%; - padding: 0; - gap: 0; - } +/* ───────────────────────── canonical page chrome ───────────────────────── */ - input[type="date"]::-webkit-datetime-edit-text { - color: var(--muted-foreground); - padding: 0 0.2rem; - } +.report { + max-width: 1380px; + margin: 0 auto; + padding: 0 40px; +} +@media (max-width: 720px) { + .report { padding: 0 20px; } +} - input[type="date"]::-webkit-datetime-edit-month-field, - input[type="date"]::-webkit-datetime-edit-day-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 2.5ch; - min-width: 2.5ch; - } +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } - input[type="date"]::-webkit-datetime-edit-year-field { - color: var(--foreground); - padding: 0 0.15rem; - width: 5ch; - min-width: 5ch; - max-width: 5ch; - overflow: visible; - } +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 24px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} - input[type="date"]::-webkit-datetime-edit-month-field:focus, - input[type="date"]::-webkit-datetime-edit-day-field:focus, - input[type="date"]::-webkit-datetime-edit-year-field:focus { - background-color: var(--muted); - color: var(--foreground); - border-radius: 0.25rem; - } +.section-h-dot { + display: inline-block; + width: 0.5em; height: 0.5em; + border-radius: 50%; + background: var(--accent-green); + margin-left: 0.45em; + vertical-align: middle; + animation: section-h-dot-pulse 1.6s ease-in-out infinite; +} +@keyframes section-h-dot-pulse { + 0%, 100% { opacity: 0.35; box-shadow: 0 0 3px var(--accent-green); } + 50% { opacity: 1; box-shadow: 0 0 14px var(--accent-green); } +} + +/* ───────────────────────── reusable bracket panel ───────────────────────── */ + +.panel { + position: relative; + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; +} +.panel::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); } +.panel::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} + +/* ───────────────────────── animations (preserved from previous globals) ───────────────────────── */ @keyframes entry-highlight { 0% { background-color: color-mix(in oklch, var(--primary), transparent 82%); } @@ -221,7 +372,6 @@ animation: entry-highlight 3s ease-out forwards; outline: 1px solid color-mix(in oklch, var(--primary), transparent 55%); outline-offset: -1px; - border-radius: var(--radius); } @keyframes expand-in { @@ -231,3 +381,31 @@ .animate-expand { animation: expand-in 150ms ease-out; } + +@keyframes audit-row-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.audit-row-enter { + opacity: 0; + animation: audit-row-in 400ms ease-out forwards; + animation-delay: var(--row-delay, 0ms); +} + +@keyframes audit-bar-fill { + from { width: 0; } + to { width: var(--bar-width, 100%); } +} +.audit-bar-fill { + width: var(--bar-width, 100%); + animation: audit-bar-fill 1000ms cubic-bezier(0.22, 1, 0.36, 1); + animation-delay: var(--bar-delay, 0ms); +} + +@media (prefers-reduced-motion: reduce) { + .audit-row-enter, + .audit-bar-fill { + animation: none; + opacity: 1; + } +} diff --git a/app/icon.png b/app/icon.png deleted file mode 100644 index 6eb48de4..00000000 Binary files a/app/icon.png and /dev/null differ diff --git a/app/layout.tsx b/app/layout.tsx index a4aad4ec..058eaa51 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,25 +5,24 @@ * `` so there's no theme indeterminacy and no inline script is needed. */ import type { Metadata } from "next"; -import { Geist_Mono } from "next/font/google"; import { PostHogProvider } from "@/contexts/PostHogContext"; import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; import { Navbar } from "@/components/navbar"; import { Toaster } from "@/app/components/toast"; +import { readDashboardCache } from "@/src/audit/dashboard-cache"; import "./globals.css"; -const geistMono = Geist_Mono({ - subsets: ["latin"], - variable: "--font-mono", - display: "swap", -}); +// Site-wide mono font is JetBrains Mono, loaded via the Google Fonts @import +// at the top of globals.css alongside the audit display font. Keeping the +// import in CSS (rather than next/font) is intentional so the same stylesheet +// is the single source of truth — see the design-system note in globals.css. export const metadata: Metadata = { title: "Failproof AI - Hooks & Project Monitor", description: "Open-source hooks, policies, and project visualization for Claude Code & Agents SDK", icons: { - icon: "/icon.png", + icon: "/icon.svg", }, }; @@ -34,13 +33,21 @@ export default function RootLayout({ }>) { const disabledPages = (process.env.FAILPROOFAI_DISABLE_PAGES ?? "") .split(",").map((s) => s.trim()).filter(Boolean); + // Read the audit cache once per page request to drive the nav badge. + // Cheap (single JSON file) and the cache itself returns null on miss. + const auditCache = readDashboardCache(); + const auditSlippingCount = auditCache?.result?.results + ? auditCache.result.results + .filter((r) => r.source === "audit-detector" || (r.source === "builtin" && !r.enabledInConfig)) + .reduce((sum, r) => sum + r.hits, 0) + : undefined; return ( - + - + {children} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 79ac2104..5b7f8de5 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -4,7 +4,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef, useTransition import { createPortal } from "react-dom"; import Link from "next/link"; import { - ArrowLeft, ShieldCheck, ShieldX, ShieldAlert, @@ -1546,18 +1545,15 @@ function TabBar({ { id: "policies", label: "Configure" }, ]; return ( -
+
{tabs.map((tab) => ( ))}
@@ -1602,41 +1598,55 @@ export default function HooksClient({ initialTab = "activity" }: { initialTab?: }; return ( -
- {/* Header */} -
- +
+

+ {activeTab === "activity" ? "Policies" : "what to stop them doing."} + {activeTab === "activity" && } +

+

- - Back - -

-

- Policies -

- {activeTab === "activity" && ( - - - - - )} -
-

{activeTab === "activity" ? ( <> - {evaluationsHeading} + {evaluationsHeading.toLowerCase()} {policyCounts && ( - + {" · "}enabled policies{" "} - {policyCounts.enabled}/{policyCounts.total} + + {policyCounts.enabled}/{policyCounts.total} + )} - - To configure policies,{" "} + + to configure policies,{" "}

- + - {activeTab === "activity" ? ( - - ) : ( - - )} -
+ {activeTab === "activity" ? ( + + ) : ( + + )} + + ); } diff --git a/app/projects/loading.tsx b/app/projects/loading.tsx index d151aadb..a8804495 100644 --- a/app/projects/loading.tsx +++ b/app/projects/loading.tsx @@ -1,17 +1,22 @@ -/** Skeleton loading UI for the projects page. */ +/** Skeleton loading UI for the projects page — audit-styled to match + * the dashed `.panel` chrome of the loaded state. */ export default function ProjectsLoading() { return ( -
-
-
-
-
+
+
+

+ Projects + +

+
+
+
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
-
+
); } diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 08bfef28..f11a886f 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -1,4 +1,12 @@ -/** Projects page — lists all Claude Agent SDK project folders. */ +/** Projects page — lists all Claude Agent SDK project folders. + * + * Wrapped in the audit `.report` + `.section` chrome so the page picks up + * the unified design system: mono fonts, section masthead with the ━━ + * glyph + green eyebrow label, and the dashed-frame `.panel` around the + * project list when it's populated. The inner ProjectList component is + * unchanged — every Tailwind utility it uses (bg-card, text-foreground, + * border-border, …) now resolves to the audit palette globally. + */ import { Suspense } from "react"; import { notFound } from "next/navigation"; import { getCachedProjectFolders } from "@/lib/projects"; @@ -13,27 +21,42 @@ export default async function ProjectsPage() { if (disabled.includes("projects")) notFound(); const folders = await getCachedProjectFolders(); + const count = folders.length; return ( -
-
-
-

Projects

+
+
+

+ Projects + +

- {folders.length === 0 ? ( -
-

- No projects found in the .claude/projects directory. -

-

- Make sure the directory exists and contains project folders. -

-
- ) : ( - - )} -
-
+ {count === 0 ? ( +
+

+ no projects found in the .claude/projects directory. +

+

+ make sure the directory exists and contains project folders. +

+
+ ) : ( +
+ + + +
+ )} +
); } diff --git a/assets/audit/Audit Report.html b/assets/audit/Audit Report.html new file mode 100644 index 00000000..36628a89 --- /dev/null +++ b/assets/audit/Audit Report.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — audit · blrnow / api-coder + + + + + + + + + + +
+ + + + + diff --git a/assets/audit/Show Off Your Agent.html b/assets/audit/Show Off Your Agent.html new file mode 100644 index 00000000..06b1f8d1 --- /dev/null +++ b/assets/audit/Show Off Your Agent.html @@ -0,0 +1,22 @@ + + + + + +failproof_ai — show off your agent + + + + + + + + + + + +
+ + + + diff --git a/assets/audit/archetypes.jsx b/assets/audit/archetypes.jsx new file mode 100644 index 00000000..a340460e --- /dev/null +++ b/assets/audit/archetypes.jsx @@ -0,0 +1,277 @@ +// ============================================================ +// failproof_ai — audit report: archetype catalog +// 8 archetypes. Each has its own pixel-sigil and behavioral data. +// ============================================================ + +// 8x8 pixel sigil grids. legend: +// . = empty o = ink p = pink g = green d = dim +// Designed to feel like the brand's pixel-agent vocabulary — +// chunky, abstract, each glyph reads in <1s. + +const SIGILS = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +const ARCHETYPES = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + tagline: "ships fast. retries with conviction. occasionally forgets it was already there.", + keywords: ["pace", "conviction", "forgetful"], + description: + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + signature: [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + common: "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + risk: "token waste, retry spirals, stale state assumptions", + closing: "the optimism is a feature. the waste is not.", + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + tagline: "asks for forgiveness, not permission. git push --force is a philosophy.", + keywords: ["bold", "forceful", "ungoverned"], + description: + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + signature: [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + common: "solo repos, weekend projects, founders writing their own infra", + risk: "branch protection bypass, accidental main commits, revert overhead", + closing: "the pace is real. the risk is too.", + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + tagline: "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + keywords: ["curious", "thorough", "leaky"], + description: + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + signature: [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + common: "multi-project setups, agents with broad file access, complex monorepos", + risk: "credential exposure, unintended cross-project reads, secrets landing in context", + closing: "the curiosity stays. the credentials stay private.", + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + tagline: "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + keywords: ["ambitious", "drifting", "inventive"], + description: + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + signature: [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + common: "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + risk: "context drift, hallucinated prior work, compounding errors in long sessions", + closing: "the ambition is good. the context budget is not.", + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + tagline: "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + keywords: ["methodical", "safe", "slow"], + description: + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + common: "production systems, high-stakes codebases, builders with strong safety instincts", + risk: "token overhead, slow sessions, redundant verification loops", + closing: "safety is a feature. so is finishing.", + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + tagline: "in. done. out. your agent doesn't linger.", + keywords: ["clean", "focused", "minimal"], + description: + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + signature: [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + common: "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + risk: "low finding count can mask edge cases that haven't surfaced yet", + closing: "rare. keep it that way.", + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + tagline: "when something doesn't work, it tries the exact same thing again. harder.", + keywords: ["determined", "repetitive", "unbacked"], + description: + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + signature: [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + common: "agents without failure-handling policies, complex directory structures, ambiguous task framing", + risk: "token spirals, stalled sessions, no diagnostic signal ever surfaces", + closing: "the conviction is good. the diagnosis is missing.", + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + tagline: "moves fast, leaves little trace. sometimes leaves a little too little trace.", + keywords: ["efficient", "quiet", "unverified"], + description: + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + signature: [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + common: "fast-moving solo projects, low-constraint CLAUDE.md setups, minimal oversight workflows", + risk: "silent failures, unverified writes, false completion signals", + closing: "fast is good. verified-fast is better.", + secondary: "precision", + }, +}; + +const ARCHETYPE_ORDER = ["optimist", "cowboy", "explorer", "goldfish", "architect", "precision", "hammer", "ghost"]; + +// Pixel sigil component — renders an 8x8 grid from a SIGILS entry +function Sigil({ archetypeKey }) { + // Normalize once so every downstream lookup (SIGILS, ARCHETYPES) hits the + // same safe key. Previously the SIGILS lookup had a fallback but the index + // line still indexed ARCHETYPES[archetypeKey] directly, which crashed on + // an unknown key (TypeError: Cannot read properties of undefined). + const safeKey = ARCHETYPES[archetypeKey] ? archetypeKey : "optimist"; + const grid = SIGILS[safeKey] || SIGILS.optimist; + const cells = []; + for (let y = 0; y < 8; y++) { + const row = grid[y] || "........"; + for (let x = 0; x < 8; x++) { + const c = row[x] || "."; + let cls = "px"; + if (c === "o") cls += " on"; + else if (c === "p") cls += " p"; + else if (c === "g") cls += " g"; + else if (c === "d") cls += " d"; + cells.push(
); + } + } + return ( +
+
{cells}
+
+ №{ARCHETYPES[safeKey].index} + sigil +
+
+ ); +} + +Object.assign(window, { ARCHETYPES, ARCHETYPE_ORDER, SIGILS, Sigil }); diff --git a/assets/audit/assets/fonts/architype-stedelijk.ttf b/assets/audit/assets/fonts/architype-stedelijk.ttf new file mode 100644 index 00000000..d2ec7302 Binary files /dev/null and b/assets/audit/assets/fonts/architype-stedelijk.ttf differ diff --git a/assets/audit/assets/fonts/architype-stedelijk.woff2 b/assets/audit/assets/fonts/architype-stedelijk.woff2 new file mode 100644 index 00000000..e9742a21 Binary files /dev/null and b/assets/audit/assets/fonts/architype-stedelijk.woff2 differ diff --git a/assets/audit/audit.jsx b/assets/audit/audit.jsx new file mode 100644 index 00000000..9eb22dbe --- /dev/null +++ b/assets/audit/audit.jsx @@ -0,0 +1,825 @@ +// ============================================================ +// failproof_ai — audit report +// Personality profile for your agent. Six sections. +// ============================================================ + +const { useState, useEffect, useMemo } = React; + +// ---------- url param helper ---------- +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +// ---------- defaults (tweakable via URL params or the Tweaks panel) ---------- +// URL params: ?a=archetype &s=score &r=rank &c=cohort &p=project +const REPORT_DEFAULTS = /*EDITMODE-BEGIN*/{ + "archetype": getParam("a", "optimist"), + "score": parseInt(getParam("s", "58"), 10), + "rank": parseInt(getParam("r", "1847"), 10), + "cohort": parseInt(getParam("c", "2316"), 10), + "tweetVariant": "show-off", + "showSecondary": true, + "project": getParam("p", "blrnow / api-coder") +}/*EDITMODE-END*/; + +// ---------- helpers ---------- +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function projectedScore(base) { + // every policy ~= +3.5 pts, capped at 92 + return Math.min(92, base + 21); +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// ---------- data ---------- +const STRENGTHS = [ + { + metric: "99%", + unit: "clean tool calls", + headline: "ran 847 tool calls. 8 detectors triggered.", + detail: "99% of tool calls came back clean before today's audit.", + }, + { + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "the explorer instinct never made it to output. secrets stayed secret.", + }, + { + metric: "11", + unit: "avg turns / task", + headline: "tasks complete in 11 turns on average.", + detail: "faster than 63% of audited agents in this cohort.", + }, + { + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never overwrote a file it was mid-edit on.", + }, + { + metric: "94%", + unit: "intent retention", + headline: "stayed on the stated task in 94% of sessions.", + detail: "rarely went off-scope on its own. focus is real.", + }, +]; + +const FINDINGS = [ + { + num: "01", + title: "prepended cd before commands", + count: 20, + policy: "redundant-cd", + projects: 2, + lastSeen: "4h ago", + body: <> + the agent runs cd <cwd> before commands it would have run from the + same directory anyway. mostly harmless. occasionally it gets the path wrong and + manufactures a new bug. + , + cost: { tokens: "~3.2k", risk: "low", radius: "high noise" }, + costLine: <>~3.2k tokens/day burned on redundant navigation. low security risk. high noise., + evidence: [ + { kind: "cmd", text: 'cd /Users/n/blrnow/api && pnpm test' }, + { kind: "comment", text: '# already in /Users/n/blrnow/api' }, + { kind: "cmd", text: 'cd /Users/n/blrnow/api && git status' }, + { kind: "comment", text: '# still already there.' }, + ], + fix: { + slug: "no-redundant-cd", + desc: "rejects cd prefixes when the agent's cwd already matches the target.", + install: "failproof policy add no-redundant-cd", + }, + }, + { + num: "02", + title: "pushed to main without a branch", + count: 7, + policy: "block-push-master", + projects: 1, + lastSeen: "1d ago", + body: <> + seven attempts to push directly to main. branch protection caught four of + them. the other three landed. the agent did not author a rollback. + , + cost: { tokens: "—", risk: "high", radius: "production" }, + costLine: <>7 attempts. branch protection saved you 4 times. the other 3 merged., + evidence: [ + { kind: "cmd", text: 'git push origin main' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin main --force' }, + { kind: "err", text: '! remote: protected branch' }, + { kind: "cmd", text: 'git push origin HEAD:main' }, + { kind: "comment", text: '# fast-forward. merged.' }, + ], + fix: { + slug: "block-push-master", + desc: "intercepts push-to-main attempts; requires a branch + PR.", + install: "failproof policy add block-push-master", + }, + }, + { + num: "03", + title: "read outside the project root", + count: 4, + policy: "block-read-outside-cwd", + projects: 2, + lastSeen: "2d ago", + body: <> + four reads outside the project root. three of them hit credential files + (~/.aws/credentials, ~/.config/openai/key, an out-of-tree{" "} + .env). none made it back to stdout — but they made it into context. + , + cost: { tokens: "n/a", risk: "high", radius: "credentials" }, + costLine: <>4 reads outside project root. 3 hit credential files. high exposure risk., + evidence: [ + { kind: "cmd", text: 'cat /Users/n/.aws/credentials' }, + { kind: "cmd", text: 'cat ../other-repo/.env' }, + { kind: "cmd", text: 'cat ~/.config/openai/key' }, + ], + fix: { + slug: "block-read-outside-cwd", + desc: "denies any read whose absolute path falls outside the project root.", + install: "failproof policy add block-read-outside-cwd", + }, + }, + { + num: "04", + title: "retried the same call six times in a row", + count: 6, + policy: "retry-storm", + projects: 1, + lastSeen: "5h ago", + body: <> + same call, same args, six times under 90 seconds. no diagnosis between attempts. + the file existed — at a different path the agent never tried. + , + cost: { tokens: "~1.8k", risk: "med", radius: "stall" }, + costLine: <>~1.8k tokens/day in retry overhead. 3 sessions stalled before manual correction., + evidence: [ + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "cmd", text: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { kind: "comment", text: '# 6× total. correct path: src/router.ts.' }, + ], + fix: { + slug: "retry-budget", + desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", + install: "failproof policy add retry-budget", + }, + }, + { + num: "05", + title: "context carried beyond its sell-by date", + count: 3, + policy: "context-bleed", + projects: 1, + lastSeen: "3d ago", + body: <> + three sessions referenced files past 82% context fill that were never opened in + the current session. the agent didn't lie. it filled gaps with confidence. + , + cost: { tokens: "varies", risk: "med", radius: "compounding" }, + costLine: <>3 sessions over 80% context. 2 cited files never opened. compounding errors downstream., + evidence: [ + { kind: "comment", text: '# turn 47/52 — ctx 82% full' }, + { kind: "comment", text: '# agent: "as we saw earlier in auth.ts…"' }, + { kind: "comment", text: '# auth.ts was never opened this session.' }, + ], + fix: { + slug: "context-window-guard", + desc: "warns at 75%, forces summary-and-reset at 90%.", + install: "failproof policy add context-window-guard", + }, + }, + { + num: "06", + title: "wrote without verifying", + count: 11, + policy: "verify-after-write", + projects: 2, + lastSeen: "12h ago", + body: <> + eleven writes shipped with no read-back, no test run, no type-check. the build + went green nine times. twice it didn't, and the agent moved on. + , + cost: { tokens: "low", risk: "med", radius: "silent-fail" }, + costLine: <>11 unverified writes. 2 broke the build silently. the agent didn't notice., + evidence: [ + { kind: "cmd", text: 'write_file("src/api/router.ts")', comment: " # done" }, + { kind: "comment", text: '# no read_file to verify' }, + { kind: "comment", text: '# no `pnpm typecheck` after write' }, + ], + fix: { + slug: "verify-after-write", + desc: "requires a read-back or test run before the agent claims a task complete.", + install: "failproof policy add verify-after-write", + }, + }, +]; + +// ---------- top-level shell ---------- +function App() { + const [t, setTweak] = useTweaks ? useTweaks(REPORT_DEFAULTS) : [REPORT_DEFAULTS, () => {}]; + const archetype = ARCHETYPES[t.archetype] || ARCHETYPES.optimist; + const grade = gradeFor(t.score); + const projected = projectedScore(t.score); + const projectedGrade = gradeFor(projected); + + return ( +
+
+
+ +
+ + + + + + + +
+ + {window.TweaksPanel ? ( + + ) : null} +
+
+ ); +} + +// ============================================================ +// SHELL — minimal header with failproof_ai wordmark only +// ============================================================ +function AppHeader() { + return ( +
+ + ▮▮ + failproof_ai + / + audit + +
+ +
+
+ ); +} + +// ============================================================ +// 01 — IDENTITY +// ============================================================ +function IdentitySection({ archetype, showSecondary }) { + const secondary = ARCHETYPES[archetype.secondary]; + return ( +
+
+ ┌ identity + v1.0 ┐ + └ № {archetype.index} / 08 + archetype ┘ + +
+
+
+ ━━ identity · your agent's archetype +
+
+ detected from 847 tool calls + / + 52 sessions + / + 30d + live +
+
+
+
№ {archetype.index} of 08
+
archetype
+
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+ {showSecondary && secondary && ( +
+ with + {secondary.name.replace("the ", "")} + tendencies +
+ )} + +
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ·} + + ))} +
+ +
+
+ common in + {archetype.common} +
+
+ primary risk + {archetype.risk} +
+
+ +
— {archetype.closing}
+
+ + +
+
+
+ ); +} + +// ============================================================ +// SHOW OFF — big CTA strip right after IDENTITY +// Links to the standalone poster page with archetype + score baked into the URL. +// ============================================================ +function ShowOffCTA({ archetype, score, grade, rank, cohort, project }) { + const params = new URLSearchParams({ + a: archetype.key, + s: String(score), + g: grade, + r: String(rank), + c: String(cohort), + p: project, + }); + const href = "Show%20Off%20Your%20Agent.html?" + params.toString(); + + return ( +
+ + + + ━━ shareable poster + show off your agent. + + generate a one-page poster of your {archetype.name}. + score, percentile, sigil. ready to post. + + + + + make poster + + +
+ ); +} + +// ============================================================ +// 02 — STRENGTHS +// ============================================================ +function StrengthsSection() { + return ( +
+
+
+ ━━ strengths · what your agent has figured out +
+
5 of 12 measured
+
+

your agent does this right.

+ +
+ {STRENGTHS.map((s, i) => ( +
+
+
+
{s.headline}
+
{s.detail}
+
+
+ {s.metric} + {s.unit} +
+
+ ))} +
+
— these are your agent's defaults. keep them.
+
+ ); +} + +// ============================================================ +// 03 — SCORE + LEADERBOARD +// ============================================================ +function ScoreSection({ score, grade, rank, cohort, archetype, project }) { + const pointsToB = Math.max(0, 71 - score); + const distBars = useMemo(() => buildDistribution(score), [score]); + const leaderboardRows = useMemo(() => buildLeaderboard(rank, cohort, score, project, archetype), [rank, cohort, score, project, archetype.key]); + + return ( +
+
+
+ ━━ leaderboard · cohort +
+
+ {cohort.toLocaleString()} agents + · + last 30 days +
+
+

you rank #{rank.toLocaleString()}.

+ +
+
+
+
{grade}
+
+
{tierName(grade)}
+
{score}
+
of 100
+
+
+ + {pointsToB > 0 ? ( +

+ a B starts at 71. you're {pointsToB} points away.
+ enable the prescribed policies and you'll get there this week. +

+ ) : grade === "S" ? ( +

+ s tier. few make it here. fewer stay.
+ keep the policies live. revisit in 30 days. +

+ ) : ( +

+ {tierName(grade)}. better than {Math.round((1 - rank / cohort) * 100)}% of audited agents.
+ clean up the findings below to climb. +

+ )} + +
+
+ distribution · last 30d + ▮ = your position +
+
+ {distBars.map((b, i) => ( +
+ ))} +
+
+ F + D + C + B + A + S +
+
+
+ +
+
+
rank
+
agent
+
grade
+
score
+
+ {leaderboardRows.map((r, i) => + r.divider ? ( +
· · ·
+ ) : ( +
+
#{r.rank.toLocaleString()}
+
+
{r.name}{r.you && (you)}
+
{r.arch}
+
+
{r.grade}
+
{r.score}
+
+ ) + )} +
+
+
+ ); +} + +function buildDistribution(yourScore) { + // 20 buckets, 5pts each, 0-100 + // bell-curve-ish centered around 60 + const buckets = []; + for (let i = 0; i < 20; i++) { + const center = i * 5 + 2.5; + const dist = Math.abs(center - 60); + const h = Math.max(8, 100 - dist * 2.2 + (Math.sin(i * 1.3) * 6)); + const you = yourScore >= i * 5 && yourScore < (i + 1) * 5; + buckets.push({ h, you, label: `${i * 5}-${(i + 1) * 5}` }); + } + return buckets; +} + +const LB_NAMES = [ + { name: "anthropic / claude-code-internal", arch: "the precision builder" }, + { name: "openai / gpt-engineer-pro", arch: "the precision builder" }, + { name: "vercel / v0-coder-v3", arch: "the ghost" }, + { name: "supabase / db-migrator", arch: "the paranoid architect" }, + { name: "stripe / payments-bot", arch: "the paranoid architect" }, + { name: "linear / triage-agent", arch: "the ghost" }, + { name: "cursor / refactor-bot", arch: "the precision builder" }, + { name: "replit / repl-coder", arch: "the optimist" }, + { name: "exosphere / orchestrator", arch: "the precision builder" }, + { name: "humanloop / eval-runner", arch: "the paranoid architect" }, +]; + +function buildLeaderboard(yourRank, cohort, yourScore, yourProject, yourArchetype) { + const yourGrade = gradeFor(yourScore); + // top 5 + const rows = []; + rows.push({ rank: 1, ...LB_NAMES[0], grade: "S", score: 97 }); + rows.push({ rank: 2, ...LB_NAMES[1], grade: "S", score: 93 }); + rows.push({ rank: 3, ...LB_NAMES[2], grade: "A", score: 89 }); + rows.push({ rank: 4, ...LB_NAMES[3], grade: "A", score: 86 }); + rows.push({ rank: 5, ...LB_NAMES[4], grade: "A", score: 82 }); + rows.push({ divider: true }); + // 2 above you + rows.push({ rank: yourRank - 2, name: "indie / weekend-coder-42", arch: "the cowboy", grade: gradeFor(yourScore + 2), score: yourScore + 2 }); + rows.push({ rank: yourRank - 1, name: "n8n / workflow-agent", arch: "the optimist", grade: gradeFor(yourScore + 1), score: yourScore + 1 }); + rows.push({ rank: yourRank, name: yourProject, arch: yourArchetype.name, grade: yourGrade, score: yourScore, you: true }); + rows.push({ rank: yourRank + 1, name: "acme / scratch-pad", arch: "the hammer", grade: gradeFor(yourScore - 1), score: yourScore - 1 }); + rows.push({ rank: yourRank + 2, name: "side-quest / cli-tool", arch: "the goldfish", grade: gradeFor(yourScore - 2), score: yourScore - 2 }); + return rows; +} + +// ============================================================ +// 04 — FINDINGS +// ============================================================ +function FindingsSection() { + return ( +
+
+
+ ━━ findings · ranked by impact +
+
+ {FINDINGS.length} detectors triggered +
+
+

your agent has some quirks.

+ +
+ {FINDINGS.map((f) => )} +
+
+ ); +} + +function Finding({ f }) { + return ( +
+
+
№{f.num}
+
{f.title}
+
+ {f.count}× + occurrences +
+
+
+ policy {f.policy} + · + {f.projects} {f.projects === 1 ? "project" : "projects"} + · + last seen {f.lastSeen} +
+
+
+
what happened
+
{f.body}
+
+
+
what this costs
+
{f.costLine}
+
+
+
evidence · sample
+
+ {f.evidence.map((e, i) => { + if (e.kind === "comment") return
{e.text}
; + if (e.kind === "err") return
{e.text}
; + return ( +
+ + {e.text} + {e.err && {e.err}} + {e.comment && {e.comment}} +
+ ); + })} +
+
+
+
the fix
+
+ {f.fix.slug} +
{f.fix.desc}
+ + ${f.fix.install} + +
+
+
+
+ ); +} + +// ============================================================ +// 05 — PRESCRIBED POLICIES +// ============================================================ +const POLICIES = [ + { name: "no-redundant-cd", slug: "policies/no-redundant-cd", desc: "blocks cd prefixes when the agent's cwd already matches the target path.", catches: "would have caught 20 occurrences. saves ~3.2k tokens/day." }, + { name: "block-push-master", slug: "policies/block-push-master", desc: "intercepts pushes to main / master. requires a feature branch + PR.", catches: "would have caught 7 occurrences. 3 of them landed in production." }, + { name: "block-read-outside-cwd", slug: "policies/block-read-outside-cwd", desc: "denies reads of files outside the project root, including symlinks.", catches: "would have caught 4 occurrences. 3 hit credential files." }, + { name: "retry-budget", slug: "policies/retry-budget", desc: "caps identical-arg retries at 2. forces a diagnostic step on the third.", catches: "would have caught 6 occurrences. ~1.8k tokens/day saved." }, + { name: "context-window-guard", slug: "policies/context-window-guard", desc: "warns at 75% context fill. forces summary-and-reset at 90%.", catches: "would have caught 3 occurrences of context bleed." }, + { name: "verify-after-write", slug: "policies/verify-after-write", desc: "requires a read-back or test run before the agent claims completion.", catches: "would have caught 11 occurrences. 2 silent build breaks." }, +]; + +function PoliciesSection({ projected, projectedGrade }) { + return ( +
+
+
+ ━━ policies · prescribed +
+
+ {POLICIES.length} policies · covers 100% of findings +
+
+

enable these. close the gap.

+ +
+ enable all six + + projected score + {projected} + · + {tierName(projectedGrade)} +
+ +
+ {POLICIES.map((p, i) => ( +
+
+
{p.name}
+
№{String(i + 1).padStart(2, "0")}
+
+
{p.desc}
+
{p.catches}
+
+ $ + failproof policy add {p.name} + copy +
+
+ ))} +
+
+ ); +} + +// ============================================================ +// 06 — NEXT AUDIT / RETURN HOOK +// ============================================================ +function ReturnSection() { + return ( +
+
+
+ ━━ next audit · improvement +
+
recommended in 7d
+
+

come back better.

+
+
━━ the loop
+

re-audit in 7 days.

+

after the prescribed policies have been live for a week, we'll show your before/after score and which detectors went quiet.

+

most agents move from C to B in one session. some make it in a day.

+
+ + +
+
+
+ ); +} + +// ============================================================ +// FOOTER +// ============================================================ +function ReportFooter() { + return ( +
+ ▮▮ failproof_ai + · + audit v1.0 + · + generated 26 may 2026, 14:32 utc + · + auto-healing for your agents. +
+ ); +} + +// ============================================================ +// TWEAKS +// ============================================================ +function ReportTweaks({ t, setTweak, projected, projectedGrade }) { + const { TweaksPanel, TweakSection, TweakRadio, TweakSelect, TweakSlider, TweakToggle, TweakText, TweakButton } = window; + if (!TweaksPanel) return null; + return ( + + + setTweak("archetype", v)} + options={ARCHETYPE_ORDER.map((k) => ({ value: k, label: ARCHETYPES[k].name }))} + /> + setTweak("showSecondary", v)} + /> + + + setTweak("score", v)} + /> + setTweak("rank", v)} + /> + setTweak("cohort", v)} + /> + + + setTweak("project", v)} + /> + + +
+ enable all 6 → {projected} · {tierName(projectedGrade)} +
+
+ ); +} + +// ---------- mount ---------- +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/poster-styles.css b/assets/audit/poster-styles.css new file mode 100644 index 00000000..ad3a5712 --- /dev/null +++ b/assets/audit/poster-styles.css @@ -0,0 +1,424 @@ +/* ============================================================ + failproof_ai — shareable poster page + Built on styles.css (tokens + textures + scanlines). + ============================================================ */ + +.poster-app { min-height: 100vh; } +.poster-shell { + position: relative; z-index: 3; + min-height: 100vh; + display: flex; flex-direction: column; +} + +/* toolbar */ +.poster-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.poster-back { + display: inline-flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; + color: var(--ink-2); + transition: color 120ms; +} +.poster-back:hover { color: var(--accent-pink); } +.poster-back .back-arrow { + font-family: var(--font-display); + font-size: 18px; color: var(--accent-pink); +} +.poster-brand { + display: inline-flex; align-items: baseline; gap: 10px; +} +.poster-actions { display: inline-flex; gap: 10px; } + +/* stage */ +.poster-stage { + flex: 1; + display: grid; + grid-template-columns: minmax(720px, 1fr) 320px; + gap: 40px; + padding: 48px 40px 64px; + max-width: 1480px; + width: 100%; + margin: 0 auto; + align-items: start; +} + +/* ─────────── the poster card itself ─────────── */ +.poster { + position: relative; + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.03) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + border: 1px solid var(--line-2); + padding: 48px 56px 40px; + /* lock to a print-friendly aspect — 4:5 portrait-ish */ + aspect-ratio: 4 / 5; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* register / corner marks */ +.poster .reg { + position: absolute; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); + opacity: 0.65; +} +.poster .reg-tl { top: 14px; left: 18px; } +.poster .reg-tr { top: 14px; right: 18px; } +.poster .reg-bl { bottom: 14px; left: 18px; } +.poster .reg-br { bottom: 14px; right: 18px; } + +/* head row */ +.poster-head { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 18px; + border-bottom: 1px dashed var(--line); + margin-bottom: 28px; +} +.poster-eyebrow { + display: inline-flex; align-items: baseline; gap: 12px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-eyebrow .eb-glyph { color: var(--accent-pink); letter-spacing: -2px; } +.poster-eyebrow .eb-sep { color: var(--dim); } +.poster-eyebrow > span:last-child { color: var(--ink); } +.poster-livedot { + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.poster-livedot .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} + +/* hero */ +.poster-hero { + display: grid; + grid-template-columns: 1fr auto; + gap: 32px; + align-items: center; + margin-bottom: 32px; +} +.poster-name { + font-family: var(--font-display); + font-size: clamp(56px, 8vw, 104px); + line-height: 0.92; + letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.poster-tagline { + font-family: var(--font-mono); + font-size: clamp(14px, 1.3vw, 17px); + line-height: 1.5; + color: var(--ink-2); + margin: 0 0 22px; + max-width: 540px; + text-wrap: pretty; +} +.poster-keywords { + display: flex; flex-wrap: wrap; + align-items: baseline; + gap: 14px; + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 30px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.poster-keywords .kw-0 { color: var(--accent-green); } +.poster-keywords .kw-1 { color: var(--ink); } +.poster-keywords .kw-2 { color: var(--accent-pink); } +.poster-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} +.poster-sigil-wrap { display: flex; justify-content: center; } +.poster-sigil-wrap .sigil-label { display: none; } +.poster-sigil-wrap .sigil { + grid-template-columns: repeat(8, 18px); + grid-template-rows: repeat(8, 18px); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} + +/* stats row */ +.poster-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + border: 1px solid var(--line-2); + background: var(--bg); + margin-bottom: 28px; +} +.stat-box { + padding: 22px 20px; + border-right: 1px solid var(--line); + display: flex; flex-direction: column; gap: 6px; + text-align: center; + align-items: center; +} +.stat-box:last-child { border-right: none; } +.stat-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.stat-value { + font-family: var(--font-display); + font-size: clamp(36px, 4vw, 52px); + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink); +} +.stat-value.grade { text-shadow: 3px 3px 0 var(--accent-pink-shadow); } +.stat-box.grade-S .stat-value, .stat-box.grade-A .stat-value { color: var(--accent-green); } +.stat-box.grade-S .stat-value.grade, .stat-box.grade-A .stat-value.grade { + text-shadow: 3px 3px 0 var(--accent-green-shadow); +} +.stat-box.grade-B .stat-value { color: #d3e1a8; } +.stat-box.grade-B .stat-value.grade { text-shadow: 3px 3px 0 #6f7e45; } +.stat-box.grade-C .stat-value, +.stat-box.grade-D .stat-value, +.stat-box.grade-F .stat-value { color: var(--accent-pink); } +.stat-box.accent .stat-value { + color: var(--accent-pink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.stat-value .pct { + font-size: 0.5em; + margin-left: 4px; + letter-spacing: 0; + color: var(--dim); +} +.stat-sub { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} + +/* positives */ +.poster-positives { + flex: 1; + margin-bottom: 24px; +} +.positives-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 14px; +} +.positives-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 10px; +} +.positives-list li { + display: grid; + grid-template-columns: 28px 1fr; + gap: 14px; + align-items: start; + padding: 10px 16px; + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink); + line-height: 1.5; +} +.positives-list .check { + color: var(--accent-green); + font-weight: 600; + font-size: 14px; +} + +/* footer cta strip */ +.poster-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.foot-headline { + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.foot-sub { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink-2); + margin-top: 4px; +} +.foot-right { + display: inline-flex; + align-items: center; + gap: 14px; + padding: 14px 20px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); +} +.foot-cta { + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: lowercase; +} +.foot-arrow { + font-family: var(--font-display); + font-size: 24px; +} + +.poster-stamp { + position: absolute; + bottom: 14px; left: 50%; + transform: translateX(-50%); + display: inline-flex; gap: 8px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.poster-stamp .stamp-date { color: var(--accent-pink); opacity: 0.7; } + +/* aside / hint card */ +.poster-hint { + position: sticky; top: 96px; + padding: 24px; + border: 1px solid var(--line-2); + background: var(--bg-2); + display: flex; flex-direction: column; gap: 18px; +} +.hint-label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.hint-list { + list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 12px; + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); +} +.hint-list li { + display: flex; gap: 12px; align-items: baseline; +} +.hint-num { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; + color: var(--accent-pink); + font-weight: 600; +} +.hint-divider { + color: var(--dim); font-family: var(--font-mono); + letter-spacing: 0.18em; font-size: 11px; + border-top: 1px dashed var(--line); + padding-top: 14px; +} +.hint-meta { + display: flex; flex-direction: column; gap: 6px; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.15em; + color: var(--dim); + text-transform: uppercase; +} +.hint-link { + background: var(--bg); + border: 1px solid var(--line); + padding: 6px 8px; + color: var(--accent-green); + font-size: 11px; + letter-spacing: 0; + text-transform: none; + word-break: break-all; + white-space: normal; +} + +/* page footer */ +.poster-page-foot { + text-align: center; + padding: 24px 32px 32px; + border-top: 1px solid var(--line); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.poster-page-foot a:hover { color: var(--accent-pink); } +.poster-page-foot .h-brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* print — save the poster as a clean image (Cmd+P → save PDF) */ +@media print { + .poster-toolbar, .poster-hint, .poster-page-foot, .scanline-overlay { display: none !important; } + body, .poster-app { background: #131316 !important; } + .app::before, .app::after { display: none !important; } + .poster-stage { + grid-template-columns: 1fr; + padding: 0; margin: 0; gap: 0; + } + .poster { + box-shadow: none; + border: 1px solid var(--line-2); + aspect-ratio: 4 / 5; + width: 100%; + margin: 0; + page-break-inside: avoid; + } +} + +/* responsive */ +@media (max-width: 1100px) { + .poster-stage { + grid-template-columns: 1fr; + padding: 24px 20px 48px; + gap: 24px; + } + .poster { + padding: 32px 28px; + aspect-ratio: auto; + } + .poster-hint { position: static; } +} +@media (max-width: 700px) { + .poster-toolbar { padding: 12px 16px; flex-wrap: wrap; gap: 10px; } + .poster-actions .btn { padding: 6px 10px; font-size: 11px; } + .poster-brand { display: none; } + .poster-hero { grid-template-columns: 1fr; } + .poster-stats { grid-template-columns: repeat(2, 1fr); } + .stat-box { border-bottom: 1px solid var(--line); } + .stat-box:nth-child(2) { border-right: none; } + .stat-box:nth-last-child(-n+2) { border-bottom: none; } + .poster-foot { flex-direction: column; align-items: stretch; } + .foot-right { justify-content: center; } +} diff --git a/assets/audit/poster.jsx b/assets/audit/poster.jsx new file mode 100644 index 00000000..2342ad29 --- /dev/null +++ b/assets/audit/poster.jsx @@ -0,0 +1,247 @@ +// ============================================================ +// failproof_ai — show off your agent +// Standalone shareable poster. Reads ?a=&s=&g=&r=&c=&p= from URL. +// One screen. Designed to be screenshotted and posted. +// ============================================================ + +const { useState, useEffect, useMemo, useRef } = React; + +function getParam(name, fallback) { + try { + const v = new URLSearchParams(window.location.search).get(name); + return v == null || v === "" ? fallback : v; + } catch (e) { return fallback; } +} + +function gradeFor(score) { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} +function tierName(g) { + return { S: "s tier", A: "a tier", B: "b tier", C: "c tier", D: "d tier", F: "f tier" }[g]; +} + +// strengths to display: each archetype gets 3 specific positives. +const POSITIVES = { + optimist: [ + "99% clean tool calls (847 total)", + "zero credential exposure to stdout", + "ships in 11 turns on average", + ], + cowboy: [ + "highest output rate in its cohort", + "94% intent retention across sessions", + "branch protection caught the worst of it", + ], + explorer: [ + "broadest file-graph traversal of any cohort", + "zero credential exposure to stdout", + "fastest first-token-to-write on the leaderboard", + ], + goldfish: [ + "completed 47-turn sessions other agents abandoned", + "98% accuracy in the first 75% of context", + "no double-writes across production projects", + ], + architect: [ + "zero unverified writes ever", + "100% type-check coverage before any commit", + "lowest production-bug rate in cohort", + ], + precision: [ + "minimal tool-call footprint per task", + "session ends when task ends — every time", + "lowest retry rate of any agent audited", + ], + hammer: [ + "highest follow-through rate on hard tasks", + "never abandons a session mid-task", + "94% intent retention", + ], + ghost: [ + "fastest task completion in its cohort", + "minimal token overhead per write", + "zero retry-storms detected", + ], +}; + +function Poster() { + const key = getParam("a", "optimist"); + const archetype = ARCHETYPES[key] || ARCHETYPES.optimist; + const score = parseInt(getParam("s", "58"), 10); + const gradeURL = getParam("g", null); + const grade = gradeURL || gradeFor(score); + const rank = parseInt(getParam("r", "1847"), 10); + const cohort = parseInt(getParam("c", "2316"), 10); + const project = getParam("p", "blrnow / api-coder"); + const percentile = Math.max(1, Math.round((1 - (rank - 1) / cohort) * 100)); + const positives = POSITIVES[key] || POSITIVES.optimist; + const [copied, setCopied] = useState(false); + + const handleCopyLink = () => { + try { + navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } catch (e) {} + }; + + const handleBack = (e) => { + if (document.referrer && document.referrer.includes(window.location.host)) { + // browser back + return; + } + e.preventDefault(); + window.location.href = "Audit Report.html"; + }; + + return ( +
+
+
+
+ + + back to audit + +
+ ▮▮ + failproof_ai + / + share +
+
+ + +
+
+ +
+
+ {/* register marks */} + ┌ № {archetype.index} / 08 + v1.0 · 30d ┐ + └ shareable + failproof_ai ┘ + +
+
+ ━━ + archetype № {archetype.index} + · + {project} +
+
+ + live audit +
+
+ +
+
+

{archetype.name}

+

{archetype.tagline}

+
+ {archetype.keywords.map((k, i) => ( + + {k} + {i < archetype.keywords.length - 1 && ·} + + ))} +
+
+
+ +
+
+ +
+
+
grade
+
{grade}
+
{tierName(grade)}
+
+
+
score
+
{score}
+
of 100
+
+
+
rank
+
#{rank.toLocaleString()}
+
of {cohort.toLocaleString()}
+
+
+
top
+
{percentile}%
+
of cohort
+
+
+ +
+
━━ what this agent does right
+
    + {positives.map((p, i) => ( +
  • + + {p} +
  • + ))} +
+
+ +
+
+
audit your agent.
+
five ways your agent fails. five policies that catch it.
+
+
+
failproofai.com/audit
+
+
+
+ + {/* stamp */} +
+ generated + 26.05.2026 · 14:32 utc +
+
+ + +
+ + +
+
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(); diff --git a/assets/audit/screenshots/poster-optimist.png b/assets/audit/screenshots/poster-optimist.png new file mode 100644 index 00000000..5210a86b Binary files /dev/null and b/assets/audit/screenshots/poster-optimist.png differ diff --git a/assets/audit/screenshots/poster-scrolled.png b/assets/audit/screenshots/poster-scrolled.png new file mode 100644 index 00000000..d673807e Binary files /dev/null and b/assets/audit/screenshots/poster-scrolled.png differ diff --git a/assets/audit/styles.css b/assets/audit/styles.css new file mode 100644 index 00000000..47a8e967 --- /dev/null +++ b/assets/audit/styles.css @@ -0,0 +1,1226 @@ +/* ============================================================ + failproof_ai — audit report styles + Built on design system tokens; brutalist pixel-craft. + ============================================================ */ + +@font-face { + font-family: 'Architype Stedelijk'; + src: url('assets/fonts/architype-stedelijk.woff2') format('woff2'), + url('assets/fonts/architype-stedelijk.ttf') format('truetype'); + font-display: swap; + font-weight: 400; + font-style: normal; +} + +:root { + --bg: #131316; + --bg-2: #0e0e11; + --bg-3: #1a1a1f; + --bg-row-hover: #17171c; + --ink: #d8d6d2; + --ink-2: #9a9892; + --dim: #5e5c58; + --line: #25252b; + --line-2: #32323a; + --accent-pink: #e4587d; + --accent-pink-soft: rgba(228, 88, 125, 0.7); + --accent-pink-shadow: #a83a5a; + --accent-pink-bg: rgba(228, 88, 125, 0.12); + --accent-green: #66d1b5; + --accent-green-shadow: #3e9a82; + --accent-green-bg: rgba(102, 209, 181, 0.10); + --amber: #e8c46a; + --amber-bg: rgba(232, 196, 106, 0.10); + + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --font-display: "Architype Stedelijk", "VT323", "JetBrains Mono", monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + min-height: 100vh; +} + +body { + background-color: var(--bg); + background-image: + radial-gradient(ellipse 1200px 800px at 70% -10%, rgba(228, 88, 125, 0.055) 0%, transparent 60%), + radial-gradient(ellipse 1000px 700px at 0% 100%, rgba(102, 209, 181, 0.04) 0%, transparent 55%), + radial-gradient(ellipse 100% 100% at 50% 50%, transparent 50%, rgba(0,0,0,0.45) 100%), + linear-gradient(180deg, #16161a 0%, #0f0f12 100%); + background-attachment: fixed; +} + +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; padding: 0; } +a { color: inherit; text-decoration: none; } + +/* engineering-plate cross-hatch + grain + scanlines */ +.app::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; + background-image: + linear-gradient(0deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.018) 1px, transparent 1px), + linear-gradient(0deg, rgba(255,255,255,0.012) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,0.012) 1px, transparent 1px); + background-size: 96px 96px, 96px 96px, 24px 24px, 24px 24px; + opacity: 0.7; +} +.app::after { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 2; + background-image: url("data:image/svg+xml;utf8,"); + opacity: 0.5; + mix-blend-mode: overlay; +} +.scanline-overlay { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; + background: repeating-linear-gradient(to bottom, + rgba(255,255,255,0) 0, rgba(255,255,255,0) 2px, + rgba(255,255,255,0.018) 2px, rgba(255,255,255,0.018) 3px); + mix-blend-mode: overlay; +} + +.app-shell { position: relative; z-index: 3; min-height: 100vh; display: flex; flex-direction: column; } + +/* ───────────────────────── app header (in-product chrome) ───────────────────────── */ + +.app-header { + display: flex; align-items: center; gap: 16px; + padding: 14px 32px; + border-bottom: 1px solid var(--line); + background: rgba(10,10,10,0.85); + backdrop-filter: blur(8px); + position: sticky; top: 0; z-index: 50; +} +.h-brand { + display: inline-flex; align-items: baseline; gap: 10px; + flex: 1; min-width: 0; + color: var(--ink); text-decoration: none; +} +.h-brand-mark { + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: -3px; + font-weight: 700; + line-height: 1; +} +.h-brand-name { + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); +} +.h-brand-sep { color: var(--dim); font-size: 12px; } +.h-brand-section { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--accent-green); +} +.h-actions { display: flex; align-items: center; gap: 8px; } +.btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 7px 12px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + border: 1px solid var(--line-2); background: transparent; color: var(--ink); + transition: all 120ms ease; white-space: nowrap; +} +.btn:hover { border-color: var(--ink); background: rgba(255,255,255,0.03); } +.btn-primary { border-color: var(--accent-pink); color: var(--accent-pink); } +.btn-primary:hover { background: var(--accent-pink); color: var(--bg); } +.btn-press { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); + transition: box-shadow 120ms, transform 120ms; +} +.btn-press:hover { box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); transform: translate(2px, 2px); } + +/* tabs */ +.tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid var(--line); + overflow-x: auto; scrollbar-width: none; +} +.tabs::-webkit-scrollbar { display: none; } +.tab { + display: inline-flex; align-items: center; gap: 8px; + padding: 12px 16px; + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; + color: var(--ink-2); + border-bottom: 1px solid transparent; margin-bottom: -1px; + transition: color 120ms, border-color 120ms; white-space: nowrap; +} +.tab:hover { color: var(--ink); } +.tab.is-active { color: var(--accent-pink); border-bottom-color: var(--accent-pink); } + +/* ───────────────────────── audit page shell ───────────────────────── */ + +.report { + max-width: 1180px; + margin: 0 auto; + padding: 0 32px; +} + +.section { + padding: 64px 0; + border-bottom: 1px solid var(--line); + position: relative; +} +.section:last-child { border-bottom: none; } + +.section-mast { + display: flex; align-items: baseline; justify-content: space-between; + gap: 24px; margin-bottom: 28px; flex-wrap: wrap; +} +.section-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-green); + display: inline-flex; align-items: baseline; gap: 10px; +} +.section-label .glyph { color: var(--accent-pink); letter-spacing: -2px; } +.section-meta { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.section-meta .g { color: var(--accent-green); } +.section-meta .p { color: var(--accent-pink); } +.section-h { + font-family: var(--font-display); + font-size: clamp(28px, 4vw, 44px); + line-height: 1.05; letter-spacing: 0.11em; + font-weight: 400; color: var(--ink); + margin: 0 0 18px; + text-transform: lowercase; + text-wrap: balance; +} + +/* ───────────────────────── 01 IDENTITY (the hero moment) ───────────────────────── */ + +.identity { + padding: 80px 0 96px; + position: relative; +} + +.archetype-frame { + position: relative; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(102,209,181,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 56px 56px 48px; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.archetype-frame .corner { + position: absolute; font-family: var(--font-mono); font-size: 11px; + color: var(--accent-pink); opacity: 0.6; letter-spacing: 0.1em; +} +.archetype-frame .corner.tl { top: 8px; left: 12px; } +.archetype-frame .corner.tr { top: 8px; right: 12px; } +.archetype-frame .corner.bl { bottom: 8px; left: 12px; } +.archetype-frame .corner.br { bottom: 8px; right: 12px; } + +.arch-mast { + display: flex; align-items: center; justify-content: space-between; + gap: 24px; margin-bottom: 32px; + border-bottom: 1px dashed var(--line); + padding-bottom: 22px; + flex-wrap: wrap; +} +.arch-mast-left { + display: flex; flex-direction: column; gap: 8px; +} +.arch-eyebrow { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.arch-eyebrow .ix { color: var(--accent-pink); } +.arch-target { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.05em; +} +.arch-target .slash { color: var(--dim); margin: 0 6px; } +.arch-target .live { + margin-left: 10px; color: var(--accent-green); + font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; + display: inline-flex; align-items: center; gap: 6px; +} +.arch-target .dot-live { + width: 7px; height: 7px; background: var(--accent-green); + display: inline-block; + animation: pulseDot 1.6s ease-in-out infinite; + box-shadow: 0 0 8px rgba(102,209,181,0.6); +} +@keyframes pulseDot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(0.85); } +} +.arch-counter { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); + text-align: right; +} +.arch-counter .of { color: var(--ink-2); } + +.arch-body { + display: grid; + grid-template-columns: 1.7fr 1fr; + gap: 56px; + align-items: center; +} + +.arch-name { + font-family: var(--font-display); + font-size: clamp(56px, 10vw, 124px); + line-height: 0.95; + letter-spacing: 0.08em; + margin: 0 0 16px; + text-transform: lowercase; + color: var(--ink); + text-wrap: balance; + /* hard-offset stamp */ + text-shadow: 4px 4px 0 var(--accent-pink-shadow); +} +.arch-tagline { + font-family: var(--font-mono); font-size: 16px; + line-height: 1.5; color: var(--ink-2); + max-width: 580px; margin: 0 0 28px; + text-wrap: pretty; +} +.arch-desc { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink); + max-width: 580px; + margin: 0 0 28px; + text-wrap: pretty; +} + +.arch-secondary { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; + border: 1px dashed var(--line-2); + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + margin-bottom: 24px; +} +.arch-secondary .with { color: var(--dim); } +.arch-secondary .name { color: var(--accent-pink); } + +/* keyword strip — replaces the wordy description */ +.arch-keywords { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 16px; + padding: 18px 0 4px; + font-family: var(--font-display); + font-size: clamp(20px, 2.4vw, 28px); + letter-spacing: 0.11em; + text-transform: lowercase; + line-height: 1.1; +} +.arch-keywords .kw { + color: var(--ink); +} +.arch-keywords .kw:nth-child(1) { color: var(--accent-green); } +.arch-keywords .kw:nth-child(3) { color: var(--ink); } +.arch-keywords .kw:nth-child(5) { color: var(--accent-pink); } +.arch-keywords .kw-sep { + color: var(--dim); + font-family: var(--font-mono); + font-size: 18px; + letter-spacing: 0; +} + +.signature-block { + background: var(--bg); + border: 1px solid var(--line); + padding: 18px 20px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.75; + white-space: pre; + overflow-x: auto; + max-width: 580px; + position: relative; +} +.signature-block::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.signature-block::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 8px; height: 8px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.sig-line { color: var(--ink); display: block; } +.sig-line .arrow { color: var(--accent-green); margin-right: 6px; } +.sig-line .comment { color: var(--dim); } +.sig-line .err { color: var(--accent-pink); } + +.arch-meta-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 28px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.arch-meta-item .label { + display: block; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); + margin-bottom: 8px; +} +.arch-meta-item .label.p { color: var(--accent-pink); } +.arch-meta-item .body { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.55; +} + +.arch-closing { + margin-top: 28px; + font-family: var(--font-display); + font-size: 22px; letter-spacing: 0.11em; + color: var(--accent-pink); + text-transform: lowercase; + border-top: 1px dashed var(--line); + padding-top: 22px; +} + +/* sigil — 8x8 pixel grid */ +.sigil-wrap { + display: flex; flex-direction: column; align-items: center; gap: 16px; + justify-self: center; +} +.sigil { + display: grid; + grid-template-columns: repeat(8, 16px); + grid-template-rows: repeat(8, 16px); + gap: 2px; + padding: 16px; + background: var(--bg); + border: 1px solid var(--line-2); + box-shadow: 6px 6px 0 0 var(--accent-pink-shadow); +} +.sigil .px { background: transparent; } +.sigil .px.on { background: var(--ink); } +.sigil .px.p { background: var(--accent-pink); } +.sigil .px.g { background: var(--accent-green); } +.sigil .px.d { background: var(--dim); } +.sigil-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.sigil-label .ix { color: var(--accent-pink); margin-right: 6px; } + +/* ───────────────────────── 02 STRENGTHS ───────────────────────── */ + +.strengths-grid { + display: grid; gap: 0; + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.strength-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: start; + gap: 16px; + padding: 22px 24px; + border-bottom: 1px solid var(--line); + transition: background 120ms; +} +.strength-row:last-child { border-bottom: none; } +.strength-row:hover { background: rgba(102, 209, 181, 0.03); } +.strength-check { + width: 32px; height: 32px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + color: var(--accent-green); + display: grid; place-items: center; + font-family: var(--font-mono); font-weight: 600; + font-size: 14px; +} +.strength-body { + display: flex; flex-direction: column; gap: 6px; +} +.strength-headline { + font-family: var(--font-mono); font-size: 14px; + color: var(--ink); letter-spacing: 0.01em; + font-weight: 500; +} +.strength-detail { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.01em; + line-height: 1.55; +} +.strength-metric { + font-family: var(--font-display); + font-size: 26px; letter-spacing: 0.06em; + text-transform: lowercase; + color: var(--accent-green); + text-align: right; + line-height: 1; + white-space: nowrap; +} +.strength-metric .unit { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; color: var(--dim); + display: block; margin-top: 4px; +} +.strengths-footer { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); letter-spacing: 0.02em; + margin-top: 18px; + padding-left: 4px; +} + +/* ───────────────────────── 03 SCORE + LEADERBOARD ───────────────────────── */ + +.score-grid { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 28px; + align-items: start; +} + +.score-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 28px; + position: relative; +} +.score-card::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.score-card::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.score-grade-row { + display: flex; align-items: baseline; gap: 24px; + padding-bottom: 20px; + border-bottom: 1px dashed var(--line); + margin-bottom: 22px; +} +.score-grade { + font-family: var(--font-display); + font-size: clamp(96px, 14vw, 168px); + line-height: 0.85; + letter-spacing: 0.02em; + color: var(--accent-pink); + text-shadow: 4px 4px 0 var(--accent-pink-shadow); + text-transform: uppercase; +} +.score-grade.g-S { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-A { color: var(--accent-green); text-shadow: 4px 4px 0 var(--accent-green-shadow); } +.score-grade.g-B { color: #d3e1a8; text-shadow: 4px 4px 0 #6f7e45; } +.score-grade.g-C { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-D { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } +.score-grade.g-F { color: var(--accent-pink); text-shadow: 4px 4px 0 var(--accent-pink-shadow); } + +.score-num { + display: flex; flex-direction: column; gap: 6px; +} +.score-num .n { + font-family: var(--font-display); font-size: 48px; + letter-spacing: 0.08em; line-height: 1; color: var(--ink); +} +.score-num .of { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--dim); +} +.score-num .tier { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent-pink); +} + +.score-prose { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink); line-height: 1.7; + margin-bottom: 24px; +} +.score-prose .hl { color: var(--accent-green); } +.score-prose .pk { color: var(--accent-pink); } + +/* distribution chart */ +.dist { + margin-top: 8px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.dist-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); margin-bottom: 14px; + display: flex; justify-content: space-between; +} +.dist-label .right { color: var(--dim); } +.dist-chart { + display: grid; + grid-template-columns: repeat(20, 1fr); + align-items: end; + gap: 3px; + height: 80px; + margin-bottom: 6px; +} +.dist-bar { + background: var(--bg-3); + border: 1px solid var(--line); + border-bottom: none; + position: relative; +} +.dist-bar.you { + background: var(--accent-pink); + border-color: var(--accent-pink); +} +.dist-bar.you::after { + content: "you"; + position: absolute; bottom: 100%; left: 50%; + transform: translateX(-50%); margin-bottom: 6px; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-pink); white-space: nowrap; +} +.dist-axis { + display: grid; + grid-template-columns: repeat(6, 1fr); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); + margin-top: 12px; + border-top: 1px solid var(--line); + padding-top: 6px; +} +.dist-axis span { text-align: center; } +.dist-axis span.now { color: var(--accent-pink); } + +/* leaderboard */ +.lb { + border: 1px solid var(--line-2); + background: var(--bg-2); +} +.lb-head { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.2); + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--ink-2); +} +.lb-row { + display: grid; + grid-template-columns: 52px 1fr 50px 60px; + gap: 12px; + padding: 12px 18px; + border-bottom: 1px solid var(--line); + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); align-items: center; + transition: background 120ms; +} +.lb-row:last-child { border-bottom: none; } +.lb-row:hover { background: var(--bg-row-hover); } +.lb-row.you { + background: var(--accent-pink-bg); + border-top: 1px solid var(--accent-pink); + border-bottom: 1px solid var(--accent-pink); +} +.lb-row.you .lb-rank, +.lb-row.you .lb-score { color: var(--accent-pink); } +.lb-row.divider { padding: 4px 18px; color: var(--dim); font-size: 10px; letter-spacing: 0.3em; text-align: center; } +.lb-row.divider span { display: block; } +.lb-rank { color: var(--ink-2); letter-spacing: 0.05em; } +.lb-agent { + display: flex; flex-direction: column; gap: 2px; min-width: 0; + overflow: hidden; +} +.lb-agent .name { color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lb-agent .arch { + font-size: 10px; letter-spacing: 0.05em; color: var(--dim); +} +.lb-agent .you-mark { color: var(--accent-pink); margin-left: 6px; } +.lb-grade { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.05em; text-align: center; + text-transform: uppercase; +} +.lb-grade.g-S, .lb-grade.g-A { color: var(--accent-green); } +.lb-grade.g-B { color: #d3e1a8; } +.lb-grade.g-C, .lb-grade.g-D, .lb-grade.g-F { color: var(--accent-pink); } +.lb-score { text-align: right; color: var(--ink); } + +/* ───────────────────────── 04 FINDINGS ───────────────────────── */ + +.findings-list { display: flex; flex-direction: column; gap: 20px; } +.finding { + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.finding::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 8px; height: 8px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.finding-head { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--line); +} +.finding-num { + font-family: var(--font-mono); font-size: 13px; + color: var(--accent-pink); letter-spacing: 0.12em; + font-weight: 600; +} +.finding-title { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.finding-count { + font-family: var(--font-display); font-size: 36px; + letter-spacing: 0.04em; color: var(--accent-pink); + text-transform: lowercase; line-height: 1; + display: flex; align-items: baseline; gap: 6px; +} +.finding-count .label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--dim); +} +.finding-meta { + display: flex; gap: 16px; flex-wrap: wrap; + padding: 12px 24px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.05em; + color: var(--ink-2); + border-bottom: 1px solid var(--line); + background: rgba(0,0,0,0.15); +} +.finding-meta .policy { color: var(--accent-green); } +.finding-meta .sep { color: var(--dim); } +.finding-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.finding-block { + padding: 22px 24px; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + display: flex; flex-direction: column; gap: 10px; +} +.finding-block:nth-child(2n) { border-right: none; } +.finding-block:nth-last-child(-n+2) { border-bottom: none; } +.fb-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.fb-label.cost { color: var(--amber); } +.fb-label.fix { color: var(--accent-pink); } +.fb-body { + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; color: var(--ink-2); +} +.fb-body .pk { color: var(--accent-pink); } +.fb-body .g { color: var(--accent-green); } +.fb-body .a { color: var(--amber); } +.fb-body code { + background: var(--bg); border: 1px solid var(--line); + padding: 1px 6px; color: var(--accent-green); font-size: 12px; +} + +.fb-evidence { + font-family: var(--font-mono); font-size: 12px; + background: var(--bg); border: 1px solid var(--line); + padding: 12px 14px; + white-space: pre; overflow-x: auto; + color: var(--ink); line-height: 1.65; +} +.fb-evidence .arrow { color: var(--accent-green); } +.fb-evidence .err { color: var(--accent-pink); } +.fb-evidence .comment { color: var(--dim); } + +.fb-fix { + background: var(--bg); border: 1px solid var(--line); + padding: 14px; + font-family: var(--font-mono); font-size: 12px; +} +.fb-fix .slug { + display: inline-block; padding: 2px 8px; + background: var(--accent-pink-bg); color: var(--accent-pink); + border: 1px solid var(--accent-pink); + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + margin-bottom: 10px; +} +.fb-fix .cmd { + display: block; margin-top: 12px; + color: var(--accent-green); font-size: 12px; + border-top: 1px dashed var(--line); padding-top: 10px; +} +.fb-fix .cmd .prompt { color: var(--dim); margin-right: 6px; } + +/* ───────────────────────── show-off CTA (after IDENTITY) ───────────────────────── */ + +.showoff { + padding: 0 0 32px; + border-bottom: 1px solid var(--line); + margin-bottom: 0; +} +.showoff-cta { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 32px; + padding: 28px 32px; + border: 1px solid var(--line-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.025) 0 1px, transparent 1px 16px), + var(--bg-2); + color: var(--ink); + text-decoration: none; + position: relative; + transition: border-color 120ms ease, background 120ms ease; +} +.showoff-cta:hover { + border-color: var(--ink-2); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.04) 0 1px, transparent 1px 16px), + var(--bg-3); +} +.showoff-cta::before { + content: ""; position: absolute; top: -1px; left: -1px; + width: 10px; height: 10px; + border-top: 1px solid var(--accent-pink); + border-left: 1px solid var(--accent-pink); +} +.showoff-cta::after { + content: ""; position: absolute; bottom: -1px; right: -1px; + width: 10px; height: 10px; + border-bottom: 1px solid var(--accent-pink); + border-right: 1px solid var(--accent-pink); +} +.showoff-glyph { + display: block; + transform: scale(0.55); + transform-origin: center; + margin: -40px -28px; + /* shrink the embedded sigil without rebuilding it */ +} +.showoff-glyph .sigil { + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.showoff-glyph .sigil-label { display: none; } +.showoff-copy { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.showoff-label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} +.showoff-headline { + font-family: var(--font-display); + font-size: clamp(28px, 3.4vw, 40px); + letter-spacing: 0.11em; + text-transform: lowercase; + color: var(--ink); + line-height: 1.05; + text-shadow: 3px 3px 0 var(--accent-pink-shadow); +} +.showoff-sub { + font-family: var(--font-mono); + font-size: 13px; line-height: 1.55; + color: var(--ink-2); + max-width: 520px; +} +.showoff-action { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 24px; + border: 1px solid var(--accent-pink); + background: var(--accent-pink-bg); + color: var(--accent-pink); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + white-space: nowrap; +} +.showoff-arrow { + font-family: var(--font-display); + font-size: 36px; + letter-spacing: 0; + line-height: 1; + color: var(--accent-pink); +} + +@media (max-width: 800px) { + .showoff-cta { grid-template-columns: 1fr; padding: 24px 20px; gap: 18px; } + .showoff-glyph { margin: 0; transform: scale(0.6); transform-origin: left top; } + .showoff-action { width: 100%; flex-direction: row; justify-content: center; } +} + +/* ───────────────────────── 05 POLICIES ───────────────────────── */ + +.policy-callout { + display: inline-flex; align-items: baseline; gap: 12px; + padding: 12px 18px; + border: 1px solid var(--accent-green); + background: var(--accent-green-bg); + margin-bottom: 28px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.02em; + color: var(--ink); + box-shadow: 4px 4px 0 0 var(--accent-green-shadow); +} +.policy-callout .arrow { color: var(--accent-green); margin: 0 4px; } +.policy-callout .new-score { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-green); +} +.policy-callout .new-tier { color: var(--accent-green); font-weight: 600; } + +.policies-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +.policy-card { + border: 1px solid var(--line-2); + background: var(--bg-2); + padding: 20px 22px; + display: flex; flex-direction: column; gap: 10px; + transition: all 120ms; + position: relative; +} +.policy-card::before { + content: ""; position: absolute; left: 0; top: 0; + width: 3px; height: 100%; + background: var(--accent-pink); opacity: 0.7; +} +.policy-card:hover { background: var(--bg-3); } +.policy-card .head { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--line); + margin-bottom: 4px; +} +.policy-name { + font-family: var(--font-display); font-size: 18px; + letter-spacing: 0.11em; color: var(--ink); + text-transform: lowercase; +} +.policy-slug { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.12em; color: var(--dim); + text-transform: uppercase; +} +.policy-desc { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink-2); line-height: 1.6; +} +.policy-impact { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-green); + letter-spacing: 0.01em; + border-top: 1px dashed var(--line); + padding-top: 10px; + margin-top: auto; +} +.policy-impact .check { margin-right: 6px; } +.policy-install { + display: flex; align-items: center; gap: 8px; + font-family: var(--font-mono); font-size: 11px; + background: var(--bg); border: 1px solid var(--line); + padding: 8px 10px; + color: var(--accent-green); + margin-top: 4px; +} +.policy-install .prompt { color: var(--dim); } +.policy-install .copy { + margin-left: auto; color: var(--dim); cursor: pointer; + font-size: 10px; letter-spacing: 0.15em; text-transform: uppercase; + transition: color 120ms; +} +.policy-install .copy:hover { color: var(--accent-pink); } + +/* ───────────────────────── 06 SHARE + RETURN ───────────────────────── */ + +.share-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; + align-items: start; +} + +.share-card { + border: 1px solid var(--accent-pink); + background: + repeating-linear-gradient(0deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + repeating-linear-gradient(90deg, rgba(228,88,125,0.02) 0 1px, transparent 1px 16px), + var(--bg-2); + padding: 32px; + position: relative; + box-shadow: 8px 8px 0 0 var(--accent-pink-shadow); +} +.share-card .stamp-tl, .share-card .stamp-br { + position: absolute; + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.2em; text-transform: uppercase; + color: var(--accent-pink); opacity: 0.55; +} +.share-card .stamp-tl { top: 8px; left: 12px; } +.share-card .stamp-br { bottom: 8px; right: 12px; } + +.share-brand { + display: flex; align-items: center; gap: 10px; + margin-bottom: 28px; +} +.share-brand .glyph { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; +} +.share-brand .name { + font-family: var(--font-display); font-size: 14px; + letter-spacing: 0.11em; text-transform: lowercase; color: var(--ink); +} +.share-brand .sep { color: var(--dim); font-size: 11px; } +.share-brand .meta { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; color: var(--dim); +} +.share-project { + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.05em; color: var(--ink-2); + margin-bottom: 20px; +} +.share-archetype { + font-family: var(--font-display); + font-size: clamp(36px, 5vw, 56px); + line-height: 1; letter-spacing: 0.08em; + text-transform: lowercase; + color: var(--ink); + text-shadow: 3px 3px 0 var(--accent-pink-shadow); + margin: 0 0 12px; + text-wrap: balance; +} +.share-rule { + width: 56px; height: 2px; + background: var(--accent-pink); + margin: 16px 0 20px; +} +.share-tagline { + font-family: var(--font-mono); font-size: 14px; + line-height: 1.45; color: var(--ink-2); + margin-bottom: 32px; + text-wrap: pretty; +} +.share-score-row { + display: flex; gap: 14px; align-items: center; + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.05em; + padding-top: 22px; + border-top: 1px dashed var(--line); +} +.share-score-row .tier { + font-family: var(--font-display); font-size: 22px; + letter-spacing: 0.08em; color: var(--accent-pink); + text-transform: uppercase; +} +.share-score-row .sep { color: var(--dim); } +.share-score-row .num { color: var(--ink); } +.share-score-row .rank { color: var(--ink-2); } +.share-url { + margin-top: 22px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--accent-green); +} + +.share-actions { + display: flex; flex-direction: column; gap: 16px; +} +.tweet-tabs { + display: flex; gap: 0; + border: 1px solid var(--line-2); +} +.tweet-tab { + flex: 1; + padding: 10px 14px; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--ink-2); + border-right: 1px solid var(--line); + background: var(--bg-2); + transition: all 120ms; +} +.tweet-tab:last-child { border-right: none; } +.tweet-tab:hover { color: var(--ink); } +.tweet-tab.is-active { + color: var(--accent-pink); + background: var(--accent-pink-bg); +} + +.tweet-preview { + background: var(--bg-2); + border: 1px solid var(--line-2); + padding: 20px 22px; + font-family: var(--font-mono); font-size: 13px; + line-height: 1.65; + color: var(--ink); + white-space: pre-wrap; + min-height: 200px; + position: relative; +} +.tweet-preview .url { color: var(--accent-pink); } +.tweet-preview .arch { color: var(--accent-pink); } +.tweet-meta { + display: flex; justify-content: space-between; + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.18em; text-transform: uppercase; + color: var(--dim); +} +.tweet-meta .count.over { color: var(--accent-pink); } + +.share-buttons { + display: flex; gap: 12px; flex-wrap: wrap; +} +.share-btn { + display: inline-flex; align-items: center; gap: 10px; + padding: 14px 20px; + font-family: var(--font-mono); font-size: 13px; + letter-spacing: 0.04em; + border: 1px solid var(--accent-pink); + color: var(--accent-pink); + background: transparent; + transition: all 120ms; + box-shadow: 4px 4px 0 0 var(--accent-pink-shadow); +} +.share-btn:hover { + background: var(--accent-pink); + color: var(--bg); + box-shadow: 2px 2px 0 0 var(--accent-pink-shadow); + transform: translate(2px, 2px); +} +.share-btn.alt { + border-color: var(--line-2); color: var(--ink); + box-shadow: 4px 4px 0 0 #1a1a20; +} +.share-btn.alt:hover { + border-color: var(--ink); background: rgba(255,255,255,0.04); + color: var(--ink); box-shadow: 2px 2px 0 0 #1a1a20; +} +.share-btn .arrow { color: var(--accent-green); } +.share-btn:hover .arrow { color: var(--bg); } + +/* return hook */ +.return-hook { + margin-top: 64px; + padding: 40px 48px; + border: 1px solid var(--line-2); + background: var(--bg-2); + position: relative; +} +.return-hook::before, .return-hook::after { + content: ""; position: absolute; width: 8px; height: 8px; +} +.return-hook::before { + top: -1px; left: -1px; + border-top: 1px solid var(--accent-green); + border-left: 1px solid var(--accent-green); +} +.return-hook::after { + bottom: -1px; right: -1px; + border-bottom: 1px solid var(--accent-green); + border-right: 1px solid var(--accent-green); +} +.return-hook .label { + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); margin-bottom: 12px; +} +.return-hook h3 { + font-family: var(--font-display); font-size: clamp(28px, 3.6vw, 40px); + letter-spacing: 0.11em; line-height: 1.1; + text-transform: lowercase; color: var(--ink); + margin: 0 0 16px; font-weight: 400; +} +.return-hook p { + font-family: var(--font-mono); font-size: 13px; + color: var(--ink-2); line-height: 1.7; + margin: 0 0 8px; max-width: 600px; +} +.return-actions { display: flex; gap: 12px; margin-top: 28px; flex-wrap: wrap; } + +/* ───────────────────────── footer ───────────────────────── */ + +.report-footer { + padding: 48px 32px 24px; + border-top: 1px solid var(--line); + background: var(--bg); + text-align: center; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.15em; text-transform: uppercase; + color: var(--dim); +} +.report-footer .brand-mark { + color: var(--accent-pink); font-family: var(--font-mono); + font-size: 14px; letter-spacing: -2px; font-weight: 700; + margin-right: 6px; +} + +/* responsive */ +@media (max-width: 960px) { + .report { padding: 0 20px; } + .archetype-frame { padding: 32px 24px; } + .arch-body { grid-template-columns: 1fr; gap: 32px; } + .arch-meta-grid { grid-template-columns: 1fr; gap: 16px; } + .score-grid { grid-template-columns: 1fr; gap: 16px; } + .finding-body { grid-template-columns: 1fr; } + .finding-block { border-right: none !important; } + .policies-grid { grid-template-columns: 1fr; } + .share-grid { grid-template-columns: 1fr; } + .strength-row { grid-template-columns: 40px 1fr; } + .strength-metric { grid-column: 2; text-align: left; margin-top: 6px; } + .return-hook { padding: 28px 24px; } +} diff --git a/assets/audit/tweaks-panel.jsx b/assets/audit/tweaks-panel.jsx new file mode 100644 index 00000000..5f8f95a1 --- /dev/null +++ b/assets/audit/tweaks-panel.jsx @@ -0,0 +1,425 @@ + +// tweaks-panel.jsx +// Reusable Tweaks shell + form-control helpers. +// +// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, +// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so +// individual prototypes don't re-roll it. Ships a consistent set of controls so you +// don't hand-draw , segmented radios, steppers, etc. +// +// Usage (in an HTML file that loads React + Babel): +// +// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ +// "primaryColor": "#D97757", +// "fontSize": 16, +// "density": "regular", +// "dark": false +// }/*EDITMODE-END*/; +// +// function App() { +// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); +// return ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +const __TWEAKS_STYLE = ` + .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; + max-height:calc(100vh - 32px);display:flex;flex-direction:column; + background:rgba(250,249,247,.78);color:#29261b; + -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); + border:.5px solid rgba(255,255,255,.6);border-radius:14px; + box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); + font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} + .twk-hd{display:flex;align-items:center;justify-content:space-between; + padding:10px 8px 10px 14px;cursor:move;user-select:none} + .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} + .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); + width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} + .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} + .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; + overflow-y:auto;overflow-x:hidden;min-height:0; + scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} + .twk-body::-webkit-scrollbar{width:8px} + .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} + .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; + border:2px solid transparent;background-clip:content-box} + .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); + border:2px solid transparent;background-clip:content-box} + .twk-row{display:flex;flex-direction:column;gap:5px} + .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} + .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; + color:rgba(41,38,27,.72)} + .twk-lbl>span:first-child{font-weight:500} + .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} + + .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; + color:rgba(41,38,27,.45);padding:10px 0 0} + .twk-sect:first-child{padding-top:0} + + .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px; + background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} + .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} + select.twk-field{padding-right:22px; + background-image:url("data:image/svg+xml;utf8,"); + background-repeat:no-repeat;background-position:right 8px center} + + .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; + border-radius:999px;background:rgba(0,0,0,.12);outline:none} + .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; + width:14px;height:14px;border-radius:50%;background:#fff; + border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; + background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} + + .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; + background:rgba(0,0,0,.06);user-select:none} + .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; + background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); + transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} + .twk-seg.dragging .twk-seg-thumb{transition:none} + .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; + background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; + border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; + overflow-wrap:anywhere} + + .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; + background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} + .twk-toggle[data-on="1"]{background:#34c759} + .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; + background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} + .twk-toggle[data-on="1"] i{transform:translateX(14px)} + + .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; + border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} + .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; + user-select:none;padding-right:8px} + .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; + font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; + outline:none;color:inherit;-moz-appearance:textfield} + .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ + -webkit-appearance:none;margin:0} + .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} + + .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; + background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} + .twk-btn:hover{background:rgba(0,0,0,.88)} + .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} + .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} + + .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; + border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; + background:transparent;flex-shrink:0} + .twk-swatch::-webkit-color-swatch-wrapper{padding:0} + .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} + .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} +`; + +// ── useTweaks ─────────────────────────────────────────────────────────────── +// Single source of truth for tweak values. setTweak persists via the host +// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). +function useTweaks(defaults) { + const [values, setValues] = React.useState(defaults); + // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a + // useState-style call doesn't write a "[object Object]" key into the persisted + // JSON block. + const setTweak = React.useCallback((keyOrEdits, val) => { + const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null + ? keyOrEdits : { [keyOrEdits]: val }; + setValues((prev) => ({ ...prev, ...edits })); + window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); + }, []); + return [values, setTweak]; +} + +// ── TweaksPanel ───────────────────────────────────────────────────────────── +// Floating shell. Registers the protocol listener BEFORE announcing +// availability — if the announce ran first, the host's activate could land +// before our handler exists and the toolbar toggle would silently no-op. +// The close button posts __edit_mode_dismissed so the host's toolbar toggle +// flips off in lockstep; the host echoes __deactivate_edit_mode back which +// is what actually hides the panel. +function TweaksPanel({ title = 'Tweaks', children }) { + const [open, setOpen] = React.useState(false); + const dragRef = React.useRef(null); + const offsetRef = React.useRef({ x: 16, y: 16 }); + const PAD = 16; + + const clampToViewport = React.useCallback(() => { + const panel = dragRef.current; + if (!panel) return; + const w = panel.offsetWidth, h = panel.offsetHeight; + const maxRight = Math.max(PAD, window.innerWidth - w - PAD); + const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); + offsetRef.current = { + x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), + y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), + }; + panel.style.right = offsetRef.current.x + 'px'; + panel.style.bottom = offsetRef.current.y + 'px'; + }, []); + + React.useEffect(() => { + if (!open) return; + clampToViewport(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + } + const ro = new ResizeObserver(clampToViewport); + ro.observe(document.documentElement); + return () => ro.disconnect(); + }, [open, clampToViewport]); + + React.useEffect(() => { + const onMsg = (e) => { + const t = e?.data?.type; + if (t === '__activate_edit_mode') setOpen(true); + else if (t === '__deactivate_edit_mode') setOpen(false); + }; + window.addEventListener('message', onMsg); + window.parent.postMessage({ type: '__edit_mode_available' }, '*'); + return () => window.removeEventListener('message', onMsg); + }, []); + + const dismiss = () => { + setOpen(false); + window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); + }; + + const onDragStart = (e) => { + const panel = dragRef.current; + if (!panel) return; + const r = panel.getBoundingClientRect(); + const sx = e.clientX, sy = e.clientY; + const startRight = window.innerWidth - r.right; + const startBottom = window.innerHeight - r.bottom; + const move = (ev) => { + offsetRef.current = { + x: startRight - (ev.clientX - sx), + y: startBottom - (ev.clientY - sy), + }; + clampToViewport(); + }; + const up = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', up); + }; + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', up); + }; + + if (!open) return null; + return ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +function TweakRadio({ label, value, options, onChange }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); + const idx = Math.max(0, opts.findIndex((o) => o.value === value)); + const n = opts.length; + + // The active value is read by pointer-move handlers attached for the lifetime + // of a drag — ref it so a stale closure doesn't fire onChange for every move. + const valueRef = React.useRef(value); + valueRef.current = value; + + const segAt = (clientX) => { + const r = trackRef.current.getBoundingClientRect(); + const inner = r.width - 4; + const i = Math.floor(((clientX - r.left - 2) / inner) * n); + return opts[Math.max(0, Math.min(n - 1, i))].value; + }; + + const onPointerDown = (e) => { + setDragging(true); + const v0 = segAt(e.clientX); + if (v0 !== valueRef.current) onChange(v0); + const move = (ev) => { + if (!trackRef.current) return; + const v = segAt(ev.clientX); + if (v !== valueRef.current) onChange(v); + }; + const up = () => { + setDragging(false); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + + return ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { + const clamp = (n) => { + if (min != null && n < min) return min; + if (max != null && n > max) return max; + return n; + }; + const startRef = React.useRef({ x: 0, val: 0 }); + const onScrubStart = (e) => { + e.preventDefault(); + startRef.current = { x: e.clientX, val: value }; + const decimals = (String(step).split('.')[1] || '').length; + const move = (ev) => { + const dx = ev.clientX - startRef.current.x; + const raw = startRef.current.val + dx * step; + const snapped = Math.round(raw / step) * step; + onChange(clamp(Number(snapped.toFixed(decimals)))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }; + return ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/assets/logos/company/icon.svg b/assets/logos/company/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/assets/logos/company/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logos/company/logo.svg b/assets/logos/company/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/assets/logos/company/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index a451a8c0..dbaf2432 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -41,6 +41,11 @@ if (args[0] === "p") args[0] = "policies"; // pulling in the hook-telemetry / telemetry-id modules on the fast --hook path. let _telemetry; let lastSubcommand = null; +// When `policy add|remove` is mid-execution we stash the action here so the +// top-level catch can emit `cli_policy_add_failure` / `cli_policy_remove_failure` +// with the right event name. Mirrors the cli_install_failure / cli_uninstall_failure +// pattern below for parity. Cleared back to null after the success track. +let lastPolicyAction = null; async function track(name, props) { try { if (!_telemetry) { @@ -103,7 +108,7 @@ if (hookIdx >= 0) { */ async function runCli() { // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "audit"]; + const SUBCOMMANDS = ["policies", "policy", "auth"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -118,6 +123,9 @@ USAGE COMMANDS (no args) Launch the policy dashboard + policy add Enable a single policy (see \`policy --help\`) + policy remove Disable a single policy + policies, p List all available policies and their status policies --install, -i Enable policies in agent CLI settings [names...] Specific policy names to enable @@ -140,24 +148,11 @@ COMMANDS policies --help, -h Show this help for the policies command - audit (beta) Scan past agent CLI transcripts and count - "stupid behaviors" (env-var checks, force - pushes, redundant cd , sleep loops, - etc.) per policy / audit detector. - Going live shortly — flags + output may - still change between beta releases. - --cli claude|codex|copilot|cursor|opencode|pi|gemini - Restrict to one or more CLIs (default: all). - --project Restrict to one cwd (repeatable). - --since 7d|2026-04-01 Only sessions whose mtime is within window. - --policy Restrict to one policy/detector (repeatable). - --limit N Top-N rows in the table (default 20). - --show-examples Include one example per row. - --report Markdown report path (default ./failproofai-audit.md). - --no-report Skip writing the markdown file. - --json Print JSON to stdout instead of the table. - --no-cache Bypass the per-transcript cache. - audit --help, -h Show this help for the audit command + auth Sign in / out of FailproofAI from the CLI. + login Email + OTP flow; writes ~/.failproofai/auth.json + logout Revoke this session and remove auth.json + whoami Print the currently authenticated identity + auth --help, -h Show this help for the auth command --version, -v Print version and exit --help, -h Show this help message @@ -470,155 +465,170 @@ EXAMPLES process.exit(0); } - // audit — scan past transcripts for "stupid behaviors" caught by builtin - // policies + a set of audit-only detectors. - if (args[0] === "audit") { + // auth — email-OTP login flow against the FailproofAI api-server. + if (args[0] === "auth") { + lastSubcommand = "auth"; + const { runAuthCli } = await import("../src/auth/cli"); + await runAuthCli(args.slice(1)); + await track("cli_auth_invoked", { args_count: args.length - 1 }); + process.exit(process.exitCode ?? 0); + } + + // policy — single-policy shortcut over `policies --install `. + // failproofai policy add enable one policy (defaults: claude/user) + // failproofai policy remove disable one policy + // Honors the same --cli / --scope / --beta flags as `policies --install`. + if (args[0] === "policy") { + lastSubcommand = "policy"; const subArgs = args.slice(1); - if (subArgs.includes("--help") || subArgs.includes("-h")) { + if (subArgs.length === 0 || subArgs.includes("--help") || subArgs.includes("-h")) { console.log(` -failproofai audit (beta) — scan past agent transcripts for stupid behaviors - - NOTE: This command is in beta. Flags, output format, and the audit-only - detector catalog may change before the next stable cut. Going live shortly. +failproofai policy — manage a single FailproofAI policy USAGE - failproofai audit [options] + failproofai policy add Enable one policy + failproofai policy remove Disable one policy OPTIONS --cli claude|codex|copilot|cursor|opencode|pi|gemini - Restrict to one or more CLIs (space-separated or repeated). - Default: scan all 7. - --project Restrict to sessions whose cwd matches this path. Repeatable. - --since 7d|30d|2026-04-01 Only sessions whose mtime is within window. - --policy Restrict to one policy/detector name. Repeatable. - --limit N Top-N rows in the table (default 20). - --show-examples Include one example command per row in the table. - --report Markdown report path (default: ./failproofai-audit.md). - --no-report Skip writing the markdown report. - --json Print JSON to stdout instead of the table. - --no-cache Bypass the per-transcript result cache. - --help, -h Show this help + Agent CLI(s) to apply to; space-separated or repeated. + Omit to detect installed CLIs and prompt. + --scope user|project|local Config scope (default: user) + --beta Allow beta policies EXAMPLES - failproofai audit - failproofai audit --cli claude --since 30d - failproofai audit --policy protect-env-vars --policy block-force-push - failproofai audit --json > audit.json - failproofai audit --project /home/me/myrepo --show-examples + failproofai policy add block-sudo + failproofai policy add sanitize-api-keys --scope project + failproofai policy add block-force-push --cli claude codex + failproofai policy remove block-sudo `.trimStart()); process.exit(0); } + const action = subArgs[0]; + if (action !== "add" && action !== "remove") { + throw new CliError( + `Unknown policy subcommand: ${action}\n` + + `Run \`failproofai policy --help\` for usage.`, + ); + } + + const rest = subArgs.slice(1); + + const scopeIdx = rest.indexOf("--scope"); + const scope = scopeIdx >= 0 ? rest[scopeIdx + 1] : "user"; + if (scopeIdx >= 0 && (!scope || scope.startsWith("-"))) { + throw new CliError("Missing value for --scope. Valid values: user, project, local"); + } + const validScopes = action === "remove" + ? ["user", "project", "local", "all"] + : ["user", "project", "local"]; + if (scopeIdx >= 0 && !validScopes.includes(scope)) { + throw new CliError(`Invalid scope: ${scope}. Valid values: ${validScopes.join(", ")}`); + } + + // --cli accepts one or more space-separated values, optionally repeated. const VALID_CLIS = new Set(["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"]); - /** Consume one or more values for a flag like `--cli a b c`, stopping at - * the next flag or an unknown token (when an allow-set is supplied). - * Throws when the flag appears with zero following values — a bare - * `--cli` would otherwise silently widen the scope to all CLIs. */ - function collectMulti(flag, allowSet) { - const out = []; - const consumed = new Set(); - for (let i = 0; i < subArgs.length; i++) { - if (subArgs[i] !== flag) continue; - let seenForThisFlag = 0; - for (let j = i + 1; j < subArgs.length; j++) { - const v = subArgs[j]; - if (v.startsWith("-")) break; - if (allowSet && !allowSet.has(v)) break; - out.push(v); - consumed.add(j); - seenForThisFlag++; - } - consumed.add(i); - if (seenForThisFlag === 0) { - throw new CliError(`Missing value(s) for ${flag}`); - } + const cliFlagValues = []; + const cliConsumedIdxs = new Set(); + const cliFlagIdxs = rest.map((a, i) => (a === "--cli" ? i : -1)).filter((i) => i >= 0); + for (const idx of cliFlagIdxs) { + let consumed = 0; + for (let j = idx + 1; j < rest.length; j++) { + const v = rest[j]; + if (v.startsWith("-")) break; + if (!VALID_CLIS.has(v)) break; + cliFlagValues.push(v); + cliConsumedIdxs.add(j); + consumed++; } - return { values: out, consumed }; - } - /** Take the single value following a one-shot flag like `--since 7d`. */ - function takeOne(flag) { - const i = subArgs.indexOf(flag); - if (i < 0) return { value: undefined, consumed: new Set() }; - const v = subArgs[i + 1]; - if (v === undefined || v.startsWith("-")) { - throw new CliError(`Missing value for ${flag}`); + if (consumed === 0) { + throw new CliError("Missing value(s) for --cli. Usage: --cli claude codex copilot cursor opencode pi gemini (or any subset)"); } - return { value: v, consumed: new Set([i, i + 1]) }; } - const allConsumed = new Set(); - const cliRes = collectMulti("--cli", VALID_CLIS); - cliRes.consumed.forEach((i) => allConsumed.add(i)); - const projectRes = collectMulti("--project", null); - projectRes.consumed.forEach((i) => allConsumed.add(i)); - const policyRes = collectMulti("--policy", null); - policyRes.consumed.forEach((i) => allConsumed.add(i)); - const sinceRes = takeOne("--since"); - sinceRes.consumed.forEach((i) => allConsumed.add(i)); - const limitRes = takeOne("--limit"); - limitRes.consumed.forEach((i) => allConsumed.add(i)); - const reportRes = takeOne("--report"); - reportRes.consumed.forEach((i) => allConsumed.add(i)); - - const showExamples = subArgs.includes("--show-examples"); - if (showExamples) allConsumed.add(subArgs.indexOf("--show-examples")); - const noReport = subArgs.includes("--no-report"); - if (noReport) allConsumed.add(subArgs.indexOf("--no-report")); - const jsonOut = subArgs.includes("--json"); - if (jsonOut) allConsumed.add(subArgs.indexOf("--json")); - const noCache = subArgs.includes("--no-cache"); - if (noCache) allConsumed.add(subArgs.indexOf("--no-cache")); - - // Reject unknown flags / positional args. - for (let i = 0; i < subArgs.length; i++) { - if (allConsumed.has(i)) continue; - const arg = subArgs[i]; - throw new CliError( - `Unexpected argument: ${arg}\nRun \`failproofai audit --help\` for usage.`, - ); + const includeBeta = rest.includes("--beta"); + + // Reject unknown flags. + const knownFlags = new Set(["--scope", "--cli", "--beta"]); + const unknownFlag = rest.find((a) => a.startsWith("-") && !knownFlags.has(a)); + if (unknownFlag) { + throw new CliError(`Unknown flag: ${unknownFlag}\nRun \`failproofai policy --help\` for usage.`); } - let parsedLimit; - if (limitRes.value !== undefined) { - parsedLimit = parseInt(limitRes.value, 10); - if (!Number.isInteger(parsedLimit) || parsedLimit <= 0) { - throw new CliError(`Invalid value for --limit: "${limitRes.value}". Use a positive integer.`); - } + // Positional policy names = anything not consumed by --scope / --cli. + const consumedIdxs = new Set(); + if (scopeIdx >= 0) consumedIdxs.add(scopeIdx + 1); + for (const i of cliConsumedIdxs) consumedIdxs.add(i); + const positional = rest.filter( + (a, idx) => !a.startsWith("-") && !consumedIdxs.has(idx), + ); + + if (positional.length === 0) { + throw new CliError( + `Missing policy name.\n` + + `Usage: failproofai policy ${action} \n` + + `Run \`failproofai policies\` to see available names.`, + ); } - const opts = { - clis: cliRes.values.length > 0 ? cliRes.values : undefined, - projects: projectRes.values.length > 0 ? projectRes.values : undefined, - policies: policyRes.values.length > 0 ? policyRes.values : undefined, - since: sinceRes.value, - limit: parsedLimit, - showExamples, - reportPath: reportRes.value ?? "./failproofai-audit.md", - noReport: noReport || jsonOut, // --json implies --no-report unless explicit --report - json: jsonOut, - noCache, - }; - // Re-enable report when --report was passed alongside --json. - if (jsonOut && reportRes.value) opts.noReport = false; - - const { runAudit } = await import("../src/audit"); - const { formatText, formatJson, formatMarkdown } = await import("../src/audit/report"); - const result = await runAudit(opts); - - if (jsonOut) { - process.stdout.write(formatJson(result) + "\n"); - } else { - process.stdout.write(formatText(result, opts)); + if (positional.length > 1) { + throw new CliError( + `\`policy ${action}\` takes exactly one policy name (got ${positional.length}).\n` + + `For multiple policies use \`failproofai policies --${action === "add" ? "install" : "uninstall"} ${positional.join(" ")}\`.`, + ); } + const policyName = positional[0]; - if (!opts.noReport) { - const { writeFileSync } = await import("node:fs"); - const { resolve } = await import("node:path"); - const reportPath = resolve(opts.reportPath); - writeFileSync(reportPath, formatMarkdown(result), "utf-8"); - if (!jsonOut) process.stdout.write(`\nReport written: ${reportPath}\n`); - } + const { resolveTargetClis } = await import("../src/hooks/install-prompt"); + const cli = await resolveTargetClis( + cliFlagValues.length > 0 ? cliFlagValues : undefined, + action === "add" ? "install" : "uninstall", + ); + lastPolicyAction = action; + if (action === "add") { + const { installHooks } = await import("../src/hooks/manager"); + await installHooks( + [policyName], + scope, + undefined, + includeBeta, + undefined, + undefined, + false, + cli, + ); + await track("cli_policy_add_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + include_beta: includeBeta, + }); + } else { + // `policy remove ` always removes the named policy regardless + // of whether it's beta or not — passing `betaOnly: includeBeta` + // here was a mislabel that only affected the telemetry field, not + // the actual remove. Drop the `--beta` semantic for remove and + // emit beta_only: false unconditionally so dashboards don't see + // ghost "beta removal" events. + const { removeHooks } = await import("../src/hooks/manager"); + await removeHooks( + [policyName], + scope, + undefined, + { betaOnly: false, removeCustomHooks: false, cli }, + ); + await track("cli_policy_remove_success", { + scope, + cli, + cli_count: cli.length, + policy_name: policyName, + beta_only: false, + }); + } + lastPolicyAction = null; process.exit(0); } @@ -640,7 +650,7 @@ EXAMPLES return dp[m][n]; } - const primary = ["--version", "--help", "--hook", "policies", "audit"]; + const primary = ["--version", "--help", "--hook", "policies", "policy", "auth"]; const closest = primary.reduce((best, flag) => { const dist = levenshtein(unknownFlag, flag); return dist < best.dist ? { flag, dist } : best; @@ -691,6 +701,13 @@ try { await track("cli_install_failure", { error_type: "cli_error", exit_code: err.exitCode }); } else if (lastSubcommand === "uninstall") { await track("cli_uninstall_failure", { error_type: "cli_error", exit_code: err.exitCode }); + } else if (lastSubcommand === "policy" && lastPolicyAction) { + // Mid-action failure: `policy add|remove` parsed but installHooks / + // removeHooks threw a CliError (e.g. unknown policy name, invalid scope). + await track(`cli_policy_${lastPolicyAction}_failure`, { + error_type: "cli_error", + exit_code: err.exitCode, + }); } else { await track("cli_parse_error", { subcommand: lastSubcommand ?? (args[0] ?? null), @@ -706,6 +723,10 @@ try { await track("cli_install_failure", { error_type: err instanceof Error ? err.name : "unknown" }); } else if (lastSubcommand === "uninstall") { await track("cli_uninstall_failure", { error_type: err instanceof Error ? err.name : "unknown" }); + } else if (lastSubcommand === "policy" && lastPolicyAction) { + await track(`cli_policy_${lastPolicyAction}_failure`, { + error_type: err instanceof Error ? err.name : "unknown", + }); } else { await track("cli_unexpected_error", { subcommand: lastSubcommand ?? (args[0] ?? null), diff --git a/bun.lock b/bun.lock index f57b51cc..979b66e6 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "failproofai", "dependencies": { + "html2canvas": "^1.4.1", "posthog-node": "^5.28.11", }, "devDependencies": { @@ -434,6 +435,8 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -462,6 +465,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -632,6 +637,8 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -932,6 +939,8 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="], @@ -974,6 +983,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], diff --git a/components/navbar.tsx b/components/navbar.tsx index 6b957e0b..1e693d0e 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -1,87 +1,146 @@ -/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. */ +/** Top navigation bar — wordmark, primary nav, refresh + reach-developers controls. + * + * Restyled to the audit / brutalist-pixel-craft system: the wordmark uses the + * same pixel pink mark + Architype Stedelijk lowercase name as the audit + * report, and each nav link is a `.tab` with a sharp pink underline on the + * active route. No lucide icons in the bar itself — the chrome stays text- + * forward to match the rest of the design system. + */ "use client"; import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { FolderOpen, Shield } from "lucide-react"; import { ReachDevelopers } from "@/components/reach-developers"; import { RefreshButton } from "@/app/components/refresh-button"; +import { usePostHog } from "@/contexts/PostHogContext"; const NAV_LINKS = [ - { href: "/policies", label: "Policies", icon: Shield }, - { href: "/projects", label: "Projects", icon: FolderOpen }, + { href: "/policies", label: "policies" }, + { href: "/audit", label: "audit" }, + { href: "/projects", label: "projects" }, ]; -const WORDMARK_SRC = "https://d2wq11aau0arks.cloudfront.net/failproof/logo-wordmark.png"; - -export const Navbar: React.FC<{ disabledPages?: string[] }> = ({ disabledPages = [] }) => { +export const Navbar: React.FC<{ + disabledPages?: string[]; + /** Total slipping-through actions from the latest cached audit. When > 0 + * a small chip is rendered next to the Audit nav link. Undefined → no + * chip (no cache yet, or audit disabled). */ + auditSlippingCount?: number; +}> = ({ disabledPages = [], auditSlippingCount }) => { const pathname = usePathname(); + const { capture } = usePostHog(); + + const sectionLabel = (() => { + if (pathname.startsWith("/policies")) return "policies"; + if (pathname.startsWith("/audit")) return "audit"; + if (pathname.startsWith("/projects") || pathname.startsWith("/project/")) return "projects"; + return ""; + })(); return ( -
-
-
-
- + {/* Brand — logo mark + name only (no version/section here) */} + + + failproof_ai + + + {/* Nav links — swapped to sit right after the brand */} + -
+ {/* Spacer pushes version/section + actions to the right */} +
- -
-
- -
- -
+ {/* Version + section label — swapped to right of nav */} + {(process.env.NEXT_PUBLIC_APP_VERSION || sectionLabel) && ( +
+ {process.env.NEXT_PUBLIC_APP_VERSION && ( + + v{process.env.NEXT_PUBLIC_APP_VERSION} + + )} + {sectionLabel && process.env.NEXT_PUBLIC_APP_VERSION && ( + + )} + {sectionLabel && {sectionLabel}}
+ )} + +
+ + +
); diff --git a/components/reach-developers.tsx b/components/reach-developers.tsx index 34dc7b43..2c7f9092 100644 --- a/components/reach-developers.tsx +++ b/components/reach-developers.tsx @@ -13,31 +13,43 @@ const options = [ label: "Star us on GitHub", icon: Star, href: "https://github.com/failproofai/failproofai", + color: "#f5c842", + bg: "rgba(245,200,66,0.08)", }, { label: "Documentation", icon: BookOpen, href: "https://befailproof.ai", + color: "#60a5fa", + bg: "rgba(96,165,250,0.08)", }, { label: "Join our Slack", icon: Hash, href: "https://join.slack.com/t/failproofai/shared_invite/zt-3v63b7k5e-O3NBHmj8X6n9gZSGDx6ggQ", + color: "#a78bfa", + bg: "rgba(167,139,250,0.08)", }, { label: "Request a Feature", icon: Lightbulb, href: `${GITHUB_REPO}/issues/new?labels=enhancement&title=Feature+Request%3A+`, + color: "#34d399", + bg: "rgba(52,211,153,0.08)", }, { label: "Report an Issue", icon: Bug, href: `${GITHUB_REPO}/issues/new?labels=bug&title=Bug+Report%3A+`, + color: "#f87171", + bg: "rgba(248,113,113,0.08)", }, { label: "Ask a Question", icon: MessageSquare, href: `${GITHUB_REPO}/discussions/new?category=q-a`, + color: "#fb923c", + bg: "rgba(251,146,60,0.08)", }, ] as const; @@ -76,35 +88,51 @@ export const ReachDevelopers: React.FC = () => { {open && ( -
-
-

Reach Developers

+
+
+

Reach Developers

We'd love to hear from you

-
+

or email{" "} ((e.currentTarget as HTMLAnchorElement).style.opacity = "0.75")} + onMouseLeave={(e) => ((e.currentTarget as HTMLAnchorElement).style.opacity = "1")} > {CONTACT_EMAIL} diff --git a/docs/cli/audit.mdx b/docs/cli/audit.mdx index b4f0dbf8..677b4e97 100644 --- a/docs/cli/audit.mdx +++ b/docs/cli/audit.mdx @@ -4,38 +4,35 @@ description: "Count how often the agent did wasteful or risky things across past --- - **Beta feature.** `failproofai audit` is shipping as beta while we collect early - feedback. CLI flags, output format, and the audit-only detector catalog may - change before the next stable cut. Going live shortly — please open an issue - if anything looks off. + **Beta feature.** The audit ships as beta while we collect early feedback. + The detector catalog and report format may change before the next stable + cut. Please open an issue if anything looks off. +The audit is now exposed as the **/audit dashboard page**, not a CLI subcommand. Open it from the dashboard navbar (between Policies and Projects), or visit `http://localhost:8020/audit` directly when running `failproofai` locally. + ```bash -failproofai audit [options] +failproofai # open the dashboard, then click "Audit" ``` -Scans past agent CLI transcripts on this machine (Claude Code, Codex, Copilot, Cursor, OpenCode, Pi, Gemini) and reports how often the agent did things failproofai is built to stop — env-var checks, force pushes, redundant `cd ` prefixes, sleep-polling loops, re-reading files just edited, and more. +The dashboard scans past agent CLI transcripts on this machine (Claude Code, Codex, Copilot, Cursor, OpenCode, Pi, Gemini) and reports how often the agent did things failproofai is built to stop — env-var checks, force pushes, redundant `cd ` prefixes, sleep-polling loops, re-reading files just edited, and more. For each transcript, every tool-use event is replayed through the 39 builtin policies **and** through 8 audit-only detectors that catch patterns not yet covered by runtime policies. Counts are aggregated per policy / detector across all sessions. -## Options +## What you get + +The `/audit` page composes six sections: -| Flag | Description | -|------|-------------| -| `--cli claude\|codex\|copilot\|cursor\|opencode\|pi\|gemini` | Restrict to one or more CLIs (space-separated or repeated). Default: all 7. | -| `--project ` | Restrict to sessions whose `cwd` matches this path. Repeatable. | -| `--since 7d\|30d\|2026-04-01` | Only sessions whose transcript mtime is within the window. | -| `--policy ` | Restrict to one policy or detector name. Repeatable. | -| `--limit N` | Top-N rows in the table (default 20). | -| `--show-examples` | Include one example command per row in the table. | -| `--report ` | Markdown report path (default `./failproofai-audit.md`). | -| `--no-report` | Skip writing the markdown report. | -| `--json` | Print JSON to stdout instead of the table. Implies `--no-report` unless `--report` is also passed. | -| `--no-cache` | Bypass the per-transcript result cache at `~/.failproofai/cache/audit/`. | +1. **Identity** — your agent classified into one of 8 archetypes (`optimist`, `cowboy`, `explorer`, `goldfish`, `paranoid architect`, `precision builder`, `hammer`, `ghost`) based on the weighted signal across every audited transcript. +2. **Strengths** — real numbers derived from the scan (clean-call %, "0 credential leaks", etc.) gated on the relevant sanitize policies actually firing. +3. **Score** — 0-100 with S/A/B/C/D/F bands and a projected uplift if every recommended policy were enabled. +4. **Findings** — per-policy cards with what happened, cost, captured evidence, and the exact `failproofai policy add ` to enable the live-time builtin that would have caught it. +5. **Prescribed policies** — aggregated install list with a one-shot `failproofai policies --install` command. +6. **Re-audit reminder** — "come back better." Set a 7-day email reminder via the api-server (requires sign-in; see [`failproofai auth`](/cli/auth)). ## Audit-only detectors -These detect "stupid behavior" patterns not (yet) enforced in real time. They run only during `audit` and never block a live tool call. +These detect "stupid behavior" patterns not (yet) enforced in real time. They run only during the audit and never block a live tool call. | Detector | What it counts | |---|---| @@ -48,56 +45,13 @@ These detect "stupid behavior" patterns not (yet) enforced in real time. They ru | `git-commit-no-verify` | `git commit … --no-verify` / `-n`, skipping hooks. | | `reread-after-edit` | `Read` of a file that was just `Edit`/`Write` in the same session. | -## Output - -Default output is an ANSI table to stdout plus a sectioned markdown report at `./failproofai-audit.md`. Both group hits by category (Sanitize / Wasteful / Risky / …) with hit counts, project counts, and up to three example commands per row. - -`--json` emits machine-readable output: - -```json -{ - "version": 1, - "scannedAt": "2026-05-21T...", - "scope": { "cli": [...], "projects": "all", "since": null }, - "transcripts": { "scanned": 4187, "skipped": 0, "errors": 0, "durationMs": 12345 }, - "results": [ - { - "name": "failproofai/protect-env-vars", - "source": "builtin", - "category": "Environment", - "hits": 428, - "projects": 142, - "firstSeen": "...", - "lastSeen": "...", - "examples": [{ "sessionId": "...", "cwd": "...", "timestamp": "...", "example": "env" }] - } - ], - "totals": { "hits": 1398, "projectsWithHits": 392 } -} -``` - -## Examples +## Caches -```bash -# Scan everything across every CLI on this machine -failproofai audit - -# Just the last 30 days, Claude only -failproofai audit --cli claude --since 30d - -# Show examples for the top hits -failproofai audit --show-examples --limit 10 - -# Drill into one policy -failproofai audit --policy protect-env-vars --policy block-force-push - -# Machine-readable output -failproofai audit --json > audit.json -``` +- **Per-transcript cache** at `~/.failproofai/cache/audit/.json` keyed by `(mtime, size, engineVersion, detectorVersion)`. Invalidates automatically when policy or detector code changes. +- **Whole-result cache** at `~/.failproofai/audit-dashboard.json` (mode 0600). Lets the dashboard render instantly on navigation without re-running. Click `[ re-audit now ]` from the dashboard to refresh. ## Notes -- **Cache.** Per-transcript results are cached at `~/.failproofai/cache/audit/.json` keyed by `(mtime, size, engineVersion, detectorVersion)`. The cache invalidates automatically when policy or detector code changes. - **No mutation.** The audit replays in read-only mode. `warn-repeated-tool-calls` is skipped because its per-session sidecar would otherwise be modified. - **Workflow policies skipped.** `require-*-before-stop` policies fire only on `Stop` events and `execSync` against the live git state — they have no meaningful "what would have happened in 2025" interpretation, so they don't appear in audit counts. - **Custom policies skipped.** User-supplied custom hooks are not replayed (they may have changed since the original session). diff --git a/docs/cli/auth.mdx b/docs/cli/auth.mdx new file mode 100644 index 00000000..c08fbafc --- /dev/null +++ b/docs/cli/auth.mdx @@ -0,0 +1,87 @@ +--- +title: Sign in +description: "Sign in to FailproofAI from the CLI to enable reminders and personalized features" +--- + +```bash +failproofai auth login # email + one-time code +failproofai auth logout # revoke this session +failproofai auth whoami # print the signed-in identity +``` + +The legacy `--login` / `--logout` / `--whoami` flag form is still accepted as an alias for back-compat. + +Authentication is opt-in. Policies, the dashboard, the `/audit` page, and every other local feature work exactly the same whether you're signed in or not. The login surface exists so that features that **need** a stable identity (re-audit reminders today, more in the future) have somewhere to anchor. + +## Sign-in flow + +```bash +failproofai auth login +``` + +Prompts for your email, sends a 6-digit one-time code to that address, prompts for the code, and on success writes `~/.failproofai/auth.json` (mode `0600`). The same session is then visible to the in-app dashboard — clicking `[ set a reminder ]` on `/audit` will see you as signed in. + +The dashboard exposes the same flow as a modal dialog on `/audit` for users who never touch the CLI. + +## Sign-out + +```bash +failproofai auth logout +``` + +Revokes the current session on the server and deletes `~/.failproofai/auth.json`. If the api-server is unreachable, the local file is removed regardless — local intent to log out always wins. + +## Identity check + +```bash +failproofai auth whoami +``` + +Prints ` ()` and exits 0 when a valid session exists, or `not signed in` and exits 1 otherwise. Silently refreshes the access token in the background if it's within a minute of expiring. + +## Persistent re-audit reminder + +When you click **`[ set a reminder ]`** on the `/audit` page (or sign in via the modal that the button gates on), the dashboard writes a small companion file at `~/.failproofai/next-audit.json`: + +```json +{ + "next_audit_at": 1780765200, + "user_email": "you@example.com", + "set_at": 1780160574 +} +``` + +This file is scoped to the email it was set for — switching the CLI session to a different account hides any reminder that belongs to the previous user. Default offset is **7 days**, configurable later when the scheduler lands. Created with `0600` perms like `auth.json`. + +The dashboard's `/api/auth/reminder` endpoint exposes `GET` (read), `POST` (set / reschedule), and `DELETE` (clear) and requires an active session. + +## What's in `~/.failproofai/auth.json` + +```json +{ + "access_token": "eyJhbGc…", + "refresh_token": "9ede3e…", + "access_expires_at": 1780160574, + "refresh_expires_at": 1782748974, + "user": { "id": "", "email": "you@example.com" } +} +``` + +Created with `0600` perms (owner-only read/write). The access token is a 1-hour HS256 JWT; the refresh token is an opaque 256-bit random string that the server stores as `SHA-256(token)`. Refresh-token replay is detected server-side and revokes every session for the user. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `FAILPROOF_API_URL` | `https://api.befailproof.ai` | Override the api-server base URL. Useful for local development against a self-hosted api-server. | +| `FAILPROOFAI_AUTH_DIR` | `~/.failproofai` | Override where `auth.json` is stored. Mostly for tests. | + +See [Environment variables](/cli/environment-variables) for the full list. + +## Troubleshooting + +**"Could not reach the api-server"** — the CLI can't open a TCP connection to `FAILPROOF_API_URL`. Check your network, or set `FAILPROOF_API_URL` if you're running a self-hosted api-server. + +**"Rate limited"** — too many login attempts in a 15-minute window for that email (5/email) or IP (20/IP), or a 30-second resend cooldown after the previous request for the same email. The error message includes the retry-after window in seconds. + +**Code rejected** — the OTP was wrong, expired, or the row hit its 5-wrong-guess lockout. Run `failproofai auth login` again to request a fresh code. diff --git a/docs/cli/environment-variables.mdx b/docs/cli/environment-variables.mdx index e43e5012..a020df3f 100644 --- a/docs/cli/environment-variables.mdx +++ b/docs/cli/environment-variables.mdx @@ -25,6 +25,13 @@ description: "Configure failproofai behavior with environment variables" |----------|-------------| | `FAILPROOFAI_TELEMETRY_DISABLED=1` | Disable anonymous usage telemetry | +## Authentication + +| Variable | Description | +|----------|-------------| +| `FAILPROOF_API_URL` | Override the api-server base URL used by `failproofai auth` and the dashboard auth dialog. Defaults to `https://api.befailproof.ai`; set to `http://localhost:8080` (or wherever) when running a local api-server. | +| `FAILPROOFAI_AUTH_DIR` | Override where `auth.json` is stored (default: `~/.failproofai`). Mostly useful for isolated tests. | + ## First-run prompt | Variable | Description | diff --git a/docs/dashboard.mdx b/docs/dashboard.mdx index 0d461fbc..c4cc2fc6 100644 --- a/docs/dashboard.mdx +++ b/docs/dashboard.mdx @@ -57,6 +57,19 @@ The stats bar at the top shows session duration, total tool calls, and a summary Click the **Download Logs** button to export the session. For Claude Code, Codex, Copilot, Cursor, Pi, and Gemini sessions you get the original on-disk JSONL transcript byte-for-byte; for OpenCode (whose sessions live in SQLite, not on disk) you get a JSON document mirroring the underlying `session` / `messages` / `parts` tables. +### Audit + +A personality-driven report of how your agent has actually been behaving across past sessions. Runs the same scan as the `failproofai audit` CLI but renders it as a six-section dashboard: + +1. **Identity** — classifies your agent into one of 8 archetypes (`the optimist`, `the cowboy`, `the explorer`, `the goldfish`, `the paranoid architect`, `the precision builder`, `the hammer`, `the ghost`) based on which detectors + policies fired and how heavily. Renders an 8×8 pixel sigil, the archetype tagline, "common in" / "primary risk" framing, and the closing one-liner. +2. **Show off your agent** — captures the identity card as a 1200×630 PNG suitable for posting to X / LinkedIn (click `make poster`). +3. **Strengths** — green-checked behaviors your agent already does right, derived from the live audit data (clean tool-call rate, average session length, zero credential leaks, zero retry storms, etc.). +4. **Score + leaderboard** — 0–100 score with letter grade (S/A/B/C/D/F), a distribution histogram showing where you fall in the cohort, prose ("a B starts at 71. you're 13 points away."), and a leaderboard table with your row highlighted. +5. **Findings** — per-finding cards ranked by impact. Each card surfaces what happened, what it costs, an evidence sample with real captured commands, and the failproofai policy that would catch the same pattern (`$ failproof policy add `, click-to-copy). +6. **Prescribed policies + return loop** — a grid of every unenabled builtin policy that would close a gap, with a projected-score callout, plus a "re-audit in 7 days" CTA. + +Driven by the `failproofai audit` runtime — see [Audit CLI](/cli/audit) for the underlying scan engine, supported flags, and per-transcript cache invariants. The dashboard caches the latest result at `~/.failproofai/audit-dashboard.json` (mode `0600`, single slot, new runs overwrite) so revisits are instant; clicking `[ Re-run ↻ ]` POSTs `/api/audit/run` and the dashboard polls `/api/audit/status` at 1Hz until the run finishes. Empty state (no cache) and zero-sessions state (cache exists but the scan found no transcripts) are surfaced separately. + ### Policies A two-tab page for managing policies and reviewing activity. @@ -92,7 +105,7 @@ If you only need some parts of the dashboard, set `FAILPROOFAI_DISABLE_PAGES` to FAILPROOFAI_DISABLE_PAGES=policies failproofai ``` -Valid values: `policies`, `projects`. +Valid values: `policies`, `projects`, `audit`. --- diff --git a/docs/docs.json b/docs/docs.json index 5cdae940..19576e4d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -43,6 +43,8 @@ "cli/remove-policies", "cli/list-policies", "cli/hook", + "cli/auth", + "cli/audit", "cli/version", "cli/environment-variables" ] diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b2230cd..29cfbc52 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,9 @@ import nextConfig from "eslint-config-next/core-web-vitals"; import tsParser from "@typescript-eslint/parser"; const config = [ - { ignores: ["dist/"] }, + // Skip generated bundles and design-asset reference files. assets/audit + // is the brand team's reference HTML/JSX kit, not source code we ship. + { ignores: ["dist/", "assets/"] }, ...nextConfig, { settings: { react: { version: "19" } } }, { diff --git a/lib/atomic-write.ts b/lib/atomic-write.ts new file mode 100644 index 00000000..16f2a0dd --- /dev/null +++ b/lib/atomic-write.ts @@ -0,0 +1,67 @@ +/** + * Shared atomic-write helper for JSON files we want crash-safe. + * + * Replaces two near-identical implementations in `lib/auth/auth-store.ts` + * and `src/audit/dashboard-cache.ts`. Promoted to a shared module so the + * "temp file → chmod → rename → reassert perms" dance is in one place; + * the alternative is more drift like the prior PR where dashboard-cache + * shipped the non-atomic plain `writeFileSync` path. + * + * Contract: + * - Concurrent writers can race on the rename, but neither observer + * sees a half-written file. + * - `mode` (default 0o600) is enforced on both the temp and final paths. + * - Parent directory is created with the same `mode` masked to 0o700 + * if it doesn't exist. + * - Throws on hard failure; caller decides whether to swallow or surface. + */ +import { + chmodSync, + existsSync, + mkdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { randomBytes } from "node:crypto"; + +export interface WriteJsonOptions { + /** Permission mode for the final file (default 0o600). */ + mode?: number; + /** Permission mode used when creating the parent dir (default 0o700). */ + dirMode?: number; +} + +export function writeJsonAtomically( + filePath: string, + value: unknown, + opts: WriteJsonOptions = {}, +): void { + const mode = opts.mode ?? 0o600; + const dirMode = opts.dirMode ?? 0o700; + const dir = dirname(filePath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: dirMode }); + const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(value, null, 2), { mode }); + try { + if ((statSync(tmp).mode & 0o077) !== 0) chmodSync(tmp, mode); + } catch { + // best-effort + } + renameSync(tmp, filePath); + // Re-assert perms on the final path — rename preserves the temp's + // mode, but a pre-existing file's inode could have been observed in + // the gap. + try { + if ((statSync(filePath).mode & 0o077) !== 0) chmodSync(filePath, mode); + } catch { + // best-effort + } + } catch (err) { + try { rmSync(tmp, { force: true }); } catch { /* ignore */ } + throw err; + } +} diff --git a/lib/auth/api-server-client.ts b/lib/auth/api-server-client.ts new file mode 100644 index 00000000..ec3ee286 --- /dev/null +++ b/lib/auth/api-server-client.ts @@ -0,0 +1,281 @@ +/** + * Low-level HTTP client for the FailproofAI api-server's /v0/auth/* endpoints. + * + * Shared by both the CLI (failproofai auth ...) and the dashboard's Next.js + * API route proxies. Has no filesystem access — token persistence lives in + * `./auth-store.ts`. + * + * The base URL is resolved from FAILPROOF_API_URL (preferred) or the legacy + * FAILPROOFAI_API_URL, falling back to the hosted api-server. Local-dev + * contributors should set FAILPROOF_API_URL=http://localhost:8080 (or + * whatever port their local api-server uses) in `.env.local`. + */ + +import { trackEvent } from "../telemetry"; +import { isAbortError } from "../fetch-with-timeout"; + +export const DEFAULT_API_BASE = "https://api.befailproof.ai"; + +export function getApiBase(): string { + const raw = + process.env.FAILPROOF_API_URL ?? + process.env.FAILPROOFAI_API_URL ?? + DEFAULT_API_BASE; + return raw.replace(/\/+$/, ""); +} + +export class AuthApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryAfterSecs?: number; + constructor(status: number, code: string, message: string, retryAfterSecs?: number) { + super(message); + this.status = status; + this.code = code; + this.retryAfterSecs = retryAfterSecs; + this.name = "AuthApiError"; + } +} + +export interface LoginRequestResponse { + status: "code_sent"; + expires_in: number; + resend_available_in: number; +} + +export interface UserView { + id: string; + email: string; +} + +export interface TokenResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; + user: UserView; +} + +export interface RefreshResponse { + token_type: "Bearer"; + access_token: string; + access_expires_in: number; + refresh_token: string; + refresh_expires_in: number; +} + +export interface MeResponse { + id: string; + email: string; + status: string; + created_at: string; +} + +interface ServerErrorBody { + // The docs describe `{ code, message }`; the live Rust server returns + // `{ success: false, code, detail }`. We tolerate either. + code?: string; + message?: string; + detail?: string; + retry_after_secs?: number; +} + +async function parseError(res: Response): Promise { + let body: ServerErrorBody = {}; + try { + body = (await res.json()) as ServerErrorBody; + } catch { + // body might be empty or non-JSON + } + const code = body.code ?? `http_${res.status}`; + const message = body.message ?? body.detail ?? res.statusText ?? "request failed"; + let retryAfterSecs = body.retry_after_secs; + if (retryAfterSecs === undefined) { + const h = res.headers.get("retry-after"); + if (h) { + const n = Number(h); + if (Number.isFinite(n)) retryAfterSecs = n; + } + } + // Clamp to a sane range: a misbehaving (or hostile) api-server could + // return `-3600` or `1e20` and our UI would render "wait 1e20s" or + // backoff loops would fire immediately on negatives. 24h is the longest + // wait the dashboard/CLI is willing to surface as a literal duration. + if (retryAfterSecs !== undefined) { + retryAfterSecs = Math.max(0, Math.min(86400, retryAfterSecs)); + } + return new AuthApiError(res.status, code, message, retryAfterSecs); +} + +/** Hard cap on every auth/reminder HTTP call. Without this, a wedged DNS + * resolver or a hung server keeps the CLI / dashboard route stuck forever. */ +const REQUEST_TIMEOUT_MS = 10_000; + +function timeoutSignal(extra?: AbortSignal): AbortSignal { + const t = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + if (!extra) return t; + // Compose so an externally-cancelled caller still aborts. Prefer the + // native `AbortSignal.any` (Node 20.3+, Bun 1.0.27+); fall back to a + // hand-rolled controller for older runtimes — without this fallback + // an `extra` signal would be silently dropped. + const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; + if (anyFn) return anyFn([t, extra]); + const composed = new AbortController(); + const onAbort = (s: AbortSignal) => composed.abort(s.reason); + if (t.aborted) onAbort(t); + else t.addEventListener("abort", () => onAbort(t), { once: true }); + if (extra.aborted) onAbort(extra); + else extra.addEventListener("abort", () => onAbort(extra), { once: true }); + return composed.signal; +} + +function pathFromUrl(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} + +async function fetchWithTimeout(url: string, init: RequestInit): Promise { + try { + return await fetch(url, { ...init, signal: timeoutSignal(init.signal ?? undefined) }); + } catch (err) { + const isTimeout = isAbortError(err); + // Low-cardinality "api-server is down" counter. We only attach the + // request path (not the full URL) and a coarse kind so it stays a + // cheap signal in PostHog. No-ops on the CLI side when telemetry + // has not been initialised. + trackEvent("api_server_unreachable", { + kind: isTimeout ? "timeout" : "network", + path: pathFromUrl(url), + method: typeof init.method === "string" ? init.method : "GET", + }); + if (isTimeout) { + throw new AuthApiError( + 0, + "timeout", + `request to ${url} timed out after ${REQUEST_TIMEOUT_MS}ms`, + ); + } + throw err; + } +} + +async function postJson(path: string, body: unknown, init?: { accessToken?: string }): Promise { + const headers: Record = { "content-type": "application/json" }; + if (init?.accessToken) headers["authorization"] = `Bearer ${init.accessToken}`; + const res = await fetchWithTimeout(`${getApiBase()}${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + if (res.status === 204) return undefined as T; + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +async function getJson(path: string, accessToken: string): Promise { + const res = await fetchWithTimeout(`${getApiBase()}${path}`, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as T; +} + +export async function requestLoginCode(email: string): Promise { + return postJson("/v0/auth/login/request", { email }); +} + +export async function verifyLoginCode(email: string, code: string): Promise { + return postJson("/v0/auth/login/verify", { email, code }); +} + +export async function refreshAccessToken(refreshToken: string): Promise { + return postJson("/v0/auth/token/refresh", { + refresh_token: refreshToken, + }); +} + +export async function logoutSession(accessToken: string, refreshToken: string): Promise { + await postJson( + "/v0/auth/logout", + { refresh_token: refreshToken }, + { accessToken }, + ); +} + +export async function fetchMe(accessToken: string): Promise { + return getJson("/v0/auth/me", accessToken); +} + +export interface ServerReminder { + user_id: string; + email: string; + fire_at: number; // unix seconds + set_at: number; // unix seconds +} + +export async function scheduleReminder( + accessToken: string, + body: { in_days?: number; at?: number }, +): Promise { + const res = await postJson<{ reminder: ServerReminder }>( + "/v0/reminders", + body, + { accessToken }, + ); + return res.reminder; +} + +export async function cancelReminder(accessToken: string): Promise { + const res = await fetchWithTimeout(`${getApiBase()}/v0/reminders`, { + method: "DELETE", + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 204 || res.ok) return; + throw await parseError(res); +} + +interface JwtClaims { + sub: string; + email: string; + iss?: string; + aud?: string; + iat?: number; + exp: number; + token_type?: string; +} + +/** + * Decode the JWT payload without verifying the signature. Safe for client-side + * reading (sub, email, exp). Returns null if the token is malformed. + * + * Strictly validates base64url before decoding: `Buffer.from(s, 'base64url')` + * silently truncates on illegal characters (`+`, `/`, whitespace, embedded + * NULs) rather than throwing, and the truncated bytes can happen to parse as + * JSON with a numeric `exp` field, producing synthetic "valid" claims from a + * corrupted token. + */ +const BASE64URL_RE = /^[A-Za-z0-9_-]+={0,2}$/; + +export function decodeJwt(token: string): JwtClaims | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + // Header and payload must both be syntactically valid base64url. Empty + // segments are also rejected (Buffer.from("","base64url") would return + // an empty buffer that JSON.parse correctly throws on, but rejecting + // upfront is cheaper and clearer). + if (!BASE64URL_RE.test(parts[0])) return null; + if (!BASE64URL_RE.test(parts[1])) return null; + const json = Buffer.from(parts[1], "base64url").toString("utf8"); + const parsed = JSON.parse(json) as JwtClaims; + if (typeof parsed.exp !== "number") return null; + return parsed; + } catch { + return null; + } +} diff --git a/lib/auth/auth-store.ts b/lib/auth/auth-store.ts new file mode 100644 index 00000000..23002812 --- /dev/null +++ b/lib/auth/auth-store.ts @@ -0,0 +1,250 @@ +/** + * Persistence layer for the FailproofAI auth.json file. + * + * Tokens live at ~/.failproofai/auth.json with mode 0600. The dashboard's + * Next.js API routes and the CLI both read/write through here so the user's + * session survives across `failproofai` (dashboard) and `failproofai auth` + * (CLI) invocations. + */ + +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { writeJsonAtomically } from "../atomic-write"; +import { + AuthApiError, + decodeJwt, + fetchMe, + refreshAccessToken, + type MeResponse, +} from "./api-server-client"; + +export interface StoredAuth { + access_token: string; + refresh_token: string; + access_expires_at: number; // unix seconds + refresh_expires_at: number; // unix seconds (best-effort; not strictly verified server-side) + user: { id: string; email: string }; +} + +export function getAuthDir(): string { + const override = process.env.FAILPROOFAI_AUTH_DIR; + if (override) return override; + return join(homedir(), ".failproofai"); +} + +export function getAuthFilePath(): string { + return join(getAuthDir(), "auth.json"); +} + +/** Location of the persisted re-audit reminder (separate from auth.json so + * the reminder survives unrelated session refreshes). */ +export function getReminderFilePath(): string { + return join(getAuthDir(), "next-audit.json"); +} + +export interface StoredReminder { + /** Unix seconds. */ + next_audit_at: number; + /** Email the reminder was set for. Used to invalidate the reminder if the + * active session belongs to a different user. */ + user_email: string; + /** Unix seconds. */ + set_at: number; +} + +export function readReminder(): StoredReminder | null { + const p = getReminderFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.next_audit_at !== "number" || + typeof parsed.user_email !== "string" || + typeof parsed.set_at !== "number" + ) { + return null; + } + return { + next_audit_at: parsed.next_audit_at, + user_email: parsed.user_email, + set_at: parsed.set_at, + }; + } catch { + return null; + } +} + +export function writeReminder(reminder: StoredReminder): void { + writeJsonAtomically(getReminderFilePath(), reminder); +} + +export function deleteReminder(): void { + const p = getReminderFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + +export function readAuth(): StoredAuth | null { + const p = getAuthFilePath(); + if (!existsSync(p)) return null; + try { + const raw = readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.access_token !== "string" || + typeof parsed.refresh_token !== "string" || + typeof parsed.access_expires_at !== "number" || + typeof parsed.user !== "object" || + !parsed.user || + typeof (parsed.user as { id?: unknown }).id !== "string" || + typeof (parsed.user as { email?: unknown }).email !== "string" + ) { + return null; + } + return { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + access_expires_at: parsed.access_expires_at, + refresh_expires_at: + typeof parsed.refresh_expires_at === "number" + ? parsed.refresh_expires_at + : parsed.access_expires_at, + user: { + id: (parsed.user as { id: string }).id, + email: (parsed.user as { email: string }).email, + }, + }; + } catch { + return null; + } +} + +export function writeAuth(auth: StoredAuth): void { + writeJsonAtomically(getAuthFilePath(), auth); +} + +export function deleteAuth(): void { + const p = getAuthFilePath(); + if (existsSync(p)) rmSync(p, { force: true }); +} + +/** Convert verify/refresh response into the on-disk shape. */ +export function authFromTokenResponse(token: { + access_token: string; + refresh_token: string; + access_expires_in: number; + refresh_expires_in: number; + user?: { id: string; email: string }; +}, existingUser?: { id: string; email: string }): StoredAuth { + const now = Math.floor(Date.now() / 1000); + const user = token.user ?? existingUser; + if (!user) { + throw new Error("authFromTokenResponse: missing user identity"); + } + return { + access_token: token.access_token, + refresh_token: token.refresh_token, + access_expires_at: now + token.access_expires_in, + refresh_expires_at: now + token.refresh_expires_in, + user, + }; +} + +/** + * Return a fresh access token, refreshing in-place if the current one is + * within the leeway window of expiry. Mutates auth.json on disk on success. + * Returns null if the stored session is gone or the refresh failed (caller + * should treat that as "logged out"). + */ +const REFRESH_LEEWAY_SECS = 60; + +/** + * In-flight refresh dedup. Without this, two concurrent callers (e.g. + * the dashboard's `/api/auth/status` poll and a `/api/auth/reminder` + * POST in flight) both observe the same expired access token, both call + * `refreshAccessToken(auth.refresh_token)` with the same refresh token, + * and the api-server treats the second call as token-replay and revokes + * every session for that user — a silent logout. Keying on the refresh + * token avoids accidentally sharing a refresh across logins/logouts in + * the same process. + */ +const inFlightRefreshes = new Map>(); + +async function dedupedRefresh(auth: StoredAuth): Promise { + const existing = inFlightRefreshes.get(auth.refresh_token); + if (existing) return existing; + const p = (async () => { + const refreshed = await refreshAccessToken(auth.refresh_token); + const next = authFromTokenResponse(refreshed, auth.user); + writeAuth(next); + return next; + })(); + inFlightRefreshes.set(auth.refresh_token, p); + try { + return await p; + } finally { + inFlightRefreshes.delete(auth.refresh_token); + } +} + +export async function getValidAccessToken(): Promise { + const auth = readAuth(); + if (!auth) return null; + const now = Math.floor(Date.now() / 1000); + if (auth.access_expires_at - now > REFRESH_LEEWAY_SECS) return auth; + // Either expired or close to expiring — try to refresh. + try { + return await dedupedRefresh(auth); + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Session unrecoverable — wipe. + deleteAuth(); + return null; + } + // Network errors etc — surface to caller as null so the UI can recover. + return null; + } +} + +/** + * Verify with the server that the stored access token is still valid. + * Refreshes once on 401. Returns the live /me response and the (possibly + * refreshed) stored auth, or null if the session can't be recovered. + */ +export async function whoAmI(): Promise<{ me: MeResponse; auth: StoredAuth } | null> { + const fresh = await getValidAccessToken(); + if (!fresh) return null; + try { + const me = await fetchMe(fresh.access_token); + return { me, auth: fresh }; + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // Maybe the leeway wasn't enough — try one more refresh and retry. + const reread = readAuth(); + if (!reread) return null; + try { + const next = await dedupedRefresh(reread); + const me = await fetchMe(next.access_token); + return { me, auth: next }; + } catch (retryErr) { + // Symmetry with `getValidAccessToken`: wipe the session only on an + // unambiguous 401. A transient timeout/5xx during the retry-fetchMe + // must NOT throw away the freshly-written valid tokens, otherwise a + // brief api-server hiccup silently logs the user out. + if (retryErr instanceof AuthApiError && retryErr.status === 401) { + deleteAuth(); + } + return null; + } + } + return null; + } +} + +/** Reads the JWT exp claim for diagnostics. */ +export function readAccessExpiry(auth: StoredAuth): number | null { + const claims = decodeJwt(auth.access_token); + return claims?.exp ?? null; +} diff --git a/lib/fetch-with-timeout.ts b/lib/fetch-with-timeout.ts new file mode 100644 index 00000000..d6f707c3 --- /dev/null +++ b/lib/fetch-with-timeout.ts @@ -0,0 +1,42 @@ +/** + * Shared fetch wrapper with per-request timeout via AbortController. + * + * Replaces three byte-equivalent implementations that had drifted on the + * default timeout (15s in two client components, 10s in the server-side + * api-server-client). Co-locates the `isAbortError` predicate so callers + * can classify timeout vs network failures consistently. + * + * Server-side uses `AbortSignal.timeout` where available (cheaper); this + * helper uses the AbortController+setTimeout form because the client + * code-paths need a guaranteed cleanup hook. + */ + +/** Hard cap on every fetch using this helper unless overridden. Picked to + * exceed the server-side `REQUEST_TIMEOUT_MS` (10s) in `api-server-client.ts` + * so a slow but successful upstream response still lands. */ +export const DEFAULT_FETCH_TIMEOUT_MS = 15_000; + +export async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(id); + } +} + +/** True when an error came from an AbortController.abort() OR a + * `AbortSignal.timeout` firing. Both surface as `Error` with `name` + * set to "AbortError" or "TimeoutError" respectively. Keeping the two + * in one predicate prevents the "I forgot TimeoutError" drift class. */ +export function isAbortError(err: unknown): boolean { + return ( + err instanceof Error + && (err.name === "AbortError" || err.name === "TimeoutError") + ); +} diff --git a/lib/telemetry.ts b/lib/telemetry.ts index 3c4dbdb2..b5693792 100644 --- a/lib/telemetry.ts +++ b/lib/telemetry.ts @@ -81,13 +81,18 @@ export function isTelemetryEnabled(): boolean { /** * Lazily import posthog-node and create a client. - * No-op when telemetry is disabled. + * + * No-op when telemetry is disabled. **Never throws** — callers (the + * Next.js API routes and the CLI subcommands) can `await initTelemetry()` + * unguarded and a posthog init failure can't 500 a valid auth response. + * The outer try/catch is the single source of truth; do NOT add a + * per-call wrapper at the call sites. */ export async function initTelemetry(): Promise { - if (!isTelemetryEnabled()) return; - if (globalThis.__FAILPROOFAI_POSTHOG__) return; - try { + if (!isTelemetryEnabled()) return; + if (globalThis.__FAILPROOFAI_POSTHOG__) return; + const mod: { PostHog: new (key: string, opts: PostHogOptions) => PostHogClient } = await import("posthog-node"); const apiKey = process.env.FAILPROOFAI_POSTHOG_KEY ?? DEFAULT_API_KEY; @@ -110,7 +115,7 @@ export async function initTelemetry(): Promise { process.on("SIGINT", onExit); } catch (err) { // Always log init failures — silent swallowing makes standalone debugging impossible - console.warn("[failproofai:telemetry] PostHog init failed:", err instanceof Error ? err.message : err); + console.warn("[failproofai:telemetry] init failed:", err instanceof Error ? err.message : err); } } diff --git a/package.json b/package.json index 3139b066..2bfd4ae8 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "vitest": "^4.0.18" }, "dependencies": { + "html2canvas": "^1.4.1", "posthog-node": "^5.28.11" }, "overrides": { diff --git a/public/audit/fonts/architype-stedelijk.ttf b/public/audit/fonts/architype-stedelijk.ttf new file mode 100644 index 00000000..d2ec7302 Binary files /dev/null and b/public/audit/fonts/architype-stedelijk.ttf differ diff --git a/public/audit/fonts/architype-stedelijk.woff2 b/public/audit/fonts/architype-stedelijk.woff2 new file mode 100644 index 00000000..e9742a21 Binary files /dev/null and b/public/audit/fonts/architype-stedelijk.woff2 differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 00000000..3bc25c4e --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 00000000..9ce0d7de --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/audit/archetypes.ts b/src/audit/archetypes.ts new file mode 100644 index 00000000..53de174c --- /dev/null +++ b/src/audit/archetypes.ts @@ -0,0 +1,939 @@ +/** + * Agent archetype catalog + classifier. + * + * Eight archetypes capture the failure-mode shape of a given coding agent. + * The classifier maps each policy/detector hit to one or more archetypes, + * weights them by hits × policy-severity, and picks the dominant signature. + * + * Used by the `/audit` dashboard to render an agent personality identity + * card. + * + * Variant model + * ------------- + * Each archetype carries arrays of variants for taglines, keyword sets, + * descriptions, "common in" / "primary risk" / closing lines, and the + * signature code block. `pickArchetypeVariant(key, seed)` resolves those + * arrays down to a single concrete `ResolvedArchetype` using a small + * hash of the seed. Same user (same seed) → same variant on every render; + * different seeds → different cards. + */ +import type { AuditResult } from "./types"; + +export type ArchetypeKey = + | "optimist" + | "cowboy" + | "explorer" + | "goldfish" + | "architect" + | "precision" + | "hammer" + | "ghost"; + +export interface SignatureLine { + arrow?: string; + body?: string; + comment?: string; + err?: string; +} + +/** + * The raw archetype carries arrays of variants. Render code must pick one + * concrete variant via `pickArchetypeVariant` before consuming any of the + * variant fields. + */ +export interface Archetype { + key: ArchetypeKey; + index: string; + name: string; + taglines: string[]; + keywordSets: string[][]; // each entry is a 3-word set + descriptions: string[]; + signatures: SignatureLine[][]; + commons: string[]; + risks: string[]; + closings: string[]; + secondary: ArchetypeKey; +} + +/** A single resolved variant — what render code actually consumes. */ +export interface ResolvedArchetype { + key: ArchetypeKey; + index: string; + name: string; + tagline: string; + keywords: string[]; + description: string; + signature: SignatureLine[]; + common: string; + risk: string; + closing: string; + secondary: ArchetypeKey; +} + +export const ARCHETYPE_ORDER: ArchetypeKey[] = [ + "optimist", "cowboy", "explorer", "goldfish", + "architect", "precision", "hammer", "ghost", +]; + +export const ARCHETYPES: Record = { + optimist: { + key: "optimist", + index: "01", + name: "the optimist", + taglines: [ + "ships fast. retries with conviction. occasionally forgets it was already there.", + "moves first, reads later. every failure is just step one of the next attempt.", + "the floor is hope. the ceiling is also hope. there is no diagnosis in between.", + "if at first you don't succeed — try the exact same thing, with more energy.", + "writes confident code. burns confident tokens. neither knows the difference.", + "speed is a feature. so is the directory it's already in.", + ], + keywordSets: [ + ["pace", "conviction", "forgetful"], + ["fast", "trusting", "redundant"], + ["eager", "looping", "stateful"], + ["bold", "unblocked", "drifty"], + ["forward", "hopeful", "wasteful"], + ["shipper", "retrier", "doubler"], + ], + descriptions: [ + "moves at pace. doesn't second-guess itself — which is mostly a feature. when something fails, it tries again: same args, same hope. when uncertain about its location, it prepends the directory anyway. just in case. the optimism is earned. this agent gets things done. it just occasionally burns tokens proving it.", + "ships first, asks questions never. the optimist is the agent that always has momentum — which is exactly the problem. cwd assumptions stack up. retries pile up. the work gets done. it's just twice as expensive as it needed to be.", + "high trust in its own state model. low evidence that the model is correct. when things go sideways, the optimist's first move is to re-run the same call with the same args and a renewed sense of conviction. mostly it's right. when it's wrong, it's wrong loudly.", + "the optimist treats every error as a transient. cd before every command, just in case. prepend the absolute path, just in case. retry on any non-zero exit, just in case. the just-in-case tax is real. so is the velocity.", + ], + signatures: [ + [ + { arrow: "→", body: "cd /Users/n/blrnow/api &&", comment: " # (already here)" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT × 6" }, + { arrow: "→", body: "retries: 6. diagnosis: 0." }, + ], + [ + { arrow: "→", body: "cd /Users/n/proj &&", comment: " # cwd already /Users/n/proj" }, + { arrow: "→", body: "cd /Users/n/proj && ls" }, + { arrow: "→", body: "cd /Users/n/proj && cat package.json" }, + ], + [ + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", err: " → ETIMEDOUT" }, + { arrow: "→", body: "npm install", comment: " # third time's the charm" }, + ], + [ + { arrow: "→", body: 'cat "package.json" | head', comment: " # ← read 1" }, + { arrow: "→", body: 'cat "package.json"', comment: " # ← read 2" }, + { comment: "# could've been one Read tool call." }, + ], + ], + commons: [ + "fast-iteration solo projects, early-stage prototypes, builders who ship daily", + "weekend hacks, hackathon repos, side-projects under active push", + "early-stage codebases without a strong test harness yet", + "agents given task framing without explicit success criteria", + "loose-context sessions where exact cwd state is ambiguous", + ], + risks: [ + "token waste, retry spirals, stale state assumptions", + "redundant cd's, repeated reads, retries without diagnosis", + "false confidence in cwd, doubled-up shell setup, idle loops", + "rate-limit hits from blind retries on transient failures", + "context bloat from re-reading the same files three different ways", + ], + closings: [ + "the optimism is a feature. the waste is not.", + "ship fast. retry less.", + "energy is good. diagnosis is better.", + "momentum keeps. the second cd does not.", + "trust the work. verify the state.", + ], + secondary: "explorer", + }, + cowboy: { + key: "cowboy", + index: "02", + name: "the cowboy", + taglines: [ + "asks for forgiveness, not permission. git push --force is a philosophy.", + "your branch protection rules are the only thing between this agent and prod.", + "fast hands, faster history rewrites. the audit log is for other people.", + "high trust in its own judgment. low patience for code review.", + "main is just a branch. branch protection is just a suggestion. ship.", + "ships hot. reverts later. occasionally needs an adult in the room.", + ], + keywordSets: [ + ["bold", "forceful", "ungoverned"], + ["direct", "destructive", "swift"], + ["fearless", "reckless", "loud"], + ["assertive", "loose", "unblocked"], + ["confident", "skipping", "main-bound"], + ["sudo-curious", "force-prone", "fast"], + ], + descriptions: [ + "high output. low ceremony. the cowboy gets code onto main faster than anyone — and your branch protection rules are the only thing standing between this agent and your production database. not reckless. just confident. in a way that requires guardrails.", + "doesn't see commits. sees a delivery mechanism. force-pushes when history is inconvenient. drops into main when feature branches feel slow. the cowboy is the agent every team accidentally creates, and every team eventually wants policies for.", + "the velocity is unmatched. the blast radius is also unmatched. this agent will solve your problem and rewrite three years of git history while it's at it. not malicious. just allergic to friction.", + "treats every guardrail as a temporary obstacle. sudo here, --no-verify there, a quick rm -rf to clean up. it's getting work done — by sidestepping every check that might slow it down.", + ], + signatures: [ + [ + { arrow: "→", body: "git push origin main --force" }, + { arrow: "!", body: "remote: branch protection rule", comment: " # caught it" }, + { arrow: "→", body: "git push origin HEAD:main", err: " # non-fast-forward, again." }, + ], + [ + { arrow: "→", body: "rm -rf ./node_modules && rm -rf ./dist" }, + { arrow: "→", body: 'git commit -am "wip" --no-verify' }, + { arrow: "→", body: "git push --force-with-lease" }, + ], + [ + { arrow: "→", body: "sudo systemctl restart postgres" }, + { arrow: "→", body: "kubectl delete pod api-prod-7f4 --grace-period=0" }, + { arrow: "→", body: 'echo "should be fine"' }, + ], + [ + { arrow: "→", body: "git checkout main && git merge feature --ff-only" }, + { arrow: "!", body: "merge would fail" }, + { arrow: "→", body: "git reset --hard feature && git push" }, + ], + ], + commons: [ + "solo repos, weekend projects, founders writing their own infra", + "agents with broad shell access and no PR-gating workflow", + "early-stage product code where speed > governance", + "ops scripts, one-off migrations, cleanup tasks", + "sandboxes that look like production until they aren't", + ], + risks: [ + "branch protection bypass, accidental main commits, revert overhead", + "destructive shell operations, unrecoverable state changes", + "force-pushed history, lost commits, irreproducible deploys", + "sudo escalations, container blast radius, infra mutations without rollback plan", + "policy bypass via --no-verify, --force, and friends", + ], + closings: [ + "the pace is real. the risk is too.", + "speed is a feature. guardrails are not optional.", + "ship hot. revert clean.", + "a fast agent without policies is a fast incident.", + "confidence is fine. consent is better.", + ], + secondary: "hammer", + }, + explorer: { + key: "explorer", + index: "03", + name: "the explorer", + taglines: [ + "technically brilliant. occasionally reads your ~/.aws/credentials while doing it.", + "follows every reference. opens every neighbor. some neighbors aren't yours.", + "thorough to a fault. the fault is usually a .env file two directories up.", + "knows the codebase deeply. knows your secrets drawer almost as well.", + "wide-context by default. wide-context isn't always free.", + "great at maps. less great at fences.", + ], + keywordSets: [ + ["curious", "thorough", "leaky"], + ["wide", "deep", "drifting"], + ["mapping", "tracing", "boundary-blind"], + ["broad", "diligent", "porous"], + ["thinking", "wandering", "exposing"], + ["research-mode", "context-hungry", "secret-adjacent"], + ], + descriptions: [ + "curious by nature. reads broadly, thinks laterally, sometimes follows a symlink somewhere it wasn't meant to go. this isn't malice — it's thoroughness that hasn't learned boundaries yet. the explorer builds great things. it just occasionally needs someone to close the door to the secrets drawer.", + "the explorer treats every file path as part of the working context. ~/.aws/credentials is just another config file to it. ../other-repo/.env is just one more reference. the work is genuinely better-informed because of this. the credentials are also genuinely in the context window.", + "no malice. no shortcuts. just a thoroughness that follows references straight through your boundary fence. great research instincts. needs explicit walls.", + "broad-context is a feature in this agent. it's also why your private keys show up in a chat log every two weeks. the curiosity is good. the perimeter needs help.", + ], + signatures: [ + [ + { arrow: "→", body: "cat /Users/n/.aws/credentials" }, + { arrow: "→", body: "cat ../other-repo/.env" }, + { arrow: "→", body: "cat ~/.config/openai/key" }, + ], + [ + { arrow: "→", body: 'find / -name "*.env" 2>/dev/null', comment: " # full-FS scan" }, + { arrow: "→", body: 'grep -r "AKIA" /Users/n/' }, + { arrow: "→", body: 'cat "$(find ~ -name credentials -print -quit)"' }, + ], + [ + { arrow: "→", body: "ls ~/.ssh/" }, + { arrow: "→", body: "cat ~/.ssh/config" }, + { arrow: "→", body: "cat ~/.ssh/id_rsa", comment: " # for context" }, + ], + [ + { arrow: "→", body: "open ../sibling-project" }, + { arrow: "→", body: "git log --all --source ../sibling-project" }, + { arrow: "→", body: "cat ../sibling-project/.env.production" }, + ], + ], + commons: [ + "multi-project setups, agents with broad file access, complex monorepos", + "research-style work — debugging, refactoring, cross-repo investigations", + "macOS / linux dev boxes with shared credential directories", + "agents without explicit cwd-restriction policies", + "long-running sessions where context tends to drift outward", + ], + risks: [ + "credential exposure, unintended cross-project reads, secrets landing in context", + ".env file leaks, AWS / OpenAI / GCP key exfiltration through chat logs", + "neighboring-repo bleed, business-secret cross-contamination", + "global filesystem scans that surface sensitive paths", + "broad reads that quietly inflate context window with private data", + ], + closings: [ + "the curiosity stays. the credentials stay private.", + "wide is fine. wide-and-outside-the-fence is not.", + "thorough is a feature. perimeter is a setting.", + "research deep. boundary clean.", + "knows everything. shares nothing it shouldn't.", + ], + secondary: "architect", + }, + goldfish: { + key: "goldfish", + index: "04", + name: "the goldfish", + taglines: [ + "long sessions, short memory. every turn is a fresh start. some turns are a little too fresh.", + "great at the first 40 turns. inventive for the next 40.", + "past 80% context, history becomes a draft.", + "remembers the task. forgets which file the task was in.", + "ambitious. earnest. quietly making things up around turn 50.", + "long-context vibes. short-context recall.", + ], + keywordSets: [ + ["ambitious", "drifting", "inventive"], + ["sprawling", "creative", "post-cache"], + ["long-running", "hallucinatory", "well-meaning"], + ["earnest", "context-full", "fabricating"], + ["sustained", "forgetful", "confabulating"], + ["marathon", "drifted", "compounding"], + ], + descriptions: [ + "great at long tasks. not great at remembering which long task it's on. past 80% context, the goldfish starts inventing history — citing files it never opened, referencing edits it never made. not lying. just filling gaps with confidence. the longer the session, the more creative the memory.", + "the goldfish is what every agent looks like after turn 50. confident about prior work it didn't do. mistakenly sure of file contents it never read. the work it actually delivered is real. the context around it is increasingly fictional.", + "ambition outlasts recall. once context fills, the goldfish smooths over gaps with plausible inventions: a fake earlier edit, a misremembered file path, a hallucinated test that passed. it's never trying to mislead. it just doesn't always know what's true anymore.", + "long-task specialist with a memory ceiling. the work compounds beautifully until it doesn't, and then it compounds wrongly. needs session breaks more than it needs encouragement.", + ], + signatures: [ + [ + { comment: "# turn 47/52 — ctx 82% full" }, + { comment: '# agent: "as we saw earlier in auth.ts…"' }, + { comment: "# auth.ts was never opened this session." }, + ], + [ + { comment: "# turn 63 — context 91%" }, + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "# agent: \"reverting my earlier change.\" # there was no earlier change." }, + ], + [ + { comment: "# turn 51 — fabricated test reference" }, + { arrow: "→", body: 'run("npm test src/auth.test.ts")', err: " → no such file" }, + { comment: '# agent: "the test we wrote earlier." # no such test exists.' }, + ], + [ + { comment: "# session-time 3h 14m" }, + { comment: "# context: 88% — auto-compress in 4 turns" }, + { comment: "# next plan cites 3 files only one of which exists." }, + ], + ], + commons: [ + "long-running refactor sessions, complex multi-file tasks, agents without session breaks", + "auto-driven coding loops with no human turn between iterations", + "tasks that span hours or days without explicit memory checkpoints", + "open-ended migrations and refactors with diffuse success criteria", + "scripted swarms where each agent inherits a long prior transcript", + ], + risks: [ + "context drift, hallucinated prior work, compounding errors in long sessions", + "fabricated file references, invented function signatures, ghost edits", + "tests cited that don't exist, edits remembered that didn't happen", + "confident misstatements compounding into wrong-architecture deliverables", + "auto-compression discarding the load-bearing details and keeping the noise", + ], + closings: [ + "the ambition is good. the context budget is not.", + "remember less. checkpoint more.", + "long is fine. drifted is expensive.", + "ambition is welcome. invention is not.", + "fresh sessions beat creative ones.", + ], + secondary: "optimist", + }, + architect: { + key: "architect", + index: "05", + name: "the paranoid architect", + taglines: [ + "has never shipped a bug it didn't catch first. also hasn't shipped since tuesday.", + "reads the same file from two different paths. just to be sure.", + "verifies twice, edits maybe.", + "safest agent in the room. also the one nobody waits for.", + "would rather diagnose for an hour than retry for a second.", + "extremely careful. extremely slow. extremely correct.", + ], + keywordSets: [ + ["methodical", "safe", "slow"], + ["thorough", "verifying", "circular"], + ["careful", "patient", "redundant"], + ["double-checking", "guarded", "deliberate"], + ["safety-first", "loop-prone", "anchored"], + ["measured", "audited", "looping"], + ], + descriptions: [ + "methodical. thorough. reads the same file from two different paths, just to be sure. verifies before every write. double-checks the package.json before running anything. the paranoid architect rarely makes mistakes — because it rarely finishes fast enough to make them. your safest agent. your slowest agent.", + "safety is the architect's love language. read the file. re-read it from a different path. verify the cwd. check the lockfile. run the test before writing. run it again after. the work is correct. the work is also six times more expensive than it had to be.", + "the architect's mental model is built on triangulation: every fact must be confirmed from two independent reads. when it works, you ship near-zero bugs. when it doesn't, you ship near-zero features.", + "extremely careful. extremely slow. extremely correct. the architect rarely makes mistakes — but it also rarely makes deadlines. the safety is genuine; so is the cost.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', comment: " # read 1" }, + { arrow: "→", body: 'read_file("./src/api/router.ts")', comment: " # read 2" }, + { arrow: "→", body: "ls src/api/", comment: " # just confirming" }, + ], + [ + { arrow: "→", body: 'read_file("package.json")' }, + { arrow: "→", body: 'read_file("./package.json")' }, + { arrow: "→", body: "cat package.json | jq .scripts", comment: " # one more time" }, + ], + [ + { arrow: "→", body: "git status", comment: " # check 1" }, + { arrow: "→", body: "git status --short", comment: " # check 2" }, + { arrow: "→", body: "git diff --stat", comment: " # check 3" }, + ], + [ + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })' }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # verifying the edit landed" }, + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # again, just to be sure" }, + ], + ], + commons: [ + "production systems, high-stakes codebases, builders with strong safety instincts", + "regulated codebases (fin / med / compliance) where bugs are expensive", + "teams burned by past prod incidents that hardened review norms", + "agents instructed with strong 'verify everything' system prompts", + "post-incident codebases recovering from a recent outage", + ], + risks: [ + "token overhead, slow sessions, redundant verification loops", + "verification cycles that eat 3× the budget of the actual change", + "stalled progress on otherwise routine edits", + "checkpoint loops that read the same file 6 times in a row", + "over-caution masking simple problems behind ceremony", + ], + closings: [ + "safety is a feature. so is finishing.", + "double-check is fine. quadruple-check is not.", + "careful is good. moving is also good.", + "rigor wins. rigor twice is just slower.", + "verify once. ship once.", + ], + secondary: "precision", + }, + precision: { + key: "precision", + index: "06", + name: "the precision builder", + taglines: [ + "in. done. out. your agent doesn't linger.", + "small footprint. right calls. correct exits.", + "few findings isn't no findings. but it's close.", + "the rhythm is dialed in. the rest is iteration.", + "every call is intentional. every session ends cleanly.", + "minimal noise. maximum signal. occasional smugness.", + ], + keywordSets: [ + ["clean", "focused", "minimal"], + ["surgical", "tight", "deliberate"], + ["disciplined", "concise", "intentional"], + ["measured", "exact", "trim"], + ["calibrated", "small-radius", "complete"], + ["dialed-in", "right-sized", "low-noise"], + ], + descriptions: [ + "minimal footprint. focused calls. gets in, does the work, gets out. the precision builder is what every agent aspires to be — and what most agents aren't yet. few findings don't mean no findings. but it means your agent has found its rhythm. the gap between here and s-tier is smaller than you think.", + "tight loops. correct tools. clean exits. the precision builder treats each tool call like it has a budget — because it does. nothing redundant. nothing wasteful. when this agent finishes, the work is done and the transcript is short.", + "this is what every agent aspires to be. surgical reads. matched edits. test runs that actually verify the right thing. precision is rare. when you see it, you've earned it.", + "minimal blast radius. minimal token waste. minimal surprises. the precision builder is what your agent looks like after enough iteration loops. respect.", + ], + signatures: [ + [ + { arrow: "→", body: "clean tool calls. right paths, right args." }, + { arrow: "→", body: "sessions end when the task ends." }, + { arrow: "→", body: "no redundant reads. no retry storms." }, + ], + [ + { arrow: "→", body: 'read_file("src/foo.ts")', comment: " # one read" }, + { arrow: "→", body: 'apply_edit("src/foo.ts", { ... })', comment: " # one edit" }, + { arrow: "→", body: 'run("bun test src/foo.test.ts")', comment: " # green ✓" }, + ], + [ + { arrow: "→", body: "git status" }, + { arrow: "→", body: "git add -p && git commit -m \"fix: ...\"" }, + { arrow: "→", body: "git push", comment: " # session done." }, + ], + [ + { arrow: "→", body: 'grep -rn "useFoo" src/' }, + { arrow: "→", body: 'apply_edit("src/hooks/use-foo.ts")' }, + { arrow: "→", body: 'run("bun test")', comment: " # all green." }, + ], + ], + commons: [ + "mature agents, heavily policy-enforced setups, builders who've iterated for a while", + "teams running failproofai for ≥ a week with policies tuned", + "experienced operators who curate their tool list and CLI flags", + "codebases with strong test coverage that reward intentional edits", + "agents kept on a tight cwd-restricted leash", + ], + risks: [ + "low finding count can mask edge cases that haven't surfaced yet", + "narrow scope might be hiding work the agent isn't being asked to do", + "small-radius work can plateau before it surfaces deeper issues", + "few findings can read as 'untested' rather than 'safe'", + "complacency — the rhythm works until the task shape changes", + ], + closings: [ + "rare. keep it that way.", + "few findings. real signal. respect.", + "this is the rhythm. don't break it.", + "minimal is hard-earned. defend it.", + "you're already past the agent learning curve.", + ], + secondary: "ghost", + }, + hammer: { + key: "hammer", + index: "07", + name: "the hammer", + taglines: [ + "when something doesn't work, it tries the exact same thing again. harder.", + "diagnosis-light. repetition-heavy. mostly burns tokens with conviction.", + "the first call failed. so did the next six. the seventh probably won't.", + "no diagnosis, no backoff, no arg change. just the same call, louder.", + "the failure mode is not learning. the failure mode is also the strategy.", + "every retry is identical. every retry is also confident.", + ], + keywordSets: [ + ["determined", "repetitive", "unbacked"], + ["looping", "stubborn", "unblocked"], + ["unchanging", "burning", "convicted"], + ["sticky", "spiraling", "diagnosis-free"], + ["repeated", "uncorrected", "headstrong"], + ["unchanged-args", "no-backoff", "patient-failure"], + ], + descriptions: [ + "determined. possibly to a fault. the hammer's first response to failure is repetition. no diagnosis, no arg change, no backoff. just the same call, six times, under 90 seconds, with conviction. occasionally works. mostly burns tokens and stalls the session. needs a budget more than it needs encouragement.", + "the hammer treats every transient as a signal-to-retry. it never widens the search, never alters the args, never escalates. just runs the same failing call until either the call starts working or someone notices the session has stalled.", + "the diagnosis instinct is missing. when something fails, the hammer's first move is to repeat. when that fails too, it's to repeat. and again. eventually it works, or eventually the session gets killed. either way, the model is unchanged.", + "high persistence. low introspection. the hammer is what your agent becomes when you don't give it a budget — or a reason to think differently between attempts.", + ], + signatures: [ + [ + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { arrow: "→", body: 'read_file("src/api/router.ts")', err: " → ENOENT" }, + { comment: "# 6× total. file is at src/router.ts." }, + ], + [ + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { arrow: "→", body: 'run("bun test")', err: " → exit 1" }, + { comment: "# same args. same failure. four more attempts queued." }, + ], + [ + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { arrow: "→", body: 'sleep 1; pgrep -f "build"' }, + { comment: "# polling loop. no timeout, no break condition." }, + ], + [ + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { arrow: "→", body: 'curl https://api.example.com/v1/foo', err: " → 502" }, + { comment: "# no backoff. no jitter. no API status check." }, + ], + ], + commons: [ + "agents without failure-handling policies, complex directory structures, ambiguous task framing", + "tasks where the agent doesn't have an obvious 'try-another-angle' move", + "transient-failure scenarios (rate limits, flaky tests, network blips)", + "agents without a per-task retry budget", + "tool-call patterns where the args themselves are the problem", + ], + risks: [ + "token spirals, stalled sessions, no diagnostic signal ever surfaces", + "rate-limit overruns, API ban-risk, infinite poll loops", + "wasted minutes on retries when one diff would have fixed it", + "transient errors mistaken for permanent ones (and vice versa)", + "no learning between attempts — same outcome, more cost", + ], + closings: [ + "the conviction is good. the diagnosis is missing.", + "retry less. think more.", + "harder isn't a strategy. different is.", + "stop. read the error. then try again.", + "the loop is the bug.", + ], + secondary: "optimist", + }, + ghost: { + key: "ghost", + index: "08", + name: "the ghost", + taglines: [ + "moves fast, leaves little trace. sometimes leaves a little too little trace.", + "writes the file. doesn't verify the write. trusts the silence.", + "completion ceremony? skipped. exits ceremony? also skipped.", + "the work probably worked. probably.", + "edits land. tests don't run. nothing checks the result.", + "efficient. quiet. occasionally lies to itself about success.", + ], + keywordSets: [ + ["efficient", "quiet", "unverified"], + ["clean", "trusting", "skip-the-check"], + ["fast", "silent", "uncommitted"], + ["light-touch", "trust-the-write", "no-test"], + ["minimal", "exit-fast", "audit-light"], + ["smooth", "untraced", "unconfirmed"], + ], + descriptions: [ + "efficient. clean. doesn't hang around. the ghost completes tasks with minimal overhead — no redundant reads, no retry storms, no boundary drift. the risk is quiet: it doesn't always check that things worked. the build passes. or it looks like it does. the ghost trusts its own output more than it should.", + "the ghost ships and exits. no verification loop. no test run. no read-after-write. the work is probably correct. probably. you'll find out next session — or when CI does, on someone else's screen.", + "no waste. no noise. no proof. the ghost writes the file, declares success, and moves on. when it's right, you've got a clean session. when it's wrong, you don't find out until the next deploy.", + "trusts the diff. trusts the toolchain. trusts the silence after a write. the ghost is the precision builder with one missing step: the verification at the end.", + ], + signatures: [ + [ + { arrow: "→", body: 'write_file("src/api/router.ts")', comment: " # done" }, + { comment: "→ [no read_file to verify]" }, + { comment: "→ [no test run after write]" }, + { comment: "# task complete. # maybe." }, + ], + [ + { arrow: "→", body: 'apply_edit("src/auth.ts", { ... })' }, + { comment: "→ [no test run]" }, + { comment: "→ [stop event fired with uncommitted changes]" }, + ], + [ + { arrow: "→", body: 'write_file("config/prod.json", "{...}")' }, + { comment: "# no schema check, no lint, no diff review" }, + { comment: "→ session ends." }, + ], + [ + { arrow: "→", body: "git merge feature-branch" }, + { arrow: "!", body: "merge conflicts: 3 files" }, + { comment: "→ stop event with conflicts unresolved." }, + ], + ], + commons: [ + "fast-moving solo projects, low-constraint setups, minimal oversight workflows", + "side projects where the cost of a missed bug is low", + "agents without 'require-tests-before-stop' style policies", + "monorepos where the test command is non-obvious", + "sessions auto-ended on success without an explicit verification step", + ], + risks: [ + "silent failures, unverified writes, false completion signals", + "uncommitted changes left on the floor, stop events firing dirty", + "missing test runs masking regressions until CI", + "merge conflicts left unresolved across session boundaries", + "PR-less work that's never reviewed before deploy", + ], + closings: [ + "fast is good. verified-fast is better.", + "ship. then check.", + "writes are a bet. verify it.", + "silent success isn't a signal. green tests are.", + "trust your toolchain. confirm with proof.", + ], + secondary: "precision", + }, +}; + +// ============================================================ +// 8x8 pixel sigils. legend: +// . = empty o = ink p = pink g = green d = dim +// ============================================================ +export const SIGILS: Record = { + optimist: [ + "........", + "...p....", + "..p.p...", + ".p...p..", + "p.....p.", + "..ooo...", + "..o.o...", + ".oo.oo..", + ], + cowboy: [ + "..pppp..", + ".p....p.", + "p..pp..p", + "pppppppp", + "..o..o..", + "..o..o..", + ".oo..oo.", + "........", + ], + explorer: [ + "..pppp..", + ".p.gg.p.", + "p.g..g.p", + "p.g..g.p", + ".p.gg.pp", + "..pppp.p", + "........", + "........", + ], + goldfish: [ + "....p...", + "..oooop.", + ".ooooopp", + "ooooooop", + ".oooooo.", + "..ooo...", + ".o...o..", + "o.....o.", + ], + architect: [ + "oooooooo", + "o......o", + "o.pppp.o", + "o.p..p.o", + "o.p..p.o", + "o.pppp.o", + "o......o", + "oooooooo", + ], + precision: [ + "...gg...", + "...gg...", + "........", + "gg...gg.", + "gg.gg.gg", + "...gg...", + "...gg...", + "........", + ], + hammer: [ + "..ooooo.", + ".oppppo.", + ".oppppo.", + "..o..o..", + "...oo...", + "...oo...", + "...oo...", + "..pppp..", + ], + ghost: [ + "..dddd..", + ".dddddd.", + "ddpd.pd.", + "ddddddd.", + "ddddddd.", + "ddddddd.", + "d.d.d.d.", + ".d...d..", + ], +}; + +// ============================================================ +// Variant picker — deterministic over (key, seed) +// ============================================================ + +/** djb2-style hash. Stable across renders, no crypto needed. */ +function hashSeed(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h >>> 0; +} + +function pickAt(arr: T[], h: number, axis: number): T { + if (arr.length === 0) throw new Error("pickAt: empty array"); + // Mix axis into the hash so different fields don't all land on the same + // index. xmur3-ish per-field scramble keeps the picks decorrelated. + // The final `>>> 0` coerces back to an unsigned 32-bit int so the + // modulo is always positive (`^=` re-introduces signedness). + let n = h ^ Math.imul(axis, 0x9e3779b9); + n = Math.imul(n ^ (n >>> 16), 0x85ebca6b); + n = Math.imul(n ^ (n >>> 13), 0xc2b2ae35); + n = (n ^ (n >>> 16)) >>> 0; + return arr[n % arr.length]!; +} + +/** + * Pick a single concrete variant of an archetype. + * + * `seed` must be stable for a given user/audit (project name is the + * natural choice — same project shows the same persona blurb on every + * re-render, but different projects get different ones). + */ +export function pickArchetypeVariant(key: ArchetypeKey, seed: string): ResolvedArchetype { + const a = ARCHETYPES[key]; + const h = hashSeed(seed || key); + return { + key: a.key, + index: a.index, + name: a.name, + secondary: a.secondary, + tagline: pickAt(a.taglines, h, 1), + keywords: pickAt(a.keywordSets, h, 2), + description: pickAt(a.descriptions, h, 3), + signature: pickAt(a.signatures, h, 4), + common: pickAt(a.commons, h, 5), + risk: pickAt(a.risks, h, 6), + closing: pickAt(a.closings, h, 7), + }; +} + +// ============================================================ +// Classifier +// ============================================================ + +/** Mapping from policy/detector short-name → which archetype its hits feed, + * and how heavily. Higher weight = stronger signal. */ +const SIGNAL_MAP: Record = { + // ---- audit-only detectors ---- + "redundant-cd-cwd": { archetype: "optimist", weight: 1.0 }, + "prefer-edit-over-read-cat":{ archetype: "optimist", weight: 0.5 }, + "prefer-edit-over-sed-awk": { archetype: "cowboy", weight: 0.8 }, + "prefer-write-over-heredoc":{ archetype: "cowboy", weight: 0.5 }, + "sleep-polling-loop": { archetype: "hammer", weight: 1.2 }, + "find-from-root": { archetype: "explorer", weight: 1.0 }, + "git-commit-no-verify": { archetype: "cowboy", weight: 1.5 }, + "reread-after-edit": { archetype: "architect", weight: 0.8 }, + + // ---- builtin policies (mapped by primary failure-mode flavor) ---- + // cowboy: forceful git, destructive shell, bypassing guardrails + "block-push-master": { archetype: "cowboy", weight: 1.5 }, + "block-force-push": { archetype: "cowboy", weight: 1.5 }, + "block-work-on-main": { archetype: "cowboy", weight: 1.2 }, + "block-rm-rf": { archetype: "cowboy", weight: 2.0 }, + "block-sudo": { archetype: "cowboy", weight: 1.5 }, + "block-curl-pipe-sh": { archetype: "cowboy", weight: 1.5 }, + "block-failproofai-commands":{ archetype: "cowboy", weight: 2.0 }, + "warn-git-amend": { archetype: "cowboy", weight: 0.8 }, + "warn-git-stash-drop": { archetype: "cowboy", weight: 1.0 }, + "warn-all-files-staged": { archetype: "cowboy", weight: 0.6 }, + "warn-destructive-sql": { archetype: "cowboy", weight: 1.5 }, + "warn-schema-alteration": { archetype: "cowboy", weight: 1.0 }, + "warn-package-publish": { archetype: "cowboy", weight: 1.0 }, + + // explorer: reading outside boundary, secrets exposure + "block-read-outside-cwd": { archetype: "explorer", weight: 1.2 }, + "block-env-files": { archetype: "explorer", weight: 1.5 }, + "block-secrets-write": { archetype: "explorer", weight: 1.5 }, + "protect-env-vars": { archetype: "explorer", weight: 1.0 }, + "sanitize-api-keys": { archetype: "explorer", weight: 1.2 }, + "sanitize-jwt": { archetype: "explorer", weight: 1.2 }, + "sanitize-connection-strings":{ archetype: "explorer",weight: 1.2 }, + "sanitize-private-key-content":{ archetype: "explorer",weight: 1.5 }, + "sanitize-bearer-tokens": { archetype: "explorer", weight: 1.0 }, + + // optimist: rushing, global installs, low-friction patterns + "warn-global-package-install":{ archetype: "optimist",weight: 0.8 }, + + // ghost: large blind writes, unsupervised background work, no completion ceremony + "warn-large-file-write": { archetype: "ghost", weight: 1.0 }, + "warn-background-process": { archetype: "ghost", weight: 0.8 }, + "require-commit-before-stop":{ archetype: "ghost", weight: 1.2 }, + "require-push-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-pr-before-stop": { archetype: "ghost", weight: 1.0 }, + "require-ci-green-before-stop":{ archetype: "ghost", weight: 1.2 }, + + // hammer: literal repetition + "warn-repeated-tool-calls": { archetype: "hammer", weight: 1.5 }, + + // cowboy: cloud / cluster CLIs that mutate live infrastructure + "block-kubectl": { archetype: "cowboy", weight: 1.5 }, + "block-terraform": { archetype: "cowboy", weight: 1.5 }, + "block-helm": { archetype: "cowboy", weight: 1.5 }, + "block-aws-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gcloud": { archetype: "cowboy", weight: 1.2 }, + "block-az-cli": { archetype: "cowboy", weight: 1.2 }, + "block-gh-pipeline": { archetype: "cowboy", weight: 1.2 }, + + // optimist: package-manager churn (grabs whatever tool is at hand) + "prefer-package-manager": { archetype: "optimist", weight: 0.8 }, + + // ghost: completion ceremony skipped — leaving merge conflicts on the floor + "require-no-conflicts-before-stop": { archetype: "ghost", weight: 1.0 }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +export interface Classification { + archetype: ArchetypeKey; + /** Same-key when no meaningful secondary; the IdentitySection hides the + * secondary chip whenever `secondary === archetype`. */ + secondary: ArchetypeKey; + /** Per-archetype raw weight. Useful for debug and for the sigil-meter + * variants (not currently rendered). */ + weights: Record; + /** Total signal — sum of weighted hits across all archetypes. */ + totalSignal: number; +} + +/** + * Classify an `AuditResult` into one of the 8 archetypes plus an optional + * secondary tendency. + * + * Rules: + * 1. Empty signal (no hits, nothing detected) → precision. This is the + * "you're already running clean" outcome. + * 2. Spread across many archetypes (top-3 share < 60% of total) and ≥5 + * distinct archetypes triggered → goldfish (drift across categories). + * 3. Otherwise: highest-weighted archetype wins. The secondary is the + * second-highest, but only when it's ≥40% of the primary — otherwise + * we fall back to the archetype's authored secondary. + */ +export function classifyAgent(result: AuditResult): Classification { + const weights: Record = { + optimist: 0, cowboy: 0, explorer: 0, goldfish: 0, + architect: 0, precision: 0, hammer: 0, ghost: 0, + }; + + for (const row of result.results) { + const sig = SIGNAL_MAP[shortName(row.name)]; + if (!sig) continue; + weights[sig.archetype] += row.hits * sig.weight; + } + + const totalSignal = Object.values(weights).reduce((s, w) => s + w, 0); + const sorted = (Object.entries(weights) as [ArchetypeKey, number][]) + .sort((a, b) => b[1] - a[1]); + + // Rule 1: no signal → precision (clean baseline). + if (totalSignal === 0) { + return { + archetype: "precision", + secondary: ARCHETYPES.precision.secondary, + weights, + totalSignal: 0, + }; + } + + // Rule 2: goldfish (broad spread). + const nonZero = sorted.filter(([, w]) => w > 0); + const top3Sum = sorted.slice(0, 3).reduce((s, [, w]) => s + w, 0); + if (nonZero.length >= 5 && top3Sum / totalSignal < 0.6) { + return { + archetype: "goldfish", + secondary: sorted[0][0], + weights, + totalSignal, + }; + } + + // Rule 3: highest-weighted wins. + const primary = sorted[0][0]; + const secondary = sorted[1] && sorted[1][1] >= sorted[0][1] * 0.4 + ? sorted[1][0] + : ARCHETYPES[primary].secondary; + + return { archetype: primary, secondary, weights, totalSignal }; +} diff --git a/src/audit/cache.ts b/src/audit/cache.ts index 57668cc8..4474ee73 100644 --- a/src/audit/cache.ts +++ b/src/audit/cache.ts @@ -49,7 +49,24 @@ function getCachePathFor(transcriptPath: string): string { return join(root, `${key}.json`); } +/** + * Bump whenever the on-disk shape of a cached transcript entry changes in + * a way the reader can't tolerate (added required field, renamed key, + * swapped result version). Entries written with a different + * `schemaVersion` are rejected. + * + * v2: `TranscriptAuditResult` gained `cwd` and `eventsScanned` fields + * (surfaced up to `AuditResult.projectsScanned` / `eventsScanned`). + * Pre-PR cache entries lack them; on upgrade the aggregator would have + * silently rendered them as `cwd: undefined` (dropped from + * `projectsScanned`) and `eventsScanned: 0`. Rejecting v1 forces a + * re-scan so the new fields are populated correctly. + */ +export const CACHE_SCHEMA_VERSION = 2; + interface CacheEntry { + /** Bumped whenever the on-disk shape changes incompatibly. */ + schemaVersion: number; mtimeMs: number; sizeBytes: number; engineVersion: string; @@ -67,12 +84,13 @@ export function readCachedTranscriptResult( if (!existsSync(cachePath)) return null; try { const raw = readFileSync(cachePath, "utf-8"); - const entry = JSON.parse(raw) as CacheEntry; + const entry = JSON.parse(raw) as Partial; + if (entry.schemaVersion !== CACHE_SCHEMA_VERSION) return null; if (entry.mtimeMs !== mtimeMs) return null; if (entry.sizeBytes !== sizeBytes) return null; if (entry.engineVersion !== getEngineVersion()) return null; if (entry.detectorVersion !== getDetectorVersion()) return null; - return entry.result; + return entry.result ?? null; } catch { return null; } @@ -89,6 +107,7 @@ export function writeCachedTranscriptResult( try { mkdirSync(join(homedir(), ".failproofai", "cache", "audit"), { recursive: true }); const entry: CacheEntry = { + schemaVersion: CACHE_SCHEMA_VERSION, mtimeMs, sizeBytes, engineVersion: getEngineVersion(), diff --git a/src/audit/dashboard-cache.ts b/src/audit/dashboard-cache.ts new file mode 100644 index 00000000..eec1c17f --- /dev/null +++ b/src/audit/dashboard-cache.ts @@ -0,0 +1,111 @@ +/** + * Whole-result cache for the Next.js dashboard's `/audit` page. + * + * Stored at `~/.failproofai/audit-dashboard.json` with mode 0600. Single + * slot — a new run with different params overwrites the previous entry. + * Read by `app/actions/get-audit-result.ts` (server action) and written by + * `app/api/audit/run/route.ts` on successful run completion. + * + * Separate from the per-transcript cache at `~/.failproofai/cache/audit/` + * (see `src/audit/cache.ts`): that one makes re-running fast; this one + * makes navigating back to /audit instant without re-running. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { writeJsonAtomically } from "../../lib/atomic-write"; +import type { AuditResult, RunAuditOptions } from "./types"; + +const DEFAULT_MAX_AGE_MINUTES = 30; + +/** + * Bump whenever the on-disk shape of a cached entry changes in a way the + * reader can't tolerate (added required field, renamed key, swapped result + * version). Entries written with a different `schemaVersion` are rejected + * — better an empty state than rendering against the wrong shape. + * + * v2: AuditResult.version bumped 1→2 (added `projectsScanned`, + * `eventsScanned`, `enabledBuiltinNames`). Renderers defaulted missing + * fields silently, which masked a stale cache as "0 tool calls scanned" + * instead of triggering the empty-state recovery. Rejecting v1 entries + * forces a re-run. + */ +export const DASHBOARD_CACHE_SCHEMA_VERSION = 2; + +export interface DashboardCacheEntry { + /** Bumped whenever the cache shape changes incompatibly. */ + schemaVersion: number; + /** ISO timestamp the cache was written at. */ + cachedAt: string; + /** The exact RunAuditOptions the cached result was produced with. */ + params: RunAuditOptions; + /** The full `AuditResult` from `runAudit()`. */ + result: AuditResult; +} + +function getCachePath(): string { + return join(homedir(), ".failproofai", "audit-dashboard.json"); +} + +/** Read the cache file. Returns null on missing/corrupt/unreadable file — + * callers treat "no cache" as the empty state. */ +export function readDashboardCache(): DashboardCacheEntry | null { + const cachePath = getCachePath(); + if (!existsSync(cachePath)) return null; + try { + const raw = readFileSync(cachePath, "utf-8"); + const entry = JSON.parse(raw) as DashboardCacheEntry; + // `typeof null === "object"`, so explicit null checks are required for + // params and result — otherwise a corrupt cache like `{"params": null}` + // would slip through and crash downstream readers. + if ( + !entry + || typeof entry !== "object" + || typeof entry.cachedAt !== "string" + || !entry.params + || typeof entry.params !== "object" + || !entry.result + || typeof entry.result !== "object" + ) { + return null; + } + // Reject anything written by an older code version with a different + // shape. The dashboard treats null as the "no cached result" empty + // state, which is the safer fallback when we can't trust the bytes. + if (entry.schemaVersion !== DASHBOARD_CACHE_SCHEMA_VERSION) { + return null; + } + return entry; + } catch { + return null; + } +} + +/** Write the cache file atomically via the shared `writeJsonAtomically` + * helper. Best-effort overall (swallows errors so a failed write never + * breaks the run path), but the temp-file dance protects concurrent + * readers (e.g. the 1s status poll firing while a fresh run writes a + * multi-hundred-KB AuditResult) from observing a torn JSON file. */ +export function writeDashboardCache(params: RunAuditOptions, result: AuditResult): void { + try { + const entry: DashboardCacheEntry = { + schemaVersion: DASHBOARD_CACHE_SCHEMA_VERSION, + cachedAt: new Date().toISOString(), + params, + result, + }; + writeJsonAtomically(getCachePath(), entry); + } catch { + // Cache writes are best-effort. + } +} + +/** True when the cache is older than `maxAgeMinutes` (default 30). The + * dashboard doesn't auto-refresh on stale cache — staleness only drives + * the "Re-run" affordance hint. */ +export function isCacheStale(cachedAt: string, maxAgeMinutes: number = DEFAULT_MAX_AGE_MINUTES): boolean { + const cachedMs = new Date(cachedAt).getTime(); + if (Number.isNaN(cachedMs)) return true; + const ageMs = Date.now() - cachedMs; + return ageMs > maxAgeMinutes * 60_000; +} diff --git a/src/audit/findings.ts b/src/audit/findings.ts new file mode 100644 index 00000000..0fa44242 --- /dev/null +++ b/src/audit/findings.ts @@ -0,0 +1,298 @@ +/** + * Build the FindingsSection cards from a live AuditResult. + * + * Each card has four blocks (per reference design): + * - what happened (prose summary, hand-written per policy) + * - what this costs (severity / radius framing) + * - evidence (real examples from the AuditResult) + * - the fix (policy slug + install command — only when not enabled) + * + * The body / cost copy is hand-curated per policy/detector when we have + * good copy for it; otherwise we fall back to the policy's authored + * `displayTitle` + `impact` strings. + */ +import type { AuditCount, AuditResult } from "./types"; + +/** Plain-text body so this module stays JSX-free and can be imported + * server-side. The React layer renders these as paragraphs. */ +export interface FindingCopy { + body: string; + cost: string; +} + +/** + * Audit-detector → builtin-policy mapping. + * + * Each audit-only detector is paired with the closest real-time policy + * that catches the same class of behavior. The detector still does the + * specific pattern-matching; the "fix" prescribed in the report is the + * builtin policy. Removes the "audit-only — no real-time policy yet" + * framing so every finding looks like it has a failproofai fix. + * + * Mappings authored against the policy catalog in src/hooks/builtin-policies.ts. + * The first entry is the primary fix (shown in the "$ install" block); + * additional entries are listed alongside as "also covered by". + */ +const DETECTOR_TO_POLICY: Record = { + // wasteful shell: repetitive cd && cmd burns tokens — same class as + // 3+ identical tool calls + "redundant-cd-cwd": { primary: "warn-repeated-tool-calls" }, + // wrong tool choice: bash cat/head/tail on source files crosses the + // same file-read surface block-read-outside-cwd gates; the repetition + // is what warn-repeated-tool-calls would have caught + "prefer-edit-over-read-cat":{ primary: "block-read-outside-cwd", also: "warn-repeated-tool-calls" }, + // wrong tool choice: sed -i / awk > file route a write through the + // shell — same class as the repeated-mis-use pattern + "prefer-edit-over-sed-awk": { primary: "warn-repeated-tool-calls" }, + // bash file bypass: heredoc / echo > file is the layer that bypasses + // the Write tool — both .env and secret-key writes route through it + "prefer-write-over-heredoc":{ primary: "block-env-files", also: "block-secrets-write" }, + // wasted execution: long sleeps + while-sleep loops are the same + // shape as backgrounded processes that never get cleaned up + "sleep-polling-loop": { primary: "warn-background-process" }, + // risky filesystem: find /, /home, /usr is exactly the class of + // out-of-cwd reads that block-read-outside-cwd gates + "find-from-root": { primary: "block-read-outside-cwd" }, + // hook bypass: --no-verify is a dangerous-commit-flag pattern; the + // bypass means CI / hooks never ran — both warn-git-amend's "rewriting + // history" class and the require-ci-green stop-gate cover this + "git-commit-no-verify": { primary: "warn-git-amend", also: "require-ci-green-before-stop" }, + // wasteful reads: read after edit/write is identical-tool-call + // overhead — same redundant-invocation class + "reread-after-edit": { primary: "warn-repeated-tool-calls" }, +}; + +const FINDING_COPY: Record = { + "redundant-cd-cwd": { + body: "the agent runs `cd ` before commands it would have run from the same directory anyway. mostly harmless. occasionally it gets the path wrong and manufactures a new bug.", + cost: "tokens burned on redundant navigation. low security risk. high noise.", + }, + "block-push-master": { + body: "attempts to push directly to main. branch protection caught some, but the agent kept going. each retry costs a round-trip and pollutes the audit log.", + cost: "branch protection saved you most of the time. the rest landed or required a revert.", + }, + "block-force-push": { + body: "force pushes to non-main branches. fast-forward errors rewritten by overwriting remote history — risky on shared branches even when not main.", + cost: "lost commits, broken PR diffs, confused reviewers downstream.", + }, + "block-work-on-main": { + body: "commits or merges made while the agent was sitting on main / master. work that should land via PR skipped review.", + cost: "code that didn't pass review made it into the default branch.", + }, + "block-read-outside-cwd": { + body: "reads outside the project root. some hit credential files (~/.aws/credentials, ~/.config/openai/key, out-of-tree .env). none made it back to stdout — but they made it into context.", + cost: "credential exposure risk. data crossed project boundaries into the agent's context window.", + }, + "block-env-files": { + body: "the agent tried to read or write `.env` files directly. these typically contain API keys and database credentials in plaintext.", + cost: "high exposure risk. secrets one tool-call away from leaving the project.", + }, + "block-secrets-write": { + body: "attempts to write credential-shaped strings to files that aren't typically credential stores.", + cost: "could have committed live secrets to the repo.", + }, + "block-rm-rf": { + body: "recursive deletes against paths that could plausibly take out unrelated work. `rm -rf` is the agent's preferred way of cleaning up — even when it shouldn't be.", + cost: "irreversible. one wrong path argument = lost work.", + }, + "block-sudo": { + body: "sudo invocations from inside the agent shell. escalating to root inside an unsupervised tool call is rarely the answer.", + cost: "privilege escalation in a context where the agent isn't meant to have it.", + }, + "block-curl-pipe-sh": { + body: "curl | sh patterns — fetching a remote script and piping it straight into the shell. no checksum, no review, no rollback.", + cost: "supply-chain attack surface. arbitrary code execution from a URL.", + }, + "warn-repeated-tool-calls": { + body: "same call, same args, multiple times under 90 seconds. no diagnosis between attempts. the call's been failing for the same reason every time.", + cost: "retry overhead. sessions stall before manual correction.", + }, + "sleep-polling-loop": { + body: "long sleeps or busy-wait loops where the agent waits for a state it has no reason to expect.", + cost: "wall-clock burned. better to wait for an explicit signal.", + }, + "find-from-root": { + body: "`find` invoked against `/`, `/home`, `/usr`, etc. — searching the whole filesystem when a project-scoped query would have answered the question.", + cost: "exhausts resources. surfaces files outside the project that taint context.", + }, + "git-commit-no-verify": { + body: "commits made with `--no-verify` / `-n`, skipping pre-commit hooks. the hooks exist to catch lint errors, broken types, malformed configs — bypassing them means those checks never ran.", + cost: "broken or unsafe code lands without the safety net.", + }, + "prefer-edit-over-read-cat": { + body: "`cat` / `head` / `tail` on source files routed through Bash output instead of the Read tool. round-trips the file through a less efficient channel.", + cost: "burns tokens on shell output that the Read tool would have returned cleanly.", + }, + "prefer-edit-over-sed-awk": { + body: "in-place edits via `sed -i` or `awk … > file`. no diff to inspect, no rollback if the regex was wrong.", + cost: "destructive when the regex matches more than expected. no verification surface.", + }, + "prefer-write-over-heredoc": { + body: "multi-line file writes via heredoc or `echo > file`. the Write tool handles escaping and produces a verifiable diff.", + cost: "subtle escape bugs. content arrives in the file with quoting drift.", + }, + "reread-after-edit": { + body: "reads of files that were Edit'd or Write'n earlier in the same session. the editor already returned the updated content — the second read is wasted.", + cost: "tokens spent re-fetching content the tool already returned.", + }, + "warn-large-file-write": { + body: "writes to files significantly larger than typical for the project. blast radius increases with file size; large writes deserve a second look.", + cost: "harder to review, harder to roll back, easier to break something downstream.", + }, + "warn-background-process": { + body: "spawned a background process and moved on. nothing watches the process; if it crashes the agent doesn't know.", + cost: "silent failures. resource leaks if the process never exits.", + }, + "require-commit-before-stop": { + body: "the agent reported a task complete while changes were still uncommitted in the working tree.", + cost: "unsaved work. next session starts with a dirty checkout the agent thinks is clean.", + }, + "require-push-before-stop": { + body: "the agent stopped with commits sitting only on the local branch — nothing pushed to the remote.", + cost: "no one else can see the work. silent loss if the machine dies.", + }, + "require-pr-before-stop": { + body: "the agent stopped without opening a PR. the commits are on a branch nobody reviewed.", + cost: "no review, no merge path, no record that the work happened.", + }, + "require-ci-green-before-stop": { + body: "the agent declared completion before CI returned green (or while CI was already failing).", + cost: "false completion signal. broken main if anyone trusts the agent's word.", + }, +}; + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function relTimeAgo(iso?: string): string { + if (!iso) return "—"; + const ms = Date.now() - new Date(iso).getTime(); + if (Number.isNaN(ms) || ms < 0) return "—"; + const m = Math.floor(ms / 60_000); + if (m < 60) return `${Math.max(1, m)}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const months = Math.floor(d / 30); + return `${months}mo ago`; +} + +export interface FindingCard { + num: string; + title: string; + count: number; + /** Unique identifier for React keys. This is the original detector + * or policy short slug (e.g. "redundant-cd-cwd", "block-push-master"), + * NOT the prescribed-fix slug — which can repeat across cards when + * multiple detectors share the same fix policy. */ + sourceSlug: string; + /** Slug shown in the meta line — the prescribed-fix policy. May + * repeat across cards (e.g. several detectors → warn-repeated-tool-calls). */ + policy: string; + projects: number; + lastSeen: string; + body: string; + cost: string; + evidence: { text: string; kind: "cmd" | "comment" | "err" }[]; + /** Prescribed fix. Always populated now — detectors route to their + * closest builtin policy (see DETECTOR_TO_POLICY). */ + fix: { slug: string; desc: string; install: string; alsoCoveredBy?: string }; + /** True when the prescribed fix policy is already in the user's + * enabled set. UI tones the fix block accordingly. */ + alreadyEnabled: boolean; +} + +/** Build the per-policy/detector finding cards. Ranks by hits desc and + * drops rows that would otherwise be uninformative (zero hits). */ +export function deriveFindings(result: AuditResult): FindingCard[] { + const sorted = [...result.results] + .filter((r) => r.hits > 0) + .sort((a, b) => b.hits - a.hits); + + const enabledSet = new Set(result.enabledBuiltinNames ?? []); + return sorted.map((r, i) => buildCard(r, i, enabledSet)); +} + +/** Lightweight metadata for a policy that we may need to display even + * when the policy didn't fire on its own (a detector pointed at it). + * Mirrors the relevant subset of `BuiltinPolicy` so this module stays + * client-bundle-safe (no node imports). */ +const POLICY_META: Record = { + "warn-repeated-tool-calls": { + displayTitle: "Called the same tool 3+ times with identical arguments", + impact: "catches identical-arg retries before they spiral into a token-burning loop.", + }, + "block-read-outside-cwd": { + displayTitle: "Tried to read files outside your project directory", + impact: "denies reads of files outside the project root, including symlinks.", + }, + "block-env-files": { + displayTitle: "Tried to read or write a .env file", + impact: "blocks reads and writes of `.env` files at the tool layer.", + }, + "block-secrets-write": { + displayTitle: "Tried to write a secret-key file", + impact: "blocks writes to .pem, id_rsa, credentials.json, and similar.", + }, + "warn-background-process": { + displayTitle: "Started a long-lived background process", + impact: "warns on nohup / & / screen / tmux / disown patterns the agent forgets to clean up.", + }, + "warn-git-amend": { + displayTitle: "Used git commit --amend", + impact: "warns before amending — same class as dangerous-commit-flag bypasses.", + }, + "require-ci-green-before-stop": { + displayTitle: "Stopped with failing CI", + impact: "requires CI checks to pass on HEAD before declaring done.", + }, +}; + +function buildCard(r: AuditCount, idx: number, enabledSet: Set): FindingCard { + const slug = shortName(r.name); + const isDetector = r.source === "audit-detector"; + const mapping = isDetector ? DETECTOR_TO_POLICY[slug] : undefined; + + // For a detector, the prescribed fix points at its mapped policy. + // For a builtin row, it points at itself. + const fixSlug = mapping?.primary ?? slug; + const meta = POLICY_META[fixSlug]; + const fixDesc = meta?.impact ?? r.impact ?? r.displayTitle; + const alsoCoveredBy = mapping?.also; + + const alreadyEnabled = enabledSet.has(fixSlug) + || (r.source === "builtin" && r.enabledInConfig); + + const copy = FINDING_COPY[slug]; + + const evidence: FindingCard["evidence"] = r.examples.slice(0, 4).map((e) => ({ + text: e.example, + kind: "cmd" as const, + })); + if (evidence.length === 0) { + evidence.push({ text: "no example commands captured.", kind: "comment" }); + } + + return { + num: String(idx + 1).padStart(2, "0"), + title: r.displayTitle.toLowerCase(), + count: r.hits, + sourceSlug: slug, + policy: fixSlug, + projects: r.projects, + lastSeen: relTimeAgo(r.lastSeen), + body: copy?.body ?? r.impact ?? r.displayTitle, + cost: copy?.cost ?? r.impact ?? "see policy description above.", + evidence, + fix: { + slug: fixSlug, + desc: fixDesc, + install: `failproofai policy add ${fixSlug}`, + alsoCoveredBy, + }, + alreadyEnabled, + }; +} diff --git a/src/audit/index.ts b/src/audit/index.ts index 70c9d884..22da8c5b 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -15,13 +15,7 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types"; import { ADAPTERS } from "./cli-adapters"; import { AUDIT_DETECTORS } from "./detectors"; import { readCachedTranscriptResult, writeCachedTranscriptResult } from "./cache"; -import { initReplay, replayEvent } from "./replay"; -import { - trackAuditCompleted, - trackAuditInstallCtaShown, - trackAuditPatternDetected, - trackAuditStarted, -} from "./telemetry"; +import { initReplay, replayEvent, restoreReplay } from "./replay"; import { AUDIT_EXAMPLE_MAX_CHARS, AUDIT_MAX_EXAMPLES_PER_NAME, @@ -100,6 +94,8 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise { const startedAt = Date.now(); initReplay(); + try { + return await runAuditInner(opts, startedAt); + } finally { + // Always restore the caller's policy registry, even on error. Without + // this, embedding runAudit() in a long-running process (e.g. the Next.js + // dashboard) would clobber any pre-existing policy registrations. + restoreReplay(); + } +} - const outputMode = opts.json ? "json" : opts.noReport ? "text" : "text+markdown"; - trackAuditStarted(opts, outputMode); - +async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise { const clis = (opts.clis ?? Array.from(INTEGRATION_TYPES)) as IntegrationType[]; const sinceMs = parseSinceOpt(opts.since); @@ -301,13 +308,19 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise return fresh; } catch { errors++; + // Match the empty/full result shape — `cwd` is unknowable here (we + // never got to scan the events that carry it), but `eventsScanned: 0` + // is right and keeps the aggregator's `t.eventsScanned ?? 0` shape + // explicit. cwd defaults to "" so `if (t.cwd)` skips it cleanly. return { transcriptPath: meta.transcriptPath, cli: meta.cli, projectName: meta.projectName, + cwd: "", sessionId: meta.sessionId, mtimeMs: meta.mtimeMs, sizeBytes: meta.sizeBytes, + eventsScanned: 0, hitsByName: {}, examplesByName: {}, rangeByName: {}, @@ -331,12 +344,16 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise const totalsHits = results.reduce((sum, r) => sum + r.hits, 0); const projectsWithHits = new Set(); + const projectsScannedSet = new Set(); + let eventsScanned = 0; for (const t of perTranscript) { if (Object.keys(t.hitsByName).length > 0) projectsWithHits.add(t.projectName); + if (t.cwd) projectsScannedSet.add(t.cwd); + eventsScanned += t.eventsScanned ?? 0; } const auditResult: AuditResult = { - version: 1, + version: 2, scannedAt: new Date(startedAt).toISOString(), scope: { cli: clis, @@ -354,16 +371,13 @@ export async function runAudit(opts: RunAuditOptions = {}): Promise hits: totalsHits, projectsWithHits: projectsWithHits.size, }, + projectsScanned: [...projectsScannedSet].sort(), + eventsScanned, + // Pull short names off the user's enabled builtin set so the dashboard + // can answer "is policy X enabled?" without iterating result rows. + enabledBuiltinNames: [...enabledBuiltins] + .map((n) => (n.includes("/") ? n.slice(n.indexOf("/") + 1) : n)), }; - // Telemetry — fire-and-forget, never blocks the CLI. See src/audit/telemetry.ts - // for the privacy contract (slugs + counts + booleans only). - for (const count of results) trackAuditPatternDetected(count); - const unenabledBuiltinNames = results - .filter((r) => r.source === "builtin" && !r.enabledInConfig) - .map((r) => r.name); - trackAuditInstallCtaShown(unenabledBuiltinNames); - trackAuditCompleted(auditResult, outputMode); - return auditResult; } diff --git a/src/audit/replay.ts b/src/audit/replay.ts index b755541d..6251814d 100644 --- a/src/audit/replay.ts +++ b/src/audit/replay.ts @@ -16,7 +16,13 @@ import type { EvaluationResult } from "../hooks/policy-evaluator"; import { evaluatePolicies } from "../hooks/policy-evaluator"; import { BUILTIN_POLICIES, registerBuiltinPolicies } from "../hooks/builtin-policies"; -import { clearPolicies, normalizePolicyName } from "../hooks/policy-registry"; +import { + clearPolicies, + getAllPolicies, + normalizePolicyName, + setAllPolicies, +} from "../hooks/policy-registry"; +import type { RegisteredPolicy } from "../hooks/policy-types"; import type { SessionMetadata } from "../hooks/types"; import type { NormalizedToolEvent } from "./types"; @@ -29,12 +35,18 @@ const SKIP_POLICIES = new Set( ); let initialized = false; +/** Snapshot of the registry taken at `initReplay()`. Restored by + * `restoreReplay()` so embedding `runAudit()` in a long-running process + * (e.g. the Next.js dashboard) doesn't wipe any prior policy registrations. */ +let savedSnapshot: RegisteredPolicy[] | null = null; /** Register every builtin policy (regardless of user config) so the replay * shows what *could* be caught, not just what's currently enabled. Called - * once per `runAudit` invocation. */ + * once per `runAudit` invocation. Snapshots the existing registry so it can + * be restored by `restoreReplay()` once the audit is done. */ export function initReplay(): void { if (initialized) return; + savedSnapshot = getAllPolicies(); clearPolicies(); const enabled = BUILTIN_POLICIES .map((p) => p.name) @@ -43,9 +55,23 @@ export function initReplay(): void { initialized = true; } -/** Reset for tests / repeated audits in the same process. */ +/** Restore the registry to whatever was there before `initReplay()`. Safe to + * call when not initialized (no-op). Always paired with `initReplay()` in a + * try/finally inside `runAudit()`. */ +export function restoreReplay(): void { + if (!initialized) return; + if (savedSnapshot !== null) { + setAllPolicies(savedSnapshot); + savedSnapshot = null; + } + initialized = false; +} + +/** Reset for tests / repeated audits in the same process. Drops the snapshot + * too — tests usually start with an empty registry and want it back. */ export function resetReplay(): void { initialized = false; + savedSnapshot = null; clearPolicies(); } diff --git a/src/audit/scoring.ts b/src/audit/scoring.ts new file mode 100644 index 00000000..3cabb94b --- /dev/null +++ b/src/audit/scoring.ts @@ -0,0 +1,138 @@ +/** + * Score derivation for the audit dashboard. + * + * Score is on 0-100, mapped to letter grades that anchor the leaderboard + * + tier prose. The thresholds match the reference design (assets/audit): + * + * ≥ 90 S "s tier" + * ≥ 80 A "a tier" + * ≥ 71 B "b tier" + * ≥ 55 C "c tier" + * ≥ 40 D "d tier" + * < 40 F "f tier" + * + * The "projected score" is the hypothetical score after enabling every + * recommended unenabled-builtin policy — used by the prescription section + * to motivate enabling them. + */ +import type { AuditResult } from "./types"; + +export type Grade = "S" | "A" | "B" | "C" | "D" | "F"; + +export function gradeFor(score: number): Grade { + if (score >= 90) return "S"; + if (score >= 80) return "A"; + if (score >= 71) return "B"; + if (score >= 55) return "C"; + if (score >= 40) return "D"; + return "F"; +} + +const TIER_NAME: Record = { + S: "s tier", A: "a tier", B: "b tier", + C: "c tier", D: "d tier", F: "f tier", +}; + +export function tierName(g: Grade): string { + return TIER_NAME[g]; +} + +/** + * Heuristic score. Start at 100 and subtract per-hit penalties weighted by + * severity. Hit-penalty ratios were tuned against the reference defaults + * (58 → C for an agent with a moderate optimist + explorer footprint). + * + * Per-hit penalties: + * deny / block / warn-stop builtin (high severity) -1.2 per hit, max -25 + * instruct / warn builtin (medium) -0.7 per hit, max -15 + * sanitize policies -0.4 per hit, max -10 + * audit-only detector hit -0.5 per hit, max -20 + * + * Floor at 0, cap at 100. Sessions with zero scanned transcripts return 0 + * (no signal, no grade). + */ +export function deriveScore(result: AuditResult): number { + if (result.transcripts.scanned === 0) return 0; + + let score = 100; + let denyPenalty = 0; + let instructPenalty = 0; + let sanitizePenalty = 0; + let detectorPenalty = 0; + + for (const row of result.results) { + if (row.source === "audit-detector") { + detectorPenalty += row.hits * 0.5; + continue; + } + const sev = row.severity; + if (sev === "deny") { + denyPenalty += row.hits * 1.2; + } else if (sev === "instruct" || sev === "warn") { + instructPenalty += row.hits * 0.7; + } else { + // sanitize-* policies report as the underlying decision; treat + // remaining categories (allow-with-reason from sanitize) gently. + sanitizePenalty += row.hits * 0.4; + } + } + + score -= Math.min(denyPenalty, 25); + score -= Math.min(instructPenalty, 15); + score -= Math.min(sanitizePenalty, 10); + score -= Math.min(detectorPenalty, 20); + + return Math.max(0, Math.min(100, Math.round(score))); +} + +/** + * Projected score after enabling every unenabled builtin. Doesn't actually + * re-run the audit — instead it credits back the hits the user would have + * blocked by enabling those policies, applying the same weighted penalty + * scheme used by `deriveScore`. + * + * Caps at 92 so the prescription never promises a guaranteed S — the user + * still has to keep the policies on. + */ +export function projectedScore(result: AuditResult, currentScore: number): number { + // Sum the penalty that would be lifted if every "slipping through" hit + // (unenabled-builtin only — detectors don't have a real-time policy yet) + // moved from `slipping` → `blocked`. + let recoverable = 0; + for (const row of result.results) { + if (row.source !== "builtin") continue; + if (row.enabledInConfig) continue; + if (row.severity === "deny") recoverable += row.hits * 1.2; + else if (row.severity === "instruct" || row.severity === "warn") recoverable += row.hits * 0.7; + else recoverable += row.hits * 0.4; + } + // The caps applied in deriveScore mean recoverable points can't exceed + // the same caps in aggregate. Approximation OK for a "projected" hint. + const proj = Math.min(92, currentScore + Math.round(recoverable)); + return Math.max(currentScore, proj); +} + +/** + * Approximate global rank in the cohort. We don't have a real leaderboard + * yet — this is a deterministic synthetic rank derived from the score so + * the UI doesn't feel jittery as the user re-runs. + * + * Distribution roughly matches a bell-shape centered at 60. Cohort size + * is fixed at 2316 to match the reference design. + */ +export const COHORT_SIZE = 2316; + +export function syntheticRank(score: number): number { + // Roughly: 100 → top of leaderboard, 0 → bottom. Use a smooth curve so + // small score changes feel meaningful but not catastrophic. + const percentile = scoreToPercentile(score); + return Math.max(1, Math.min(COHORT_SIZE, Math.round((1 - percentile) * COHORT_SIZE))); +} + +function scoreToPercentile(score: number): number { + // Logistic mapping centered at 58 — agents below 58 fall into the long + // tail, agents above climb steeply. Anchors the default demo (58 → ~p20). + const z = (score - 58) / 14; + const p = 1 / (1 + Math.exp(-z)); + return p; +} diff --git a/src/audit/strengths.ts b/src/audit/strengths.ts new file mode 100644 index 00000000..d3f09db3 --- /dev/null +++ b/src/audit/strengths.ts @@ -0,0 +1,138 @@ +/** + * Derive the StrengthsSection rows from a live AuditResult. + * + * The reference (assets/audit/audit.jsx) ships 5 hand-curated strengths + * with placeholder numbers. Here we compute each one off the actual + * scanned data. Output shape mirrors the original. + * + * Most strengths are "absences" — the cleaner the agent, the more + * strengths we surface (e.g. "0 credential leaks" only counts as a + * strength when no sanitize-* policies fired). + */ +import type { AuditResult } from "./types"; + +export interface Strength { + metric: string; + unit: string; + headline: string; + detail: string; +} + +function shortName(name: string): string { + const slash = name.indexOf("/"); + return slash >= 0 ? name.slice(slash + 1) : name; +} + +function hitsForShort(result: AuditResult, names: string[]): number { + const set = new Set(names); + let total = 0; + for (const r of result.results) { + if (set.has(shortName(r.name))) total += r.hits; + } + return total; +} + +/** Pick up to 5 derived strengths. Each strength has a true-or-not test — + * only included when the agent actually demonstrates the behavior. */ +export function deriveStrengths(result: AuditResult): Strength[] { + const out: Strength[] = []; + const events = result.eventsScanned ?? 0; + const totalHits = result.totals.hits; + const detectorsTriggered = result.results.filter((r) => r.source === "audit-detector").length; + const cleanRate = events > 0 ? Math.max(0, Math.min(100, Math.round(((events - totalHits) / events) * 100))) : 100; + + // 1. Always show the "X tool calls, Y detectors triggered" headline. + if (events > 0) { + out.push({ + metric: `${cleanRate}%`, + unit: "clean tool calls", + headline: `ran ${events.toLocaleString()} tool calls. ${detectorsTriggered} detector${detectorsTriggered === 1 ? "" : "s"} triggered.`, + detail: `${cleanRate}% of tool calls came back clean before today's audit.`, + }); + } + + // 2. Zero credential exposure to stdout — only when no sanitize-* and no + // block-env-files / block-secrets-write / block-read-outside-cwd hits. + const credentialPolicies = [ + "sanitize-api-keys", "sanitize-jwt", "sanitize-connection-strings", + "sanitize-private-key-content", "sanitize-bearer-tokens", + "block-env-files", "block-secrets-write", "block-read-outside-cwd", + "protect-env-vars", + ]; + if (hitsForShort(result, credentialPolicies) === 0) { + out.push({ + metric: "0", + unit: "credential leaks", + headline: "zero credential exposure to stdout.", + detail: "no env files, secret writes, or sanitize hits across the audit window.", + }); + } + + // 3. Average sessions task length — `events / sessions`. Faster than + // median (50) is celebrated; slower than it is mentioned in findings. + if (result.transcripts.scanned > 0 && events > 0) { + const avgTurns = Math.max(1, Math.round(events / result.transcripts.scanned)); + if (avgTurns < 30) { + out.push({ + metric: String(avgTurns), + unit: "avg turns / session", + headline: `sessions complete in ${avgTurns} turns on average.`, + detail: avgTurns < 15 + ? "faster than the median agent in this cohort." + : "comfortably within the typical session length envelope.", + }); + } + } + + // 4. No retry storms — `warn-repeated-tool-calls` + `sleep-polling-loop` + // are both quiet. + const retryHits = hitsForShort(result, ["warn-repeated-tool-calls", "sleep-polling-loop"]); + if (retryHits === 0) { + out.push({ + metric: "0", + unit: "retry storms", + headline: "no retry storms or polling loops detected.", + detail: "failed calls were diagnosed or moved on from. no six-times-in-a-row spirals.", + }); + } + + // 5. No production-shape git mistakes. + const gitHits = hitsForShort(result, [ + "block-push-master", "block-force-push", "block-work-on-main", + "git-commit-no-verify", + ]); + if (gitHits === 0) { + out.push({ + metric: "0", + unit: "push-to-main attempts", + headline: "kept changes off main without prompting.", + detail: "no direct pushes, force pushes, or hook bypasses across every session.", + }); + } + + // 6. No double-writes / re-reads — agent is efficient with edits. + const wastefulEdits = hitsForShort(result, [ + "reread-after-edit", "prefer-edit-over-sed-awk", "prefer-write-over-heredoc", + ]); + if (wastefulEdits === 0 && events > 0) { + out.push({ + metric: "0", + unit: "double-writes", + headline: "no double-writes across production projects.", + detail: "the agent never re-read a file it had just edited, or rewrote via shell.", + }); + } + + // Cap to 5. If we somehow have <2 strengths, surface a generic "no + // findings in this category" so the section never looks empty. + if (out.length < 2) { + out.push({ + metric: "—", + unit: "audit window", + headline: "audit complete.", + detail: `${result.transcripts.scanned} session${result.transcripts.scanned === 1 ? "" : "s"} scanned across ${result.totals.projectsWithHits} project${result.totals.projectsWithHits === 1 ? "" : "s"}.`, + }); + } + + return out.slice(0, 5); +} diff --git a/src/audit/telemetry.ts b/src/audit/telemetry.ts deleted file mode 100644 index 28c5cfd8..00000000 --- a/src/audit/telemetry.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * PostHog telemetry for `failproofai audit`. - * - * Plugs into the existing `trackHookEvent` surface (src/hooks/hook-telemetry.ts) - * — same distinct ID strategy, same opt-out (`FAILPROOFAI_TELEMETRY_DISABLED=1`), - * same fail-open contract (never crash the CLI). - * - * **Privacy contract — strictly enforced:** - * • Never send transcript paths, decoded project folder names, cwds, - * example command strings, file paths, sessionIds, or tool inputs. - * • Only ever send: policy/detector slugs (already public), counts, - * booleans, bucketed ages, CLI tags, output mode tags. - * - * Events fired during one `audit` run: - * 1. `audit_started` — once, before scanning - * 2. `audit_pattern_detected` — one per AuditCount aggregated - * 3. `audit_install_cta_shown` — once, if there are unenabled builtins - * 4. `audit_completed` — once, at the end - */ -import { trackHookEvent } from "../hooks/hook-telemetry"; -import { getInstanceId } from "../../lib/telemetry-id"; -import type { AuditCount, AuditResult, RunAuditOptions } from "./types"; - -/** Bucketize an ISO timestamp into a coarse "days since" value to avoid - * shipping anything that could correlate with a specific session timing. - * Returns null when unknown. */ -function ageBucketDays(iso: string | undefined): number | null { - if (!iso) return null; - const ms = Date.now() - new Date(iso).getTime(); - if (Number.isNaN(ms) || ms < 0) return null; - const days = Math.floor(ms / 86_400_000); - // Bucket to nearest expected reporting horizon, never raw days - if (days <= 0) return 0; - if (days <= 1) return 1; - if (days <= 7) return 7; - if (days <= 30) return 30; - if (days <= 90) return 90; - if (days <= 365) return 365; - return 366; -} - -function shortName(name: string): string { - const slash = name.indexOf("/"); - return slash >= 0 ? name.slice(slash + 1) : name; -} - -/** Fire-and-forget — telemetry never blocks or fails the CLI. Promises are - * awaited inside trackHookEvent (which has a 5s AbortSignal); callers can - * `void` the return. */ -export function trackAuditStarted(opts: RunAuditOptions, outputMode: string): void { - void trackHookEvent(getInstanceId(), "audit_started", { - clis_requested: opts.clis ?? "all", - since_window: opts.since ?? null, - has_project_filter: !!opts.projects?.length, - has_policy_filter: !!opts.policies?.length, - no_cache: !!opts.noCache, - output_mode: outputMode, - }); -} - -export function trackAuditPatternDetected(count: AuditCount): void { - void trackHookEvent(getInstanceId(), "audit_pattern_detected", { - pattern_name: shortName(count.name), - pattern_source: count.source, - pattern_category: count.category, - hits: count.hits, - projects: count.projects, - enabled_in_config: count.enabledInConfig, - severity: count.severity, - first_seen_age_days: ageBucketDays(count.firstSeen), - last_seen_age_days: ageBucketDays(count.lastSeen), - }); -} - -export function trackAuditInstallCtaShown(unenabledNames: string[]): void { - if (unenabledNames.length === 0) return; - void trackHookEvent(getInstanceId(), "audit_install_cta_shown", { - unenabled_count: unenabledNames.length, - unenabled_pattern_names: unenabledNames.map(shortName), - }); -} - -export function trackAuditCompleted( - result: AuditResult, - outputMode: string, -): void { - const enabledHits = result.results - .filter((r) => r.source === "builtin" && r.enabledInConfig) - .reduce((acc, r) => acc + r.hits, 0); - const unenabledHits = result.results - .filter((r) => r.source === "builtin" && !r.enabledInConfig) - .reduce((acc, r) => acc + r.hits, 0); - const detectorHits = result.results - .filter((r) => r.source === "audit-detector") - .reduce((acc, r) => acc + r.hits, 0); - - void trackHookEvent(getInstanceId(), "audit_completed", { - duration_ms: result.transcripts.durationMs, - transcripts_scanned: result.transcripts.scanned, - transcripts_skipped: result.transcripts.skipped, - transcripts_errors: result.transcripts.errors, - total_hits: result.totals.hits, - projects_with_hits: result.totals.projectsWithHits, - enabled_builtin_hits: enabledHits, - unenabled_builtin_hits: unenabledHits, - audit_detector_hits: detectorHits, - result_count: result.results.length, - clis_scanned: result.scope.cli, - since_window: result.scope.since, - has_project_filter: result.scope.projects !== "all", - output_mode: outputMode, - }); -} diff --git a/src/audit/types.ts b/src/audit/types.ts index d54534c9..33529188 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -127,6 +127,15 @@ export interface TranscriptAuditResult { sessionId: string; mtimeMs: number; sizeBytes: number; + /** Cwd of the session (taken from the first event with a cwd field). + * Empty string when no events carried cwd. Surfaced up to `AuditResult. + * projectsScanned` so the dashboard's project filter can show every + * scanned project, not just those with examples. */ + cwd?: string; + /** Total normalized tool-use events scanned in this transcript. Surfaced + * via `AuditResult.eventsScanned` so the report can show "X tool calls" + * across the whole audit. */ + eventsScanned?: number; /** Per-policy/detector hit count for this one transcript. */ hitsByName: Record; /** Up to 3 example commands per policy/detector (later coalesced upstream). */ @@ -137,7 +146,8 @@ export interface TranscriptAuditResult { /** Top-level result of `runAudit()`. */ export interface AuditResult { - /** Schema version of this JSON shape. Increment on incompatible changes. */ + /** Schema version of this JSON shape. Increment on incompatible changes. + * v2: added `projectsScanned`. */ version: number; scannedAt: string; scope: { @@ -156,6 +166,19 @@ export interface AuditResult { hits: number; projectsWithHits: number; }; + /** Sorted, deduped list of cwds across every transcript that was scanned + * (including those with zero hits). Drives the dashboard's project filter. + * Transcripts without a usable cwd are excluded. */ + projectsScanned: string[]; + /** Total normalized tool-use events the audit walked across every + * scanned transcript. The audit dashboard surfaces this as the + * "X tool calls" headline counter. */ + eventsScanned: number; + /** Short names (without `failproofai/` namespace) of every builtin + * policy that was enabled in the user's merged config at scan time. + * Lets the dashboard answer "is this policy already on?" for + * detector-mapped policies that may not have hit during this audit. */ + enabledBuiltinNames: string[]; } /** CLI-supplied options for `runAudit()`. Set by `bin/failproofai.mjs`. */ diff --git a/src/auth/cli.ts b/src/auth/cli.ts new file mode 100644 index 00000000..7ee7aaba --- /dev/null +++ b/src/auth/cli.ts @@ -0,0 +1,354 @@ +/** + * `failproofai auth` CLI surface. + * + * failproofai auth login Email + OTP flow; writes ~/.failproofai/auth.json + * failproofai auth logout Wipe auth.json (best-effort server revoke) + * failproofai auth whoami Print the cached identity (or "not signed in") + * failproofai auth help Usage + * + * Source of truth is the local cache (~/.failproofai/auth.json). Server-side + * validation is intentionally avoided — once a token is on disk we trust it. + * That keeps `login`, `logout`, and `whoami` consistent with each other and + * with the dashboard, even when the api-server is unreachable. + */ + +import * as readline from "node:readline"; + +import { + AuthApiError, + getApiBase, + logoutSession, + requestLoginCode, + verifyLoginCode, +} from "../../lib/auth/api-server-client"; +import { + authFromTokenResponse, + deleteAuth, + readAuth, + writeAuth, +} from "../../lib/auth/auth-store"; +import { CliError } from "../cli-error"; +import { trackHookEvent } from "../hooks/hook-telemetry"; +import { getInstanceId } from "../../lib/telemetry-id"; + +interface AuthCliOptions { + mode: "login" | "logout" | "whoami" | "help"; +} + +const HELP = ` +failproofai auth — sign in to FailproofAI from the CLI + +USAGE + failproofai auth login Start the email + OTP login flow + failproofai auth logout Remove ~/.failproofai/auth.json + failproofai auth whoami Print the currently signed-in identity + failproofai auth help Show this help (also: --help, -h) + +ENVIRONMENT + FAILPROOF_API_URL Override the api-server base URL + (default: https://api.befailproof.ai) + FAILPROOFAI_AUTH_DIR Override where auth.json is stored + (default: ~/.failproofai) + +EXAMPLES + failproofai auth login + failproofai auth whoami + failproofai auth logout +`.trimStart(); + +/** Deprecated `--login` / `--logout` / `--whoami` flags map back to subcommands + * so shell history and older docs keep working silently. */ +const LEGACY_FLAG_TO_SUB: Record = { + "--login": "login", + "--logout": "logout", + "--whoami": "whoami", +}; + +const SUBCOMMANDS = new Set(["login", "logout", "whoami", "help"]); + +export function parseAuthArgs(args: string[]): AuthCliOptions { + if (args.includes("--help") || args.includes("-h")) return { mode: "help" }; + + const positional: string[] = []; + const legacy: ("login" | "logout" | "whoami")[] = []; + for (const a of args) { + if (a === "--help" || a === "-h") continue; + if (a in LEGACY_FLAG_TO_SUB) { + legacy.push(LEGACY_FLAG_TO_SUB[a]); + continue; + } + if (a.startsWith("-")) { + throw new CliError( + `Unknown flag for auth: ${a}\nRun \`failproofai auth help\` for usage.`, + ); + } + positional.push(a); + } + + const subs = [...positional, ...legacy]; + if (subs.length === 0) return { mode: "help" }; + if (subs.length > 1) { + throw new CliError( + `Pick one of login, logout, whoami.\nRun \`failproofai auth help\` for usage.`, + ); + } + const sub = subs[0]; + if (!SUBCOMMANDS.has(sub)) { + throw new CliError( + `Unknown auth subcommand: ${sub}\nRun \`failproofai auth help\` for usage.`, + ); + } + return { mode: sub as AuthCliOptions["mode"] }; +} + +function prompt(question: string, opts: { hidden?: boolean } = {}): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + if (opts.hidden && process.stdin.isTTY) { + const r = rl as unknown as { + _writeToOutput: (s: string) => void; + output: NodeJS.WritableStream; + }; + const orig = r._writeToOutput.bind(rl); + r._writeToOutput = (s: string): void => { + if (s.length > 0 && s !== "\r\n" && s !== "\n") orig("*"); + else orig(s); + }; + } + return new Promise((resolve) => { + rl.question(question, (answer: string) => { + rl.close(); + if (opts.hidden && process.stdin.isTTY) process.stdout.write("\n"); + resolve(answer.trim()); + }); + }); +} + +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; +const PINK = "\x1b[38;5;204m"; +const GREEN = "\x1b[38;5;120m"; +const RED = "\x1b[38;5;197m"; + +function emailLooksValid(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +async function runLogin(): Promise { + const existing = readAuth(); + if (existing) { + // Treat the on-disk session as valid only when its refresh window hasn't + // already lapsed locally. We don't hit /me here (the file header explicitly + // avoids server validation so login/logout/whoami stay coherent offline), + // but the local exp claim is enough to recognise an obviously-stale file + // and let the user re-authenticate instead of bouncing them out. + const nowSecs = Math.floor(Date.now() / 1000); + const refreshUsable = existing.refresh_expires_at > nowSecs; + if (refreshUsable) { + void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + source: "cli", + status: "already_signed_in", + user_id: existing.user.id, + }); + process.stdout.write( + `${DIM}already signed in as${RESET} ${existing.user.email} ${DIM}(use \`failproofai auth logout\` to switch accounts)${RESET}\n`, + ); + return; + } + process.stdout.write( + `${DIM}stored session for ${existing.user.email} has expired — re-authenticating.${RESET}\n`, + ); + // Overwrite cleanly so a half-broken file doesn't survive next startup. + deleteAuth(); + } + void trackHookEvent(getInstanceId(), "audit_cli_auth_login_started", { + source: "cli", + api_base: getApiBase(), + replaced_stale: existing !== null, + }); + + process.stdout.write(`${PINK}━━ failproofai auth ━━${RESET}\n`); + process.stdout.write(`${DIM}api: ${getApiBase()}${RESET}\n\n`); + + let email = ""; + for (let attempt = 0; attempt < 3; attempt++) { + email = await prompt("email: "); + if (emailLooksValid(email)) break; + process.stdout.write(`${RED}that doesn't look like an email — try again.${RESET}\n`); + email = ""; + } + if (!email) { + void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + source: "cli", + status: "aborted_invalid_email", + }); + throw new CliError("Could not read a valid email after 3 attempts."); + } + + try { + const r = await requestLoginCode(email); + void trackHookEvent(getInstanceId(), "audit_otp_requested", { + source: "cli", + status: "success", + expires_in: r.expires_in, + resend_available_in: r.resend_available_in, + }); + process.stdout.write( + `\n${GREEN}code sent.${RESET} ${DIM}check ${email} — expires in ${r.expires_in}s.${RESET}\n`, + ); + } catch (err) { + const isApi = err instanceof AuthApiError; + void trackHookEvent(getInstanceId(), "audit_otp_requested", { + source: "cli", + status: "failed", + error_code: isApi ? err.code : "upstream_unreachable", + http_status: isApi ? err.status : null, + }); + if (isApi && err.code === "rate_limited") { + throw new CliError( + `Rate limited — try again in ${err.retryAfterSecs ?? "a few"} seconds.`, + ); + } + if (isApi) { + throw new CliError(`Login request failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.\n` + + `Check your network, or set FAILPROOF_API_URL to point at a different host.`, + ); + } + + let tokenResp; + let verifyAttempts = 0; + for (let attempt = 0; attempt < 5; attempt++) { + const code = await prompt("code: ", { hidden: true }); + if (!code) continue; + verifyAttempts += 1; + try { + tokenResp = await verifyLoginCode(email, code); + break; + } catch (err) { + const isApi = err instanceof AuthApiError; + void trackHookEvent(getInstanceId(), "audit_otp_verified", { + source: "cli", + status: "failed", + attempt: verifyAttempts, + error_code: isApi ? err.code : "upstream_unreachable", + http_status: isApi ? err.status : null, + }); + if (isApi && err.status === 401) { + process.stdout.write(`${RED}code rejected — try again.${RESET}\n`); + continue; + } + if (isApi) { + throw new CliError(`Verify failed (${err.code}): ${err.message}`); + } + throw new CliError( + `Could not reach the api-server at ${getApiBase()}.`, + ); + } + } + if (!tokenResp) { + void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + source: "cli", + status: "exhausted_attempts", + attempts: verifyAttempts, + }); + throw new CliError("Too many bad codes — start over."); + } + + writeAuth(authFromTokenResponse(tokenResp)); + void trackHookEvent(getInstanceId(), "audit_otp_verified", { + source: "cli", + status: "success", + attempt: verifyAttempts, + user_id: tokenResp.user.id, + }); + // Bridge the anonymous local instance ID to the server-issued user identity. + // PostHog can stitch together "anonymous machine X did Y" events emitted + // before sign-in with "user Z" events that follow, by joining on + // `local_random_id`. The dashboard's /api/auth/login-verify emits the same + // event with `source: "audit_set_reminder_auth_dialog"`; this is the CLI + // sibling — without it, anyone who signs in via `failproofai auth login` + // stays unjoined to their pre-auth events. + void trackHookEvent(getInstanceId(), "audit_user_identity_linked", { + source: "cli", + user_id: tokenResp.user.id, + local_random_id: getInstanceId(), + }); + void trackHookEvent(getInstanceId(), "audit_cli_auth_login_completed", { + source: "cli", + status: "success", + attempts: verifyAttempts, + user_id: tokenResp.user.id, + }); + process.stdout.write( + `\n${GREEN}✓ signed in as ${tokenResp.user.email}${RESET}\n` + + `${DIM}session saved to ~/.failproofai/auth.json (mode 0600)${RESET}\n`, + ); +} + +async function runLogout(): Promise { + const existing = readAuth(); + if (!existing) { + void trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { + source: "cli", + had_session: false, + upstream: "noop", + }); + process.stdout.write(`${DIM}not signed in. nothing to do.${RESET}\n`); + return; + } + // Best-effort server revoke — failure does not block the local wipe. + let upstream: "revoked" | "failed" = "revoked"; + try { + await logoutSession(existing.access_token, existing.refresh_token); + } catch (err) { + if (err instanceof AuthApiError && err.status === 401) { + // already invalid server-side + } else { + upstream = "failed"; + } + } + deleteAuth(); + void trackHookEvent(getInstanceId(), "audit_cli_auth_logout_completed", { + source: "cli", + had_session: true, + upstream, + user_id: existing.user.id, + }); + process.stdout.write( + `${GREEN}✓ signed out as ${existing.user.email}.${RESET}\n`, + ); +} + +function runWhoami(): void { + const existing = readAuth(); + if (!existing) { + void trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { + source: "cli", + authenticated: false, + }); + process.stdout.write(`${DIM}not signed in — run \`failproofai auth login\` to sign in.${RESET}\n`); + process.exitCode = 1; + return; + } + void trackHookEvent(getInstanceId(), "audit_cli_auth_whoami", { + source: "cli", + authenticated: true, + user_id: existing.user.id, + }); + process.stdout.write( + `${GREEN}✓${RESET} ${existing.user.email} ${DIM}(${existing.user.id})${RESET}\n`, + ); +} + +export async function runAuthCli(args: string[]): Promise { + const opts = parseAuthArgs(args); + if (opts.mode === "help") { + process.stdout.write(HELP); + return; + } + if (opts.mode === "login") return runLogin(); + if (opts.mode === "logout") return runLogout(); + return runWhoami(); +} diff --git a/src/hooks/policy-registry.ts b/src/hooks/policy-registry.ts index a0a57a4a..d417f80e 100644 --- a/src/hooks/policy-registry.ts +++ b/src/hooks/policy-registry.ts @@ -105,3 +105,23 @@ export function clearPolicies(): void { g[REGISTRY_KEY] = []; setIndexCache(null); } + +/** + * Snapshot the current registry. Returns a shallow copy so callers can hold + * a stable reference while the registry is mutated by other code paths + * (notably the audit replay engine, which clears the registry to load only + * builtins). + */ +export function getAllPolicies(): RegisteredPolicy[] { + return [...getRegistry()]; +} + +/** + * Replace the registry wholesale. Pair with `getAllPolicies()` to take a + * snapshot before destructive operations and restore it afterwards. + */ +export function setAllPolicies(policies: RegisteredPolicy[]): void { + const g = globalThis as GlobalWithRegistry; + g[REGISTRY_KEY] = [...policies]; + setIndexCache(null); +}