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/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 23a5a8b6..f842a7bc 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,67 @@ 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 + + - 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/publish.yml b/.github/workflows/publish.yml index 06bfa9bf..c07c4008 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -136,6 +136,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 +250,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 +388,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 @@ -352,6 +459,43 @@ 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[@]}" + - name: Publish env: DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} @@ -376,6 +520,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 @@ -415,3 +636,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 cc1f9e27..8a9d03d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,87 @@ # Changelog -## 0.0.16-beta.0 — 2026-07-31 +## 1.0.0-beta.5 — 2026-08-04 + +### Features +- 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. + +### 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. + +## 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) - 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) ### Dependencies diff --git a/CLAUDE.md b/CLAUDE.md index c7140786..b8e35d63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -867,13 +867,128 @@ 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. + +### 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 +1063,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..7d7b3300 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2222 @@ +# 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.5" +dependencies = [ + "fpai-collect", + "fpai-ipc", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2", + "tracing", + "tracing-subscriber", +] + +[[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.5" +dependencies = [ + "notify", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "time", + "tokio", + "tracing", + "wiremock", +] + +[[package]] +name = "fpai-ipc" +version = "1.0.0-beta.5" +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_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 = "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 = "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..67f12369 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "1.0.0-beta.5" +edition = "2024" +license-file = "LICENSE" +repository = "https://github.com/FailproofAI/failproofai" diff --git a/__tests__/ci/daemon-packages.test.ts b/__tests__/ci/daemon-packages.test.ts new file mode 100644 index 00000000..85bc9714 --- /dev/null +++ b/__tests__/ci/daemon-packages.test.ts @@ -0,0 +1,189 @@ +// @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 { gzipSync } from "node:zlib"; +import { + DAEMON_PLATFORMS, + daemonAssetName, + daemonOptionalDependencies, + daemonPackageName, +} from "../../scripts/daemon-platforms.mjs"; +import { + STAGING_DIR, + pinRootManifest, + platformPackageManifest, + stagePlatformPackage, +} 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("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("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..b2668f01 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"); @@ -243,9 +338,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 +439,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..f24cc9bb --- /dev/null +++ b/__tests__/components/pause-notices.test.tsx @@ -0,0 +1,99 @@ +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; setBy: string }> = {}) => ({ + sessionId: "s1", + pausedAt: 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__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 496f2732..e20fd3c5 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,129 @@ 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"); + }); + + 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 +3222,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-enrollment-cli.test.ts b/__tests__/hooks/cloud-enrollment-cli.test.ts new file mode 100644 index 00000000..2822e5e2 --- /dev/null +++ b/__tests__/hooks/cloud-enrollment-cli.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { 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"; + +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 })); + +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(); +}); + +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, + daemonStatus: () => "running" as const, +}; + +describe("--connect", () => { + it("verifies before writing, and reports what is assigned", async () => { + const r = await runConnectCommand({ ...base, machineId: "m-1" }); + expect(r.exitCode).toBe(0); + expect(r.lines.join("\n")).toMatch(/Connected to https:\/\/be\.failproof\.ai as 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 }); + }); + + 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("defaults the machine id to the host name", async () => { + await runConnectCommand({ ...base, defaultMachineId: "my-laptop" }); + expect(readCloudCredentials()?.machineId).toBe("my-laptop"); + }); + + 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/events", + key: "a-machine-token", + }); + // The ingest endpoint is DERIVED, never asked for separately. + expect(ingestOk).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://be.failproof.ai/events" }), + ); + }); + + it("does not send transcripts unless asked, and says so", async () => { + // A transcript carries prompts, file contents and whatever was pasted into + // a terminal. It can never be a side effect of connecting. + const r = await runConnectCommand({ ...base, machineId: "m-1" }); + expect(readHooksConfig().collector).toMatchObject({ hooks: true, sessions: false }); + expect(r.lines.join("\n")).toMatch(/transcripts are NOT being sent/i); + }); + + it("sends transcripts when explicitly opted in", async () => { + await runConnectCommand({ ...base, machineId: "m-1", sessions: true }); + expect(readHooksConfig().collector).toMatchObject({ sessions: true }); + }); + + 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"); + expect(readIngestCredential()?.url).toBe("https://be.failproof.ai/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(/stop being sent/); + }); +}); + +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..13e28937 --- /dev/null +++ b/__tests__/hooks/cloud-enrollment.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createServer, type Server } from "node:http"; +import { mkdtempSync, rmSync, statSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { + clearCloudCredentials, + cloudCredentialPath, + maskToken, + readCloudCredentials, + 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("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-managed-policies.test.ts b/__tests__/hooks/cloud-managed-policies.test.ts new file mode 100644 index 00000000..21995a2f --- /dev/null +++ b/__tests__/hooks/cloud-managed-policies.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { 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/); + }); +}); diff --git a/__tests__/hooks/collector-config.test.ts b/__tests__/hooks/collector-config.test.ts new file mode 100644 index 00000000..59590a2e --- /dev/null +++ b/__tests__/hooks/collector-config.test.ts @@ -0,0 +1,172 @@ +// @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, +} 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); + expect(JSON.parse(readFileSync(path, "utf8")).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..b1a40bc4 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"; @@ -13,6 +13,56 @@ vi.mock("../../src/hooks/tui", async (importOriginal) => { return { ...actual, selectOne: vi.fn(), multiSelect: vi.fn(), intro: vi.fn(), outro: vi.fn() }; }); vi.mock("../../src/hooks/manager", () => ({ installHooks: vi.fn(async () => {}) })); +// The wizard'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), + // 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 wizard kicks off the audit pipeline after a completed apply; stub it so // tests never scan real history. vi.mock("../../src/audit/cli", () => ({ runPostSetupAudit: vi.fn(async () => {}) })); @@ -25,6 +75,11 @@ vi.mock("../../src/hooks/integrations", async (importOriginal) => { import { selectOne, multiSelect, outro, type TTYIn, type TTYOut } from "../../src/hooks/tui"; import { installHooks } from "../../src/hooks/manager"; +import { + isDaemonSupportedPlatform, + installDaemonService, + primeElevation, +} from "../../src/hooks/daemon-service"; import { buildScopeChoices, buildAgentChoices, @@ -36,11 +91,13 @@ 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"; const mkTtyStdin = (): TTYIn => ({ isTTY: true }) as unknown as TTYIn; const mkTtyStdout = (): TTYOut => @@ -52,6 +109,8 @@ const ttyIO = () => ({ stdin: mkTtyStdin(), stdout: mkTtyStdout() }); // 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 +124,11 @@ afterAll(() => { } catch { /* ignore */ } + try { + rmSync(WIZARD_TEST_CONFIG_DIR, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); beforeEach(() => { @@ -73,6 +137,13 @@ 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); }); describe("configure-wizard pure builders", () => { @@ -150,6 +221,20 @@ 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({ + scope: "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", () => { @@ -188,6 +273,65 @@ describe("configure-wizard orchestration", () => { expect(call[7]).toEqual([...INTEGRATION_TYPES]); // all CLIs, regardless of detection }); + it("accepts an empty policy selection and still installs the hooks", async () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("apply"); // review + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) // assistants + .mockResolvedValueOnce([]); // 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 () => { + vi.mocked(selectOne) + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) + .mockResolvedValueOnce([]); + + await runConfigureWizard(ttyIO()); + + const [assistantsOpts] = vi.mocked(multiSelect).mock.calls[0]; + const [policyOpts] = 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; + + vi.mocked(selectOne).mockResolvedValueOnce("project").mockResolvedValueOnce("apply"); + vi.mocked(multiSelect) + .mockResolvedValueOnce(["claude"]) + .mockResolvedValueOnce(["git"]); // 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 @@ -370,3 +514,267 @@ 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 resolve(fileHome, ".failproofai", "policies-config.json"); + } + function readGlobalConfig(): Record { + const path = globalConfigPath(); + if (!existsSync(path)) return {}; + return JSON.parse(readFileSync(path, "utf8")) as Record; + } + + // 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 }); + }); + + it("installs the daemon and marks it configured when step 0 asks for it", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") // 0 — daemon + .mockResolvedValueOnce("user") // scope + .mockResolvedValueOnce("no") // 4 — send data to AgentEye? (declined) + .mockResolvedValueOnce("apply"); // review + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).toHaveBeenCalledTimes(1); + expect(readGlobalConfig().daemonConfigured).toBe(true); + }); + + it("primes sudo at step 0, before any other question is asked", async () => { + // Ordering is the whole point: sudo must prompt on a clean terminal. Once + // a TUI screen has been drawn, the password prompt is invisible and the + // typed characters land 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; + }); + // Question order: daemon → scope → (assistants, policies are multiSelect) + // → send-data → review. "no" declines the collector, which is its default + // and keeps these daemon-focused tests to one subject. + const answers = ["install", "user", "no", "apply"]; + vi.mocked(selectOne).mockImplementation(async () => { + order.push("prompt"); + return answers[order.filter((o) => o === "prompt").length - 1] as never; + }); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(order[0]).toBe("prompt"); // the daemon question itself + expect(order[1]).toBe("sudo"); // then the password, before anything else + }); + + it("carries on without a daemon when sudo cannot be obtained", async () => { + // A refused password must not cost the user the rest of their setup. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(primeElevation).mockReturnValue(false); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + // No send-data answer here on purpose: sudo fails, so daemonWanted goes + // false and the collector question is never asked. Adding one would + // desync this chain — which is exactly the gating this test proves. + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("never attempts daemon install when step 0 is declined", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(selectOne) + .mockResolvedValueOnce("skip") // 0 — daemon: not now + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(primeElevation).not.toHaveBeenCalled(); + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("installs the daemon at project scope too — it is machine-level, not per-project", async () => { + // Deliberately NOT gated on the scope any more: one daemon serves every + // project on the machine, and step 0 is where the user consents to it. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("project") + .mockResolvedValueOnce("no") // 4 — send data to AgentEye? (declined) + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).toHaveBeenCalledTimes(1); + }); + + it("never attempts daemon install when the platform is unsupported, even at user scope", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(false); + // No step 0 on an unsupported platform — the daemon question never renders. + vi.mocked(selectOne).mockResolvedValueOnce("user").mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + expect(installDaemonService).not.toHaveBeenCalled(); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + }); + + it("sends a classification, never the raw reason, in the daemon-install telemetry", async () => { + // The raw reason is an errno message built from homedir()-derived paths + // ("EACCES: permission denied, open '/home//.config/systemd/...'"), + // so it carries the OS username and the local filesystem layout. Only a + // bounded code may leave the machine; the full text stays in the local log. + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "EACCES: permission denied, open '/home/somebody/.config/systemd/user/failproofaid.service'", + }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("no") // 4 — send data to AgentEye? (declined) + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + // Last, not first: mock calls accumulate across the tests in this block. + const call = vi + .mocked(trackHookEvent) + .mock.calls.filter(([, event]) => event === "configure_daemon_install") + .at(-1); + expect(call).toBeDefined(); + const props = call![2] as Record; + expect(props.installed).toBe(false); + expect(props.reason).toBe("service_manager_error"); + expect(JSON.stringify(props)).not.toContain("somebody"); + }); + + it("classifies each daemon-install failure mode into a fixed set of codes", () => { + expect(classifyDaemonInstallFailure("failproofaid is not supported on win32 yet")).toBe( + "unsupported_platform", + ); + expect( + classifyDaemonInstallFailure("failproofaid binary not found for this platform (no matching …)"), + ).toBe("binary_not_found"); + expect( + classifyDaemonInstallFailure("failproofaid was installed but did not reach a running state within 5000ms"), + ).toBe("did_not_start"); + expect(classifyDaemonInstallFailure("ENOSPC: no space left on device, write")).toBe( + "service_manager_error", + ); + expect(classifyDaemonInstallFailure(undefined)).toBe("unknown"); + }); + + it("classifies the download and elevation failure modes distinctly", () => { + // These three have different remedies — retry the network, re-run under + // sudo, or treat a bad digest as a supply-chain signal — so collapsing + // them into service_manager_error would make the telemetry useless for + // telling an offline laptop from a tampered artifact. + expect( + classifyDaemonInstallFailure( + "root privileges are required to install the failproofaid system service, and sudo is not available without a password. Run: sudo failproofai config", + ), + ).toBe("needs_root"); + expect( + classifyDaemonInstallFailure("checksum mismatch for failproofaid-linux-x64.gz (expected ab…, got cd…)"), + ).toBe("checksum_mismatch"); + expect(classifyDaemonInstallFailure("daemon downloads are disabled (FAILPROOFAI_NO_DOWNLOAD)")).toBe( + "downloads_disabled", + ); + expect( + classifyDaemonInstallFailure("failed to download failproofaid v1.0.0 for linux-x64: fetch failed"), + ).toBe("download_failed"); + }); + + it("does not fail the wizard or mark daemonConfigured when daemon install fails", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ + installed: false, + reason: "no failproofaid binary found", + }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("no") // 4 — send data to AgentEye? (declined) + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + const result = await runConfigureWizard(ttyIO()); + + expect(result.applied).toBe(true); + expect(installHooks).toHaveBeenCalledTimes(1); + expect(readGlobalConfig().daemonConfigured).toBeUndefined(); + const message = vi.mocked(outro).mock.calls[0]![0]; + expect(message).not.toContain("background daemon enabled"); + }); + + it("mentions the background daemon in the outro message only on a successful install", async () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + vi.mocked(installDaemonService).mockResolvedValue({ installed: true }); + vi.mocked(selectOne) + .mockResolvedValueOnce("install") + .mockResolvedValueOnce("user") + .mockResolvedValueOnce("no") // 4 — send data to AgentEye? (declined) + .mockResolvedValueOnce("apply"); + vi.mocked(multiSelect).mockResolvedValueOnce(["claude"]).mockResolvedValueOnce(["git"]); + + await runConfigureWizard(ttyIO()); + + const message = vi.mocked(outro).mock.calls[0]![0]; + expect(message).toContain("background daemon enabled"); + }); + + it("reviewLines shows the daemon line only when step 0 asked for it", () => { + vi.mocked(isDaemonSupportedPlatform).mockReturnValue(true); + const withDaemon = reviewLines({ + scope: "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({ + scope: "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({ + scope: "user", + clis: ["claude"], + policies: ["block-sudo"], + cwd: "/tmp/proj", + }).join("\n"); + expect(unsupported).not.toContain("Daemon"); + }); +}); diff --git a/__tests__/hooks/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts new file mode 100644 index 00000000..d87c832a --- /dev/null +++ b/__tests__/hooks/daemon-client.test.ts @@ -0,0 +1,298 @@ +// @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"; + +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("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 () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], daemonConfigured: true }), + ); + const { isDaemonConfigured } = await import("../../src/hooks/daemon-client"); + expect(isDaemonConfigured()).toBe(true); + }); + + it("is false when daemonConfigured is explicitly false", async () => { + const dir = join(globalConfigDir, ".failproofai"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], daemonConfigured: 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..bb990792 --- /dev/null +++ b/__tests__/hooks/daemon-download.test.ts @@ -0,0 +1,428 @@ +// @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"; + +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(resolve(home, ".failproofai", "bin"))).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(resolve(home, ".failproofai", "bin"), { 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(resolve(home, ".failproofai", "bin"))).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); + }); + + 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-service.test.ts b/__tests__/hooks/daemon-service.test.ts new file mode 100644 index 00000000..0e0bf684 --- /dev/null +++ b/__tests__/hooks/daemon-service.test.ts @@ -0,0 +1,504 @@ +// @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"; + +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 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; + }); + + 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(resolve(home, ".failproofai", "bin"), { 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}"`, + ); + } + }); + + // 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"); + expect(result.reason).toContain("systemctl 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"); + }); + }); + + // 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("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" + ); + const { getConfigPathForScope } = await import("../../src/hooks/hooks-config"); + const configPath = getConfigPathForScope("user"); + 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(JSON.parse(readFileSync(configPath, "utf8")).daemonConfigured).toBe(true); + + // Without this, removing the service leaves the machine failing + // closed forever against a socket that is gone. + await uninstallDaemonService(); + expect(JSON.parse(readFileSync(configPath, "utf8")).daemonConfigured).toBeUndefined(); + } finally { + if (preexisting !== null) writeFileSync(configPath, preexisting, "utf8"); + else rmSync(configPath, { force: true }); + } + }, 20_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/handler.test.ts b/__tests__/hooks/handler.test.ts index 072020aa..0083f302 100644 --- a/__tests__/hooks/handler.test.ts +++ b/__tests__/hooks/handler.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { handleHookEvent } from "../../src/hooks/handler"; +import { handleHookEvent, evaluateHookEvent } from "../../src/hooks/handler"; vi.mock("../../src/hooks/hooks-config", () => ({ readMergedHooksConfig: vi.fn(() => ({ enabledPolicies: ["block-sudo"] })), @@ -263,6 +263,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/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/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/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..702a41f3 --- /dev/null +++ b/__tests__/hooks/session-pause-cli.test.ts @@ -0,0 +1,146 @@ +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", () => { + it("is the hard ceiling by default", () => { + expect(effectiveCeilingMs("/tmp/p")).toBe(PAUSE_CEILING_MS); + }); + + it("lets config LOWER the ceiling", () => { + vi.mocked(readMergedHooksConfig).mockReturnValue({ enabledPolicies: [], maxPauseMs: 600_000 } as never); + expect(effectiveCeilingMs("/tmp/p")).toBe(600_000); + }); + + it("does NOT let config raise it", () => { + // A ceiling a project can raise is not a ceiling. + vi.mocked(readMergedHooksConfig).mockReturnValue({ enabledPolicies: [], maxPauseMs: 999 * 3_600_000 } as never); + expect(effectiveCeilingMs("/tmp/p")).toBe(PAUSE_CEILING_MS); + }); + + it("keeps the ceiling when config is unreadable", () => { + vi.mocked(readMergedHooksConfig).mockImplementation(() => { throw new Error("bad config"); }); + expect(effectiveCeilingMs("/tmp/p")).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..35056d75 --- /dev/null +++ b/__tests__/hooks/session-pause.test.ts @@ -0,0 +1,161 @@ +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); + }); +}); + +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/worker-server.test.ts b/__tests__/hooks/worker-server.test.ts new file mode 100644 index 00000000..780a8fc1 --- /dev/null +++ b/__tests__/hooks/worker-server.test.ts @@ -0,0 +1,353 @@ +// @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 (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"); + }); + + 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("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 + }); +}); 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/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