diff --git a/labs/15-pitot/pitot-distribution/UPSTREAM.json b/labs/15-pitot/pitot-distribution/UPSTREAM.json index 220e63615..9d686bc0e 100644 --- a/labs/15-pitot/pitot-distribution/UPSTREAM.json +++ b/labs/15-pitot/pitot-distribution/UPSTREAM.json @@ -2,7 +2,7 @@ "files": { "CONTRIBUTING.md": "0613b71aa497f8ca7d7296bf34ade87bfc7237a664d2e812d9b77b3b6befb0ad", "README.md": "e54193fee53061889b3d5e82df7c76e72e440f265377d260b4a793475c216a7a", - "adapters/adapters.go": "80110df7289ea69f830b48667326ef297770c596e4ddbf1bc66201639f8288ad", + "adapters/adapters.go": "1b09f188ed9d3f8a1b159055556bd2a68153ad6ee9d59810b23c18a96a4a83bd", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", "assets/pitot-boundary.svg": "0c3871d70c84748573f231842091deb38a6def2862403ca34e8cc4493b9c9ebf", "assets/pitot-hero.png": "a73532252b1e66c06273abbf5a4fe6261e98de3133b09e8d550edacfeeab92f8", @@ -13,15 +13,15 @@ "bridge/bridge.go": "79ac2e025e16782f3c283b43cbea5b9ba4f837446583864df8f57d6346cae816", "bridge/bridge_test.go": "23a19b7580d4b1e826224ec8322208ccca44ddd9a97b150efe15cafecc53e47f", "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", - "cmd/pitot/main.go": "a8ef8a789abe13f41d8a1014de3fb4ad46522b558f0ace70786b904e81892b05", - "cmd/pitot/main_test.go": "ab2894327e7a5e72e9f3d83cae637e3ccb2e1ae05f14458be4fe9016149aeef7", + "cmd/pitot/main.go": "266cfdd1b758f2842a3cc232346e4ada4360077b1aa18c3a635697f56cab78a8", + "cmd/pitot/main_test.go": "7281ac0029d5e92c63c9feb1d2095aaef924aacf4e8a3db9af52872174d609ac", "conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb", "conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd", "conformance/fixtures/negative.jsonl": "383dd001910699886bb1074d9225c91d8a6201e9fb3267d1a1f6e1c2753b0ba6", "conformance/fixtures/positive.jsonl": "d3af0f2529dac9b33fa4900f5938e36fd0b17383088dd4f0de7e6eca269441d1", - "doc.go": "4dcd7a831a0a8ee6c3f6eeb8408c2e6898994509b11209564c0ed6b9a5218fce", + "doc.go": "9b51db0301aa428db731edcf8aa6b5a4fd71ae84f6a0d6cb2fc27656b04b1428", "e2e/e2e_coverage_test.go": "8fac62d6d1c4ced359f8bb070379a88e77ded9912d867c642cda2d4703df23a7", - "e2e/e2e_hook_test.go": "a1c4601964eed674693306450249cf11a50978e5d2e00df152e33dc2c45ee1a0", + "e2e/e2e_hook_test.go": "cb9f27f4471c81c3320ea11be039a19514f5399b5ec5a80ae3b375dad8f8e8b5", "examples/doc.go": "58f3f9eb7d272d7b6eecdb05f43e1613d5e3ef92d15d97c5440bd4b6990c26f9", "examples/local-approval/main.go": "51386af324cd7d3bb07fe3ace53503884b02714b96b83073342fde81ce3b83a5", "examples/token-meter/main.go": "4b1b9c1a43c3cf48b09dba6f607776caced9d2b5b562373496b31ed184582dd1", @@ -42,7 +42,7 @@ "sensor/sensor.go": "939e146eaa51906972f98cf4615747eb75811b0c54d467a938825750336abeae", "sensor/sensor_test.go": "ea9a58d45ad56d29214a40fb2cfa935e769f85334afcb1856a20fb938d486245", "windtunnel/doc.go": "44e0bcde632da73e1f8b98beade3a34ca8e0d0ea79cdfb91d131de290b164fc4", - "windtunnel/windtunnel_test.go": "1c8b6c5d56ff5c4e66403baeae0d98108255d153c1275572b8c68e333e091493" + "windtunnel/windtunnel_test.go": "7e70ddf99411278175d2d2eb8ed73733367c77b3d04d55b4bf00738518673200" }, "schema_version": 1 } diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md new file mode 100644 index 000000000..374d69afa --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md @@ -0,0 +1,3 @@ +### Add Gemini CLI as a supported host adapter + +Pitot now supports the `gemini` host adapter. This integration natively parses Gemini CLI's `BeforeTool` synchronous hook events, allowing the Pitot sensor to safely monitor and project Gemini's shell command behavior along with Cursor, Claude, and Codex. diff --git a/labs/15-pitot/pitot/adapters/adapters.go b/labs/15-pitot/pitot/adapters/adapters.go index 401faadbc..3ff0bcbeb 100644 --- a/labs/15-pitot/pitot/adapters/adapters.go +++ b/labs/15-pitot/pitot/adapters/adapters.go @@ -26,6 +26,7 @@ const ( Cursor Host = "cursor" Claude Host = "claude" Codex Host = "codex" + Gemini Host = "gemini" ) // AdapterVersion is the semantic version stamped onto normalized events so @@ -122,6 +123,26 @@ var ( Controllable: []string{"PreToolUse"}, }, }, + Gemini: { + 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 != "" + }, + ActionKinds: map[string]string{ + "BeforeTool": "shell", + }, + }, + Partition: ControlPartition{ + // In Gemini, the BeforeTool hook is synchronous (blocking). + Controllable: []string{"BeforeTool"}, + }, + }, } ) diff --git a/labs/15-pitot/pitot/cmd/pitot/main.go b/labs/15-pitot/pitot/cmd/pitot/main.go index f41ce6aba..aa8d13555 100644 --- a/labs/15-pitot/pitot/cmd/pitot/main.go +++ b/labs/15-pitot/pitot/cmd/pitot/main.go @@ -53,10 +53,10 @@ func run(args []string, stdout, stderr io.Writer) error { // from stdin, normalizes it, and exits with 0 (allow) or 2 (block/deny). func runHook(args []string, stdout, stderr io.Writer) error { if len(args) == 0 { - return fmt.Errorf("pitot: hook requires a host identifier (cursor, claude, codex)") + return fmt.Errorf("pitot: hook requires a host identifier (cursor, claude, codex, gemini)") } host := adapters.Host(args[0]) - if host != adapters.Cursor && host != adapters.Claude && host != adapters.Codex { + if host != adapters.Cursor && host != adapters.Claude && host != adapters.Codex && host != adapters.Gemini { return fmt.Errorf("pitot: unsupported hook host %q", host) } diff --git a/labs/15-pitot/pitot/cmd/pitot/main_test.go b/labs/15-pitot/pitot/cmd/pitot/main_test.go index 41e00db51..a6d82105c 100644 --- a/labs/15-pitot/pitot/cmd/pitot/main_test.go +++ b/labs/15-pitot/pitot/cmd/pitot/main_test.go @@ -14,7 +14,7 @@ func TestDoctorReportsBoundary(t *testing.T) { t.Fatalf("doctor: %v", err) } out := stdout.String() - for _, want := range []string{"local boundary", "cursor", "claude", "codex", "decoder=PASS", "unauthenticated local socket: none"} { + for _, want := range []string{"local boundary", "cursor", "claude", "codex", "gemini", "decoder=PASS", "unauthenticated local socket: none"} { if !strings.Contains(out, want) { t.Errorf("doctor output missing %q\n%s", want, out) } diff --git a/labs/15-pitot/pitot/doc.go b/labs/15-pitot/pitot/doc.go index 0825540cd..7e86b52c6 100644 --- a/labs/15-pitot/pitot/doc.go +++ b/labs/15-pitot/pitot/doc.go @@ -9,7 +9,7 @@ // // schema/ public event and response types + versioned constants // protocol/ newline-delimited JSON framing and state-machine helpers -// adapters/ Claude Code, Cursor, and Codex host boundaries +// adapters/ Claude Code, Cursor, Codex, and Gemini CLI host boundaries // sensor/ normalization and observation pipeline (decoder) // bridge/ controller routing and single-response transport // projection/ full, sha256, and omit content policies diff --git a/labs/15-pitot/pitot/e2e/e2e_hook_test.go b/labs/15-pitot/pitot/e2e/e2e_hook_test.go index dc0e94e27..ec7dfda5d 100644 --- a/labs/15-pitot/pitot/e2e/e2e_hook_test.go +++ b/labs/15-pitot/pitot/e2e/e2e_hook_test.go @@ -25,6 +25,7 @@ func TestE2ESensorsConformityAcrossAllAdapters(t *testing.T) { adapters.Claude: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, adapters.Cursor: `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, adapters.Codex: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`, } hosts := adapters.Supported() diff --git a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go index 86b4f9616..95bd99d26 100644 --- a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go +++ b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go @@ -29,6 +29,7 @@ var boatstackCanonicalEvents = map[adapters.Host]string{ adapters.Cursor: `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, adapters.Claude: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, adapters.Codex: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`, } func TestSensorConsumesBoatstackCanonicalEvents(t *testing.T) { diff --git a/labs/15-pitot/tests/e2e_gemini_cli_test.sh b/labs/15-pitot/tests/e2e_gemini_cli_test.sh new file mode 100755 index 000000000..53ab3b9a4 --- /dev/null +++ b/labs/15-pitot/tests/e2e_gemini_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "gemini"