diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45c38f7..fea906d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,32 @@ Before proposing an adapter or protocol change: 5. prove passive Consumers cannot reach the response channel; and 6. document any change to request correlation or timeout behavior. +## Adding a host adapter + +A new host is admissible only if its blocking boundary can complete Pitot's +causal loop: a proposed command reaches a Controller, allow/deny applies before +execution, and a denied action returns to the model so the agent continues. If +the host can only abort on denial, it is not yet supervisable — say so in the +research note and stop there (see `docs/devin-adapter-research.md` for a worked +rejection-then-acceptance across two of the same host's surfaces). + +Adapters come in two transport classes: + +- **One-shot hook** (Claude, Codex, Cursor, Copilot, Gemini, Kimi, OpenCode, + Pi, Qwen): the host invokes `pitot hook HOST` at a synchronous pre-execution + boundary, Pitot answers by exit code or a native block envelope, and the + process ends. Add a decoder that normalizes the host payload to a `shell` + action and an encoder for the host's native deny shape. +- **Stateful transport** (Devin, over ACP): Pitot launches and drives a + long-lived host process, correlates each command to a permission request, and + maps allow/deny onto the host's typed one-shot permission options. Never + select persistent or bypass options. + +Either way: add positive and negative conformance fixtures, prove passive +Consumers cannot reach the response channel, and register the adapter in the +supervised inventory so it joins the cross-platform E2E matrix (each adapter is +verified allow-and-deny on Ubuntu, macOS, and Windows before it ships). + ## Development The reference implementation is a Go module (`go 1.26`). From the repository diff --git a/README.md b/README.md index 3433134..46f727e 100644 --- a/README.md +++ b/README.md @@ -304,9 +304,10 @@ Agent finished. Runtime stopped. ``` `--host` must name a supported agent (`claude`, `codex`, `copilot`, `cursor`, -`gemini`, `kimi`, `opencode`, `pi`, `qwen`), and that agent's host hook must +`devin`, `gemini`, `kimi`, `opencode`, `pi`, `qwen`). Hook-based hosts must already be wired to `pitot hook HOST` (see **Connect your agent** and -`pitot doctor --host HOST`). The runtime descriptor lives in a per-invocation +`pitot doctor --host HOST`); Devin connects over ACP and needs no hook file. +The runtime descriptor lives in a per-invocation temporary path and is removed on exit, so concurrent `pitot dev` sessions never collide. @@ -398,25 +399,47 @@ pitot run --runtime $env:PITOT_RUNTIME Every host below normalizes its native blocking boundary to a `shell` action and passes Pitot's language-neutral decoder conformance suite. The E2E column marks -adapters exercised by the cross-platform agent supervisor (the badge at the top) -on Ubuntu, macOS, and Windows. Kimi additionally has an in-repo, no-model test -that asserts the full allow **and** deny control path end to end. - -| Host | Blocking boundary | Hook wiring | Verified in this repo | -|---|---|---|---| -| Kimi Code | `PreToolUse` / Bash | native `config.toml` | decoder + E2E + allow/deny control test | -| Claude | `PreToolUse` | native settings hook | decoder + E2E | -| Cursor | `beforeShellExecution` | bridge (`integrations/cursor`) | decoder + E2E | -| Codex | `PreToolUse` | bridge (`integrations/codex`) | decoder + E2E | -| GitHub Copilot CLI | `PreToolUse` | bridge (`integrations/copilot`) | decoder + E2E | -| Gemini | `BeforeTool` | bridge (`integrations/gemini`) | decoder + E2E | -| OpenCode | `PreToolUse` | bridge (`integrations/opencode`) | decoder + E2E | -| Pi | `tool_call` | extension (`integrations/pi`) | decoder + E2E | -| Qwen Code | `PreToolUse` | bridge (`integrations/qwen`) | decoder + E2E | +adapters exercised by the cross-platform agent supervisor (the badge at the top); +the Platforms column names the operating systems each one is verified on. Kimi +additionally has an in-repo, no-model test that asserts the full allow **and** +deny control path end to end. + +| Host | Blocking boundary | Hook wiring | Platforms | Verified in this repo | +|---|---|---|---|---| +| Kimi Code | `PreToolUse` / Bash | native `config.toml` | Ubuntu · macOS · Windows | decoder + E2E + allow/deny control test | +| Claude | `PreToolUse` | native settings hook | Ubuntu · macOS · Windows | decoder + E2E | +| Cursor | `beforeShellExecution` | bridge (`integrations/cursor`) | Ubuntu · macOS · Windows (WSL) | decoder + E2E | +| Codex | `PreToolUse` | bridge (`integrations/codex`) | Ubuntu · macOS · Windows | decoder + E2E | +| Devin | `session/request_permission` (ACP) | stateful ACP transport | Ubuntu · macOS · Windows | decoder + E2E | +| GitHub Copilot CLI | `PreToolUse` | bridge (`integrations/copilot`) | Ubuntu · macOS · Windows | decoder + E2E | +| Gemini | `BeforeTool` | bridge (`integrations/gemini`) | Ubuntu · macOS · Windows | decoder + E2E | +| OpenCode | `PreToolUse` | bridge (`integrations/opencode`) | Ubuntu · macOS · Windows | decoder + E2E | +| Pi | `tool_call` | extension (`integrations/pi`) | Ubuntu · macOS · Windows | decoder + E2E | +| Qwen Code | `PreToolUse` | bridge (`integrations/qwen`) | Ubuntu · macOS · Windows | decoder + E2E | "Decoder" means Pitot correctly normalizes that host's payload into the stable event envelope. It does not claim Pitot judges whether any command is safe — that -decision belongs to your Controller. +decision belongs to your Controller. On Windows, Cursor runs under WSL; every +other host runs natively. + +## Host admission criteria + +A host earns a supervised adapter only when its blocking boundary can complete +Pitot's causal loop: a proposed command must reach a Controller, an allow or +deny decision must apply **before** the command runs, and — critically — a +denied action must return to the model so the agent can continue from the +blocked outcome rather than halting. This last requirement, deny-continuation, +is what distinguishes a supervisable boundary from one that can only abort. + +Devin is the worked example. Its lifecycle hooks (`PreToolUse`, +`PermissionRequest`) apply a denial but end the turn instead of handing the +rejected outcome back to the model, so they do not complete the loop in +non-interactive mode. Its Agent Client Protocol surface does: the client +selects `reject_once`, the canary never executes, and Devin makes a follow-up +model request carrying the rejection and continues. Pitot therefore ships Devin +over ACP and does not ship the hook wiring. The full investigation, including +the content-safe evidence receipt, is in +[docs/devin-adapter-research.md](docs/devin-adapter-research.md). ## Connect your agent @@ -568,6 +591,31 @@ blocking `tool_call` event into Pitot's stable envelope and returns Pi's native `block` response when Pitot rejects the request. See the official [Pi extensions documentation](https://pi.dev/docs/latest/extensions). +### Devin + +Devin needs no hook file. Pitot speaks to it over the Agent Client Protocol, +launching `devin acp` as a stdio JSON-RPC server and correlating each +`tool_call` command by its tool-call ID: + +```bash +pitot dev --host devin -- devin -p "Run: echo hello" +``` + +To attach to an already-running runtime, use the explicit single-prompt +surface: + +```bash +pitot acp devin --runtime "$PITOT_RUNTIME" --prompt "Run: echo hello" +``` + +Pitot maps an allow decision to ACP's one-shot `allow_once` and a deny to +`reject_once`; it never selects a persistent option such as `allow_always` or a +bypass mode. Attestation comes from the Controller receipts and canary rather +than a lifecycle-hook witness. This initial adapter is single-prompt; resume and +multi-turn Devin sessions are not yet supported. See +[docs/devin-adapter-research.md](docs/devin-adapter-research.md) for why ACP, +not hooks, is the supervised boundary. + Pitot uses supervised local processes in v1. It starts declared Consumers and Controllers itself, applies each projection before bytes enter the child pipe, and exposes only a loopback endpoint authenticated by the owner-only runtime @@ -685,6 +733,45 @@ Pitot is local and storage-free by default. - custom agent interfaces over existing runtimes; and - new Pitot-compatible coding-agent runtimes. +## Threat model + +Pitot mediates the host's wired boundary and nothing else. Understanding where +that boundary ends is part of using it correctly. + +- **Only the wired boundary is mediated.** Pitot sees an action when the host + actually routes it through the configured hook or ACP transport. An action + the host takes through a path you did not wire is not observed. `pitot doctor + --host HOST` reports whether the boundary is present, and for repo-owned + hosts whether the entry has drifted. +- **User-level hook configs are user-editable.** For hosts wired at user level + (Kimi, Copilot, Qwen) the hook lives in a file the user owns and can change + or remove. Pitot does not police edits outside the repository; it reports the + current state through `pitot doctor`. +- **Without a runtime, hooks only observe.** A `pitot hook` invocation with no + `PITOT_RUNTIME` (or `--runtime`) selected records the action and exits + allowing it — observation-only, for backwards compatibility. It now prints a + one-line notice so this mode is never silent, and `pitot doctor` flags a host + that is wired but has no runtime. Once a runtime is explicitly selected, + transport or authentication failure blocks the controllable action rather + than falling open. +- **Hosts retain their own bypass options.** Some hosts expose persistent or + bypass permissions (for example Devin's ACP `allow_always` and switch-bypass + modes). Pitot never selects them — it uses only the one-shot `allow_once` / + `reject_once` options — but a human operating the host directly still can. +- **Kimi executes the action if its hook process crashes or times out.** This + is Kimi's host semantics, not a Pitot decision, and Pitot cannot override it: + a supervisory-control analysis of the Kimi lifecycle shows a reachable + transition from the pending state straight to an unsupervised execution when + the hook fails open, which no supervisor placed at the boundary can prevent. + Every other supervised host either fails closed or returns the denial to the + model. `pitot doctor --host kimi` states this plainly. If your policy cannot + tolerate fail-open execution, prefer a fail-closed host (such as Cursor, + wired with `failClosed: true`) or Devin's ACP transport. + +None of these change the core contract: within the boundary Pitot mediates, +every pending action receives exactly one terminal resolution. **Pitot reports. +Your controller decides.** + ## What Pitot does not decide Pitot does not define whether: @@ -743,10 +830,13 @@ at the host boundary; interpretation and control belong downstream. Start with the protocol and conformance fixtures. A new adapter should declare its host capabilities, normalize supported events, classify boundary faults without exposing content, encode Controller responses, and pass the shared -positive and negative fixture suite. +positive and negative fixture suite. It must also meet the host admission +criteria above — a boundary that can only abort on denial, rather than return +the outcome to the model, is not yet supervisable. -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and compatibility -requirements. +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the two adapter +transport classes (one-shot hook and stateful ACP-style transport), and the +cross-platform verification every adapter must pass. ## License diff --git a/UPSTREAM.json b/UPSTREAM.json index 661b886..798b80c 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,11 +1,12 @@ { "files": { ".goreleaser.yml": "d375f187624219e9420acd2a40cfae95772ef1fee3abda3e729d6a16ba612032", - "CONTRIBUTING.md": "02c89a5790f2943e5e2466b0d260210238cae306922f0f031adb5f7028b48066", + "CONTRIBUTING.md": "a983d968cf228cc60d3f80cecd9a52e5ba8f3235595886f858fc384ed3dd114b", "LICENSE": "bd17d47aefaa7406616179288058001b6532881a2517254fe668d7e9c4965cfb", - "README.md": "27df9b71b14ac474de3d03f2049eabb7d1c2c35514b7d351940683b0651d1984", + "README.md": "dce6eccbb3638c04ea0310a3f6efe103f0181c22e254e79c2bdd91e923656e19", "adapter-verification.json": "fedd1624e01d19739114203674281296de779097e45fbb9e497047805b7d9250", - "adapters/adapters.go": "13cc5d94ea7c4ae50eccfd27d63e0d91b38cf76ee20aa9737a7edc9bd47d13b2", + "adapters/adapters.go": "25ebd930b4e3aa92a02043e75f2a4a6a818c546b479c978f4b75b8c3962c56de", + "adapters/guard_test.go": "cd1c09e2fb3a2ecc7a73a1714d9b5bf976d67ab206ed845027ed34699c8e123d", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", "assets/pitot-boundary.svg": "0c3871d70c84748573f231842091deb38a6def2862403ca34e8cc4493b9c9ebf", "assets/pitot-hero.png": "a73532252b1e66c06273abbf5a4fe6261e98de3133b09e8d550edacfeeab92f8", @@ -13,17 +14,19 @@ "assets/pitot-mark.svg": "cacd728b4d4da45000ccde15d905314f2d92eb47b5e40a5f86a9e24ad671a003", "assets/pitot-two-roles.png": "9093368b30b7a0b704358343e78df2f937af21f2263b50e8da21a2946306255a", "assets/pitot-two-roles.svg": "528edf57c3eddb4432e119b9aa80a47452d47eb428e0e77a6f8623f244334475", - "bridge/bridge.go": "5adfcd3f743cae46e4446a6e030d53464ada97de0261a8588fa2a9fcd62136b8", + "bridge/bridge.go": "104d515b1cd66ef1e9dac57e5ef25d80b847b7512c44fc6703ad43efa99e59be", "bridge/bridge_test.go": "6dcc6d05f2b39c25955fc0b2d21d3d148dd9d77600fb12799941f86bdb1acb61", + "bridge/window_test.go": "3d9f3f6af95af61a26d99da39f84842db8b71f33c0788f746782e8ec2f216c60", "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", "cmd/pitot/devin_acp.go": "b10962cc21239d0b3955d2521f1f4d940c5de34624d14ce89ef0e40e1fbc1138", "cmd/pitot/devin_acp_test.go": "158a9ee019e8368b9f9434000eea8cbeee98201203213ef83797102549e1dd0a", - "cmd/pitot/doctor_host.go": "fa7819448218c3dd92d3393df9a0ac6e5aa70803348898eec10220bc7b3867a4", + "cmd/pitot/doctor_host.go": "f9d91ae1f0e8cba344f8a6e742467e98e2cf7c82afdb9da3043fba1bc13bbee5", "cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f", + "cmd/pitot/hook_notice_test.go": "b6a0c3689a97beecf32e9aced68585715cc125139f1646f8ba90c4e65e6b2fa8", "cmd/pitot/install.go": "46cab87809566f8da47c4d71b8a87618d06cb0829bdabe1c8f734faf53a9f46c", "cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4", "cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473", - "cmd/pitot/main.go": "9b7851b4e91c3eef08d6dac82939c1842f8e1a5237bf603e5a6ccc51a50561ec", + "cmd/pitot/main.go": "230d3ccc99a244150644602692a5efc0f40cf8e82de840f43cb830db29fd6bd9", "cmd/pitot/main_test.go": "35c17c8de7caf13d0ed399c0b67903236cd3c64faa1ea134c5445d00887a32a5", "cmd/pitot/shim.go": "3a7a4be5bd769010ad947de78188dd17c729388dbf74153d4dbd51fba2121ab1", "cmd/pitot/substrate_test.go": "b4e7d83aa592f7b5bf4ec059ec552abd62473e51a6634a26428448168898d0a3", @@ -36,7 +39,7 @@ "cmd/pitot/workbench_contract_test.go": "5e465f3d3f8b93ffaacfb4738279d369b13514bf7613b8611952f3cd26896586", "cmd/pitot/workbench_dev_test.go": "abffe81e1a25f086d7f3c2f1c32986ee93618bbfc87220bdfda840ec6c6466bf", "cmd/pitot/workbench_test.go": "3c561498dfee4aacf6935fe4b0bfe4449c3961c169c04f1f13e81a38b22d0914", - "config/config.go": "84476c7525833b07222b3b06834ff528f9c496f164bb065d006afad29bbca1fb", + "config/config.go": "d49d274ec7fe16aebb0991b2422095f2f68a28ba818c6a2b2e9826ee30377645", "config/config_test.go": "87d3e5ddc4a3b43c736070de671d03e03ffe29cdd759771526ad27fd9bc0034c", "config/findroot_test.go": "89d8b31f188325a02bde2dcd77e683c06e9dca4f59c872366c027111147ea8a5", "config/merge_test.go": "595d2c96ac879cca7c57b77b99504f34629b8aa55b38d515f35ad76c006ef532", @@ -67,8 +70,9 @@ "integrations/pi/pitot.ts": "ed2d60d5ab6e33e115cfa058e4f96095100e93a061567d0af31249aa756bab3e", "integrations/qwen/PreToolUse": "95c358620f2f882bb8680e6aa9639f3b36a8567a3fd60cf14a6cdf3b3fbcf78b", "integrations/qwen/PreToolUse.cjs": "e9bf00bbbee5c15f01ea203d0992b34f8754b75a9c9889eecb16baeb10fcce6c", - "internal/devinacp/client.go": "e2e70755d5888a2bd34e44b160d1dbf955fe147adf020050aa3fbc68cdbe709d", + "internal/devinacp/client.go": "531bf28ea26c5c4b417c7b6ce2f4b97a41ca760226ca06509412c353aae29131", "internal/devinacp/client_test.go": "5bfff9b7f29d253450e4424ab44dab97877ddb39b3941607a4e264dd66da32cb", + "internal/devinacp/meta_test.go": "0b56f3beb24d4afeff0a6066cb4600bb773976cfa558fea7b963ccda9752c939", "internal/testrole/main.go": "6d657eb85d8ddaff0ae5a3da281aa7aa0a4bdda860d179e99508110033765787", "internal/testrole/main_test.go": "c7fbc4905bcef7d662c9e162a4c32c8a86d5c1a97820e9f4ee48f643c4e47a38", "projection/projection.go": "4d3c823fd72a3ca5387dba3683838a1d7e455e9b18309acc839763a39b7bb35f", @@ -79,9 +83,11 @@ "runtime/descriptor_windows.go": "2d9ffefe3af0154fa8042de6b67460d4e86dd3f4cdd9e986f180f7d0c535c9a5", "runtime/request.go": "198c44fd6c547022a15b6d0d48e4d0130fa8afb687994365115576e4d874550d", "runtime/request_test.go": "86d8a2feb4ec72e8ed675b9567da2d1f5d628950eeec907c10b9cc1675aa1904", - "runtime/runtime.go": "b90072bf119c9121e3d185fa27e8ac372ec9dcb33c9f38fea8050c314b9dd5e4", + "runtime/runtime.go": "36fdf4643c04d058fef380333a0a3942913d1dd60e910b893dc134da4b65d6bf", "runtime/runtime_test.go": "afd78d122af20148bf30d0db873ff002544189df0dfec0f6167b8cf5cd0d42b1", - "runtime/transport.go": "83e2218fb28474e875dafa6943bc5b665acef0565aaf5955fa88b1b4fd21614e", + "runtime/strict_test.go": "d2eb2b78563733e3279289d8b6d17a4276e831ed3c78f8b3ec9d6706992ffaec", + "runtime/transport.go": "671d67be8b8ea0c5c3132cc355054b36b0ecf8001dcc79b4e04be7a584f4bf02", + "runtime/transport_fault_test.go": "1eb58f09a2412ed5411f5d07c4ac62bb8f0fca1ce41201f670f5fe9e98b1abc7", "runtime/transport_test.go": "9b69f590f1e258470adea249b3ac6d4a00f1001f10bb08dfa7b56c6e2d6709ae", "schema/schema.go": "fd5c3b76979c88aeed75fadb7e3c94abcad62e067d77595f021fb422a60f2211", "sdk/csharp/Pitot/Types.cs": "3fecc1eabca6e3ad218c73741d185f815d868a618d1e7ed3b9320eeb6942977d", @@ -112,10 +118,11 @@ "sdk/typescript/src/runner.ts": "c58babd3ec3996a05988f3cb7dec061cf7c48f41cd5125625be3408cb9b207f0", "sdk/typescript/tsconfig.json": "de0065da9acf19a93ba8646c90b7101dfd5a8667552d3d3c59e3effd813218aa", "sensor/decode_fuzz_test.go": "d27f2fbbc069eded26a73c9cd9bace98dd8a9e34949576790b81a08d130fbaf2", - "sensor/sensor.go": "498d4f69c9243a409b25704beab9d441bb312c7813d168138b85a69cff08d8e9", + "sensor/guard_test.go": "bbd112398c9bb61eeb889f5cb5468bd0d4581537b4292edfcb3b6bc0864b7347", + "sensor/sensor.go": "3832eeb1267513f74eefe910394983df847a4c5152029df0e19d170bd05fa059", "sensor/sensor_test.go": "9e0ec50ec3930c37b0e4cc56419be451fb56432f0f22efa145b55ddf6440cd4b", "tests/cursor_control_proxy.mjs": "ab532aa56a9299f497f3ceeedb4b6a0beb26089b7c022ed774385dd149ea56e6", - "tests/devin_control_proxy.py": "0be724db777bb790d18c49fad6280ddf77c261f7ae3c947323cc0cc20aab0422", + "tests/devin_control_proxy.py": "8306a80af890b040bfa7bda43573cd2fec7df207aa653f0b1a970f625a263566", "tests/e2e_claude_cli_test.sh": "b28c4d1963e326b4b3f158a7cfc1b92771e768ac9665dd43e8a339013cc11568", "tests/e2e_codex_cli_test.sh": "dbae5224c87410a5a5d67023d7e23406453bc5493d981af9347993b4e1f562f2", "tests/e2e_copilot_cli_test.sh": "61b1e44dcd598d2d33e7f04dec26bec74405a9e2456ecda46e94dc8d43ad4315", @@ -132,7 +139,7 @@ "tests/install_real_agent.py": "a8dc79bf0914ed5ab2a82c4f2471e805f24eef2adde2261fee22dbd5aabeb985", "tests/mock_anthropic_server.js": "ecebea62f9e93791a79f1ae3dd3c67b8fa42490e9805b23b662b877edfdb0f0e", "tests/model_control_proxy.py": "cd4733ab20c16770fe3bf6ac477935f7dc285cd89be020848ae4798cb509e5f2", - "tests/real_agent_driver.py": "789e72797137fedcc7afc61f14a943df3f57e02f0a2329d125beeb8ce9311394", + "tests/real_agent_driver.py": "33955c673caabcff33f7884a60b87a84e60880cce658e5747a9c939f9f20f4ee", "tests/run_e2e_report.py": "a72cbc486c100b75fccf6ac787da1736e13426b44a15d9d5dddcb8177bbaf596", "tests/runtime_capability_driver.py": "54a485fc4f16981f2542d0dbd903a12b80f31f071908a378607f26678aeea07f", "tests/witness/main.go": "cd56bbd00aa44cc5baf6426c8461a8ebca4a8391518f6acfa2301ac36add7c5f", @@ -144,7 +151,7 @@ "generator": "operatorstack/pitot:project", "schema_version": 1, "source": { - "commit": "57a17ed991224e56638ad3ca0ccda98a3c41bf16", + "commit": "b6b7e44f9b2c48ba6a48ef373adc624610582c57", "path": "labs/15-pitot", "repository": "operatorstack/intelligence-flow" } diff --git a/adapters/adapters.go b/adapters/adapters.go index 9f886c3..c24f0b6 100644 --- a/adapters/adapters.go +++ b/adapters/adapters.go @@ -116,7 +116,7 @@ var ( MainEventName: "tool_call", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"tool_call","tool_name":"bash","tool_input":{"command":"git status --short"}}`), - CommandFor: toolInputCommand, + CommandFor: shellCommandForTools("bash"), ActionKinds: map[string]string{"tool_call": "shell"}, }, Partition: ControlPartition{Controllable: []string{"tool_call"}}, @@ -142,13 +142,7 @@ var ( MainEventName: "PreToolUse", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), - CommandFor: func(raw RawHookEvent) (string, bool) { - if raw.ToolInput == nil { - return "", false - } - value, present := raw.ToolInput["command"].(string) - return value, present && value != "" - }, + CommandFor: shellCommandForTools("Bash"), ActionKinds: map[string]string{ "PreToolUse": "shell", }, @@ -162,13 +156,7 @@ var ( MainEventName: "PreToolUse", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), - CommandFor: func(raw RawHookEvent) (string, bool) { - if raw.ToolInput == nil { - return "", false - } - value, present := raw.ToolInput["command"].(string) - return value, present && value != "" - }, + CommandFor: shellCommandForTools("Bash"), ActionKinds: map[string]string{ "PreToolUse": "shell", }, @@ -182,13 +170,7 @@ var ( MainEventName: "BeforeTool", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`), - CommandFor: func(raw RawHookEvent) (string, bool) { - if raw.ToolInput == nil { - return "", false - } - value, present := raw.ToolInput["command"].(string) - return value, present && value != "" - }, + CommandFor: shellCommandForTools("run_shell_command"), ActionKinds: map[string]string{ "BeforeTool": "shell", }, @@ -202,13 +184,7 @@ var ( MainEventName: "PreToolUse", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), - CommandFor: func(raw RawHookEvent) (string, bool) { - if raw.ToolInput == nil { - return "", false - } - value, present := raw.ToolInput["command"].(string) - return value, present && value != "" - }, + CommandFor: shellCommandForTools("Bash"), ActionKinds: map[string]string{ "PreToolUse": "shell", }, @@ -222,13 +198,7 @@ var ( MainEventName: "PreToolUse", Parser: ParserConfig{ CanonicalEvent: []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), - CommandFor: func(raw RawHookEvent) (string, bool) { - if raw.ToolInput == nil { - return "", false - } - value, present := raw.ToolInput["command"].(string) - return value, present && value != "" - }, + CommandFor: shellCommandForTools("Bash"), ActionKinds: map[string]string{ "PreToolUse": "shell", }, @@ -246,6 +216,24 @@ func toolInputCommand(raw RawHookEvent) (string, bool) { return value, present && value != "" } +// shellCommandForTools returns a CommandFor that accepts tool_input.command +// only when the event's tool_name is one of names. Hosts scope their hook to +// the shell tool in their own config; this guard holds the same line inside +// the decoder, so a widened or drifted host matcher cannot promote another +// tool's input to a supervised shell action. +func shellCommandForTools(names ...string) func(RawBoundaryEvent) (string, bool) { + allowed := make(map[string]struct{}, len(names)) + for _, name := range names { + allowed[name] = struct{}{} + } + return func(raw RawBoundaryEvent) (string, bool) { + if _, ok := allowed[raw.ToolName]; !ok { + return "", false + } + return toolInputCommand(raw) + } +} + func preToolUseHost() HostConfig { return HostConfig{ MainEventName: "PreToolUse", @@ -463,15 +451,24 @@ func (h Host) HasBoundaryEvent(name string) bool { // Deprecated: use HasBoundaryEvent. func (h Host) HasHookEvent(name string) bool { return h.HasBoundaryEvent(name) } -// ActionKind returns the normalized action kind for a boundary event. +// ActionKind returns the normalized action kind for a boundary event. An +// empty boundaryEventName resolves through the host's main boundary event +// (hosts whose payloads omit the discriminator mean their primary boundary). +// An unknown non-empty event returns "" so callers fault instead of silently +// acquiring the shell kind. func (h Host) ActionKind(boundaryEventName string) string { registryMu.RLock() defer registryMu.RUnlock() - if config, exists := registry[h]; exists { - if kind, ok := config.Parser.ActionKinds[boundaryEventName]; ok { - return kind - } + config, exists := registry[h] + if !exists { + return "" + } + if boundaryEventName == "" { + boundaryEventName = config.MainEventName + } + if kind, ok := config.Parser.ActionKinds[boundaryEventName]; ok { + return kind } - return "shell" // fallback default + return "" } diff --git a/adapters/guard_test.go b/adapters/guard_test.go new file mode 100644 index 0000000..902e3dd --- /dev/null +++ b/adapters/guard_test.go @@ -0,0 +1,48 @@ +package adapters + +import "testing" + +// The decoder holds the same tool-scope line the host matcher promises, so a +// widened or drifted host config cannot promote another tool's input to a +// supervised shell action. +func TestCommandForRejectsNonShellTools(t *testing.T) { + cases := map[Host]struct { + accepted []string + }{ + Claude: {accepted: []string{"Bash"}}, + Codex: {accepted: []string{"Bash"}}, + Kimi: {accepted: []string{"Bash"}}, + Opencode: {accepted: []string{"Bash"}}, + Gemini: {accepted: []string{"run_shell_command"}}, + Pi: {accepted: []string{"bash"}}, + Qwen: {accepted: []string{"Bash", "run_shell_command"}}, + } + for host, tc := range cases { + for _, tool := range tc.accepted { + raw := RawBoundaryEvent{ToolName: tool, ToolInput: map[string]any{"command": "git status"}} + if command, ok := host.CommandFor(raw); !ok || command != "git status" { + t.Errorf("%s: expected tool %q accepted, got ok=%v", host, tool, ok) + } + } + raw := RawBoundaryEvent{ToolName: "Write", ToolInput: map[string]any{"command": "rm -rf /"}} + if _, ok := host.CommandFor(raw); ok { + t.Errorf("%s: non-shell tool %q must not yield a supervised shell command", host, "Write") + } + } +} + +func TestActionKindNeverSilentlyDefaults(t *testing.T) { + if kind := Claude.ActionKind("SomeFutureEvent"); kind != "" { + t.Fatalf("unknown boundary event must have no kind, got %q", kind) + } + // An omitted discriminator means the host's main boundary event. + if kind := Claude.ActionKind(""); kind != "shell" { + t.Fatalf("empty event name should resolve via the main boundary event, got %q", kind) + } + if kind := Cursor.ActionKind("beforeMCPExecution"); kind != "mcp" { + t.Fatalf("registered kinds must be preserved, got %q", kind) + } + if kind := Host("unregistered").ActionKind("PreToolUse"); kind != "" { + t.Fatalf("unregistered host must have no kind, got %q", kind) + } +} diff --git a/bridge/bridge.go b/bridge/bridge.go index 1c2b7e6..2634139 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -47,10 +47,18 @@ func (r Registration) validate() error { return nil } +// resolvedWindow bounds the duplicate-detection memory: the Router remembers +// the most recent resolvedWindow resolved action IDs. Action IDs are 16 +// crypto-random bytes minted per action, so a duplicate arriving after 4096 +// newer actions is not a realistic correlation hazard, and the bound keeps +// long-lived runtimes at constant memory. +const resolvedWindow = 4096 + // Router holds at most one Controller registration per request kind. type Router struct { registrations map[string]Registration resolved map[string]struct{} + resolvedOrder []string mu sync.Mutex } @@ -59,6 +67,18 @@ func NewRouter() *Router { return &Router{registrations: map[string]Registration{}, resolved: map[string]struct{}{}} } +// markResolved records actionID in the bounded duplicate-detection window. +// Callers must hold r.mu. +func (r *Router) markResolved(actionID string) { + r.resolved[actionID] = struct{}{} + r.resolvedOrder = append(r.resolvedOrder, actionID) + if len(r.resolvedOrder) > resolvedWindow { + evict := r.resolvedOrder[0] + r.resolvedOrder = r.resolvedOrder[1:] + delete(r.resolved, evict) + } +} + // Register records reg, enforcing the exactly-one-Controller-per-kind rule. func (r *Router) Register(reg Registration) error { r.mu.Lock() @@ -119,7 +139,7 @@ func (r *Router) Resolve(req schema.ControlRequested, candidate *schema.ControlR if !ok { return schema.ControlResponse{}, ErrNoController } - r.resolved[req.ActionID] = struct{}{} + r.markResolved(req.ActionID) if candidate == nil { return r.defaultResponse(reg, req, reg.OnUnavailable), nil } @@ -153,7 +173,7 @@ func (r *Router) TimeoutResponse(req schema.ControlRequested) (schema.ControlRes if !ok { return schema.ControlResponse{}, ErrNoController } - r.resolved[req.ActionID] = struct{}{} + r.markResolved(req.ActionID) return r.defaultResponse(reg, req, reg.OnTimeout), nil } diff --git a/bridge/window_test.go b/bridge/window_test.go new file mode 100644 index 0000000..0626a18 --- /dev/null +++ b/bridge/window_test.go @@ -0,0 +1,42 @@ +package bridge + +import ( + "fmt" + "testing" + + "github.com/operatorstack/pitot/schema" +) + +// The duplicate-detection window is bounded: long-lived runtimes stay at +// constant memory, and the window semantics (most recent resolvedWindow +// actions) are explicit. +func TestResolvedWindowIsBounded(t *testing.T) { + router := NewRouter() + if err := router.Register(Registration{Kind: "shell", ControllerID: "c1", DeadlineMS: 1000, OnTimeout: schema.OutcomeDeny, OnUnavailable: schema.OutcomeDeny}); err != nil { + t.Fatal(err) + } + request := func(i int) schema.ControlRequested { + return schema.ControlRequested{ + PitotVersion: schema.Version, + Type: schema.TypeControlRequested, + Kind: "shell", + ActionID: fmt.Sprintf("act_%032d", i), + } + } + for i := 0; i < resolvedWindow+10; i++ { + if _, err := router.Resolve(request(i), nil); err != nil { + t.Fatalf("resolve %d: %v", i, err) + } + } + if len(router.resolved) != resolvedWindow { + t.Fatalf("resolved map should be capped at %d, got %d", resolvedWindow, len(router.resolved)) + } + // A duplicate inside the window is still rejected. + if _, err := router.Resolve(request(resolvedWindow+9), nil); err != ErrDuplicate { + t.Fatalf("recent duplicate must be rejected, got %v", err) + } + // The oldest entries were evicted (documented window boundary). + if _, dup := router.resolved[request(0).ActionID]; dup { + t.Fatal("oldest action should have been evicted from the window") + } +} diff --git a/cmd/pitot/doctor_host.go b/cmd/pitot/doctor_host.go index aa7cdfb..3c39706 100644 --- a/cmd/pitot/doctor_host.go +++ b/cmd/pitot/doctor_host.go @@ -2,6 +2,8 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -69,12 +71,15 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error { if found, err := config.FindRoot("."); err == nil { root = found } - return printWiringStatus(root, string(host), stdout) + err := printWiringStatus(root, string(host), stdout) + doctorCommonNotes(host, stdout) + return err } probe, known := hostProbes[host] if !known { fmt.Fprintf(stdout, " host-config inspection is not implemented for %q in this release; run `pitot doctor` for the decoder status, or `pitot init --host %s` for the wiring snippet\n", host, host) + doctorCommonNotes(host, stdout) return nil } @@ -139,6 +144,7 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error { // Kimi's PreToolUse hook is fail-open on crash/timeout per host semantics. fmt.Fprintf(stdout, " note: %s hooks are fail-open on hook crash or timeout per host semantics; this sample controller is not a security sandbox\n", host) + doctorCommonNotes(host, stdout) if len(problems) > 0 { return fmt.Errorf("pitot doctor: %s host check found %d issue(s): %s", host, len(problems), strings.Join(problems, "; ")) @@ -147,6 +153,46 @@ func doctorHost(host adapters.Host, stdout, stderr io.Writer) error { return nil } +// doctorCommonNotes surfaces the two silent degradations every wired host is +// exposed to: hooks without a selected runtime observe instead of supervise, +// and user-level wrappers resolve `pitot` from PATH, which can drift from the +// binary the operator reviewed. Notes never fail the check; they name the +// state so the operator can decide. +func doctorCommonNotes(host adapters.Host, stdout io.Writer) { + if os.Getenv("PITOT_RUNTIME") == "" { + fmt.Fprintln(stdout, " note: PITOT_RUNTIME is not set in this shell; hooks run observe-only until a runtime is selected (`pitot run` / `pitot dev`), and `pitot run --strict` or `require_controller: true` faults instead of allowing") + } + if wiring.Supported(string(host)) { + return // repo-wired hosts pin .pitot/bin/pitot; PATH is not consulted + } + self, selfErr := os.Executable() + onPath, pathErr := doctorLookPath("pitot") + if selfErr != nil || pathErr != nil { + return + } + selfSum, err1 := fileSHA256(self) + pathSum, err2 := fileSHA256(onPath) + if err1 != nil || err2 != nil { + return + } + if selfSum != pathSum { + fmt.Fprintf(stdout, " warning: `pitot` on PATH (%s) is a different binary than this one (%s); user-level %s wrappers resolve ${PITOT_BIN:-pitot} from PATH, so the drifted binary would do the supervising\n", onPath, self, host) + } +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + func doctorDevin(stdout, stderr io.Writer) error { const supportedVersion = "3000.3.22" path, err := doctorLookPath("devin") diff --git a/cmd/pitot/hook_notice_test.go b/cmd/pitot/hook_notice_test.go new file mode 100644 index 0000000..a89f062 --- /dev/null +++ b/cmd/pitot/hook_notice_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" +) + +// Observation-only stays exit-0 but is never silent (Locus root-cause: +// silent-mode-degradation). +func TestHookWithoutRuntimeAnnouncesObserveOnly(t *testing.T) { + t.Setenv("PITOT_RUNTIME", "") + stdin := strings.NewReader(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}`) + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + if err := runWithIO(context.Background(), []string{"hook", "claude"}, stdin, stdout, stderr); err != nil { + t.Fatalf("observe-only hook must exit clean: %v", err) + } + if !strings.Contains(stdout.String(), `"action.requested"`) { + t.Fatalf("normalized event missing: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "observe-only") { + t.Fatalf("observe-only notice missing: %q", stderr.String()) + } +} diff --git a/cmd/pitot/main.go b/cmd/pitot/main.go index 167ce73..e407cef 100644 --- a/cmd/pitot/main.go +++ b/cmd/pitot/main.go @@ -116,6 +116,9 @@ func runHook(ctx context.Context, args []string, stdin io.Reader, stdout, stderr return fmt.Errorf("pitot: emit normalized event: %w", err) } if runtimePath == "" { + // Observation-only is a feature; silence about it is not (Locus + // root-cause: silent-mode-degradation). Exit 0 is preserved. + fmt.Fprintln(stderr, "pitot: observe-only (no --runtime/PITOT_RUNTIME); this action is recorded, not supervised") return nil } client, err := runtime.OpenClient(runtimePath) @@ -128,7 +131,17 @@ func runHook(ctx context.Context, args []string, stdin io.Reader, stdout, stderr fmt.Fprintln(stderr, err) return errBlocked } - if response == nil || response.Outcome == schema.OutcomeAllow { + if response == nil { + return nil + } + if response.Outcome == schema.OutcomeAllow { + if response.ActionID != actionID { + // An allow that does not correlate to this action is not an + // allow for this action (same nonce binding the ACP client + // enforces before selecting allow_once). + fmt.Fprintln(stderr, "pitot: invalid controller resolution") + return errBlocked + } return nil } if response.Outcome != schema.OutcomeDeny || response.ActionID != actionID { @@ -260,6 +273,7 @@ func doctor(args []string, stdout, stderr io.Writer) error { func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) error { configPath := "" runtimePath := "" + strict := false for i := 0; i < len(args); i++ { switch args[i] { case "--config": @@ -274,6 +288,8 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er } runtimePath = args[i+1] i++ + case "--strict": + strict = true default: return fmt.Errorf("pitot: unexpected argument %q", args[i]) } @@ -301,6 +317,9 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er if err != nil { return err } + if strict { + loaded.Config.RequireController = true + } manager, err := runtime.Start(ctx, loaded.Config, stderr) if err != nil { return err @@ -339,7 +358,7 @@ usage: pitot dev --host HOST -- AGENT [ARGS...] pitot acp devin --runtime PATH --prompt TEXT [--exec PATH] [--model MODEL] [--agent-type TYPE] [--cwd PATH] pitot doctor [--host HOST] [--fix] - pitot run [--config PATH] --runtime PATH + pitot run [--config PATH] [--strict] --runtime PATH pitot hook HOST [--runtime PATH] pitot request KIND [--data JSON] --runtime PATH pitot version diff --git a/config/config.go b/config/config.go index f7faf4b..b2a8d27 100644 --- a/config/config.go +++ b/config/config.go @@ -37,6 +37,12 @@ type Config struct { RequiresProtocol string `yaml:"requires_protocol,omitempty"` Consumers []ConsumerConfig `yaml:"consumers,omitempty"` Controllers map[string]ControllerConfig `yaml:"controllers,omitempty"` + // RequireController converts silent observation-only degradation into a + // fault: an action kind delivered without a registered Controller is + // blocked instead of allowed. Additive and off by default — plain + // observation-only operation is unchanged. Any fragment declaring it + // makes the merged config strict. + RequireController bool `yaml:"require_controller,omitempty"` } // ConsumerConfig declares a passive JSON-Lines event sink. @@ -210,6 +216,9 @@ func mergeSources(sources []source) (Loaded, error) { if len(fragment.Consumers) == 0 && len(fragment.Controllers) == 0 { return Loaded{}, fmt.Errorf("pitot: fragment %q declares no consumers or controllers", rel) } + if fragment.RequireController { + merged.RequireController = true + } if fragment.RequiresProtocol != "" && fragment.RequiresProtocol != schema.Version { return Loaded{}, fmt.Errorf("pitot: fragment %q requires protocol %q but this pitot speaks protocol %q", rel, fragment.RequiresProtocol, schema.Version) } diff --git a/internal/devinacp/client.go b/internal/devinacp/client.go index 650a335..d2e0939 100644 --- a/internal/devinacp/client.go +++ b/internal/devinacp/client.go @@ -58,10 +58,14 @@ type client struct { stdin io.WriteCloser scanner *bufio.Scanner stdout io.Writer + stderr io.Writer deliver DeliverFunc commands map[string]string nextID int writeMu sync.Mutex + // metaAbsenceReported dedupes the once-per-session diagnostic for + // tool_call updates missing the vendor _meta tool name. + metaAbsenceReported bool } // Run launches Devin as an ACP subprocess and completes one prompt turn. @@ -126,6 +130,7 @@ func Run(ctx context.Context, options Options) error { stdin: stdin, scanner: scanner, stdout: options.Stdout, + stderr: options.Stderr, deliver: deliver, commands: map[string]string{}, } @@ -278,6 +283,13 @@ func (c *client) handleUpdate(raw json.RawMessage) error { tool, _ := params.Update.Meta["cognition.ai/inferenceToolName"].(string) if params.SessionID != "" && params.Update.ToolCallID != "" && tool == "exec" && command != "" { c.commands[commandKey(params.SessionID, params.Update.ToolCallID)] = command + } else if tool == "" && command != "" && !c.metaAbsenceReported { + // Fail-closed fallbacks must be observable: without the vendor + // _meta tool name no command is ever cached, so every permission + // request will be rejected. Name protocol drift once so it is + // not mistaken for Controller policy. + c.metaAbsenceReported = true + fmt.Fprintln(c.stderr, "pitot acp: tool_call update lacks _meta cognition.ai/inferenceToolName; commands cannot be attributed and permissions will be rejected (fail-closed)") } case "agent_message_chunk": var content struct { diff --git a/internal/devinacp/meta_test.go b/internal/devinacp/meta_test.go new file mode 100644 index 0000000..0061b3e --- /dev/null +++ b/internal/devinacp/meta_test.go @@ -0,0 +1,44 @@ +package devinacp + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// A tool_call update missing the vendor _meta tool name leaves the command +// cache empty (fail-closed rejections follow). That degradation must be +// named once per session, not mistaken for Controller policy. +func TestMetaAbsenceIsDiagnosedOnce(t *testing.T) { + stderr := &bytes.Buffer{} + transport := &client{stderr: stderr, stdout: &bytes.Buffer{}, commands: map[string]string{}} + update := json.RawMessage(`{"sessionId":"s1","update":{"sessionUpdate":"tool_call","toolCallId":"t1","rawInput":{"command":"git status"}}}`) + for i := 0; i < 2; i++ { + if err := transport.handleUpdate(update); err != nil { + t.Fatalf("update %d: %v", i, err) + } + } + if len(transport.commands) != 0 { + t.Fatalf("no command may be cached without the vendor tool name, got %v", transport.commands) + } + if got := strings.Count(stderr.String(), "cognition.ai/inferenceToolName"); got != 1 { + t.Fatalf("expected exactly one diagnostic, got %d in %q", got, stderr.String()) + } +} + +// The normal path still caches and emits no diagnostic. +func TestMetaPresenceCachesWithoutDiagnostic(t *testing.T) { + stderr := &bytes.Buffer{} + transport := &client{stderr: stderr, stdout: &bytes.Buffer{}, commands: map[string]string{}} + update := json.RawMessage(`{"sessionId":"s1","update":{"sessionUpdate":"tool_call","toolCallId":"t1","rawInput":{"command":"git status"},"_meta":{"cognition.ai/inferenceToolName":"exec"}}}`) + if err := transport.handleUpdate(update); err != nil { + t.Fatal(err) + } + if transport.commands[commandKey("s1", "t1")] != "git status" { + t.Fatalf("command not cached: %v", transport.commands) + } + if stderr.Len() != 0 { + t.Fatalf("unexpected diagnostic: %q", stderr.String()) + } +} diff --git a/runtime/runtime.go b/runtime/runtime.go index a466dc7..440eaac 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -34,14 +34,21 @@ type Decision struct { // Manager starts configured role processes and exposes their shared delivery path. type Manager struct { - ctx context.Context - cancel context.CancelFunc - stderr io.Writer - controllers map[string]*controllerWorker - consumers []*consumerWorker - observer func(Decision) + ctx context.Context + cancel context.CancelFunc + stderr io.Writer + controllers map[string]*controllerWorker + consumers []*consumerWorker + observer func(Decision) + requireController bool + observedKindsMu sync.Mutex + observedKinds map[string]struct{} } +// ErrControllerRequired reports a kind delivered without a registered +// Controller while require_controller (or --strict) is active. +var ErrControllerRequired = errors.New("pitot: no controller registered for this action kind and require_controller is active") + // SetDecisionObserver registers a callback invoked for every resolved controller // decision. It is optional; a nil observer disables receipts. Not safe to change // concurrently with active delivery. @@ -66,10 +73,12 @@ func (m *Manager) reportDecision(kind string, response *schema.ControlResponse) func Start(parent context.Context, cfg config.Config, stderr io.Writer) (*Manager, error) { ctx, cancel := context.WithCancel(parent) manager := &Manager{ - ctx: ctx, - cancel: cancel, - stderr: stderr, - controllers: map[string]*controllerWorker{}, + ctx: ctx, + cancel: cancel, + stderr: stderr, + controllers: map[string]*controllerWorker{}, + requireController: cfg.RequireController, + observedKinds: map[string]struct{}{}, } for _, kind := range sortedKinds(cfg.Controllers) { declared := cfg.Controllers[kind] @@ -126,6 +135,17 @@ func (m *Manager) DeliverEvent(ctx context.Context, event schema.Event) (*schema } worker, exists := m.controllers[event.Action.Kind] if !exists { + if m.requireController { + return nil, fmt.Errorf("%w (kind %q)", ErrControllerRequired, event.Action.Kind) + } + // Observation-only is a feature; silence about it is not. Name the + // degradation once per kind on the operator's channel. + m.observedKindsMu.Lock() + if _, seen := m.observedKinds[event.Action.Kind]; !seen { + m.observedKinds[event.Action.Kind] = struct{}{} + fmt.Fprintf(m.stderr, "pitot: kind %q has no controller; observation-only (set require_controller: true or run with --strict to fault instead)\n", event.Action.Kind) + } + m.observedKindsMu.Unlock() return nil, nil } data, err := json.Marshal(event) diff --git a/runtime/strict_test.go b/runtime/strict_test.go new file mode 100644 index 0000000..ceff87a --- /dev/null +++ b/runtime/strict_test.go @@ -0,0 +1,53 @@ +package runtime + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/operatorstack/pitot/config" +) + +// require_controller converts the silent observation-only allow into a fault +// (Locus strict-mode candidate strict-config-v1 / strict-flag-v1). +func TestRequireControllerFaultsInsteadOfAllowing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + manager, err := Start(ctx, config.Config{RequireController: true}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + response, err := manager.DeliverEvent(ctx, actionEvent(t, "act_strict_1", "true")) + if response != nil { + t.Fatalf("strict mode must not resolve, got %+v", response) + } + if !errors.Is(err, ErrControllerRequired) { + t.Fatalf("expected ErrControllerRequired, got %v", err) + } +} + +// Without strict mode, observation-only stays allowed but is announced once +// per kind on the operator's channel (never silently). +func TestObservationOnlyIsAnnouncedOncePerKind(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + stderr := &bytes.Buffer{} + manager, err := Start(ctx, config.Config{}, stderr) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + for i, id := range []string{"act_obs_1", "act_obs_2"} { + response, deliverErr := manager.DeliverEvent(ctx, actionEvent(t, id, "true")) + if response != nil || deliverErr != nil { + t.Fatalf("delivery %d: expected observation-only nil/nil, got %+v %v", i, response, deliverErr) + } + } + notice := "observation-only" + if got := strings.Count(stderr.String(), notice); got != 1 { + t.Fatalf("expected exactly one %q notice, got %d in %q", notice, got, stderr.String()) + } +} diff --git a/runtime/transport.go b/runtime/transport.go index 97e4363..ae793a6 100644 --- a/runtime/transport.go +++ b/runtime/transport.go @@ -142,6 +142,12 @@ func (s *Server) handleEvent(w http.ResponseWriter, request *http.Request) { response, err := s.manager.DeliverEvent(request.Context(), event) if err != nil { fmt.Fprintf(s.stderr, "pitot: action %s resolved with boundary fault: %v\n", event.Action.ID, err) + if response == nil { + // An errored delivery with no resolution must never read as an + // observation-only allow (204): fail closed so the hook blocks. + writeJSON(w, http.StatusConflict, map[string]string{"error": "event delivery faulted"}) + return + } } if response == nil { w.WriteHeader(http.StatusNoContent) diff --git a/runtime/transport_fault_test.go b/runtime/transport_fault_test.go new file mode 100644 index 0000000..7f605c1 --- /dev/null +++ b/runtime/transport_fault_test.go @@ -0,0 +1,46 @@ +package runtime + +import ( + "context" + "io" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/operatorstack/pitot/config" +) + +// An errored delivery with no resolution must never read as an +// observation-only allow: the transport fails closed and the hook blocks. +func TestStrictDeliveryFaultIsNotAnAllow(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + manager, err := Start(ctx, config.Config{RequireController: true}, io.Discard) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "runtime.json") + done := make(chan error, 1) + go func() { done <- NewServer(manager, strings.Repeat("a", 64), path, io.Discard, io.Discard).Serve(ctx) }() + t.Cleanup(func() { + cancel() + manager.Close() + <-done + }) + var client *Client + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); { + if opened, openErr := OpenClient(path); openErr == nil { + client = opened + break + } + time.Sleep(20 * time.Millisecond) + } + if client == nil { + t.Fatal("runtime descriptor was not published") + } + response, err := client.DeliverEvent(ctx, actionEvent(t, "act_strict_http", "true")) + if err == nil { + t.Fatalf("strict fault must surface as an error, got response %+v", response) + } +} diff --git a/sensor/guard_test.go b/sensor/guard_test.go new file mode 100644 index 0000000..fd5f775 --- /dev/null +++ b/sensor/guard_test.go @@ -0,0 +1,47 @@ +package sensor + +import ( + "errors" + "strings" + "testing" + + "github.com/operatorstack/pitot/adapters" + "github.com/operatorstack/pitot/schema" +) + +// A non-shell tool carrying a command string must fault at the decoder even +// if the host matcher forwarded it (defense in depth for the five hosts that +// previously accepted any tool_input.command). +func TestDecodeRejectsNonShellToolInput(t *testing.T) { + payloads := map[adapters.Host]string{ + adapters.Claude: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"command":"rm -rf /"}}`, + adapters.Codex: `{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{"command":"curl evil"}}`, + adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"write_file","tool_input":{"command":"true"}}`, + adapters.Opencode: `{"hook_event_name":"PreToolUse","tool_name":"webfetch","tool_input":{"command":"true"}}`, + adapters.Kimi: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"command":"true"}}`, + adapters.Pi: `{"hook_event_name":"tool_call","tool_name":"editor","tool_input":{"command":"true"}}`, + } + for host, payload := range payloads { + _, err := Decode(host, []byte(payload), "full") + var fault *FaultError + if !errors.As(err, &fault) || fault.Reason != schema.ReasonEmptyCommand { + t.Errorf("%s: non-shell tool must fault with empty-command, got %v", host, err) + } + } +} + +// Payloads that omit the event discriminator keep decoding as the host's main +// boundary event — the tolerated-empty-name behavior is a feature and must +// not regress into the removed silent shell fallback. +func TestDecodeEmptyEventNameResolvesMainBoundary(t *testing.T) { + event, err := Decode(adapters.Claude, []byte(`{"tool_name":"Bash","tool_input":{"command":"git status"}}`), "full") + if err != nil { + t.Fatalf("empty event name should decode via the main boundary event: %v", err) + } + if event.Action.Kind != "shell" { + t.Fatalf("expected shell kind, got %q", event.Action.Kind) + } + if !strings.Contains(string(event.Content.Full), "git status") { + t.Fatalf("expected command content, got %s", event.Content.Full) + } +} diff --git a/sensor/sensor.go b/sensor/sensor.go index 0de152e..2c428cf 100644 --- a/sensor/sensor.go +++ b/sensor/sensor.go @@ -67,6 +67,13 @@ func Decode(host adapters.Host, raw []byte, mode projection.Mode) (schema.Event, return schema.Event{}, &FaultError{Host: host, Reason: schema.ReasonEmptyCommand} } + kind := host.ActionKind(eventName) + if kind == "" { + // No registered kind for this boundary event: a malformed boundary, + // never a silently-defaulted shell action. + return schema.Event{}, &FaultError{Host: host, Reason: schema.ReasonMalformed} + } + content, err := projection.Apply(mode, []byte(command)) if err != nil { return schema.Event{}, err @@ -83,7 +90,7 @@ func Decode(host adapters.Host, raw []byte, mode projection.Mode) (schema.Event, Name: string(host), AdapterVersion: adapters.AdapterVersion, }, - Action: &schema.Action{Kind: host.ActionKind(eventName)}, + Action: &schema.Action{Kind: kind}, Content: &content, Observation: schema.Observation{ Source: source, diff --git a/tests/devin_control_proxy.py b/tests/devin_control_proxy.py index 6463397..cc13409 100644 --- a/tests/devin_control_proxy.py +++ b/tests/devin_control_proxy.py @@ -87,11 +87,18 @@ def rlog(line: str) -> None: "auxiliary_requests": 0, } + receipt_lock = threading.Lock() + def save() -> None: - args.receipt.parent.mkdir(parents=True, exist_ok=True) - temporary = args.receipt.with_suffix(".tmp") - temporary.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(args.receipt) + # Handlers run on concurrent threads (one per connection). The receipt + # mutation and the tmp-file replace must be atomic as a unit: a shared + # tmp name raced by two handlers throws, killing the connection — + # which a bursting Devin client observes as "error sending request". + with receipt_lock: + args.receipt.parent.mkdir(parents=True, exist_ok=True) + temporary = args.receipt.with_suffix(f".{threading.get_ident()}.tmp") + temporary.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(args.receipt) class Handler(BaseHTTPRequestHandler): # Devin's HTTP client pools connections. Under the default HTTP/1.0 diff --git a/tests/real_agent_driver.py b/tests/real_agent_driver.py index 0a49e29..b2a1411 100644 --- a/tests/real_agent_driver.py +++ b/tests/real_agent_driver.py @@ -360,6 +360,13 @@ def validate_receipts( if agent["integration"] == "acp_client": if witnesses: raise RuntimeError("ACP control unexpectedly entered the lifecycle-hook witness path") + # Observed baseline is ~10 auxiliary (non-chat) requests per session; + # an unexplained surge means the proxy is answering traffic the + # trajectory never accounted for. Test-side ceiling only: the proxy + # itself stays permissive so a chattier Devin build cannot black-hole. + auxiliary = int(proxy.get("auxiliary_requests", 0)) + if auxiliary > 100: + raise RuntimeError(f"devin proxy answered {auxiliary} auxiliary requests; ceiling is 100") else: if len(witnesses) != 2: raise RuntimeError("Pitot witness receipts do not identify exactly two hook actions")