From eb494564a8a5c4cb7f4d1e5a74601c2442076acd Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 13:25:45 +0530 Subject: [PATCH 01/17] [failproofaid] Add empty Rust workspace and gated CI plumbing Stage 1 of the failproofai/failproofaid split: an empty Cargo workspace plus a rust-quality CI job gated on crates/*/Cargo.toml existing, so it goes green before any daemon code lands. Also fixes the bun cache key (hashFiles('bun.lockb') has silently never matched anything since this repo tracks bun.lock, not bun.lockb) and extends the version-consistency check to cover the new Cargo workspace version. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ .gitignore | 3 +++ Cargo.toml | 9 +++++++ crates/.gitkeep | 0 rust-toolchain.toml | 4 +++ 5 files changed, 71 insertions(+) create mode 100644 Cargo.toml create mode 100644 crates/.gitkeep create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23a5a8b6..6e8ae9ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,14 @@ jobs: MISMATCH=1 fi done + # 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 +83,53 @@ jobs: timeout_minutes: 5 command: bunx tsc --noEmit + rust-quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + + # 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 + + - 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/.gitignore b/.gitignore index e52e93d3..fdd830ea 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 diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..213efd8e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "0.0.16-beta.0" +edition = "2024" +license-file = "LICENSE" +repository = "https://github.com/FailproofAI/failproofai" diff --git a/crates/.gitkeep b/crates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..5ec58696 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] +profile = "minimal" From 933d9fc1a4a4609c5ee8b05459eda4e605fb6f4c Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 13:37:18 +0530 Subject: [PATCH 02/17] [failproofaid] Add the daemon skeleton and IPC wire protocol Stage 2: crates/fpai-ipc (length-prefixed JSON framing, the ping/hook envelope, SO_PEERCRED/getpeereid peer verification) and crates/failproofaid (a Unix-socket server binding ~/.failproofai/run/failproofaid.sock at 0600 inside a 0700 dir, a flock-based singleton guard, and graceful SIGTERM shutdown). Hook requests get a stub "not implemented" error for now -- wiring them to a real warm Node/Bun worker is Stage 3. 28 Rust tests (unit + black-box binary integration tests spawning the real compiled binary), plus manual end-to-end verification against an independent Python client exercising ping/pong, the stub hook response, protocol-version mismatch, malformed frames, and an oversized length prefix. Testing caught two real bugs before they shipped: serde's rename_all on an enum only renames variant tags, not struct-variant field names (protocolVersion was silently serializing as protocol_version), and ensure_run_dir was unconditionally chmod-ing whatever directory FAILPROOFAI_DAEMON_SOCKET's parent resolved to -- now it refuses to touch a pre-existing directory it didn't create itself. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 428 ++++++++++++++++++++++++ crates/PROTOCOL.md | 122 +++++++ crates/failproofaid/Cargo.toml | 18 + crates/failproofaid/src/lock.rs | 81 +++++ crates/failproofaid/src/main.rs | 67 ++++ crates/failproofaid/src/paths.rs | 160 +++++++++ crates/failproofaid/src/server.rs | 308 +++++++++++++++++ crates/failproofaid/tests/daemon_e2e.rs | 179 ++++++++++ crates/fpai-ipc/Cargo.toml | 16 + crates/fpai-ipc/src/envelope.rs | 175 ++++++++++ crates/fpai-ipc/src/framing.rs | 177 ++++++++++ crates/fpai-ipc/src/lib.rs | 10 + crates/fpai-ipc/src/peer.rs | 80 +++++ 13 files changed, 1821 insertions(+) create mode 100644 Cargo.lock create mode 100644 crates/PROTOCOL.md create mode 100644 crates/failproofaid/Cargo.toml create mode 100644 crates/failproofaid/src/lock.rs create mode 100644 crates/failproofaid/src/main.rs create mode 100644 crates/failproofaid/src/paths.rs create mode 100644 crates/failproofaid/src/server.rs create mode 100644 crates/failproofaid/tests/daemon_e2e.rs create mode 100644 crates/fpai-ipc/Cargo.toml create mode 100644 crates/fpai-ipc/src/envelope.rs create mode 100644 crates/fpai-ipc/src/framing.rs create mode 100644 crates/fpai-ipc/src/lib.rs create mode 100644 crates/fpai-ipc/src/peer.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..085472ca --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,428 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "failproofaid" +version = "0.0.16-beta.0" +dependencies = [ + "fpai-ipc", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "fpai-ipc" +version = "0.0.16-beta.0" +dependencies = [ + "libc", + "proptest", + "serde", + "serde_json", +] + +[[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", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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", + "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 = "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", +] + +[[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", +] + +[[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_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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", +] + +[[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 = "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 = "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 = "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", +] + +[[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 = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/PROTOCOL.md b/crates/PROTOCOL.md new file mode 100644 index 00000000..26613275 --- /dev/null +++ b/crates/PROTOCOL.md @@ -0,0 +1,122 @@ +# failproofaid wire protocol + +The contract between the `failproofai` CLI (thin client) and the `failproofaid` +daemon over a Unix domain socket. This is the same contract every hook +invocation already has today — see `src/hooks/handler.ts`'s +`handleHookEvent`: `(argv --hook --cli , stdin JSON payload) -> +(stdout, stderr, exitCode)`. The daemon adds nothing to that contract; it only +relays it over a socket instead of a fresh process's argv/stdin/stdout. + +Implemented in `crates/fpai-ipc` (framing + envelope + peer verification) and +`crates/failproofaid` (the socket server itself). As of Stage 2, the daemon +answers `ping` and rejects `hook` with a stub "not implemented" error — Stage 3 +wires `hook` up to a real warm Node/Bun worker. + +## Transport + +One Unix domain socket, one connection per request. The daemon reads exactly +one frame, dispatches it, writes exactly one frame back, and lets the +connection close — there is no request-ID multiplexing because there is never +more than one logical request in flight per connection. + +Default path: `~/.failproofai/run/failproofaid.sock`. Overridable via the +`FAILPROOFAI_DAEMON_SOCKET` env var (used by local dev and tests — never point +this at a directory failproofaid doesn't own itself; see +`crates/failproofaid/src/paths.rs`'s `ensure_run_dir`, which refuses to modify +permissions on a pre-existing directory it didn't create). + +Permissions: the run directory is `0700` and the socket file `0600` — this is +the actual access-control boundary (user-scope only, no elevation, same OS +user only). `crates/fpai-ipc/src/peer.rs`'s `SO_PEERCRED` (Linux) / +`getpeereid` (macOS) check is defense-in-depth on top of that, not a stronger +boundary — same-user access can always reach this daemon regardless. + +## Framing + +Every message, in both directions, is: + +``` ++----------------------------+------------------------------+ +| length (4 bytes, big-endian u32) | UTF-8 JSON body (length bytes) | ++----------------------------+------------------------------+ +``` + +A declared length over `MAX_FRAME_LEN` (16 MiB) is rejected without +allocating a body buffer for it — a hook payload is at most 1 MiB (see +`handler.ts`'s own stdin cap), so 16 MiB is headroom, not an expected size. + +## Envelope + +Tagged JSON, `"type"` as the discriminant, camelCase field names. + +### Client → daemon (`ClientMessage`) + +```jsonc +// Liveness/handshake check. +{ "type": "ping", "protocolVersion": 1 } + +// One hook evaluation request — one per `failproofai --hook --cli ` invocation. +{ + "type": "hook", + "protocolVersion": 1, + "hookEvent": "PreToolUse", + "cli": "claude", + "stdin": "", + "cwd": "/path/to/session/cwd" // optional; see the note below +} +``` + +`cwd` must be the *originating* CLI process's cwd, captured by the thin +client before dispatch — never the daemon's own cwd. The daemon is a single +long-lived process; its own `cwd` does not vary per request and must never be +used to resolve project config or custom policies (this is the "process.cwd() +hazard" the TS-side plan calls out explicitly). + +### Daemon → client (`ServerMessage`) + +```jsonc +{ "type": "pong", "protocolVersion": 1 } + +{ + "type": "hookResult", + "protocolVersion": 1, + "exitCode": 0, + "stdout": "...", + "stderr": "..." +} + +// The daemon accepted the connection and parsed the request, but could not +// produce a verdict (worker down/hung, or — in Stage 2 — hook evaluation +// simply isn't wired up yet). Distinct from hookResult so the client can +// tell "ran and decided" apart from "daemon couldn't evaluate at all" — the +// latter is what drives the client's fail-closed path. +{ "type": "error", "protocolVersion": 1, "message": "..." } +``` + +## Protocol versioning + +`protocolVersion` is carried on every message in both directions. A mismatch +gets an explicit `error` response from the daemon — the client treats *any* +failure mode (missing socket, refused connection, timeout, malformed +response, or an explicit version mismatch) identically: fall through to +whatever the client's fail-closed/in-process policy dictates. There is no +negotiation, only agree-or-fall-back. + +## Peer verification + +Every connection is checked with `SO_PEERCRED`/`getpeereid` before a single +byte of the request is read. A peer running as a different OS user gets the +connection dropped with **no response at all** — not even an `error` frame — +so a connection from the wrong user can't even confirm a daemon is listening +there. + +## Malformed input handling + +- A frame whose declared length exceeds `MAX_FRAME_LEN`: connection closed, + no response, no allocation attempted for the declared size. +- A syntactically invalid or truncated frame: connection closed, no response. +- A well-formed frame with an unrecognized `"type"`: fails to deserialize, + same as above. + +None of these crash the daemon or the connection-handling thread — a bad +frame from one connection has no effect on any other connection. diff --git a/crates/failproofaid/Cargo.toml b/crates/failproofaid/Cargo.toml new file mode 100644 index 00000000..c28cf8b5 --- /dev/null +++ b/crates/failproofaid/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "failproofaid" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +description = "Thin Rust supervisor: owns the failproofai IPC socket, service lifecycle, and warm worker process supervision. Carries no policy logic." +publish = false + +[[bin]] +name = "failproofaid" +path = "src/main.rs" + +[dependencies] +fpai-ipc = { path = "../fpai-ipc" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +libc = "0.2" diff --git a/crates/failproofaid/src/lock.rs b/crates/failproofaid/src/lock.rs new file mode 100644 index 00000000..f9acb261 --- /dev/null +++ b/crates/failproofaid/src/lock.rs @@ -0,0 +1,81 @@ +//! Single-instance guard: at most one `failproofaid` per OS user. +//! +//! Uses an advisory `flock()` on a dedicated lock file rather than a +//! PID file — a PID file has to be checked-then-trusted (the PID could +//! have been reused by an unrelated process since), whereas `flock` is +//! released automatically by the kernel when the holding process exits or +//! is killed, for any reason, with no stale-file cleanup required. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::unix::io::AsRawFd; +use std::path::Path; + +pub struct SingletonLock { + // Held for the guard's lifetime; the flock is released when this File + // (and its underlying fd) is dropped. + _file: File, +} + +#[derive(Debug)] +pub enum LockError { + Io(io::Error), + AlreadyRunning, +} + +impl std::fmt::Display for LockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LockError::Io(e) => write!(f, "io error acquiring daemon lock: {e}"), + LockError::AlreadyRunning => { + write!(f, "another failproofaid is already running for this user") + } + } + } +} + +impl std::error::Error for LockError {} + +/// Tries to acquire the singleton lock at `path`, creating the file if +/// needed. Returns [`LockError::AlreadyRunning`] immediately (non-blocking) +/// if another live process already holds it. +pub fn acquire(path: &Path) -> Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(LockError::Io)?; + + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if ret != 0 { + let err = io::Error::last_os_error(); + return match err.raw_os_error() { + Some(libc::EWOULDBLOCK) => Err(LockError::AlreadyRunning), + _ => Err(LockError::Io(err)), + }; + } + Ok(SingletonLock { _file: file }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_second_acquire_in_the_same_process_fails_while_the_first_is_held() { + let tmp = std::env::temp_dir().join(format!( + "failproofaid-lock-test-{}-{}", + std::process::id(), + line!() + )); + let first = acquire(&tmp).expect("first acquire should succeed"); + let second = acquire(&tmp); + assert!(matches!(second, Err(LockError::AlreadyRunning))); + drop(first); + // Once released, a fresh acquire succeeds again. + let third = acquire(&tmp); + assert!(third.is_ok()); + std::fs::remove_file(&tmp).ok(); + } +} diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs new file mode 100644 index 00000000..e783c4e0 --- /dev/null +++ b/crates/failproofaid/src/main.rs @@ -0,0 +1,67 @@ +mod lock; +mod paths; +mod server; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--version" || a == "-v") { + println!("failproofaid {}", env!("CARGO_PKG_VERSION")); + return; + } + + if let Err(err) = run() { + eprintln!("[failproofaid] {err}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let lock_path = paths::lock_path()?; + paths::ensure_run_dir()?; + let _singleton = lock::acquire(&lock_path)?; + + let socket_path = paths::socket_path()?; + let srv = server::Server::bind(&socket_path)?; + eprintln!("[failproofaid] listening on {}", socket_path.display()); + + let shutdown = Arc::new(AtomicBool::new(false)); + install_signal_handler(shutdown.clone()); + + srv.run_until(shutdown)?; + Ok(()) +} + +/// Requests a clean shutdown (socket file removal, lock release via Drop) +/// on SIGTERM/SIGINT instead of dying mid-accept-loop — this is how a +/// systemd `stop`/launchd unload is expected to end the process. +fn install_signal_handler(shutdown: Arc) { + static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false); + + extern "C" fn handle_signal(_sig: libc::c_int) { + SHUTDOWN_REQUESTED.store(true, Ordering::Relaxed); + } + + unsafe { + libc::signal( + libc::SIGTERM, + handle_signal as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGINT, + handle_signal as *const () as libc::sighandler_t, + ); + } + + std::thread::spawn(move || { + loop { + if SHUTDOWN_REQUESTED.load(Ordering::Relaxed) { + shutdown.store(true, Ordering::Relaxed); + return; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + }); +} diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs new file mode 100644 index 00000000..137443f1 --- /dev/null +++ b/crates/failproofaid/src/paths.rs @@ -0,0 +1,160 @@ +//! Resolves where failproofaid's runtime state lives on disk. +//! +//! User-scope only (per the plan: no elevation, everything under the +//! invoking user's home directory) — Linux and macOS only, matching the +//! rest of this crate. + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +/// `~/.failproofai/run` — directory holding the socket and singleton lock +/// file. Overridable via `FAILPROOFAI_DAEMON_SOCKET`'s parent for local dev +/// (see `run_dir_override`), so a `bun run daemon:dev` loop never touches a +/// real installed daemon's state. +pub fn run_dir() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + let path = PathBuf::from(socket_override); + return path + .parent() + .map(PathBuf::from) + .ok_or_else(|| io::Error::other("FAILPROOFAI_DAEMON_SOCKET has no parent directory")); + } + let home = std::env::var_os("HOME") + .ok_or_else(|| io::Error::other("HOME is not set; failproofaid is user-scope only"))?; + Ok(PathBuf::from(home).join(".failproofai").join("run")) +} + +pub fn socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_DAEMON_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("failproofaid.sock")) +} + +pub fn lock_path() -> io::Result { + Ok(run_dir()?.join("failproofaid.lock")) +} + +/// Creates the run directory (`0700`) if it doesn't exist yet. This +/// directory holds a socket that evaluates security-relevant decisions, so +/// a freshly created one is always locked to owner-only. +/// +/// If the directory already exists, this deliberately does **not** chmod +/// it into shape — only a directory failproofaid created itself gets its +/// permissions enforced. `FAILPROOFAI_DAEMON_SOCKET` is a raw path (dev/test +/// override; see `run_dir`), and blindly tightening permissions on whatever +/// pre-existing directory its parent happens to resolve to would let a +/// misconfigured override silently reach out and chmod an unrelated shared +/// directory (worst case, something like `/tmp` itself). Failing loudly is +/// the safe default; a real deployment's `~/.failproofai/run` is always +/// failproofaid's own directory and will simply be created fresh the first +/// time, taking the safe branch below. +pub fn ensure_run_dir() -> io::Result { + let dir = run_dir()?; + if dir.exists() { + let mode = fs::metadata(&dir)?.permissions().mode() & 0o777; + if mode != 0o700 { + return Err(io::Error::other(format!( + "run directory {} already exists with permissions {:o} (expected 0700) — \ + refusing to modify a directory failproofaid did not create itself", + dir.display(), + mode + ))); + } + return Ok(dir); + } + fs::create_dir_all(&dir)?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?; + Ok(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // std::env::set_var affects the whole process, so these tests must not + // run concurrently with each other or with other tests reading these + // vars. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn socket_override_takes_precedence_over_home() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", "/tmp/example/daemon.sock"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/tmp/example/daemon.sock") + ); + assert_eq!(run_dir().unwrap(), PathBuf::from("/tmp/example")); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn default_socket_path_lives_under_home_dot_failproofai_run() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + std::env::set_var("HOME", "/home/example-user"); + } + assert_eq!( + socket_path().unwrap(), + PathBuf::from("/home/example-user/.failproofai/run/failproofaid.sock") + ); + } + + #[test] + fn ensure_run_dir_creates_it_with_owner_only_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = + std::env::temp_dir().join(format!("failproofaid-paths-test-{}", std::process::id())); + unsafe { + std::env::set_var( + "FAILPROOFAI_DAEMON_SOCKET", + tmp.join("run").join("failproofaid.sock"), + ); + } + let dir = ensure_run_dir().unwrap(); + let mode = fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } + + #[test] + fn ensure_run_dir_refuses_to_touch_a_preexisting_directory_with_the_wrong_permissions() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = std::env::temp_dir().join(format!( + "failproofaid-paths-test-preexisting-{}", + std::process::id() + )); + // Simulate FAILPROOFAI_DAEMON_SOCKET being pointed at some + // unrelated, already-existing directory (e.g. a misconfigured + // override resolving to a shared temp dir) — this must error + // instead of silently chmod-ing a directory failproofaid doesn't + // own. + fs::create_dir_all(&tmp).unwrap(); + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755)).unwrap(); + unsafe { + std::env::set_var("FAILPROOFAI_DAEMON_SOCKET", tmp.join("failproofaid.sock")); + } + + let result = ensure_run_dir(); + assert!(result.is_err(), "expected an error, got {result:?}"); + let mode_after = fs::metadata(&tmp).unwrap().permissions().mode() & 0o777; + assert_eq!(mode_after, 0o755, "permissions must be left untouched"); + + fs::remove_dir_all(&tmp).ok(); + unsafe { + std::env::remove_var("FAILPROOFAI_DAEMON_SOCKET"); + } + } +} diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs new file mode 100644 index 00000000..809cea35 --- /dev/null +++ b/crates/failproofaid/src/server.rs @@ -0,0 +1,308 @@ +//! The Unix-socket server: bind, accept, verify the peer, dispatch one +//! request per connection. +//! +//! Stage 2 scope only — `Hook` requests get an `Error` response saying so. +//! Wiring `Hook` up to a real warm Node/Bun worker is Stage 3's job (see +//! the plan's suggested implementation sequence); this stage proves the +//! socket, the framing, the protocol-version handshake, and peer +//! verification end to end with nothing downstream to depend on yet. + +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, peer, read_message, write_message}; +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +pub struct Server { + listener: UnixListener, + socket_path: PathBuf, +} + +impl Server { + /// Binds a fresh listener at `socket_path`, replacing a stale socket + /// file left behind by a process that didn't shut down cleanly (a live + /// daemon is never listening on a leftover file — the singleton lock in + /// `lock.rs` is what actually prevents two daemons; this only clears + /// the debris of one that's already gone). + pub fn bind(socket_path: &Path) -> io::Result { + if socket_path.exists() { + fs::remove_file(socket_path)?; + } + let listener = UnixListener::bind(socket_path)?; + fs::set_permissions(socket_path, fs::Permissions::from_mode(0o600))?; + Ok(Server { + listener, + socket_path: socket_path.to_path_buf(), + }) + } + + /// Accepts and handles connections, one thread per connection, until + /// `shutdown` is set to `true`. A short accept timeout keeps the loop + /// polling `shutdown` instead of blocking forever in `accept()`, which + /// is what lets tests stop a server cleanly instead of leaking a + /// blocked thread for the rest of the test process's life. + pub fn run_until(&self, shutdown: Arc) -> io::Result<()> { + self.listener.set_nonblocking(true)?; + while !shutdown.load(Ordering::Relaxed) { + match self.listener.accept() { + Ok((stream, _addr)) => { + std::thread::spawn(move || { + if let Err(err) = handle_connection(stream) { + log_connection_error(&err); + } + }); + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + Err(err) => return Err(err), + } + } + Ok(()) + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + } +} + +fn log_connection_error(err: &io::Error) { + eprintln!("[failproofaid] connection error: {err}"); +} + +/// Handles exactly one request on `stream`: verify the peer is the same OS +/// user, read one framed [`ClientMessage`], dispatch it, write back one +/// framed [`ServerMessage`], then let the connection close. +pub fn handle_connection(stream: UnixStream) -> io::Result<()> { + match peer::is_same_user(&stream) { + Ok(true) => {} + Ok(false) => { + // Different OS user: drop the connection with no response at + // all, rather than an Error message that would confirm a + // daemon is listening here to a peer that has no business + // asking. + return Ok(()); + } + Err(err) => return Err(err), + } + + let mut reader = stream.try_clone()?; + let mut writer = stream; + + let request: ClientMessage = match read_message(&mut reader) { + Ok(msg) => msg, + Err(_) => return Ok(()), // malformed frame: nothing to respond to, nothing to act on + }; + + let response = dispatch(request); + write_message(&mut writer, &response) + .map_err(|e| io::Error::other(format!("failed to write response: {e}"))) +} + +fn dispatch(request: ClientMessage) -> ServerMessage { + if request.protocol_version() != PROTOCOL_VERSION { + return ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!( + "protocol version mismatch: daemon speaks {PROTOCOL_VERSION}, client sent {}", + request.protocol_version() + ), + }; + } + + match request { + ClientMessage::Ping { .. } => ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION, + }, + ClientMessage::Hook { .. } => ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: "hook evaluation is not wired up in this daemon build yet".to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use std::time::Duration; + + fn temp_socket_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "failproofaid-server-test-{}-{}-{}", + std::process::id(), + name, + fastrand_ish() + )) + } + + // Avoids pulling in a `rand` dependency just to de-collide temp socket + // paths across tests running in parallel threads. + fn fastrand_ish() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + fn start_test_server(socket_path: PathBuf) -> (Arc, std::thread::JoinHandle<()>) { + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = shutdown.clone(); + let handle = std::thread::spawn(move || { + let server = Server::bind(&socket_path).expect("bind should succeed"); + server + .run_until(shutdown_clone) + .expect("run_until should not error"); + }); + // Give the background thread a moment to actually bind before the + // test tries to connect. + std::thread::sleep(Duration::from_millis(50)); + (shutdown, handle) + } + + #[test] + fn ping_gets_pong() { + let socket_path = temp_socket_path("ping"); + let (shutdown, handle) = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + } + + #[test] + fn hook_request_gets_a_not_yet_implemented_error_in_this_stage() { + let socket_path = temp_socket_path("hook-stub"); + let (shutdown, handle) = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin: "{}".to_string(), + cwd: None, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { .. } => {} + other => panic!("expected Error, got {other:?}"), + } + + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + } + + #[test] + fn mismatched_protocol_version_gets_an_explicit_error() { + let socket_path = temp_socket_path("version-mismatch"); + let (shutdown, handle) = start_test_server(socket_path.clone()); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION + 999, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::Error { message, .. } => { + assert!(message.contains("version")); + } + other => panic!("expected Error, got {other:?}"), + } + + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + } + + #[test] + fn bind_replaces_a_stale_socket_file() { + let socket_path = temp_socket_path("stale"); + // Simulate a leftover file from a crashed daemon: not even a valid + // socket, just a regular file at that path. + std::fs::write(&socket_path, b"not a socket").unwrap(); + + let server = Server::bind(&socket_path).expect("bind should clear the stale file"); + drop(server); + assert!( + !socket_path.exists(), + "Drop should clean up the socket file" + ); + } + + #[test] + fn bound_socket_file_is_owner_only() { + let socket_path = temp_socket_path("perms"); + let server = Server::bind(&socket_path).unwrap(); + let mode = std::fs::metadata(&socket_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + drop(server); + } + + #[test] + fn multiple_concurrent_pings_all_get_answered() { + let socket_path = temp_socket_path("concurrent"); + let (shutdown, handle) = start_test_server(socket_path.clone()); + + let clients: Vec<_> = (0..8) + .map(|_| { + let path = socket_path.clone(); + std::thread::spawn(move || { + let mut stream = UnixStream::connect(&path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + }) + }) + .collect(); + for c in clients { + c.join().unwrap(); + } + + shutdown.store(true, Ordering::Relaxed); + handle.join().unwrap(); + } +} diff --git a/crates/failproofaid/tests/daemon_e2e.rs b/crates/failproofaid/tests/daemon_e2e.rs new file mode 100644 index 00000000..ee95fb48 --- /dev/null +++ b/crates/failproofaid/tests/daemon_e2e.rs @@ -0,0 +1,179 @@ +//! Black-box tests that spawn the real compiled `failproofaid` binary as a +//! subprocess and talk to it over a real Unix socket — closer to how a +//! systemd unit / launchd agent will actually invoke it (Stage 4) than the +//! in-process unit tests in `src/server.rs` are. + +use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, read_message, write_message}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +fn binary_path() -> &'static str { + env!("CARGO_BIN_EXE_failproofaid") +} + +/// A dedicated, never-before-existing run directory per test — mirroring a +/// real deployment, where `~/.failproofai/run` is always failproofaid's own +/// directory that it creates itself. `ensure_run_dir` (see `src/paths.rs`) +/// deliberately refuses to chmod a pre-existing directory it didn't create, +/// so reusing a shared directory like the bare system temp dir here would +/// make every test in this file fail with a permissions error instead of +/// exercising the daemon at all. +fn unique_socket_path(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!( + "failproofaid-e2e-{}-{}-{}", + std::process::id(), + name, + nanos + )) + .join("failproofaid.sock") +} + +fn spawn_daemon(socket_path: &PathBuf) -> Child { + let child = Command::new(binary_path()) + .env("FAILPROOFAI_DAEMON_SOCKET", socket_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn failproofaid binary"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !socket_path.exists() { + if Instant::now() > deadline { + panic!("daemon never created its socket file within 5s"); + } + std::thread::sleep(Duration::from_millis(20)); + } + child +} + +#[test] +fn version_flag_prints_a_version_and_exits_without_binding_a_socket() { + let output = Command::new(binary_path()) + .arg("--version") + .output() + .expect("failed to run --version"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.starts_with("failproofaid "), "got: {stdout:?}"); +} + +#[test] +fn real_binary_answers_ping_over_a_real_socket() { + let socket_path = unique_socket_path("ping"); + let mut child = spawn_daemon(&socket_path); + + let mut stream = UnixStream::connect(&socket_path).expect("connect should succeed"); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + + unsafe { + libc::kill(child.id() as libc::pid_t, libc::SIGTERM); + } + let status = child.wait().expect("daemon should exit after SIGTERM"); + assert!( + status.success(), + "daemon should exit 0 on SIGTERM, got {status:?}" + ); + assert!( + !socket_path.exists(), + "daemon should remove its socket file on clean shutdown" + ); +} + +#[test] +fn a_second_daemon_on_the_same_socket_refuses_to_start() { + let socket_path = unique_socket_path("singleton"); + let mut first = spawn_daemon(&socket_path); + + let second_output = Command::new(binary_path()) + .env("FAILPROOFAI_DAEMON_SOCKET", &socket_path) + .output() + .expect("failed to run second instance"); + assert!( + !second_output.status.success(), + "a second daemon on the same socket must not exit 0" + ); + let stderr = String::from_utf8_lossy(&second_output.stderr); + assert!( + stderr.contains("already running"), + "expected an 'already running' message, got: {stderr:?}" + ); + + // First instance is still healthy and answers requests after the + // second one's failed startup attempt. + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + + unsafe { + libc::kill(first.id() as libc::pid_t, libc::SIGTERM); + } + first.wait().ok(); +} + +#[test] +fn a_fresh_daemon_can_bind_again_after_the_previous_one_shut_down_cleanly() { + let socket_path = unique_socket_path("restart"); + + let mut first = spawn_daemon(&socket_path); + unsafe { + libc::kill(first.id() as libc::pid_t, libc::SIGTERM); + } + let status = first.wait().unwrap(); + assert!(status.success()); + + // The lock is released by the kernel when the process exits, so a + // fresh daemon must be able to acquire it and bind immediately. + let mut second = spawn_daemon(&socket_path); + let mut stream = UnixStream::connect(&socket_path).unwrap(); + write_message( + &mut stream, + &ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + assert_eq!( + response, + ServerMessage::Pong { + protocol_version: PROTOCOL_VERSION + } + ); + + unsafe { + libc::kill(second.id() as libc::pid_t, libc::SIGTERM); + } + second.wait().ok(); +} diff --git a/crates/fpai-ipc/Cargo.toml b/crates/fpai-ipc/Cargo.toml new file mode 100644 index 00000000..ae4797e3 --- /dev/null +++ b/crates/fpai-ipc/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fpai-ipc" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +description = "Wire protocol (framing + envelope + peer verification) shared by failproofaid and its tests" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +libc = "0.2" + +[dev-dependencies] +proptest = "1" diff --git a/crates/fpai-ipc/src/envelope.rs b/crates/fpai-ipc/src/envelope.rs new file mode 100644 index 00000000..55f86727 --- /dev/null +++ b/crates/fpai-ipc/src/envelope.rs @@ -0,0 +1,175 @@ +//! The JSON envelope carried inside each [`crate::framing`] frame. +//! +//! This mirrors the `(argv --hook --cli , stdin JSON payload) +//! -> (stdout, stderr, exitCode)` contract every hook invocation already +//! has today — see `src/hooks/handler.ts`'s `handleHookEvent`. The daemon +//! adds nothing to that contract; it only relays it over a socket instead +//! of a fresh process's argv/stdin/stdout. + +use serde::{Deserialize, Serialize}; + +/// Bumped whenever a wire-incompatible change is made to either message +/// enum below. The client treats any mismatch identically to "daemon +/// unreachable" (see `docs/configuration.mdx` and the failproofaid plan) — +/// there is no negotiation, only agree-or-fall-back. +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ClientMessage { + /// Cheap liveness/handshake check — used by tests and by a client that + /// only wants to confirm a compatible daemon is listening. + #[serde(rename_all = "camelCase")] + Ping { protocol_version: u32 }, + /// One hook evaluation request, corresponding 1:1 to one + /// `failproofai --hook --cli ` invocation. + #[serde(rename_all = "camelCase")] + Hook { + protocol_version: u32, + hook_event: String, + cli: String, + /// The raw stdin payload the calling agent CLI wrote to the + /// one-shot `failproofai` process — forwarded verbatim. + stdin: String, + /// Best-effort cwd of the *originating* CLI process, not the + /// daemon's own cwd (the daemon is a single long-lived process; its + /// own `cwd` does not vary per request and must never be used to + /// resolve project config or custom policies). + cwd: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ServerMessage { + #[serde(rename_all = "camelCase")] + Pong { protocol_version: u32 }, + #[serde(rename_all = "camelCase")] + HookResult { + protocol_version: u32, + exit_code: i32, + stdout: String, + stderr: String, + }, + /// The daemon accepted the connection and parsed the request, but could + /// not produce a verdict (e.g. the worker is down/hung). Distinct from + /// `HookResult` so a client can tell "ran and decided" apart from + /// "daemon couldn't evaluate at all" — the latter is what drives the + /// client's fail-closed path. + #[serde(rename_all = "camelCase")] + Error { + protocol_version: u32, + message: String, + }, +} + +impl ClientMessage { + pub fn protocol_version(&self) -> u32 { + match self { + ClientMessage::Ping { protocol_version } => *protocol_version, + ClientMessage::Hook { + protocol_version, .. + } => *protocol_version, + } + } +} + +impl ServerMessage { + pub fn protocol_version(&self) -> u32 { + match self { + ServerMessage::Pong { protocol_version } => *protocol_version, + ServerMessage::HookResult { + protocol_version, .. + } => *protocol_version, + ServerMessage::Error { + protocol_version, .. + } => *protocol_version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ping_serializes_with_a_type_tag() { + let msg = ClientMessage::Ping { + protocol_version: PROTOCOL_VERSION, + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "ping"); + assert_eq!(json["protocolVersion"], PROTOCOL_VERSION); + } + + #[test] + fn hook_request_uses_camel_case_field_names_on_the_wire() { + let msg = ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin: "{}".to_string(), + cwd: Some("/repo".to_string()), + }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "hook"); + assert_eq!(json["hookEvent"], "PreToolUse"); + assert_eq!(json["cli"], "claude"); + assert_eq!(json["cwd"], "/repo"); + } + + #[test] + fn hook_request_cwd_is_optional() { + let json = serde_json::json!({ + "type": "hook", + "protocolVersion": 1, + "hookEvent": "Stop", + "cli": "codex", + "stdin": "{}" + }); + let msg: ClientMessage = serde_json::from_value(json).unwrap(); + match msg { + ClientMessage::Hook { cwd, .. } => assert_eq!(cwd, None), + _ => panic!("expected Hook variant"), + } + } + + #[test] + fn unknown_message_type_fails_to_deserialize() { + let json = serde_json::json!({ "type": "bogus", "protocolVersion": 1 }); + let result: Result = serde_json::from_value(json); + assert!(result.is_err()); + } + + #[test] + fn server_hook_result_round_trips() { + let msg = ServerMessage::HookResult { + protocol_version: PROTOCOL_VERSION, + exit_code: 2, + stdout: String::new(), + stderr: "blocked".to_string(), + }; + let json = serde_json::to_string(&msg).unwrap(); + let decoded: ServerMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn protocol_version_accessor_matches_every_variant() { + assert_eq!( + ClientMessage::Ping { + protocol_version: 7 + } + .protocol_version(), + 7 + ); + assert_eq!( + ServerMessage::Error { + protocol_version: 9, + message: "x".to_string() + } + .protocol_version(), + 9 + ); + } +} diff --git a/crates/fpai-ipc/src/framing.rs b/crates/fpai-ipc/src/framing.rs new file mode 100644 index 00000000..c7f24ba8 --- /dev/null +++ b/crates/fpai-ipc/src/framing.rs @@ -0,0 +1,177 @@ +//! Length-prefixed JSON framing shared by the daemon and every client of it. +//! +//! Frame = 4-byte big-endian `u32` byte length, followed by that many bytes +//! of UTF-8 JSON. One frame in, one frame out, per connection — the daemon +//! never keeps a connection open across multiple logical requests, so there +//! is no need for a request-id-multiplexed protocol. + +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::io::{self, Read, Write}; + +/// Hard cap on a single frame's declared length. A hook payload is at most +/// 1 MiB (see `handler.ts`'s own stdin cap); 16 MiB leaves headroom without +/// letting a corrupt or hostile length prefix trigger an unbounded +/// allocation before a single byte of the body has even been read. +pub const MAX_FRAME_LEN: u32 = 16 * 1024 * 1024; + +#[derive(Debug)] +pub enum FramingError { + Io(io::Error), + FrameTooLarge { declared: u32, max: u32 }, + Json(serde_json::Error), +} + +impl std::fmt::Display for FramingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FramingError::Io(e) => write!(f, "io error: {e}"), + FramingError::FrameTooLarge { declared, max } => { + write!(f, "frame length {declared} exceeds max {max}") + } + FramingError::Json(e) => write!(f, "invalid JSON frame: {e}"), + } + } +} + +impl std::error::Error for FramingError {} + +impl From for FramingError { + fn from(e: io::Error) -> Self { + FramingError::Io(e) + } +} + +impl From for FramingError { + fn from(e: serde_json::Error) -> Self { + FramingError::Json(e) + } +} + +/// Serializes `value` to JSON and writes it as one length-prefixed frame. +pub fn write_message( + writer: &mut W, + value: &T, +) -> Result<(), FramingError> { + let body = serde_json::to_vec(value)?; + let len = u32::try_from(body.len()).map_err(|_| FramingError::FrameTooLarge { + declared: u32::MAX, + max: MAX_FRAME_LEN, + })?; + if len > MAX_FRAME_LEN { + return Err(FramingError::FrameTooLarge { + declared: len, + max: MAX_FRAME_LEN, + }); + } + writer.write_all(&len.to_be_bytes())?; + writer.write_all(&body)?; + writer.flush()?; + Ok(()) +} + +/// Reads one length-prefixed frame and deserializes it as `T`. Rejects the +/// frame (without allocating a body buffer) if the declared length exceeds +/// [`MAX_FRAME_LEN`], so a corrupt or adversarial length prefix can't be +/// used to force a large allocation. +pub fn read_message(reader: &mut R) -> Result { + let mut len_bytes = [0u8; 4]; + reader.read_exact(&mut len_bytes)?; + let len = u32::from_be_bytes(len_bytes); + if len > MAX_FRAME_LEN { + return Err(FramingError::FrameTooLarge { + declared: len, + max: MAX_FRAME_LEN, + }); + } + let mut body = vec![0u8; len as usize]; + reader.read_exact(&mut body)?; + let value = serde_json::from_slice(&body)?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + use std::io::Cursor; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct Sample { + a: u32, + b: String, + } + + #[test] + fn round_trips_a_simple_value() { + let mut buf = Vec::new(); + let msg = Sample { + a: 42, + b: "hello".to_string(), + }; + write_message(&mut buf, &msg).unwrap(); + let mut cursor = Cursor::new(buf); + let decoded: Sample = read_message(&mut cursor).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn round_trips_empty_string_fields() { + let mut buf = Vec::new(); + let msg = Sample { + a: 0, + b: String::new(), + }; + write_message(&mut buf, &msg).unwrap(); + let mut cursor = Cursor::new(buf); + let decoded: Sample = read_message(&mut cursor).unwrap(); + assert_eq!(decoded, msg); + } + + #[test] + fn rejects_a_declared_length_over_the_cap_without_reading_a_body() { + let mut buf = Vec::new(); + buf.extend_from_slice(&(MAX_FRAME_LEN + 1).to_be_bytes()); + // Deliberately no body bytes follow — if `read_message` tried to + // allocate/read the declared length before checking the cap, this + // would hang or panic on the short read instead of erroring cleanly. + let mut cursor = Cursor::new(buf); + let result: Result = read_message(&mut cursor); + assert!(matches!(result, Err(FramingError::FrameTooLarge { .. }))); + } + + #[test] + fn refuses_to_write_a_frame_over_the_cap() { + #[derive(Serialize)] + struct Big { + data: String, + } + let big = Big { + data: "x".repeat((MAX_FRAME_LEN as usize) + 1), + }; + let mut buf = Vec::new(); + let result = write_message(&mut buf, &big); + assert!(matches!(result, Err(FramingError::FrameTooLarge { .. }))); + } + + #[test] + fn rejects_truncated_frames() { + let mut buf = Vec::new(); + buf.extend_from_slice(&10u32.to_be_bytes()); + buf.extend_from_slice(b"short"); // fewer than the declared 10 bytes + let mut cursor = Cursor::new(buf); + let result: Result = read_message(&mut cursor); + assert!(matches!(result, Err(FramingError::Io(_)))); + } + + #[test] + fn rejects_malformed_json_inside_a_valid_frame() { + let mut buf = Vec::new(); + let body = b"not json"; + buf.extend_from_slice(&(body.len() as u32).to_be_bytes()); + buf.extend_from_slice(body); + let mut cursor = Cursor::new(buf); + let result: Result = read_message(&mut cursor); + assert!(matches!(result, Err(FramingError::Json(_)))); + } +} diff --git a/crates/fpai-ipc/src/lib.rs b/crates/fpai-ipc/src/lib.rs new file mode 100644 index 00000000..3fc9aa0c --- /dev/null +++ b/crates/fpai-ipc/src/lib.rs @@ -0,0 +1,10 @@ +//! Wire protocol shared by `failproofaid` and every client/test that talks +//! to it: message framing, the JSON envelope, and Unix-socket peer +//! verification. + +pub mod envelope; +pub mod framing; +pub mod peer; + +pub use envelope::{ClientMessage, PROTOCOL_VERSION, ServerMessage}; +pub use framing::{FramingError, MAX_FRAME_LEN, read_message, write_message}; diff --git a/crates/fpai-ipc/src/peer.rs b/crates/fpai-ipc/src/peer.rs new file mode 100644 index 00000000..1ff61791 --- /dev/null +++ b/crates/fpai-ipc/src/peer.rs @@ -0,0 +1,80 @@ +//! Verifies that a Unix socket peer is running as the same OS user as this +//! process. +//! +//! failproofaid is user-scope only (see the plan: "no elevation, the daemon +//! spawns the worker because the daemon already runs as the user"): the +//! socket directory is `0700` and the socket file `0600`, which is the +//! actual access-control boundary. This check is a second, defense-in-depth +//! layer against the narrow window between a socket file existing and its +//! permissions having been fully applied, and against misconfigured +//! filesystems (e.g. a shared mount with unexpectedly loose permissions). +//! It is not a stronger security boundary than the filesystem permissions +//! already provide — same OS user can always reach this daemon regardless. + +use std::io; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::UnixStream; + +/// Returns the effective UID of the process on the other end of `stream`. +#[cfg(target_os = "linux")] +pub fn peer_uid(stream: &UnixStream) -> io::Result { + use std::mem; + + let fd = stream.as_raw_fd(); + let mut cred: libc::ucred = unsafe { mem::zeroed() }; + let mut len = mem::size_of::() as libc::socklen_t; + + let ret = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut libc::ucred as *mut libc::c_void, + &mut len, + ) + }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + Ok(cred.uid) +} + +/// Returns the effective UID of the process on the other end of `stream`. +#[cfg(target_os = "macos")] +pub fn peer_uid(stream: &UnixStream) -> io::Result { + let fd = stream.as_raw_fd(); + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + + let ret = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) }; + if ret != 0 { + return Err(io::Error::last_os_error()); + } + Ok(uid) +} + +/// Returns this process's own effective UID, for comparison against +/// [`peer_uid`]. +pub fn own_uid() -> u32 { + unsafe { libc::geteuid() } +} + +/// `true` if `stream`'s peer is running as this process's own OS user. +pub fn is_same_user(stream: &UnixStream) -> io::Result { + Ok(peer_uid(stream)? == own_uid()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_local_socketpair_peer_is_always_the_same_user() { + // socketpair() connects two ends of the same process — the peer + // UID must equal our own, on every CI runner regardless of who's + // executing the test. + let (a, _b) = UnixStream::pair().unwrap(); + assert!(is_same_user(&a).unwrap()); + assert_eq!(peer_uid(&a).unwrap(), own_uid()); + } +} From 515bff200ddfc97d6b9a388ae34f84d1aaefc046 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 14:19:59 +0530 Subject: [PATCH 03/17] [failproofaid] Wire the warm worker, TS daemon-client, and fail-closed path Stage 3: the full daemon-aware hook path, end to end. TypeScript side: - handler.ts: extract the core evaluation logic into evaluateHookEvent(), which takes the stdin payload as a parameter and returns {exitCode,stdout,stderr} instead of touching process.stdin/stdout directly -- callable repeatedly inside a long-lived worker process. handleHookEvent() keeps its exact existing signature/behavior as a thin wrapper, so the 1300+ line existing test suite needed zero changes. Adds a forceDecision option that registers a single synthetic policy and runs it through the real, unmodified evaluatePolicies() -- the fail-closed path gets correct per-CLI response shaping (Cursor's flat continue:false, Factory's exit-2, ...) for free, with no duplicated shaping logic. Also closes the process.cwd() hazard: a fallbackCwd option lets a warm worker use the *originating* CLI's cwd instead of its own fixed one when a payload omits cwd. - worker-server.ts + bin/failproofai-worker.mjs: the Node/Bun process failproofaid spawns. Listens on its own Unix socket (not stdio -- a stray console.log from a user's custom policy must never desync a shared framed channel), processing requests strictly sequentially so the globalThis-backed policy registry stays correct with zero changes. - daemon-client.ts: the thin client, real net.Server-tested framing matching crates/PROTOCOL.md exactly. ~150ms connect+roundtrip budget, null on any failure (no partial trust). isDaemonConfigured() gates the whole path on a new HooksConfig.daemonConfigured marker (global scope only) that nothing sets until Stage 4 -- inert on every machine today. - bin/failproofai.mjs: daemon-attempt-then-fail-closed wiring, byte-for- byte unchanged when not daemon-configured. - builtin-policies.ts: fixes the git-branch cache for a warm process -- it previously never actually cached anything (one-shot process), and reusing it unconditionally across many calls would silently serve a stale branch after a checkout. Now gated on .git/HEAD's mtime. Rust side: - worker.rs: spawns and supervises the worker, relays Hook requests to it, translating between the worker-facing protocol (no protocolVersion -- this process always spawns a version-matched worker) and the client-facing one (which does). - server.rs: Hook requests now relay through the worker instead of returning a stub error; any worker failure becomes a client Error response, never a hang. A live end-to-end test (real failproofaid, real bun-spawned worker, real block-sudo policy) surfaced a genuine process-leak bug during development: `sh -c "bun ..."` isn't guaranteed to exec(2) in place, so killing only the tracked PID could leave a live grandchild worker process orphaned -- reproduced live as three orphaned failproofai-worker.mjs processes still running minutes later. Fixed with process groups (spawn in a new group, kill the whole group) and piped rather than inherited stdio (an inherited fd on a worker that outlives its intended lifetime keeps a wrapping shell's pipe from ever seeing EOF). Test infrastructure also gained an RAII guard so a failing assertion cleans up its spawned worker exactly as reliably as a passing one. 29 Rust tests, all passing worker-server.ts/daemon-client.ts tests against real sockets (not mocks), full existing TS suite green. Co-Authored-By: Claude Sonnet 5 --- __tests__/hooks/builtin-policies.test.ts | 96 ++++++ __tests__/hooks/daemon-client.test.ts | 268 +++++++++++++++ __tests__/hooks/worker-server.test.ts | 207 ++++++++++++ bin/failproofai-worker.mjs | 51 +++ bin/failproofai.mjs | 41 +++ crates/failproofaid/src/main.rs | 8 +- crates/failproofaid/src/paths.rs | 12 + crates/failproofaid/src/server.rs | 210 ++++++++++-- crates/failproofaid/src/worker.rs | 255 ++++++++++++++ src/hooks/builtin-policies.ts | 50 ++- src/hooks/daemon-client.ts | 179 ++++++++++ src/hooks/handler.ts | 409 ++++++++++++----------- src/hooks/normalize-cli-payload.ts | 61 ++++ src/hooks/policy-types.ts | 15 + src/hooks/read-stdin.ts | 48 +++ src/hooks/worker-server.ts | 146 ++++++++ 16 files changed, 1813 insertions(+), 243 deletions(-) create mode 100644 __tests__/hooks/daemon-client.test.ts create mode 100644 __tests__/hooks/worker-server.test.ts create mode 100644 bin/failproofai-worker.mjs create mode 100644 crates/failproofaid/src/worker.rs create mode 100644 src/hooks/daemon-client.ts create mode 100644 src/hooks/normalize-cli-payload.ts create mode 100644 src/hooks/read-stdin.ts create mode 100644 src/hooks/worker-server.ts diff --git a/__tests__/hooks/builtin-policies.test.ts b/__tests__/hooks/builtin-policies.test.ts index 496f2732..d1ba0b4e 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"; @@ -3096,6 +3099,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/daemon-client.test.ts b/__tests__/hooks/daemon-client.test.ts new file mode 100644 index 00000000..b7f431f3 --- /dev/null +++ b/__tests__/hooks/daemon-client.test.ts @@ -0,0 +1,268 @@ +// @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("returns null and does not hang when the server never responds", async () => { + await startServer(async (socket) => { + await readFrame(socket); + // Deliberately never write a response. + }); + + 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(); + // Must hit (roughly) the attempt timeout, not hang indefinitely. + expect(elapsedMs).toBeGreaterThanOrEqual(140); + expect(elapsedMs).toBeLessThan(2000); + }); + + 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/worker-server.test.ts b/__tests__/hooks/worker-server.test.ts new file mode 100644 index 00000000..9c3eaeee --- /dev/null +++ b/__tests__/hooks/worker-server.test.ts @@ -0,0 +1,207 @@ +// @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 { 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())); + 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("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"); + }); +}); diff --git a/bin/failproofai-worker.mjs b/bin/failproofai-worker.mjs new file mode 100644 index 00000000..6ddc58b0 --- /dev/null +++ b/bin/failproofai-worker.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * failproofai-worker — the warm worker process failproofaid (the Rust + * supervisor) spawns and supervises. NOT a user-facing entry point (no + * "bin" entry in package.json) — the daemon always invokes this directly by + * path, either `node bin/failproofai-worker.mjs` (dev, via + * FAILPROOFAI_WORKER_CMD) or the built `dist/worker.mjs` in production. + * + * Reads FAILPROOFAI_WORKER_SOCKET for where to listen — the daemon resolves + * and passes this; the worker never guesses a path of its own. + */ +import { realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +if (!process.env.FAILPROOFAI_PACKAGE_ROOT) { + process.env.FAILPROOFAI_PACKAGE_ROOT = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + ".." + ); +} + +if (!process.env.FAILPROOFAI_DIST_PATH) { + process.env.FAILPROOFAI_DIST_PATH = resolve( + dirname(realpathSync(fileURLToPath(import.meta.url))), + "..", + "dist" + ); +} + +const socketPath = process.env.FAILPROOFAI_WORKER_SOCKET; +if (!socketPath) { + console.error("[failproofai-worker] FAILPROOFAI_WORKER_SOCKET is not set"); + process.exit(1); +} + +const { startWorkerServer } = await import("../src/hooks/worker-server"); +const server = startWorkerServer(socketPath); + +server.on("error", (err) => { + console.error(`[failproofai-worker] server error: ${err.message}`); + process.exit(1); +}); + +console.error(`[failproofai-worker] listening on ${socketPath}`); + +function shutdown() { + server.close(() => process.exit(0)); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/bin/failproofai.mjs b/bin/failproofai.mjs index eed99600..03562156 100755 --- a/bin/failproofai.mjs +++ b/bin/failproofai.mjs @@ -94,6 +94,47 @@ if (hookIdx >= 0) { ? cliArg : "claude"; try { + // Daemon-aware path — inert (and this whole block skipped) on every + // machine until `failproofai config` has installed failproofaid AND + // written the daemonConfigured marker (Stage 4). Until then this is + // byte-for-byte the same handleHookEvent(...) call below. + const { isDaemonConfigured, tryDaemonHook } = await import("../src/hooks/daemon-client"); + if (isDaemonConfigured()) { + const { readStdinPayload } = await import("../src/hooks/read-stdin"); + const { evaluateHookEvent } = await import("../src/hooks/handler"); + const stdinRead = await readStdinPayload(); + + const daemonResult = await tryDaemonHook({ + hookEvent: eventType, + cli, + stdin: stdinRead.payload, + // The client's own cwd IS the originating CLI session's cwd — this + // process is spawned fresh, at that location, by the calling agent + // CLI's own hook mechanism. See daemon-client.ts / PROTOCOL.md. + cwd: process.cwd(), + }); + + // No silent fallback to in-process evaluation here: this machine was + // explicitly configured to run through the daemon, so an unreachable + // daemon fails closed instead — a loud, correctly-shaped deny (real + // per-CLI response shaping, not a generic denial) rather than a + // silent downgrade. See the plan's "Confirmed scope decisions". + const result = + daemonResult ?? + (await evaluateHookEvent(eventType, cli, stdinRead.payload, { + forceDecision: { + decision: "deny", + reason: + "failproofaid could not be reached. This machine is configured to run hooks through it " + + "— check the daemon (see `failproofai config`) rather than retrying blindly.", + }, + })); + + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exit(result.exitCode); + } + const { handleHookEvent } = await import("../src/hooks/handler"); const exitCode = await handleHookEvent(eventType, cli); // handleHookEvent already flushes its own telemetry before returning; this diff --git a/crates/failproofaid/src/main.rs b/crates/failproofaid/src/main.rs index e783c4e0..468d38c2 100644 --- a/crates/failproofaid/src/main.rs +++ b/crates/failproofaid/src/main.rs @@ -1,6 +1,7 @@ mod lock; mod paths; mod server; +mod worker; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -24,7 +25,12 @@ fn run() -> Result<(), Box> { let _singleton = lock::acquire(&lock_path)?; let socket_path = paths::socket_path()?; - let srv = server::Server::bind(&socket_path)?; + let worker_socket_path = paths::worker_socket_path()?; + let worker = Arc::new(worker::Worker::new( + worker_socket_path, + worker::WorkerCommand::from_env(), + )); + let srv = server::Server::bind(&socket_path, worker)?; eprintln!("[failproofaid] listening on {}", socket_path.display()); let shutdown = Arc::new(AtomicBool::new(false)); diff --git a/crates/failproofaid/src/paths.rs b/crates/failproofaid/src/paths.rs index 137443f1..963bbae2 100644 --- a/crates/failproofaid/src/paths.rs +++ b/crates/failproofaid/src/paths.rs @@ -37,6 +37,18 @@ pub fn lock_path() -> io::Result { Ok(run_dir()?.join("failproofaid.lock")) } +/// Where the daemon tells the worker subprocess to listen — a second +/// socket, distinct from `socket_path()`, that only this process ever +/// connects to. Overridable via `FAILPROOFAI_WORKER_SOCKET` for local dev +/// (mirrors `FAILPROOFAI_DAEMON_SOCKET`'s override for the client-facing +/// socket). +pub fn worker_socket_path() -> io::Result { + if let Some(socket_override) = std::env::var_os("FAILPROOFAI_WORKER_SOCKET") { + return Ok(PathBuf::from(socket_override)); + } + Ok(run_dir()?.join("worker.sock")) +} + /// Creates the run directory (`0700`) if it doesn't exist yet. This /// directory holds a socket that evaluates security-relevant decisions, so /// a freshly created one is always locked to owner-only. diff --git a/crates/failproofaid/src/server.rs b/crates/failproofaid/src/server.rs index 809cea35..662981f0 100644 --- a/crates/failproofaid/src/server.rs +++ b/crates/failproofaid/src/server.rs @@ -1,12 +1,12 @@ //! The Unix-socket server: bind, accept, verify the peer, dispatch one //! request per connection. //! -//! Stage 2 scope only — `Hook` requests get an `Error` response saying so. -//! Wiring `Hook` up to a real warm Node/Bun worker is Stage 3's job (see -//! the plan's suggested implementation sequence); this stage proves the -//! socket, the framing, the protocol-version handshake, and peer -//! verification end to end with nothing downstream to depend on yet. +//! `Hook` requests are relayed to the warm worker (see `worker.rs`); `Ping` +//! is answered directly. Any failure to reach/use the worker becomes a +//! client-facing `Error` response — never a hang, never a crash of the +//! connection-handling thread. +use crate::worker::Worker; use fpai_ipc::{ClientMessage, PROTOCOL_VERSION, ServerMessage, peer, read_message, write_message}; use std::fs; use std::io; @@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; pub struct Server { listener: UnixListener, socket_path: PathBuf, + worker: Arc, } impl Server { @@ -27,7 +28,7 @@ impl Server { /// daemon is never listening on a leftover file — the singleton lock in /// `lock.rs` is what actually prevents two daemons; this only clears /// the debris of one that's already gone). - pub fn bind(socket_path: &Path) -> io::Result { + pub fn bind(socket_path: &Path, worker: Arc) -> io::Result { if socket_path.exists() { fs::remove_file(socket_path)?; } @@ -36,6 +37,7 @@ impl Server { Ok(Server { listener, socket_path: socket_path.to_path_buf(), + worker, }) } @@ -49,8 +51,9 @@ impl Server { while !shutdown.load(Ordering::Relaxed) { match self.listener.accept() { Ok((stream, _addr)) => { + let worker = self.worker.clone(); std::thread::spawn(move || { - if let Err(err) = handle_connection(stream) { + if let Err(err) = handle_connection(stream, &worker) { log_connection_error(&err); } }); @@ -78,7 +81,7 @@ fn log_connection_error(err: &io::Error) { /// Handles exactly one request on `stream`: verify the peer is the same OS /// user, read one framed [`ClientMessage`], dispatch it, write back one /// framed [`ServerMessage`], then let the connection close. -pub fn handle_connection(stream: UnixStream) -> io::Result<()> { +pub fn handle_connection(stream: UnixStream, worker: &Worker) -> io::Result<()> { match peer::is_same_user(&stream) { Ok(true) => {} Ok(false) => { @@ -99,12 +102,12 @@ pub fn handle_connection(stream: UnixStream) -> io::Result<()> { Err(_) => return Ok(()), // malformed frame: nothing to respond to, nothing to act on }; - let response = dispatch(request); + let response = dispatch(request, worker); write_message(&mut writer, &response) .map_err(|e| io::Error::other(format!("failed to write response: {e}"))) } -fn dispatch(request: ClientMessage) -> ServerMessage { +fn dispatch(request: ClientMessage, worker: &Worker) -> ServerMessage { if request.protocol_version() != PROTOCOL_VERSION { return ServerMessage::Error { protocol_version: PROTOCOL_VERSION, @@ -119,9 +122,23 @@ fn dispatch(request: ClientMessage) -> ServerMessage { ClientMessage::Ping { .. } => ServerMessage::Pong { protocol_version: PROTOCOL_VERSION, }, - ClientMessage::Hook { .. } => ServerMessage::Error { - protocol_version: PROTOCOL_VERSION, - message: "hook evaluation is not wired up in this daemon build yet".to_string(), + ClientMessage::Hook { + hook_event, + cli, + stdin, + cwd, + .. + } => match worker.call(&hook_event, &cli, &stdin, cwd.as_deref()) { + Ok(outcome) => ServerMessage::HookResult { + protocol_version: PROTOCOL_VERSION, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stderr: outcome.stderr, + }, + Err(err) => ServerMessage::Error { + protocol_version: PROTOCOL_VERSION, + message: format!("worker call failed: {err}"), + }, }, } } @@ -129,6 +146,7 @@ fn dispatch(request: ClientMessage) -> ServerMessage { #[cfg(test)] mod tests { use super::*; + use crate::worker::WorkerCommand; use std::sync::atomic::AtomicBool; use std::time::Duration; @@ -151,11 +169,48 @@ mod tests { .as_nanos() as u64 } - fn start_test_server(socket_path: PathBuf) -> (Arc, std::thread::JoinHandle<()>) { + /// A `WorkerCommand` that never produces a working worker — for tests + /// that don't care about Hook responses and just need *a* command + /// (Ping never touches the worker at all). + fn broken_worker_cmd() -> WorkerCommand { + WorkerCommand::shell("true") + } + + /// Owns a running test server + the worker it supervises. Shuts the + /// server down and joins its thread on drop — including during an + /// unwind from a failed `assert!`/`.unwrap()` — so a failing test + /// cleans up its spawned worker process (via `Worker::drop`'s + /// process-group kill, see worker.rs) exactly as reliably as a passing + /// one. Before this guard existed, a panic between `start_test_server` + /// and the manual `shutdown.store` + `handle.join()` at the end of a + /// test permanently orphaned the server thread and its live `bun` + /// worker subprocess — reproduced live: three orphaned + /// `failproofai-worker.mjs` processes were still running, minutes + /// later, from earlier iterations of this very test file. + struct TestServerGuard { + shutdown: Arc, + handle: Option>, + } + + impl Drop for TestServerGuard { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn start_test_server_with_worker( + socket_path: PathBuf, + worker_cmd: WorkerCommand, + ) -> TestServerGuard { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_clone = shutdown.clone(); + let worker_socket_path = temp_socket_path("worker-internal"); let handle = std::thread::spawn(move || { - let server = Server::bind(&socket_path).expect("bind should succeed"); + let worker = Arc::new(crate::worker::Worker::new(worker_socket_path, worker_cmd)); + let server = Server::bind(&socket_path, worker).expect("bind should succeed"); server .run_until(shutdown_clone) .expect("run_until should not error"); @@ -163,13 +218,20 @@ mod tests { // Give the background thread a moment to actually bind before the // test tries to connect. std::thread::sleep(Duration::from_millis(50)); - (shutdown, handle) + TestServerGuard { + shutdown, + handle: Some(handle), + } + } + + fn start_test_server(socket_path: PathBuf) -> TestServerGuard { + start_test_server_with_worker(socket_path, broken_worker_cmd()) } #[test] fn ping_gets_pong() { let socket_path = temp_socket_path("ping"); - let (shutdown, handle) = start_test_server(socket_path.clone()); + let _guard = start_test_server(socket_path.clone()); let mut stream = UnixStream::connect(&socket_path).unwrap(); write_message( @@ -186,15 +248,12 @@ mod tests { protocol_version: PROTOCOL_VERSION } ); - - shutdown.store(true, Ordering::Relaxed); - handle.join().unwrap(); } #[test] - fn hook_request_gets_a_not_yet_implemented_error_in_this_stage() { - let socket_path = temp_socket_path("hook-stub"); - let (shutdown, handle) = start_test_server(socket_path.clone()); + fn hook_request_gets_an_error_when_the_worker_cannot_be_reached() { + let socket_path = temp_socket_path("hook-broken-worker"); + let _guard = start_test_server(socket_path.clone()); let mut stream = UnixStream::connect(&socket_path).unwrap(); write_message( @@ -213,15 +272,94 @@ mod tests { ServerMessage::Error { .. } => {} other => panic!("expected Error, got {other:?}"), } + } - shutdown.store(true, Ordering::Relaxed); - handle.join().unwrap(); + /// The real end-to-end path: a real `failproofaid` server relaying a + /// real Hook request to the real TypeScript worker (spawned via `bun` + /// against this repo's own `bin/failproofai-worker.mjs`), which runs + /// the actual, unmodified policy-evaluation engine. Proves Rust and TS + /// sides of the wire protocol actually agree, not just that each side's + /// own unit tests pass in isolation. + #[test] + fn relays_a_hook_request_to_the_real_typescript_worker_end_to_end() { + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("crates/failproofaid should be two levels under the repo root") + .to_path_buf(); + let worker_script = repo_root.join("bin").join("failproofai-worker.mjs"); + assert!( + worker_script.exists(), + "expected {worker_script:?} to exist" + ); + + // A real project dir with block-sudo enabled, so the response + // proves real policy evaluation ran, not just that SOME response + // came back. + let project_dir = std::env::temp_dir().join(format!( + "failproofaid-e2e-project-{}-{}", + std::process::id(), + fastrand_ish() + )); + std::fs::create_dir_all(project_dir.join(".failproofai")).unwrap(); + std::fs::write( + project_dir + .join(".failproofai") + .join("policies-config.json"), + r#"{"enabledPolicies":["block-sudo"]}"#, + ) + .unwrap(); + + let socket_path = temp_socket_path("hook-real-worker"); + let worker_cmd = WorkerCommand::shell(format!("bun {}", worker_script.display())); + let _guard = start_test_server_with_worker(socket_path.clone(), worker_cmd); + + let stdin = serde_json::json!({ + "cwd": project_dir.to_string_lossy(), + "tool_name": "Bash", + "tool_input": { "command": "sudo rm -rf /" }, + }) + .to_string(); + + let mut stream = UnixStream::connect(&socket_path).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .unwrap(); + write_message( + &mut stream, + &ClientMessage::Hook { + protocol_version: PROTOCOL_VERSION, + hook_event: "PreToolUse".to_string(), + cli: "claude".to_string(), + stdin, + cwd: Some(project_dir.to_string_lossy().to_string()), + }, + ) + .unwrap(); + let response: ServerMessage = read_message(&mut stream).unwrap(); + match response { + ServerMessage::HookResult { + exit_code, stdout, .. + } => { + // Claude's PreToolUse deny contract is JSON on stdout at + // exit 0 (hookSpecificOutput.permissionDecision), not a + // nonzero exit code. + assert_eq!(exit_code, 0); + assert!( + stdout.contains("\"permissionDecision\":\"deny\""), + "expected a real deny from block-sudo, got stdout: {stdout}" + ); + } + other => panic!("expected HookResult, got {other:?}"), + } + + std::fs::remove_dir_all(&project_dir).ok(); } #[test] fn mismatched_protocol_version_gets_an_explicit_error() { let socket_path = temp_socket_path("version-mismatch"); - let (shutdown, handle) = start_test_server(socket_path.clone()); + let _guard = start_test_server(socket_path.clone()); let mut stream = UnixStream::connect(&socket_path).unwrap(); write_message( @@ -238,9 +376,6 @@ mod tests { } other => panic!("expected Error, got {other:?}"), } - - shutdown.store(true, Ordering::Relaxed); - handle.join().unwrap(); } #[test] @@ -250,7 +385,11 @@ mod tests { // socket, just a regular file at that path. std::fs::write(&socket_path, b"not a socket").unwrap(); - let server = Server::bind(&socket_path).expect("bind should clear the stale file"); + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("stale-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).expect("bind should clear the stale file"); drop(server); assert!( !socket_path.exists(), @@ -261,7 +400,11 @@ mod tests { #[test] fn bound_socket_file_is_owner_only() { let socket_path = temp_socket_path("perms"); - let server = Server::bind(&socket_path).unwrap(); + let worker = Arc::new(crate::worker::Worker::new( + temp_socket_path("perms-worker"), + broken_worker_cmd(), + )); + let server = Server::bind(&socket_path, worker).unwrap(); let mode = std::fs::metadata(&socket_path) .unwrap() .permissions() @@ -274,7 +417,7 @@ mod tests { #[test] fn multiple_concurrent_pings_all_get_answered() { let socket_path = temp_socket_path("concurrent"); - let (shutdown, handle) = start_test_server(socket_path.clone()); + let _guard = start_test_server(socket_path.clone()); let clients: Vec<_> = (0..8) .map(|_| { @@ -301,8 +444,5 @@ mod tests { for c in clients { c.join().unwrap(); } - - shutdown.store(true, Ordering::Relaxed); - handle.join().unwrap(); } } diff --git a/crates/failproofaid/src/worker.rs b/crates/failproofaid/src/worker.rs new file mode 100644 index 00000000..f413f832 --- /dev/null +++ b/crates/failproofaid/src/worker.rs @@ -0,0 +1,255 @@ +//! Spawns and supervises the warm Node/Bun worker process, and relays +//! `Hook` requests to it. +//! +//! The worker speaks a SEPARATE, simpler internal protocol on its own +//! socket (no `protocolVersion` — this process always spawns a +//! version-matched worker, so there's nothing to negotiate) — this module +//! is the only thing that talks to it, and it's the one place that +//! translates between that internal protocol and the client-facing +//! [`fpai_ipc::ServerMessage`] envelope (which DOES carry `protocolVersion`, +//! since that one crosses a real compatibility boundary against whatever +//! `failproofai` CLI version happens to be installed). + +use fpai_ipc::framing::{read_message, write_message}; +use serde_json::json; +use std::io; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub enum WorkerError { + Io(io::Error), + /// The worker never created its socket within the startup deadline. + StartupTimedOut, + /// A response arrived but wasn't a well-formed hookResult/error. + BadResponse(String), +} + +impl std::fmt::Display for WorkerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WorkerError::Io(e) => write!(f, "worker io error: {e}"), + WorkerError::StartupTimedOut => write!(f, "worker did not start in time"), + WorkerError::BadResponse(s) => write!(f, "unexpected worker response: {s}"), + } + } +} + +impl std::error::Error for WorkerError {} + +pub struct HookOutcome { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +/// How to launch the worker process. `FAILPROOFAI_WORKER_CMD` (dev/test +/// override — a full shell command string, run via `sh -c`) always takes +/// precedence; otherwise falls back to `node