diff --git a/.bunfig.toml b/.bunfig.toml deleted file mode 100644 index fc4c176f6..000000000 --- a/.bunfig.toml +++ /dev/null @@ -1,3 +0,0 @@ -[install] -# Use exact versions for reproducibility -exact = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..4f1c97dfd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,48 @@ +# Generated and vendored content, marked so GitHub's Linguist stops counting it. +# +# Why this file exists: 658 of this repo's ~1,428 tracked files — 46% — are +# machine-generated translations regenerated by .github/workflows/translate-docs.yml. +# Unmarked, they dominate the language bar, expand by default in every pull +# request, and make a two-line change to an English page look like a 600-file +# diff. `linguist-generated` collapses each one to a single line in the diff +# view and drops it from the language statistics. Nothing about the build, +# the tests, or the published package changes — this is git metadata only. +# +# The 14 language codes below are the same set as LANGUAGES in +# scripts/translate-docs/config.ts. __tests__/scripts/translate-docs/config.test.ts +# asserts the two lists agree, so adding a language to the translator without +# adding it here fails CI rather than silently un-collapsing a new locale. + +# --- Translated documentation (regenerated; edit the English source instead) --- +docs/ar/** linguist-generated=true +docs/de/** linguist-generated=true +docs/es/** linguist-generated=true +docs/fr/** linguist-generated=true +docs/he/** linguist-generated=true +docs/hi/** linguist-generated=true +docs/it/** linguist-generated=true +docs/ja/** linguist-generated=true +docs/ko/** linguist-generated=true +docs/pt-br/** linguist-generated=true +docs/ru/** linguist-generated=true +docs/tr/** linguist-generated=true +docs/vi/** linguist-generated=true +docs/zh/** linguist-generated=true + +# --- Translated READMEs (regenerated from the root README.md) --- +docs/i18n/README.*.md linguist-generated=true + +# --- Lockfiles: resolver output, never hand-edited --- +bun.lock linguist-generated=true +Cargo.lock linguist-generated=true + +# --- Release history: append-only, and the single noisiest file in any diff --- +CHANGELOG.md linguist-generated=true + +# --- The audit design lab: a standalone reference kit, not application source. +# Its .jsx/.html/.css are read by humans and by nothing else in the build. --- +assets/audit/** linguist-vendored=true + +# --- Keep shell scripts and .mjs hooks executable-safe across platforms --- +*.sh text eol=lf +*.mjs text eol=lf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..ddca61ec4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,158 @@ +# Contributing to Failproof AI + +## Start here + +Every top-level directory now carries its own `README.md` — `src/`, `app/`, `lib/`, +`crates/`, `bin/`, `scripts/`, `docs/`, `examples/`, `__tests__/`, `assets/`, +`public/`, `pi-extension/`, `openclaw-plugin/`, `integration-suite/`, +`docker-hook-sync/`, `skills/`. Read the one for the directory you are about to +change before you change it. `CLAUDE.md` at the repo root (1,200+ lines) is the +de-facto architecture manual — every per-CLI hook contract is documented there and +nowhere else. + +## Five products, one package.json + +This is not one codebase. It is five, sharing a lockfile: + +| Product | Lives in | What it is | +|---------|----------|------------| +| CLI | `src/hooks/`, `src/audit/`, `bin/`, parts of `lib/` | Installs hook configs into 12 agent CLIs; evaluates policies when those hooks fire. Also the `failproofai audit` product. | +| Dashboard | `app/`, `public/`, `proxy.ts`, `instrumentation.ts`, `next.config.ts`, most of `lib/` | Next.js 16 app router — session viewer, policy config, audit results. | +| Daemon | `crates/` (`failproofaid`, `fpai-collect`, `fpai-ipc`) | ~41k lines of Rust. Socket server, service lifecycle, worker supervision, and session collectors for 12 CLIs. | +| Docs site | `docs/` | Mintlify. 48 hand-written English `.mdx`; the other 644 are machine translations regenerated by `.github/workflows/translate-docs.yml`. `docs/agenteye/` is a **different product's** docs sharing the site. | +| Plugin packages | `pi-extension/`, `openclaw-plugin/` | Static packages shipped inside the npm tarball (`"files"` in `package.json`). Their directory names are frozen — installed users' settings files reference them by path. | + +The 12 supported agent CLIs: `claude`, `codex`, `copilot`, `cursor`, `opencode`, +`pi`, `hermes`, `openclaw`, `factory`, `devin`, `antigravity`, `goose`. + +On a machine that finished `failproofai config`, the **daemon is the only +evaluator** and an unreachable daemon **denies**. In-process evaluation survives +only where `daemonConfigured` is false — including this repo's own dogfood configs. + +## Prerequisites + +- **Bun >= 1.3.0** — required, not optional. `bun install` runs `prepare` → + `bun run build`, which shells out to `bun build`. There is no Node-only setup path. +- **Node.js >= 20.9.0** — what the published package targets, and what runs the + in-repo dev hooks' launcher. +- **Rust** (`rust-toolchain.toml` pins it) only if you touch `crates/`. + +## Development setup + +```bash +git clone https://github.com/failproofai/failproofai.git +cd failproofai +bun install # runs `prepare` → `bun run build` (dist/ + a full Next.js build) +bun run dev # dev server at http://localhost:8020 +``` + +`bun install --frozen-lockfile --ignore-scripts` skips that `prepare` build. It is +what the `rust-quality` CI job uses, and it is the fast install when you only need +`node_modules` (Rust work, docs work) and not `dist/`. Nothing else documents it. + +**`bun run dev` and `bun run start` are not read-only.** Their `predev`/`prestart` +scripts run `bun run build:cli && bun link`, which symlinks a global `failproofai` +binary pointing at this working copy. If a `failproofai` on your PATH starts +behaving like your branch, that is why. `bun unlink` in the repo root undoes it. + +### Build before the in-repo dev hooks will work + +This repo **dogfoods failproofai on itself**, and the dot-directories at the root +are how. They are not all the same thing, and the difference matters: + +| | Directories | How enforcement is wired | +|---|---|---| +| **Shell hooks** | `.claude/` `.codex/` `.cursor/` `.devin/` `.factory/` `.agents/` `.github/hooks/` | each config runs `node scripts/dev-hook.mjs --hook --cli ` | +| **In-process plugins** | `.opencode/` `.pi/` | OpenCode and Pi have no shell-hook system, so these register a plugin instead — `.opencode/plugins/failproofai.mjs` and `.pi/settings.json` pointing at `../pi-extension`. Neither names `dev-hook.mjs`. | +| **Policy files** | `.failproofai/` | not hooks at all — the policy config and the convention policies this repo enforces on itself | + +The shell-hook configs use `dev-hook.mjs` and **never `npx -y failproofai`** — the +`npx` form self-references the package being developed here. `internals/dogfood.md` +explains why each vendor's path is what it is. + +> Do **not** run `failproofai policies --install` inside this repo. It rewrites +> those configs to the production `npx -y failproofai` form and breaks dogfooding. +> `__tests__/hooks/dogfood-configs.test.ts` is the tripwire that catches it. + +`scripts/dev-hook.mjs` locates `bun` (across `PATH`, `$BUN_INSTALL/bin`, +`~/.bun/bin`, Homebrew, and every `~/.nvm/versions/node/*/bin`), builds +`dist/index.js` if it is missing, then hands off to `bin/failproofai.mjs`. See its +header, and the CLAUDE.md section, for why node fronts a bun-only binary. The +policies in `.failproofai/policies/*.mjs` `import` the `failproofai` package, which +resolves against the compiled **`dist/index.js`** bundle — not your live `src/`. + +If `dist/` is missing the launcher rebuilds it and says so on stderr. If it is +**stale**, nothing detects that and you get policies enforcing yesterday's code. +Rebuild whenever you change `src/`: + +```bash +bun run build # full build (dist/index.js + dist/cli.mjs + dist/worker.mjs + Next.js) +# …or just the hook bundle, much faster while iterating on policies: +bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts +``` + +## The files you touch + +| Task | Files | +|------|-------| +| Add a builtin policy | `src/hooks/builtin-policies.ts` (define it, `registerPolicy`), `src/hooks/policy-presets.ts` (which preset ships it on), `__tests__/hooks/builtin-policies.test.ts`, `docs/built-in-policies.mdx` | +| Add support for a new agent CLI | ~28 files. Enforcement: `src/hooks/integrations.ts`, `types.ts` (event/tool/input maps), `handler.ts`, `policy-evaluator.ts`, `enforcement-capability.ts`, `normalize-cli-payload.ts`, `tool-name-canonicalize.ts`, `resolve-transcript-path.ts`. Audit: `src/audit/cli-adapters/.ts` + `index.ts`, `lib/-sessions.ts`, `lib/-projects.ts`, `lib/projects.ts`, `lib/cli-registry.ts`, `lib/download-session.ts`. Daemon: `crates/fpai-collect/src/sources//{mod,transform}.rs`, `sources/mod.rs`, `crates/failproofaid/src/main.rs`. Plus `assets/logos/-{dark,light}.svg`, the dogfood config, `scripts/dev-hook.mjs`, `integration-suite/`, and a canonicalize test per CLI. It is three products at once — that is why it is 28 files, not four. | +| Fix a dashboard bug | The route under `app/` (e.g. `app/project/[name]/page.tsx`), its server action in `app/actions/`, the parser in `lib/` it reads from, and a test in `__tests__/components/` or `__tests__/lib/` | +| Change a CLI's hook verdict shape | `src/hooks/policy-evaluator.ts` (the `cli === ""` branch), `src/hooks/enforcement-capability.ts` (the machine-readable capability table), `__tests__/hooks/inert-deny-shapes.test.ts`, `__tests__/hooks/enforcement-capability.test.ts` | +| Add a daemon session collector | `crates/fpai-collect/src/sources//{mod.rs,transform.rs}`, `crates/fpai-collect/src/sources/mod.rs`, `crates/failproofaid/src/main.rs` (task wiring + the harness-key list), `crates/fpai-collect/tests/_source.rs` | +| Edit the documentation | The English `.mdx` under `docs/` **only** — never a file under `docs/{ar,de,es,fr,he,hi,it,ja,ko,pt-br,ru,tr,vi,zh}/`, which `translate-docs.yml` regenerates. Add new pages to `docs/docs.json` nav or `mintlify validate` fails. | + +Never edit a language directory by hand, and never rename `pi-extension/` or +`openclaw-plugin/`. + +## Available scripts + +| Script | Description | +|--------|-------------| +| `bun run dev` | Dev server on :8020 (also `bun link`s a global binary) | +| `bun run build` | Full build: `dist/index.js`, `dist/cli.mjs`, `dist/worker.mjs`, Next.js standalone | +| `bun run lint` | ESLint | +| `bunx tsc --noEmit` | Type-check | +| `bun run test:run` | Vitest once (`bun run test` for watch) | +| `bun run test:e2e` | E2E suite (`vitest.config.e2e.mts`) | +| `bun run validate:mdx` | Parse every MDX page and resolve image references | +| `cargo test --workspace` | Rust tests — spawns the real TS worker via `bun`, so `bun install` first | + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `CLAUDE_PROJECTS_PATH` | Path to Claude projects directory | +| `FAILPROOFAI_LOG_LEVEL` | `info`, `warn`, `error` (default `warn`) | +| `FAILPROOFAI_TELEMETRY_DISABLED` | `1` disables anonymous telemetry | +| `FAILPROOFAI_DISABLE_PAGES` | Comma-separated: `policies`, `projects` | +| `FAILPROOFAI_DIST_PATH` | Where custom policies resolve `failproofai` from | +| `FAILPROOFAI_NO_DOWNLOAD` | `1` blocks fetching the daemon binary (air-gapped) | + +## Pull requests + +1. **One PR per branch.** Check `gh pr list --head ` first; if one exists, + push to the same branch. +2. **Your branch must contain all of `main`.** `git fetch origin && + git log --oneline origin/main ^HEAD` must print nothing; rebase if it does not. +3. **Update `CHANGELOG.md`.** Every PR. One line under the current + `## ` heading, in Features / Fixes / Docs / + Dependencies. There is no `Unreleased` section. +4. **Add tests for new behaviour** in `__tests__/`. Do not edit an existing test to + make it pass — fix the code instead. The exception is a test asserting the exact + value you intentionally changed. +5. **CI must be green after every push.** Six jobs in `.github/workflows/ci.yml`: + `quality` (lint + tsc + version consistency, including `Cargo.toml` against root + `package.json`), `rust-quality` (fmt + clippy + `cargo test --workspace`), + `test` (unit, three env configs), `build`, `test-e2e`, `docs`. Locally: + + ```bash + bun run lint && bunx tsc --noEmit && bun run test:run && bun run build && bun run test:e2e + ``` + + `gh run watch` after pushing. Never leave CI red. + +## Reporting issues + +[Open an issue](https://github.com/failproofai/failproofai/issues) — the templates +ask for the details we need. diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 96% rename from SECURITY.md rename to .github/SECURITY.md index 24ba3dc7b..09012e300 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -26,7 +26,7 @@ for supply-chain threats before it can merge, via two complementary layers. ### 1. OSV-Scanner — the blocking CI gate -[`.github/workflows/osv-scanner.yml`](.github/workflows/osv-scanner.yml) runs +[`.github/workflows/osv-scanner.yml`](workflows/osv-scanner.yml) runs [OSV-Scanner](https://google.github.io/osv-scanner/) against the resolved dependency tree (`bun.lock`). It checks every direct and transitive package against [OSV.dev](https://osv.dev), which aggregates GitHub/npm security @@ -55,7 +55,7 @@ When the OSV-Scanner gate fails on a PR: 1. **Prefer fixing it.** Bump the affected dependency to a patched version. For a transitive dependency that a parent pins to a vulnerable version, add a minimal - [`overrides`](package.json) entry (as we do for `postcss`) and let CI validate + [`overrides`](../package.json) entry (as we do for `postcss`) and let CI validate the build. 2. **Only if there is no fix**, add a justified, time-boxed entry to [`osv-scanner.toml`](osv-scanner.toml) (`id`, `reason`, `ignoreUntil`). Never diff --git a/osv-scanner.toml b/.github/osv-scanner.toml similarity index 100% rename from osv-scanner.toml rename to .github/osv-scanner.toml diff --git a/.github/smoke-test/expected/policies.html b/.github/smoke-test/expected/policies.html deleted file mode 100644 index 389b21108..000000000 --- a/.github/smoke-test/expected/policies.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/.github/smoke-test/expected/projects.html b/.github/smoke-test/expected/projects.html deleted file mode 100644 index 7eb913f26..000000000 --- a/.github/smoke-test/expected/projects.html +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ee4b39f5..fcd96f192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,13 +48,6 @@ jobs: MISMATCH=1 fi done - # Check optionalDependencies in wrapper - for dep_version in $(jq -r '.optionalDependencies // {} | values[]' packages/wrapper/package.json 2>/dev/null || true); do - if [ "$dep_version" != "$ROOT_VERSION" ]; then - echo "::error file=packages/wrapper/package.json::Dependency version mismatch: $dep_version, expected $ROOT_VERSION" - MISMATCH=1 - fi - done # The daemon binaries DO ship as npm platform packages # (@failproofai/failproofaid--), but their pins are injected # into package.json at publish time by diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 742bf0e39..1b380cee0 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -18,8 +18,10 @@ # without needing a second channel. Posting is itself optional: it silently # no-ops when the SLACK_WEBHOOK_URL repository secret isn't set. # -# Triage / allow-listing unfixable advisories: see SECURITY.md and osv-scanner.toml -# (auto-loaded from the repo root by OSV-Scanner). +# Triage / allow-listing unfixable advisories: see .github/SECURITY.md and +# .github/osv-scanner.toml. OSV-Scanner only auto-discovers a config sitting in +# the SCANNED file's own directory — it does not walk parents — so the config +# lives next to this workflow and is passed explicitly via `--config` below. # # Third-party actions are pinned to a commit SHA (we're a supply-chain tool — # practice what we preach). @@ -59,6 +61,7 @@ jobs: uses: google/osv-scanner-action/osv-scanner-action@f4cfcc01edc9c8b756a9b873b7a623ca674da51e # v2.3.8 with: scan-args: |- + --config=.github/osv-scanner.toml --lockfile=bun.lock --lockfile=Cargo.lock # Only the schedule run notifies — nothing on main touched the lockfile, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9081affee..cc0ad26f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.1-beta.0 — 2026-08-12 +## 1.0.1-beta.0 — 2026-08-14 ### Features @@ -12,17 +12,47 @@ - Give cron one short line per job. A crontab entry must be a SINGLE line — the format has no continuation — so the docker invocation could not be wrapped, which made each entry ~350 characters: unreadable in a crontab, and mangled by every chat client it was pasted through on the way to whoever sets the box up. `integration-suite/local/run-job.sh` now holds the invocation and the crontab reads `$HOME/fp-canary/run.sh canary`. It also OWNS ITS OWN LOG, which closes a real trap: cron evaluates a `>>` redirect BEFORE the command runs, so a missing `logs/` directory meant the job silently never started — and the container could not create the directory its own redirect needed. mkdir then redirect, in that order. (#694) -### Dependencies - -- Pin `nanoid` to 3.3.18 through `overrides`, closing GHSA-2v37-7h3g-55p8 (CVSS 8.2 — custom generators can loop indefinitely when size is zero). Not introduced here: the lockfile is untouched by this branch, `main` passed the same scan at 04:57 and this branch failed at 16:21, because the advisory's affected range was published in between. It arrives transitively through `postcss`, which asks for `^3.3.17`, so the pin satisfies it without moving anything else — two lines of lockfile, 657 entries before and after. An `overrides` pin rather than an `osv-scanner.toml` ignore because that file's own rule is to prefer fixing, and there is a fix. (#694) +- Add `__tests__/ci/tarball-surface.test.ts` and `__tests__/ci/standalone-prune.test.ts`, the first tests in this repo that check what users RECEIVE rather than what the repo contains. The tarball test pins the paths an installed user depends on (`dist/cli.mjs`, the daemon shim, the two frozen plugin package directories) and asserts no over-traced repo content reached `.next/standalone`; the prune test cross-checks `scripts/prune-standalone.mjs`'s hand-maintained denylist against every tracked top-level entry, so a newly added directory fails CI instead of silently shipping. Both are written as invariants rather than file-list snapshots, because a 1,775-path snapshot gets updated reflexively and stops being a tripwire. The prune test found the dogfood-config leak above on its first run. (#696) ### Fixes - Stop the canary reporting an agent's workaround as broken enforcement. antigravity failed probe B three runs straight, and it was never an enforcement bug: recorded live against agy 1.1.11, `view_file` delivers `AbsolutePath` — which `ANTIGRAVITY_TOOL_INPUT_MAP` already carries — and a deny on it IS honoured (`tool call denied with reason`, sentinel never reaching the model). What actually happened is that `canary-read` identifies the marker by SUBSTRING on the command text. Denied on `cat …/CANARY_MARKER.txt`, the agent retried with `cat …/CANARY_MA*`: the same file, read by a string that no longer contains the matched substring, so the shell expanded the glob and the sentinel landed in the transcript — where a leaked sentinel deliberately outranks our own log claiming a deny. Widening the match closed that family (`CANARY*` globs, and the `cat *` case that names nothing at all) and a later run leaked by yet another route, which is the point: the ways to read a file with a shell are not enumerable. So probe B now tells the two situations apart instead of trying to prevent one of them. A second policy, `canary-read-shell`, denies shell file-reads DURING THE READ PROBE ONLY — identified from the per-probe oracle dir (`FAILPROOFAI_HOOK_LOG_FILE` ends `log-read`), the one per-probe signal a policy can read, since the daemon wire protocol carries no env — and its separate name means a deny under it can never satisfy `read_denied` and score a PASS. A leak that arrives WHILE those shell reads are being denied is now INCONCLUSIVE (unproven) rather than FAIL (broken). The exception is deliberately narrow: a leak with NO shell attempt is still a FAIL, because that is exactly what a CLI ignoring our deny looks like (copilot 1.0.70), and blurring the two would blind this suite to the silent-allow it exists to catch. `read_denied`'s grep grew a trailing space for the same reason — without it `canary-read` also matches the `canary-read-shell` line. Navigation (`ls`, `pwd`, `find` without an `-exec` read) stays allowed, since several CLIs locate the file before reading it and denying that would push CLIs that pass today into INCONCLUSIVE for no gain. Verified: claude and codex still PASS both probes with the detector active, and all six verdict combinations were exercised against the real shell functions. (#694) - Make the canary box a one-command install. Setting it up was four commands, and three of them fail SILENTLY for a day — the wrong property for the thing whose whole job is noticing silent failures. A work dir mounted at a different path inside the container than out leaves the sibling-container `-v` sources resolving against the host to nothing; a `CANARY_REF` left at the shipped `origin/failproofaid` points the box at a branch that merged in #632, so it would test a frozen tree forever and never say so; and a filled-in env file with no Slack webhook produces a run that works perfectly and reports nowhere, which is worse than no canary because it looks like coverage. `integration-suite/local/install.sh` refuses each at install time, in front of a person, rather than at 06:17 tomorrow in front of nobody — the webhook is required for that reason, not because the run needs it. It builds the runner image straight from the git URL (Docker takes `#:` as a build context) so the box never clones, installs the env file at mode 600, and REWRITES rather than appends its cron line — it carries a `# failproofai-canary` marker and strips any previous line first, so re-running upgrades the schedule instead of scheduling a second job. No credentials template ships in the repo at all — a file that looks like a credentials file is one `git add -A` away from being committed by whoever fills it in — so running the installer with no arguments prints the variable list instead, generated from the same `REQUIRED_` lists it enforces and therefore unable to drift the way a checked-in example silently does. `--dry-run` distinguishes what it CHECKED (the preflight really runs; it keeps its ✓) from what it would CHANGE, because a script reporting success for work it did not do is the same defect class this canary exists to find. (#686) + - Stop the nightly doc translation re-translating everything, most days. Runs cost **4 minutes** on Aug 3-5 and **118-136 minutes** every day from Aug 6-11 — ~750 wasted runner-minutes and six full-corpus passes through the LLM gateway in six days. Three causes compound, and none of them was the translation cache's own logic, which is sound. **First, the cache was being evicted between runs.** `ci.yml` cached `target/` under a combined `actions/cache@v6`, so every PR ref that missed the exact key wrote its own 1.5-2.3 GiB copy; five were live at once (#677, #679, #680, #681 and main), putting the repo at **11.56 GiB against GitHub's 10 GiB cap** and so permanently in LRU eviction. What that evicted was the 13 KB translation cache — touched once every 24 hours, therefore always the least-recently-used thing in the store. The restore/save split is the one `build-daemon.yml:117-144` already uses, and its comment there already gives the second reason to want it. **Second, the cache was saved once, at the end of a serial pipeline.** The only save sat in `consolidate`, downstream of both the matrix gate and `mintlify validate`, so a single page failing in a single language discarded all fourteen languages' work: Aug 6 lost ~110 completed minutes to one `ko` page. Each language now saves its own fragment in the job that produced it, immediately after the step that proved it good; the merged entry stays as a cross-language fallback. **Third, a cache HIT never checked that the translated file exists.** `isCached` is a pure function of the English source hash — it records that a page was translated once, not that it is on disk — and translations land on an auto-translate PR branch. With #682 unmerged, `main` lacked `docs//cli/{update,migrate}.mdx` while the cache reported them done, so they were never regenerated, `--update-nav` (which reads the *English* tree) emitted nav entries pointing at them, and `mintlify validate` failed on 28 missing files. That is non-convergent: **a cache hit fails validation and only a full 120-minute miss goes green**, which is exactly what Aug 12 did. Statting the output makes the cache self-healing against any "translated once, never landed" gap. Also: a cache miss is now a visible `::warning` rather than silent — the old restore key always evaluated to the bare literal `translation-cache-`, since the file is gitignored and `hashFiles` returns `""` for an absent path, so every restore that ever worked was a prefix fallback and a total miss looked identical to a hit. Artifact retention goes 1 → 7 days so a run that dies mid-pipeline leaves a manual recovery path. (#685) +- Delete eight dead files a repo survey confirmed unreachable, each verified by an exhaustive reference search rather than a missing import. `src/audit/report.ts` (348 lines) rendered the `--report`/`--json`/`--limit`/`--show-examples` output for flags `runAuditCli()` has rejected since the dashboard flow replaced it — and `package.json` `files[]` ships `src/`, so it was published in every tarball. `lib/claude-config.ts` was a re-export wrapper around `lib/paths.ts` whose only mention anywhere was its own JSDoc example. `lib/extract-subagent-ids.ts` was imported by nothing but its own test; the live path re-implements the regex inline in `log-entries.ts`, whose comment now describes the id format instead of pointing at a module that no longer exists. `.github/smoke-test/expected/` held two placeholder fixtures naming a workflow that does not exist. `crates/.gitkeep` landed in the same commit as the crates it was meant to hold open. (#696) + +- Delete `tailwind.config.ts` and `components.json`, both inert. Tailwind 4 is CSS-first: `app/globals.css` does `@import "tailwindcss"` and there is no `@config` directive anywhere in the repo, so the v3-style JS config was never loaded — its `content` globs even pointed at a `./pages/**` directory that does not exist. `components.json` configured shadcn/ui with zero shadcn components installed, no Radix dependency and no `class-variance-authority`; the one file under `components/ui/` is hand-written. (#696) + +- Delete `.bunfig.toml`, which bun has never read. Bun's config file is `bunfig.toml` — the dot-prefixed name is ignored entirely, verified by A/B: with `bunfig.toml` present `bun add` writes `"3.0.1"`, with `.bunfig.toml` it writes `"^3.0.1"`, identical to no config at all. Its `[install] exact = true` and the comment "Use exact versions for reproducibility" have had no effect since the file was added, which is why `package.json` carries caret ranges throughout. Deleting it preserves today's behaviour; **renaming** it would switch every future install to exact resolution and is deliberately left as a separate decision. (#696) + +- Stop shipping `assets/` inside the standalone bundle. Next's tracer pulled the 612 KB audit design lab and CLI logo set into `.next/standalone`, from where `files[]` published it to every npm user; the dashboard serves its own icons out of `public/` and imports nothing from `assets/`. This also makes the `readme-arch-hq.gif` move below free rather than an 11 MB regression. `templates/` is pruned alongside it for the same reason. (#696) + +- Remove a dead version-consistency branch in `ci.yml` that read `packages/wrapper/package.json` behind `2>/dev/null || true` — a check that has been passing by doing nothing since the `packages/` workspace layout was abandoned. The two `packages/*` entries left in `.gitignore` are deliberately kept as a pre-flight warning: anything that recreates that layout must delete them first, or git silently drops the CLI's own entrypoint. (#696) + +- Stop publishing this repo's own dogfood hook configs to npm. Next's file tracer was sweeping `.codex/`, `.cursor/`, `.factory/`, `.opencode/` and `.pi/` into `.next/standalone`, from where `files[]` shipped them — eight files of configuration pointing at `scripts/dev-hook.mjs`, a launcher that exists only in a checkout. The `skills` submodule was riding along too (544 KB), traced in because a gitlink sits at the repo root and a directory-shaped filter walks straight past it. All ten dogfood directories plus `skills` are now pruned, and `__tests__/ci/standalone-prune.test.ts` reads `git ls-files --stage` so a submodule cannot slip through the same gap twice. (#696) + +### Docs + +- Give all 15 top-level directories a README answering the same four questions: which of the five products it belongs to, who consumes it, whether it ships to npm, and where its tests live. 27 of 28 directories had none, so the only way to learn that `lib/` is imported by both the dashboard and the published CLI, or that `pi-extension/`'s directory name is frozen by already-installed users' settings files, was to read `CLAUDE.md` end to end. (#696) + +- Rewrite `.github/CONTRIBUTING.md` around the five products. Its old "Project Structure" tree listed 10 directories and omitted `crates/` — the 41k-line Rust daemon and the largest subsystem in the repo — along with `docs/`, `assets/`, `examples/`, `integration-suite/`, both shipped plugin packages, and every dogfood directory. It now carries a "the files you touch" table mapping each common task to the 2-4 files it actually needs, including the honest answer for adding a new agent CLI, and documents three things that were nowhere: `bun install --ignore-scripts` is what CI uses, `bun run dev`/`start` silently `bun link` a global binary, and running `failproofai policies --install` inside this repo overwrites the dogfood configs. (#696) + +- Add `internals/` for engineering documentation that is not product documentation: `repo-map.md` (every directory, whether it ships, and a "things that look wrong and are not" section), `dogfood.md` (why ten hook-config directories exist and cannot be consolidated — each vendor hardcodes its path; Claude Code carries the literal `".claude","settings.json"` in its binary), and `docs-site.md`. Deliberately not under `docs/`: Mintlify serves unlisted `.md` files, so a contributor README there becomes a public docs page. (#696) + +- Add `.gitattributes` marking the 644 translated `.mdx`, the 14 translated READMEs, and both lockfiles `linguist-generated`, and the audit design lab `linguist-vendored`. 658 files — 46% of the repo — now collapse to one line each in every diff and drop out of GitHub's language bar. Pure git metadata; the build, the tests and the published package are untouched. A new assertion in `__tests__/scripts/translate-docs/config.test.ts` fails if a language is added to the translator without being added here. (#696) + +- Move six files off the repo root, which had grown to 59 tracked entries. `CONTRIBUTING.md` and `SECURITY.md` to `.github/` (GitHub's community-health search order is `.github` → root → `docs`, so both stay linked in the UI); `osv-scanner.toml` to `.github/` alongside the workflow that consumes it, passed explicitly with `--config` because OSV-Scanner only auto-discovers a config in the scanned file's own directory and never walks parents; `Dockerfile.docs` to `docs/Dockerfile.dev`; `readme-arch-hq.gif` to `assets/`, with the absolute `raw.githubusercontent.com` URL repointed in all 14 translated READMEs in the same commit, since a stale one 404s silently rather than failing a build. `instrumentation.node.ts` moves to `lib/instrumentation-node.ts` — only the exact basename `instrumentation` is a Next convention, so the Node half was never a root file to begin with. Root files drop 31 → 22. (#696) + +### Dependencies + +- Pin `nanoid` to 3.3.18 through `overrides`, closing GHSA-2v37-7h3g-55p8 (CVSS 8.2 — custom generators can loop indefinitely when size is zero). Not introduced here: the lockfile is untouched by this branch, `main` passed the same scan at 04:57 and this branch failed at 16:21, because the advisory's affected range was published in between. It arrives transitively through `postcss`, which asks for `^3.3.17`, so the pin satisfies it without moving anything else — two lines of lockfile, 657 entries before and after. An `overrides` pin rather than an `osv-scanner.toml` ignore because that file's own rule is to prefer fixing, and there is a fix. (#694) + +- Drop the unused `proptest` dev-dependency from `fpai-ipc`, which was declared and never referenced. Regenerating `Cargo.lock` removes it and 13 transitive crates (`rand`, `rustix`, `tempfile`, `rusty-fork`, `zerocopy` and friends) — 237 lines of lockfile. (#696) + + ## 1.0.0 — 2026-08-12 The first stable release. Everything below this heading shipped across the @@ -190,8 +220,8 @@ never "blocked". - Carry the NEWEST policy selection across a layout upgrade rather than the oldest. Two layouts kept `policies-config.json` in two places — layout 2 at `policies/local-policies/`, layout 1 at the home root — and layout 3 puts it back at the root, so layout 1's path and the destination are now the same file. The carry read layout 1's copy unconditionally, so a machine upgrading 2 → 3 would have had the pre-layout-2 file written forward over it: every builtin enabled, every `customPoliciesPaths` entry and every `policyParams` value chosen since the layout-2 upgrade, silently replaced by whatever was there before it. It now prefers layout 2's nested file whenever it exists. (#663) - Stop two Rust test suites racing each other on process-global environment variables. `paths.rs` and `cloud_client.rs` each declared their own `ENV_LOCK`, both carrying a comment saying these tests must not run concurrently "with anything else reading the same variables" — which two separate mutexes is precisely what cannot deliver, since both set `HOME` and `FAILPROOFAI_HOME` and `cargo test` runs them as threads in one process. The loser read the developer's real `~/.failproofai/`, and on a machine with real credentials on disk that is a failing assertion in a test that is not wrong. It then cascaded: the panicking test poisoned its mutex and every later `.lock().unwrap()` panicked too, so one race surfaced as failures in tests that never ran, all pointing at the lock rather than at the cause. `fpai-collect` had the same shape with no lock at all — two tests set `FAILPROOFAI_CLAUDE_EXTRA_PATHS` and a reader asserting on the file's value got theirs, about one run in six. Each crate now has one lock, taken by READERS as well as writers (a mutex only serialises the parties that ask for it, and here the reader is the one that fails), and poison-tolerant, so a failing test fails alone. (#663) - Fix collector config tests that were reading TOML out of `.json` files. The layout-3 conversion renamed the fixtures' filenames but not their bodies, so `credentials.json` held `[ingest]\nkey = "abc"` and the loader rejected it as malformed — four tests failing for a reason unrelated to what they test. Two more passed for the wrong reason: one asserted an error message contained "comma" while `tmp_home("comma")` had put that word in the temp path that every error prints, so it was satisfied by its own scratch directory rather than by the rejection under test, and would have kept passing had the check been deleted. (#663) -- Make the hook path able to read a `schemaVersion: 1` active manifest it already claimed to accept. The reader listed 1 as supported "for files a pre-rename beta daemon left behind", then read only the post-rename field names — so every genuine v1 file threw `active manifest deployment is invalid` and the acceptance was unreachable. The Rust reader of the same file handles it with `#[serde(alias)]`; this is the TypeScript half of that pair, and it was the half missing. The failure shape is the one this module's own header warns about: the daemon reconciles happily, `active.json` is correct on disk, and the hook path alone refuses — cloud policy stops being enforced while every other signal reports the machine healthy, and recovery needs a successful poll, so an offline machine stays exposed. The fixtures hid it: every `schemaVersion: 1` fixture paired that version with post-rename field names, a shape no writer ever produced. (#694) -- Rename the daemon's own contract document (`crates/CLOUD_POLICIES.md`) and the live cross-repo pairing harness, both of which the rename missed. The document's desired-state example was a payload the daemon now rejects, and the harness still read `revision` off a publish response, sent `revision` in two deploy bodies, and asserted an ingested event carried `cloud_generation` — so it would have failed against the renamed server while proving nothing about the new contract. (#694) +- Make the hook path able to read a `schemaVersion: 1` active manifest it already claimed to accept. The reader listed 1 as supported "for files a pre-rename beta daemon left behind", then read only the post-rename field names — so every genuine v1 file threw `active manifest deployment is invalid` and the acceptance was unreachable. The Rust reader of the same file handles it with `#[serde(alias)]`; this is the TypeScript half of that pair, and it was the half missing. The failure shape is the one this module's own header warns about: the daemon reconciles happily, `active.json` is correct on disk, and the hook path alone refuses — cloud policy stops being enforced while every other signal reports the machine healthy, and recovery needs a successful poll, so an offline machine stays exposed. The fixtures hid it: every `schemaVersion: 1` fixture paired that version with post-rename field names, a shape no writer ever produced. (#PR) +- Rename the daemon's own contract document (`crates/CLOUD_POLICIES.md`) and the live cross-repo pairing harness, both of which the rename missed. The document's desired-state example was a payload the daemon now rejects, and the harness still read `revision` off a publish response, sent `revision` in two deploy bodies, and asserted an ingested event carried `cloud_generation` — so it would have failed against the renamed server while proving nothing about the new contract. (#PR) - Move the cloud-policy desired state to `schemaVersion: 2`, and accept 1 only from disk. The v1 payload named its fields `generation` and `revision`; after the rename it carried neither, at the same version number — same endpoint, same version, different shape, which is the one thing a schema version exists to prevent. AgentEye now emits 2 (FailproofAI/agenteye#559) and this daemon accepts both: 2 from the server, 1 solely so a `desired-state.json` or `active.json` written by an earlier beta daemon still parses off disk, since both structs carry `deny_unknown_fields` and a refusal there would leave the machine unable to read its own persisted state and quietly not enforcing. For the same reason the field aliases stay on the persisted `ActiveDeployment`/`ActivePolicy` and were REMOVED from the wire `DesiredState`/`DesiredPolicy` — no server can emit the old spelling, so tolerating it there would be dead code, and a silently-accepted stale field is how the two sides drift apart again. The TypeScript hook reader accepts the same pair; it previously accepted only 1, which meant the daemon reconciled happily, wrote a correct `active.json`, and the hook path alone refused it — cloud policy silently unenforced while every other signal read healthy. Verified against the real server end to end: publish, deploy, pull, verify, activate, deny. (#663) - Stop ← hanging the setup wizard. `BACK` is a symbol the shared prompt handler injects when a prompt opts into back navigation, so it is not a value of any prompt's own result type — and `multiSelect`'s summary calls `values.includes(...)`, which throws `TypeError` on a symbol. It threw *inside* `finish`, before the promise resolved, so pressing ← never settled it and the wizard stopped responding to input entirely; `selectOne` did not throw but rendered the literal text `Symbol(failproofai.back)` as the user's answer. Handled once in `collapse()` rather than in each `summaryFor`, so no prompt has to know about a symbol it never declared. (#663) - Keep cloud-policy state written before the deployment/version rename readable across an upgrade. `ActiveDeployment` and `ActivePolicy` carry `deny_unknown_fields`, so an upgraded daemon failed to parse its own `active.json` on three counts at once — `generation` unrecognised, `deployment` missing, and the same again for every policy's `revision`. The machine silently lost the deployment it was enforcing until a poll succeeded, which on a fail-closed machine is precisely the gap this subsystem exists to close. Both spellings are now accepted, on the persisted state and on the desired state the server sends. (#663) @@ -231,7 +261,7 @@ never "blocked". ## 1.0.0-beta.12 — 2026-08-07 ### Fixes -- Stop daemon crash/restart cycles from orphaning live workers. A replacement worker now probes an existing worker socket, sends an acknowledged shutdown request, and only removes the socket after the old worker has begun shutting down; an incompatible live listener blocks startup instead of being silently unlinked. (#694) +- Stop daemon crash/restart cycles from orphaning live workers. A replacement worker now probes an existing worker socket, sends an acknowledged shutdown request, and only removes the socket after the old worker has begun shutting down; an incompatible live listener blocks startup instead of being silently unlinked. (#PR) ## 1.0.0-beta.11 — 2026-08-07 @@ -239,39 +269,39 @@ never "blocked". - Move the daily CLI integration suite off GH Actions onto a local canary box whose entire contract is Docker + one cron line + one env file, and make it probe the daemon path. `integration-suite/local/` ships a self-contained runner image (`Dockerfile.runner`) that drives the *host's* Docker through the mounted socket — sibling containers, with the work dir mounted at an identical path inside and out so the harness's `-v` sources resolve on both sides — whose baked entrypoint stays deliberately thin: lock, clone/fetch `CANARY_REF`, then hand off to `runner-daily.sh` *from the checkout*, so harness changes reach the box through git with no image rebuild. A leg that dies *before* posting its report gets a Slack crash-note with the log tail (the replacement for GHA's red-job email); the workflow keeps `workflow_dispatch` as the cloud fallback and loses its cron, which was the entire Actions cost. On the box the stable leg runs `CANARY_DAEMON=1`: the harness cross-compiles `failproofaid` in a `rust:1-bookworm` container (glibc-matched to the sandbox), sets the `daemon.configured` fail-closed marker through the real `updateConfig` path, and restarts the daemon per probe — the wire protocol carries no env, so the warm worker's oracle log dir is fixed at daemon start, and sharing one dir across probes would let probe A's incidental read-denies false-PASS probe B. A dead daemon cannot false-PASS either: its fail-closed deny is shaped by the synthetic `failproofai/daemon-unreachable` policy, which the probes' greps never match. `CANARY_DAEMON_DEAD=1` adds the complementary fail-closed leg — daemon-configured, daemon deliberately never started, every CLI must deny — which live-testing against 10 real CLIs proved out (all denied; factory and antigravity retry-stormed the deny for the full 10-minute timeout, an availability finding now kept visible by this leg), with its results kept in a separate state lane so a "denied while dead" PASS can never gate-skip a real enforcement probe; the marker is set only after `wire()`, whose vendor onboarding fires hooks that a marker-without-daemon would fail-close. All pinned, along with the marker hygiene, the env-file↔workflow secret parity, and the workflow staying cron-free, in `__tests__/integration-suite/local-runner.test.ts`. (#656) ### Fixes -- Stop `handler.test.ts` reading the developer's own machine. It set no `FAILPROOFAI_HOME`, and `handler.ts` resolves cloud-managed policies from disk — so once cloud policy started working, anyone with a real deployment saw the suite fail with their own artifacts as the unexpected argument (`["/home/…/cloud-policies/generations/4/block-curl-simple.mjs"]` where the assertion wanted `undefined`). Nothing was broken; the test was reading their laptop. That is worse than flakiness: CI is green, so the red is only ever seen locally, by exactly the people who most need to trust the suite. Each test now runs against a throwaway home, and the variable is restored rather than deleted so one test cannot hand the real home to the next. (#694) -- Make the Rust daemon enforce the same cloud-URL rule the TS side does. `CloudClient::new()` checked only that the scheme was `http` or `https`, so `http://internal-host` was accepted and `spawn_maintenance()` then put the org-scoped `policies:pull` bearer token on the wire **in clear, every 30 seconds**. `validateCloudUrl()` in `cloud-enrollment.ts` has always blocked non-loopback `http`, and `configure-wizard.ts` carries a comment asserting the daemon enforces the same rule — it did not. It matters most on the path the TS validator cannot cover: `FAILPROOFAI_CLOUD_URL` takes precedence over the credentials file and is a documented CI/container knob, so it reaches the constructor without passing through the wizard. (#694) -- Stop a second daemon unlinking a live daemon's socket. `Server::bind()` removed whatever sat at the socket path unconditionally, on the stated grounds that `lock.rs`'s `flock()` makes two daemons impossible. That does not hold across hosts on an NFS-mounted home — pre-NFSv4 locks are client-local without an active lockd, and nothing checks what filesystem `$HOME`/`FAILPROOFAI_HOME` lives on (`audit-lock.ts` already engineers around NFS for `O_EXCL`, so it is a shape this codebase accounts for elsewhere). The path is now **probed** rather than assumed: if something is still accepting there, the second daemon refuses to start instead of stealing the socket and silently orphaning every client of a daemon that is still running. The ordinary restart case is unchanged — a socket file with no listener is still debris. (#694) -- Stop macOS reporting a healthy daemon as stopped. `daemonServiceStatus()`'s darwin branch runs `sudo -n launchctl print`, and mapped **every** failure to `"stopped"` — including a sudo cache merely gone stale, which is five minutes by default. The wizard then demanded a password and ran an `unload` → write → `load -w` cycle on a service that was fine: a real fail-closed window on a `daemonConfigured` machine, opened to fix nothing, and a direct breach of `configure-wizard.ts`'s own rule that setup must not demand sudo for work already done (which holds on Linux, where `systemctl is-active` needs no root). "Cannot read the state" is now its own `unknown` status, and the wizard answers it by asking the daemon itself — a real hook evaluation over the socket, needing no privileges. (#694) -- Verify the daemon binary installed from the npm channel. `installFromNpmPackage()` did no integrity check at all, reasoning that npm verified the tarball on install — true, and about a different moment: npm checks at **extraction**, while this reads a loose file out of a shared, writable `node_modules` some time later and installs it as a root-owned, boot-persistent system service. The publish now records each binary's SHA-256 in the **root** manifest (not in the platform package beside the bytes it describes, which would verify nothing) and the install refuses a mismatch, falling through to the release-download channel that verifies its own digest. Honest about its limits: it closes accidental corruption and a non-adaptive overwrite, not an attacker already executing code in the same tree. Absent digests — every dev build and unpublished commit — mean "nothing to compare against", never "verified". (#694) -- Correct `daemon-client.ts`'s comments, which described behaviour `2926252` deliberately removed. `DaemonFailure`'s doc still said a protocol mismatch must fall back to in-process evaluation because "denying every tool call over it would take a working machine offline to protect nothing"; both failures have routed to the same forced deny since that commit. A contributor reading only this file had every reason to "restore" the fallback and reintroduce the second reachable policy engine that change existed to eliminate. (#694) -- Harden the release pipeline in three places. (1) `publish.yml`'s concurrency group was `publish-${{ github.ref }}`, and the two triggers it exists to serialize never share a ref — `release: published` runs as `refs/tags/vX.Y.Z`, `workflow_dispatch` as `refs/heads/main` — so they landed in different groups and neither queued behind the other. `npm view` reads through a cache documented to lag up to two minutes, so both could pass the preflight's "unpublished" check and proceed, which is the orphaned-platform-package split the block was written to prevent. Now a constant group. (2) The publish job checked out with the version-bot App token **persisted**, and that token bypasses the org ruleset on `main` — so it sat readable in `.git/config` through `bun install`'s `prepare` (a full Next build) and every dependency lifecycle script, long before the single `git push` at the end that needs it. It is now supplied to that one command. `ci.yml` and `build-daemon.yml` were hardened for the same risk carrying a *weaker* token; this job was missed. (3) `npm publish` re-runs `prepare`, which inherited `NODE_AUTH_TOKEN` into the bundler and everything it loads. The build is now its own step and the publish skips scripts. The rebuild itself had to stay — `bun build` inlines `package.json`'s version into `dist/cli.mjs`, and `daemon-download.ts` derives the release URL from it, so a tarball built before the version step would ship a CLI fetching its daemon from the wrong tag. (#694) -- Close the dashboard's CSRF gap on a non-loopback bind. The lockdown is three layers — bind loopback, pin the `Host`, reject cross-origin mutating requests — and a request carrying **no** `Origin` was exempt from the third one unconditionally. That exemption's own comment explains it in terms of the bind ("with a loopback bind it is necessarily a local process"), but it was never gated on one. `dashboard-host.ts` deliberately supports a routable bind for containers and remote dev boxes, and there all three layers were off at once: layer 1 by the operator's choice, layer 2 because the `Host` pin is skipped for exactly that case, and layer 3 because no `Origin` is the default for `curl` and every other non-browser client. Any host on the segment could POST `/api/auth/login-verify` (unauthenticated, grafts a token into `auth.json`), `/policies` (uninstalls failproofai's hooks from every CLI) or `/api/audit/run`. Origin-less **mutating** requests are now refused unless the bind is loopback; reads and genuine same-origin writes are untouched, so the deliberate bind stays usable. (#694) -- Stop `bun run dev -H ` desyncing the dashboard's real bind address from the one it enforces against. `parse-script-args.ts` captured only `--host`, but `bun run dev` forwards unrecognised arguments to `next dev`, whose own spelling is `-H`/`--hostname` — so a raw `-H 0.0.0.0` bound the wildcard while `FAILPROOFAI_DASHBOARD_HOST` stayed `127.0.0.1`. `proxy.ts` then enforced the loopback-only `Host` pin, which a raw network client forges trivially and a browser cannot, against a server that really was reachable — and skipped the no-Origin refusal above, which is the check that actually matters for that bind. Contributor-workflow only: the shipped dashboard takes no CLI passthrough. (#694) -- Make reinstalling the daemon actually replace the daemon. The Linux install path ran `daemon-reload` + `enable --now`, and `--now` starts a unit that is **stopped** and does nothing to one that is already active — so every reinstall over a live daemon rewrote `/etc/systemd/system/failproofaid@.service` and left the **old process running the old binary**, reporting success. `ensureDaemonServiceCurrent` already documents this trap and uses `restart`; the install path never inherited it. Version skew is where it bit: the wizard's `daemonBroken` is `daemonUpToDate && !daemonAnswers` and `daemonUpToDate` requires no skew, so skew can never set it, the uninstall-then-reinstall path never fires, and the install runs straight over the survivor. `probeDaemon()` then reads that survivor's protocol-mismatch reply as `ok` — deliberately, because it is "acted on elsewhere", elsewhere being exactly this install — so `daemonConfigured` was recorded at the NEW version and `pruneOldDaemonBinaries()` was free to delete the binary the running process came from. The documented recovery for a `PROTOCOL_VERSION` bump (`npm update -g failproofai` → `failproofai config`) therefore left the machine exactly as skewed as it started. Now `enable` + `restart`, which also covers the fresh-install case since `restart` starts a stopped unit. The by-hand commands printed when sudo is unavailable were fixed too — they told an upgrading user to run the same no-op. Live-reproduced against real systemd 249: `enable --now` left MainPID 81 on the old binary; `enable` + `restart` moved to a new PID on the new one. (#694) -- Make the hook CLI's outermost error boundary fail **closed**, which it never did. Any exception reaching `bin/failproofai.mjs`'s `--hook` catch wrote **zero bytes** to stdout, logged to stderr and exited 2 — a deny for Claude and Factory's non-Stop events, and a silent **ALLOW** for the seven CLIs that read their verdict from stdout JSON and ignore the exit code (Cursor, Pi, Hermes, OpenClaw, Devin, Antigravity, Goose, plus Factory's Stop). Wrapping `readActiveCloudManagedPolicies`' fourteen throw sites closed one source of such throws; the boundary itself still failed open for every other source — **including a throw from the forced-deny call that handles an unreachable daemon**, so the fail-closed path could itself fail open. It now emits a real deny, shaped by the same evaluator the unreachable-daemon path uses (so no second copy of twelve CLI contracts can drift), and leaves through `exitAfterFlush` rather than the one bare `process.exit` left on the hook path — which could truncate the very bytes carrying the deny. The verdict is written **before** telemetry, because `flushHookTelemetry` loops unbounded and a stuck send used to hold it back indefinitely. (#694) -- Stop redaction destroying the data next to the secret. `match_assignment` matched at the opening quote rather than at the value, so `quoted` looked at the `=` before it and read false — and the unquoted stop-set then ran past the closing quote to the next space. `docker run -e API_KEY="…"myapp/image:latest` came out as `API_KEY=[redacted:secret-assignment]` with the image tag **silently deleted**, and nothing in the output distinguished "a secret was removed" from "your data was eaten". Even the plain `KEY="value"` case swallowed both quotes, which the function's own doc comment says it does not. Quotes now also terminate an *unquoted* value, matching what `match_bearer` already did: an unquoted shell word does not contain a bare quote, so stopping costs no real redaction, and running past one destroys whatever it delimits. A test pins the general invariant — every character outside the replaced span survives verbatim. (#694) -- Keep collector health reporting on the live collector instead of a dead one. `fpai_collect::health`'s registry was a `OnceLock`, the same defect fixed in `telemetry.rs`'s sibling `COLLECTOR_METRICS` and never mirrored here — so only the FIRST `install()` took effect. That was harmless until the collector became cyclable; now every credential rotation, `[collector]` change and `failproofai backfill` rebuilds it, and every install after the first was silently dropped. Sources reported through the free functions into the orphaned first generation while the live writer published the one nobody wrote to, so `collector-health.json` was faithfully rewritten every 30s with frozen numbers — and a source that had gone completely dark read exactly like a healthy idle one, which is the single thing this file exists to tell apart. (#694) -- Never let a thread the OS refused take the machine's enforcement with it. `5faf3bc` converted three daemon lanes from `std::thread::spawn` to `Builder::spawn`, and missed the two spawns that matter more. `server.rs` spawned every connection handler with the panicking form, on the daemon's MAIN thread, up to 64 concurrently — so one `EAGAIN` under a `RLIMIT_NPROC` or pids-cgroup ceiling killed `failproofaid`, denied every tool call across all twelve CLIs, and returned to the same exhausted limit under `Restart=on-failure`. It now logs and drops that one connection — the bounded overload `MAX_INFLIGHT_CONNECTIONS` already produces — and returns its in-flight slot, because leaking 64 of those would wedge the daemon exactly as the panic did. `worker.rs` had the same trigger with a quieter ending: its output drainers spawn while `ensure_started` holds the child mutex, so a refusal panicked mid-guard and POISONED it, and that unwind reaches only a handler thread. The daemon survived, kept answering `Ping`, looked healthy — and panicked on every `Hook` request for the rest of its life, while `shutdown()` and `Drop` silently stopped reaping the worker and left it orphaned. Both halves are closed: the spawn cannot panic, and every lock site recovers a poisoned guard instead of treating it as unusable. (#694) +- Stop `handler.test.ts` reading the developer's own machine. It set no `FAILPROOFAI_HOME`, and `handler.ts` resolves cloud-managed policies from disk — so once cloud policy started working, anyone with a real deployment saw the suite fail with their own artifacts as the unexpected argument (`["/home/…/cloud-policies/generations/4/block-curl-simple.mjs"]` where the assertion wanted `undefined`). Nothing was broken; the test was reading their laptop. That is worse than flakiness: CI is green, so the red is only ever seen locally, by exactly the people who most need to trust the suite. Each test now runs against a throwaway home, and the variable is restored rather than deleted so one test cannot hand the real home to the next. (#PR) +- Make the Rust daemon enforce the same cloud-URL rule the TS side does. `CloudClient::new()` checked only that the scheme was `http` or `https`, so `http://internal-host` was accepted and `spawn_maintenance()` then put the org-scoped `policies:pull` bearer token on the wire **in clear, every 30 seconds**. `validateCloudUrl()` in `cloud-enrollment.ts` has always blocked non-loopback `http`, and `configure-wizard.ts` carries a comment asserting the daemon enforces the same rule — it did not. It matters most on the path the TS validator cannot cover: `FAILPROOFAI_CLOUD_URL` takes precedence over the credentials file and is a documented CI/container knob, so it reaches the constructor without passing through the wizard. (#PR) +- Stop a second daemon unlinking a live daemon's socket. `Server::bind()` removed whatever sat at the socket path unconditionally, on the stated grounds that `lock.rs`'s `flock()` makes two daemons impossible. That does not hold across hosts on an NFS-mounted home — pre-NFSv4 locks are client-local without an active lockd, and nothing checks what filesystem `$HOME`/`FAILPROOFAI_HOME` lives on (`audit-lock.ts` already engineers around NFS for `O_EXCL`, so it is a shape this codebase accounts for elsewhere). The path is now **probed** rather than assumed: if something is still accepting there, the second daemon refuses to start instead of stealing the socket and silently orphaning every client of a daemon that is still running. The ordinary restart case is unchanged — a socket file with no listener is still debris. (#PR) +- Stop macOS reporting a healthy daemon as stopped. `daemonServiceStatus()`'s darwin branch runs `sudo -n launchctl print`, and mapped **every** failure to `"stopped"` — including a sudo cache merely gone stale, which is five minutes by default. The wizard then demanded a password and ran an `unload` → write → `load -w` cycle on a service that was fine: a real fail-closed window on a `daemonConfigured` machine, opened to fix nothing, and a direct breach of `configure-wizard.ts`'s own rule that setup must not demand sudo for work already done (which holds on Linux, where `systemctl is-active` needs no root). "Cannot read the state" is now its own `unknown` status, and the wizard answers it by asking the daemon itself — a real hook evaluation over the socket, needing no privileges. (#PR) +- Verify the daemon binary installed from the npm channel. `installFromNpmPackage()` did no integrity check at all, reasoning that npm verified the tarball on install — true, and about a different moment: npm checks at **extraction**, while this reads a loose file out of a shared, writable `node_modules` some time later and installs it as a root-owned, boot-persistent system service. The publish now records each binary's SHA-256 in the **root** manifest (not in the platform package beside the bytes it describes, which would verify nothing) and the install refuses a mismatch, falling through to the release-download channel that verifies its own digest. Honest about its limits: it closes accidental corruption and a non-adaptive overwrite, not an attacker already executing code in the same tree. Absent digests — every dev build and unpublished commit — mean "nothing to compare against", never "verified". (#PR) +- Correct `daemon-client.ts`'s comments, which described behaviour `2926252` deliberately removed. `DaemonFailure`'s doc still said a protocol mismatch must fall back to in-process evaluation because "denying every tool call over it would take a working machine offline to protect nothing"; both failures have routed to the same forced deny since that commit. A contributor reading only this file had every reason to "restore" the fallback and reintroduce the second reachable policy engine that change existed to eliminate. (#PR) +- Harden the release pipeline in three places. (1) `publish.yml`'s concurrency group was `publish-${{ github.ref }}`, and the two triggers it exists to serialize never share a ref — `release: published` runs as `refs/tags/vX.Y.Z`, `workflow_dispatch` as `refs/heads/main` — so they landed in different groups and neither queued behind the other. `npm view` reads through a cache documented to lag up to two minutes, so both could pass the preflight's "unpublished" check and proceed, which is the orphaned-platform-package split the block was written to prevent. Now a constant group. (2) The publish job checked out with the version-bot App token **persisted**, and that token bypasses the org ruleset on `main` — so it sat readable in `.git/config` through `bun install`'s `prepare` (a full Next build) and every dependency lifecycle script, long before the single `git push` at the end that needs it. It is now supplied to that one command. `ci.yml` and `build-daemon.yml` were hardened for the same risk carrying a *weaker* token; this job was missed. (3) `npm publish` re-runs `prepare`, which inherited `NODE_AUTH_TOKEN` into the bundler and everything it loads. The build is now its own step and the publish skips scripts. The rebuild itself had to stay — `bun build` inlines `package.json`'s version into `dist/cli.mjs`, and `daemon-download.ts` derives the release URL from it, so a tarball built before the version step would ship a CLI fetching its daemon from the wrong tag. (#PR) +- Close the dashboard's CSRF gap on a non-loopback bind. The lockdown is three layers — bind loopback, pin the `Host`, reject cross-origin mutating requests — and a request carrying **no** `Origin` was exempt from the third one unconditionally. That exemption's own comment explains it in terms of the bind ("with a loopback bind it is necessarily a local process"), but it was never gated on one. `dashboard-host.ts` deliberately supports a routable bind for containers and remote dev boxes, and there all three layers were off at once: layer 1 by the operator's choice, layer 2 because the `Host` pin is skipped for exactly that case, and layer 3 because no `Origin` is the default for `curl` and every other non-browser client. Any host on the segment could POST `/api/auth/login-verify` (unauthenticated, grafts a token into `auth.json`), `/policies` (uninstalls failproofai's hooks from every CLI) or `/api/audit/run`. Origin-less **mutating** requests are now refused unless the bind is loopback; reads and genuine same-origin writes are untouched, so the deliberate bind stays usable. (#PR) +- Stop `bun run dev -H ` desyncing the dashboard's real bind address from the one it enforces against. `parse-script-args.ts` captured only `--host`, but `bun run dev` forwards unrecognised arguments to `next dev`, whose own spelling is `-H`/`--hostname` — so a raw `-H 0.0.0.0` bound the wildcard while `FAILPROOFAI_DASHBOARD_HOST` stayed `127.0.0.1`. `proxy.ts` then enforced the loopback-only `Host` pin, which a raw network client forges trivially and a browser cannot, against a server that really was reachable — and skipped the no-Origin refusal above, which is the check that actually matters for that bind. Contributor-workflow only: the shipped dashboard takes no CLI passthrough. (#PR) +- Make reinstalling the daemon actually replace the daemon. The Linux install path ran `daemon-reload` + `enable --now`, and `--now` starts a unit that is **stopped** and does nothing to one that is already active — so every reinstall over a live daemon rewrote `/etc/systemd/system/failproofaid@.service` and left the **old process running the old binary**, reporting success. `ensureDaemonServiceCurrent` already documents this trap and uses `restart`; the install path never inherited it. Version skew is where it bit: the wizard's `daemonBroken` is `daemonUpToDate && !daemonAnswers` and `daemonUpToDate` requires no skew, so skew can never set it, the uninstall-then-reinstall path never fires, and the install runs straight over the survivor. `probeDaemon()` then reads that survivor's protocol-mismatch reply as `ok` — deliberately, because it is "acted on elsewhere", elsewhere being exactly this install — so `daemonConfigured` was recorded at the NEW version and `pruneOldDaemonBinaries()` was free to delete the binary the running process came from. The documented recovery for a `PROTOCOL_VERSION` bump (`npm update -g failproofai` → `failproofai config`) therefore left the machine exactly as skewed as it started. Now `enable` + `restart`, which also covers the fresh-install case since `restart` starts a stopped unit. The by-hand commands printed when sudo is unavailable were fixed too — they told an upgrading user to run the same no-op. Live-reproduced against real systemd 249: `enable --now` left MainPID 81 on the old binary; `enable` + `restart` moved to a new PID on the new one. (#PR) +- Make the hook CLI's outermost error boundary fail **closed**, which it never did. Any exception reaching `bin/failproofai.mjs`'s `--hook` catch wrote **zero bytes** to stdout, logged to stderr and exited 2 — a deny for Claude and Factory's non-Stop events, and a silent **ALLOW** for the seven CLIs that read their verdict from stdout JSON and ignore the exit code (Cursor, Pi, Hermes, OpenClaw, Devin, Antigravity, Goose, plus Factory's Stop). Wrapping `readActiveCloudManagedPolicies`' fourteen throw sites closed one source of such throws; the boundary itself still failed open for every other source — **including a throw from the forced-deny call that handles an unreachable daemon**, so the fail-closed path could itself fail open. It now emits a real deny, shaped by the same evaluator the unreachable-daemon path uses (so no second copy of twelve CLI contracts can drift), and leaves through `exitAfterFlush` rather than the one bare `process.exit` left on the hook path — which could truncate the very bytes carrying the deny. The verdict is written **before** telemetry, because `flushHookTelemetry` loops unbounded and a stuck send used to hold it back indefinitely. (#PR) +- Stop redaction destroying the data next to the secret. `match_assignment` matched at the opening quote rather than at the value, so `quoted` looked at the `=` before it and read false — and the unquoted stop-set then ran past the closing quote to the next space. `docker run -e API_KEY="…"myapp/image:latest` came out as `API_KEY=[redacted:secret-assignment]` with the image tag **silently deleted**, and nothing in the output distinguished "a secret was removed" from "your data was eaten". Even the plain `KEY="value"` case swallowed both quotes, which the function's own doc comment says it does not. Quotes now also terminate an *unquoted* value, matching what `match_bearer` already did: an unquoted shell word does not contain a bare quote, so stopping costs no real redaction, and running past one destroys whatever it delimits. A test pins the general invariant — every character outside the replaced span survives verbatim. (#PR) +- Keep collector health reporting on the live collector instead of a dead one. `fpai_collect::health`'s registry was a `OnceLock`, the same defect fixed in `telemetry.rs`'s sibling `COLLECTOR_METRICS` and never mirrored here — so only the FIRST `install()` took effect. That was harmless until the collector became cyclable; now every credential rotation, `[collector]` change and `failproofai backfill` rebuilds it, and every install after the first was silently dropped. Sources reported through the free functions into the orphaned first generation while the live writer published the one nobody wrote to, so `collector-health.json` was faithfully rewritten every 30s with frozen numbers — and a source that had gone completely dark read exactly like a healthy idle one, which is the single thing this file exists to tell apart. (#PR) +- Never let a thread the OS refused take the machine's enforcement with it. `5faf3bc` converted three daemon lanes from `std::thread::spawn` to `Builder::spawn`, and missed the two spawns that matter more. `server.rs` spawned every connection handler with the panicking form, on the daemon's MAIN thread, up to 64 concurrently — so one `EAGAIN` under a `RLIMIT_NPROC` or pids-cgroup ceiling killed `failproofaid`, denied every tool call across all twelve CLIs, and returned to the same exhausted limit under `Restart=on-failure`. It now logs and drops that one connection — the bounded overload `MAX_INFLIGHT_CONNECTIONS` already produces — and returns its in-flight slot, because leaking 64 of those would wedge the daemon exactly as the panic did. `worker.rs` had the same trigger with a quieter ending: its output drainers spawn while `ensure_started` holds the child mutex, so a refusal panicked mid-guard and POISONED it, and that unwind reaches only a handler thread. The daemon survived, kept answering `Ping`, looked healthy — and panicked on every `Hook` request for the rest of its life, while `shutdown()` and `Drop` silently stopped reaping the worker and left it orphaned. Both halves are closed: the spawn cannot panic, and every lock site recovers a poisoned guard instead of treating it as unusable. (#PR) ## 1.0.0-beta.10 — 2026-08-07 ### Features -- Add `failproofai backfill` — re-send history the collector has already read past. The collector never re-reads a file it has a cursor for, which is right for steady state and wrong exactly twice: when the dashboard's data was cleared or a machine was re-enrolled, and when cursors advanced before there was anywhere to send. Both leave a machine whose transcripts exist locally and nowhere else, with no way to ask for them again. Re-sending is safe rather than lucky — re-reading is already the documented recovery path for a damaged cursor store, and redaction is deterministic, so a re-sent event hashes identically to its first send and collapses into the row already there. Defaults to 30 days (`--since 30d | 6m | YYYY-MM-DD` to widen, `--dry-run` to look first), covers every agent CLI with sessions on disk, and sends only the streams `[collector]` enables — a backfill can never ship transcripts on a machine that set `sessions = false`. It hands off to the daemon, because the cursors it rewinds are held in memory by the running collector, which would write them straight back over; but every precondition a person can get wrong (no home, no credential, collection switched off) is checked synchronously first, so a request that cannot work fails immediately rather than in the journal. (#694) +- Add `failproofai backfill` — re-send history the collector has already read past. The collector never re-reads a file it has a cursor for, which is right for steady state and wrong exactly twice: when the dashboard's data was cleared or a machine was re-enrolled, and when cursors advanced before there was anywhere to send. Both leave a machine whose transcripts exist locally and nowhere else, with no way to ask for them again. Re-sending is safe rather than lucky — re-reading is already the documented recovery path for a damaged cursor store, and redaction is deterministic, so a re-sent event hashes identically to its first send and collapses into the row already there. Defaults to 30 days (`--since 30d | 6m | YYYY-MM-DD` to widen, `--dry-run` to look first), covers every agent CLI with sessions on disk, and sends only the streams `[collector]` enables — a backfill can never ship transcripts on a machine that set `sessions = false`. It hands off to the daemon, because the cursors it rewinds are held in memory by the running collector, which would write them straight back over; but every precondition a person can get wrong (no home, no credential, collection switched off) is checked synchronously first, so a request that cannot work fails immediately rather than in the journal. (#PR) ### Fixes -- Let a backfill actually reach past 7 days. `new_cursor` refuses any file older than `since_days`, which the daemon hardcoded to 7 — so rewinding cursors for a 30-day window silently delivered a week, gave the older files no cursor at all, and re-skipped them on every subsequent poll. A backfill that asks for 30 days and quietly ships 7 is worse than none: the gap is invisible and the dashboard looks complete. The window is now widened for the rebuild a backfill triggers. Verified on a real machine: 3,364 files older than 7 days were read, the oldest 28.3 days. (#694) -- Make the daemon pick up a configuration change instead of running on whatever it started with. The collector resolves its ingest credential once, when it starts, and the uploader caches the bearer key at construction — so rotating a key left the file correct and the process wrong. The failure is invisible from every angle a person can check: `--connect` verifies the NEW key itself and reports success, the service stays healthy, and `credentials.toml` holds a key that works when you curl it, while every batch 401s and parks. Observed live: a key revoked at 13:05:37 and replaced 37 seconds later was still producing 401s twenty minutes on, with 26 parked batches and a CLI insisting the machine was connected; the only symptom was data that never arrived. The collector manager now compares the whole on-disk `CollectorConfig` each tick and cycles the collector when it differs — which covers a rotated credential, a stream switched off, a verbosity change and a redaction change, all of which are baked into the tasks at build time and none of which took effect before. Fixing it in the DAEMON rather than the CLI is what makes it unconditional: `config.toml` says "Safe to edit by hand" and means it, and a fleet tool, an editor or a `sed` are all legitimate ways to change it — none of which run our code. It cycles the COLLECTOR, not the daemon, so the enforcement socket keeps serving throughout and a `daemonConfigured` machine never denies a tool call for it. An unreadable file is treated as "wait", not "disabled", so a config caught mid-save is not mistaken for a change. (#694) -- Stop the telemetry lane reporting a dead collector's counters. The health registry was a `OnceLock`, so every publish after the first was silently dropped — once the collector could be cycled, that meant polling a generation that had already been joined and reporting its totals as current. Nothing errored; the numbers simply stopped moving, which is indistinguishable from a healthy idle machine. (#694) +- Let a backfill actually reach past 7 days. `new_cursor` refuses any file older than `since_days`, which the daemon hardcoded to 7 — so rewinding cursors for a 30-day window silently delivered a week, gave the older files no cursor at all, and re-skipped them on every subsequent poll. A backfill that asks for 30 days and quietly ships 7 is worse than none: the gap is invisible and the dashboard looks complete. The window is now widened for the rebuild a backfill triggers. Verified on a real machine: 3,364 files older than 7 days were read, the oldest 28.3 days. (#PR) +- Make the daemon pick up a configuration change instead of running on whatever it started with. The collector resolves its ingest credential once, when it starts, and the uploader caches the bearer key at construction — so rotating a key left the file correct and the process wrong. The failure is invisible from every angle a person can check: `--connect` verifies the NEW key itself and reports success, the service stays healthy, and `credentials.toml` holds a key that works when you curl it, while every batch 401s and parks. Observed live: a key revoked at 13:05:37 and replaced 37 seconds later was still producing 401s twenty minutes on, with 26 parked batches and a CLI insisting the machine was connected; the only symptom was data that never arrived. The collector manager now compares the whole on-disk `CollectorConfig` each tick and cycles the collector when it differs — which covers a rotated credential, a stream switched off, a verbosity change and a redaction change, all of which are baked into the tasks at build time and none of which took effect before. Fixing it in the DAEMON rather than the CLI is what makes it unconditional: `config.toml` says "Safe to edit by hand" and means it, and a fleet tool, an editor or a `sed` are all legitimate ways to change it — none of which run our code. It cycles the COLLECTOR, not the daemon, so the enforcement socket keeps serving throughout and a `daemonConfigured` machine never denies a tool call for it. An unreadable file is treated as "wait", not "disabled", so a config caught mid-save is not mistaken for a change. (#PR) +- Stop the telemetry lane reporting a dead collector's counters. The health registry was a `OnceLock`, so every publish after the first was silently dropped — once the collector could be cycled, that meant polling a generation that had already been joined and reporting its totals as current. Nothing errored; the numbers simply stopped moving, which is indistinguishable from a healthy idle machine. (#PR) ## 1.0.0-beta.9 — 2026-08-06 ### Fixes - Carry a machine's decision history across the layout-1 upgrade instead of deleting it. `cache/hook-activity` — every decision the machine had ever recorded, and the data the dashboard's activity tab exists to show — sat inside `cache/`, which the reset removed as a unit. An upgrade therefore threw it away silently, and the message even said so ("removed … activity history") without offering an alternative. The log is now MOVED into layout 2's `hook-activity/`, and the choice of move over copy is the whole design rather than an implementation detail: the collector keys its cursors on `(device, inode)` — deliberately, because the store rotates by renaming `current.jsonl` and a path-keyed cursor would both re-ship the rotated file and carry its offset onto the fresh one — so `rename()` keeps every carried page recognisable and resuming at the right offset, where a copy would give each page a new inode, read as never-seen, and re-ship the lot. `head_fingerprint`, added earlier to defend against inode REUSE, is what makes that safe rather than lucky: it verifies a file's first bytes exactly when a resumed cursor's path has changed, which is precisely this situation. `EXDEV` (a `cache/` on another filesystem) falls back to copy and accepts the re-ship, since ingest dedups on a content hash. The legacy `current.jsonl` is carried under a PAGE name because the destination has its own and it may be mid-write; `current.count` and `stats.json` are dropped rather than merged, because two derived counters cannot be reconciled without inventing a number. - Stop the reset deleting the cursors that make the above worth doing, and stop it deleting the destination it had just written. `at("cursors")` was removed on the explicit principle of "one rule, no exceptions", accepting a one-off re-ship — a reasonable call when nothing was preserved, and the wrong one now: keeping the log while dropping the watermarks is half a feature, since every carried page would re-ship anyway. More sharply, `hookActivityDir()` was still in `resettablePaths()`, and the reset runs that list AFTER the migrations — so the log was moved and then deleted moments later. `cache/` is likewise no longer removed wholesale; its other children (`cache/audit`, `cache/codex-session-paths.json`) are named individually so nothing else quietly outlives the reset. The reset message now says what was KEPT as well as what went, and counts the carried pages rather than naming them — a user who reads only "removed" has no way to know their history survived. -- Point the default ingest endpoint at the dashboard hostname (`https://app.befailproof.ai/v1/events`) instead of the API server's own. The reverse proxy in front of the hosted deployment already routes `/v1/*` and `/enforcement/v1/*` to the server (`dashboard-ingressroute.yaml`, priority 100 over the catch-all), so this reaches ingest exactly as before while leaving the API server without a public hostname of its own to expose. It also makes one origin sufficient: ingest, `/v1/auth/introspect` and `/enforcement/v1/*` now all hang off the origin someone already has in their browser. Machines with a URL already recorded in `credentials.toml` are untouched — this is only the value used when none was. (#694) -- Stop asking for the Cloud URL during setup, and take it from `FAILPROOFAI_CLOUD_URL` when a different endpoint is genuinely needed. There is one right answer for the hosted product, and asking made it look like a decision — which is how an API key gets pasted into the URL field, and how the dashboard's own address gets typed at a prompt that wants the API server. Neither is a mistake the person making it can avoid: the prompt had no knowable answer other than the default already on screen. `FAILPROOFAI_CLOUD_URL` is deliberately the same variable the daemon already reads for cloud-managed policy, so one export points the whole machine at one place rather than leaving the wizard and the daemon disagreeing about where it reports. The env value goes through the same `validateCloudUrl` a typed one did (http stays loopback-only, so a bearer token still cannot be exported onto the wire in clear), and an unusable value cancels loudly rather than silently falling back to the hosted service. The destination now appears in the key prompt itself (`API key for app.befailproof.ai`). `--connect --token ` is unchanged. (#694) -- Enforce that the TypeScript and Rust copies of `DEFAULT_INGEST_URL` stay byte-identical. Both files carried a "MUST stay byte-identical" comment and nothing checked. The CLI resolves a credential to verify the endpoint at setup and the daemon resolves one independently to POST to it, so a divergence fails silently in the worst way: the wizard reports success, the daemon looks healthy, and nothing ever arrives. (#694) +- Point the default ingest endpoint at the dashboard hostname (`https://app.befailproof.ai/v1/events`) instead of the API server's own. The reverse proxy in front of the hosted deployment already routes `/v1/*` and `/enforcement/v1/*` to the server (`dashboard-ingressroute.yaml`, priority 100 over the catch-all), so this reaches ingest exactly as before while leaving the API server without a public hostname of its own to expose. It also makes one origin sufficient: ingest, `/v1/auth/introspect` and `/enforcement/v1/*` now all hang off the origin someone already has in their browser. Machines with a URL already recorded in `credentials.toml` are untouched — this is only the value used when none was. (#PR) +- Stop asking for the Cloud URL during setup, and take it from `FAILPROOFAI_CLOUD_URL` when a different endpoint is genuinely needed. There is one right answer for the hosted product, and asking made it look like a decision — which is how an API key gets pasted into the URL field, and how the dashboard's own address gets typed at a prompt that wants the API server. Neither is a mistake the person making it can avoid: the prompt had no knowable answer other than the default already on screen. `FAILPROOFAI_CLOUD_URL` is deliberately the same variable the daemon already reads for cloud-managed policy, so one export points the whole machine at one place rather than leaving the wizard and the daemon disagreeing about where it reports. The env value goes through the same `validateCloudUrl` a typed one did (http stays loopback-only, so a bearer token still cannot be exported onto the wire in clear), and an unusable value cancels loudly rather than silently falling back to the hosted service. The destination now appears in the key prompt itself (`API key for app.befailproof.ai`). `--connect --token ` is unchanged. (#PR) +- Enforce that the TypeScript and Rust copies of `DEFAULT_INGEST_URL` stay byte-identical. Both files carried a "MUST stay byte-identical" comment and nothing checked. The CLI resolves a credential to verify the endpoint at setup and the daemon resolves one independently to POST to it, so a divergence fails silently in the worst way: the wizard reports success, the daemon looks healthy, and nothing ever arrives. (#PR) ### Docs - Correct the hook-activity paths, which described a design two layouts old. `~/.failproofai/hook-activity.jsonl` was named in three places as the activity log; there is no such file — it is a DIRECTORY of paged JSONL (`current.jsonl` rotating into `page--.jsonl`), and has been since before the layout-2 move out of `cache/`. The same table also gave layout 1's `policies-config.json` and `hook.log` at the home root, both of which moved. Someone following those docs looks for files that are not there and concludes nothing is being recorded — which matters more now that the upgrade preserves that directory rather than deleting it. @@ -280,16 +310,16 @@ never "blocked". ### Features -- Add `failproofai uninstall` — the sanctioned way off a machine. npm runs no uninstall script, so `npm rm -g failproofai` deletes the package and leaves behind everything durable it installed: hook entries in up to twelve agent CLIs' settings files and a root-owned systemd unit. Those leftovers are not inert — the hook entries invoke `npx -y failproofai`, which re-downloads the package, so a "removed" failproofai keeps running on every tool call; and on a `daemonConfigured` machine the surviving unit points at a worker script npm just deleted, which under fail-closed semantics denies EVERY tool call with nothing on screen naming the cause. The command clears `daemonConfigured` **first**, before hooks and before the service, so a partial uninstall can only ever fail open — the intuitive order leaves a window where the flag demands a daemon that is already gone, and that window is a total agent lockout. `--purge` also deletes `~/.failproofai`; `--dry-run` shows the plan; `--yes` skips the prompt, which is required rather than assumed when there is no TTY. Incomplete cleanup exits non-zero and prints the exact `sudo` commands to finish, and `--purge` suppresses the command's own telemetry — resolving an instance id lazily WRITES `state/telemetry-id`, which re-created the whole directory seconds after deleting it and left a just-wiped machine holding a brand-new tracking identifier. (#694) +- Add `failproofai uninstall` — the sanctioned way off a machine. npm runs no uninstall script, so `npm rm -g failproofai` deletes the package and leaves behind everything durable it installed: hook entries in up to twelve agent CLIs' settings files and a root-owned systemd unit. Those leftovers are not inert — the hook entries invoke `npx -y failproofai`, which re-downloads the package, so a "removed" failproofai keeps running on every tool call; and on a `daemonConfigured` machine the surviving unit points at a worker script npm just deleted, which under fail-closed semantics denies EVERY tool call with nothing on screen naming the cause. The command clears `daemonConfigured` **first**, before hooks and before the service, so a partial uninstall can only ever fail open — the intuitive order leaves a window where the flag demands a daemon that is already gone, and that window is a total agent lockout. `--purge` also deletes `~/.failproofai`; `--dry-run` shows the plan; `--yes` skips the prompt, which is required rather than assumed when there is no TTY. Incomplete cleanup exits non-zero and prints the exact `sudo` commands to finish, and `--purge` suppresses the command's own telemetry — resolving an instance id lazily WRITES `state/telemetry-id`, which re-created the whole directory seconds after deleting it and left a just-wiped machine holding a brand-new tracking identifier. (#PR) ### Fixes - Stop `block-sudo` being defeated by a path. It matched the literal word at a command boundary, so **an absolute path to the elevation binary was ALLOWED**: a direct invocation, no obfuscation, one path prefix away from root on a `defaultEnabled` guard. The sibling `block-self-pause` had already been hardened against exactly this and the two had simply drifted. Elevation is now anchored structurally, as that sibling does: prefix assignments, redirections, runners and their flags are walked off, the comparison is on the BASENAME, and `doas` is included because a machine with it installed and only the other one blocked is not blocked. Quoted and backslash-escaped spellings are caught by unquoting each token individually — NOT by stripping the whole string first, which the first attempt did and which turned an escaped pipe inside a `grep` alternation into a segment separator, denying an ordinary search; a security policy that fires on `grep` gets switched off, and a policy that is off protects nothing. A quoted argument is re-examined only when a shell runner was invoked with an eval flag, since a runner evaluating its argument and a search string containing the same text are identical from outside, and the only thing separating them is whether the receiving binary evaluates it. `block-sudo-anchoring.test.ts` covers both directions, and states as an explicit test what static inspection genuinely cannot reach — a variable, base64 through a pipe, a wrapper script on disk — so the honest claim for this policy stays "stops the obvious attempt" rather than "prevents elevation". -- Gate the systemd unit on the files it cannot run without, so an install that is no longer there stops instead of thrashing. `ConditionPathExists=` now covers both the daemon binary and the worker script. Previously a deleted worker left systemd happily running a daemon that could only deny, and a deleted BINARY was worse: ExecStart failed 203/EXEC under `Restart=on-failure` and cycled until it tripped the start-limit and latched into "start request repeated too quickly", which then refused a legitimate restart later. A failed condition is not a failure — systemd skips the job and `systemctl status` names the exact missing path. Verified against real systemd, including that restoring the file brings the unit straight back. (#694) -- Tell a skipped service apart from a stopped one. `daemonServiceStatus()` gained `condition-failed`, read from systemd's own `ConditionResult`, and the self-heal in `fp-reset` treats it like "not-installed" — clearing `daemonConfigured` so the machine stops denying every tool call, which "stopped" deliberately never does (a restart in flight looks identical, and clearing there would silently downgrade a healthy machine to the in-process path). (#694) -- Exempt `uninstall` from the first-run wizard. Offering to set a machine up on the way to tearing it down would install hooks and a root-owned unit seconds before the command removes them. (#694) -- Stop `failproofai config` refusing to finish against a daemon that is working. The setup health probe sends no `cwd`; the daemon deserialises that as `None` and forwards it with `json!({ "cwd": cwd })`, which writes an explicit **null** rather than omitting the key — and the worker's request validator accepted only `undefined`. So the worker answered "unrecognized request shape", the probe failed, and setup aborted with "its worker process could not be run" **against a worker that had already logged that it was listening**. It was not intermittent: the probe never sends a cwd, so it failed on every machine, every time, and setup could never install a daemon. The validator now treats null and absent alike (a wrong TYPE is still refused) and normalises to `undefined` so nothing downstream learns how the wire spells "absent". The same mismatch sat under real enforcement, not just setup: on a `daemonConfigured` machine a hook payload without a cwd fails closed, i.e. denies the tool call. (#694) -- Make the health probe wait for the socket instead of racing it. It runs moments after `systemctl enable --now`, and a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before the daemon has bound. The probe got the hook path's deliberately-tight 150ms connect budget and one attempt, so on a loaded machine it lost that race and reported a healthy daemon as broken. It now retries for up to 10s. The 150ms is untouched, because that budget is what stops a dead daemon adding latency to every tool call. (#694) -- Say which fault the probe actually hit. `DaemonFailure` reports `unreachable` for BOTH a refused connection and a request that was accepted and never answered, so setup told people their worker would not start when nothing was listening at all — sending them to inspect a healthy process. `probeDaemon` now distinguishes "never accepted a connection" from "accepted, but could not answer a hook", and the wizard prints the matching remedy. (#694) +- Gate the systemd unit on the files it cannot run without, so an install that is no longer there stops instead of thrashing. `ConditionPathExists=` now covers both the daemon binary and the worker script. Previously a deleted worker left systemd happily running a daemon that could only deny, and a deleted BINARY was worse: ExecStart failed 203/EXEC under `Restart=on-failure` and cycled until it tripped the start-limit and latched into "start request repeated too quickly", which then refused a legitimate restart later. A failed condition is not a failure — systemd skips the job and `systemctl status` names the exact missing path. Verified against real systemd, including that restoring the file brings the unit straight back. (#PR) +- Tell a skipped service apart from a stopped one. `daemonServiceStatus()` gained `condition-failed`, read from systemd's own `ConditionResult`, and the self-heal in `fp-reset` treats it like "not-installed" — clearing `daemonConfigured` so the machine stops denying every tool call, which "stopped" deliberately never does (a restart in flight looks identical, and clearing there would silently downgrade a healthy machine to the in-process path). (#PR) +- Exempt `uninstall` from the first-run wizard. Offering to set a machine up on the way to tearing it down would install hooks and a root-owned unit seconds before the command removes them. (#PR) +- Stop `failproofai config` refusing to finish against a daemon that is working. The setup health probe sends no `cwd`; the daemon deserialises that as `None` and forwards it with `json!({ "cwd": cwd })`, which writes an explicit **null** rather than omitting the key — and the worker's request validator accepted only `undefined`. So the worker answered "unrecognized request shape", the probe failed, and setup aborted with "its worker process could not be run" **against a worker that had already logged that it was listening**. It was not intermittent: the probe never sends a cwd, so it failed on every machine, every time, and setup could never install a daemon. The validator now treats null and absent alike (a wrong TYPE is still refused) and normalises to `undefined` so nothing downstream learns how the wire spells "absent". The same mismatch sat under real enforcement, not just setup: on a `daemonConfigured` machine a hook payload without a cwd fails closed, i.e. denies the tool call. (#PR) +- Make the health probe wait for the socket instead of racing it. It runs moments after `systemctl enable --now`, and a `Type=simple` unit is reported ACTIVE the instant systemd forks it — before the daemon has bound. The probe got the hook path's deliberately-tight 150ms connect budget and one attempt, so on a loaded machine it lost that race and reported a healthy daemon as broken. It now retries for up to 10s. The 150ms is untouched, because that budget is what stops a dead daemon adding latency to every tool call. (#PR) +- Say which fault the probe actually hit. `DaemonFailure` reports `unreachable` for BOTH a refused connection and a request that was accepted and never answered, so setup told people their worker would not start when nothing was listening at all — sending them to inspect a healthy process. `probeDaemon` now distinguishes "never accepted a connection" from "accepted, but could not answer a hook", and the wizard prints the matching remedy. (#PR) ### Chores @@ -308,46 +338,46 @@ never "blocked". - Suggest the NEAREST subcommand for an unknown one, instead of the literal string "policies" for every input. That was only ever right when the typo happened to be a typo of that word, and removing `auth` made it concrete: `auth` was a real subcommand until this release, so an old script or plain muscle memory lands on this path and was answered with the one command that has nothing to do with what was typed. `failproofai auth` now points at `audit`, `confg` at `config`. The Levenshtein helper the flag guard already used is hoisted so both guards share it. ### Fixes -- Stop the layout reset deleting hand-written policies, and stop it hiding that it did. `resettablePaths()` listed `at("policies")` — an unconditional recursive remove — and on layout 1 that directory IS the documented home for personal convention policies (`docs/configuration.mdx`: "User | `~/.failproofai/policies/`"). Those are source files a person wrote: nothing regenerated them, nothing backed them up, and the printed message named only "policy config, activity history and audit cache". Three things compounded it into a silent enforcement gap. It fired from `failproofai policies --help`, because the help block skips subcommands and the layout check did not exempt help the way the adjacent first-run gate always has. Afterwards the machine still reported itself configured — `isConfigured()` is a union that also counts the agent CLIs' settings files, which the reset deliberately leaves alone — so the wizard was skipped and `markLauncherSeen()` back-filled the marker so every later run skipped it too, leaving hooks firing on every tool call against an empty policy set with nothing ever saying so. The reset now enumerates the machine-owned children (`local-policies`, `cloud-policies`, and layout 1's `cloud-managed`), MOVES top-level policy sources into `policies/custom-policies/` where layout 2's loader actually reads them, names each file it moved, exempts `--help`/`--version`, and reports `didReset` so the caller forces setup. (#694) -- Add the cross-language layout guard that `fp-home.ts` and `paths.rs` had both been citing for versions. `fp-home.ts` named a test containing no reference to `crates/`; `paths.rs` named `crates/failproofaid/tests/layout.rs`, which was never created. While both claimed coverage, three mirrored paths were wrong in production at once. `paths::tests::every_mirrored_path_agrees_with_fp_home_ts` imports the TypeScript module in a child process and compares all ten, rather than restating their values in Rust — which is exactly why the existing hand-written assertions stayed green through all three bugs. (#694) -- Give an installed-but-broken daemon a route back. `ExecStart` bakes in `process.execPath`, so an `nvm uninstall 20` months after setup leaves a unit systemd still calls active whose worker dies on every spawn — and `daemonConfigured` then denies every tool call across all twelve CLIs, `UserPromptSubmit` included, so the user cannot even ask their agent why. Every existing check passed that machine: `waitForDaemonRunning()` asks the service manager, `Ping` is answered without touching the worker, and the wizard's "already installed and running — leaving it alone" branch skipped the documented repair. Adds `probeDaemonEndToEnd()` (a real `SessionStart` hook, 5s budget) and runs it before `daemonConfigured` is ever set — in the wizard rather than inside `installDaemonService`, which can only honestly report on the service, and whose install mechanics are testable against a stub binary precisely because the two are kept apart. Extends `healDaemonFlag` from not-installed to running-but-broken, and gives `uninstallDaemonService` its first production caller — the wizard tears a wedged unit down before reinstalling, since it holds the singleton flock the replacement needs. Also adds the first `forceDecision` test: the fail-closed path, the most consequential branch in the product, had none. (#694) -- Stop a policy file with a never-resolving top-level `await` wedging the daemon permanently. `worker-server.ts`'s `.catch()` covers a queued task that REJECTS and does nothing for one that never SETTLES, which a bare `await import()` of such a module produces — so every subsequent hook on the machine queued behind it forever and fail-closed denied, across every CLI, until someone restarted the daemon. A class the warm worker creates: in the one-shot path the identical file hangs one hook process and the agent CLI's own timeout reaps it. The import is now bounded at 10s (matching the per-policy budget), with a 60s backstop on the queue that exits rather than continuing — the orphaned task still holds the `globalThis` registry the chain exists to serialize. (#694) -- Anchor `block-self-pause` to command position. `SELF_PAUSE_RE` had none, so any command merely CONTAINING the string matched: `grep -rn "failproofai config --pause" docs/`, `git commit -m "docs: explain failproofai config --pause"`, `gh pr create --body`, `git log --grep`. The policy is `defaultEnabled`, and this repo's own CHANGELOG and `docs/built-in-policies.mdx` carry that literal string — so the first thing it did on a real machine was deny an agent reading the documentation for it. Its sibling `FAILPROOFAI_CLI_RE` was anchored from the start. Now matched structurally: segments split on shell operators (including command substitution), runners and their flags walked off, and the binary required where the shell will look for a command. All fourteen existing red-team spellings still deny. (#694) -- Filter CLIs per scope in the wizard's apply loop. "Both" + "Everything available" built the CLI list as the union across scopes — correct, so a user-scope-only gateway is still installed via the user half — and then passed that union to EVERY scope. `installHooksImpl` validates each CLI against the scope up front and THROWS (`Scope "project" is not supported by Hermes`); it does not skip, despite the comment here that said it did. With no try/catch the run died mid-apply, after the daemon was installed, `daemonConfigured` was set and user-scope hooks were written, and before any project config or the pasted cloud key. The wizard's own tests mock `installHooks` wholesale, so the real validation path was never exercised. (#694) -- Validate the cloud URL inside `connectToCloud`. `ConnectInput.url` was documented as "already validated by `validateCloudUrl`", and only one of the two callers did it: `--connect` validated, while the interactive wizard — the documented primary path — matched `/^https?:\/\//` and handed the raw string to `validateIngestKey` and then to `connectToCloud`. So the flow most people use put the machine's bearer token on the wire in clear against any `http://` host. The check now runs at the boundary that depends on it, so no path can skip it, and at the prompt as well so a typo fails before the key is even asked for. Loopback over http still works, which is what the local walkthrough needs. (#694) -- Fix redaction leaking the tail of every non-ASCII secret. `match_bearer` and `match_assignment` returned `.chars().count()` where `scrub_str` uses the value as a BYTE offset (`i += len`). Both predicates accept non-ASCII — unlike `is_token_char` and the JWT matcher's `is_b64`, which are ASCII-only and where the two counts coincide — so one multi-byte character left the cursor inside the secret and the unconsumed tail was copied out verbatim. Eleven two-byte characters leak eleven bytes, which in a realistic value is the whole readable tail. Invisible to every existing test because every token in them was ASCII; the new test asserts exact equality, since a "does the tail survive" check passes while the bug is fully present. (#694) -- Stop a corrupted cloud-managed manifest aborting hook evaluation. `readActiveCloudManagedPolicies()` has fourteen throw sites and sat bare inside `evaluateHookEvent`'s `try`, whose only handler is a `finally`. What that cost depended on where the hook ran, and neither outcome was intended: on a daemon machine the client fail-closed denies everything, and off it the throw reaches the CLI's outer catch, which exits 2 with nothing on stdout — a deny on Claude and Factory, but a logged warning followed by an ALLOW on Copilot, Cursor, Goose, Pi and Hermes, which read a decision off stdout and ignore the exit code. So one corrupt byte was either a permanent machine-wide lockout or silent non-enforcement, depending on the CLI. Now wrapped like its siblings: the cloud layer degrades alone and loudly, while builtins and local custom policies keep enforcing. (#694) -- Bound a daemon connection by an actual deadline. `CONNECTION_IO_TIMEOUT` was handed to `set_read_timeout`, which is `SO_RCVTIMEO` and bounds ONE `read(2)`, while `read_message` reads through `read_exact` — so every byte that arrived reset the clock. A peer dribbling one byte every nine seconds satisfied it forever while pinning its handler thread, and 64 of those fill `MAX_INFLIGHT_CONNECTIONS`, after which the daemon refuses every real hook and a `daemonConfigured` machine fails closed on all of them. `server.rs`'s own comment asserted the opposite invariant. Replaced with a `Deadline` wrapper enforcing one wall-clock budget across every read and write. The worker stream also gained the write timeout it never had: `write_message` is a blocking `write_all`, so a worker whose single-threaded loop stalls stops draining its socket and blocks the writer once the kernel send buffer fills, with nothing to reclaim the thread. (#694) -- Unlink the daemon socket path unconditionally. `Server::bind` used `Path::exists()`, which FOLLOWS symlinks — so a dangling symlink at the socket path reported false, was never cleaned, and `UnixListener::bind` failed `EADDRINUSE`. With `Restart=on-failure` that is a crash loop, and a crash-looping daemon on a `daemonConfigured` machine denies every tool call. One `ln -s` away. (#694) -- Reset the whole cursor when a tailed file is truncated, and detect inode reuse. The truncation branch reset `offset` and `state` and kept everything else, so for a `ValidatePrefix` format `rebase_on_first_line` applied a delta computed against the OLD first line to the freshly-zeroed offset and skipped bytes — and `agent_start_emitted` staying true meant the replacement content never announced a session, which the server selects on, so it was spooled and then absent from the product entirely. It now re-derives the cursor from the file as it is. Separately, the inode-reuse guard only fired when the recorded path still EXISTED and still held that inode, and in a real reuse the old file was unlinked — which is how its inode came to be free — so it never fired for the case it was named after. Cursors now carry a fingerprint of the file's first bytes, checked only when a resumed cursor's path has changed, so rotation still resumes and reuse does not. (#694) -- Stop the collector fsyncing an unchanged cursor map every two seconds per source. `save()` ran unconditionally at the end of every pass, serializing the whole map with `to_string_pretty`, `sync_all`ing and renaming — and the map grows monotonically, because `retain_existing` only drops cursors for DELETED files and agent transcripts are never deleted. Now gated on a dirty flag, cleared only after a successful rename so a failed write retries rather than being silently dropped. (#694) -- Let a file tailer report an error. `poll_once` warned per file and returned `Ok(0)` even when every file failed, and `record_poll` then unconditionally cleared `last_error` — so a source whose root is unreadable was indistinguishable from an idle one, which is the exact distinction per-source health exists to draw. Relatedly, every Hermes profile reported under the bare key `"hermes"` despite each already having its own cursor directory for the same reason, so two profiles with one database missing made `collector-health.json` alternate `root_present` true/false every five seconds. (#694) -- Fold the attribution into the aggregate `hook_id`. `BucketKey` deliberately includes `Attribution` so a minute mixing policy sources emits one aggregate per source, but `to_event` built the id from `session:minute:event:tool:agg` alone — so two buckets the key had just split apart carried byte-identical ids, and per that file's own header the server dedups on `hook_id`. The split was therefore undone downstream in exactly the two cases it was built for: the minute a pause starts, and the minute a cloud generation flips during a rollout, which is the measurement `cloud_generation` exists to enable. `tests/hooks_source.rs` constructed that precise collision and never compared the two ids. (#694) -- Make the 8h pause ceiling an actual ceiling. It was measured from `pausedAt`, which every renewal reset to now, so `--pause 8h` re-issued every seven hours suspended enforcement indefinitely — one individually-legal command at a time, with every check passing. Pauses now carry `firstPausedAt`, and the expiry is clamped against it at WRITE and at READ, so a hand-edited state file in the owner-writable state directory cannot buy an unbounded pause either. A lapsed pause starts a fresh ceiling: the bound is on one unbroken stretch of suspended enforcement, not a daily quota. `maxPauseMs` is removed rather than wired up — the merge in `hooks-config.ts` never emitted it, so the lookup could only ever read `undefined`, and its two tests `vi.mock`ed that function to return a field the real one cannot produce. (#694) -- Make `--disconnect` disconnect. It cleared the credential, which stops POLLING — every artifact already on disk stayed referenced by `active.json` and kept being loaded and enforced on every tool call, so a machine that had deliberately left its organisation went on being governed by whatever generation was current when it left, indefinitely, while `--status` reported it as unconnected. It also printed "Hook activity and transcripts stop being sent", which was not true of the running daemon: the collector manager starts once for the daemon's lifetime and the uploader caches its bearer key at construction, so nothing already running notices the file disappear. The manifest is now cleared, and the message names the restart instead of asserting something false. (#694) -- Refuse to compose a service definition from a value carrying a quote, backslash or newline. `systemdUnitContents` interpolated `workerCmd`/`cliCmd`/`binaryPath`/`homedir()`/`User=` into a unit installed root-owned at `/etc/systemd/system` and loaded at every boot, with no escaping for systemd's grammar — and a newline ENDS a directive. This repo's own refresh test demonstrates the mechanism by setting `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and relying on systemd HONOURING the injected `User=`; it passes only because that user does not exist, where `User=root` or an added `ExecStartPre=` would have succeeded silently and undone the "root-installed but never root-run" invariant. These values are resolved paths and commands, so this rejects rather than inventing an escaping scheme, and the refusal happens before anything is written or stopped. (#694) -- Stop leaking a generated policy module per killed hook, and stop reporting them back to the user. The temporary tree is written beside the user's sources — the only place a rewritten relative import resolves — and its name now carries a pid and sequence number, so unlike the old fixed name each abnormal termination leaks a file permanently instead of leaving one the next load overwrites. `findSkippedPolicyFiles` then reported each leftover as a policy file that would not load, which is an accusation about a file failproofai wrote itself. Generated files are now excluded from that scan and swept on load, age-gated so a sweep can never remove a tree another process is still importing. `policyModuleCache` also gained the cap its sibling `gitBranchCache` states the rationale for and it never carried over. (#694) -- Do not orphan the worker when SIGTERM races its cold start. `main.rs` pre-warms on a detached thread holding its own `Arc` whose `JoinHandle` was discarded, so a signal arriving while that thread was still inside `ensure_started()` — hundreds of milliseconds, against an accept loop that returns in tens — left the refcount above zero when `run()` dropped its reference. `Worker::drop` never fired, the worker's process group was never killed, and the daemon exited leaving it running. Adds an explicit `Worker::shutdown()` whose flag is checked under the same lock `ensure_started` takes, so a warm-up that had not yet spawned refuses instead of installing a worker after the kill. (#694) -- Repair cloud-managed policies from a valid `active.json` even when `desired-state.json` is corrupt. `repair_active_from_cache` propagated any parse error straight out, short-circuiting before the branch that rebuilds a tampered generation copy from the content-addressed artifact — so one bad byte in a file that branch does not need permanently disabled self-healing, and per `CLOUD_POLICIES.md` the only thing that rewrites it is a successful cloud poll, which never happens on an unenrolled or unreachable machine. `reconcile()` already tolerated exactly this for `active.json`; the two are now symmetric. (#694) -- Wire `[collector] redact` to the sources it configures. It parsed correctly and reached nothing: no source carried the field, so `SpoolWriter::with_redact` had exactly two references — its own definition and its own unit test — and every real writer kept the hardcoded `Redact::Minimal`. Setting `redact = "off"` had no observable effect anywhere, which is worse than not offering the setting. (#694) -- Sweep the layout-1 paths that survived the reorganisation. `install-check.ts` read layout 1's `policies-config.json`, so `checkHooks()` reported every layout-2 machine as unconfigured with zero policies and `package_installed` telemetry has recorded that for every install since; `manager.ts` printed that path in all three render states, so a user who hand-edited it to enable a policy got silence; and the wizard's convention scan read the layout-1 global directory while the loader reads `customPoliciesDir()`. `last-version` moved under `state/`, which also fixes the banner every fresh install saw — the CLI wrote that file and then read it back as one of `detectLayout()`'s layout-1 landmarks, so a brand-new home classified as stale and opened with "Removed 1 item(s) from the old layout". The install report now runs after the layout check for the same reason. (#694) -- Report the wizard's real outcome. `cli_configure_invoked` sent `result.scope`, a field the wizard rework replaced with `target`/`scopes`, so it has been null on every run since — `.mjs` is outside the tsconfig include, so `tsc --noEmit` could not catch it. The same call site discarded `result.abort`, so `failproofai config` exited 0 even when the machine was left unconfigured because the required daemon could not be installed, which a fleet script cannot distinguish from a user pressing Esc. (#694) -- Bump the Cargo workspace version alongside `package.json` when a release opens the next development cycle, and serialize `publish.yml`. CI compares the two versions and the bump commit carries `[skip ci]`, so a bump that moved only `package.json` left `main` red and the failure surfaced on the next unrelated PR as `Version mismatch: Cargo.toml has …`. Every release did this. `publish.yml` also had no `concurrency` group despite two entry points that can fire for the same version — both would pass the "already published" preflight before either published, and the bump step's unguarded `git push origin main` simply loses for one of them. Not `cancel-in-progress`: the release assets attach before the npm publish, so a run killed between them leaves a tag whose binaries exist and whose package does not. (#694) -- Scan `Cargo.lock` for known-vulnerable dependencies. Its 238 packages were covered by nothing — OSV-Scanner was only ever given `bun.lock`, and Dependabot had no `cargo` ecosystem — for a TLS stack that compiles into a root-installed system service. (#694) +- Stop the layout reset deleting hand-written policies, and stop it hiding that it did. `resettablePaths()` listed `at("policies")` — an unconditional recursive remove — and on layout 1 that directory IS the documented home for personal convention policies (`docs/configuration.mdx`: "User | `~/.failproofai/policies/`"). Those are source files a person wrote: nothing regenerated them, nothing backed them up, and the printed message named only "policy config, activity history and audit cache". Three things compounded it into a silent enforcement gap. It fired from `failproofai policies --help`, because the help block skips subcommands and the layout check did not exempt help the way the adjacent first-run gate always has. Afterwards the machine still reported itself configured — `isConfigured()` is a union that also counts the agent CLIs' settings files, which the reset deliberately leaves alone — so the wizard was skipped and `markLauncherSeen()` back-filled the marker so every later run skipped it too, leaving hooks firing on every tool call against an empty policy set with nothing ever saying so. The reset now enumerates the machine-owned children (`local-policies`, `cloud-policies`, and layout 1's `cloud-managed`), MOVES top-level policy sources into `policies/custom-policies/` where layout 2's loader actually reads them, names each file it moved, exempts `--help`/`--version`, and reports `didReset` so the caller forces setup. (#PR) +- Add the cross-language layout guard that `fp-home.ts` and `paths.rs` had both been citing for versions. `fp-home.ts` named a test containing no reference to `crates/`; `paths.rs` named `crates/failproofaid/tests/layout.rs`, which was never created. While both claimed coverage, three mirrored paths were wrong in production at once. `paths::tests::every_mirrored_path_agrees_with_fp_home_ts` imports the TypeScript module in a child process and compares all ten, rather than restating their values in Rust — which is exactly why the existing hand-written assertions stayed green through all three bugs. (#PR) +- Give an installed-but-broken daemon a route back. `ExecStart` bakes in `process.execPath`, so an `nvm uninstall 20` months after setup leaves a unit systemd still calls active whose worker dies on every spawn — and `daemonConfigured` then denies every tool call across all twelve CLIs, `UserPromptSubmit` included, so the user cannot even ask their agent why. Every existing check passed that machine: `waitForDaemonRunning()` asks the service manager, `Ping` is answered without touching the worker, and the wizard's "already installed and running — leaving it alone" branch skipped the documented repair. Adds `probeDaemonEndToEnd()` (a real `SessionStart` hook, 5s budget) and runs it before `daemonConfigured` is ever set — in the wizard rather than inside `installDaemonService`, which can only honestly report on the service, and whose install mechanics are testable against a stub binary precisely because the two are kept apart. Extends `healDaemonFlag` from not-installed to running-but-broken, and gives `uninstallDaemonService` its first production caller — the wizard tears a wedged unit down before reinstalling, since it holds the singleton flock the replacement needs. Also adds the first `forceDecision` test: the fail-closed path, the most consequential branch in the product, had none. (#PR) +- Stop a policy file with a never-resolving top-level `await` wedging the daemon permanently. `worker-server.ts`'s `.catch()` covers a queued task that REJECTS and does nothing for one that never SETTLES, which a bare `await import()` of such a module produces — so every subsequent hook on the machine queued behind it forever and fail-closed denied, across every CLI, until someone restarted the daemon. A class the warm worker creates: in the one-shot path the identical file hangs one hook process and the agent CLI's own timeout reaps it. The import is now bounded at 10s (matching the per-policy budget), with a 60s backstop on the queue that exits rather than continuing — the orphaned task still holds the `globalThis` registry the chain exists to serialize. (#PR) +- Anchor `block-self-pause` to command position. `SELF_PAUSE_RE` had none, so any command merely CONTAINING the string matched: `grep -rn "failproofai config --pause" docs/`, `git commit -m "docs: explain failproofai config --pause"`, `gh pr create --body`, `git log --grep`. The policy is `defaultEnabled`, and this repo's own CHANGELOG and `docs/built-in-policies.mdx` carry that literal string — so the first thing it did on a real machine was deny an agent reading the documentation for it. Its sibling `FAILPROOFAI_CLI_RE` was anchored from the start. Now matched structurally: segments split on shell operators (including command substitution), runners and their flags walked off, and the binary required where the shell will look for a command. All fourteen existing red-team spellings still deny. (#PR) +- Filter CLIs per scope in the wizard's apply loop. "Both" + "Everything available" built the CLI list as the union across scopes — correct, so a user-scope-only gateway is still installed via the user half — and then passed that union to EVERY scope. `installHooksImpl` validates each CLI against the scope up front and THROWS (`Scope "project" is not supported by Hermes`); it does not skip, despite the comment here that said it did. With no try/catch the run died mid-apply, after the daemon was installed, `daemonConfigured` was set and user-scope hooks were written, and before any project config or the pasted cloud key. The wizard's own tests mock `installHooks` wholesale, so the real validation path was never exercised. (#PR) +- Validate the cloud URL inside `connectToCloud`. `ConnectInput.url` was documented as "already validated by `validateCloudUrl`", and only one of the two callers did it: `--connect` validated, while the interactive wizard — the documented primary path — matched `/^https?:\/\//` and handed the raw string to `validateIngestKey` and then to `connectToCloud`. So the flow most people use put the machine's bearer token on the wire in clear against any `http://` host. The check now runs at the boundary that depends on it, so no path can skip it, and at the prompt as well so a typo fails before the key is even asked for. Loopback over http still works, which is what the local walkthrough needs. (#PR) +- Fix redaction leaking the tail of every non-ASCII secret. `match_bearer` and `match_assignment` returned `.chars().count()` where `scrub_str` uses the value as a BYTE offset (`i += len`). Both predicates accept non-ASCII — unlike `is_token_char` and the JWT matcher's `is_b64`, which are ASCII-only and where the two counts coincide — so one multi-byte character left the cursor inside the secret and the unconsumed tail was copied out verbatim. Eleven two-byte characters leak eleven bytes, which in a realistic value is the whole readable tail. Invisible to every existing test because every token in them was ASCII; the new test asserts exact equality, since a "does the tail survive" check passes while the bug is fully present. (#PR) +- Stop a corrupted cloud-managed manifest aborting hook evaluation. `readActiveCloudManagedPolicies()` has fourteen throw sites and sat bare inside `evaluateHookEvent`'s `try`, whose only handler is a `finally`. What that cost depended on where the hook ran, and neither outcome was intended: on a daemon machine the client fail-closed denies everything, and off it the throw reaches the CLI's outer catch, which exits 2 with nothing on stdout — a deny on Claude and Factory, but a logged warning followed by an ALLOW on Copilot, Cursor, Goose, Pi and Hermes, which read a decision off stdout and ignore the exit code. So one corrupt byte was either a permanent machine-wide lockout or silent non-enforcement, depending on the CLI. Now wrapped like its siblings: the cloud layer degrades alone and loudly, while builtins and local custom policies keep enforcing. (#PR) +- Bound a daemon connection by an actual deadline. `CONNECTION_IO_TIMEOUT` was handed to `set_read_timeout`, which is `SO_RCVTIMEO` and bounds ONE `read(2)`, while `read_message` reads through `read_exact` — so every byte that arrived reset the clock. A peer dribbling one byte every nine seconds satisfied it forever while pinning its handler thread, and 64 of those fill `MAX_INFLIGHT_CONNECTIONS`, after which the daemon refuses every real hook and a `daemonConfigured` machine fails closed on all of them. `server.rs`'s own comment asserted the opposite invariant. Replaced with a `Deadline` wrapper enforcing one wall-clock budget across every read and write. The worker stream also gained the write timeout it never had: `write_message` is a blocking `write_all`, so a worker whose single-threaded loop stalls stops draining its socket and blocks the writer once the kernel send buffer fills, with nothing to reclaim the thread. (#PR) +- Unlink the daemon socket path unconditionally. `Server::bind` used `Path::exists()`, which FOLLOWS symlinks — so a dangling symlink at the socket path reported false, was never cleaned, and `UnixListener::bind` failed `EADDRINUSE`. With `Restart=on-failure` that is a crash loop, and a crash-looping daemon on a `daemonConfigured` machine denies every tool call. One `ln -s` away. (#PR) +- Reset the whole cursor when a tailed file is truncated, and detect inode reuse. The truncation branch reset `offset` and `state` and kept everything else, so for a `ValidatePrefix` format `rebase_on_first_line` applied a delta computed against the OLD first line to the freshly-zeroed offset and skipped bytes — and `agent_start_emitted` staying true meant the replacement content never announced a session, which the server selects on, so it was spooled and then absent from the product entirely. It now re-derives the cursor from the file as it is. Separately, the inode-reuse guard only fired when the recorded path still EXISTED and still held that inode, and in a real reuse the old file was unlinked — which is how its inode came to be free — so it never fired for the case it was named after. Cursors now carry a fingerprint of the file's first bytes, checked only when a resumed cursor's path has changed, so rotation still resumes and reuse does not. (#PR) +- Stop the collector fsyncing an unchanged cursor map every two seconds per source. `save()` ran unconditionally at the end of every pass, serializing the whole map with `to_string_pretty`, `sync_all`ing and renaming — and the map grows monotonically, because `retain_existing` only drops cursors for DELETED files and agent transcripts are never deleted. Now gated on a dirty flag, cleared only after a successful rename so a failed write retries rather than being silently dropped. (#PR) +- Let a file tailer report an error. `poll_once` warned per file and returned `Ok(0)` even when every file failed, and `record_poll` then unconditionally cleared `last_error` — so a source whose root is unreadable was indistinguishable from an idle one, which is the exact distinction per-source health exists to draw. Relatedly, every Hermes profile reported under the bare key `"hermes"` despite each already having its own cursor directory for the same reason, so two profiles with one database missing made `collector-health.json` alternate `root_present` true/false every five seconds. (#PR) +- Fold the attribution into the aggregate `hook_id`. `BucketKey` deliberately includes `Attribution` so a minute mixing policy sources emits one aggregate per source, but `to_event` built the id from `session:minute:event:tool:agg` alone — so two buckets the key had just split apart carried byte-identical ids, and per that file's own header the server dedups on `hook_id`. The split was therefore undone downstream in exactly the two cases it was built for: the minute a pause starts, and the minute a cloud generation flips during a rollout, which is the measurement `cloud_generation` exists to enable. `tests/hooks_source.rs` constructed that precise collision and never compared the two ids. (#PR) +- Make the 8h pause ceiling an actual ceiling. It was measured from `pausedAt`, which every renewal reset to now, so `--pause 8h` re-issued every seven hours suspended enforcement indefinitely — one individually-legal command at a time, with every check passing. Pauses now carry `firstPausedAt`, and the expiry is clamped against it at WRITE and at READ, so a hand-edited state file in the owner-writable state directory cannot buy an unbounded pause either. A lapsed pause starts a fresh ceiling: the bound is on one unbroken stretch of suspended enforcement, not a daily quota. `maxPauseMs` is removed rather than wired up — the merge in `hooks-config.ts` never emitted it, so the lookup could only ever read `undefined`, and its two tests `vi.mock`ed that function to return a field the real one cannot produce. (#PR) +- Make `--disconnect` disconnect. It cleared the credential, which stops POLLING — every artifact already on disk stayed referenced by `active.json` and kept being loaded and enforced on every tool call, so a machine that had deliberately left its organisation went on being governed by whatever generation was current when it left, indefinitely, while `--status` reported it as unconnected. It also printed "Hook activity and transcripts stop being sent", which was not true of the running daemon: the collector manager starts once for the daemon's lifetime and the uploader caches its bearer key at construction, so nothing already running notices the file disappear. The manifest is now cleared, and the message names the restart instead of asserting something false. (#PR) +- Refuse to compose a service definition from a value carrying a quote, backslash or newline. `systemdUnitContents` interpolated `workerCmd`/`cliCmd`/`binaryPath`/`homedir()`/`User=` into a unit installed root-owned at `/etc/systemd/system` and loaded at every boot, with no escaping for systemd's grammar — and a newline ENDS a directive. This repo's own refresh test demonstrates the mechanism by setting `FAILPROOFAI_CLI_CMD` to `/usr/bin/true"\nUser=failproofai-no-such-user` and relying on systemd HONOURING the injected `User=`; it passes only because that user does not exist, where `User=root` or an added `ExecStartPre=` would have succeeded silently and undone the "root-installed but never root-run" invariant. These values are resolved paths and commands, so this rejects rather than inventing an escaping scheme, and the refusal happens before anything is written or stopped. (#PR) +- Stop leaking a generated policy module per killed hook, and stop reporting them back to the user. The temporary tree is written beside the user's sources — the only place a rewritten relative import resolves — and its name now carries a pid and sequence number, so unlike the old fixed name each abnormal termination leaks a file permanently instead of leaving one the next load overwrites. `findSkippedPolicyFiles` then reported each leftover as a policy file that would not load, which is an accusation about a file failproofai wrote itself. Generated files are now excluded from that scan and swept on load, age-gated so a sweep can never remove a tree another process is still importing. `policyModuleCache` also gained the cap its sibling `gitBranchCache` states the rationale for and it never carried over. (#PR) +- Do not orphan the worker when SIGTERM races its cold start. `main.rs` pre-warms on a detached thread holding its own `Arc` whose `JoinHandle` was discarded, so a signal arriving while that thread was still inside `ensure_started()` — hundreds of milliseconds, against an accept loop that returns in tens — left the refcount above zero when `run()` dropped its reference. `Worker::drop` never fired, the worker's process group was never killed, and the daemon exited leaving it running. Adds an explicit `Worker::shutdown()` whose flag is checked under the same lock `ensure_started` takes, so a warm-up that had not yet spawned refuses instead of installing a worker after the kill. (#PR) +- Repair cloud-managed policies from a valid `active.json` even when `desired-state.json` is corrupt. `repair_active_from_cache` propagated any parse error straight out, short-circuiting before the branch that rebuilds a tampered generation copy from the content-addressed artifact — so one bad byte in a file that branch does not need permanently disabled self-healing, and per `CLOUD_POLICIES.md` the only thing that rewrites it is a successful cloud poll, which never happens on an unenrolled or unreachable machine. `reconcile()` already tolerated exactly this for `active.json`; the two are now symmetric. (#PR) +- Wire `[collector] redact` to the sources it configures. It parsed correctly and reached nothing: no source carried the field, so `SpoolWriter::with_redact` had exactly two references — its own definition and its own unit test — and every real writer kept the hardcoded `Redact::Minimal`. Setting `redact = "off"` had no observable effect anywhere, which is worse than not offering the setting. (#PR) +- Sweep the layout-1 paths that survived the reorganisation. `install-check.ts` read layout 1's `policies-config.json`, so `checkHooks()` reported every layout-2 machine as unconfigured with zero policies and `package_installed` telemetry has recorded that for every install since; `manager.ts` printed that path in all three render states, so a user who hand-edited it to enable a policy got silence; and the wizard's convention scan read the layout-1 global directory while the loader reads `customPoliciesDir()`. `last-version` moved under `state/`, which also fixes the banner every fresh install saw — the CLI wrote that file and then read it back as one of `detectLayout()`'s layout-1 landmarks, so a brand-new home classified as stale and opened with "Removed 1 item(s) from the old layout". The install report now runs after the layout check for the same reason. (#PR) +- Report the wizard's real outcome. `cli_configure_invoked` sent `result.scope`, a field the wizard rework replaced with `target`/`scopes`, so it has been null on every run since — `.mjs` is outside the tsconfig include, so `tsc --noEmit` could not catch it. The same call site discarded `result.abort`, so `failproofai config` exited 0 even when the machine was left unconfigured because the required daemon could not be installed, which a fleet script cannot distinguish from a user pressing Esc. (#PR) +- Bump the Cargo workspace version alongside `package.json` when a release opens the next development cycle, and serialize `publish.yml`. CI compares the two versions and the bump commit carries `[skip ci]`, so a bump that moved only `package.json` left `main` red and the failure surfaced on the next unrelated PR as `Version mismatch: Cargo.toml has …`. Every release did this. `publish.yml` also had no `concurrency` group despite two entry points that can fire for the same version — both would pass the "already published" preflight before either published, and the bump step's unguarded `git push origin main` simply loses for one of them. Not `cancel-in-progress`: the release assets attach before the npm publish, so a run killed between them leaves a tag whose binaries exist and whose package does not. (#PR) +- Scan `Cargo.lock` for known-vulnerable dependencies. Its 238 packages were covered by nothing — OSV-Scanner was only ever given `bun.lock`, and Dependabot had no `cargo` ecosystem — for a TLS stack that compiles into a root-installed system service. (#PR) ## 1.0.0-beta.5 — 2026-08-05 ### Fixes -- Write the ESM shim into the user's own state directory instead of beside the installed `dist/index.js`, so a non-root user can load cloud-managed and custom policies at all. The shim is what makes `import ... from 'failproofai'` resolve inside a policy file, and it was written into the package's own directory — which belongs to whoever installed it. On a **system-wide install** (`sudo npm i -g`, a container image, a shared build host, a CI runner) that directory is root-owned, so every hook run by a non-root user failed with `EACCES`, the policy never loaded, and **the hook exited 0 — the tool call was allowed**. Builtin policies need no file loading and kept firing, so the machine looked protected: denies appeared, the dashboard showed activity, `--status` reported connected and pulling, while the organisation's centrally-managed policy did nothing. The only signal was one line on stderr. Reproduced in a container and confirmed by toggling nothing but that directory's mode — `chmod 777` and the policy denies, `chmod 755` and the same call is allowed. A single-user machine never saw it because npm's global prefix there (`~/.nvm/versions/node/*/lib/node_modules`) is owned by the user running the hook. The state directory is normally created by us at 0700 and owned by that user, and — unlike a shared `/tmp` — no other local user can pre-plant a file at a path we are about to `import`. It is not assumed: `mkdir` with `recursive` resolves on a directory that already exists **whatever its mode**, so a `state/shims` left behind unwritable (a container that ran the CLI as root and then dropped to a non-root user) would otherwise sail past the guard and throw on the write, reproducing the very fail-open being fixed. The write therefore sits inside the same guard as the mkdir, the directory is `lstat`-checked to be a real, private, self-owned one (a plain write follows a planted symlink straight out of the home), the shim is written 0600 rather than inheriting `0666 & ~umask`, and any failure degrades to `os.tmpdir()` **loudly** — a silent slide into the weaker path is this bug's own shape. There the name is predictable, so the write is `O_EXCL`, and the per-load suffix now carries a random id so a leftover file cannot fail a legitimate load. **This does not close the whole class:** rewritten policy copies are still written beside their source, so a read-only policy directory (a root-owned org policy pack, a `:ro` mount) still fails the same way — untouched here, and worth fixing separately. The file name still carries the per-invocation suffix, because `fingerprintTemporaryTree` normalises exactly that substring away and a name it could not normalise would miss the policy module cache on every hook call. (#694) +- Write the ESM shim into the user's own state directory instead of beside the installed `dist/index.js`, so a non-root user can load cloud-managed and custom policies at all. The shim is what makes `import ... from 'failproofai'` resolve inside a policy file, and it was written into the package's own directory — which belongs to whoever installed it. On a **system-wide install** (`sudo npm i -g`, a container image, a shared build host, a CI runner) that directory is root-owned, so every hook run by a non-root user failed with `EACCES`, the policy never loaded, and **the hook exited 0 — the tool call was allowed**. Builtin policies need no file loading and kept firing, so the machine looked protected: denies appeared, the dashboard showed activity, `--status` reported connected and pulling, while the organisation's centrally-managed policy did nothing. The only signal was one line on stderr. Reproduced in a container and confirmed by toggling nothing but that directory's mode — `chmod 777` and the policy denies, `chmod 755` and the same call is allowed. A single-user machine never saw it because npm's global prefix there (`~/.nvm/versions/node/*/lib/node_modules`) is owned by the user running the hook. The state directory is normally created by us at 0700 and owned by that user, and — unlike a shared `/tmp` — no other local user can pre-plant a file at a path we are about to `import`. It is not assumed: `mkdir` with `recursive` resolves on a directory that already exists **whatever its mode**, so a `state/shims` left behind unwritable (a container that ran the CLI as root and then dropped to a non-root user) would otherwise sail past the guard and throw on the write, reproducing the very fail-open being fixed. The write therefore sits inside the same guard as the mkdir, the directory is `lstat`-checked to be a real, private, self-owned one (a plain write follows a planted symlink straight out of the home), the shim is written 0600 rather than inheriting `0666 & ~umask`, and any failure degrades to `os.tmpdir()` **loudly** — a silent slide into the weaker path is this bug's own shape. There the name is predictable, so the write is `O_EXCL`, and the per-load suffix now carries a random id so a leftover file cannot fail a legitimate load. **This does not close the whole class:** rewritten policy copies are still written beside their source, so a read-only policy directory (a root-owned org policy pack, a `:ro` mount) still fails the same way — untouched here, and worth fixing separately. The file name still carries the per-invocation suffix, because `fingerprintTemporaryTree` normalises exactly that substring away and a name it could not normalise would miss the policy module cache on every hook call. (#PR) - Capture Pi's tool events and Hermes's working directory, both of which the audit adapters were discarding. Pi's parser handled only `text` and `thinking` content blocks, so `toolCall` blocks fell through to the generic "system" branch and the separate `role: "toolResult"` records attached to nothing — Pi contributed zero tool events. The file's own header recorded this as "tool-call blocks are not yet observed", and kept an unused `formatTimestamp` import alive with a `void` for "once Pi emits it", so the gap was known but its premise was wrong rather than stale: verified against pi 0.73.1 and 0.83.0, an assistant turn carries `{type:"toolCall", id, name, arguments}` and each result arrives as its own record with a third role (`toolCallId`, `toolName`, `content[]`, `isError`). Results now pair to their call by id rather than position — Pi emits them in call order today, but pairing by order would break silently the first time it does not — and duration is derived from the call/result gap since Pi records none, matching the OpenClaw parser. Separately, the Hermes adapter returned nothing at all for `audit --project `, on the premise that gateway sessions have no working directory; verified against hermes-agent 0.19.0, `sessions` carries real `cwd`, `git_branch` and `git_repo_root` columns and every `source='cli'` session populates them, so a repo the user had driven Hermes in silently reported zero Hermes findings. Sessions with a cwd now filter and group by working directory like Claude/Goose/Devin, while genuinely cwd-less Slack/Telegram sessions keep their `(profile, source)` bucket and stay excluded from cwd filters. (#639) -- Clear systemd's start-limit before the daemon-service refresh restarts, or the rollback that is supposed to save the machine cannot. The unit ships `Restart=on-failure` with `RestartSec=2`, so a rewrite systemd accepts but cannot run (the poisoned `User=` the refresh test injects; in the field, a genuinely un-startable regenerated unit) does not fail once — it cycles, and within `DefaultStartLimitIntervalSec` trips `DefaultStartLimitBurst` and latches into "start request repeated too quickly". On systemd 255 — what ubuntu-24.04 and the CI runners ship — that latch is sticky at the unit level: the rollback restores a perfectly runnable definition, `systemctl restart` is refused anyway, and `ensureDaemonServiceCurrent` returns `daemonRunning:false` on a machine whose only safety net just failed — which on a `daemonConfigured` box is every tool call across all 12 CLIs denied. `restartSystemdUnit()` now runs `systemctl reset-failed` (best-effort; a no-op on a healthy unit) before every refresh/rollback restart, making the restart deterministic. The behaviour is load-dependent — an idle machine accumulates too few cycles to trip the limit before the 5s wait ends, which is why it surfaced only under CI's parallel-suite load — and was proven both ways against real systemd 255: original code returns `daemonRunning:false`, the fix returns `daemonRunning:true`. (#694) -- Make the dashboard's audit run take the cross-process cache lock, closing a three-writer race the `/settings` "run now" button turned into an everyday path. A scheduled daemon child, `failproofai audit`, and `POST /api/audit/run` all write the same sha1-keyed per-transcript cache and single-slot dashboard cache, but the dashboard route guarded only against overlapping runs *within its own Next.js process* — invisible to the other two, so a dashboard scan could co-write the cache with a scheduled run the daemon started at the same moment, and each would clobber the other's entries. `/api/audit/run` now also acquires `src/audit/audit-lock.ts`; held ⇒ it backs the in-memory lock out and returns the same `409 already-running` the client already treats as "poll the in-flight run". `/api/audit/status` folds the cross-process lock (via a new side-effect-free `readActiveAuditLock`, which applies the same dead-pid/age staleness rules so a crashed run's leftover file never wedges status at "running") into `running`, so a client polling after that 409 — and the settings page on mount — sees the machine as busy and waits the external scan out instead of reading idle. `runPostSetupAudit`, the fourth writer, takes the lock too and skips when it is held. A scan already running is information, not an error. (#694) -- A daemon lane that the OS refused to start took the whole daemon with it. `telemetry::spawn`, `audit_lane::spawn` and `spawn_collector_manager` each `.expect()`ed `Builder::spawn`, so a machine at its thread limit (`EAGAIN`) panicked `run()` — and a `failproofaid` that will not start denies every tool call across all twelve CLIs, which is the exact outcome each of those lanes is otherwise written to avoid. Every lane body already refused to propagate a fault; the one line that could not be caught by the lane was the spawn itself. All three now return `Option`, log loudly, and leave the daemon running without that feature. Losing the scheduled audit is a feature being off; losing the daemon is a machine being unusable. (#694) -- Raise the per-transcript audit cache TTL from 7 days to 30. It was exactly `DEFAULT_AUDIT_INTERVAL_DAYS`, so a scheduled run at T+7d found every entry written by the previous run already expired and cold-scanned the entire history — ~104 seconds and megabytes of rewrites, on every run, for a lane whose whole purpose is to be cheap. The margin was one scan duration, so any suspend, missed tick or deferral tipped all of it at once. Correctness never rested on the TTL: `engineVersion` and `detectorVersion` already invalidate the cache when detection logic actually changes. A test now pins the RELATIONSHIP to the audit interval rather than just the constant, because the existing TTL test had silently stopped exercising the TTL — its 8-day fixture sat inside the new window and passed on an unrelated check. (#694) +- Clear systemd's start-limit before the daemon-service refresh restarts, or the rollback that is supposed to save the machine cannot. The unit ships `Restart=on-failure` with `RestartSec=2`, so a rewrite systemd accepts but cannot run (the poisoned `User=` the refresh test injects; in the field, a genuinely un-startable regenerated unit) does not fail once — it cycles, and within `DefaultStartLimitIntervalSec` trips `DefaultStartLimitBurst` and latches into "start request repeated too quickly". On systemd 255 — what ubuntu-24.04 and the CI runners ship — that latch is sticky at the unit level: the rollback restores a perfectly runnable definition, `systemctl restart` is refused anyway, and `ensureDaemonServiceCurrent` returns `daemonRunning:false` on a machine whose only safety net just failed — which on a `daemonConfigured` box is every tool call across all 12 CLIs denied. `restartSystemdUnit()` now runs `systemctl reset-failed` (best-effort; a no-op on a healthy unit) before every refresh/rollback restart, making the restart deterministic. The behaviour is load-dependent — an idle machine accumulates too few cycles to trip the limit before the 5s wait ends, which is why it surfaced only under CI's parallel-suite load — and was proven both ways against real systemd 255: original code returns `daemonRunning:false`, the fix returns `daemonRunning:true`. (#PR) +- Make the dashboard's audit run take the cross-process cache lock, closing a three-writer race the `/settings` "run now" button turned into an everyday path. A scheduled daemon child, `failproofai audit`, and `POST /api/audit/run` all write the same sha1-keyed per-transcript cache and single-slot dashboard cache, but the dashboard route guarded only against overlapping runs *within its own Next.js process* — invisible to the other two, so a dashboard scan could co-write the cache with a scheduled run the daemon started at the same moment, and each would clobber the other's entries. `/api/audit/run` now also acquires `src/audit/audit-lock.ts`; held ⇒ it backs the in-memory lock out and returns the same `409 already-running` the client already treats as "poll the in-flight run". `/api/audit/status` folds the cross-process lock (via a new side-effect-free `readActiveAuditLock`, which applies the same dead-pid/age staleness rules so a crashed run's leftover file never wedges status at "running") into `running`, so a client polling after that 409 — and the settings page on mount — sees the machine as busy and waits the external scan out instead of reading idle. `runPostSetupAudit`, the fourth writer, takes the lock too and skips when it is held. A scan already running is information, not an error. (#PR) +- A daemon lane that the OS refused to start took the whole daemon with it. `telemetry::spawn`, `audit_lane::spawn` and `spawn_collector_manager` each `.expect()`ed `Builder::spawn`, so a machine at its thread limit (`EAGAIN`) panicked `run()` — and a `failproofaid` that will not start denies every tool call across all twelve CLIs, which is the exact outcome each of those lanes is otherwise written to avoid. Every lane body already refused to propagate a fault; the one line that could not be caught by the lane was the spawn itself. All three now return `Option`, log loudly, and leave the daemon running without that feature. Losing the scheduled audit is a feature being off; losing the daemon is a machine being unusable. (#PR) +- Raise the per-transcript audit cache TTL from 7 days to 30. It was exactly `DEFAULT_AUDIT_INTERVAL_DAYS`, so a scheduled run at T+7d found every entry written by the previous run already expired and cold-scanned the entire history — ~104 seconds and megabytes of rewrites, on every run, for a lane whose whole purpose is to be cheap. The margin was one scan duration, so any suspend, missed tick or deferral tipped all of it at once. Correctness never rested on the TTL: `engineVersion` and `detectorVersion` already invalidate the cache when detection logic actually changes. A test now pins the RELATIONSHIP to the audit interval rather than just the constant, because the existing TTL test had silently stopped exercising the TTL — its 8-day fixture sat inside the new window and passed on an unrelated check. (#PR) - Repair cloud-managed policy, which layout 2 had left **dead on arrival**, and close a fail-closed lockout beside it. Four defects in the daemon, each of which looked like a working system from whichever side you inspected. (1) `paths::run_dir()` read `$HOME` directly while the CLI's `fp-home.ts` derives the same path from `FAILPROOFAI_HOME` — so setting that variable put the daemon on one socket and the hook on another, and because a `daemonConfigured` machine fails closed, a **perfectly healthy daemon denied every tool call across all 11 CLIs**, reporting only the generic "failproofaid could not be reached". (2) `cloud_managed_policy_dir()` still wrote layout 1's `policies/cloud-managed`, while the CLI reads `policies/cloud-policies`: the daemon downloaded each generation, verified its hashes and wrote it to disk, and the hook path read an empty directory and enforced nothing. (3) The same function, and (4) `cloud_client::credentials_path()`, both bypassed `failproofai_home()` — and (4) additionally looked for the enrolment in `cloud.json`, which layout 2 replaced with `credentials.toml`'s `[cloud]` table, so `--connect` reported success, wrote a credential the daemon never opened, and the daemon logged "cloud-managed policy polling disabled" exactly as it would on a machine that had never enrolled. **No cloud policy could reach any machine.** The loader now reads the TOML, falls back to `cloud.json` only when the TOML is absent (a daemon upgraded before its CLI ran once to migrate), and never falls back at all when `FAILPROOFAI_CLOUD_CREDENTIALS` names a file — naming a file means "use this credential", and quietly substituting another would point the machine at a different org than the operator asked for. A file with no `[cloud]` table reads as not-enrolled rather than malformed, because an `events:add`-only machine has a valid `credentials.toml` and no policy credential by design. Regression-tested in `paths.rs`, `cloud_client.rs` and end to end against a live deployment by `__tests__/e2e/layout/cloud-pairing.sh`. ### Features -- Add a `/settings` page to the dashboard with two sections — scheduled audit and email reports — plus the degraded states that are most of the real screens (daemon not installed / stopped / unavailable, no scan ever run, a scan running now, a last scheduled run that failed, signed out, and not cloud-enrolled, each stated plainly rather than hidden). The scheduled-audit section wires an enable toggle to `[audit] auto` with a plain statement that the scan reads the *contents* of every session transcript on disk, an interval control that lets `config.toml` own the 1..90 clamp and reflects what it stored, a last-run / next-due readout that is the first TypeScript reader of the daemon-written `state/audit-schedule.json` (tolerant of an absent, malformed, or schema-ahead file so a version-ahead daemon never blanks the page), and a "run now" that reuses the existing `/api/audit/run`. The email section reuses the OTP-verified identity — signed out leads into the existing login flow, never a second email field; not cloud-enrolled says plainly that email needs a connection and how to get one — and its toggle delegates to the same `runEmailReportsOn/OffCommand` the CLI uses so the rules live in one place. Telemetry is deliberately not surfaced. Every write goes through `updateConfig`; the `next-audit.json` reminder is kept separate with a distinct meaning (a cloud email nudge to a human, not a scan schedule), so "when does the next scan run" has exactly one answer: `audit-schedule.json`. (#694) +- Add a `/settings` page to the dashboard with two sections — scheduled audit and email reports — plus the degraded states that are most of the real screens (daemon not installed / stopped / unavailable, no scan ever run, a scan running now, a last scheduled run that failed, signed out, and not cloud-enrolled, each stated plainly rather than hidden). The scheduled-audit section wires an enable toggle to `[audit] auto` with a plain statement that the scan reads the *contents* of every session transcript on disk, an interval control that lets `config.toml` own the 1..90 clamp and reflects what it stored, a last-run / next-due readout that is the first TypeScript reader of the daemon-written `state/audit-schedule.json` (tolerant of an absent, malformed, or schema-ahead file so a version-ahead daemon never blanks the page), and a "run now" that reuses the existing `/api/audit/run`. The email section reuses the OTP-verified identity — signed out leads into the existing login flow, never a second email field; not cloud-enrolled says plainly that email needs a connection and how to get one — and its toggle delegates to the same `runEmailReportsOn/OffCommand` the CLI uses so the rules live in one place. Telemetry is deliberately not surfaced. Every write goes through `updateConfig`; the `next-audit.json` reminder is kept separate with a distinct meaning (a cloud email nudge to a human, not a scan schedule), so "when does the next scan run" has exactly one answer: `audit-schedule.json`. (#PR) - Check a machine key against the server **before** using it, and record which organisation it reports into. Until `/v1/auth/introspect` existed a machine could not describe its own credential — every other `/keys*` route needs a `keys:*` permission a machine credential must never hold — so a revoked key, a valid key missing one permission, and a valid key pasted from the **wrong organisation** all failed identically: later, at the point of use, as an empty dashboard or a machine that never receives policy. `--connect` now introspects first and gates the two capabilities independently on `events:add` and `policies:pull`, skipping the probe a key provably cannot pass; the resulting message names the missing permission *and* the org the key genuinely belongs to, where the 403 it replaces read like a server fault. Permissions are read from the server's **effective** set, which it widens at authentication time and enforces against — the dashboard and CLI both display the stored grant, so a local check built against the displayed list would be wrong in the permissive direction. A rejected key stops before probing anything further; a server with no introspect endpoint (404, a redirect into the web app, or a 200 that is not JSON) falls back to the probing every previous release did, since the CLI ships independently of whatever AgentEye a customer runs. The org is stored **once**, in its own `[org]` table rather than as a field on `[cloud]` and `[ingest]` both: it describes the token, the same token serves both capabilities, and an `events:add`-only key never writes `[cloud]` at all — the case a per-table field would silently have lost. `--status` answers "where does this machine's data go?" from that record with no network call, and the connect output names the org on the partial branches too, since a key from the wrong org authenticates perfectly and reports somewhere nobody is looking. - Point ingest at the versioned `/v1/events` route, in both the TypeScript and Rust defaults, and accept either form when someone pastes an ingest URL where a base URL is expected. @@ -361,20 +391,20 @@ never "blocked". - Verify, after publishing, that every package in a release actually landed on the registry at one version. Every published name already derives from the same `PUBLISH_VERSION` — the root package, the four `@failproofai/failproofaid--` packages, the aliases — so a run that completes is in lockstep by construction. What construction cannot cover is a **partial** run, and both halves of that split have shipped once each: `1.0.0-beta.1` through `.3` published the CLI with no platform packages behind it (the publish step did not exist yet), and `1.0.0-beta.0` published four platform packages whose CLI was already on the registry without pins to them. Each of those runs reported success. The release now asks the registry directly — all five names must resolve at the publish version, and the published root package's `optionalDependencies` must pin that same version, or the job fails. Retried against read-through-cache lag, and skipped on a dry run, where nothing was published to verify. - Finish a release by **installing it**, on a clean runner, once per platform the daemon ships for. Querying the registry proves a manifest exists; it does not prove the tarball is fetchable, that npm's `os`/`cpu` filters resolve the right platform package on the machine it is meant for, that the executable bit survived publish → install, or that the binary inside is the version the CLI beside it believes it is — and each of those fails while every manifest query still reads as healthy. The new `verify-install` job does a real `npm install -g failproofai@` and then runs both binaries: the CLI must report the published version, and the daemon must resolve *the way the CLI resolves it at runtime* (through the installed package, not by path), be executable, and report the same version. It is a matrix rather than one runner because npm installs the one platform package matching the runner's os/cpu and silently skips the other three, so a single leg can only ever verify a quarter of what shipped. Both this and the registry check retry immediately, then at 10s / 30s / 1m / 2m — long enough that read-through-cache propagation is not mistaken for a failed publish, short enough that a genuinely failed one is reported in the same run rather than hours later by a user. - Bump the version to `1.0.0-beta.5` so this branch carries an unpublished version. `1.0.0-beta.0` through `1.0.0-beta.4` are all on npm; a dispatch from here could not have published anything. -- Stamp the OS user on every collected event, alongside the machine id, so two profiles on one machine stop collapsing into one. Identity is the pair `(machine_id, user)` — a username is unique only within a machine — and it is stamped at the single `SpoolWriter` choke point every event already passes through, so no source can forget it and a new source inherits it for free. Resolved once at daemon start from the real uid via `getpwuid_r`, not `$USER`, which a system-scope unit may leave unset or stale; a uid with no passwd entry yields no user rather than an invented one. (#694) -- Mint a stable machine id at enrolment instead of defaulting to the hostname. Two hosts sharing a hostname — fresh cloud VMs, cloned images — enrolled under the same id and **silently merged into one machine** on the server. `--machine-id` still wins, an already-enrolled id is reused so re-running `--connect` is idempotent, and only a machine with neither mints a fresh UUID. The hostname becomes `machine_label`, a mutable display name that is free to collide because the id keeps machines apart; it persists in `credentials.toml`'s `[cloud]` table and rides the enrolment request as a `&label=` an older server ignores. (#694) -- Add `[telemetry] enabled` to `config.toml` as a telemetry off-switch that works everywhere. `FAILPROOFAI_TELEMETRY_DISABLED=1` is read from `process.env` and keeps working, but it is structurally incapable of reaching **failproofaid** — a system-scope service unit whose environment carries essentially nothing — so a machine running the daemon had no way to opt out of daemon-side reporting at all. All four dispatchers now resolve through one shared gate that takes the **more restrictive** of the two, so the environment can never re-enable something the file switched off, and the install dispatcher (a dependency-free `.mjs` that cannot parse TOML) is told the verdict by its caller rather than resolving its own. Documented in the environment-variables page. (#694) -- Add the `[audit]` block to `config.toml`, a headless `failproofai audit --scheduled`, and a cross-process lock the audit paths share. `auto` ships **off** — the scan reads the *contents* of every session transcript on this machine, so nothing scans on a timer until it is asked to — and unlike `[telemetry]` the block is written to the file every time, because a switch nobody can find is the same as one that does not exist. A nonsense `interval_days` resolves to the 7-day default rather than clamping up to the 1-day floor: a `0` almost certainly means "off", and reading it as a *daily* scan of every transcript is the loudest available way to get that wrong. The headless entry point exists because the scan is a **separate short-lived process by necessity** — a measured full audit is ~104 seconds, the warm worker serialises every request through one chain behind a 30-second cap, and a timeout there is a fail-closed DENY across all 12 CLIs — and it reports through an exit code (0 / 1 / **75** = "another audit already had the lock", which a scheduler must not treat as a failure). The lock itself closes a real gap: three processes can start an audit and all three write the same sha1-keyed cache files, while the only lock that existed was a module-level singleton inside the Next.js server that neither of the other two could see. It steals a lock whose pid is gone (Ctrl+C leaves no chance to clean up) or that is older than an hour, and the interactive path releases it when the *scan* ends rather than when the dashboard it then serves is closed. (#694) -- Give **failproofaid** a way to run the CLI, and rewrite the service definitions that cannot. A system-scope service has no login environment, so its PATH is the system default — and the single most common Node install is nvm, which lives under `~/.nvm/versions/node/*/bin` and is on no system PATH — so the daemon cannot find `failproofai` to spawn a scheduled audit any more than it could find the warm worker. `FAILPROOFAI_CLI_CMD` now rides in the unit's `Environment=` / the plist's `EnvironmentVariables` beside `FAILPROOFAI_WORKER_CMD`, resolved once at install time to an absolute, shell-quoted ` /dist/cli.mjs` — the bundle, not `bin/failproofai.mjs`, which has a bun shebang and syntax node cannot load. **The upgrade case is the point:** `npm i -g failproofai@latest` replaces the CLI and never touches `/etc/systemd/system`, and the wizard's own "already installed and running — leaving it alone" branch skipped it too, so an upgraded machine would keep a unit with no such variable forever and the audit lane would be permanently inert while `config.toml` said the scan was on — with no symptom anywhere. Setup now detects that unit by reading it (not by trusting a revision mirrored into `config.toml`, which is how `daemon.installed_version` is modelled and that field has never once been written) and rewrites it in place: the ExecStart and any environment value this process cannot re-resolve are carried forward, because right after a CLI upgrade the version-stamped daemon binary for the new version is not on disk yet and a resolver returning null must never silently delete a working `FAILPROOFAI_WORKER_CMD` from a working unit. It ends in `systemctl restart`, not `enable --now`, which returns success against an already-active unit having changed nothing — without it the "fix" would reach the running daemon only at the next boot. That restart is also the one genuinely dangerous thing here — it is the first code path that ever touches a **healthy, running** daemon, and on a `daemonConfigured` machine a daemon that does not come back is not a missing feature but every tool call across all 12 CLIs denied against a socket nothing is listening on — so a refresh that cannot bring the service back puts the previous definition back and restarts it, and if even that fails the machine is switched off the daemon and back to in-process evaluation rather than left denying everything. Confined to the wizard, and it cannot abort a setup: a machine that cannot elevate keeps its working daemon and its enforcement, and loses only the scheduled audit. (#694) -- Give the daemon an audit lane, which is what makes the scheduled scan actually happen. A new thread in **failproofaid** re-reads `config.toml`'s `[audit]` table every minute — like the collector manager and the cloud lane, so switching `auto = true` on takes effect without a restart, which matters because `failproofai config` writes that file without root while the service is system-scope — and when the wall clock says a scan is due it spawns `failproofai audit --scheduled` from `FAILPROOFAI_CLI_CMD` as a `nice(19)` process in its own process group, with piped-and-drained stdio and a 30-minute kill. It is a **separate process by necessity**: the warm worker serialises every request through one chain that `worker.rs` caps at 30 seconds and `daemon-client.ts` turns into a DENY, so a ~104-second scan there would be every tool call on the machine denied across all 12 CLIs for as long as it ran — `worker-server.test.ts` now carries a tripwire so an "optimisation" onto that chain fails loudly instead. Nothing propagates out of the lane: a panic is caught and the next tick still runs, because a daemon that dies denies the whole machine. **The due time is wall clock, persisted to `state/audit-schedule.json`** (0600, atomic, schema-guarded, daemon-owned) rather than an `Instant` like every other lane — a monotonic clock does not advance across suspend and resets each process start, so a seven-day timer on a laptop or on a daemon that restarts every upgrade never fires at all. A machine asleep past its due time runs **once** on wake, never a backlog, because the next due time is recomputed from *now* and never by adding intervals to the one that was missed; a clock corrected backwards (or a shortened `interval_days`) is repaired by rewriting the schedule rather than clamping it at read time, which would sit one interval ahead of the present forever. **The schedule is written BEFORE the scan is spawned**, deliberately inverting the collector's flush-then-advance rule: there a crash costs a re-ship the server dedups, here the unit is `Restart=on-failure` and run-then-write means a scan that takes the daemon down relaunches itself on every restart, forever — so a schedule that cannot be written skips the scan instead. A second, in-memory 15-minute floor backs that up for the one case the file cannot: a home the daemon has no way to write to. Exit **75** from the child ("another audit already holds the lock") is retried at that floor and recorded as neither a run nor a failure. And a first start **schedules** rather than scans, because the daemon restarts on every upgrade and every boot and "scan the first time you see no state" would be a full scan per restart. (#694) +- Stamp the OS user on every collected event, alongside the machine id, so two profiles on one machine stop collapsing into one. Identity is the pair `(machine_id, user)` — a username is unique only within a machine — and it is stamped at the single `SpoolWriter` choke point every event already passes through, so no source can forget it and a new source inherits it for free. Resolved once at daemon start from the real uid via `getpwuid_r`, not `$USER`, which a system-scope unit may leave unset or stale; a uid with no passwd entry yields no user rather than an invented one. (#PR) +- Mint a stable machine id at enrolment instead of defaulting to the hostname. Two hosts sharing a hostname — fresh cloud VMs, cloned images — enrolled under the same id and **silently merged into one machine** on the server. `--machine-id` still wins, an already-enrolled id is reused so re-running `--connect` is idempotent, and only a machine with neither mints a fresh UUID. The hostname becomes `machine_label`, a mutable display name that is free to collide because the id keeps machines apart; it persists in `credentials.toml`'s `[cloud]` table and rides the enrolment request as a `&label=` an older server ignores. (#PR) +- Add `[telemetry] enabled` to `config.toml` as a telemetry off-switch that works everywhere. `FAILPROOFAI_TELEMETRY_DISABLED=1` is read from `process.env` and keeps working, but it is structurally incapable of reaching **failproofaid** — a system-scope service unit whose environment carries essentially nothing — so a machine running the daemon had no way to opt out of daemon-side reporting at all. All four dispatchers now resolve through one shared gate that takes the **more restrictive** of the two, so the environment can never re-enable something the file switched off, and the install dispatcher (a dependency-free `.mjs` that cannot parse TOML) is told the verdict by its caller rather than resolving its own. Documented in the environment-variables page. (#PR) +- Add the `[audit]` block to `config.toml`, a headless `failproofai audit --scheduled`, and a cross-process lock the audit paths share. `auto` ships **off** — the scan reads the *contents* of every session transcript on this machine, so nothing scans on a timer until it is asked to — and unlike `[telemetry]` the block is written to the file every time, because a switch nobody can find is the same as one that does not exist. A nonsense `interval_days` resolves to the 7-day default rather than clamping up to the 1-day floor: a `0` almost certainly means "off", and reading it as a *daily* scan of every transcript is the loudest available way to get that wrong. The headless entry point exists because the scan is a **separate short-lived process by necessity** — a measured full audit is ~104 seconds, the warm worker serialises every request through one chain behind a 30-second cap, and a timeout there is a fail-closed DENY across all 12 CLIs — and it reports through an exit code (0 / 1 / **75** = "another audit already had the lock", which a scheduler must not treat as a failure). The lock itself closes a real gap: three processes can start an audit and all three write the same sha1-keyed cache files, while the only lock that existed was a module-level singleton inside the Next.js server that neither of the other two could see. It steals a lock whose pid is gone (Ctrl+C leaves no chance to clean up) or that is older than an hour, and the interactive path releases it when the *scan* ends rather than when the dashboard it then serves is closed. (#PR) +- Give **failproofaid** a way to run the CLI, and rewrite the service definitions that cannot. A system-scope service has no login environment, so its PATH is the system default — and the single most common Node install is nvm, which lives under `~/.nvm/versions/node/*/bin` and is on no system PATH — so the daemon cannot find `failproofai` to spawn a scheduled audit any more than it could find the warm worker. `FAILPROOFAI_CLI_CMD` now rides in the unit's `Environment=` / the plist's `EnvironmentVariables` beside `FAILPROOFAI_WORKER_CMD`, resolved once at install time to an absolute, shell-quoted ` /dist/cli.mjs` — the bundle, not `bin/failproofai.mjs`, which has a bun shebang and syntax node cannot load. **The upgrade case is the point:** `npm i -g failproofai@latest` replaces the CLI and never touches `/etc/systemd/system`, and the wizard's own "already installed and running — leaving it alone" branch skipped it too, so an upgraded machine would keep a unit with no such variable forever and the audit lane would be permanently inert while `config.toml` said the scan was on — with no symptom anywhere. Setup now detects that unit by reading it (not by trusting a revision mirrored into `config.toml`, which is how `daemon.installed_version` is modelled and that field has never once been written) and rewrites it in place: the ExecStart and any environment value this process cannot re-resolve are carried forward, because right after a CLI upgrade the version-stamped daemon binary for the new version is not on disk yet and a resolver returning null must never silently delete a working `FAILPROOFAI_WORKER_CMD` from a working unit. It ends in `systemctl restart`, not `enable --now`, which returns success against an already-active unit having changed nothing — without it the "fix" would reach the running daemon only at the next boot. That restart is also the one genuinely dangerous thing here — it is the first code path that ever touches a **healthy, running** daemon, and on a `daemonConfigured` machine a daemon that does not come back is not a missing feature but every tool call across all 12 CLIs denied against a socket nothing is listening on — so a refresh that cannot bring the service back puts the previous definition back and restarts it, and if even that fails the machine is switched off the daemon and back to in-process evaluation rather than left denying everything. Confined to the wizard, and it cannot abort a setup: a machine that cannot elevate keeps its working daemon and its enforcement, and loses only the scheduled audit. (#PR) +- Give the daemon an audit lane, which is what makes the scheduled scan actually happen. A new thread in **failproofaid** re-reads `config.toml`'s `[audit]` table every minute — like the collector manager and the cloud lane, so switching `auto = true` on takes effect without a restart, which matters because `failproofai config` writes that file without root while the service is system-scope — and when the wall clock says a scan is due it spawns `failproofai audit --scheduled` from `FAILPROOFAI_CLI_CMD` as a `nice(19)` process in its own process group, with piped-and-drained stdio and a 30-minute kill. It is a **separate process by necessity**: the warm worker serialises every request through one chain that `worker.rs` caps at 30 seconds and `daemon-client.ts` turns into a DENY, so a ~104-second scan there would be every tool call on the machine denied across all 12 CLIs for as long as it ran — `worker-server.test.ts` now carries a tripwire so an "optimisation" onto that chain fails loudly instead. Nothing propagates out of the lane: a panic is caught and the next tick still runs, because a daemon that dies denies the whole machine. **The due time is wall clock, persisted to `state/audit-schedule.json`** (0600, atomic, schema-guarded, daemon-owned) rather than an `Instant` like every other lane — a monotonic clock does not advance across suspend and resets each process start, so a seven-day timer on a laptop or on a daemon that restarts every upgrade never fires at all. A machine asleep past its due time runs **once** on wake, never a backlog, because the next due time is recomputed from *now* and never by adding intervals to the one that was missed; a clock corrected backwards (or a shortened `interval_days`) is repaired by rewriting the schedule rather than clamping it at read time, which would sit one interval ahead of the present forever. **The schedule is written BEFORE the scan is spawned**, deliberately inverting the collector's flush-then-advance rule: there a crash costs a re-ship the server dedups, here the unit is `Restart=on-failure` and run-then-write means a scan that takes the daemon down relaunches itself on every restart, forever — so a schedule that cannot be written skips the scan instead. A second, in-memory 15-minute floor backs that up for the one case the file cannot: a home the daemon has no way to write to. Exit **75** from the child ("another audit already holds the lock") is retried at that floor and recorded as neither a run nor a failure. And a first start **schedules** rather than scans, because the daemon restarts on every upgrade and every boot and "scan the first time you see no state" would be a full scan per restart. (#PR) -- Give **failproofaid** a telemetry lane, so the one component whose failure denies every tool call on a machine stops being the one component nobody can see. Everything that moved into the daemon — collection, cloud policy pull, worker supervision, the fail-closed enforcement path — reported *nothing*; it now posts a small **lifecycle** stream to PostHog's `/batch/` endpoint under a fifth `$lib`, `failproofai-daemon`, distinct from the four TypeScript dispatchers because "which component reported this" is the first question asked of any of these events. What it reports: that the daemon started and **whether the previous run exited cleanly** (the one signal here worth alerting on, and invisible everywhere else — systemd restarts the unit and the next log line reads like an ordinary start), that it stopped, every warm-worker spawn with its reason, outcome and cold-start milliseconds, collector task failures and restarts, and the outcome of a cloud-policy pull on a **change** rather than per tick (a 30-second poll would otherwise send ~2,900 events a day from every enrolled machine to say nothing happened). There is deliberately **no per-hook-call event**: the existing code never sends an `allow`, and awaiting one on the deny path already cost ~700ms once and blew the 150ms fail-closed budget. **Nothing here can reach the hook path.** Recording is a bounded push onto a 128-event in-memory ring and nothing else — no I/O, no network — and the ring lock is always released before a request starts, so a black-holing corporate proxy can stall the lane for its whole timeout without a hook call noticing; the lane runs on its own thread with the shared shutdown flag and a `catch_unwind` per tick, a batch is retried weakly and then dropped rather than retried forever, and the flush on the way out uses a much shorter timeout than the periodic one because `systemctl stop` waits on it and an upgrade pays it every time. **The opt-out is checked before an event is even buffered**, resolved to the more restrictive of `[telemetry] enabled` and `FAILPROOFAI_TELEMETRY_DISABLED` and re-read every tick rather than memoised — `failproofai config` writes that file without root while this is a system unit, so an opt-out that only took effect on restart would not hold — and a tick that sees it switched off **clears** the buffer as well as closing it. Identity is the id the CLI already resolved, which `getInstanceId()` now publishes to `state/telemetry-id` for the daemon to read: the daemon deliberately does **not** re-derive it, because that tier hashes Node-formatted strings (`os.arch()` is `x64` where Rust says `x86_64`) and a near-miss there does not fail — it silently files one machine under two PostHog persons with nothing in the data to say so. When the file is absent the daemon recomputes the CLI's *first* tier instead (the raw platform machine id under the same HMAC, which has no Node in it), then mints and persists one, then degrades to a per-process id — and every event carries which rung it used. That same "one machine must not become two" rule governs the plain `platform` and `arch` properties, which go out under Node's spelling (`darwin`, `x64`) rather than Rust's (`macos`, `x86_64`), because two of the four release legs are macOS and the other four dispatchers already send the Node names — a raw value would not fail, it would just split one population across two names in every breakdown. The payload is enums, booleans and counts only: no file path, command string, policy, prompt, transcript text, URL, token, or error message. (#694) +- Give **failproofaid** a telemetry lane, so the one component whose failure denies every tool call on a machine stops being the one component nobody can see. Everything that moved into the daemon — collection, cloud policy pull, worker supervision, the fail-closed enforcement path — reported *nothing*; it now posts a small **lifecycle** stream to PostHog's `/batch/` endpoint under a fifth `$lib`, `failproofai-daemon`, distinct from the four TypeScript dispatchers because "which component reported this" is the first question asked of any of these events. What it reports: that the daemon started and **whether the previous run exited cleanly** (the one signal here worth alerting on, and invisible everywhere else — systemd restarts the unit and the next log line reads like an ordinary start), that it stopped, every warm-worker spawn with its reason, outcome and cold-start milliseconds, collector task failures and restarts, and the outcome of a cloud-policy pull on a **change** rather than per tick (a 30-second poll would otherwise send ~2,900 events a day from every enrolled machine to say nothing happened). There is deliberately **no per-hook-call event**: the existing code never sends an `allow`, and awaiting one on the deny path already cost ~700ms once and blew the 150ms fail-closed budget. **Nothing here can reach the hook path.** Recording is a bounded push onto a 128-event in-memory ring and nothing else — no I/O, no network — and the ring lock is always released before a request starts, so a black-holing corporate proxy can stall the lane for its whole timeout without a hook call noticing; the lane runs on its own thread with the shared shutdown flag and a `catch_unwind` per tick, a batch is retried weakly and then dropped rather than retried forever, and the flush on the way out uses a much shorter timeout than the periodic one because `systemctl stop` waits on it and an upgrade pays it every time. **The opt-out is checked before an event is even buffered**, resolved to the more restrictive of `[telemetry] enabled` and `FAILPROOFAI_TELEMETRY_DISABLED` and re-read every tick rather than memoised — `failproofai config` writes that file without root while this is a system unit, so an opt-out that only took effect on restart would not hold — and a tick that sees it switched off **clears** the buffer as well as closing it. Identity is the id the CLI already resolved, which `getInstanceId()` now publishes to `state/telemetry-id` for the daemon to read: the daemon deliberately does **not** re-derive it, because that tier hashes Node-formatted strings (`os.arch()` is `x64` where Rust says `x86_64`) and a near-miss there does not fail — it silently files one machine under two PostHog persons with nothing in the data to say so. When the file is absent the daemon recomputes the CLI's *first* tier instead (the raw platform machine id under the same HMAC, which has no Node in it), then mints and persists one, then degrades to a per-process id — and every event carries which rung it used. That same "one machine must not become two" rule governs the plain `platform` and `arch` properties, which go out under Node's spelling (`darwin`, `x64`) rather than Rust's (`macos`, `x86_64`), because two of the four release legs are macOS and the other four dispatchers already send the Node names — a raw value would not fail, it would just split one population across two names in every breakdown. The payload is enums, booleans and counts only: no file path, command string, policy, prompt, transcript text, URL, token, or error message. (#PR) ### Fixes -- **Lock down the local dashboard, which bound every network interface with no authentication.** `scripts/launch.ts` set `HOSTNAME = "0.0.0.0"` unconditionally, and the dashboard is a *write* surface for this machine's security configuration — `removeHooksWebAction` strips failproofai's hooks out of every agent CLI's settings file and `togglePolicyAction` disables individual policies, so any peer on the network could turn enforcement off and read session transcripts through `/api/download`. Three layers now stand in the way, each closing an attack the others do not: the server binds loopback by default (`--host` / `FAILPROOFAI_DASHBOARD_HOST` still allow a routable address deliberately, and say what it costs); `Host` is pinned to loopback, which is what defeats DNS rebinding — a bind alone does not, because rebinding targets 127.0.0.1 on purpose and arrives with Origin and Host in agreement, satisfying every same-origin check including the framework's own; and cross-origin mutating requests are refused by `Origin`, because route handlers get none of the Server-Action protection and `req.json()` ignores Content-Type, making a plain cross-site `POST` a CORS *simple* request that reaches `login-verify` — which is unauthenticated and never checks the email relates to an existing session, so a page could write its own tokens into `auth.json`. `x-forwarded-host` is stripped, since the framework prefers it over `Host` and nothing proxies this server. (#694) -- **The daemon could not see its own cloud enrolment on a layout-2 home.** `cloud_client.rs` still resolved the layout-1 `~/.failproofai/cloud.json` and parsed it as JSON, while the CLI had moved the credential into the `[cloud]` table of `credentials.toml` — so an enrolled machine's daemon found no credential, pulled no policy, and reported itself as simply not connected while `failproofai config --status` showed a perfectly good connection. It now reads the TOML table, mirroring the TS reader field for field: an absent or incomplete `[cloud]` is "connected for reporting but not policy", a supported half-state rather than an error, and only unparseable TOML is fatal. `FAILPROOFAI_CLOUD_CREDENTIALS` still names a standalone JSON file, because CI and containers already use it that way. A leftover layout-1 `cloud.json` is deliberately **not** honoured as a fallback — layout 2 chose wipe-and-re-setup over migration, so reading it would resurrect an enrolment the CLI considers gone. (#694) -- Correct a false claim `config.toml` wrote to disk. The `[mode]` block stated `"oss" — fully local. Nothing is sent anywhere, ever.`, which has not been true for as long as the four telemetry dispatchers have existed. It now scopes the claim to what `mode` actually governs — transcripts, hook activity and policy. (#694) -- **Stop the scheduled audit from deleting the config that scheduled it.** `audit --scheduled` was headless inside `src/audit/cli.ts`, but the daemon does not call that function — it spawns the *binary*, so everything `bin/failproofai.mjs` does before dispatch now runs unattended on a timer too. One of those things is the layout check, and on a home written by an older layout that check **resets the home**: `resettablePaths()` deletes `config.toml` and `credentials.toml`. So a single scheduled tick could silently revoke a user's `[telemetry] enabled = false`, erase their cloud enrolment, and switch off `[audit] auto` — the very setting that scheduled the run — with the explanation going only to the service journal, where nobody is looking. The reset module's own doctrine already forbids this ("only a real CLI command resets; a hook never deletes anything, because a hook runs unattended"), and a timer-spawned scan is unattended in exactly the sense that rule cares about. It now takes the hook's branch: warn on stderr, exit 1, delete nothing. An interactive command still resets a stale home, visibly, which is what the layout mechanism is for. This was reachable on any home carrying `config.toml` without a current `VERSION`, and becomes reachable on **every** machine at the next `LAYOUT_VERSION` bump, when a home carrying `auto = true` is stale by definition. (#694) +- **Lock down the local dashboard, which bound every network interface with no authentication.** `scripts/launch.ts` set `HOSTNAME = "0.0.0.0"` unconditionally, and the dashboard is a *write* surface for this machine's security configuration — `removeHooksWebAction` strips failproofai's hooks out of every agent CLI's settings file and `togglePolicyAction` disables individual policies, so any peer on the network could turn enforcement off and read session transcripts through `/api/download`. Three layers now stand in the way, each closing an attack the others do not: the server binds loopback by default (`--host` / `FAILPROOFAI_DASHBOARD_HOST` still allow a routable address deliberately, and say what it costs); `Host` is pinned to loopback, which is what defeats DNS rebinding — a bind alone does not, because rebinding targets 127.0.0.1 on purpose and arrives with Origin and Host in agreement, satisfying every same-origin check including the framework's own; and cross-origin mutating requests are refused by `Origin`, because route handlers get none of the Server-Action protection and `req.json()` ignores Content-Type, making a plain cross-site `POST` a CORS *simple* request that reaches `login-verify` — which is unauthenticated and never checks the email relates to an existing session, so a page could write its own tokens into `auth.json`. `x-forwarded-host` is stripped, since the framework prefers it over `Host` and nothing proxies this server. (#PR) +- **The daemon could not see its own cloud enrolment on a layout-2 home.** `cloud_client.rs` still resolved the layout-1 `~/.failproofai/cloud.json` and parsed it as JSON, while the CLI had moved the credential into the `[cloud]` table of `credentials.toml` — so an enrolled machine's daemon found no credential, pulled no policy, and reported itself as simply not connected while `failproofai config --status` showed a perfectly good connection. It now reads the TOML table, mirroring the TS reader field for field: an absent or incomplete `[cloud]` is "connected for reporting but not policy", a supported half-state rather than an error, and only unparseable TOML is fatal. `FAILPROOFAI_CLOUD_CREDENTIALS` still names a standalone JSON file, because CI and containers already use it that way. A leftover layout-1 `cloud.json` is deliberately **not** honoured as a fallback — layout 2 chose wipe-and-re-setup over migration, so reading it would resurrect an enrolment the CLI considers gone. (#PR) +- Correct a false claim `config.toml` wrote to disk. The `[mode]` block stated `"oss" — fully local. Nothing is sent anywhere, ever.`, which has not been true for as long as the four telemetry dispatchers have existed. It now scopes the claim to what `mode` actually governs — transcripts, hook activity and policy. (#PR) +- **Stop the scheduled audit from deleting the config that scheduled it.** `audit --scheduled` was headless inside `src/audit/cli.ts`, but the daemon does not call that function — it spawns the *binary*, so everything `bin/failproofai.mjs` does before dispatch now runs unattended on a timer too. One of those things is the layout check, and on a home written by an older layout that check **resets the home**: `resettablePaths()` deletes `config.toml` and `credentials.toml`. So a single scheduled tick could silently revoke a user's `[telemetry] enabled = false`, erase their cloud enrolment, and switch off `[audit] auto` — the very setting that scheduled the run — with the explanation going only to the service journal, where nobody is looking. The reset module's own doctrine already forbids this ("only a real CLI command resets; a hook never deletes anything, because a hook runs unattended"), and a timer-spawned scan is unattended in exactly the sense that rule cares about. It now takes the hook's branch: warn on stderr, exit 1, delete nothing. An interactive command still resets a stale home, visibly, which is what the layout mechanism is for. This was reachable on any home carrying `config.toml` without a current `VERSION`, and becomes reachable on **every** machine at the next `LAYOUT_VERSION` bump, when a home carrying `auto = true` is stale by definition. (#PR) ### Chores - Remove this repo's dogfood `block-version-bumps` policy, which reserved `package.json` version edits for `luv-cut-X.Y.Z` branches. It was added in #285 after the #270/#284 version drift, but it also blocks the only fix for a burned publish version, and the preflight check above now catches the failure it was guarding against at the point where it actually matters. The `release-prep-check` instruction that referenced it drops its last line. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 217bc4747..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,111 +0,0 @@ -# Contributing to Failproof AI - -Thanks for your interest in contributing! Here's how to get started. - -## Prerequisites - -- Bun >= 1.3.0 — required, not optional. `bun install` runs `prepare` → `bun run build`, - which shells out to `bun build`, so there is no Node-only setup path. -- Node.js >= 20.9.0 — what the published package targets, and what runs the in-repo - dev hooks' launcher. - -## Development Setup - -```bash -git clone https://github.com/failproofai/failproofai.git -cd failproofai -bun install # runs the `prepare` script → `bun run build`, which creates dist/ -bun run dev -``` - -The dev server starts at `http://localhost:8020`. - -### Build before the in-repo dev hooks will work - -This repo **dogfoods failproofai on itself**: `.claude/settings.json` (and the -sibling `.codex/`, `.cursor/`, `.github/hooks/`, … configs) register hooks that run -`node scripts/dev-hook.mjs --hook --cli `. That launcher locates `bun` -(across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, Homebrew, and every -`~/.nvm/versions/node/*/bin`), builds `dist/index.js` if it is missing, then hands off -to `bin/failproofai.mjs`. See `scripts/dev-hook.mjs` for why node fronts a bun-only -binary. Those hooks load the custom policies in `.failproofai/policies/*.mjs`, which -`import` the `failproofai` package — resolved against the **compiled `dist/index.js` -bundle**. - -If `dist/` is missing the launcher rebuilds it for you and says so on stderr. If it is -**stale** — you changed `src/index.ts` and the bundle predates it — nothing detects that, -and you'll get policies enforcing yesterday's code. When the bundle is missing *and* the -launcher can't run, the errors look like: - -``` -[failproofai:hook] ERROR failed to load custom hooks from - .failproofai/policies/review-policies.mjs: Cannot find package 'failproofai' … -``` - -`bun install` builds `dist/` for you via its `prepare` script, so a clean clone -just works. **Rebuild explicitly whenever you've changed `src/`** (the hooks load the -compiled bundle, not your live `src/`): - -```bash -bun run build # full build (Next.js + dist/) -# …or, just the hook bundle (much faster while iterating on policies): -bun build --target=node --format=cjs --outfile=dist/index.js src/index.ts -``` - -## Available Scripts - -| Script | Description | -|--------|-------------| -| `bun run dev` | Start the development server | -| `bun run lint` | Run ESLint | -| `bunx tsc --noEmit` | Type-check without emitting | -| `bun run test:run` | Run tests once (Vitest) | -| `bun run test` | Run tests in watch mode | -| `bun run build` | Production build (Next.js) | - -## Project Structure - -``` -failproofai/ -├── app/ # Next.js app router (pages, layouts, server actions) -├── bin/ # CLI entry point -├── components/ # Shared React components -├── contexts/ # React context providers -├── lib/ # Core logic (logging, telemetry, paths, URL utils) -├── src/hooks/ # Hook handler, built-in policies, custom hooks loader -├── scripts/ # Dev/start/build helper scripts -├── __tests__/ # Test files -├── examples/ # Example custom hook policies -└── public/ # Static assets -``` - -### Key Subsystems - -| Directory | Description | -|-----------|-------------| -| `src/hooks/` | Hook handler, built-in policies, custom hooks loader | -| `app/actions/` | Next.js server actions | -| `app/components/` | Session viewer, project list, log viewer | - -### Environment Variables - -| Variable | Description | -|----------|-------------| -| `CLAUDE_PROJECTS_PATH` | Path to Claude projects directory | -| `FAILPROOFAI_LOG_LEVEL` | Log level: `info`, `warn`, `error` (default: `warn`) | -| `FAILPROOFAI_TELEMETRY_DISABLED` | Set to `1` to disable anonymous telemetry | -| `FAILPROOFAI_DISABLE_PAGES` | Comma-separated pages to disable: `policies`, `projects` | - -## Pull Request Guidelines - -1. Keep changes focused — one concern per PR. -2. Make sure all checks pass before requesting review: - ```bash - bun run lint && bunx tsc --noEmit && bun run test:run && bun run build - ``` -3. Include a clear description of what the PR does and why. -4. Add tests for new functionality when applicable. - -## Reporting Issues - -Found a bug or have a feature idea? [Open an issue](https://github.com/failproofai/failproofai/issues). The issue templates will guide you through providing the right details. diff --git a/Cargo.lock b/Cargo.lock index f780ab530..adfcb1904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,33 +27,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitflags" version = "2.13.1" @@ -111,7 +90,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -190,16 +169,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "failproofaid" version = "1.0.1-beta.0" @@ -230,12 +199,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -283,7 +246,6 @@ name = "fpai-ipc" version = "1.0.1-beta.0" dependencies = [ "libc", - "proptest", "serde", "serde_json", ] @@ -398,18 +360,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -419,8 +369,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", + "rand_core", "wasm-bindgen", ] @@ -790,12 +740,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -874,15 +818,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "num_cpus" version = "1.17.0" @@ -932,15 +867,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -950,31 +876,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand 0.9.5", - "rand_chacha", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quinn" version = "0.11.11" @@ -1004,7 +905,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1040,28 +941,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1070,26 +955,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1104,16 +970,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -1230,19 +1087,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.43" @@ -1284,18 +1128,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - [[package]] name = "ryu" version = "1.0.23" @@ -1480,19 +1312,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "2.0.19" @@ -1730,12 +1549,6 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -1772,15 +1585,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -1806,15 +1610,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2093,12 +1888,6 @@ dependencies = [ "url", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" @@ -2128,26 +1917,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Dockerfile.docs b/Dockerfile.docs deleted file mode 100644 index b8db06d61..000000000 --- a/Dockerfile.docs +++ /dev/null @@ -1,12 +0,0 @@ -FROM node:22-slim - -RUN npm install -g mintlify - -RUN useradd --create-home --uid 10001 docsuser -WORKDIR /app/docs -COPY --chown=docsuser:docsuser docs/ ./ - -USER docsuser -EXPOSE 3000 - -CMD ["mintlify", "dev", "--host", "0.0.0.0"] diff --git a/README.md b/README.md index a565ee9a8..ba33c1610 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ before they become incidents. Zero latency. Runs locally.

- Failproof AI in action + Failproof AI in action

--- @@ -205,13 +205,13 @@ MIT with [Commons Clause](https://commonsclause.com/) — free for internal and ## Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md). New policies, edge cases, and translations all welcome. +See [CONTRIBUTING.md](./.github/CONTRIBUTING.md). New policies, edge cases, and translations all welcome. > **Build before you start.** Run `bun install && bun run build` first. This repo runs > failproofai's own hooks on itself, and they resolve the `failproofai` import against the > compiled `dist/` bundle — without a build you'll hit `Cannot find package 'failproofai'` > hook errors. Rebuild after changing `src/`. See -> [Build before the in-repo dev hooks will work](./CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](./.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/__tests__/README.md b/__tests__/README.md new file mode 100644 index 000000000..7dd96f52c --- /dev/null +++ b/__tests__/README.md @@ -0,0 +1,42 @@ +# `__tests__/` + +The Vitest suite for every product in this repo except the Rust daemon: the CLI (`__tests__/hooks/`, +`__tests__/audit/`, `__tests__/scripts/`), the Next.js dashboard (`__tests__/app`-facing dirs +`actions/`, `api/`, `components/`, `contexts/`), and the shared `lib/`. 196 unit files and 16 e2e +files. The daemon's own tests live in Rust, under `crates/*/tests/`. + +## What this is + +Two suites with two configs. `vitest.config.mts` runs the unit half — jsdom, `__tests__/setup.ts` +loaded, everything except `__tests__/e2e/**`. `vitest.config.e2e.mts` runs `__tests__/e2e/**/*.e2e.test.ts` +in a node environment with `pool: "forks"` (the tests spawn real subprocesses) and a 20s timeout, and +it does **not** load `setup.ts`. That matters: `setup.ts` installs a fetch guard that fails any unit +test reaching a non-loopback host, naming the host so the missing stub is findable. E2E is exempt +because it is supposed to talk to real things. + +## Who consumes it + +CI only — `.github/workflows/ci.yml` runs the `test` job (`bun run test:run`, three env configs) and +the `test-e2e` job (`bun run test:e2e`). Nothing imports these files at runtime. + +## Does it ship + +No. `__tests__/` is absent from package.json's `files` array, so it never reaches an installed user. + +## Where its tests live + +| Suite | Command | +|---|---| +| unit (`__tests__/**`, minus e2e) | `bun run test:run` | +| e2e (`__tests__/e2e/**/*.e2e.test.ts`) | `bun run test:e2e` | +| daemon (`crates/*/tests/`) | `cargo test --workspace` | + +Gotchas. `__tests__/hooks/` is 83 files and many are named after the **bug** they pin, not the module +they cover — `daemon-probe-race`, `fail-closed-force-decision`, `cloud-artifact-collision`, +`claude-prune-malformed-settings`. Grep for the behaviour, not the filename, or you will write a +second copy of a test that already exists. Four files are tripwires for hand-maintained artifacts that +nothing generates and whose drift is otherwise silent: `hooks/dogfood-configs.test.ts` (this repo's own +`.claude/`, `.codex/`, `.factory/`… configs), `network-guard.test.ts` (tests the `setup.ts` guard +itself), `dashboard-lockdown.test.ts` (the unauthenticated local dashboard's loopback/Origin checks, +written as real exploits), and `ci/release-pipeline.test.ts` + `ci/daemon-packages.test.ts` (publish +ordering and the four `@failproofai/failproofaid--` pins). diff --git a/__tests__/audit/auth-dialog.test.tsx b/__tests__/audit/auth-dialog.test.tsx index 21a2fe712..40f115c13 100644 --- a/__tests__/audit/auth-dialog.test.tsx +++ b/__tests__/audit/auth-dialog.test.tsx @@ -13,7 +13,7 @@ import { render, screen, cleanup } from "@testing-library/react"; // useEffect dep array, so a fresh fn each render would re-fire the effect and // loop forever (the real usePostHog returns a useCallback-stable fn). const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() })); -vi.mock("@/contexts/PostHogContext", () => ({ +vi.mock("@/app/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }), })); diff --git a/__tests__/audit/come-back-better-section.test.tsx b/__tests__/audit/come-back-better-section.test.tsx index b381ec7bc..292744a22 100644 --- a/__tests__/audit/come-back-better-section.test.tsx +++ b/__tests__/audit/come-back-better-section.test.tsx @@ -9,7 +9,7 @@ import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/re // Stable capture (see auth-dialog.test.tsx for why identity must not change). const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() })); -vi.mock("@/contexts/PostHogContext", () => ({ +vi.mock("@/app/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }), })); diff --git a/__tests__/audit/how-to-improve-section.test.tsx b/__tests__/audit/how-to-improve-section.test.tsx index c90a0f7a9..f5fc2f380 100644 --- a/__tests__/audit/how-to-improve-section.test.tsx +++ b/__tests__/audit/how-to-improve-section.test.tsx @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; const { captureMock } = vi.hoisted(() => ({ captureMock: vi.fn() })); -vi.mock("@/contexts/PostHogContext", () => ({ +vi.mock("@/app/contexts/PostHogContext", () => ({ usePostHog: () => ({ capture: captureMock }), })); diff --git a/__tests__/ci/standalone-prune.test.ts b/__tests__/ci/standalone-prune.test.ts new file mode 100644 index 000000000..2074752d2 --- /dev/null +++ b/__tests__/ci/standalone-prune.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +/** + * Drift guard for scripts/prune-standalone.mjs. + * + * Next's file tracer over-collects: it emits the warning "whole project was + * traced unintentionally" and sweeps repo directories into `.next/standalone`, + * which `package.json` "files" then publishes. prune-standalone.mjs deletes + * them again from a HAND-MAINTAINED denylist — so the moment someone adds a + * top-level directory, it ships to every npm user and no test says a word. + * + * That is not hypothetical. `assets/` (612 KB of design lab) was shipping + * unnoticed until a survey looked, and `target/` once made `npm pack` hang on + * fifteen gigabytes of Rust build output. + * + * This test reads the script as TEXT rather than importing it, because the + * module has no exports and executes on import (it exits 1 when + * `.next/standalone` is absent). That is the same technique + * `release-pipeline.test.ts` uses against the workflow YAML. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { describe, it, expect } from "vitest"; + +const ROOT = resolve(import.meta.dirname, "..", ".."); +const SCRIPT = readFileSync(join(ROOT, "scripts", "prune-standalone.mjs"), "utf8"); + +/** Top-level directories the Next server genuinely needs at runtime, plus the + * build outputs it is assembled from. Everything else is repo content. */ +const RUNTIME_NEEDED = new Set([".next", "node_modules", "public", "app", "lib"]); + +function listed(constName: string): string[] { + const block = SCRIPT.match( + new RegExp(`const ${constName} = \\[([\\s\\S]*?)\\n\\];`), + ); + if (!block) throw new Error(`${constName} not found in prune-standalone.mjs`); + return [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); +} + +describe("prune-standalone denylist", () => { + const prunedDirs = listed("STANDALONE_ROOT_PRUNE"); + const prunedFiles = listed("STANDALONE_ROOT_PRUNE_FILES"); + + it("accounts for every tracked top-level directory", () => { + // `git ls-files --stage` rather than plain `ls-files`, because a SUBMODULE + // is a gitlink (mode 160000) with no trailing path segment — plain + // `ls-files` reports `skills` as if it were a file, so a directory-only + // filter walks straight past it. That is exactly how the 544 KB `skills/` + // submodule ended up in the published tarball unnoticed. + const staged = execFileSync("git", ["ls-files", "--stage"], { + cwd: ROOT, + encoding: "utf8", + }) + .split("\n") + .filter(Boolean) + .map((line) => { + const [meta, path] = line.split("\t"); + return { mode: meta.split(" ")[0], path }; + }); + + const tracked = new Set(); + for (const { mode, path } of staged) { + if (mode === "160000") tracked.add(path); // submodule gitlink + else if (path.includes("/")) tracked.add(path.split("/")[0]); + } + + const unaccounted = [...tracked].filter( + (d) => !RUNTIME_NEEDED.has(d) && !prunedDirs.includes(d), + ); + + expect( + unaccounted, + `these top-level directories are neither needed at runtime nor pruned, ` + + `so Next may trace them into the published tarball — add each to ` + + `STANDALONE_ROOT_PRUNE or to RUNTIME_NEEDED in this test`, + ).toEqual([]); + }); + + it("names no directory that no longer exists", () => { + // A stale entry is harmless at runtime but rots the list into noise, which + // is how the real gaps stay hidden. + // + // ABSENT BY DESIGN. Most of the prune list is build output that does not + // exist in a fresh checkout — `target/` only after `cargo build`, `dist/` + // only after `bun run build` — while the CI `test` job runs just + // `bun install --frozen-lockfile && bun run test:run`. Hardcoding those + // names is how this assertion ends up passing on a developer's machine and + // failing in CI, so ask GIT instead: anything it ignores is generated, and + // its absence proves nothing. Only the editor dirs need naming, since they + // are neither tracked nor ignored. + const ignored = (d: string) => + spawnSync("git", ["check-ignore", "-q", d], { cwd: ROOT }).status === 0; + const editorDirs = new Set([".vscode", ".idea", "design-docs"]); + const present = new Set(readdirSync(ROOT)); + const stale = prunedDirs.filter( + (d) => + !present.has(d) && + !editorDirs.has(d) && + !ignored(d) && + !d.startsWith("release-") && + !d.startsWith("."), + ); + expect(stale, "prune list names directories that do not exist").toEqual([]); + }); + + it("prunes the directories whose contents would be largest", () => { + // These four are the ones that have actually caused damage. + for (const d of ["target", "crates", "assets", "__tests__"]) { + expect(prunedDirs, `${d} must stay in the prune list`).toContain(d); + } + }); + + it("keeps the runtime entrypoint and app code", () => { + for (const keep of ["public", "app", ".next", "node_modules"]) { + expect(prunedDirs, `${keep} must never be pruned`).not.toContain(keep); + expect(prunedFiles, `${keep} must never be pruned`).not.toContain(keep); + } + }); +}); diff --git a/__tests__/ci/tarball-surface.test.ts b/__tests__/ci/tarball-surface.test.ts new file mode 100644 index 000000000..199b85847 --- /dev/null +++ b/__tests__/ci/tarball-surface.test.ts @@ -0,0 +1,121 @@ +// @vitest-environment node +/** + * Drift guard for what users actually RECEIVE. + * + * Every other test in this repo checks what the repo contains. This one checks + * the shipped surface, which drifts independently: `package.json` "files" ships + * whole directories (`src/`, `lib/`, `scripts/`), and Next's tracer sweeps + * arbitrary repo content into `.next/standalone`. Both are how dead weight + * reaches users with nothing failing. + * + * Two real cases this exists for: + * - `src/audit/report.ts` was 348 lines of renderer for CLI flags + * `runAuditCli()` rejects. Unreachable since the dashboard flow replaced it, + * and published in every tarball because "files" ships `src/`. + * - Five dogfood hook-config directories and the `skills` submodule were + * traced into `.next/standalone` and published — configuration pointing at + * `scripts/dev-hook.mjs`, which exists only in a checkout. + * + * DELIBERATELY STATIC. The first version of this file shelled out to + * `npm pack --dry-run --json`, and that was a mistake twice over. npm does not + * guarantee stdout is only json — the runner's npm interleaves file-list + * notices, and those paths contain `[project]` (Turbopack chunk names), so + * every attempt to locate the array by bracket found a notice instead. And + * packing a 12 MB / 54 MB-unpacked tarball on three matrix legs at once starved + * the runner enough that a neighbouring test spawning `node dist/cli.mjs` blew + * its 30s timeout. A guard that fails for reasons unrelated to what it guards + * is worse than no guard, so this now reads the manifest instead of invoking + * the packer. `__tests__/ci/standalone-prune.test.ts` covers the bundle side by + * the same principle. + */ +import { existsSync, readFileSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { describe, it, expect } from "vitest"; + +const ROOT = resolve(import.meta.dirname, "..", ".."); +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + files: string[]; + bin: Record; +}; + +/** Load-bearing paths. Each is depended on by an INSTALLED user, so dropping it + * from "files" breaks them and nothing else in this repo would fail. Every + * entry is checked two ways: covered by a `files` entry, and present on disk + * when it is a source file rather than build output. */ +const MUST_SHIP = [ + { path: "bin/failproofaid-shim.mjs", built: false }, // package.json bin.failproofaid + { path: "bin/failproofai.mjs", built: false }, // pi/openclaw source-fallback imports out of this + { path: "pi-extension/index.ts", built: false }, // directory name frozen by installed users' settings + { path: "openclaw-plugin/index.js", built: false }, // same + { path: "dist/cli.mjs", built: true }, // package.json bin.failproofai + { path: "dist/index.js", built: true }, // what `from 'failproofai'` resolves to in a user policy + { path: "dist/worker.mjs", built: true }, // spawned by the Rust daemon +]; + +/** Files removed as unreachable. `files` ships `src/` and `lib/` wholesale, so + * each of these was published before it was deleted — if one comes back, it + * ships again silently. */ +const MUST_NOT_EXIST = [ + "src/audit/report.ts", + "lib/claude-config.ts", + "lib/extract-subagent-ids.ts", +]; + +const coveredByFiles = (p: string): boolean => + pkg.files.some((entry) => { + const e = entry.replace(/\/$/, ""); + return p === e || p.startsWith(`${e}/`); + }); + +describe("npm tarball surface", () => { + it("ships every path an installed user depends on", () => { + for (const { path } of MUST_SHIP) { + expect( + coveredByFiles(path), + `${path} is not covered by any package.json "files" entry`, + ).toBe(true); + } + }); + + it("has those paths on disk, so the entry is not covering a hole", () => { + for (const { path, built } of MUST_SHIP) { + // Build output only exists after `bun run build`. In CI's test job it + // does, because `bun install` runs `prepare` — but that is incidental, + // and a missing dist/ is not this test's finding to report. + if (built && !existsSync(join(ROOT, "dist"))) continue; + expect(existsSync(join(ROOT, path)), `${path} is missing on disk`).toBe( + true, + ); + } + }); + + it("keeps both frozen plugin-package directories in files", () => { + // Already-installed users have these absolute paths written into their own + // settings files, and integrations.ts resolves them from + // FAILPROOFAI_PACKAGE_ROOT. Renaming either orphans their uninstall path. + for (const dir of ["pi-extension/", "openclaw-plugin/"]) { + expect(pkg.files, `${dir} must stay in package.json "files"`).toContain( + dir, + ); + } + }); + + it("declares both bin entries against shipped paths", () => { + for (const [name, target] of Object.entries(pkg.bin)) { + const rel = target.replace(/^\.\//, ""); + expect( + coveredByFiles(rel), + `bin.${name} -> ${target} is not covered by "files"`, + ).toBe(true); + } + }); + + it("has not resurrected a file deleted as unreachable", () => { + for (const gone of MUST_NOT_EXIST) { + expect( + existsSync(join(ROOT, gone)), + `${gone} is back — it would ship again, since "files" covers its directory`, + ).toBe(false); + } + }); +}); diff --git a/__tests__/components/button.test.tsx b/__tests__/components/button.test.tsx index 414072793..b774584b4 100644 --- a/__tests__/components/button.test.tsx +++ b/__tests__/components/button.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { createRef } from "react"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/app/components/ui/button"; describe("Button", () => { it("renders children text", () => { diff --git a/__tests__/components/reach-developers.test.tsx b/__tests__/components/reach-developers.test.tsx index 5bca78648..f01c1ed28 100644 --- a/__tests__/components/reach-developers.test.tsx +++ b/__tests__/components/reach-developers.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { ReachDevelopers } from "@/components/reach-developers"; +import { ReachDevelopers } from "@/app/components/reach-developers"; describe("ReachDevelopers", () => { it("renders trigger button", () => { diff --git a/__tests__/contexts/posthog-context.test.tsx b/__tests__/contexts/posthog-context.test.tsx index 166c825dc..cbb286417 100644 --- a/__tests__/contexts/posthog-context.test.tsx +++ b/__tests__/contexts/posthog-context.test.tsx @@ -19,7 +19,7 @@ vi.mock("next/navigation", () => ({ usePathname: () => mockPathname, })); -import { PostHogProvider, usePostHog } from "@/contexts/PostHogContext"; +import { PostHogProvider, usePostHog } from "@/app/contexts/PostHogContext"; const enabledConfig = { enabled: true, diff --git a/__tests__/helpers/test-utils.tsx b/__tests__/helpers/test-utils.tsx index e72ae552f..87f682eb4 100644 --- a/__tests__/helpers/test-utils.tsx +++ b/__tests__/helpers/test-utils.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render, type RenderOptions } from "@testing-library/react"; -import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; +import { AutoRefreshProvider } from "@/app/contexts/AutoRefreshContext"; function Providers({ children }: { children: React.ReactNode }) { return {children}; diff --git a/__tests__/lib/extract-subagent-ids.test.ts b/__tests__/lib/extract-subagent-ids.test.ts deleted file mode 100644 index a29709e5e..000000000 --- a/__tests__/lib/extract-subagent-ids.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { extractSubagentIds } from "@/lib/extract-subagent-ids"; - -describe("extractSubagentIds", () => { - it("returns deduped ids from valid user entries", () => { - const fileContent = [ - JSON.stringify({ type: "user", toolUseResult: { agentId: "abc123" } }), - JSON.stringify({ type: "user", toolUseResult: { agentId: "def456" } }), - JSON.stringify({ type: "user", toolUseResult: { agentId: "abc123" } }), - ].join("\n"); - - expect(extractSubagentIds(fileContent)).toEqual(["abc123", "def456"]); - }); - - it("skips malformed JSON lines silently", () => { - const fileContent = [ - JSON.stringify({ type: "user", toolUseResult: { agentId: "abc123" } }), - "{bad json", - JSON.stringify({ type: "user", toolUseResult: { agentId: "def456" } }), - ].join("\n"); - - expect(extractSubagentIds(fileContent)).toEqual(["abc123", "def456"]); - }); - - it("ignores entries where type is not user", () => { - const fileContent = [ - JSON.stringify({ type: "assistant", toolUseResult: { agentId: "abc123" } }), - JSON.stringify({ type: "system", toolUseResult: { agentId: "def456" } }), - ].join("\n"); - - expect(extractSubagentIds(fileContent)).toEqual([]); - }); - - it("rejects non-hex agent ids", () => { - const fileContent = [ - JSON.stringify({ type: "user", toolUseResult: { agentId: "abc123" } }), - JSON.stringify({ type: "user", toolUseResult: { agentId: "abc123g" } }), - JSON.stringify({ type: "user", toolUseResult: { agentId: "ABC123" } }), - ].join("\n"); - - expect(extractSubagentIds(fileContent)).toEqual(["abc123"]); - }); - - it("ignores entries missing toolUseResult", () => { - const fileContent = [ - JSON.stringify({ type: "user" }), - JSON.stringify({ type: "user", toolUseResult: null }), - ].join("\n"); - - expect(extractSubagentIds(fileContent)).toEqual([]); - }); - - it("returns an empty array for empty input", () => { - expect(extractSubagentIds("")).toEqual([]); - }); -}); diff --git a/__tests__/scripts/translate-docs/config.test.ts b/__tests__/scripts/translate-docs/config.test.ts index cec14327a..0e10a27dc 100644 --- a/__tests__/scripts/translate-docs/config.test.ts +++ b/__tests__/scripts/translate-docs/config.test.ts @@ -1,4 +1,6 @@ // @vitest-environment node +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, it, expect } from "vitest"; import { LANGUAGES, @@ -109,6 +111,40 @@ describe("DO_NOT_TRANSLATE", () => { }); }); +describe(".gitattributes", () => { + // The translated docs are 46% of this repo's tracked files. `.gitattributes` + // marks them linguist-generated so they collapse in every diff and drop out + // of the language bar. That list is hand-maintained, so adding a language to + // LANGUAGES without adding it here would silently un-collapse the new locale + // — a regression nobody would notice until a PR diff blew up months later. + const gitattributes = readFileSync( + join(import.meta.dirname, "..", "..", "..", ".gitattributes"), + "utf8", + ); + + it("marks every translated docs directory as generated", () => { + for (const { code } of LANGUAGES) { + expect( + gitattributes, + `docs/${code}/** is missing a linguist-generated rule in .gitattributes`, + ).toContain(`docs/${code}/**`); + } + }); + + it("marks no directory that is not a real language", () => { + const marked = [...gitattributes.matchAll(/^docs\/([^/*\s]+)\/\*\*/gm)].map( + (m) => m[1], + ); + expect(marked.sort()).toEqual(LANGUAGES.map((l) => l.code).sort()); + }); + + it("marks the translated READMEs and both lockfiles", () => { + expect(gitattributes).toContain("docs/i18n/README.*.md"); + expect(gitattributes).toContain("bun.lock"); + expect(gitattributes).toContain("Cargo.lock"); + }); +}); + describe("NAV_TRANSLATIONS", () => { it("has entries for all 14 languages plus English", () => { const expectedCodes = ["en", ...LANGUAGES.map((l) => l.code)]; diff --git a/__tests__/scripts/translate-docs/readme-translator.test.ts b/__tests__/scripts/translate-docs/readme-translator.test.ts index b4803c8d7..9eb2b753c 100644 --- a/__tests__/scripts/translate-docs/readme-translator.test.ts +++ b/__tests__/scripts/translate-docs/readme-translator.test.ts @@ -56,8 +56,8 @@ describe("rebaseReadmePaths", () => { expect( rebaseReadmePaths(''), ).toBe(``); - expect(rebaseReadmePaths("![demo](readme-arch-hq.gif)")).toBe( - `![demo](${RAW}/readme-arch-hq.gif)`, + expect(rebaseReadmePaths("![demo](assets/readme-arch-hq.gif)")).toBe( + `![demo](${RAW}/assets/readme-arch-hq.gif)`, ); }); @@ -151,14 +151,14 @@ describe("rebaseReadmePaths", () => { // the later src/srcset passes reading stale offsets and rewriting the // literal sample paths inside the block. const fenced = - "![arch](readme-arch-hq.gif)\n" + + "![arch](assets/readme-arch-hq.gif)\n" + "\n" + "```html\n" + '\n' + '\n' + "```"; expect(rebaseReadmePaths(fenced)).toBe( - `![arch](${RAW}/readme-arch-hq.gif)\n` + + `![arch](${RAW}/assets/readme-arch-hq.gif)\n` + "\n" + "```html\n" + '\n' + diff --git a/app/README.md b/app/README.md new file mode 100644 index 000000000..56c7102c7 --- /dev/null +++ b/app/README.md @@ -0,0 +1,45 @@ +# app/ + +The Next.js 16 App Router source for **the dashboard** — the local web UI a user gets from +`failproofai` (dev: `bun run dev` on port 8020). It renders four route trees: `/policies` +(policy toggles + hook-activity log), `/projects` and `/project/[name]/session/[sessionId]` +(session browsing), `/audit` (the audit product's report), and `/settings`. `app/page.tsx` +is a deliberate `notFound()` — the real entry redirects live in `proxy.ts` at the repo root. + +## Who consumes it + +The Next server process, started via `scripts/launch.ts` → `.next/standalone/server.js`. +Server code here reaches straight into the CLI's own modules — `app/actions/*.ts` are +`"use server"` actions importing `@/src/hooks/manager`, `@/src/hooks/hooks-config` and +`@/src/audit`, so the dashboard writes the same config files the CLI does. `app/api/*/route.ts` +are HTTP route handlers for things a server action can't do: streaming a transcript +(`api/download/[project]/[session]`), the fire-and-forget audit run (`api/audit/run` + +`status`, sharing module state in `api/audit/_state.ts`), and OTP auth (`api/auth/*`). +Every request first passes `proxy.ts`, which enforces loopback Host + same-Origin — this UI +has no authentication and can uninstall hooks from every CLI. + +## Does it ship + +Yes, and more literally than you would expect. `app/` is absent from package.json +`"files"`, but `.next/standalone/` is in it — and Next's file tracer copies the raw App +Router sources into that bundle, so **82 `.ts`/`.tsx` files under `app/` ship verbatim** +alongside the compiled output. `scripts/prune-standalone.mjs` trims the bundle but +deliberately keeps `app`, because the standalone server reads from it. + +Practical consequence: renaming a route directory changes the installed dashboard's URLs, +and anything you add under `app/` reaches every npm user. Verify with +`npm pack --dry-run --json | jq -r '.[0].files[].path' | grep standalone/app`. + +## Where its tests live + +`__tests__/actions/`, `__tests__/api/`, `__tests__/components/`, `__tests__/contexts/`, and +`__tests__/audit/` (the `audit/_components` React tests sit alongside the audit engine's), +run with `bun run test:run`. Browser-level layout checks are shell scripts under +`__tests__/e2e/layout/`, run with `bun run test:e2e`. + +Notes: `app/components/` and `app/contexts/` were just moved in from the repo root on this +branch (`components/ui/button.tsx` → `app/components/ui/button.tsx`, both contexts likewise), +so imports are `@/app/components/...` — older code or docs referencing `@/components/...` +are stale. `app/policies/hooks-client.tsx` is by far the largest file here (~1,968 lines); +its `page.tsx` is only a Suspense wrapper. Route access is gated at runtime by +`FAILPROOFAI_DISABLE_PAGES`, checked in `app/layout.tsx` and in each page. diff --git a/app/audit/_components/audit-dashboard.tsx b/app/audit/_components/audit-dashboard.tsx index ce64dfc3f..5fa872b21 100644 --- a/app/audit/_components/audit-dashboard.tsx +++ b/app/audit/_components/audit-dashboard.tsx @@ -21,7 +21,7 @@ import { classifyAgent } from "@/src/audit/archetypes"; import { deriveScore, gradeFor, projectedScore } from "@/src/audit/scoring"; import { deriveStrengths } from "@/src/audit/strengths"; import { deriveFindings } from "@/src/audit/findings"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { AuditPoster } from "./audit-poster"; import { StrengthsSection } from "./strengths-section"; diff --git a/app/audit/_components/audit-poster.tsx b/app/audit/_components/audit-poster.tsx index 9822eda34..de324318c 100644 --- a/app/audit/_components/audit-poster.tsx +++ b/app/audit/_components/audit-poster.tsx @@ -27,7 +27,7 @@ import { type Grade } from "@/src/audit/scoring"; import { getArchetypeRarityPct } from "@/src/audit/social-proof"; import { copyOrDownloadCard, downloadCard, shareCardNative, shareCardToastMessage } from "@/lib/share-card"; import { toast } from "@/app/components/toast"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { Sigil } from "./sigil"; import { X_TEMPLATES, LI_TEMPLATES, pickTemplate, type ShareCtx } from "./share-templates"; diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx index b37cf2f5d..101f3ab27 100644 --- a/app/audit/_components/auth-dialog.tsx +++ b/app/audit/_components/auth-dialog.tsx @@ -14,7 +14,7 @@ */ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { fetchWithTimeout, isAbortError } from "@/lib/fetch-with-timeout"; // Co-located so the dialog is styled on EVERY route that renders it (/audit and // /settings), not only the one route stylesheet these rules used to live in. diff --git a/app/audit/_components/come-back-better-section.tsx b/app/audit/_components/come-back-better-section.tsx index 68efcbf57..a72b1045c 100644 --- a/app/audit/_components/come-back-better-section.tsx +++ b/app/audit/_components/come-back-better-section.tsx @@ -19,7 +19,7 @@ * dominating the layout. */ import { useCallback, useEffect, useRef, useState } from "react"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { isAbortError } from "@/lib/fetch-with-timeout"; import { AuthDialog, type AuthedUser } from "./auth-dialog"; import { InviteDialog } from "./invite-dialog"; diff --git a/app/audit/_components/empty-state.tsx b/app/audit/_components/empty-state.tsx index 2e29a79c7..4c2ad7403 100644 --- a/app/audit/_components/empty-state.tsx +++ b/app/audit/_components/empty-state.tsx @@ -13,7 +13,7 @@ * space as the loaded dashboard does on its hero — no more cramped popover. */ import { triggerRun } from "./rerun-button"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; interface Props { mode: "no-cache" | "zero-sessions"; diff --git a/app/audit/_components/how-to-improve-section.tsx b/app/audit/_components/how-to-improve-section.tsx index 878c5f01b..ec28c4f6e 100644 --- a/app/audit/_components/how-to-improve-section.tsx +++ b/app/audit/_components/how-to-improve-section.tsx @@ -13,7 +13,7 @@ import { useMemo, useState } from "react"; import type { AuditResult } from "@/src/audit/types"; import { type Grade, tierName } from "@/src/audit/scoring"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; interface Props { result: AuditResult; diff --git a/app/audit/_components/invite-dialog.tsx b/app/audit/_components/invite-dialog.tsx index 2e1b63153..3c2d26ec4 100644 --- a/app/audit/_components/invite-dialog.tsx +++ b/app/audit/_components/invite-dialog.tsx @@ -11,7 +11,7 @@ */ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { toast } from "@/app/components/toast"; interface Props { diff --git a/components/navbar.tsx b/app/components/navbar.tsx similarity index 97% rename from components/navbar.tsx rename to app/components/navbar.tsx index 32a7fa33f..abfb15ae3 100644 --- a/components/navbar.tsx +++ b/app/components/navbar.tsx @@ -11,9 +11,9 @@ import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ReachDevelopers } from "@/components/reach-developers"; +import { ReachDevelopers } from "@/app/components/reach-developers"; import { RefreshButton } from "@/app/components/refresh-button"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; const NAV_LINKS = [ { href: "/projects", label: "projects" }, diff --git a/components/reach-developers.tsx b/app/components/reach-developers.tsx similarity index 98% rename from components/reach-developers.tsx rename to app/components/reach-developers.tsx index 47a71f6b3..fd98d2d24 100644 --- a/components/reach-developers.tsx +++ b/app/components/reach-developers.tsx @@ -3,7 +3,7 @@ import React, { useState, useCallback } from "react"; import { GitBranch, ChevronDown, Star, BookOpen, MessageCircle, MessageSquareWarning } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/app/components/ui/button"; const CONTACT_EMAIL = "failproofai@exosphere.host"; diff --git a/app/components/refresh-button.tsx b/app/components/refresh-button.tsx index 3e79dee08..e7600294c 100644 --- a/app/components/refresh-button.tsx +++ b/app/components/refresh-button.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useCallback, useTransition } from "react"; import { RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; +import { useAutoRefresh } from "@/app/contexts/AutoRefreshContext"; const AUTO_REFRESH_OPTIONS = [ { label: "Off", value: 0 }, diff --git a/app/components/session-hooks-panel.tsx b/app/components/session-hooks-panel.tsx index 1481c34a6..b99c60173 100644 --- a/app/components/session-hooks-panel.tsx +++ b/app/components/session-hooks-panel.tsx @@ -11,7 +11,7 @@ import { import PaginationControls from "@/app/components/pagination-controls"; import { searchHookActivityAction } from "@/app/actions/get-hook-activity"; import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; -import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; +import { useAutoRefresh } from "@/app/contexts/AutoRefreshContext"; import { formatRelativeTime } from "@/lib/format-duration"; import { CopyButton } from "@/app/components/copy-button"; diff --git a/components/ui/button.tsx b/app/components/ui/button.tsx similarity index 100% rename from components/ui/button.tsx rename to app/components/ui/button.tsx diff --git a/contexts/AutoRefreshContext.tsx b/app/contexts/AutoRefreshContext.tsx similarity index 100% rename from contexts/AutoRefreshContext.tsx rename to app/contexts/AutoRefreshContext.tsx diff --git a/contexts/PostHogContext.tsx b/app/contexts/PostHogContext.tsx similarity index 100% rename from contexts/PostHogContext.tsx rename to app/contexts/PostHogContext.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 9a8669518..ed449a4ea 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,10 +5,10 @@ * `` so there's no theme indeterminacy and no inline script is needed. */ import type { Metadata } from "next"; -import { PostHogProvider } from "@/contexts/PostHogContext"; +import { PostHogProvider } from "@/app/contexts/PostHogContext"; import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; -import { AutoRefreshProvider } from "@/contexts/AutoRefreshContext"; -import { Navbar } from "@/components/navbar"; +import { AutoRefreshProvider } from "@/app/contexts/AutoRefreshContext"; +import { Navbar } from "@/app/components/navbar"; import { Toaster } from "@/app/components/toast"; import "./globals.css"; diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 3b0422be5..6ca7056ba 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -17,14 +17,14 @@ import type { IntegrationType } from "@/src/hooks/types"; import { toggleCustomPolicyAction, togglePolicyAction } from "@/app/actions/update-hooks-config"; import { installHooksWebAction, removeHooksWebAction } from "@/app/actions/install-hooks-web"; import { updatePolicyParamsAction } from "@/app/actions/update-policy-params"; -import { useAutoRefresh } from "@/contexts/AutoRefreshContext"; -import { usePostHog } from "@/contexts/PostHogContext"; +import { useAutoRefresh } from "@/app/contexts/AutoRefreshContext"; +import { usePostHog } from "@/app/contexts/PostHogContext"; import { useUrlParams } from "@/lib/use-url-params"; import { pageToParam, paramToPage } from "@/lib/url-filter-serializers"; import { getCliLabel, getCliBadgeClasses, KNOWN_CLI_IDS, isKnownCli, type CliId } from "@/lib/cli-registry"; import { enforcementFor } from "@/src/hooks/enforcement-capability"; import { formatRelativeTime } from "@/lib/format-duration"; -import { Button } from "@/components/ui/button"; +import { Button } from "@/app/components/ui/button"; function formatAbsoluteTime(ts: number): string { return new Date(ts).toLocaleString(undefined, { diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000..7443b2711 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,34 @@ +# assets/ + +## What this is + +Brand and reference material, not application source. Nothing here belongs to any of the five +products — no file in `assets/` is imported by the CLI, the Next dashboard, the Rust daemon, or +the docs site. It holds `logos/` (per-CLI logo SVGs plus `logos/company/`, mirrored into +`public/`), `readme-arch-hq.gif` (11 MB, the root README animation), `font-kit/` (the +befailproof.ai Bitcount wordmark packaged for reuse — see its own README), and `audit/`, a +standalone design lab of `.jsx`/`.html` files opened directly in a browser by a human. + +## Who consumes it + +Humans and GitHub, not code. `README.md` at the repo root embeds `assets/readme-arch-hq.gif` and +the twenty `assets/logos/*` CLI logo files (18 SVG, 2 PNG); `scripts/translate-docs/readme-translator.ts` rewrites +those same paths to absolute `raw.githubusercontent` URLs for the 14 translated copies under +`docs/i18n/`, because a relative `assets/` path resolves nowhere from two directories down and +Mintlify has no `assets/` tree at all. `assets/audit/*.jsx` is a prototype for the audit report — +it is opened by hand, never built or imported. `eslint.config.mjs` ignores `assets/` wholesale. + +## Does it ship + +No. `assets/` is not in package.json `"files"`. It once reached users anyway: Next's file tracer +swept it into `.next/standalone`, which *is* shipped, so 612 KB of design lab went out in every +tarball — and moving the 11 MB GIF here would have put that in front of every `npm install`. +`scripts/prune-standalone.mjs` now deletes `assets` from the standalone root deliberately. +**Adding a file here does not ship it, and should not.** If a user-facing asset is needed, it +belongs in `public/` (dashboard) or `docs/` (site). + +## Where its tests live + +`__tests__/ci/tarball-surface.test.ts` — `assets` is the first entry in its +`MUST_NOT_SHIP_UNDER_STANDALONE` list, so the prune is a tripwire, not a convention. Run it with +`bun run test:run`. diff --git a/templates/bitcount-font/README.md b/assets/font-kit/README.md similarity index 100% rename from templates/bitcount-font/README.md rename to assets/font-kit/README.md diff --git a/templates/bitcount-font/bitcount-prop-single.woff2 b/assets/font-kit/bitcount-prop-single.woff2 similarity index 100% rename from templates/bitcount-font/bitcount-prop-single.woff2 rename to assets/font-kit/bitcount-prop-single.woff2 diff --git a/templates/bitcount-font/bitcount.css b/assets/font-kit/bitcount.css similarity index 100% rename from templates/bitcount-font/bitcount.css rename to assets/font-kit/bitcount.css diff --git a/templates/bitcount-font/fonts.ts.example b/assets/font-kit/fonts.ts.example similarity index 100% rename from templates/bitcount-font/fonts.ts.example rename to assets/font-kit/fonts.ts.example diff --git a/readme-arch-hq.gif b/assets/readme-arch-hq.gif similarity index 100% rename from readme-arch-hq.gif rename to assets/readme-arch-hq.gif diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 000000000..1b59e8d7e --- /dev/null +++ b/bin/README.md @@ -0,0 +1,32 @@ +# bin/ + +Part of **the CLI** product. Three entrypoint scripts, no library code: `failproofai.mjs` is the +1,854-line CLI router (`--hook`, `--version`, `policies`, `config`, `audit`, and the dashboard +launcher); `failproofai-worker.mjs` is the warm worker the Rust daemon spawns; `failproofaid-shim.mjs` +is the `failproofaid` npm bin. Each sets `FAILPROOFAI_PACKAGE_ROOT` / `FAILPROOFAI_DIST_PATH` from its +own `import.meta.url` before importing anything else. + +## Who consumes it + +| File | Consumer | +|------|----------| +| `failproofai.mjs` | Bundled by `bun run build:cli` into `dist/cli.mjs`, which is `package.json`'s `bin.failproofai`. Installed hook configs in the 12 agent CLIs invoke it as `npx -y failproofai --hook `; in this repo `scripts/dev-hook.mjs` invokes it instead. `resolveCliCommand()` in `src/hooks/daemon-service.ts` points scheduled audits at the bundle, never at this file. | +| `failproofai-worker.mjs` | The Rust daemon only (`crates/failproofaid/src/worker.rs`), via `resolveWorkerCommand()` → `dist/worker.mjs`. It has no `bin` entry; it reads `FAILPROOFAI_WORKER_SOCKET` and calls `startWorkerServer` from `src/hooks/worker-server.ts`. | +| `failproofaid-shim.mjs` | End users typing `failproofaid` by hand. Service units point `ExecStart` at `~/.failproofai/bin/failproofaid-` directly, bypassing this shim. | + +## Does it ship + +Yes — `bin/` is in `package.json` "files". `failproofaid-shim.mjs` ships as a live path: it is +`bin.failproofaid`, so renaming it breaks `failproofaid` for every installed user. The other two ship +as source but are not executed from here in production — node cannot run them (bare +`import { version } from "../package.json"`, extensionless `.ts` specifiers); only their `dist/` +bundles are node-runnable. + +**Gotcha:** `build:cli` rewrites the shebang with a literal `.replace('#!/usr/bin/env bun', '#!/usr/bin/env node')` +on the bundle. Line 1 of `failproofai.mjs` must stay exactly `#!/usr/bin/env bun` — no test guards +this, and a changed line 1 silently ships a `bun`-shebanged CLI to machines without bun. + +## Where its tests live + +`__tests__/e2e/cli/cli-args.e2e.test.ts` (via `__tests__/e2e/helpers/cli-runner.ts`) — `bun run test:e2e`. +Hook-path behaviour is covered by `__tests__/hooks/` — `bun run test:run`. diff --git a/components.json b/components.json deleted file mode 100644 index 4fda9de81..000000000 --- a/components.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "app/globals.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} -} - diff --git a/crates/.gitkeep b/crates/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 000000000..eb8755c7a --- /dev/null +++ b/crates/README.md @@ -0,0 +1,46 @@ +# crates/ + +## What this is + +The daemon — product 3 of the five in this repo, and the only Rust. A Cargo workspace (`resolver = +"3"`, members `crates/*`) of three crates: `failproofaid` (the binary: Unix-socket server, singleton +flock, warm-worker supervision, plus the cloud-policy, telemetry, collector and scheduled-audit +lanes started in `failproofaid/src/main.rs`), `fpai-collect` (session capture), and `fpai-ipc` (the +wire protocol — length-prefixed JSON framing, a `protocolVersion` envelope, `SO_PEERCRED`/ +`getpeereid` peer checks). It holds **zero policy logic**: `server.rs` answers `ping` itself and +relays every `hook` request to a warm Node/Bun worker running the TypeScript evaluator. The contract +that makes this load-bearing is **fail-closed** — on a machine where setup completed, failproofaid is +the *only* evaluator, and an unreachable socket or a `PROTOCOL_VERSION` mismatch **denies**. Read +`PROTOCOL.md` before touching the wire and `CLOUD_POLICIES.md` before touching policy sync. + +`fpai-collect/src/sources//` is the per-CLI vertical slice the TypeScript side has no equivalent +of: one module per agent's on-disk format (`mod.rs` + `transform.rs`). Twelve CLI sources — claude, +codex, copilot, cursor, openclaw, pi, factory, antigravity as file tailers; goose, opencode, hermes, +devin as SQLite pollers — plus `sources/hooks`, the CLI-agnostic hook stream. + +## Who consumes it + +The installed service unit (`failproofaid@.service`, or a `/Library/LaunchDaemons` plist on +macOS) runs the binary; every hook invocation reaches it over the socket through +`src/hooks/daemon-client.ts` (`tryDaemonHook`: ~150 ms to connect, 30 s for the response). The daemon +in turn spawns `dist/worker.mjs` via `bin/failproofai-worker.mjs` on a second socket. Changes under +`crates/**` also trigger `.github/workflows/build-daemon.yml`, which cross-compiles the four release +binaries. + +## Does it ship + +Not as source — `crates/` is absent from package.json's `files`, so no Rust reaches an installed +user, and all three crates set `publish = false` (nothing goes to crates.io). The **compiled** binary +reaches users by two other channels: the `@failproofai/failproofaid--` optional dependency +packages, and the GitHub Release assets fetched by `src/hooks/daemon-download.ts`; both land at +`~/.failproofai/bin/failproofaid-`. The version in the root `Cargo.toml`'s +`[workspace.package]` is checked against root `package.json` by CI's version-consistency job. + +## Where its tests live + +`crates/failproofaid/tests/` (`daemon_e2e.rs`, `audit_lane_e2e.rs`, `collector_reload_e2e.rs`, +`telemetry_e2e.rs`) and `crates/fpai-collect/tests/` (one per source, plus `supervisor.rs`, +`uploader.rs`, `delivery.rs`), alongside in-module `#[cfg(test)]` units. Run `cargo test --workspace`; +CI's `rust-quality` job adds `cargo fmt --check` and `cargo clippy`. Some tests spawn the real thing — +`daemon_e2e.rs` runs the compiled binary over a real socket, and `worker.rs`'s tests launch a real +`bun bin/failproofai-worker.mjs`, so **bun must be on PATH**. diff --git a/crates/fpai-ipc/Cargo.toml b/crates/fpai-ipc/Cargo.toml index ae4797e33..faa39d609 100644 --- a/crates/fpai-ipc/Cargo.toml +++ b/crates/fpai-ipc/Cargo.toml @@ -11,6 +11,3 @@ publish = false serde = { version = "1", features = ["derive"] } serde_json = "1" libc = "0.2" - -[dev-dependencies] -proptest = "1" diff --git a/docker-hook-sync/README.md b/docker-hook-sync/README.md new file mode 100644 index 000000000..ff2b89818 --- /dev/null +++ b/docker-hook-sync/README.md @@ -0,0 +1,44 @@ +# docker-hook-sync/ + +## What this is + +Build context for the `hook-sync` container image — internal release/maintenance +tooling, not one of the five shipped products. `Dockerfile` bundles Node 20, bun, +`git`/`gh`/`jq`, the Claude Code CLI and `failproofai@latest`; `entrypoint.sh` is a +single-shot job that clones this repo, cuts an `auto/sync-cli-harnesses-` branch, +and runs `claude --effort ultracode -p ` +headless so the agent detects drift between the 12 agent-CLI hook contracts and this +repo and opens one auto-PR itself. + +Two details in `entrypoint.sh` are easy to miss. It edits the clone's +`.failproofai/policies-config.json` with `jq` to drop `require-ci-green-before-stop` +and `block-read-outside-cwd`, then `git update-index --skip-worktree`s the file so the +edit never reaches the PR while failproofai still reads it at runtime — the +`require-commit/push/pr-before-stop` gates stay on, and they are what actually forces +the agent to finish the PR. And `claude` is wrapped in `script -qefc` purely to give it +a PTY, because Node block-buffers a piped stdout and `--output-format stream-json` +would otherwise not line-flush to the pod log. + +## Who consumes it + +`.github/workflows/build-image.yml` builds this context (`context: docker-hook-sync`) +and pushes `ghcr.io/failproofai/hook-sync:latest` on pushes to `main` that touch this +directory, plus a daily 08:00 UTC rebuild that refreshes the `@latest`-pinned +`claude-code` and `failproofai` npm globals. Nothing in this repo *runs* the container: +it is executed by a k8s CronJob managed in separate infra, or by hand +(`docker run --rm -e CLAUDE_CODE_OAUTH_TOKEN=... -e GH_TOKEN=... hook-sync:latest`). + +## Does it ship + +No. `docker-hook-sync` is not in package.json's `files` array, so it never reaches an +npm user. `__tests__/ci/tarball-surface.test.ts` additionally lists it in +`MUST_NOT_SHIP_UNDER_STANDALONE`, and `scripts/prune-standalone.mjs` strips it from +`.next/standalone` where Next's tracer over-collects it. Renaming the directory only +requires updating `build-image.yml`, those two files, and the test below. + +## Where its tests live + +`__tests__/hooks/dogfood-configs.test.ts` ("the hook-sync image still installs bun" — +`scripts/dev-hook.mjs` only *locates* bun, so dropping that layer would silently kill +the stop-gates that drive the auto-PR) and `__tests__/ci/tarball-surface.test.ts`. Both +run under `bun run test:run`. diff --git a/docs/Dockerfile.dev b/docs/Dockerfile.dev new file mode 100644 index 000000000..78dc8f59d --- /dev/null +++ b/docs/Dockerfile.dev @@ -0,0 +1,21 @@ +# Local Mintlify preview for docs/. Built by no workflow — run it by hand. +# +# The COPY below is repo-root-relative, so the build CONTEXT must be the repo +# root even though this file now lives in docs/: +# +# docker build -f docs/Dockerfile.dev -t failproofai-docs . +# docker run --rm -p 3000:3000 failproofai-docs +# +# Running it from inside docs/ fails with "docs/: not found". +FROM node:22-slim + +RUN npm install -g mintlify + +RUN useradd --create-home --uid 10001 docsuser +WORKDIR /app/docs +COPY --chown=docsuser:docsuser docs/ ./ + +USER docsuser +EXPOSE 3000 + +CMD ["mintlify", "dev", "--host", "0.0.0.0"] diff --git a/docs/ar/testing.mdx b/docs/ar/testing.mdx index b2169e0cc..811e1a661 100644 --- a/docs/ar/testing.mdx +++ b/docs/ar/testing.mdx @@ -257,4 +257,4 @@ describe("block-rm-rf (E2E)", () => { يتطلب التشغيل الكامل للـ CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) أن يمر قبل الدمج. تشغل مجموعة E2E كمهمة CI منفصلة بالتوازي. -انظر [المساهمة](../CONTRIBUTING.md) للحصول على قائمة التحقق الكاملة قبل الدمج. \ No newline at end of file +انظر [المساهمة](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) للحصول على قائمة التحقق الكاملة قبل الدمج. \ No newline at end of file diff --git a/docs/de/testing.mdx b/docs/de/testing.mdx index 4fd995764..f09438973 100644 --- a/docs/de/testing.mdx +++ b/docs/de/testing.mdx @@ -257,4 +257,4 @@ Der `forks`-Pool ist wichtig: Thread-basierte Worker teilen `globalThis`, was Su Der vollständige CI-Durchlauf (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) muss erfolgreich sein, bevor ein Merge durchgeführt werden kann. Die E2E-Suite wird als separater CI-Job parallel ausgeführt. -Die vollständige Checkliste vor dem Merge ist unter [Contributing](../CONTRIBUTING.md) zu finden. \ No newline at end of file +Die vollständige Checkliste vor dem Merge ist unter [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) zu finden. \ No newline at end of file diff --git a/docs/es/testing.mdx b/docs/es/testing.mdx index e87bbede1..82f39ae8d 100644 --- a/docs/es/testing.mdx +++ b/docs/es/testing.mdx @@ -257,4 +257,4 @@ El pool `forks` es importante: los workers basados en hilos comparten `globalThi La ejecución completa de CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) debe pasar antes de hacer merge. La suite E2E se ejecuta como un job de CI independiente en paralelo. -Consulta [Contributing](../CONTRIBUTING.md) para ver el checklist completo previo al merge. \ No newline at end of file +Consulta [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) para ver el checklist completo previo al merge. \ No newline at end of file diff --git a/docs/fr/testing.mdx b/docs/fr/testing.mdx index b6d9489b7..cf5859791 100644 --- a/docs/fr/testing.mdx +++ b/docs/fr/testing.mdx @@ -257,4 +257,4 @@ Le pool `forks` est important : les workers basés sur des threads partagent `gl L'exécution complète de la CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) doit passer avant toute fusion. La suite E2E s'exécute en parallèle dans un job CI séparé. -Consultez [Contributing](../CONTRIBUTING.md) pour la liste de vérification complète avant fusion. \ No newline at end of file +Consultez [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) pour la liste de vérification complète avant fusion. \ No newline at end of file diff --git a/docs/he/testing.mdx b/docs/he/testing.mdx index ef14eec4c..fb7f03085 100644 --- a/docs/he/testing.mdx +++ b/docs/he/testing.mdx @@ -257,4 +257,4 @@ describe("block-rm-rf (E2E)", () => { ההרצה המלאה של CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) נדרשת כדי להצליח לפני merge. חבילת E2E חוברת כעבודת CI נפרדת במקביל. -ראה [Contributing](../CONTRIBUTING.md) לקבלת רשימת ה-checklist המלאה לפני merge. \ No newline at end of file +ראה [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) לקבלת רשימת ה-checklist המלאה לפני merge. \ No newline at end of file diff --git a/docs/hi/testing.mdx b/docs/hi/testing.mdx index 62be5cf03..31f56a448 100644 --- a/docs/hi/testing.mdx +++ b/docs/hi/testing.mdx @@ -257,4 +257,4 @@ E2E टेस्ट `vitest.config.e2e.mts` का उपयोग करते पूरा CI रन (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) मर्ज करने से पहले पास होना आवश्यक है। E2E सूट समानांतर में एक अलग CI कार्य के रूप में चलता है। -पूर्ण प्री-मर्ज चेकलिस्ट के लिए [Contributing](../CONTRIBUTING.md) देखें। \ No newline at end of file +पूर्ण प्री-मर्ज चेकलिस्ट के लिए [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) देखें। \ No newline at end of file diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index ada412542..699f5e045 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -26,7 +26,7 @@

- Failproof AI in action + Failproof AI in action

--- @@ -213,13 +213,13 @@ MIT مع [Commons Clause](https://commonsclause.com/) - مجاني للاستخ ## المساهمة -انظر [CONTRIBUTING.md](../../CONTRIBUTING.md). السياسات الجديدة وحالات الحافة والترجمات كلها مرحب بها. +انظر [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). السياسات الجديدة وحالات الحافة والترجمات كلها مرحب بها. > **بناء قبل أن تبدأ.** قم بتشغيل `bun install && bun run build` أولاً. يعمل هذا المستودع > خطاطيف failproofai الخاصة بها على نفسها، وهي تحل استيراد `failproofai` ضد > حزمة `dist/` المترجمة - بدون بناء ستصاب بأخطاء خطاف `Cannot find package 'failproofai'`. > أعد البناء بعد تغيير `src/`. انظر -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.de.md b/docs/i18n/README.de.md index e99fc89a5..eb0f446f1 100644 --- a/docs/i18n/README.de.md +++ b/docs/i18n/README.de.md @@ -24,7 +24,7 @@ bevor sie zu Vorfällen werden. Keine Latenz. Läuft lokal.

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT mit [Commons Clause](https://commonsclause.com/) — kostenlos für den inte ## Mitwirken -Siehe [CONTRIBUTING.md](../../CONTRIBUTING.md). Neue Richtlinien, Grenzfälle und Übersetzungen sind herzlich willkommen. +Siehe [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Neue Richtlinien, Grenzfälle und Übersetzungen sind herzlich willkommen. > **Vor dem Start bauen.** Führen Sie zuerst `bun install && bun run build` aus. Dieses Repository führt > failproofais eigene Hooks auf sich selbst aus, und sie lösen den `failproofai`-Import gegen das > kompilierte `dist/`-Bundle auf — ohne einen Build erhalten Sie `Cannot find package 'failproofai'`- > Hook-Fehler. Nach Änderungen an `src/` neu bauen. Siehe -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index 1be89c3e0..64e5ed0e6 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -24,7 +24,7 @@ antes de que se conviertan en incidentes. Latencia cero. Se ejecuta localmente.

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT con [Commons Clause](https://commonsclause.com/) — libre para uso interno ## Contribuciones -Consulta [CONTRIBUTING.md](../../CONTRIBUTING.md). Se aceptan nuevas políticas, casos límite y traducciones. +Consulta [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Se aceptan nuevas políticas, casos límite y traducciones. > **Compila antes de comenzar.** Ejecuta primero `bun install && bun run build`. Este repositorio ejecuta > sus propios hooks de failproofai sobre sí mismo, y estos resuelven la importación de `failproofai` contra el > bundle compilado de `dist/` — sin una compilación previa obtendrás errores de hook `Cannot find package 'failproofai'`. > Vuelve a compilar tras modificar `src/`. Consulta -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index 878c37ece..8835c64aa 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -24,7 +24,7 @@ avant qu'ils ne deviennent des incidents. Zéro latence. Fonctionne en local.

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT avec [Commons Clause](https://commonsclause.com/) — gratuit pour un usage ## Contribuer -Consultez [CONTRIBUTING.md](../../CONTRIBUTING.md). Les nouvelles politiques, cas limites et traductions sont les bienvenus. +Consultez [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Les nouvelles politiques, cas limites et traductions sont les bienvenus. > **Compilez avant de commencer.** Exécutez `bun install && bun run build` en premier. Ce dépôt fait tourner > les propres hooks de failproofai sur lui-même, et ils résolvent l'import `failproofai` depuis le > bundle `dist/` compilé — sans compilation, vous obtiendrez des erreurs de hook `Cannot find package 'failproofai'`. > Recompilez après avoir modifié `src/`. Voir -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.he.md b/docs/i18n/README.he.md index 400d29af5..b8c16ef31 100644 --- a/docs/i18n/README.he.md +++ b/docs/i18n/README.he.md @@ -26,7 +26,7 @@

- Failproof AI in action + Failproof AI in action

--- @@ -213,13 +213,13 @@ MIT עם [Commons Clause](https://commonsclause.com/) — חינם לשימוש ## תרומה -ראה [CONTRIBUTING.md](../../CONTRIBUTING.md). מדיניויות חדשות, edge cases, ותרגומים כולם מוזמנים. +ראה [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). מדיניויות חדשות, edge cases, ותרגומים כולם מוזמנים. > **בנה לפני שתתחיל.** הרץ `bun install && bun run build` ראשון. repo זה מריץ > את hook ה-failproofai של עצמו, והם פותרים את יבוא ה-`failproofai` כנגד > ה-bundle `dist/` המהודר — ללא build אתה תפגע בשגיאות hook `Cannot find package 'failproofai'`. > בנה מחדש לאחר שינוי `src/`. ראה -> [בנה לפני שה-hook פיתוח in-repo יעבדו](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [בנה לפני שה-hook פיתוח in-repo יעבדו](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index 18a6931d3..be7d2dcd6 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -24,7 +24,7 @@ Claude Code और Codex में हुक करता है। लूप्

- Failproof AI in action + Failproof AI in action

--- @@ -211,10 +211,10 @@ MIT with [Commons Clause](https://commonsclause.com/) — आंतरिक औ ## योगदान -[CONTRIBUTING.md](../../CONTRIBUTING.md) देखें। नई नीतियाँ, सीमांत मामले और अनुवाद सभी स्वागत हैं। +[CONTRIBUTING.md](../../.github/CONTRIBUTING.md) देखें। नई नीतियाँ, सीमांत मामले और अनुवाद सभी स्वागत हैं। > **बिल्ड करने से पहले शुरुआत करें।** पहले `bun install && bun run build` चलाएँ। यह रिपो स्वयं पर failproofai के हुक चलाता है, और वे संकलित `dist/` बंडल के विरुद्ध `failproofai` आयात को हल करते हैं — बिल्ड के बिना आपको `Cannot find package 'failproofai'` हुक त्रुटियों मिलेंगी। `src/` को बदलने के बाद फिर से बिल्ड करें। देखें -> [इन-रिपो डेव हुक काम करने से पहले बिल्ड करें](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)। +> [इन-रिपो डेव हुक काम करने से पहले बिल्ड करें](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)। --- diff --git a/docs/i18n/README.it.md b/docs/i18n/README.it.md index 78add06bf..e21ef307e 100644 --- a/docs/i18n/README.it.md +++ b/docs/i18n/README.it.md @@ -24,7 +24,7 @@ prima che diventino incidenti. Zero latenza. Eseguito localmente.

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT con [Commons Clause](https://commonsclause.com/) — gratuito per uso intern ## Contribuire -Vedi [CONTRIBUTING.md](../../CONTRIBUTING.md). Nuove politiche, casi limite e traduzioni sono tutti benvenuti. +Vedi [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Nuove politiche, casi limite e traduzioni sono tutti benvenuti. > **Compila prima di iniziare.** Esegui `bun install && bun run build` prima. Questo repository esegue > i propri hook di failproofai su se stesso, e risolvono l'import `failproofai` contro il > bundle compilato `dist/` — senza una compilazione otterrai errori di hook `Cannot find package 'failproofai'`. > Ricompila dopo aver modificato `src/`. Vedi -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index b9391df8f..7f7fc28bf 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -24,7 +24,7 @@ Claude Code や Codex にフックし、ループ・危険な操作・シーク

- Failproof AI in action + Failproof AI in action

--- @@ -209,9 +209,9 @@ MIT に [Commons Clause](https://commonsclause.com/) を付加したライセン ## コントリビューション -[CONTRIBUTING.md](../../CONTRIBUTING.md) をご参照ください。新しいポリシー、エッジケースの対応、翻訳など、あらゆる貢献を歓迎します。 +[CONTRIBUTING.md](../../.github/CONTRIBUTING.md) をご参照ください。新しいポリシー、エッジケースの対応、翻訳など、あらゆる貢献を歓迎します。 -> **開始前にビルドしてください。** 最初に `bun install && bun run build` を実行してください。このリポジトリは failproofai 自身のフックを自身に適用しており、フックは `failproofai` のインポートをコンパイル済みの `dist/` バンドルに対して解決します。ビルドなしで実行すると `Cannot find package 'failproofai'` というフックエラーが発生します。`src/` を変更した後は再ビルドしてください。詳細は [リポジトリ内の開発フックを動作させるためのビルド手順](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work) をご参照ください。 +> **開始前にビルドしてください。** 最初に `bun install && bun run build` を実行してください。このリポジトリは failproofai 自身のフックを自身に適用しており、フックは `failproofai` のインポートをコンパイル済みの `dist/` バンドルに対して解決します。ビルドなしで実行すると `Cannot find package 'failproofai'` というフックエラーが発生します。`src/` を変更した後は再ビルドしてください。詳細は [リポジトリ内の開発フックを動作させるためのビルド手順](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work) をご参照ください。 --- diff --git a/docs/i18n/README.ko.md b/docs/i18n/README.ko.md index ba28a26e3..448778cce 100644 --- a/docs/i18n/README.ko.md +++ b/docs/i18n/README.ko.md @@ -24,7 +24,7 @@ Claude Code 및 Codex에 연결됩니다. 루프, 위험한 동작, 시크릿

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ customPolicies.add({ ## 기여 -[CONTRIBUTING.md](../../CONTRIBUTING.md)를 참조하세요. 새로운 정책, 엣지 케이스, 번역 모두 환영합니다. +[CONTRIBUTING.md](../../.github/CONTRIBUTING.md)를 참조하세요. 새로운 정책, 엣지 케이스, 번역 모두 환영합니다. > **시작 전에 빌드하세요.** 먼저 `bun install && bun run build`를 실행하세요. 이 저장소는 > failproofai 자체의 훅을 자신에게 적용하며, 컴파일된 `dist/` 번들에 대해 `failproofai` 임포트를 > 해석합니다 — 빌드 없이는 `Cannot find package 'failproofai'` 훅 오류가 발생합니다. > `src/`를 변경한 후에는 다시 빌드하세요. -> [저장소 내 개발 훅이 동작하기 위한 빌드 방법](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)을 참조하세요. +> [저장소 내 개발 훅이 동작하기 위한 빌드 방법](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)을 참조하세요. --- diff --git a/docs/i18n/README.pt-br.md b/docs/i18n/README.pt-br.md index 0776bde66..f06738b88 100644 --- a/docs/i18n/README.pt-br.md +++ b/docs/i18n/README.pt-br.md @@ -24,7 +24,7 @@ antes que se tornem incidentes. Latência zero. Executa localmente.

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT com [Commons Clause](https://commonsclause.com/) — gratuito para uso inter ## Contribuindo -Consulte [CONTRIBUTING.md](../../CONTRIBUTING.md). Novas políticas, casos extremos e traduções são bem-vindos. +Consulte [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Novas políticas, casos extremos e traduções são bem-vindos. > **Compile antes de começar.** Execute `bun install && bun run build` primeiro. Este repositório executa > os próprios hooks do failproofai sobre si mesmo, e eles resolvem o import `failproofai` a partir do > bundle compilado em `dist/` — sem uma compilação você terá erros de hook `Cannot find package 'failproofai'`. > Recompile após alterar `src/`. Veja -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index d75e501c0..4d67a6830 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -24,7 +24,7 @@

- Failproof AI в действии + Failproof AI в действии

--- @@ -211,10 +211,10 @@ MIT с [Commons Clause](https://commonsclause.com/) — бесплатно дл ## Участие в разработке -Смотрите [CONTRIBUTING.md](../../CONTRIBUTING.md). Новые политики, граничные случаи и переводы приветствуются. +Смотрите [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Новые политики, граничные случаи и переводы приветствуются. > **Постройте перед началом работы.** Сначала запустите `bun install && bun run build`. Этот репозиторий запускает собственные хуки failproofai на себе, и они разрешают импорт `failproofai` против скомпилированного бандла `dist/` — без сборки вы получите ошибки хуков `Cannot find package 'failproofai'`. Пересоберите после изменения `src/`. Смотрите -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.tr.md b/docs/i18n/README.tr.md index 4365c0b02..a0457078c 100644 --- a/docs/i18n/README.tr.md +++ b/docs/i18n/README.tr.md @@ -24,7 +24,7 @@ olay haline gelmeden yakalar. Sıfır gecikme. Yerel olarak çalışır.

- Failproof AI in action + Failproof AI in action

--- @@ -211,9 +211,9 @@ bir şey ters gittiğinde tahmin yapmak zorunda kalmazsınız. → [Kontrol pane ## Katkıda Bulunmak -[CONTRIBUTING.md](../../CONTRIBUTING.md) dosyasına bakın. Yeni politikalar, sınır durumları ve çeviriler hepsi memnuniyetle karşılanır. +[CONTRIBUTING.md](../../.github/CONTRIBUTING.md) dosyasına bakın. Yeni politikalar, sınır durumları ve çeviriler hepsi memnuniyetle karşılanır. -> **Başlamadan önce derleyin.** Önce `bun install && bun run build` komutunu çalıştırın. Bu depo, failproofai'nin kendi hook'larını kendisinde çalıştırır ve `failproofai` ithalatını derlenmiş `dist/` paketine göre çözerler — derleme yapılmadan `Cannot find package 'failproofai'` hook hataları alırsınız. `src/` değiştirdikten sonra yeniden derleyin. Bkz. [Hook'lar çalışmadan önce derleme](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> **Başlamadan önce derleyin.** Önce `bun install && bun run build` komutunu çalıştırın. Bu depo, failproofai'nin kendi hook'larını kendisinde çalıştırır ve `failproofai` ithalatını derlenmiş `dist/` paketine göre çözerler — derleme yapılmadan `Cannot find package 'failproofai'` hook hataları alırsınız. `src/` değiştirdikten sonra yeniden derleyin. Bkz. [Hook'lar çalışmadan önce derleme](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.vi.md b/docs/i18n/README.vi.md index b5c4b7864..895cd1886 100644 --- a/docs/i18n/README.vi.md +++ b/docs/i18n/README.vi.md @@ -24,7 +24,7 @@ trước khi chúng trở thành sự cố. Không độ trễ. Chạy cục b

- Failproof AI in action + Failproof AI in action

--- @@ -211,13 +211,13 @@ MIT với [Commons Clause](https://commonsclause.com/) — miễn phí để s ## Đóng góp -Xem [CONTRIBUTING.md](../../CONTRIBUTING.md). Chính sách mới, trường hợp biên và bản dịch đều được chào đón. +Xem [CONTRIBUTING.md](../../.github/CONTRIBUTING.md). Chính sách mới, trường hợp biên và bản dịch đều được chào đón. > **Xây dựng trước khi bắt đầu.** Chạy `bun install && bun run build` trước. Repo này chạy > các hook của failproofai trên chính nó, và chúng giải quyết nhập `failproofai` so với > bộ bundle `dist/` được biên dịch — mà không cần xây dựng bạn sẽ gặp `Cannot find package 'failproofai'` > lỗi hook. Xây dựng lại sau khi thay đổi `src/`. Xem -> [Build before the in-repo dev hooks will work](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). +> [Build before the in-repo dev hooks will work](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work). --- diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index e07fead3f..7d9ae53e0 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -23,7 +23,7 @@

- Failproof AI in action + Failproof AI in action

--- @@ -208,10 +208,10 @@ MIT 附加 [Commons Clause](https://commonsclause.com/)——可免费用于内 ## 贡献 -请参阅 [CONTRIBUTING.md](../../CONTRIBUTING.md)。欢迎贡献新策略、边界用例及翻译。 +请参阅 [CONTRIBUTING.md](../../.github/CONTRIBUTING.md)。欢迎贡献新策略、边界用例及翻译。 > **开始前请先构建项目。** 首先运行 `bun install && bun run build`。本仓库会将 failproofai 自身的 Hook 应用于自身,这些 Hook 会从已编译的 `dist/` 包中解析 `failproofai` 导入——若未构建,将触发 `Cannot find package 'failproofai'` Hook 错误。修改 `src/` 后请重新构建。详见 -> [构建后才能使用仓库内的开发 Hook](../../CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)。 +> [构建后才能使用仓库内的开发 Hook](../../.github/CONTRIBUTING.md#build-before-the-in-repo-dev-hooks-will-work)。 --- diff --git a/docs/it/testing.mdx b/docs/it/testing.mdx index f6c5d5890..00df3f8d6 100644 --- a/docs/it/testing.mdx +++ b/docs/it/testing.mdx @@ -258,4 +258,4 @@ Il pool `forks` è importante: i worker basati su thread condividono `globalThis L'esecuzione completa di CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) deve superare i controlli prima del merge. La suite E2E viene eseguita come job CI separato in parallelo. -Vedi [Contributing](../CONTRIBUTING.md) per la checklist completa prima del merge. \ No newline at end of file +Vedi [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) per la checklist completa prima del merge. \ No newline at end of file diff --git a/docs/ja/testing.mdx b/docs/ja/testing.mdx index c6c79bc33..0354334fd 100644 --- a/docs/ja/testing.mdx +++ b/docs/ja/testing.mdx @@ -257,4 +257,4 @@ E2Eテストは `vitest.config.e2e.mts` を使用し、以下の設定が含ま マージ前に、フルの CI 実行(`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`)がパスする必要があります。E2Eスイートは別の CI ジョブとして並行して実行されます。 -完全なマージ前チェックリストについては、[コントリビューティングガイド](../CONTRIBUTING.md)を参照してください。 \ No newline at end of file +完全なマージ前チェックリストについては、[コントリビューティングガイド](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md)を参照してください。 \ No newline at end of file diff --git a/docs/ko/testing.mdx b/docs/ko/testing.mdx index fad3a32bb..8fb953224 100644 --- a/docs/ko/testing.mdx +++ b/docs/ko/testing.mdx @@ -257,4 +257,4 @@ E2E 테스트는 `vitest.config.e2e.mts`를 사용하며 다음과 같이 구성 머지 전에 전체 CI 실행 (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`)이 통과되어야 합니다. E2E 스위트는 별도의 CI 작업으로 병렬 실행됩니다. -완전한 머지 전 체크리스트는 [Contributing](../CONTRIBUTING.md)을 참고하세요. \ No newline at end of file +완전한 머지 전 체크리스트는 [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md)을 참고하세요. \ No newline at end of file diff --git a/docs/pt-br/testing.mdx b/docs/pt-br/testing.mdx index d48f29ac5..5f061693e 100644 --- a/docs/pt-br/testing.mdx +++ b/docs/pt-br/testing.mdx @@ -257,4 +257,4 @@ O pool `forks` é importante: workers baseados em threads compartilham `globalTh A execução completa de CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) deve passar antes de qualquer merge. O conjunto de testes E2E é executado como um job de CI separado, em paralelo. -Consulte [Contributing](../CONTRIBUTING.md) para o checklist completo de pré-merge. \ No newline at end of file +Consulte [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) para o checklist completo de pré-merge. \ No newline at end of file diff --git a/docs/ru/testing.mdx b/docs/ru/testing.mdx index cf9b7d543..bc676c872 100644 --- a/docs/ru/testing.mdx +++ b/docs/ru/testing.mdx @@ -257,4 +257,4 @@ E2E-тесты используют `vitest.config.e2e.mts` с: Полный запуск CI (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) должен пройти перед слиянием. E2E-набор запускается как отдельное задание CI параллельно. -Смотрите [Участие в разработке](../CONTRIBUTING.md) для полного контрольного списка перед слиянием. \ No newline at end of file +Смотрите [Участие в разработке](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) для полного контрольного списка перед слиянием. \ No newline at end of file diff --git a/docs/testing.mdx b/docs/testing.mdx index 0d0fb095e..c18b98359 100644 --- a/docs/testing.mdx +++ b/docs/testing.mdx @@ -257,4 +257,4 @@ The `forks` pool is important: thread-based workers share `globalThis`, which ca The full CI run (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) is required to pass before merging. The E2E suite runs as a separate CI job in parallel. -See [Contributing](../CONTRIBUTING.md) for the complete pre-merge checklist. +See [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) for the complete pre-merge checklist. diff --git a/docs/tr/testing.mdx b/docs/tr/testing.mdx index 84743c38e..28fd758fe 100644 --- a/docs/tr/testing.mdx +++ b/docs/tr/testing.mdx @@ -258,4 +258,4 @@ E2E testleri `vitest.config.e2e.mts` ile kullanır: Tam CI çalıştırması (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) birleştirmeden önce geçmesi gerekir. E2E paketi paralel olarak ayrı bir CI işi olarak çalışır. -Tam birleştirme öncesi kontrol listesi için [Katkı](../CONTRIBUTING.md)'ya bakın. \ No newline at end of file +Tam birleştirme öncesi kontrol listesi için [Katkı](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md)'ya bakın. \ No newline at end of file diff --git a/docs/vi/testing.mdx b/docs/vi/testing.mdx index d417c1041..52919a8c0 100644 --- a/docs/vi/testing.mdx +++ b/docs/vi/testing.mdx @@ -258,4 +258,4 @@ Pool `forks` rất quan trọng: các worker dựa trên luồng chia sẻ `glob Chạy CI đầy đủ (`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`) là bắt buộc phải vượt qua trước khi hợp nhất. Bộ kiểm thử E2E chạy như một công việc CI riêng biệt song song. -Xem [Contributing](../CONTRIBUTING.md) để biết danh sách kiểm tra đầy đủ trước khi hợp nhất. \ No newline at end of file +Xem [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md) để biết danh sách kiểm tra đầy đủ trước khi hợp nhất. \ No newline at end of file diff --git a/docs/zh/testing.mdx b/docs/zh/testing.mdx index f257b035c..98d50a661 100644 --- a/docs/zh/testing.mdx +++ b/docs/zh/testing.mdx @@ -257,4 +257,4 @@ E2E 测试使用 `vitest.config.e2e.mts`,配置如下: 合并前必须通过完整的 CI 流程(`bun run lint && bunx tsc --noEmit && bun run test:run && bun run build`)。E2E 测试套件作为独立的 CI 任务并行运行。 -完整的合并前检查清单请参阅 [Contributing](../CONTRIBUTING.md)。 \ No newline at end of file +完整的合并前检查清单请参阅 [Contributing](https://github.com/FailproofAI/failproofai/blob/main/.github/CONTRIBUTING.md)。 \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..89542ce4f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,39 @@ +# examples/ + +## What this is + +Sample custom policy files for **the CLI** — the enforcement half (`src/hooks/`). Each file +imports `customPolicies`, `allow`, `deny`, `instruct` from `failproofai` and registers policies +that the hook handler evaluates when an agent CLI fires an event. They are copy-paste starting +points for users, not code the product runs on its own. + +| File | Demonstrates | +|------|--------------| +| `policies-basic.js` | Four starter policies: production-path writes, `git push --force`, `curl \| bash`, bare `npm install` | +| `policies-advanced/index.js` + `utils.js` | Transitive local import (the loader rewrites `./utils.js`), async `fn`, `ctx.session`, PostToolUse, Stop | +| `policies-stop.js` | A Stop gate that blocks finishing with uncommitted git changes | +| `policies-notification.js` | Notification + SessionEnd forwarded to a Slack webhook (`SLACK_WEBHOOK_URL`) | +| `convention-policies/*.mjs` | Auto-loaded form — copied into `.failproofai/policies/`, no flag needed | + +## Who consumes it + +End users, by path: `failproofai policies --install --custom ./examples/policies-basic.js`, or +`cp examples/convention-policies/*.mjs .failproofai/policies/`. `docs/custom-policies.mdx` names +all four of those paths in a table, `docs/examples.mdx` links the directory, and both have 14 +machine translations that repeat the paths — so renaming a file here breaks 30+ docs pages. +`__tests__/e2e/hooks/custom-hooks.e2e.test.ts` also loads `policies-basic.js` and +`policies-advanced/index.js` as real fixtures via `customPoliciesPath`. + +Gotcha: the header comment in every top-level file still says +`failproofai --install-hooks custom `. That flag no longer exists anywhere in `src/`; the +current command is `failproofai policies --install --custom `. + +## Does it ship + +No. `examples/` is not in package.json's `files` array, so it never reaches an installed user — +they get these files from GitHub or from the docs site. Nothing installed breaks if the directory +moves; the docs and the e2e fixtures break instead. + +## Where its tests live + +`__tests__/e2e/hooks/custom-hooks.e2e.test.ts` — run with `bun run test:e2e`. diff --git a/instrumentation.ts b/instrumentation.ts index 301b334ee..9bdcc5dcc 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,11 +1,14 @@ /** * Next.js instrumentation hook — runs once on server startup. - * Delegates to instrumentation.node.ts which is dynamically imported only + * Delegates to lib/instrumentation-node.ts which is dynamically imported only * when running in the Node.js runtime, so Edge compilation never sees Node.js APIs. + * + * Only THIS file has to sit at the repo root — Next's detection matches the exact + * basename `instrumentation`, so the Node half is an ordinary module and lives in lib/. */ export async function register() { if (process.env.NEXT_RUNTIME !== 'nodejs') return; - const { registerNode } = await import("./instrumentation.node"); + const { registerNode } = await import("./lib/instrumentation-node"); await registerNode(); } diff --git a/internals/docs-site.md b/internals/docs-site.md new file mode 100644 index 000000000..d70be64ae --- /dev/null +++ b/internals/docs-site.md @@ -0,0 +1,41 @@ +# docs/ — the Mintlify site + +## What this is + +The documentation site — a [Mintlify](https://mintlify.com) project whose config is `docs/docs.json`. +It holds 692 `.mdx` pages, but only **48 are hand-written English**: the root pages +(`introduction.mdx`, `getting-started.mdx`, `architecture.mdx`, `configuration.mdx`, +`built-in-policies.mdx`, `custom-policies.mdx`, `dashboard.mdx`, `examples.mdx`, `for-agents.mdx`, +`package-aliases.mdx`, `testing.mdx`), the 10 pages under `cli/`, and the 27 under `agenteye/`. +The remaining 644 live in the 14 language directories (`ar de es fr he hi it ja ko pt-br ru tr vi zh`) +and are machine translations. `docs/i18n/` holds translated copies of the root `README.md`. + +**`docs/agenteye/` documents a different product** (AgentEye, the telemetry/observability platform); +it shares this site but not this codebase. + +**Edit only the English source. Never edit a file under a language directory** — the next +translation run overwrites it. Those paths are marked `linguist-generated=true` in `.gitattributes` +so they collapse in pull-request diffs; that marking is git metadata only and does not stop an edit. + +## Who consumes it + +Mintlify's hosted build renders this directory; nothing in the CLI, the Next dashboard, or the Rust +daemon reads it. Translations are produced by `.github/workflows/translate-docs.yml` (daily cron, +14-language matrix, `workflow_dispatch` with a `force` input) running `scripts/translate-docs/cli.ts` +— `bun run translate`, `translate:docs`, `translate:readme`, `translate:dry-run`, `translate:validate`. +`scripts/translate-docs/mintlify-nav.ts` mirrors the English navigation in `docs.json` into each +locale, so a new English page must be added to `docs.json` navigation or it will not appear anywhere. +`docs/Dockerfile.dev` runs `mintlify dev` locally. + +## Does it ship + +No. `docs/` is not in package.json's `files` array, so nothing here reaches an npm install. It is +published only to the docs site. + +## Where its tests live + +There are no tests over the `.mdx` content itself; the translator that writes it is covered by +`__tests__/scripts/translate-docs/` (`config.test.ts` asserts the `LANGUAGES` list matches +`.gitattributes`), run with `bun run test:run`. CI's `docs` job additionally runs +`mintlify validate` in this directory and `bun run validate:mdx`, which parses every page and +checks that image references resolve on disk. diff --git a/internals/dogfood.md b/internals/dogfood.md new file mode 100644 index 000000000..a0b1dcffc --- /dev/null +++ b/internals/dogfood.md @@ -0,0 +1,91 @@ +# Dogfood hook configs (`.claude/`, `.codex/`, `.cursor/`, `.devin/`, `.factory/`, `.agents/`, `.opencode/`, `.pi/`, `.failproofai/`, `.github/hooks/`) + +Ten dot-directories at the repo root, 15 tracked files between them. They are failproofai +enforcing its own policies on itself: whichever agent CLI you drive this repo with, its hooks +fire and the same builtins users get (`block-sudo`, `block-force-push`, `block-read-outside-cwd`, +…) apply to your session. `.failproofai/` is the odd one out — it holds *what* is enforced +(`policies-config.json`'s enabled list, plus two repo-specific policy files, +`policies/workflow-policies.mjs` and `policies/review-policies.mjs`). The other nine tell a +specific vendor's binary *where to find* failproofai. + +| Directory | CLI | File | +|---|---|---| +| `.claude/` | Claude Code | `settings.json` (25 commands) | +| `.codex/` | Codex | `hooks.json` (6) | +| `.github/hooks/` | Copilot CLI | `failproofai.json` (12 = 6 events × `bash` + `powershell`) | +| `.cursor/` | Cursor Agent | `hooks.json` (6) | +| `.factory/` | Factory `droid` | `hooks.json` (9) | +| `.devin/` | Devin | `config.json` (7) | +| `.agents/` | Antigravity `agy` | `hooks.json` (4, under a named-hook key) | +| `.agents/plugins/failproofai/` | Goose | `hooks/hooks.json` (5, Open Plugins layout) | +| `.opencode/` | OpenCode | `opencode.json` + `plugins/failproofai.mjs` | +| `.pi/` | Pi | `settings.json` → `../pi-extension` | +| `.failproofai/` | — | `policies-config.json`, `policies/*.mjs` | + +(`.agents/` also carries an unrelated `skills/mintlify/SKILL.md`. OpenCode and Pi have no +shell-hook system at all, so those two register an in-process plugin instead of commands.) + +## Why ten directories and not one + +Because each vendor hardcodes where it looks, and none of them can be pointed elsewhere by +anything committable. Claude Code's bundle contains the literal `xD.join(".claude", +"settings.json")`. Pi builds `.pi` from a constant in Pi's own `package.json`. Codex has no +project-dir variable — `CODEX_HOME` moves the *user* config only. Three of the twelve can be +redirected: `droid` via `FACTORY_RUNTIME_SETTINGS_PATH`, `devin --config`, `claude --settings`. +All three are per-invocation flags or exported environment variables. A contributor who forgot +one would run with zero enforcement and no warning at all — silence is the failure mode for +every hook system here, which is why we take the ten directories instead. + +Symlinking them into a single `dogfood/` folder is worse, not better: the root would then show +both `.claude` *and* `dogfood/`, so you would have ten symlinks plus a directory to explain, +rather than ten directories. + +## Why every config runs `scripts/dev-hook.mjs` + +Users install `npx -y failproofai --hook --cli `. Run that form *inside* this +repo and it is a self-reference conflict: npx fetches and runs the published package while you +are developing the package. So every dogfood command instead runs the dev launcher: + +``` +command -v node >/dev/null 2>&1 || { echo '[failproofai] node not found; install Node >=20.9' >&2; exit 2; }; node "$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs" --hook SessionStart +``` + +**Node fronts a bun-only binary.** `bin/failproofai.mjs` cannot run under node — it does a bare +`import { version } from "../package.json"` and imports an extensionless TypeScript specifier. +But naming `bun` in the config makes every hook exit 127 the moment bun is off the *hook's* +PATH, which happens routinely: `npm i -g bun` lands in one nvm version's bin dir, and a macOS +GUI launch gets a launchd PATH built without your shell rc. The session then runs unenforced, +silently. `dev-hook.mjs` runs under node, finds bun across PATH, `$BUN_INSTALL/bin`, +`~/.bun/bin`, Homebrew and every `~/.nvm/versions/node/*/bin`, installs it if genuinely absent, +ensures `dist/index.js` exists so `.failproofai/policies/*.mjs` can resolve +`import … from 'failproofai'`, then delegates and propagates the child's exit code verbatim. + +**The `command -v node` prefix is a pre-check, never a fallback.** Exit 2 is the deny signal, so +a reactive `launcher || fallback` chain would re-fire on every legitimate denial. + +**Exit-code split.** Stop-class events (`Stop`, `StopFailure`, `SubagentStop`, and the camelCase +Cursor spellings) use `exit 1`; every other event uses `exit 2`. On a Stop event exit 2 means +"don't finish, retry" — and a shell one-liner cannot read `stop_hook_active` off stdin to know +it is already retrying, so it would loop forever. + +**Paths.** `.claude/settings.json` must use `$CLAUDE_PROJECT_DIR`: Claude Code spawns hooks with +the live session cwd, which drifts as the agent `cd`s around. Every other CLI spawns hooks from +the project root, so those configs use a relative `node scripts/dev-hook.mjs`. + +## The tripwire + +These files are hand-maintained and generated by nothing — no code writes them, and no other +test reads them. `__tests__/hooks/dogfood-configs.test.ts` is what keeps them honest: it walks +each config, asserts the launcher form, the node guard, the exit-code split, the exact command +count per file, that `.claude` registers only events in `CLAUDE_INSTALL_EVENT_TYPES` (and never +`WorktreeCreate`), and that the OpenCode shim resolves bun through the shared resolver rather +than a bare `spawnSync("bun")`. It also pins one known gap deliberately: `pi-extension/index.ts` +keeps a bare `cmd: "bun"` because that file ships to users. Drift here is invisible — when #337 +landed, the OpenCode shim drifted and `block-read-outside-cwd` no-op'd on every read in this +repo, unnoticed. + +Do **not** run `failproofai policies --install --cli ` from inside this repo. It rewrites +these files back to the production `npx -y failproofai` form and reintroduces the +self-reference. Also note the standing decision that dogfood stays on the in-process evaluator +and is never daemon-configured: a flaky dev daemon fails closed, and would block the tool calls +of the contributor developing it. diff --git a/internals/repo-map.md b/internals/repo-map.md new file mode 100644 index 000000000..91e36b20d --- /dev/null +++ b/internals/repo-map.md @@ -0,0 +1,105 @@ +# internals/repo-map.md + +One `package.json` at the root ships five products. **The CLI** is `src/` (`src/hooks/` for +enforcement and install, `src/audit/` for the audit product), `bin/`, and the audit half of +`lib/`. **The dashboard** is a Next.js 16 App Router app: `app/`, `public/`, `proxy.ts`, +`instrumentation.ts`, `next.config.ts`, and most of `lib/`. **The daemon** is `crates/` — +`failproofaid` (socket server, service lifecycle, warm-worker supervision), `fpai-collect` +(session collectors, one module per agent CLI), `fpai-ipc` (wire protocol); ~41k lines of Rust +holding zero policy logic. **The docs site** is `docs/`, a Mintlify site that also hosts +`docs/agenteye/` — a different product's documentation sharing the same site. **The shipped +plugin packages** are `pi-extension/` and `openclaw-plugin/`, static packages published inside +the npm tarball because Pi and OpenClaw load in-process plugins instead of running shell hooks. + +The 12 supported agent CLIs are claude, codex, copilot, cursor, opencode, pi, hermes, openclaw, +factory, devin, antigravity, goose. + +## Top-level directories + +Ships-to-npm is decided by `package.json`'s `files` array: `bin/ src/ scripts/ lib/ +pi-extension/ openclaw-plugin/ .next/standalone/ dist/ README.md`. Nothing else is published. + +| Path | Product | Ships to npm | Purpose | +|---|---|---|---| +| `src/` | CLI | yes | `hooks/` (53 flat files: install, handler, policy evaluator, daemon client) + `audit/` (12 transcript adapters, detectors, scoring) + `index.ts`, the public policy API | +| `bin/` | CLI | yes | `failproofai.mjs` entry, `failproofai-worker.mjs` (warm worker the daemon spawns), `failproofaid-shim.mjs` | +| `lib/` | CLI + dashboard | yes | Shared `-sessions.ts` / `-projects.ts` parsers plus `paths.ts`, `sqlite-reader.ts`, telemetry, dashboard-only React hooks | +| `app/` | dashboard | yes, inside the bundle | Next 16 routes, `"use server"` actions, components, contexts. Next's tracer copies 82 raw `.ts`/`.tsx` files into `.next/standalone/app/`, so they ship verbatim | +| `public/` | dashboard | no (built output ships) | `logo.svg`, `icon.svg`, the audit share-card font | +| `crates/` | daemon | no (see below) | Cargo workspace: `failproofaid`, `fpai-collect`, `fpai-ipc`; read `PROTOCOL.md` before touching the wire | +| `docs/` | docs site | no | Mintlify site: 48 hand-written English `.mdx` (21 failproofai + 27 under `docs/agenteye/`) + 14 translated language trees | +| `scripts/` | all | yes | Five unrelated subsystems: dashboard launch, release tooling, docs translation, `dev-hook.mjs` (dogfood), `validate-mdx.ts` | +| `pi-extension/` | plugin pkg | yes | Pi in-process extension; **directory name is frozen** — installed users' `.pi/settings.json` points at this path | +| `openclaw-plugin/` | plugin pkg | yes | OpenClaw plugin shim; same frozen-name constraint via `~/.openclaw/openclaw.json` | +| `internals/` | all | no | This directory. Engineering docs for contributors — deliberately not under `docs/`, because Mintlify serves unlisted `.md` files and these would become public product pages | +| `__tests__/` | all | no | 223 files, vitest; `vitest.config.mts` (unit) and `vitest.config.e2e.mts` (e2e) | +| `examples/` | CLI | no | Sample custom policy files used by the docs and by manual smoke tests | +| `assets/` | none | no | Brand material — logos, `font-kit/`, `readme-arch-hq.gif`, an `audit/` design lab. Imported by nothing | +| `integration-suite/` | CLI | no | The daily canary: a Docker image that installs real agent CLIs and probes whether denies still land | +| `docker-hook-sync/` | CLI | no | Single-shot container (k8s CronJob) that runs Claude Code to detect drift between upstream CLI harnesses and this repo, then opens one PR | +| `skills/` | none | no | Git submodule (`github.com/failproofai/skills`), not source in this repo | +| `.github/` | all | no | CI (`ci.yml`), `build-daemon.yml`, `publish.yml`, `translate-docs.yml` — and `.github/hooks/failproofai.json`, which is a dogfood config, not CI | +| `.claude/ .codex/ .cursor/ .devin/ .factory/ .opencode/ .pi/ .agents/ .failproofai/` | all | no | Dogfood hook configs — this repo enforcing failproofai on itself. See below | +| `dist/ .next/ target/ node_modules/` | build output | `dist/` + `.next/standalone/` | Gitignored; `dist/cli.mjs`, `dist/index.js`, `dist/worker.mjs` are what users actually run | + +The daemon is the exception to the ships-to-npm column: the compiled binaries publish as four +separate `@failproofai/failproofaid--` optional dependencies **and** as GitHub Release +assets. `crates/` source itself is never published. + +## Where the numbers come from + +- **~1,428 git-tracked files**, of which **658 are generated translations** — 644 under + `docs//` and 14 `docs/i18n/README..md`. All regenerated by + `.github/workflows/translate-docs.yml`; never hand-edit them. That leaves ~770 real files. +- **~156k lines** of `.ts/.tsx/.rs/.mjs`, of which **55.7k is `__tests__/`** and **40.9k is + Rust**. Application TypeScript across `src/ lib/ app/` is 52.4k — about a third of the tree. +- **50 root entries** (55 on disk minus `.git`, `node_modules`, `.next`, `target`, `dist`). + +Counted with `git ls-files`, so build output and `node_modules` are excluded throughout. + +## Things that look wrong and are not + +**`lib/` is imported by two products.** The dashboard reaches it as `@/lib/…` (42 files under +`app/`) and the CLI reaches the same files by relative path from `src/audit/cli-adapters/*.ts`. +This is deliberate: session parsing has exactly one implementation and both the web UI and +`failproofai audit` need it. The cost is real — classify anything you add. The three files with +`"use client"` (`use-url-params.ts`, `use-filter-state.ts`, `log-format.ts`) must stay unreachable +from the CLI, and `projects.ts` imports the per-CLI providers lazily so Turbopack does not drag +`node:fs` into the browser bundle. + +**Ten dogfood dot-directories cannot be consolidated.** Each agent CLI hardcodes where its config +lives; there is no path we get to choose. Claude Code looks for `.claude/settings.json` as a +literal string in its own binary, and failproofai mirrors that literal at +`src/hooks/integrations.ts:186`. Codex wants `.codex/hooks.json`, Copilot wants +`.github/hooks/*.json` (project scope — `.copilot/` is user scope), Antigravity wants +`.agents/hooks.json` while Goose auto-discovers `.agents/plugins//hooks/hooks.json`. They +also carry ten different schemas, verified live per CLI in `CLAUDE.md`. +`__tests__/hooks/dogfood-configs.test.ts` is the tripwire — these files are hand-maintained and +generated by nothing, so without it they drift silently. + +**The same 12 CLIs' session formats are parsed twice, in TypeScript and again in Rust.** Not +duplication awaiting cleanup: the two parsers have different outputs and different lifetimes. +`crates/fpai-collect/src/sources//` is a streaming collector — it tails or polls files while +a session is live and emits AgentEye telemetry events. `lib/-sessions.ts` reads a finished +transcript on demand and produces dashboard log entries and audit findings. Merging them would +mean putting a live event pipeline behind a synchronous read, or shipping the dashboard's parser +into a daemon that must never block. + +**The daemon is fail-closed, so "the daemon is down" reads as "everything is denied."** On a +machine where `failproofai config` completed, failproofaid is the only evaluator: an unreachable +socket or a `PROTOCOL_VERSION` mismatch denies. In-process evaluation still exists but is +reachable only when `daemonConfigured` is false — which is this repo's own dogfood configs and a +machine that never finished setup. That is why `scripts/dev-hook.mjs` exists and why the dogfood +configs deliberately stay off the daemon path. + +## Start here + +1. `CLAUDE.md` — the de-facto architecture manual (1,248 lines). Skim the per-CLI sections; read + "Enforcement routes through the daemon" and "How the daemon binary reaches users" properly. +2. `src/hooks/handler.ts` — `canonicalizeEventType()` and `evaluateHookEvent()`: the whole hot + path from an agent's stdin payload to a verdict. +3. `src/hooks/integrations.ts` — one `Integration` object per CLI; the 12 config schemas in one + file, each with its live-verified contract in comments. +4. `crates/PROTOCOL.md`, then `crates/failproofaid/src/server.rs` — the socket contract and why a + version mismatch is a deny. +5. `package.json` — the `files` array and the `build` script tell you what a user actually gets. diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 000000000..d86794d1b --- /dev/null +++ b/lib/README.md @@ -0,0 +1,38 @@ +# lib/ + +This directory belongs to no single product — it is shared code for **two** of them: the +Next.js dashboard and the published CLI's audit pillar. About 61% of it (6,423 of 10,582 +lines) is one repeated block: `lib/-sessions.ts` (transcript discovery + parsing) and +`lib/-projects.ts` (grouping sessions by project cwd), cloned per agent CLI. The rest +is genuinely shared plumbing — `paths.ts`, `sqlite-reader.ts`, `log-entries.ts`, +`atomic-write.ts`, `telemetry-*.ts`, `dashboard-host.ts`, `install-check.ts`. + +## What this is + +Serving two masters is the real cost here. **Classify new code before adding it**: dashboard-only +React/UI helpers (`use-url-params.ts`, `use-filter-state.ts`, `log-format.ts` — the three files +carrying `"use client"`) must never be reachable from the CLI, and Node-only session readers must +never reach a client bundle. `cli-registry.ts` is deliberately client-safe (plain strings only) +and `projects.ts` imports the per-CLI providers lazily precisely so Turbopack does not drag +`node:fs`/`node:os` into the browser. + +## Who consumes it + +The Next server (42 files under `app/` import `@/lib/…`), plus `proxy.ts` and `scripts/launch.ts` +via `dashboard-host.ts`. The CLI reads it by relative path: `src/audit/cli-adapters/*.ts` import +the matching `lib/-sessions.ts` / `-projects.ts`, and `src/hooks/` imports `telemetry-id.ts`, +`atomic-write.ts`, and `paths.ts`. Note claude is the one CLI with no `claude-projects.ts` — +`projects.ts` reads its layout directly. + +## Does it ship + +Yes — `lib/` is in package.json's `files` array and ships raw as TypeScript. `dist/cli.mjs` +inlines what it needs at build time, but the shipped `src/` tree references these files by +relative specifier (`../../lib/paths`) and the standalone dashboard traces them, so renaming or +moving this directory breaks an installed user's audit and dashboard even though the bundled CLI +entrypoint keeps working. + +## Where its tests live + +`__tests__/lib/` (49 files, roughly one per module, including `__tests__/lib/utils/`). Run with +`bun run test:run`. diff --git a/lib/claude-config.ts b/lib/claude-config.ts deleted file mode 100644 index 920a7222f..000000000 --- a/lib/claude-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Thin wrapper around `lib/paths.ts` that re-exports the projects path - * getter under application-specific names. Keeps the rest of the app - * decoupled from the low-level path module. - */ -import { getClaudeProjectsPath as getPath } from "./paths"; - -/** - * Gets the configured .claude projects path - * This can be used throughout the application to access the path - * - * @returns The path to the .claude/projects directory - * - * @example - * ```ts - * import { getClaudeProjectsPath } from '@/lib/claude-config'; - * - * const projectsPath = getClaudeProjectsPath(); - * // Use the path to read project files - * ``` - */ -export function getClaudeProjectsPath(): string { - return getPath(); -} - -/** - * Gets the configured .claude projects path (server-side only) - * Use this in API routes, server components, or server actions - * - * @returns The path to the .claude/projects directory - */ -export function getClaudeProjectsPathServer(): string { - // In server-side code, we can access process.env directly - return process.env.CLAUDE_PROJECTS_PATH || getPath(); -} - diff --git a/lib/extract-subagent-ids.ts b/lib/extract-subagent-ids.ts deleted file mode 100644 index cc2d02e0d..000000000 --- a/lib/extract-subagent-ids.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Extract all unique subagent IDs from raw JSONL content by scanning - * for `toolUseResult.agentId` on user-type entries. - */ -export function extractSubagentIds(fileContent: string): string[] { - const ids = new Set(); - for (const line of fileContent.split("\n")) { - if (!line.trim()) continue; - try { - const raw = JSON.parse(line); - if (raw.type !== "user") continue; - const agentId = raw.toolUseResult?.agentId; - if (typeof agentId === "string" && /^[a-f0-9]+$/.test(agentId)) { - ids.add(agentId); - } - } catch { - // skip malformed lines - } - } - return Array.from(ids); -} diff --git a/instrumentation.node.ts b/lib/instrumentation-node.ts similarity index 80% rename from instrumentation.node.ts rename to lib/instrumentation-node.ts index a60420d4a..7502293d2 100644 --- a/instrumentation.node.ts +++ b/lib/instrumentation-node.ts @@ -1,19 +1,19 @@ /** * Node.js-only instrumentation logic. - * Dynamically imported from instrumentation.ts only when NEXT_RUNTIME === 'nodejs', + * Dynamically imported from the root instrumentation.ts only when NEXT_RUNTIME === 'nodejs', * so that Turbopack/Edge compilation never sees Node.js APIs like process.arch or node:os. */ export async function registerNode() { const os = await import("node:os"); - const { initLogger } = await import("./lib/logger"); + const { initLogger } = await import("./logger"); initLogger(); - const { initTelemetry, trackEvent, flushTelemetry } = await import("./lib/telemetry"); + const { initTelemetry, trackEvent, flushTelemetry } = await import("./telemetry"); await initTelemetry(); - const { hashToId } = await import("./lib/telemetry-id"); - const { version } = await import("./package.json"); + const { hashToId } = await import("./telemetry-id"); + const { version } = await import("../package.json"); trackEvent("app_started", { version, diff --git a/lib/log-entries.ts b/lib/log-entries.ts index 8b325fb0d..c7ea01d3b 100644 --- a/lib/log-entries.ts +++ b/lib/log-entries.ts @@ -242,7 +242,8 @@ async function parseFileContent(fileContent: string, source: LogSource): Promise const toolUseResult = raw.toolUseResult as Record | undefined; const agentId = (typeof toolUseResult?.agentId === "string") ? toolUseResult.agentId : undefined; - // Detect subagent IDs (mirrors extractSubagentIds) + // Detect subagent IDs — hex-only, matching the id format Claude Code + // writes into toolUseResult.agentId. if (agentId && /^[a-f0-9]+$/.test(agentId)) { subagentIdSet.add(agentId); } diff --git a/openclaw-plugin/README.md b/openclaw-plugin/README.md new file mode 100644 index 000000000..8f97f29a8 --- /dev/null +++ b/openclaw-plugin/README.md @@ -0,0 +1,39 @@ +# openclaw-plugin/ + +## What this is + +A shipped plugin package — one of the five products in this repo — that bridges the OpenClaw +gateway to failproofai enforcement. OpenClaw's file-based "internal hooks" are observation-only, +so blocking has to happen through its in-process typed plugin hooks: `index.js` calls +`definePluginEntry` and registers `before_tool_call`, `before_agent_run`, +`before_agent_finalize`, and five observation hooks. Each handler async-spawns +`failproofai --hook --cli openclaw`, writes a Claude-shaped JSON payload to stdin, and +translates the flat `{permission, reason}` verdict into that hook's native return shape +(`{block:true, blockReason}`, `{outcome:"block", reason}`, `{action:"revise", reason}`). + +It **fails open** on every spawn error, parse error, empty stdout, or the 30s guard timeout — the +handler resolves `{permission:"allow"}`. That is deliberate: OpenClaw is a long-running +multi-channel gateway, and a failproofai fault must not wedge every channel. For the same reason +the spawn is async, never `spawnSync`. + +## Who consumes it + +The OpenClaw gateway loads `index.js` in-process at startup. `src/hooks/integrations.ts` +(`getOpenClawPluginPath`, the `openclaw` Integration) registers this directory's absolute path +into `~/.openclaw/openclaw.json` under `plugins.load.paths[]` plus +`plugins.entries.failproofai`. `openclaw.plugin.json` carries the plugin id/name manifest. The +shim ships **no** tool maps — the binary canonicalizes via the `OPENCLAW_*` maps in +`src/hooks/types.ts`, the single source of truth. + +## Does it ship + +Yes — `openclaw-plugin/` is in package.json's `files` array. The directory name is frozen: +already-installed users have the literal path baked into their `openclaw.json`, and +`isFailproofaiOpenClawPath` matches on the `openclaw-plugin` segment. Renaming or moving it +silently unhooks every installed gateway. + +## Where its tests live + +`__tests__/hooks/integrations.test.ts` (install/uninstall of the plugin registration), +`__tests__/hooks/openclaw-canonicalize.test.ts`, and +`__tests__/hooks/enforcement-capability.test.ts`. Run with `bun run test:run`. diff --git a/pi-extension/README.md b/pi-extension/README.md new file mode 100644 index 000000000..6977712e4 --- /dev/null +++ b/pi-extension/README.md @@ -0,0 +1,35 @@ +# pi-extension/ + +## What this is + +A shipped plugin package — one of the five products in this repo — that bridges the CLI's +policy engine into Pi (`@mariozechner/pi-coding-agent`). Pi has no external shell-hook +system: it loads `index.ts` in-process at startup, and the default export subscribes to +eight Pi events (`tool_call`, `user_bash`, `input`, `session_start`, `tool_result`, +`agent_end`, `before_agent_start`, `session_shutdown`). Each handler `spawnSync`s +`failproofai --hook --cli pi`, parses the flat `{permission, reason}` JSON on +stdout, and returns Pi's `{block, reason}` shape. Any spawn or parse error fails open. + +## Who consumes it + +Pi itself, at startup, via a path entry in `.pi/settings.json` (project) or +`~/.pi/agent/settings.json` (user). The CLI writes that entry: `src/hooks/integrations.ts` +resolves this directory with `getPiExtensionPath()` and writes a relative `../pi-extension` +for project scope, an absolute path for user scope. The shim then calls back into the +package — `dist/cli.mjs` under node when it exists, else `bun bin/failproofai.mjs`. + +## Does it ship + +Yes — `pi-extension/` is in package.json's `files` array, so the whole directory lands in +the npm tarball. **The directory name is frozen.** Installed users have this absolute path +written into their Pi settings file; renaming or moving it breaks their live enforcement +and orphans `failproofai policies --uninstall --cli pi`, which matches entries on the +literal `pi-extension` path segment (`isFailproofaiPiEntry`). The shim is also self-contained +by rule: it duplicates `PI_TOOL_MAP` from `src/hooks/types.ts` rather than importing it, +because Pi loads it in-process. Change one copy, change both. + +## Where its tests live + +`__tests__/hooks/pi-extension-shim.test.ts` and `__tests__/hooks/pi-shim-shapes.test.ts` +(`bun run test:run`); live `pi list` roundtrips in +`__tests__/e2e/hooks/pi-integration.e2e.test.ts` (`bun run test:e2e`). diff --git a/public/README.md b/public/README.md new file mode 100644 index 000000000..1e46e45b1 --- /dev/null +++ b/public/README.md @@ -0,0 +1,38 @@ +# public/ + +Part of **the dashboard** (the Next.js 16 app). Everything here is served verbatim at the site +root by Next's static handler — `public/logo.svg` is reachable as `/logo.svg`. It holds three served +files: the brand mark, the favicon, and the one self-hosted webfont the audit pages need. + +Do not confuse it with `assets/`, which is brand/reference material (the audit design lab, the +per-CLI logo SVGs, `readme-arch-hq.gif`) that is **not** served and is explicitly deleted from the +build output by `scripts/prune-standalone.mjs`. + +## What this is + +| File | Served as | Referenced by | +|------|-----------|---------------| +| `logo.svg` | `/logo.svg` | `app/components/navbar.tsx` (`LOCAL_LOGO_URL`), `app/audit/_components/audit-poster.tsx`, `report-footer.tsx` | +| `icon.svg` | `/icon.svg` | `app/layout.tsx` — the `metadata.icons.icon` favicon | +| `audit/fonts/bitcount-prop-single.woff2` | `/audit/fonts/…` | the `@font-face` `src:` at `app/globals.css:22` | + +## Who consumes it + +The Next server at runtime, and browsers loading the dashboard. Nothing in the CLI (`src/`, +`bin/`) or the Rust daemon (`crates/`) reads this directory. Gotcha: `logo.svg` and `icon.svg` are +**byte-identical copies** of `assets/logos/company/logo.svg` and `icon.svg` (verified by md5) with +no sync step — edit one and the other silently goes stale. `navbar.tsx` treats `/logo.svg` as the +fallback when its remote logo fetch fails, so a missing file degrades quietly rather than erroring. + +## Does it ship + +Yes, but indirectly. `public/` is not itself in package.json `files`; `.next/standalone/` is, and +`bun run build` produces `.next/standalone/public/` with these files inside. Renaming a file here +without updating its referencing component gives an installed user a broken favicon, a broken +navbar logo, and an audit page that silently falls back to a system font. + +## Where its tests live + +No test targets this directory directly. The nearest coverage is `__tests__/ci/tarball-surface.test.ts` (its `MUST_SHIP` / +`MUST_NOT_SHIP_UNDER_STANDALONE` lists) and `__tests__/ci/standalone-prune.test.ts`, which assert +what reaches the published tarball. Run them with `bun run test:run`. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..d5edf164b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,36 @@ +# scripts/ + +Five unrelated subsystems in one flat directory, spanning three of the repo's products. Nothing here +is a product of its own: these are the launchers, release tooling, docs pipeline and dogfood shim that +the CLI, the dashboard and CI drive. Read `CLAUDE.md` for the architecture each one serves. + +| Subsystem | Files | Invoked by | +|---|---|---| +| Dashboard launch | `dev.ts`, `start.ts` → `launch.ts` (+ `parse-script-args.ts`, `skew-log-filter.ts`, `install-diagnosis.mjs`) | `bun run dev` / `bun run start` | +| Release | `build-daemon-packages.mjs`, `publish-aliases.mjs` (+ `alias-proxy.js`, `daemon-platforms.mjs`), `prune-standalone.mjs` | `.github/workflows/publish.yml`, `ci.yml`, `bun run build` | +| Docs | `translate-docs/` (9 files, entry `cli.ts`), `validate-mdx.ts`, `docs-audit.ts` | `bun run translate*`, `bun run validate:mdx`, `bun run docs:audit`; the nightly translation and the weekly docs audit both run on the canary box (`integration-suite/local/jobs/`) | +| Dogfood | `dev-hook.mjs` | all 10 committed dogfood hook configs | +| Container / prompts | `repro-npm-install.sh`, `sync-agent-cli-harnesses-prompt.md` | run by hand; the prompt is the `build-image.yml` sync agent's brief | + +## What this is +Build, release, launch and docs-pipeline tooling. `launch.ts` starts the Next standalone dashboard +(binding loopback unless overridden, and filtering Next's Server-Action deployment-skew noise); +`daemon-platforms.mjs` is the single list of the four `failproofaid` cross-compile targets that +`build-daemon-packages.mjs` publishes and `publish-aliases.mjs` pins into every typo-squat stub. + +## Who consumes it +CI workflows, the two `bun run` launchers, and — for two files — the CLI at runtime: +`lib/install-check.ts` imports `trackInstallEvent` from `install-telemetry.mjs`, and `launch.ts` +imports `diagnoseShadow` from `install-diagnosis.mjs`. `dev-hook.mjs` is spawned by Claude, Codex, +Copilot, Cursor, OpenCode, Pi, Factory, Devin, Antigravity and Goose when a hook fires in this repo. + +## Does it ship +Yes — `scripts/` is in package.json `"files"`, so this whole directory lands in every install. That +matters for `install-telemetry.mjs`: renaming or moving it breaks `lib/install-check.ts` in the +published package. `dev-hook.mjs` is referenced by literal path (`$CLAUDE_PROJECT_DIR/scripts/dev-hook.mjs` +and relative equivalents) from all 10 dogfood configs and must never move — `__tests__/hooks/dogfood-configs.test.ts` +is the tripwire. + +## Where its tests live +`__tests__/scripts/` (including `translate-docs/`) plus `__tests__/ci/daemon-packages.test.ts` and +`__tests__/ci/release-pipeline.test.ts`. Run with `bun run test:run`. diff --git a/scripts/prune-standalone.mjs b/scripts/prune-standalone.mjs index 76c704436..0b7e8b2e1 100644 --- a/scripts/prune-standalone.mjs +++ b/scripts/prune-standalone.mjs @@ -96,9 +96,19 @@ if (exists(NM)) prune(NM); // node_modules/, package.json, public/, and the compiled app code — the rest // is source/dev/docs that the runtime never reads. const STANDALONE_ROOT_PRUNE = [ - // Doc / dev directories - "docs", "examples", "design-docs", "__tests__", - ".claude", ".failproofai", ".github", ".vscode", ".idea", + // Doc / dev directories. `internals` is the engineering manual — contributor + // documentation, never product documentation and never shipped. + "docs", "examples", "design-docs", "__tests__", "internals", + ".vscode", ".idea", + // Every dogfood hook config. These are THIS repo enforcing its own policies + // on itself; each one points at `scripts/dev-hook.mjs`, a path that exists + // only in a checkout. Five of them (.codex .cursor .factory .opencode .pi) + // were being traced in and published to npm users, who have no dev-hook.mjs + // and no business receiving our dogfood configuration. Keep this list in + // step with the dot-directories at the repo root — + // __tests__/ci/standalone-prune.test.ts fails if a new one appears. + ".agents", ".claude", ".codex", ".cursor", ".devin", ".factory", + ".failproofai", ".github", ".opencode", ".pi", // Failproofai CLI artifacts — the dashboard never loads these "bin", "dist", "scripts", "src", // Release-pipeline scratch: the daemon binaries downloaded from the build @@ -107,6 +117,12 @@ const STANDALONE_ROOT_PRUNE = [ // RUNNER_TEMP precisely so this cannot happen), and 16 MB of `.gz` shipped // inside the CLI tarball once already because nothing pruned them. "release-assets", ".daemon-packages", + // The skills submodule: agent skill definitions for a separate repo, traced + // in because the gitlink sits at the repo root. 544 KB the dashboard never + // reads. A submodule is a gitlink, not a directory, so `git ls-files` does + // not surface it the way it surfaces every other top-level dir — which is + // exactly why this shipped unnoticed. + "skills", // The Rust workspace. `target/` is the big one: on any machine that has run // `cargo build`, NFT traces it into the standalone output and it is FIFTEEN // GIGABYTES of compiled artifacts — `npm pack` then hangs trying to tar it, @@ -119,24 +135,34 @@ const STANDALONE_ROOT_PRUNE = [ // plugin/extension dirs are shipped from the package root by `files`, not // from inside the standalone bundle. "integration-suite", "pi-extension", "openclaw-plugin", "docker-hook-sync", + // Brand/reference material: the audit design lab, the CLI logo SVGs, and the + // 11 MB README animation that moved in here out of the repo root. The + // dashboard serves its own icons from public/ and imports nothing from + // assets/, so every byte of this was dead weight in the tarball. + "assets", ]; const STANDALONE_ROOT_PRUNE_FILES = [ - // Top-level markdown / licenses / docs - "README.md", "CHANGELOG.md", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md", - "LICENSE", "Dockerfile.docs", + // Top-level markdown / licenses / docs. CONTRIBUTING.md and SECURITY.md now + // live under .github/ and Dockerfile.docs under docs/ — both directories are + // pruned wholesale above, so they need no entry here. + "README.md", "CHANGELOG.md", "CLAUDE.md", "AGENTS.md", "LICENSE", // Build / lint / test config (applied at build time, not runtime) - "tsconfig.json", "eslint.config.mjs", "tailwind.config.ts", "components.json", + "tsconfig.json", "eslint.config.mjs", "vitest.config.mts", "vitest.config.e2e.mts", // Lockfiles "bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", // Rust workspace manifests, siblings of the `target`/`crates` prune above. - "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "osv-scanner.toml", - // 11 MB README animation. Traced because it sits in the repo root and is - // referenced from README.md; the running dashboard never serves it. - "readme-arch-hq.gif", + // osv-scanner.toml moved to .github/, pruned with that directory. + "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", // Incremental typechecker state — regenerated on every build, read by nothing // at runtime, and routinely a few hundred KB. "tsconfig.tsbuildinfo", "next-env.d.ts", "postcss.config.mjs", + "skills-lock.json", ".gitattributes", + // Contributor READMEs that Next traces into the two directories the bundle + // must keep. public/README.md is the pointed one: everything under public/ is + // served verbatim at the site root, so leaving it there publishes a page + // explaining this repo's layout at https:///README.md. + "public/README.md", "app/README.md", "lib/README.md", ]; for (const d of STANDALONE_ROOT_PRUNE) { rmSync(join(STANDALONE, d), { recursive: true, force: true }); diff --git a/scripts/translate-docs/readme-translator.ts b/scripts/translate-docs/readme-translator.ts index b2041e0c7..ea33bdea2 100644 --- a/scripts/translate-docs/readme-translator.ts +++ b/scripts/translate-docs/readme-translator.ts @@ -33,8 +33,8 @@ const ASSET_RE = /\.(?:png|jpe?g|gif|svg|webp|ico|mp4|webm)$/i; * Re-point the root README's repo-root-relative paths so they still resolve * from `docs/i18n/README..md`, two directories deeper. * - * The root README lives AT the repo root, so it writes `readme-arch-hq.gif` and - * `assets/logos/claude.svg` — correct there. The translator is prompt-forbidden + * The root README lives AT the repo root, so it writes repo-root-relative paths + * like `assets/readme-arch-hq.gif` and `assets/logos/claude.svg` — correct there. The translator is prompt-forbidden * from touching paths ("Preserve all URLs and paths", translator.ts), and * rightly so, but that means every translated copy inherited those paths * verbatim into a directory two levels down, where they resolve against diff --git a/src/README.md b/src/README.md new file mode 100644 index 000000000..3c40226f7 --- /dev/null +++ b/src/README.md @@ -0,0 +1,39 @@ +# src/ + +## What this is + +Of the five products in this package.json, `src/` is **the CLI**. `src/hooks/` (53 flat files, +no subdirectories) is enforcement plus install — it writes hook configs into 12 agent CLIs and +evaluates policies when those hooks fire. `src/audit/` is the separate audit product: 13 per-CLI +12 transcript adapters under `cli-adapters/` feeding detectors, scoring and findings. `src/index.ts` +is the public API (`customPolicies`, `allow`/`deny`/`instruct`) that user policy files import as +`from 'failproofai'`. + +The hot path: an agent CLI fires a hook → `bin/failproofai.mjs --hook --cli ` → if +`daemon-client.ts`'s `isDaemonConfigured()` is true, `attemptDaemonHook()` and nothing else (an +unreachable daemon or a protocol mismatch becomes a forced **deny**) → otherwise `handler.ts` +`evaluateHookEvent()` canonicalizes event, tool name and payload, and `policy-evaluator.ts` runs +`builtin-policies.ts` plus any custom policies, shaping the verdict into each CLI's own response +contract. Subcommands (`SUBCOMMANDS` in `bin/failproofai.mjs`): `policies`/`p`, `policy`, `audit`, +`config`, `uninstall`, `backfill`, `flush`, `harness`; bare `failproofai` launches the dashboard. + +## Who consumes it + +`bin/failproofai.mjs` (every subcommand and the hook path), `bin/failproofai-worker.mjs` via +`worker-server.ts` (the warm worker the Rust daemon spawns), and the Next.js dashboard's server +actions — `app/actions/get-hooks-config.ts`, `install-hooks-web.ts`, `get-audit-result.ts` and +others import `src/hooks/*` and `src/audit/*` directly. End users import `src/index.ts` (as the +built `dist/index.js`) from their own policy files. + +## Does it ship + +Yes — `src/` is in package.json `files`. Installed users get the TypeScript sources, and +`bin/failproofai.mjs` imports them by extensionless specifier (`../src/hooks/handler`), so moving +or renaming a file under `src/hooks/` breaks the hook path for every installed user. +`src/index.ts` is also the bundle entry for `dist/index.js`, which is what resolves +`import ... from 'failproofai'` inside `.failproofai/policies/*.mjs`. + +## Where its tests live + +`__tests__/hooks/` and `__tests__/audit/` (`bun run test:run`); `__tests__/e2e/hooks/` and +`__tests__/e2e/cli/` (`bun run test:e2e`). diff --git a/src/audit/report.ts b/src/audit/report.ts deleted file mode 100644 index 31ede51b1..000000000 --- a/src/audit/report.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * Output renderers for `failproofai audit`: - * • formatText — ANSI table to stdout (GTM-oriented "moment of truth") - * • formatMarkdown — shareable sectioned report written to a file - * • formatJson — machine-readable - * - * The text renderer is the user's first impression of the audit. It leads - * with a headline-box, splits findings into "already protected" vs "slipping - * through" so the conversion ask is obvious, and ends with a copy-pasteable - * install command + report path + star link. - */ -import type { AuditCount, AuditResult, RunAuditOptions } from "./types"; - -const ANSI = { - reset: "\x1B[0m", - dim: "\x1B[2m", - bold: "\x1B[1m", - red: "\x1B[31m", - yellow: "\x1B[33m", - green: "\x1B[32m", - cyan: "\x1B[36m", - magenta: "\x1B[35m", -}; - -/** Honor https://no-color.org / NO_COLOR=1 and FORCE_COLOR=0 by stripping all - * ANSI sequences from the final output. Detected once per renderer call. */ -function noColorEnabled(): boolean { - if (process.env.NO_COLOR && process.env.NO_COLOR !== "") return true; - if (process.env.FORCE_COLOR === "0") return true; - return false; -} - -function stripAnsi(s: string): string { - return s.replace(/\x1B\[[0-9;]*m/g, ""); -} - -/** Human-readable "time ago" — "30m ago", "2h ago", "3d ago", "2w ago". - * Returns "just now" for <1 minute. */ -function formatTimeAgo(iso: string | undefined): 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 < 1) return "just now"; - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; - const d = Math.floor(h / 24); - if (d < 14) return `${d}d ago`; - const w = Math.floor(d / 7); - if (w < 8) return `${w}w ago`; - return `${Math.floor(d / 30)}mo ago`; -} - -/** Width of the headline box and section dividers. Adapts to narrow terminals - * down to a 60-char floor. */ -function getWidth(): number { - const cols = process.stdout.columns ?? 80; - return Math.max(60, Math.min(78, cols)); -} - -/** Box-drawing helpers using unicode round-corner glyphs. */ -function topBorder(w: number): string { return `╭${"─".repeat(w - 2)}╮`; } -function bottomBorder(w: number): string { return `╰${"─".repeat(w - 2)}╯`; } -function boxLine(text: string, w: number): string { - const inner = w - 4; // 2 chars of border + 1 char padding each side - const visible = stripAnsi(text); - const pad = Math.max(0, inner - visible.length); - return `│ ${text}${" ".repeat(pad)} │`; -} -function divider(w: number): string { return "─".repeat(w); } - -/** Short, qualified policy slug — `failproofai/foo` → `foo` for display. */ -function shortName(name: string): string { - const slash = name.indexOf("/"); - return slash >= 0 ? name.slice(slash + 1) : name; -} - -/** Sum hits across an AuditCount[] subset. */ -function sumHits(rows: AuditCount[]): number { - return rows.reduce((acc, r) => acc + r.hits, 0); -} - -/** Render one row in the table-of-findings form: - * 31× Tried to read files outside your project - * Stops the agent from peeking at neighboring repos… - * Last seen 2h ago · 6 projects - * › Already enforced — failproofai is blocking these in real time. - * Example: grep -n … - */ -function renderRow(r: AuditCount, opts: { showExamples?: boolean }): string[] { - const out: string[] = []; - const sev = r.severity; - const titleColor = - sev === "deny" ? ANSI.red - : sev === "warn" ? ANSI.red - : sev === "info" || sev === "instruct" ? ANSI.yellow - : ANSI.cyan; - const countStr = String(r.hits).padStart(4); - out.push(` ${titleColor}${ANSI.bold}${countStr}×${ANSI.reset} ${r.displayTitle}`); - if (r.impact) { - out.push(` ${ANSI.dim}${r.impact}${ANSI.reset}`); - } - out.push( - ` ${ANSI.dim}Last seen ${formatTimeAgo(r.lastSeen)} · ${r.projects} project${r.projects === 1 ? "" : "s"}${ANSI.reset}`, - ); - if (opts.showExamples && r.examples[0]) { - out.push(` ${ANSI.dim}Example: ${r.examples[0].example}${ANSI.reset}`); - } - if (r.installHint) { - const arrowColor = r.enabledInConfig ? ANSI.green : ANSI.cyan; - out.push(` ${arrowColor}›${ANSI.reset} ${r.installHint}`); - } - out.push(""); - return out; -} - -export function formatText(result: AuditResult, opts: RunAuditOptions = {}): string { - const w = getWidth(); - const limit = opts.limit ?? 20; - const showExamples = !!opts.showExamples; - - // Split rows by enforcement state. - const enabledRows = result.results.filter((r) => r.source === "builtin" && r.enabledInConfig); - const unenabledBuiltinRows = result.results.filter((r) => r.source === "builtin" && !r.enabledInConfig); - const detectorRows = result.results.filter((r) => r.source === "audit-detector"); - // "Slipping through" combines unenabled-builtins + audit-detectors, ranked by hits. - const slippingRows = [...unenabledBuiltinRows, ...detectorRows].sort((a, b) => b.hits - a.hits); - - const totalProtected = sumHits(enabledRows); - const totalSlipping = sumHits(slippingRows); - const totalHits = totalProtected + totalSlipping; - const sinceLabel = result.scope.since ? `the last ${result.scope.since}` : "all time"; - - const lines: string[] = []; - - // ── Header ────────────────────────────────────────────────────── - lines.push(`${ANSI.cyan}🛡 failproofai audit${ANSI.reset} ${ANSI.dim}[beta]${ANSI.reset} · ${sinceLabel}`); - lines.push( - ` ${ANSI.dim}${result.transcripts.scanned} sessions · ${result.totals.projectsWithHits} project${result.totals.projectsWithHits === 1 ? "" : "s"} with hits · scanned in ${(result.transcripts.durationMs / 1000).toFixed(1)}s${ANSI.reset}`, - ); - lines.push(""); - - // ── Headline box ──────────────────────────────────────────────── - if (totalHits === 0) { - lines.push(topBorder(w)); - lines.push(boxLine(`${ANSI.green}🎉 Clean run!${ANSI.reset} Nothing matched your policies in this window.`, w)); - lines.push(bottomBorder(w)); - lines.push(""); - return noColorEnabled() ? stripAnsi(lines.join("\n")) : lines.join("\n"); - } - lines.push(topBorder(w)); - lines.push(boxLine( - `${ANSI.bold}Your agent did ${totalHits} wasteful or risky things in ${sinceLabel}.${ANSI.reset}`, - w, - )); - if (totalSlipping > 0) { - lines.push(boxLine( - `${totalSlipping} of those would've been caught if more policies were on.`, - w, - )); - } - lines.push(bottomBorder(w)); - lines.push(""); - - // ── Section: ALREADY PROTECTED ────────────────────────────────── - if (enabledRows.length > 0) { - lines.push( - `${ANSI.green}✓ ALREADY PROTECTED${ANSI.reset} ${ANSI.dim}(${totalProtected} action${totalProtected === 1 ? "" : "s"} stopped by your current policies)${ANSI.reset}`, - ); - lines.push(""); - for (const row of enabledRows.slice(0, limit)) { - lines.push(...renderRow(row, { showExamples })); - } - if (enabledRows.length > limit) { - lines.push(` ${ANSI.dim}… ${enabledRows.length - limit} more (use --limit ${enabledRows.length})${ANSI.reset}`); - lines.push(""); - } - } - - // ── Section: SLIPPING THROUGH ─────────────────────────────────── - if (slippingRows.length > 0) { - lines.push( - `${ANSI.yellow}○ SLIPPING THROUGH${ANSI.reset} ${ANSI.dim}(${totalSlipping} action${totalSlipping === 1 ? "" : "s"} caught by audit, not blocked in real time)${ANSI.reset}`, - ); - lines.push(""); - for (const row of slippingRows.slice(0, limit)) { - lines.push(...renderRow(row, { showExamples })); - } - if (slippingRows.length > limit) { - lines.push(` ${ANSI.dim}… ${slippingRows.length - limit} more (use --limit ${slippingRows.length})${ANSI.reset}`); - lines.push(""); - } - } - - // ── Footer / NEXT step ────────────────────────────────────────── - lines.push(divider(w)); - if (unenabledBuiltinRows.length > 0) { - const installNames = unenabledBuiltinRows.map((r) => shortName(r.name)); - lines.push( - `${ANSI.bold}NEXT${ANSI.reset} Enable the ${installNames.length} unenabled real-time polic${installNames.length === 1 ? "y" : "ies"} in one command:`, - ); - lines.push(""); - lines.push(` ${ANSI.cyan}failproofai policies --install ${installNames.join(" ")}${ANSI.reset}`); - lines.push(""); - } else if (slippingRows.length > 0) { - lines.push( - `${ANSI.bold}NEXT${ANSI.reset} Everything blockable is already enabled. Audit-only findings will show up in your next ${ANSI.cyan}failproofai audit${ANSI.reset}.`, - ); - lines.push(""); - } else { - lines.push(`${ANSI.bold}NEXT${ANSI.reset} ${ANSI.green}You have the relevant policies enabled. failproofai is blocking these in real time.${ANSI.reset}`); - lines.push(""); - } - - // Mirror the actual --report path so the printed footer matches what was - // (or will be) written. Suppressed entirely with --no-report. - if (!opts.noReport) { - const reportPath = opts.reportPath ?? "./failproofai-audit.md"; - lines.push(` 📄 Shareable report: ${ANSI.cyan}${reportPath}${ANSI.reset}`); - } - lines.push(` ⭐ Star us: ${ANSI.cyan}https://github.com/FailproofAI/failproofai${ANSI.reset}`); - lines.push(""); - - return noColorEnabled() ? stripAnsi(lines.join("\n")) : lines.join("\n"); -} - -export function formatJson(result: AuditResult): string { - return JSON.stringify(result, null, 2); -} - -/** Escape characters that would break a markdown table row. Pipes split - * columns; backslashes escape the next char; leading newlines end the row. */ -function escapeTableCell(s: string): string { - return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " "); -} - -function escapeBackticks(s: string): string { - return s.replace(/`/g, "\\`"); -} - -export function formatMarkdown(result: AuditResult): string { - const out: string[] = []; - - const enabledRows = result.results.filter((r) => r.source === "builtin" && r.enabledInConfig); - const unenabledBuiltinRows = result.results.filter((r) => r.source === "builtin" && !r.enabledInConfig); - const detectorRows = result.results.filter((r) => r.source === "audit-detector"); - const slippingRows = [...unenabledBuiltinRows, ...detectorRows].sort((a, b) => b.hits - a.hits); - - const totalProtected = sumHits(enabledRows); - const totalSlipping = sumHits(slippingRows); - const totalHits = totalProtected + totalSlipping; - const sinceLabel = result.scope.since ? `last ${result.scope.since}` : "all time"; - - out.push(`# Agent behavior audit · ${sinceLabel}`); - out.push(""); - out.push("> _Generated by `failproofai audit` (**beta**) — flags and output may change between releases. Going live shortly._"); - out.push(""); - out.push(`*Generated ${result.scannedAt} — scanned ${result.transcripts.scanned} sessions across ${result.totals.projectsWithHits} project(s) in ${(result.transcripts.durationMs / 1000).toFixed(1)}s.*`); - out.push(""); - - // TL;DR — readable to someone who doesn't know failproofai. - out.push("## TL;DR"); - out.push(""); - if (totalHits === 0) { - out.push("Clean run — the AI coding agent didn't do anything `failproofai` catches in this window."); - } else { - out.push( - `Over ${result.transcripts.scanned} sessions, my AI coding agent did **${totalHits} things \`failproofai\` would have stopped**: ` + - `${totalProtected} were already blocked in real time by my current config; ` + - `**${totalSlipping} slipped through** (would've been caught if more policies were on).`, - ); - } - out.push(""); - out.push("> [failproofai](https://github.com/FailproofAI/failproofai) is a hook-based policy engine for Claude Code, Codex, Copilot, Cursor, OpenCode, and Pi. The `audit` command replays past agent sessions through every builtin policy to surface patterns that were (or could've been) stopped."); - out.push(""); - - if (totalHits === 0) return out.join("\n"); - - // What was blocked - if (enabledRows.length > 0) { - out.push(`## ✓ Already protected (${totalProtected} action${totalProtected === 1 ? "" : "s"} stopped)`); - out.push(""); - out.push("These are real-time policies you have on — `failproofai` blocked the agent before each action took effect."); - out.push(""); - out.push("| Issue | Hits | Projects | Last seen | Policy |"); - out.push("|---|---:|---:|---|---|"); - for (const r of enabledRows) { - out.push( - `| ${escapeTableCell(r.displayTitle)} | ${r.hits} | ${r.projects} | ${formatTimeAgo(r.lastSeen)} | \`${escapeTableCell(shortName(r.name))}\` |`, - ); - } - out.push(""); - } - - // What's slipping through - if (slippingRows.length > 0) { - out.push(`## ○ Slipping through (${totalSlipping} action${totalSlipping === 1 ? "" : "s"} caught by audit, not yet blocked)`); - out.push(""); - out.push("Patterns the audit detected but real-time enforcement isn't on for. The CTA column shows how to fix each."); - out.push(""); - out.push("| Issue | Hits | Projects | Last seen | Fix |"); - out.push("|---|---:|---:|---|---|"); - for (const r of slippingRows) { - const fix = r.source === "builtin" - ? `\`failproofai policies --install ${shortName(r.name)}\`` - : "_audit-only_"; - out.push( - `| ${escapeTableCell(r.displayTitle)} | ${r.hits} | ${r.projects} | ${formatTimeAgo(r.lastSeen)} | ${fix} |`, - ); - } - out.push(""); - - if (unenabledBuiltinRows.length > 0) { - out.push(`### Enable everything in one command`); - out.push(""); - out.push("```bash"); - out.push(`failproofai policies --install ${unenabledBuiltinRows.map((r) => shortName(r.name)).join(" ")}`); - out.push("```"); - out.push(""); - } - } - - // Examples appendix (only if non-trivial) - const rowsWithExamples = [...enabledRows, ...slippingRows].filter((r) => r.examples.length > 0); - if (rowsWithExamples.length > 0) { - out.push("## Examples"); - out.push(""); - for (const r of rowsWithExamples) { - out.push(`### ${escapeBackticks(r.displayTitle)} (\`${escapeBackticks(shortName(r.name))}\`)`); - out.push(""); - if (r.impact) { - out.push(`> ${r.impact}`); - out.push(""); - } - for (const e of r.examples) { - out.push(`- \`${escapeBackticks(e.example)}\` _(${e.cwd || "?"}, ${formatTimeAgo(e.timestamp)})_`); - } - out.push(""); - } - } - - out.push("---"); - out.push(""); - out.push("⭐ Star failproofai on GitHub: "); - out.push(""); - return out.join("\n"); -} diff --git a/tailwind.config.ts b/tailwind.config.ts deleted file mode 100644 index fd6b718e3..000000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - content: [ - "./pages/**/*.{js,ts,jsx,tsx,mdx}", - "./components/**/*.{js,ts,jsx,tsx,mdx}", - "./app/**/*.{js,ts,jsx,tsx,mdx}", - ], -}; - -export default config;