From bec5b148a1a359c4c2a9efb00dcfb2c2001ac13c Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Wed, 29 Jul 2026 02:09:05 +0000 Subject: [PATCH] Sync Pitot from Intelligence Flow @ 015e6ecc9ec7 --- README.md | 62 +++++-- UPSTREAM.json | 25 +-- cmd/pitot/doctor_host.go | 20 ++- cmd/pitot/main.go | 22 ++- cmd/pitot/main_test.go | 20 ++- cmd/pitot/shim.go | 7 +- cmd/pitot/upgrade.go | 16 +- cmd/pitot/wire.go | 170 +++++++++++++++++++ cmd/pitot/wire_test.go | 184 +++++++++++++++++++++ cmd/pitot/workbench.go | 26 ++- config/config.go | 27 +++ config/findroot_test.go | 78 +++++++++ wiring/wiring.go | 347 +++++++++++++++++++++++++++++++++++++++ wiring/wiring_test.go | 186 +++++++++++++++++++++ 14 files changed, 1143 insertions(+), 47 deletions(-) create mode 100644 cmd/pitot/wire.go create mode 100644 cmd/pitot/wire_test.go create mode 100644 config/findroot_test.go create mode 100644 wiring/wiring.go create mode 100644 wiring/wiring_test.go diff --git a/README.md b/README.md index a7696d8..260ee7c 100644 --- a/README.md +++ b/README.md @@ -400,10 +400,38 @@ decision belongs to your Controller. ## Connect your agent The per-host hooks below wire each agent's native blocking boundary to Pitot. -This wiring is a one-time edit to each host's own configuration; Pitot does not -edit your host config for you. Run `pitot doctor --host HOST` to check whether a -host's hook is correctly configured. Once wired, both `pitot dev` and the manual -runtime flow use the same hook. +Hosts whose hook config lives **in the repository** are wired by Pitot itself: + +```bash +pitot init --host claude # also: cursor, codex, gemini +``` + +That writes exactly one marker-owned entry into the host's repo config +(`.claude/settings.json`, `.cursor/hooks.json`, `.codex/hooks.json`, +`.gemini/settings.json`), pointing at the repo shim — foreign entries are +never touched, and the committed fragment `.pitot/hooks/.fragment.json` +witnesses what was installed. `pitot doctor --host HOST` reports the entry as +FOUND, MISSING, or DRIFTED against that witness; `pitot doctor --host HOST +--fix` restores a drifted entry (Pitot's own entries only — that is the single +mutation doctor ever performs, and only on request). + +Hosts configured at **user level** (Kimi, Copilot, Qwen) or via **plugin +files** (OpenCode, Pi) keep the one-time manual edit below — Pitot does not +edit files outside the repository; `pitot init --host kimi` prints the exact +snippet. Once wired, both `pitot dev` and the manual runtime flow use the same +hook. + +### Claude Code / Codex + +```bash +pitot init --host claude +pitot init --host codex +``` + +Both wire a `PreToolUse` hook (matcher `Bash`) whose command runs the repo +shim: `"$CLAUDE_PROJECT_DIR"/.pitot/bin/pitot hook claude` (Codex uses the +repo-relative equivalent plus a PowerShell `commandWindows`). Exit `0` allows; +exit `2` blocks with the Controller's reason. ### Kimi Code @@ -467,19 +495,27 @@ reason when a Controller rejects the command. See the official ### Cursor -Copy `integrations/cursor/beforeShellExecution` into the repository and point -`.cursor/hooks.json` at it with `failClosed: true`. The bridge returns Cursor's -native `permission: "deny"` envelope, including the Controller message, while -the runtime remains available through `PITOT_RUNTIME`. See Cursor's +```bash +pitot init --host cursor +``` + +Writes the committed bridge `.pitot/bin/hooks/cursor-beforeShellExecution` +(which execs the repo shim and returns Cursor's native `permission: "deny"` +envelope, including the Controller message) and points `.cursor/hooks.json` at +it with `failClosed: true`. See Cursor's [hooks documentation](https://cursor.com/docs/agent/hooks). ### Gemini -Copy `integrations/gemini/BeforeTool` to an executable path (or use -`BeforeTool.ps1` on Windows) and register it as a `BeforeTool` command hook for -`run_shell_command`. The bridge translates Pitot rejection into Gemini's -structured `decision: "deny"` and `reason` response so the model receives the -blocked tool result. See the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/). +```bash +pitot init --host gemini +``` + +Writes the committed bridge `.pitot/bin/hooks/gemini-BeforeTool` and registers +it as a `BeforeTool` command hook for `run_shell_command`. The bridge +translates Pitot rejection into Gemini's structured `decision: "deny"` and +`reason` response so the model receives the blocked tool result. See the +[Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/). ### Qwen Code diff --git a/UPSTREAM.json b/UPSTREAM.json index 66addea..a053337 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -3,7 +3,7 @@ ".goreleaser.yml": "d375f187624219e9420acd2a40cfae95772ef1fee3abda3e729d6a16ba612032", "CONTRIBUTING.md": "23728d8a132d62b8adfb2e5c3eb9d9bfcf8a4d04543765b1e22ad8d55424af8f", "LICENSE": "bd17d47aefaa7406616179288058001b6532881a2517254fe668d7e9c4965cfb", - "README.md": "5788010ce3ba4b7175aab509ff1dc8014f5a4c73ae6e43f07489cdbcedc9e207", + "README.md": "dc3adbcf60ed946537353c1b873a8270c67dde11d7f71cf4cd361d13544f909e", "adapter-verification.json": "f8ad4e206571650f698826a8b66d8c00822be425e8d2de8ae98d98239e575eb4", "adapters/adapters.go": "1b46ba131fa3b2c93eed23526330275a3506451ba4bbd4f497e5378dfab2b6a8", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", @@ -16,24 +16,27 @@ "bridge/bridge.go": "5adfcd3f743cae46e4446a6e030d53464ada97de0261a8588fa2a9fcd62136b8", "bridge/bridge_test.go": "6dcc6d05f2b39c25955fc0b2d21d3d148dd9d77600fb12799941f86bdb1acb61", "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", - "cmd/pitot/doctor_host.go": "7ecade40618bfb3510ae8e55fa802361371b4f7fbafedcd61233d19ef46cb219", + "cmd/pitot/doctor_host.go": "3eb21fb3a9b42fa7f2a33ed2ed82ae6de68eb2d084ecef4a9e0dce36e37d8a31", "cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f", "cmd/pitot/install.go": "46cab87809566f8da47c4d71b8a87618d06cb0829bdabe1c8f734faf53a9f46c", "cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4", "cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473", - "cmd/pitot/main.go": "69d1571a56ae1c024fcd697bb406c3aac0f4774d62b41f3444a949ffe6f4c4d4", - "cmd/pitot/main_test.go": "b381fe30dafe3299c82fe23e1899b64f79c4a2b27059b78823179625e3b6679e", - "cmd/pitot/shim.go": "a2a605b3126d4be7d3e2a587f09259a5f6e808c3a132480a760661cf34c1796f", + "cmd/pitot/main.go": "be069ed59ffe5e8cde26e3b88640dd8ae876fa0c266281ab9ac206be3e8944f1", + "cmd/pitot/main_test.go": "6a97e930a83d15a51713530c092a7e7146c4cb4e4d7eb9f58b97d1577c986c6b", + "cmd/pitot/shim.go": "3a7a4be5bd769010ad947de78188dd17c729388dbf74153d4dbd51fba2121ab1", "cmd/pitot/substrate_test.go": "b4e7d83aa592f7b5bf4ec059ec552abd62473e51a6634a26428448168898d0a3", - "cmd/pitot/upgrade.go": "3f7adf156b04f9bac4847ed66c5fa69dc0906f482cb1362f60cbf3416a5733fc", + "cmd/pitot/upgrade.go": "2ad6f581318c91c2617e1a65409458cd4de6641c940554b232dbdfd65e0fd221", "cmd/pitot/version.go": "60ddbabcc8985c5137d5b73e3ba1be0f62fbb9db2a6612027e08314dfd82d618", - "cmd/pitot/workbench.go": "afbba5557974c1a4fb28ed2097ec8bea4d9bb1fa97cda931c9ebf52daec24915", + "cmd/pitot/wire.go": "56c4d410eab4974952c5d7b7c37ad739341ebf292bb21de6143011c0bb1b1f48", + "cmd/pitot/wire_test.go": "66a3c3302780145ebf12da3df1390f61a8d9b82f30b03f6332dd958799e70f7f", + "cmd/pitot/workbench.go": "aa0a270829cdaa2bc7bf84d39a88f703088a9dc1c55057f7a2fd369ba98c9597", "cmd/pitot/workbench_build_test.go": "8d5c5c35e8cbd59e21cfdd7e206d6b5b769892ab27a99fe5d87b13b1a31d0714", "cmd/pitot/workbench_contract_test.go": "5e465f3d3f8b93ffaacfb4738279d369b13514bf7613b8611952f3cd26896586", "cmd/pitot/workbench_dev_test.go": "abffe81e1a25f086d7f3c2f1c32986ee93618bbfc87220bdfda840ec6c6466bf", "cmd/pitot/workbench_test.go": "3c561498dfee4aacf6935fe4b0bfe4449c3961c169c04f1f13e81a38b22d0914", - "config/config.go": "fa734117191ab941cfa92db82a9a121368fd604d7e556649c1b14b924b20b7f7", + "config/config.go": "84476c7525833b07222b3b06834ff528f9c496f164bb065d006afad29bbca1fb", "config/config_test.go": "87d3e5ddc4a3b43c736070de671d03e03ffe29cdd759771526ad27fd9bc0034c", + "config/findroot_test.go": "89d8b31f188325a02bde2dcd77e683c06e9dca4f59c872366c027111147ea8a5", "config/merge_test.go": "595d2c96ac879cca7c57b77b99504f34629b8aa55b38d515f35ad76c006ef532", "conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb", "conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd", @@ -126,12 +129,14 @@ "tests/runtime_capability_driver.py": "54a485fc4f16981f2542d0dbd903a12b80f31f071908a378607f26678aeea07f", "tests/witness/main.go": "cd56bbd00aa44cc5baf6426c8461a8ebca4a8391518f6acfa2301ac36add7c5f", "windtunnel/doc.go": "44e0bcde632da73e1f8b98beade3a34ca8e0d0ea79cdfb91d131de290b164fc4", - "windtunnel/windtunnel_test.go": "d34929ffdb1927b2ee27cc79640b333b0571659d231977b91c3a8898cf79bc42" + "windtunnel/windtunnel_test.go": "d34929ffdb1927b2ee27cc79640b333b0571659d231977b91c3a8898cf79bc42", + "wiring/wiring.go": "2cdf286fb26def02c3bad1d6c7e7e4419d366c732faf55ad3bb02a4ae127acdf", + "wiring/wiring_test.go": "ade8b847ecdcbee17439e27d186c7078f39490716df5b56badd43a2a05f01141" }, "generator": "operatorstack/pitot:project", "schema_version": 1, "source": { - "commit": "07c76f27ada2c07a5ae1b0426a121e174ff7763d", + "commit": "015e6ecc9ec71a8d71c29be6b68f84f7f6b17fb4", "path": "labs/15-pitot", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/pitot/doctor_host.go b/cmd/pitot/doctor_host.go index c100957..6d66501 100644 --- a/cmd/pitot/doctor_host.go +++ b/cmd/pitot/doctor_host.go @@ -11,6 +11,8 @@ import ( "time" "github.com/operatorstack/pitot/adapters" + "github.com/operatorstack/pitot/config" + "github.com/operatorstack/pitot/wiring" ) // hostProbe describes what `pitot doctor --host` inspects for a coding agent: @@ -44,18 +46,28 @@ var hostProbes = map[adapters.Host]hostProbe{ } // doctorHost reports whether a host is configured to route its blocking shell -// boundary to Pitot. It never edits configuration — it only inspects and -// reports, returning a non-nil error when a blocking issue is found so callers -// (and CI) get a clear signal. +// boundary to Pitot. It never edits configuration without an explicit --fix — +// plain doctor only inspects and reports, returning a non-nil error when a +// blocking issue is found so callers (and CI) get a clear signal. func doctorHost(host adapters.Host, stdout, stderr io.Writer) error { if !adapters.IsSupported(host) { return fmt.Errorf("pitot doctor: unsupported host %q (want one of: %s)", host, hostList()) } fmt.Fprintf(stdout, "Pitot %s — host check: %s\n", adapters.AdapterVersion, host) + // Repo-wireable hosts report their wiring state against the fragment + // witness (drift-is-named-not-silently-fixed). + if wiring.Supported(string(host)) { + root := "." + if found, err := config.FindRoot("."); err == nil { + root = found + } + return printWiringStatus(root, string(host), stdout) + } + 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\n", host) + 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) return nil } diff --git a/cmd/pitot/main.go b/cmd/pitot/main.go index f20c8a6..9d9b7b9 100644 --- a/cmd/pitot/main.go +++ b/cmd/pitot/main.go @@ -204,6 +204,7 @@ func runRequest(ctx context.Context, args []string, stdout io.Writer) error { func doctor(args []string, stdout, stderr io.Writer) error { host := "" + fix := false for i := 0; i < len(args); i++ { switch args[i] { case "--host": @@ -212,11 +213,21 @@ func doctor(args []string, stdout, stderr io.Writer) error { } host = args[i+1] i++ + case "--fix": + fix = true default: return fmt.Errorf("pitot doctor: unexpected argument %q", args[i]) } } + if fix && host == "" { + return errors.New("pitot doctor: --fix requires --host") + } if host != "" { + if fix { + // The only mutation doctor ever performs, and only on request: + // restore Pitot's own marked entries (foreign config untouched). + return runWireHost(host, true, stdout) + } return doctorHost(adapters.Host(host), stdout, stderr) } @@ -271,10 +282,14 @@ func runRuntime(ctx context.Context, args []string, stdout, stderr io.Writer) er var loaded config.Loaded var err error if configPath == "" { - loaded, err = config.Discover(".") - if errors.Is(err, config.ErrNoConfig) { + root, rootErr := config.FindRoot(".") + if rootErr != nil { return errors.New("pitot: no config fragments under .pitot/conf.d (run 'pitot init' to register a controller or consumer, or pass --config PATH)") } + loaded, err = config.Discover(root) + if err == nil { + anchorDirs(&loaded.Config, root) + } } else { loaded, err = config.Load(configPath) } @@ -315,8 +330,9 @@ func usage() string { usage: pitot init [--language python|typescript|go|rust] [--role consumer|controller] [--template shell-policy|release-approval|blank-controller|blank-consumer] [--dir PATH] [--fragment NAME] [--force] + pitot init --host HOST [--force] pitot dev --host HOST -- AGENT [ARGS...] - pitot doctor [--host HOST] + pitot doctor [--host HOST] [--fix] pitot run [--config PATH] --runtime PATH pitot hook HOST [--runtime PATH] pitot request KIND [--data JSON] --runtime PATH diff --git a/cmd/pitot/main_test.go b/cmd/pitot/main_test.go index ea7e99c..f252e71 100644 --- a/cmd/pitot/main_test.go +++ b/cmd/pitot/main_test.go @@ -137,15 +137,18 @@ controllers: } // TestRunDiscoversFragmentsAndHonorsDir proves the tenant model end to end: -// `pitot run` with no --config merges the .pitot/conf.d fragments in the -// working directory, a controller from one tenant resolves explicit requests, -// and a consumer declared with dir: runs in that working directory — its -// relative receipt path lands inside the tenant's own directory. +// `pitot run` with no --config discovers the .pitot/conf.d fragments by +// walking up from a SUBDIRECTORY to the owning root +// (discovery-walks-up-to-owned-roots-only), a controller from one tenant +// resolves explicit requests, and a consumer declared with dir: runs in its +// root-anchored working directory — its relative receipt path lands inside +// the tenant's own directory regardless of where pitot was invoked. func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) { t.Setenv("PITOT_RUNTIME", "") helper := buildTestRole(t) // build before chdir: it compiles from the package dir runtimePath := filepath.Join(t.TempDir(), "runtime.json") - t.Chdir(t.TempDir()) + root := t.TempDir() + t.Chdir(root) controllerFragment := fmt.Sprintf(`controllers: release.approval: @@ -175,6 +178,11 @@ func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) { if err := os.MkdirAll("tenant-b", 0o755); err != nil { t.Fatal(err) } + // Invoke from a subdirectory: discovery must walk up to the owning root. + if err := os.MkdirAll("apps/web", 0o755); err != nil { + t.Fatal(err) + } + t.Chdir(filepath.Join(root, "apps", "web")) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -210,7 +218,7 @@ func TestRunDiscoversFragmentsAndHonorsDir(t *testing.T) { if err := runWithIO(context.Background(), []string{"hook", "claude", "--runtime", runtimePath}, strings.NewReader(payload), &hookOut, &hookErr); err != nil { t.Fatalf("hook err=%v stderr=%s", err, hookErr.String()) } - receipt := filepath.Join("tenant-b", "receipt.jsonl") + receipt := filepath.Join(root, "tenant-b", "receipt.jsonl") deadline := time.Now().Add(5 * time.Second) for { if data, err := os.ReadFile(receipt); err == nil && strings.Contains(string(data), `"type":"action.requested"`) { diff --git a/cmd/pitot/shim.go b/cmd/pitot/shim.go index 319df5d..b16ad18 100644 --- a/cmd/pitot/shim.go +++ b/cmd/pitot/shim.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/operatorstack/pitot/config" "github.com/operatorstack/pitot/hydrate" ) @@ -198,7 +199,11 @@ exit $LASTEXITCODE // pin, slot, shim, and pin-vs-running drift. func printHydrationStatus(stdout io.Writer) { fmt.Fprintln(stdout, "hydration:") - pin, err := hydrate.Pin(".") + root := "." + if found, err := config.FindRoot("."); err == nil { + root = found + } + pin, err := hydrate.Pin(root) if err != nil { fmt.Fprintf(stdout, " pin: none (%s absent or invalid)\n", hydrate.PinPath) return diff --git a/cmd/pitot/upgrade.go b/cmd/pitot/upgrade.go index bd27187..6bbc242 100644 --- a/cmd/pitot/upgrade.go +++ b/cmd/pitot/upgrade.go @@ -47,8 +47,12 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er } } + root := "." + if found, err := config.FindRoot("."); err == nil { + root = found + } current := "none" - if pin, err := hydrate.Pin("."); err == nil { + if pin, err := hydrate.Pin(root); err == nil { current = pin } if target == "" { @@ -79,10 +83,10 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er return err } // Every tenant must still hold under the new binary before the pin moves. - if err := validateTenantsFor(target, slot); err != nil { + if err := validateTenantsFor(root, target, slot); err != nil { return err } - if err := hydrate.WritePin(".", target); err != nil { + if err := hydrate.WritePin(root, target); err != nil { return err } fmt.Fprintf(stdout, "pinned %s -> %s (%s rewritten — commit this diff; every clone hydrates %s on its next invocation)\n", current, target, hydrate.PinPath, target) @@ -92,8 +96,8 @@ func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) er // validateTenantsFor re-checks the tenancy contract against the upgrade // target: the merged config must still validate, and every fragment's // requires_protocol must be spoken by the new binary. -func validateTenantsFor(version, slot string) error { - if _, err := config.Discover("."); err != nil { +func validateTenantsFor(root, version, slot string) error { + if _, err := config.Discover(root); err != nil { if errors.Is(err, config.ErrNoConfig) { return nil // no tenants registered; nothing to preserve } @@ -103,7 +107,7 @@ func validateTenantsFor(version, slot string) error { if err != nil { return fmt.Errorf("pitot upgrade: could not determine the protocol of %s: %w", version, err) } - floors, err := fragmentProtocolFloors(".") + floors, err := fragmentProtocolFloors(root) if err != nil { return err } diff --git a/cmd/pitot/wire.go b/cmd/pitot/wire.go new file mode 100644 index 0000000..a4c6f61 --- /dev/null +++ b/cmd/pitot/wire.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/operatorstack/pitot/config" + "github.com/operatorstack/pitot/wiring" +) + +// runWireHost is `pitot init --host HOST`: ensure the repo substrate, emit +// any bridges the host needs, merge the marker-owned entry into the host +// config, and write the fragment witness. Idempotent; a DRIFTED entry is +// refused without force (drift-is-named-not-silently-fixed). +func runWireHost(host string, force bool, stdout io.Writer) error { + if !wiring.Supported(host) { + return printWiringGuidance(host, stdout) + } + root := "." + if found, err := config.FindRoot("."); err == nil { + root = found + } + + if _, err := writeSubstrate(stdout, releaseVersion()); err != nil { + return err + } + + // Drift gate: if this host was wired before, a hand-edited entry blocks + // an implicit rewrite. + if _, statErr := os.Stat(filepath.Join(root, filepath.FromSlash(wiring.FragmentPath(host)))); statErr == nil { + state, err := wiring.Check(root, host) + if err != nil { + return err + } + if state == wiring.StateDrifted && !force { + return fmt.Errorf("pitot init: the %s entry in %s drifted from the fragment witness %s; review it, then re-run with --force to restore the canonical entry (drift-is-named-not-silently-fixed)", host, wiring.ConfigPath(host), wiring.FragmentPath(host)) + } + } + + written := []string{} + for rel, body := range wiring.Bridges(host) { + path := filepath.Join(root, filepath.FromSlash(rel)) + if _, err := os.Stat(path); err == nil && !force { + continue + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { + return err + } + written = append(written, rel) + } + + configPath := filepath.Join(root, filepath.FromSlash(wiring.ConfigPath(host))) + hostConfig := map[string]any{} + if raw, err := os.ReadFile(configPath); err == nil { + if err := json.Unmarshal(raw, &hostConfig); err != nil { + return fmt.Errorf("pitot init: %s is not valid JSON; refusing to interpret it (foreign-config-is-never-repaired)", wiring.ConfigPath(host)) + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := wiring.Merge(hostConfig, host); err != nil { + return err + } + rendered, err := json.MarshalIndent(hostConfig, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(configPath, append(rendered, '\n'), 0o644); err != nil { + return err + } + written = append(written, wiring.ConfigPath(host)) + + fragment, err := wiring.Fragment(host) + if err != nil { + return err + } + fragmentPath := filepath.Join(root, filepath.FromSlash(wiring.FragmentPath(host))) + if err := os.MkdirAll(filepath.Dir(fragmentPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(fragmentPath, fragment, 0o644); err != nil { + return err + } + written = append(written, wiring.FragmentPath(host)) + + fmt.Fprintf(stdout, "Wired %s: one Pitot-owned %s entry in %s (foreign entries untouched)\n", host, wiring.Event(host), wiring.ConfigPath(host)) + fmt.Fprintf(stdout, "Files written: %s\n", joinSorted(written)) + fmt.Fprintf(stdout, "Verify any time: pitot doctor --host %s\n", host) + return nil +} + +// printWiringGuidance covers the hosts whose configuration lives outside the +// repository (user-level files or in-process plugins): Pitot prints the exact +// documented wiring instead of editing $HOME. +func printWiringGuidance(host string, stdout io.Writer) error { + guidance := map[string]string{ + "kimi": "add to ~/.kimi-code/config.toml (or $KIMI_CODE_HOME/config.toml):\n\n [[hooks]]\n event = \"PreToolUse\"\n matcher = \"Bash\"\n command = \"pitot hook kimi\"", + "qwen": "add to ~/.qwen/settings.json under hooks.PreToolUse:\n\n {\"matcher\": \"^Bash$\", \"hooks\": [{\"type\": \"command\", \"command\": \"\"}]}", + "copilot": "add to ~/.copilot/settings.json under hooks.PreToolUse:\n\n {\"matcher\": \"Bash\", \"hooks\": [{\"type\": \"command\", \"command\": \"\"}]}", + "opencode": "register the plugin in ~/.config/opencode/opencode.json:\n\n {\"plugin\": [\"file:///\"]}", + "pi": "copy integrations/pi/pitot.ts into ~/.pi/agent/extensions/ (or the repo's .pi/extensions/)", + } + text, known := guidance[host] + if !known { + return fmt.Errorf("pitot init: unsupported host %q (want one of: %s, or a guidance-only host: kimi, qwen, copilot, opencode, pi)", host, joinSorted(wiring.RepoHosts())) + } + fmt.Fprintf(stdout, "%s is configured at user level; Pitot does not edit files outside the repository.\n", host) + fmt.Fprintf(stdout, "To wire it, %s\n\nThen verify: pitot doctor --host %s\n", text, host) + return nil +} + +// printWiringStatus renders the doctor's view of a repo-wireable host. +func printWiringStatus(root, host string, stdout io.Writer) error { + state, err := wiring.Check(root, host) + if err != nil { + fmt.Fprintf(stdout, " wiring: %v\n", err) + return err + } + switch state { + case wiring.StateFound: + fmt.Fprintf(stdout, " wiring: FOUND — %s carries the canonical Pitot entry for %s\n", wiring.ConfigPath(host), wiring.Event(host)) + return nil + case wiring.StateMissing: + fmt.Fprintf(stdout, " wiring: MISSING — no Pitot entry in %s; run 'pitot init --host %s' (or doctor --fix)\n", wiring.ConfigPath(host), host) + return fmt.Errorf("pitot doctor: %s wiring is missing", host) + default: + fmt.Fprintf(stdout, " wiring: DRIFTED — the Pitot entry in %s no longer matches the fragment witness %s; run 'pitot doctor --host %s --fix' to restore it\n", wiring.ConfigPath(host), wiring.FragmentPath(host), host) + return fmt.Errorf("pitot doctor: %s wiring drifted", host) + } +} + +// anchorDirs resolves tenant working directories against the discovered +// root so `pitot run`/`dev` behave identically from any subdirectory. +func anchorDirs(cfg *config.Config, root string) { + for kind, controller := range cfg.Controllers { + controller.Dir = anchorDir(controller.Dir, root) + cfg.Controllers[kind] = controller + } + for i := range cfg.Consumers { + cfg.Consumers[i].Dir = anchorDir(cfg.Consumers[i].Dir, root) + } +} + +func anchorDir(dir, root string) string { + if filepath.IsAbs(dir) { + return dir + } + return filepath.Join(root, dir) +} + +func joinSorted(values []string) string { + out := "" + for i, v := range sorted(values) { + if i > 0 { + out += ", " + } + out += v + } + return out +} diff --git a/cmd/pitot/wire_test.go b/cmd/pitot/wire_test.go new file mode 100644 index 0000000..1ab4ae9 --- /dev/null +++ b/cmd/pitot/wire_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + goruntime "runtime" + "strings" + "testing" +) + +func runInitHost(t *testing.T, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + err := runInit(append([]string{"--host"}, args...), strings.NewReader(""), &out, &out) + return out.String(), err +} + +// control-law: wiring-is-marker-owned-and-witnessed +// control-law: one-canonical-entry-per-event +// +// End to end on claude: foreign config survives wiring byte-meaningfully, +// exactly one canonical marked entry exists, the fragment witnesses it, and +// re-running is idempotent. +func TestWireHostClaudeEndToEnd(t *testing.T) { + t.Chdir(t.TempDir()) + seed := `{"theme":"dark","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"./mine.sh"}]}]}}` + if err := os.MkdirAll(".claude", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".claude/settings.json", []byte(seed), 0o644); err != nil { + t.Fatal(err) + } + + out, err := runInitHost(t, "claude") + if err != nil { + t.Fatalf("wire claude: %v\n%s", err, out) + } + raw, err := os.ReadFile(".claude/settings.json") + if err != nil { + t.Fatal(err) + } + var settings map[string]any + if err := json.Unmarshal(raw, &settings); err != nil { + t.Fatal(err) + } + if settings["theme"] != "dark" { + t.Fatal("foreign top-level key lost") + } + entries := settings["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(entries) != 2 { + t.Fatalf("want foreign + canonical, got %d entries", len(entries)) + } + if !strings.Contains(string(raw), "mine.sh") || !strings.Contains(string(raw), ".pitot/bin/pitot hook claude") { + t.Fatalf("config missing foreign or canonical entry:\n%s", raw) + } + if _, err := os.Stat(filepath.FromSlash(".pitot/hooks/claude.fragment.json")); err != nil { + t.Fatalf("fragment witness missing: %v", err) + } + + // Idempotent: a second wire changes nothing. + before, _ := os.ReadFile(".claude/settings.json") + if _, err := runInitHost(t, "claude"); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(".claude/settings.json") + if !bytes.Equal(before, after) { + t.Fatal("re-wiring rewrote an already-canonical config") + } +} + +// control-law: drift-is-named-not-silently-fixed +// +// A hand-edited marked entry: init refuses without force, doctor names the +// drift without mutating, doctor --fix restores the canonical entry while +// the foreign entry survives. +func TestWireHostDriftFlow(t *testing.T) { + t.Chdir(t.TempDir()) + if _, err := runInitHost(t, "claude"); err != nil { + t.Fatal(err) + } + // Hand-edit the marked entry. + raw, _ := os.ReadFile(".claude/settings.json") + edited := bytes.Replace(raw, []byte(`"matcher": "Bash"`), []byte(`"matcher": "Bash|Edit"`), 1) + if bytes.Equal(raw, edited) { + t.Fatalf("test setup: matcher not found in\n%s", raw) + } + if err := os.WriteFile(".claude/settings.json", edited, 0o644); err != nil { + t.Fatal(err) + } + + if _, err := runInitHost(t, "claude"); err == nil || !strings.Contains(err.Error(), "drift-is-named-not-silently-fixed") { + t.Fatalf("drifted entry must refuse an implicit rewrite, got %v", err) + } + + var out bytes.Buffer + doctorErr := doctor([]string{"--host", "claude"}, &out, &out) + if doctorErr == nil || !strings.Contains(out.String(), "DRIFTED") { + t.Fatalf("doctor must name the drift, err=%v out=%s", doctorErr, out.String()) + } + afterDoctor, _ := os.ReadFile(".claude/settings.json") + if !bytes.Equal(edited, afterDoctor) { + t.Fatal("doctor mutated the config without --fix") + } + + out.Reset() + if err := doctor([]string{"--host", "claude", "--fix"}, &out, &out); err != nil { + t.Fatalf("doctor --fix: %v\n%s", err, out.String()) + } + fixed, _ := os.ReadFile(".claude/settings.json") + if bytes.Contains(fixed, []byte("Bash|Edit")) { + t.Fatal("--fix did not restore the canonical entry") + } + var okOut bytes.Buffer + if err := doctor([]string{"--host", "claude"}, &okOut, &okOut); err != nil || !strings.Contains(okOut.String(), "FOUND") { + t.Fatalf("post-fix doctor: err=%v out=%s", err, okOut.String()) + } +} + +// control-law: wiring-points-at-the-repo-shim +// +// Cursor wiring emits the committed bridge (0755, defaulting to the sibling +// shim) and a version-1 hooks file whose entry invokes the bridge. +func TestWireHostCursorWritesBridge(t *testing.T) { + t.Chdir(t.TempDir()) + if _, err := runInitHost(t, "cursor"); err != nil { + t.Fatal(err) + } + bridge := filepath.FromSlash(".pitot/bin/hooks/cursor-beforeShellExecution") + info, err := os.Stat(bridge) + if err != nil { + t.Fatalf("bridge missing: %v", err) + } + // POSIX execute bits do not exist on Windows; there Cursor runs the + // commandWindows PowerShell path instead of the bridge. + if goruntime.GOOS != "windows" && info.Mode()&0o111 == 0 { + t.Fatal("bridge is not executable") + } + body, _ := os.ReadFile(bridge) + if !strings.Contains(string(body), `PITOT_COMMAND="${PITOT_BIN:-$shim}"`) { + t.Fatalf("bridge must default to the repo shim:\n%s", body) + } + raw, _ := os.ReadFile(filepath.FromSlash(".cursor/hooks.json")) + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + if cfg["version"] != float64(1) { + t.Fatalf("cursor hooks.json missing version 1:\n%s", raw) + } + if !strings.Contains(string(raw), ".pitot/bin/hooks/cursor-beforeShellExecution") { + t.Fatalf("cursor entry does not invoke the bridge:\n%s", raw) + } +} + +// User-level hosts get the exact wiring snippet, never an edit; unknown +// hosts are rejected; --host excludes scaffold flags. +func TestWireHostGuidanceAndFlagRules(t *testing.T) { + t.Chdir(t.TempDir()) + out, err := runInitHost(t, "kimi") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"config.toml", "[[hooks]]", "does not edit files outside the repository"} { + if !strings.Contains(out, want) { + t.Errorf("kimi guidance missing %q:\n%s", want, out) + } + } + if entries, _ := os.ReadDir("."); len(entries) != 0 { + t.Fatalf("guidance must write nothing, found %v", entries) + } + if _, err := runInitHost(t, "notahost"); err == nil || !strings.Contains(err.Error(), "unsupported host") { + t.Fatalf("unknown host must be rejected, got %v", err) + } + var buf bytes.Buffer + err = runInit([]string{"--host", "claude", "--language", "go"}, strings.NewReader(""), &buf, &buf) + if err == nil || !strings.Contains(err.Error(), "cannot be combined") { + t.Fatalf("--host must exclude scaffold flags, got %v", err) + } + if err := doctor([]string{"--fix"}, &buf, &buf); err == nil || !strings.Contains(err.Error(), "--fix requires --host") { + t.Fatalf("doctor --fix without --host must be rejected, got %v", err) + } +} diff --git a/cmd/pitot/workbench.go b/cmd/pitot/workbench.go index c3722fa..c447bd8 100644 --- a/cmd/pitot/workbench.go +++ b/cmd/pitot/workbench.go @@ -48,10 +48,17 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { template := "" dir := "pitot-project" fragment := "" + host := "" force := false for i := 0; i < len(args); i++ { switch args[i] { + case "--host": + if i+1 >= len(args) { + return errors.New("pitot init: --host requires a host name") + } + host = args[i+1] + i++ case "--language": if i+1 >= len(args) { return errors.New("pitot init: --language requires a value (python, typescript, go, rust)") @@ -89,6 +96,15 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { } } + // --host wires an agent's hook config; it is a separate arm from project + // scaffolding and accepts only --force alongside. + if host != "" { + if lang != "" || role != "" || template != "" || fragment != "" || dir != "pitot-project" { + return errors.New("pitot init: --host wires an agent hook and cannot be combined with scaffold flags (--language/--role/--template/--dir/--fragment)") + } + return runWireHost(host, force, stdout) + } + if fragment == "" { fragment = filepath.Base(filepath.Clean(dir)) } @@ -805,13 +821,15 @@ func runDev(ctx context.Context, args []string, stdout, stderr io.Writer) error programArgs = fields[1:] } - loaded, err := config.Discover(".") + root, err := config.FindRoot(".") + if err != nil { + return errors.New("pitot dev: no config fragments under .pitot/conf.d (searched up to the repository boundary). Run 'pitot init' first") + } + loaded, err := config.Discover(root) if err != nil { - if errors.Is(err, config.ErrNoConfig) { - return errors.New("pitot dev: no config fragments under .pitot/conf.d. Run 'pitot init' first") - } return fmt.Errorf("pitot dev: load config: %w", err) } + anchorDirs(&loaded.Config, root) // Private, per-invocation runtime directory so concurrent runs never collide. runtimeDir, err := os.MkdirTemp("", "pitot-dev-") diff --git a/config/config.go b/config/config.go index 811a7a5..f7faf4b 100644 --- a/config/config.go +++ b/config/config.go @@ -89,6 +89,33 @@ func Load(path string) (Loaded, error) { return Loaded{Config: cfg, SHA256: hex.EncodeToString(digest[:])}, nil } +// FindRoot resolves the Pitot repository root from a starting directory by +// walking UP to the nearest directory owning .pitot/conf.d. The walk stops +// at the first .git boundary, at the user's home directory, and at the +// volume root — and it NEVER descends: a dependency's .pitot (e.g. inside +// node_modules) is invisible from outside it +// (discovery-walks-up-to-owned-roots-only). +func FindRoot(start string) (string, error) { + dir, err := filepath.Abs(start) + if err != nil { + return "", err + } + home, _ := os.UserHomeDir() + for { + if info, statErr := os.Stat(filepath.Join(dir, ".pitot", "conf.d")); statErr == nil && info.IsDir() { + return dir, nil + } + if _, statErr := os.Lstat(filepath.Join(dir, ".git")); statErr == nil { + return "", ErrNoConfig // owned boundary reached without a Pitot substrate + } + parent := filepath.Dir(dir) + if dir == home || parent == dir { + return "", ErrNoConfig + } + dir = parent + } +} + // source is one fragment's filename and raw bytes. type source struct { name string diff --git a/config/findroot_test.go b/config/findroot_test.go new file mode 100644 index 0000000..9da1181 --- /dev/null +++ b/config/findroot_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// control-law: discovery-walks-up-to-owned-roots-only +// +// FindRoot resolves the nearest ancestor owning .pitot/conf.d, stops at the +// first .git boundary, and never descends into dependencies. +func TestFindRootWalksUpToOwnedRootsOnly(t *testing.T) { + mk := func(t *testing.T, parts ...string) string { + t.Helper() + path := filepath.Join(parts...) + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("subdirectory resolves the owning root", func(t *testing.T) { + root := t.TempDir() + mk(t, root, ".pitot", "conf.d") + deep := mk(t, root, "apps", "web", "src") + got, err := FindRoot(deep) + if err != nil { + t.Fatal(err) + } + if resolved, _ := filepath.EvalSymlinks(got); resolved != mustEval(t, root) { + t.Fatalf("got %s, want %s", got, root) + } + }) + + t.Run("git boundary stops the walk", func(t *testing.T) { + outer := t.TempDir() + mk(t, outer, ".pitot", "conf.d") // an owning root ABOVE the repo boundary + repo := mk(t, outer, "some-repo") + mk(t, repo, ".git") + inside := mk(t, repo, "pkg") + if _, err := FindRoot(inside); !errors.Is(err, ErrNoConfig) { + t.Fatalf("the walk must stop at the repository's own .git boundary, got %v", err) + } + }) + + t.Run("a dependency's substrate is invisible from outside it", func(t *testing.T) { + repo := t.TempDir() + mk(t, repo, ".git") + mk(t, repo, "node_modules", "evil-pkg", ".pitot", "conf.d") + if _, err := FindRoot(repo); !errors.Is(err, ErrNoConfig) { + t.Fatalf("discovery must never descend into dependencies, got %v", err) + } + }) + + t.Run("root with substrate and git resolves itself", func(t *testing.T) { + repo := t.TempDir() + mk(t, repo, ".git") + mk(t, repo, ".pitot", "conf.d") + got, err := FindRoot(repo) + if err != nil { + t.Fatal(err) + } + if mustEval(t, got) != mustEval(t, repo) { + t.Fatalf("got %s, want %s", got, repo) + } + }) +} + +func mustEval(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatal(err) + } + return resolved +} diff --git a/wiring/wiring.go b/wiring/wiring.go new file mode 100644 index 0000000..74fde46 --- /dev/null +++ b/wiring/wiring.go @@ -0,0 +1,347 @@ +// Package wiring owns the host hook entries Pitot writes into repo-level +// coding-agent configs. Ownership is structural, ported from Boatstack's +// proven machinery: +// +// - wiring-is-marker-owned-and-witnessed — Pitot edits only entries whose +// command carries the marker (".pitot/bin/"); the committed fragment +// .pitot/hooks/.fragment.json witnesses exactly what was installed. +// - foreign-config-is-never-repaired — malformed host config is a hard +// error, never overwritten; repairs rewrite marked entries only. +// - one-canonical-entry-per-event — merging strips every marked entry and +// appends exactly one canonical entry, deduplicating collisions. +// - wiring-points-at-the-repo-shim — every entry and bridge references +// .pitot/bin/…, never a PATH binary, so the pin laws hold end to end. +// - drift-is-named-not-silently-fixed — Check classifies each owned event +// as FOUND, DRIFTED, or MISSING against the fragment witness; only an +// explicit fix path mutates. +package wiring + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// Marker identifies Pitot-owned hook entries: every command Pitot writes +// references the repo shim directory. +const Marker = ".pitot/bin/" + +// FragmentDir holds the per-host ownership witnesses. +const FragmentDir = ".pitot/hooks" + +// BridgeDir holds committed exit-code-to-JSON bridge scripts for hosts whose +// hook protocol cannot consume exit codes directly. +const BridgeDir = ".pitot/bin/hooks" + +// repoHosts enumerates the hosts wireable at repository level, with the +// config file each owns and the blocking shell event Pitot supervises. Only +// the pre-tool shell boundary is wired: Pitot's protocol is pre-decision +// transport, and non-shell events would fail decode into spurious denials. +var repoHosts = map[string]struct { + configPath string + event string +}{ + "claude": {configPath: ".claude/settings.json", event: "PreToolUse"}, + "codex": {configPath: ".codex/hooks.json", event: "PreToolUse"}, + "cursor": {configPath: ".cursor/hooks.json", event: "beforeShellExecution"}, + "gemini": {configPath: ".gemini/settings.json", event: "BeforeTool"}, +} + +// Supported reports whether a host is wireable at repository level. +func Supported(host string) bool { + _, ok := repoHosts[host] + return ok +} + +// RepoHosts lists the repo-level wireable hosts in sorted order. +func RepoHosts() []string { + hosts := make([]string, 0, len(repoHosts)) + for host := range repoHosts { + hosts = append(hosts, host) + } + sort.Strings(hosts) + return hosts +} + +// ConfigPath returns the repo-relative host config file a host owns. +func ConfigPath(host string) string { + return repoHosts[host].configPath +} + +// Event returns the blocking hook event Pitot wires for a host. +func Event(host string) string { + return repoHosts[host].event +} + +// CanonicalEntry is the single hook entry Pitot installs for a host's +// blocking shell event. Commands reference the repo shim (or a committed +// bridge that execs it) — never a PATH binary. +func CanonicalEntry(host string) map[string]any { + switch host { + case "claude": + return map[string]any{ + "matcher": "Bash", + "hooks": []any{map[string]any{ + "type": "command", + "command": `"$CLAUDE_PROJECT_DIR"/.pitot/bin/pitot hook claude`, + "timeout": float64(10), + }}, + } + case "codex": + return map[string]any{ + "matcher": "Bash", + "hooks": []any{map[string]any{ + "type": "command", + "command": `.pitot/bin/pitot hook codex`, + "commandWindows": `powershell -NoProfile -ExecutionPolicy Bypass -File .pitot/bin/pitot.ps1 hook codex`, + "timeout": float64(10), + }}, + } + case "cursor": + return map[string]any{ + "command": `./.pitot/bin/hooks/cursor-beforeShellExecution`, + "commandWindows": `powershell -NoProfile -ExecutionPolicy Bypass -File .pitot/bin/pitot.ps1 hook cursor`, + "failClosed": true, + "timeout": float64(10), + } + case "gemini": + return map[string]any{ + "matcher": "run_shell_command", + "sequential": true, + "hooks": []any{map[string]any{ + "name": "pitot", + "type": "command", + "command": `./.pitot/bin/hooks/gemini-BeforeTool`, + "timeout": float64(10000), + }}, + } + } + return nil +} + +// containsMarker walks any decoded JSON value looking for the ownership +// marker in string leaves. +func containsMarker(value any) bool { + switch typed := value.(type) { + case string: + return bytes.Contains([]byte(typed), []byte(Marker)) + case []any: + for _, item := range typed { + if containsMarker(item) { + return true + } + } + case map[string]any: + for _, item := range typed { + if containsMarker(item) { + return true + } + } + } + return false +} + +// Merge installs the canonical entry for a host into a decoded config: +// every marked entry is stripped (deduplicating collisions), every foreign +// entry is preserved in order, and exactly one canonical entry is appended. +// Malformed structure is a hard error — foreign config is never repaired. +func Merge(config map[string]any, host string) error { + if !Supported(host) { + return fmt.Errorf("pitot: host %q is not wireable at repository level", host) + } + hooksValue, exists := config["hooks"] + if !exists || hooksValue == nil { + hooksValue = map[string]any{} + config["hooks"] = hooksValue + } + hooks, ok := hooksValue.(map[string]any) + if !ok { + return fmt.Errorf("pitot: %s has a non-object hooks section; refusing to touch foreign configuration (foreign-config-is-never-repaired)", ConfigPath(host)) + } + + // A marked entry on an event Pitot does not own for this host is drift + // from an older layout — refuse rather than guess. + for event, value := range hooks { + if event == Event(host) { + continue + } + if containsMarker(value) { + return fmt.Errorf("pitot: %s carries a Pitot-owned entry on unowned event %q; remove it or re-run with the current layout (drift-is-named-not-silently-fixed)", ConfigPath(host), event) + } + } + + event := Event(host) + existing, exists := hooks[event] + var entries []any + if exists && existing != nil { + list, ok := existing.([]any) + if !ok { + return fmt.Errorf("pitot: %s event %q is not a list; refusing to touch foreign configuration (foreign-config-is-never-repaired)", ConfigPath(host), event) + } + entries = list + } + kept := make([]any, 0, len(entries)+1) + for _, entry := range entries { + if containsMarker(entry) { + continue // stripped; the canonical entry replaces every marked one + } + kept = append(kept, entry) + } + kept = append(kept, CanonicalEntry(host)) + hooks[event] = kept + + if host == "cursor" { + if _, exists := config["version"]; !exists { + config["version"] = float64(1) + } + } + return nil +} + +// Fragment renders the ownership witness for a host. +func Fragment(host string) ([]byte, error) { + payload := map[string]any{ + "schema_version": 1, + "host": host, + "events": map[string]any{ + Event(host): CanonicalEntry(host), + }, + } + rendered, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return nil, err + } + return append(rendered, '\n'), nil +} + +// FragmentPath is the repo-relative witness path for a host. +func FragmentPath(host string) string { + return filepath.ToSlash(filepath.Join(FragmentDir, host+".fragment.json")) +} + +// EventState classifies one owned event against the fragment witness. +type EventState string + +const ( + StateFound EventState = "FOUND" + StateDrifted EventState = "DRIFTED" + StateMissing EventState = "MISSING" +) + +// Check compares the host config against the committed fragment witness. +// The distinction matters: template migration (new release, old fragment) +// is not user drift — drift means the config no longer matches what Pitot +// itself installed. +func Check(root, host string) (EventState, error) { + if !Supported(host) { + return "", fmt.Errorf("pitot: host %q is not wireable at repository level", host) + } + fragmentRaw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(FragmentPath(host)))) + if err != nil { + return "", fmt.Errorf("pitot: %s is not wired (no fragment witness at %s); run 'pitot init --host %s'", host, FragmentPath(host), host) + } + var fragment struct { + SchemaVersion int `json:"schema_version"` + Host string `json:"host"` + Events map[string]map[string]any `json:"events"` + } + if err := json.Unmarshal(fragmentRaw, &fragment); err != nil || fragment.SchemaVersion != 1 || fragment.Host != host || len(fragment.Events) == 0 { + return "", fmt.Errorf("pitot: fragment %s is invalid; re-run 'pitot init --host %s --force'", FragmentPath(host), host) + } + + configRaw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(ConfigPath(host)))) + if err != nil { + return StateMissing, nil + } + var config map[string]any + if err := json.Unmarshal(configRaw, &config); err != nil { + return "", fmt.Errorf("pitot: %s is not valid JSON; refusing to interpret it (foreign-config-is-never-repaired)", ConfigPath(host)) + } + hooks, _ := config["hooks"].(map[string]any) + + event := Event(host) + witness := fragment.Events[event] + entries, _ := hooks[event].([]any) + marked := 0 + drifted := false + for _, entry := range entries { + if !containsMarker(entry) { + continue + } + marked++ + if !sameJSON(entry, witness) { + drifted = true + } + } + // A marked entry anywhere else is drift too. + for other, value := range hooks { + if other != event && containsMarker(value) { + drifted = true + } + } + switch { + case marked == 0: + return StateMissing, nil + case drifted || marked > 1: + return StateDrifted, nil + default: + return StateFound, nil + } +} + +func sameJSON(a, b any) bool { + left, errA := json.Marshal(a) + right, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(left, right) +} + +// Bridges returns the committed bridge scripts a host needs (repo-relative +// path -> content). The bridges translate Pitot's exit-code protocol into +// the host's native decision JSON and exec the sibling repo shim by default +// (wiring-points-at-the-repo-shim); PITOT_BIN remains a test override. +func Bridges(host string) map[string]string { + switch host { + case "cursor": + return map[string]string{ + BridgeDir + "/cursor-beforeShellExecution": `#!/bin/sh +# Pitot cursor bridge — generated by 'pitot init --host cursor'. Translates +# the shim's exit-code protocol into Cursor's decision JSON. Carries no +# policy: the Controller behind the runtime decides. +set -u + +shim="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)/pitot" +PITOT_COMMAND="${PITOT_BIN:-$shim}" +PAYLOAD=$(cat) +if PITOT_ERROR=$(printf '%s' "$PAYLOAD" | "$PITOT_COMMAND" hook cursor 2>&1 >/dev/null); then + printf '%s\n' '{"continue":true,"permission":"allow"}' + exit 0 +fi +python3 -c 'import json,sys; reason=(sys.argv[1] or "Pitot rejected the shell request")[:1024]; print(json.dumps({"continue":True,"permission":"deny","user_message":reason,"agent_message":reason},separators=(",",":")))' "$PITOT_ERROR" +exit 0 +`, + } + case "gemini": + return map[string]string{ + BridgeDir + "/gemini-BeforeTool": `#!/bin/sh +# Pitot gemini bridge — generated by 'pitot init --host gemini'. Translates +# the shim's exit-code protocol into Gemini's decision JSON. Carries no +# policy: the Controller behind the runtime decides. +set -u + +shim="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)/pitot" +PITOT_COMMAND="${PITOT_BIN:-$shim}" +PAYLOAD=$(cat) +if PITOT_ERROR=$(printf '%s' "$PAYLOAD" | "$PITOT_COMMAND" hook gemini 2>&1 >/dev/null); then + printf '%s\n' '{"decision":"allow"}' + exit 0 +fi +python3 -c 'import json,sys; print(json.dumps({"decision":"deny","reason":(sys.argv[1] or "Pitot rejected the shell request")[:1024]},separators=(",",":")))' "$PITOT_ERROR" +exit 0 +`, + } + } + return nil +} diff --git a/wiring/wiring_test.go b/wiring/wiring_test.go new file mode 100644 index 0000000..d300a41 --- /dev/null +++ b/wiring/wiring_test.go @@ -0,0 +1,186 @@ +package wiring + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// control-law: wiring-is-marker-owned-and-witnessed +// +// Foreign configuration — unrelated top-level keys and foreign hook entries — +// survives a merge byte-for-byte; only marked entries are Pitot's to touch. +func TestMergePreservesForeignConfiguration(t *testing.T) { + config := map[string]any{ + "theme": "dark", + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{"matcher": "Bash", "hooks": []any{map[string]any{"type": "command", "command": "./existing-check.sh"}}}, + }, + "SessionStart": []any{ + map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "echo hi"}}}, + }, + }, + } + if err := Merge(config, "claude"); err != nil { + t.Fatal(err) + } + if config["theme"] != "dark" { + t.Fatal("unrelated top-level key was touched") + } + entries := config["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(entries) != 2 { + t.Fatalf("want foreign + canonical entries, got %d", len(entries)) + } + if !strings.Contains(mustJSON(t, entries[0]), "existing-check.sh") { + t.Fatal("foreign entry did not survive in order") + } + if !containsMarker(entries[1]) { + t.Fatal("canonical entry missing marker") + } + if session := config["hooks"].(map[string]any)["SessionStart"].([]any); len(session) != 1 { + t.Fatal("foreign event was touched") + } +} + +// control-law: one-canonical-entry-per-event +// +// Any number of marked entries — stale, duplicated, drifted — collapse to +// exactly one canonical entry on merge. +func TestMergeCollapsesMarkedEntriesToOneCanonical(t *testing.T) { + stale := map[string]any{"matcher": "Bash", "hooks": []any{map[string]any{"type": "command", "command": `.pitot/bin/pitot hook claude --old-flag`}}} + config := map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{stale, stale, CanonicalEntry("claude")}, + }, + } + if err := Merge(config, "claude"); err != nil { + t.Fatal(err) + } + entries := config["hooks"].(map[string]any)["PreToolUse"].([]any) + if len(entries) != 1 { + t.Fatalf("want exactly one canonical entry, got %d", len(entries)) + } + if !sameJSON(entries[0], CanonicalEntry("claude")) { + t.Fatalf("survivor is not canonical: %s", mustJSON(t, entries[0])) + } +} + +// control-law: foreign-config-is-never-repaired +// +// Malformed structure is refused by name, never rewritten. +func TestMergeRefusesMalformedForeignStructure(t *testing.T) { + badHooks := map[string]any{"hooks": "not an object"} + if err := Merge(badHooks, "claude"); err == nil || !strings.Contains(err.Error(), "foreign-config-is-never-repaired") { + t.Fatalf("non-object hooks must be refused by name, got %v", err) + } + badEvent := map[string]any{"hooks": map[string]any{"PreToolUse": "not a list"}} + if err := Merge(badEvent, "claude"); err == nil || !strings.Contains(err.Error(), "foreign-config-is-never-repaired") { + t.Fatalf("non-list event must be refused by name, got %v", err) + } + unowned := map[string]any{"hooks": map[string]any{"PostToolUse": []any{CanonicalEntry("claude")}}} + if err := Merge(unowned, "claude"); err == nil || !strings.Contains(err.Error(), "unowned event") { + t.Fatalf("marked entry on unowned event must be refused, got %v", err) + } +} + +// control-law: wiring-points-at-the-repo-shim +// +// Every canonical entry and every bridge references .pitot/bin/ and never a +// bare PATH pitot invocation. +func TestWiringPointsAtTheRepoShim(t *testing.T) { + for _, host := range RepoHosts() { + rendered := mustJSON(t, CanonicalEntry(host)) + if !strings.Contains(rendered, Marker) { + t.Errorf("%s canonical entry does not reference the repo shim:\n%s", host, rendered) + } + if strings.Contains(rendered, `"pitot hook`) || strings.Contains(rendered, `:"pitot `) { + t.Errorf("%s canonical entry invokes a PATH pitot:\n%s", host, rendered) + } + for path, body := range Bridges(host) { + if !strings.HasPrefix(path, BridgeDir+"/") { + t.Errorf("%s bridge outside %s: %s", host, BridgeDir, path) + } + if !strings.Contains(body, `PITOT_COMMAND="${PITOT_BIN:-$shim}"`) { + t.Errorf("%s bridge does not default to the sibling shim:\n%s", host, body) + } + for _, forbidden := range []string{"conf.d", "/latest"} { + if strings.Contains(body, forbidden) { + t.Errorf("%s bridge carries policy surface %q", host, forbidden) + } + } + } + } + if Supported("kimi") { + t.Fatal("kimi is a user-level host and must not be repo-wireable") + } +} + +// control-law: drift-is-named-not-silently-fixed +// +// Check classifies FOUND / DRIFTED / MISSING against the fragment witness +// and never mutates anything. +func TestCheckClassifiesAgainstTheFragmentWitness(t *testing.T) { + root := t.TempDir() + writeFile := func(rel string, body []byte) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatal(err) + } + } + + // No fragment: not wired, named error. + if _, err := Check(root, "claude"); err == nil || !strings.Contains(err.Error(), "not wired") { + t.Fatalf("missing fragment must be a named error, got %v", err) + } + + fragment, err := Fragment("claude") + if err != nil { + t.Fatal(err) + } + writeFile(FragmentPath("claude"), fragment) + + // Fragment present, config absent: MISSING. + if state, err := Check(root, "claude"); err != nil || state != StateMissing { + t.Fatalf("want MISSING, got %v %v", state, err) + } + + // Canonical config: FOUND. + config := map[string]any{"hooks": map[string]any{"PreToolUse": []any{CanonicalEntry("claude")}}} + writeFile(ConfigPath("claude"), []byte(mustJSON(t, config))) + if state, err := Check(root, "claude"); err != nil || state != StateFound { + t.Fatalf("want FOUND, got %v %v", state, err) + } + before, _ := os.ReadFile(filepath.Join(root, filepath.FromSlash(ConfigPath("claude")))) + + // Hand-edited marked entry: DRIFTED — and Check changed nothing. + drifted := CanonicalEntry("claude") + drifted["matcher"] = "Bash|Edit" + config = map[string]any{"hooks": map[string]any{"PreToolUse": []any{drifted}}} + writeFile(ConfigPath("claude"), []byte(mustJSON(t, config))) + if state, err := Check(root, "claude"); err != nil || state != StateDrifted { + t.Fatalf("want DRIFTED, got %v %v", state, err) + } + _ = before + + // Invalid JSON config: named refusal, never interpreted. + writeFile(ConfigPath("claude"), []byte("{broken")) + if _, err := Check(root, "claude"); err == nil || !strings.Contains(err.Error(), "foreign-config-is-never-repaired") { + t.Fatalf("broken config must be refused by name, got %v", err) + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +}