From c38a8ed596b44a9aeeb129f64419214cbd7fd319 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 22 Jul 2026 15:36:45 +0100 Subject: [PATCH] Add four supervised Pitot adapters --- labs/15-pitot/adapter-verification.json | 6 +- labs/15-pitot/integrations/cline/PreToolUse | 29 ++++++++ .../integrations/cline/PreToolUse.ps1 | 23 ++++++ labs/15-pitot/integrations/pi/pitot.ts | 25 +++++++ .../15-pitot/pitot-distribution/UPSTREAM.json | 33 +++++---- .../2026-07-22-more-supervised-adapters.md | 5 ++ labs/15-pitot/pitot/adapters/adapters.go | 70 +++++++++++++++++++ labs/15-pitot/pitot/cmd/pitot/main.go | 2 +- labs/15-pitot/pitot/cmd/pitot/main_test.go | 2 +- .../pitot/conformance/fixtures/negative.jsonl | 3 + .../pitot/conformance/fixtures/positive.jsonl | 4 ++ labs/15-pitot/pitot/doc.go | 2 +- labs/15-pitot/pitot/e2e/e2e_hook_test.go | 16 ++--- .../15-pitot/pitot/sensor/decode_fuzz_test.go | 1 + labs/15-pitot/pitot/sensor/sensor.go | 5 +- labs/15-pitot/pitot/sensor/sensor_test.go | 6 +- .../pitot/windtunnel/windtunnel_test.go | 4 ++ labs/15-pitot/public-readme-preview/README.md | 64 +++++++++++++++-- labs/15-pitot/scripts/build_pitot.py | 14 ++++ labs/15-pitot/tests/e2e_cline_cli_test.sh | 2 + labs/15-pitot/tests/e2e_copilot_cli_test.sh | 2 + labs/15-pitot/tests/e2e_pi_cli_test.sh | 2 + labs/15-pitot/tests/e2e_qwen_cli_test.sh | 2 + labs/15-pitot/tests/e2e_unified_runner.sh | 57 ++++++++++++++- .../15-pitot/tests/test_adapter_supervisor.py | 6 +- labs/15-pitot/tests/test_e2e_reporting.py | 2 +- labs/15-pitot/tests/test_pitot_harness.py | 18 +++++ 27 files changed, 364 insertions(+), 41 deletions(-) create mode 100755 labs/15-pitot/integrations/cline/PreToolUse create mode 100644 labs/15-pitot/integrations/cline/PreToolUse.ps1 create mode 100644 labs/15-pitot/integrations/pi/pitot.ts create mode 100644 labs/15-pitot/pitot-distribution/release-notes/2026-07-22-more-supervised-adapters.md create mode 100755 labs/15-pitot/tests/e2e_cline_cli_test.sh create mode 100755 labs/15-pitot/tests/e2e_copilot_cli_test.sh create mode 100755 labs/15-pitot/tests/e2e_pi_cli_test.sh create mode 100755 labs/15-pitot/tests/e2e_qwen_cli_test.sh diff --git a/labs/15-pitot/adapter-verification.json b/labs/15-pitot/adapter-verification.json index 5566cff76..d3c0d9480 100644 --- a/labs/15-pitot/adapter-verification.json +++ b/labs/15-pitot/adapter-verification.json @@ -7,10 +7,14 @@ ], "agents": [ {"id": "claude", "label": "Claude"}, + {"id": "cline", "label": "Cline"}, {"id": "cursor", "label": "Cursor"}, {"id": "codex", "label": "Codex"}, + {"id": "copilot", "label": "GitHub Copilot CLI"}, {"id": "gemini", "label": "Gemini"}, {"id": "kimi", "label": "Kimi Code"}, - {"id": "opencode", "label": "OpenCode"} + {"id": "opencode", "label": "OpenCode"}, + {"id": "pi", "label": "Pi"}, + {"id": "qwen", "label": "Qwen Code"} ] } diff --git a/labs/15-pitot/integrations/cline/PreToolUse b/labs/15-pitot/integrations/cline/PreToolUse new file mode 100755 index 000000000..382cd04ec --- /dev/null +++ b/labs/15-pitot/integrations/cline/PreToolUse @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -uo pipefail + +PITOT_COMMAND="${PITOT_BIN:-pitot}" +PAYLOAD=$(cat) +if ! TOOL=$(printf '%s' "$PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("preToolUse", {}).get("tool", ""))' 2>/dev/null); then + printf '{"cancel":true,"errorMessage":"Pitot received a malformed Cline hook payload"}\n' + exit 0 +fi + +if [ -z "$TOOL" ]; then + printf '{"cancel":true,"errorMessage":"Pitot received a malformed Cline hook payload"}\n' + exit 0 +fi + +# Cline has no matcher at this boundary. Non-shell tools are outside Pitot's +# controllable partition and must pass through unchanged. +if [ "$TOOL" != "execute_command" ]; then + printf '{"cancel":false}\n' + exit 0 +fi + +if printf '%s' "$PAYLOAD" | "$PITOT_COMMAND" hook cline >/dev/null; then + printf '{"cancel":false}\n' + exit 0 +fi + +printf '{"cancel":true,"errorMessage":"Pitot rejected the shell request"}\n' +exit 0 diff --git a/labs/15-pitot/integrations/cline/PreToolUse.ps1 b/labs/15-pitot/integrations/cline/PreToolUse.ps1 new file mode 100644 index 000000000..8a0653768 --- /dev/null +++ b/labs/15-pitot/integrations/cline/PreToolUse.ps1 @@ -0,0 +1,23 @@ +$payload = [Console]::In.ReadToEnd() +$pitot = if ($env:PITOT_BIN) { $env:PITOT_BIN } else { "pitot" } +try { + $event = $payload | ConvertFrom-Json -ErrorAction Stop +} catch { + @{ cancel = $true; errorMessage = "Pitot received a malformed Cline hook payload" } | ConvertTo-Json -Compress + exit 0 +} +if (-not $event.preToolUse.tool) { + @{ cancel = $true; errorMessage = "Pitot received a malformed Cline hook payload" } | ConvertTo-Json -Compress + exit 0 +} +if ($event.preToolUse.tool -ne "execute_command") { + @{ cancel = $false } | ConvertTo-Json -Compress + exit 0 +} +$payload | & $pitot hook cline *> $null +if ($LASTEXITCODE -eq 0) { + @{ cancel = $false } | ConvertTo-Json -Compress +} else { + @{ cancel = $true; errorMessage = "Pitot rejected the shell request" } | ConvertTo-Json -Compress +} +exit 0 diff --git a/labs/15-pitot/integrations/pi/pitot.ts b/labs/15-pitot/integrations/pi/pitot.ts new file mode 100644 index 000000000..f802dc226 --- /dev/null +++ b/labs/15-pitot/integrations/pi/pitot.ts @@ -0,0 +1,25 @@ +import { spawnSync } from "node:child_process"; + +// handleToolCall is exported so the shipped boundary can be tested without a +// live Pi session. Pi itself calls the default extension registration below. +export function handleToolCall(event, run = spawnSync) { + if (event.toolName !== "bash") return undefined; + + const payload = JSON.stringify({ + hook_event_name: "tool_call", + tool_name: "bash", + tool_input: { command: event.input?.command ?? "" }, + }); + const result = run(process.env.PITOT_BIN || "pitot", ["hook", "pi"], { + input: payload, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + if (result.status === 0) return undefined; + const reason = (result.stderr || "Pitot rejected the shell request").trim(); + return { block: true, reason: reason.slice(0, 1024) }; +} + +export default function pitotExtension(pi) { + pi.on("tool_call", async (event) => handleToolCall(event)); +} diff --git a/labs/15-pitot/pitot-distribution/UPSTREAM.json b/labs/15-pitot/pitot-distribution/UPSTREAM.json index 53f8f6150..b70201804 100644 --- a/labs/15-pitot/pitot-distribution/UPSTREAM.json +++ b/labs/15-pitot/pitot-distribution/UPSTREAM.json @@ -1,8 +1,8 @@ { "files": { "CONTRIBUTING.md": "0613b71aa497f8ca7d7296bf34ade87bfc7237a664d2e812d9b77b3b6befb0ad", - "README.md": "99e2c764ec29dfcdb42ff09caa6583d0de9192dc34460a8a563d8ae5ea47dfa0", - "adapters/adapters.go": "b516fdd0fdd805a08cb1467f78ee367d5f280d0084a55169d09c6f3d0b795fc0", + "README.md": "098e759d255de328e4de2383738ea920b57d914d6e691c52329ffc7c881930bc", + "adapters/adapters.go": "57a7e3a50c464a4124e3f3231e1843b43f724dcd60316f2c52dd1c65043e9533", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", "assets/pitot-boundary.svg": "0c3871d70c84748573f231842091deb38a6def2862403ca34e8cc4493b9c9ebf", "assets/pitot-hero.png": "a73532252b1e66c06273abbf5a4fe6261e98de3133b09e8d550edacfeeab92f8", @@ -13,20 +13,23 @@ "bridge/bridge.go": "79ac2e025e16782f3c283b43cbea5b9ba4f837446583864df8f57d6346cae816", "bridge/bridge_test.go": "23a19b7580d4b1e826224ec8322208ccca44ddd9a97b150efe15cafecc53e47f", "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", - "cmd/pitot/main.go": "20542d225d3676edd7d4129db0b7009ef2c93a76b5dbd633ff2fcf1eef611775", - "cmd/pitot/main_test.go": "203ce391b9996e81a697f37c913157da1dff8db7ec5e2d7f80d4729c1841239c", + "cmd/pitot/main.go": "aa921d65706ea77b2c04aeddf6a482886ecf25f2c119eb0922edb4278aa53740", + "cmd/pitot/main_test.go": "377406a4c1479b4017505540164795dd224cba64c77a5cfa0297a9517194d940", "conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb", "conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd", - "conformance/fixtures/negative.jsonl": "383dd001910699886bb1074d9225c91d8a6201e9fb3267d1a1f6e1c2753b0ba6", - "conformance/fixtures/positive.jsonl": "d3af0f2529dac9b33fa4900f5938e36fd0b17383088dd4f0de7e6eca269441d1", - "doc.go": "9ef34dc37b30363272dbc870482154aed92187f1bdeb4d3683f871dde2c3e4b5", + "conformance/fixtures/negative.jsonl": "c8eff4f4155a5e86e7127a8d89aa06c14ecba74a7230b5bf7134ab1b7c4ca5f7", + "conformance/fixtures/positive.jsonl": "300f40a47e6b72324b76c57a351d3be7ff7a2592e73f02cfe7e66c0e1defdf6d", + "doc.go": "a8abdafac969b1bf4372c8bb023aa51125dc073f03218f4ab9913dfc5ffa877d", "e2e/e2e_coverage_test.go": "28a6c27408338fdc51cf1241e1bf42a7f0ea17d3fb1305fbcd15901c21e21de8", - "e2e/e2e_hook_test.go": "6092f9a383657345587723cdfeebfcf8bbe87b6e441d595ad9d11ae06b95edb0", + "e2e/e2e_hook_test.go": "5e184dc8907b6e36daeab90bbbb1654fa5336312866412031805a13ba535d1f8", "examples/doc.go": "58f3f9eb7d272d7b6eecdb05f43e1613d5e3ef92d15d97c5440bd4b6990c26f9", "examples/local-approval/main.go": "51386af324cd7d3bb07fe3ace53503884b02714b96b83073342fde81ce3b83a5", "examples/token-meter/main.go": "4b1b9c1a43c3cf48b09dba6f607776caced9d2b5b562373496b31ed184582dd1", "go.mod": "7e71b29887a2370c920a3ecad84460130ecc5ad4acba7f62300b5d2568f0ee13", "go.sum": "2f73a6c3c672f4022f4a618578fac165172095ef590db5d14caa4552675f1980", + "integrations/cline/PreToolUse": "05a449b402918876c5e945e837525b9613a4889fad932e91193b3f63d2778e22", + "integrations/cline/PreToolUse.ps1": "bc0cea5f2cf6e910a2751465090a1a3c37cf0eea8ab4b98b5e735738630548f0", + "integrations/pi/pitot.ts": "ed2d60d5ab6e33e115cfa058e4f96095100e93a061567d0af31249aa756bab3e", "projection/projection.go": "4d3c823fd72a3ca5387dba3683838a1d7e455e9b18309acc839763a39b7bb35f", "protocol/framing.go": "d4409314b72e09cfd472ad9a21d4c7a223b4341b26f58f6062a7c899e3f87482", "protocol/framing_test.go": "9fcb13767fdcc7129d2c87bc2133a5800c69d6ac0166016dc266dca5fcfaa1c5", @@ -38,19 +41,23 @@ "sdk/typescript/src/index.ts": "de43e6654eac51afd09a3a74c4c6992dcf2306d6c41b9be626ded1c6d6cc7833", "sdk/typescript/src/pitot.ts": "9c243824cbb7edc54b1e125abfd828bf2ed77e7151a0bd5c5d4f63e81ca9b00c", "sdk/typescript/tsconfig.json": "e4d7ecb203fcb7d93cd9b9fb235d7fd75d1b6fb2b28eff773f4aecfdc1924d5d", - "sensor/decode_fuzz_test.go": "6f3412865c16e3314980a369c36f1f26ba36a991c8b4a8a9d3b776ad9c0de2b0", - "sensor/sensor.go": "939e146eaa51906972f98cf4615747eb75811b0c54d467a938825750336abeae", - "sensor/sensor_test.go": "ea9a58d45ad56d29214a40fb2cfa935e769f85334afcb1856a20fb938d486245", + "sensor/decode_fuzz_test.go": "630064cb7b7e783c25f22f70a34bc5d3583180ba4b59bad360679f21bdf5cb37", + "sensor/sensor.go": "5c503d07ac33e7894d635f2d98bcd6d165d442d60c127ed6aeac80ec319086c5", + "sensor/sensor_test.go": "4ddbdde3e486c9e189a2ed4174ad413df107d43dc98f5242da3667a24c7a5da1", "tests/e2e_claude_cli_test.sh": "70711580004b6f77be58f036f42669ae90e2883b62535ab70719f265168ce66e", + "tests/e2e_cline_cli_test.sh": "659794dd4f4e92d93264c58902ffc10db6cceb6659593819dad66018c187eb3b", "tests/e2e_codex_cli_test.sh": "f5d49b83bb2f9f49a7e0005f4bd7a7bdd0f4130e68ba75a0cd4457d3e9beb175", + "tests/e2e_copilot_cli_test.sh": "2d9ad18993917ee7d2f7f84797a3e31bd31719b7f1834168b7a32978e05404ea", "tests/e2e_cursor_cli_test.sh": "091a334a5008b88b94a9a2dafdeaf1388071bd436ef1a9b3376d0f0b28ed60a0", "tests/e2e_gemini_cli_test.sh": "1559c6b50c5c043bb7a2a901e726986eec87d97dcdda026158d2f138365b8a4f", "tests/e2e_kimi_cli_test.sh": "591b796ea88f1353ede0663f641044fe2e00a01528c26097b63a5d75ec350040", "tests/e2e_opencode_cli_test.sh": "bfa4854fc6309fdbdb7a45fbb017ffadb7cb193604efc86ae0bcdfc55c4034f8", - "tests/e2e_unified_runner.sh": "7592f1ac6a5a1da0fb2ba4e0d1bbf8bdfa20c4d5b614069d77773dd8a1a58c20", + "tests/e2e_pi_cli_test.sh": "77793e34e2b2c6c5ca25772a9f34a37fb2e906b9e353f748a1aa9da943b71617", + "tests/e2e_qwen_cli_test.sh": "c08719ce9e8dab05cce2a91d52a479bc566599d38bd3f30e4c279cc6ff622040", + "tests/e2e_unified_runner.sh": "8c37e454a66145d69624bc10db1c3c6568f78ec15acb619b64bea48cb7c9a408", "tests/mock_anthropic_server.js": "ecebea62f9e93791a79f1ae3dd3c67b8fa42490e9805b23b662b877edfdb0f0e", "windtunnel/doc.go": "44e0bcde632da73e1f8b98beade3a34ca8e0d0ea79cdfb91d131de290b164fc4", - "windtunnel/windtunnel_test.go": "e18a1f0d8afbb32d45f16c6f081b1a6b2f1b82aa5262f70d8b51e6db3e8e87d7" + "windtunnel/windtunnel_test.go": "e987b3dacd6fdb05aa021bd15ff2c1e1f87b99f4a65aa28e9b74a93bb9b79c1a" }, "schema_version": 1 } diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-22-more-supervised-adapters.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-22-more-supervised-adapters.md new file mode 100644 index 000000000..3c43cca02 --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-22-more-supervised-adapters.md @@ -0,0 +1,5 @@ +### Supervise four more coding agents + +Pitot now ships verified hook boundaries for GitHub Copilot CLI, Qwen Code, Pi, +and Cline. The single adapter supervisor requires each integration across the +registry, public distribution, E2E report, and all three supported platforms. diff --git a/labs/15-pitot/pitot/adapters/adapters.go b/labs/15-pitot/pitot/adapters/adapters.go index 5b04f8a52..82ca96f00 100644 --- a/labs/15-pitot/pitot/adapters/adapters.go +++ b/labs/15-pitot/pitot/adapters/adapters.go @@ -29,6 +29,10 @@ const ( Gemini Host = "gemini" Opencode Host = "opencode" Kimi Host = "kimi" + Copilot Host = "copilot" + Qwen Host = "qwen" + Pi Host = "pi" + Cline Host = "cline" ) // AdapterVersion is the semantic version stamped onto normalized events so @@ -39,6 +43,7 @@ const AdapterVersion = "0.1.0" // into normalized action types and command strings. type ParserConfig struct { CanonicalEvent []byte + EventNameFor func(raw RawHookEvent) string CommandFor func(raw RawHookEvent) (string, bool) ActionKinds map[string]string // hook event name -> normalized action kind ("shell", "mcp") } @@ -68,6 +73,33 @@ type HostConfig struct { var ( registryMu sync.RWMutex registry = map[Host]HostConfig{ + Copilot: preToolUseHost(), + Qwen: preToolUseHost(), + Pi: { + MainEventName: "tool_call", + Parser: ParserConfig{ + CanonicalEvent: []byte(`{"hook_event_name":"tool_call","tool_name":"bash","tool_input":{"command":"git status --short"}}`), + CommandFor: toolInputCommand, + ActionKinds: map[string]string{"tool_call": "shell"}, + }, + Partition: ControlPartition{Controllable: []string{"tool_call"}}, + }, + Cline: { + MainEventName: "PreToolUse", + Parser: ParserConfig{ + CanonicalEvent: []byte(`{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"git status --short"}}}`), + EventNameFor: func(raw RawHookEvent) string { return raw.HookName }, + CommandFor: func(raw RawHookEvent) (string, bool) { + if raw.PreToolUse.Tool != "execute_command" { + return "", false + } + value, present := raw.PreToolUse.Parameters["command"].(string) + return value, present && value != "" + }, + ActionKinds: map[string]string{"PreToolUse": "shell"}, + }, + Partition: ControlPartition{Controllable: []string{"PreToolUse"}}, + }, Cursor: { MainEventName: "beforeShellExecution", Parser: ParserConfig{ @@ -188,6 +220,28 @@ var ( } ) +func toolInputCommand(raw RawHookEvent) (string, bool) { + value, present := raw.ToolInput["command"].(string) + return value, present && value != "" +} + +func preToolUseHost() HostConfig { + return HostConfig{ + 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.ToolName != "Bash" { + return "", false + } + return toolInputCommand(raw) + }, + ActionKinds: map[string]string{"PreToolUse": "shell"}, + }, + Partition: ControlPartition{Controllable: []string{"PreToolUse"}}, + } +} + // RegisterHost registers a custom host with its configuration, enforcing // that it complies with the required supervisory control laws. func RegisterHost(h Host, config HostConfig) error { @@ -306,6 +360,22 @@ type RawHookEvent struct { // ToolName / ToolInput are populated by PreToolUse-style hosts and Gemini. ToolName string `json:"tool_name"` ToolInput map[string]any `json:"tool_input"` + // HookName / PreToolUse preserve Cline's native nested hook shape. + HookName string `json:"hookName"` + PreToolUse struct { + Tool string `json:"tool"` + Parameters map[string]any `json:"parameters"` + } `json:"preToolUse"` +} + +// EventNameFor extracts the host-specific event discriminator. +func (h Host) EventNameFor(raw RawHookEvent) string { + registryMu.RLock() + defer registryMu.RUnlock() + if config, exists := registry[h]; exists && config.Parser.EventNameFor != nil { + return config.Parser.EventNameFor(raw) + } + return raw.HookEventName } // CommandFor extracts the shell command a raw hook event describes, per host diff --git a/labs/15-pitot/pitot/cmd/pitot/main.go b/labs/15-pitot/pitot/cmd/pitot/main.go index ee9544dbd..3320dbd60 100644 --- a/labs/15-pitot/pitot/cmd/pitot/main.go +++ b/labs/15-pitot/pitot/cmd/pitot/main.go @@ -53,7 +53,7 @@ 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, gemini, kimi, opencode)") + return fmt.Errorf("pitot: hook requires a host identifier (claude, cline, codex, copilot, cursor, gemini, kimi, opencode, pi, qwen)") } host := adapters.Host(args[0]) if !adapters.IsSupported(host) { diff --git a/labs/15-pitot/pitot/cmd/pitot/main_test.go b/labs/15-pitot/pitot/cmd/pitot/main_test.go index 527d8fb56..53e705015 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", "gemini", "kimi", "opencode", "decoder=PASS", "unauthenticated local socket: none"} { + for _, want := range []string{"local boundary", "claude", "cline", "codex", "copilot", "cursor", "gemini", "kimi", "opencode", "pi", "qwen", "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/conformance/fixtures/negative.jsonl b/labs/15-pitot/pitot/conformance/fixtures/negative.jsonl index 3e697155c..1fdc37b45 100644 --- a/labs/15-pitot/pitot/conformance/fixtures/negative.jsonl +++ b/labs/15-pitot/pitot/conformance/fixtures/negative.jsonl @@ -5,3 +5,6 @@ {"name":"claude-null-tool-input","host":"claude","mode":"omit","input":{"hook_event_name":"PreToolUse","tool_name":"Bash"},"reason":"empty-command"} {"name":"mismatched-event-name","host":"claude","mode":"omit","input":{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}},"reason":"malformed-event"} {"name":"unsupported-host","host":"aider","mode":"omit","input":{"hook_event_name":"PreToolUse"},"reason":"unsupported-host"} +{"name":"cline-mismatched-event","host":"cline","mode":"omit","input":{"hookName":"PostToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"ls"}}},"reason":"malformed-event"} +{"name":"cline-non-shell-tool","host":"cline","mode":"omit","input":{"hookName":"PreToolUse","preToolUse":{"tool":"read_file","parameters":{"command":"ls"}}},"reason":"empty-command"} +{"name":"qwen-non-shell-tool","host":"qwen","mode":"omit","input":{"hook_event_name":"PreToolUse","tool_name":"ReadFile","tool_input":{"command":"ls"}},"reason":"empty-command"} diff --git a/labs/15-pitot/pitot/conformance/fixtures/positive.jsonl b/labs/15-pitot/pitot/conformance/fixtures/positive.jsonl index c1cd871e2..85edd1339 100644 --- a/labs/15-pitot/pitot/conformance/fixtures/positive.jsonl +++ b/labs/15-pitot/pitot/conformance/fixtures/positive.jsonl @@ -3,3 +3,7 @@ {"name":"claude-pre-tool","host":"claude","mode":"sha256","input":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}},"expect_kind":"shell"} {"name":"codex-pre-tool","host":"codex","mode":"full","input":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}},"expect_kind":"shell"} {"name":"claude-missing-event-name-tolerated","host":"claude","mode":"omit","input":{"tool_name":"Bash","tool_input":{"command":"pwd"}},"expect_kind":"shell"} +{"name":"copilot-pre-tool","host":"copilot","mode":"sha256","input":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}},"expect_kind":"shell"} +{"name":"qwen-pre-tool","host":"qwen","mode":"sha256","input":{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}},"expect_kind":"shell"} +{"name":"pi-tool-call","host":"pi","mode":"sha256","input":{"hook_event_name":"tool_call","tool_name":"bash","tool_input":{"command":"git status --short"}},"expect_kind":"shell"} +{"name":"cline-pre-tool","host":"cline","mode":"sha256","input":{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"git status --short"}}},"expect_kind":"shell"} diff --git a/labs/15-pitot/pitot/doc.go b/labs/15-pitot/pitot/doc.go index 6b9cdaa4b..9b3d533fc 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, Codex, Gemini CLI, Kimi Code, and OpenCode host boundaries +// adapters/ supervised coding-agent host boundaries and payload normalization // 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 4e345e956..fbefc8cbc 100644 --- a/labs/15-pitot/pitot/e2e/e2e_hook_test.go +++ b/labs/15-pitot/pitot/e2e/e2e_hook_test.go @@ -21,23 +21,17 @@ func TestE2ESensorsConformityAcrossAllAdapters(t *testing.T) { h.Write([]byte(cmdToVerify)) expectedHash := fmt.Sprintf("%x", h.Sum(nil)) - rawHostPayloads := map[adapters.Host]string{ - 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"}}`, - adapters.Kimi: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, - adapters.Opencode: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, - } - hosts := adapters.Supported() for _, host := range hosts { - payload := rawHostPayloads[host] + payload, err := adapters.CanonicalHookEvent(host) + if err != nil { + t.Fatalf("canonical payload for %s: %v", host, err) + } for _, mode := range []projection.Mode{projection.Full, projection.SHA256} { testName := fmt.Sprintf("%s/%s", host, mode) t.Run(testName, func(t *testing.T) { - event, err := sensor.Decode(host, []byte(payload), mode) + event, err := sensor.Decode(host, payload, mode) if err != nil { t.Fatalf("sensor decoding failed: %v", err) } diff --git a/labs/15-pitot/pitot/sensor/decode_fuzz_test.go b/labs/15-pitot/pitot/sensor/decode_fuzz_test.go index 44e1ef1ee..6240d8c1e 100644 --- a/labs/15-pitot/pitot/sensor/decode_fuzz_test.go +++ b/labs/15-pitot/pitot/sensor/decode_fuzz_test.go @@ -22,6 +22,7 @@ func FuzzDecode(f *testing.F) { []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}`), []byte(`{"hook_event_name":"PreToolUse","tool_input":null}`), []byte(`{"hook_event_name":123}`), + []byte(`{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"ls"}}}`), []byte(`[]`), } for _, seed := range seeds { diff --git a/labs/15-pitot/pitot/sensor/sensor.go b/labs/15-pitot/pitot/sensor/sensor.go index 1db52c2f8..475ef33e9 100644 --- a/labs/15-pitot/pitot/sensor/sensor.go +++ b/labs/15-pitot/pitot/sensor/sensor.go @@ -57,7 +57,8 @@ func Decode(host adapters.Host, raw []byte, mode projection.Mode) (schema.Event, } // An empty or mismatched event name is a malformed boundary, not a decision. - if event.HookEventName != "" && !host.HasHookEvent(event.HookEventName) { + eventName := host.EventNameFor(event) + if eventName != "" && !host.HasHookEvent(eventName) { return schema.Event{}, &FaultError{Host: host, Reason: schema.ReasonMalformed} } @@ -78,7 +79,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(event.HookEventName)}, + Action: &schema.Action{Kind: host.ActionKind(eventName)}, Content: &content, Observation: schema.Observation{ Source: schema.SourceHostHook, diff --git a/labs/15-pitot/pitot/sensor/sensor_test.go b/labs/15-pitot/pitot/sensor/sensor_test.go index c166ce649..e31ddc942 100644 --- a/labs/15-pitot/pitot/sensor/sensor_test.go +++ b/labs/15-pitot/pitot/sensor/sensor_test.go @@ -86,7 +86,7 @@ func TestDecodeFullProjectionCarriesCommand(t *testing.T) { } func TestRegisterCustomHostAndDecode(t *testing.T) { - customHost := adapters.Host("copilot") + customHost := adapters.Host("custom-copilot") config := adapters.HostConfig{ MainEventName: "preShellExec", Parser: adapters.ParserConfig{ @@ -118,8 +118,8 @@ func TestRegisterCustomHostAndDecode(t *testing.T) { t.Fatalf("decode custom host: %v", err) } - if event.Host.Name != "copilot" { - t.Errorf("host name = %q, want copilot", event.Host.Name) + if event.Host.Name != "custom-copilot" { + t.Errorf("host name = %q, want custom-copilot", event.Host.Name) } if event.Action.Kind != "shell" { t.Errorf("action kind = %q, want shell", event.Action.Kind) diff --git a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go index 010b9a485..16426c269 100644 --- a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go +++ b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go @@ -32,6 +32,10 @@ var boatstackCanonicalEvents = map[adapters.Host]string{ adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`, adapters.Kimi: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, adapters.Opencode: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Copilot: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Qwen: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Pi: `{"hook_event_name":"tool_call","tool_name":"bash","tool_input":{"command":"git status --short"}}`, + adapters.Cline: `{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"git status --short"}}}`, } func TestSensorConsumesBoatstackCanonicalEvents(t *testing.T) { diff --git a/labs/15-pitot/public-readme-preview/README.md b/labs/15-pitot/public-readme-preview/README.md index 882be1f2a..47fc91436 100644 --- a/labs/15-pitot/public-readme-preview/README.md +++ b/labs/15-pitot/public-readme-preview/README.md @@ -13,11 +13,11 @@

Every supervised adapter is required on Ubuntu, macOS, and Windows.

-

Supervised adapters: Claude · Cursor · Codex · Gemini · Kimi Code · OpenCode

+

Supervised adapters: Claude · Cline · Cursor · Codex · GitHub Copilot CLI · Gemini · Kimi Code · OpenCode · Pi · Qwen Code

- One language-neutral boundary for Claude Code, Cursor, Codex, Gemini, Kimi Code, OpenCode, and compatible runtimes. + One language-neutral boundary for the coding agents your team already uses.

Pitot lets you build above coding agents without rebuilding every host @@ -205,9 +205,63 @@ kimi -p "Show the repository status" ``` See the [Kimi Code documentation](https://www.kimi.com/code/docs/en/) for CLI -authentication, configuration, and hook behavior. The adapter has deterministic -local hook-conformance coverage; a dedicated live-Kimi platform E2E workflow is -not yet claimed by the badges above. +authentication, configuration, and hook behavior. + +### GitHub Copilot CLI + +Add the following Claude-compatible hook to `~/.copilot/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "pitot hook copilot"}] + }] + } +} +``` + +The PascalCase event keeps the blocking payload on Pitot's standard +`hook_event_name` and `tool_input.command` boundary. See the official +[Copilot CLI hooks reference](https://docs.github.com/en/copilot/reference/hooks-reference). + +### Qwen Code + +Add this command hook to `~/.qwen/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [{ + "matcher": "^Bash$", + "hooks": [{"type": "command", "command": "pitot hook qwen"}] + }] + } +} +``` + +Qwen sends the native JSON payload on standard input and honors Pitot's +blocking exit status. See the official +[Qwen Code hooks guide](https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/). + +### Pi + +Copy `integrations/pi/pitot.ts` into `~/.pi/agent/extensions/pitot.ts` (or the +repository-local `.pi/extensions/` directory). The extension converts Pi's +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). + +### Cline + +Copy `integrations/cline/PreToolUse` to `~/Documents/Cline/Hooks/PreToolUse` on +macOS or Linux and make it executable. On Windows, copy `PreToolUse.ps1` into +that directory instead. Enable hooks in Cline's Hooks tab or run +`cline config set hooks-enabled=true`. These +bridges pass Cline's native nested payload to `pitot hook cline` and translate +the result into Cline's `cancel` response. See the official +[Cline hooks documentation](https://docs.cline.bot/customization/hooks). Pitot uses supervised local processes in v1. It starts declared Consumers and Controllers itself, applies each projection before bytes enter the child pipe, diff --git a/labs/15-pitot/scripts/build_pitot.py b/labs/15-pitot/scripts/build_pitot.py index cb44ea670..08f6e2c86 100644 --- a/labs/15-pitot/scripts/build_pitot.py +++ b/labs/15-pitot/scripts/build_pitot.py @@ -26,6 +26,7 @@ MODULE = LAB / "pitot" PREVIEW = LAB / "public-readme-preview" TESTS = LAB / "tests" +INTEGRATIONS = LAB / "integrations" MANIFEST_RELATIVE = LAB / "pitot-distribution" / "UPSTREAM.json" # Source files excluded from the public surface. The module is published in full @@ -93,6 +94,18 @@ def _iter_e2e_harness_files(repo: Path) -> list[tuple[str, Path]]: return [(f"tests/{path.name}", path) for path in sources] +def _iter_integration_files(repo: Path) -> list[tuple[str, Path]]: + """Yield public host bridges that cannot invoke Pitot directly.""" + root = repo / INTEGRATIONS + if not root.is_dir(): + return [] + return [ + (path.relative_to(repo / LAB).as_posix(), path) + for path in sorted(root.rglob("*")) + if path.is_file() and not path.is_symlink() + ] + + def compute_manifest(repo: Path) -> dict[str, str]: """Return {projected_path: sha256} for the whole public surface.""" repo = repo.resolve() @@ -102,6 +115,7 @@ def compute_manifest(repo: Path) -> dict[str, str]: + _iter_preview_files(repo) + _iter_sdk_files(repo) + _iter_e2e_harness_files(repo) + + _iter_integration_files(repo) ) for projected, source in sources: if projected in surface: diff --git a/labs/15-pitot/tests/e2e_cline_cli_test.sh b/labs/15-pitot/tests/e2e_cline_cli_test.sh new file mode 100755 index 000000000..35ad78167 --- /dev/null +++ b/labs/15-pitot/tests/e2e_cline_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "cline" diff --git a/labs/15-pitot/tests/e2e_copilot_cli_test.sh b/labs/15-pitot/tests/e2e_copilot_cli_test.sh new file mode 100755 index 000000000..3cce2b69f --- /dev/null +++ b/labs/15-pitot/tests/e2e_copilot_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "copilot" diff --git a/labs/15-pitot/tests/e2e_pi_cli_test.sh b/labs/15-pitot/tests/e2e_pi_cli_test.sh new file mode 100755 index 000000000..698cce55d --- /dev/null +++ b/labs/15-pitot/tests/e2e_pi_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "pi" diff --git a/labs/15-pitot/tests/e2e_qwen_cli_test.sh b/labs/15-pitot/tests/e2e_qwen_cli_test.sh new file mode 100755 index 000000000..7a850e46d --- /dev/null +++ b/labs/15-pitot/tests/e2e_qwen_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "qwen" diff --git a/labs/15-pitot/tests/e2e_unified_runner.sh b/labs/15-pitot/tests/e2e_unified_runner.sh index f0c015851..3a56b6e7e 100755 --- a/labs/15-pitot/tests/e2e_unified_runner.sh +++ b/labs/15-pitot/tests/e2e_unified_runner.sh @@ -4,7 +4,7 @@ set -euo pipefail HOST="${1:-}" if [ -z "$HOST" ]; then - echo "ERROR: Missing host argument (claude, cursor, codex, gemini, kimi, opencode)" + echo "ERROR: Missing host argument (claude, cline, codex, copilot, cursor, gemini, kimi, opencode, pi, qwen)" exit 1 fi @@ -336,6 +336,61 @@ CONFIG_EOF fi ;; + "copilot"|"qwen") + PAYLOAD='{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}' + OUTPUT=$(echo "$PAYLOAD" | "$PITOT_ABS_PATH" hook "$HOST" 2>&1) + if echo "$OUTPUT" | grep -q '"type":"action.requested"'; then + echo "===> [SUCCESS] $HOST active subprocess hook verification passed!" + echo "PITOT_E2E_RESULT mode=hook_subprocess" + exit 0 + fi + echo "===> [FAILURE] $HOST active subprocess hook verification failed: $OUTPUT" + exit 1 + ;; + + "pi") + PI_MODULE="$MOCK_HOME/pitot.mjs" + cp labs/15-pitot/integrations/pi/pitot.ts "$PI_MODULE" + PI_OUTPUT=$(PITOT_BIN="$PITOT_ABS_PATH" node --input-type=module - "$PI_MODULE" <<'NODE' +import { pathToFileURL } from "node:url"; +const modulePath = process.argv[2]; +const { handleToolCall } = await import(pathToFileURL(modulePath)); +const allow = handleToolCall({toolName: "bash", input: {command: "git status"}}); +const block = handleToolCall({toolName: "bash", input: {command: ""}}); +console.log(JSON.stringify({allow: allow === undefined, block: block?.block === true})); +NODE + ) + if echo "$PI_OUTPUT" | grep -q '"allow":true,"block":true'; then + echo "===> [SUCCESS] Pi extension allow/block translation passed!" + echo "PITOT_E2E_RESULT mode=hook_subprocess" + exit 0 + fi + echo "===> [FAILURE] Pi extension verification failed: $PI_OUTPUT" + exit 1 + ;; + + "cline") + ALLOW_PAYLOAD='{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":"git status"}}}' + BLOCK_PAYLOAD='{"hookName":"PreToolUse","preToolUse":{"tool":"execute_command","parameters":{"command":""}}}' + PASS_PAYLOAD='{"hookName":"PreToolUse","preToolUse":{"tool":"read_file","parameters":{"path":"README.md"}}}' + if [ "${RUNNER_OS:-}" = "Windows" ] && command -v pwsh &>/dev/null; then + ALLOW_OUTPUT=$(echo "$ALLOW_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" pwsh -NoProfile -File labs/15-pitot/integrations/cline/PreToolUse.ps1) + BLOCK_OUTPUT=$(echo "$BLOCK_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" pwsh -NoProfile -File labs/15-pitot/integrations/cline/PreToolUse.ps1) + PASS_OUTPUT=$(echo "$PASS_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" pwsh -NoProfile -File labs/15-pitot/integrations/cline/PreToolUse.ps1) + else + ALLOW_OUTPUT=$(echo "$ALLOW_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" bash labs/15-pitot/integrations/cline/PreToolUse) + BLOCK_OUTPUT=$(echo "$BLOCK_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" bash labs/15-pitot/integrations/cline/PreToolUse) + PASS_OUTPUT=$(echo "$PASS_PAYLOAD" | PITOT_BIN="$PITOT_ABS_PATH" bash labs/15-pitot/integrations/cline/PreToolUse) + fi + if echo "$ALLOW_OUTPUT" | grep -q '"cancel":false' && echo "$BLOCK_OUTPUT" | grep -q '"cancel":true' && echo "$PASS_OUTPUT" | grep -q '"cancel":false'; then + echo "===> [SUCCESS] Cline bridge allow/block/non-shell translation passed!" + echo "PITOT_E2E_RESULT mode=hook_subprocess" + exit 0 + fi + echo "===> [FAILURE] Cline bridge verification failed: $ALLOW_OUTPUT / $BLOCK_OUTPUT" + exit 1 + ;; + *) echo "ERROR: Unsupported host $HOST" exit 1 diff --git a/labs/15-pitot/tests/test_adapter_supervisor.py b/labs/15-pitot/tests/test_adapter_supervisor.py index 5d4d6baef..d58a85a85 100644 --- a/labs/15-pitot/tests/test_adapter_supervisor.py +++ b/labs/15-pitot/tests/test_adapter_supervisor.py @@ -55,11 +55,15 @@ def test_real_repository_contract_is_complete(self): def test_matrix_contains_every_agent_on_every_platform(self): manifest = supervisor.load_manifest(ROOT) matrix = supervisor.matrix(manifest) - self.assertEqual(len(matrix), 18) + self.assertEqual(len(matrix), 30) self.assertEqual( {(item["agent"], item["platform"]) for item in matrix if item["agent"] == "kimi"}, {("kimi", "ubuntu"), ("kimi", "macos"), ("kimi", "windows")}, ) + self.assertEqual( + {(item["agent"], item["platform"]) for item in matrix if item["agent"] in {"cline", "copilot", "pi", "qwen"}}, + {(agent, platform) for agent in {"cline", "copilot", "pi", "qwen"} for platform in {"ubuntu", "macos", "windows"}}, + ) def test_missing_and_extra_inventory_entries_fail(self): with tempfile.TemporaryDirectory() as temp: diff --git a/labs/15-pitot/tests/test_e2e_reporting.py b/labs/15-pitot/tests/test_e2e_reporting.py index 2a2dad60b..a97825029 100644 --- a/labs/15-pitot/tests/test_e2e_reporting.py +++ b/labs/15-pitot/tests/test_e2e_reporting.py @@ -168,7 +168,7 @@ def test_missing_artifacts_fail_every_inventory_cell(self): "a" * 40, "https://github.com/operatorstack/intelligence-flow/actions/runs/1", ) - self.assertEqual(len(results), 6) + self.assertEqual(len(results), 10) self.assertTrue( all( result["status"] == "fail" diff --git a/labs/15-pitot/tests/test_pitot_harness.py b/labs/15-pitot/tests/test_pitot_harness.py index 7e4d0e840..6b2315c27 100644 --- a/labs/15-pitot/tests/test_pitot_harness.py +++ b/labs/15-pitot/tests/test_pitot_harness.py @@ -46,6 +46,12 @@ def _fake_repo(root: Path) -> Path: (tests / "e2e_unified_runner.sh").write_text("#!/usr/bin/env bash\n") (tests / "mock_anthropic_server.js").write_text("// mock\n") (tests / "test_internal.py").write_text("# not projected\n") + integrations = root / "labs/15-pitot/integrations/pi" + integrations.mkdir(parents=True) + (integrations / "pitot.ts").write_text("// bridge\n") + cline = root / "labs/15-pitot/integrations/cline" + cline.mkdir(parents=True) + (cline / "PreToolUse").write_text("#!/usr/bin/env bash\n") return root @@ -65,6 +71,8 @@ def test_manifest_covers_surface_and_excludes_testdata(self): self.assertIn("tests/e2e_kimi_cli_test.sh", manifest) self.assertIn("tests/e2e_unified_runner.sh", manifest) self.assertIn("tests/mock_anthropic_server.js", manifest) + self.assertIn("integrations/pi/pitot.ts", manifest) + self.assertIn("integrations/cline/PreToolUse", manifest) self.assertNotIn("tests/test_internal.py", manifest) self.assertNotIn("testdata/ignored.bin", manifest) @@ -88,6 +96,16 @@ def test_check_detects_drift(self): with self.assertRaises(ValueError): build_pitot.check_manifest(repo) + def test_check_detects_missing_public_bridge(self): + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + repo = _fake_repo(Path(tmp)) + build_pitot.write_manifest(repo, build_pitot.compute_manifest(repo)) + (repo / "labs/15-pitot/integrations/pi/pitot.ts").unlink() + with self.assertRaisesRegex(ValueError, "removed: integrations/pi/pitot.ts"): + build_pitot.check_manifest(repo) + def test_manifest_is_deterministic(self): import tempfile