From 045c505a3f63fad3acb753aa58e64ede5af3669d Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 13 Oct 2026 14:13:23 +0100 Subject: [PATCH] Validate runtime registrations before starting processes --- README.md | 33 +++++++++-- adapters/adapters.go | 23 ++++---- config/config.go | 24 ++++++-- config/config_test.go | 13 +++++ runtime/request.go | 4 +- runtime/runtime.go | 3 + runtime/startup_test.go | 122 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 runtime/startup_test.go diff --git a/README.md b/README.md index f95952d..1248004 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ One language-neutral boundary for the coding agents your team already uses.

-Your coding agent runs shell commands, edits files, and calls tools. Pitot lets -you put your own code in the loop at that boundary — to allow, deny, or record -each action — without forking the agent or rewriting a host integration for -every tool. +Pitot puts your own code at a coding agent's wired action boundary, so it can +allow, deny, or observe a proposed action. The shipped Codex, Qwen, and DeepSeek +integrations supervise shell tools; they do not mediate every file edit or tool +call. Applications can also issue explicit requests for their own action kinds. Pitot reports what happened. Your code decides what it means. @@ -419,6 +419,7 @@ deny control path end to end. | Claude | `PreToolUse` | native settings hook | Ubuntu · macOS · Windows | decoder + E2E | | Cursor | `beforeShellExecution` | bridge (`integrations/cursor`) | Ubuntu · macOS · native Windows with PowerShell 7 | decoder + E2E | | Codex | `PreToolUse` | bridge (`integrations/codex`) | Ubuntu · macOS · Windows | decoder + E2E | +| DeepSeek Harness | `tools/pre-execute` / `bash`, `pwsh` | native middleware ([setup](docs/deepseek-adapter.md)) | Ubuntu · macOS · Windows | decoder + E2E | | Devin | `session/request_permission` (ACP) | stateful ACP transport | Ubuntu · macOS · Windows | decoder + E2E | | GitHub Copilot CLI | `PreToolUse` | bridge (`integrations/copilot`) | Ubuntu · macOS · Windows | decoder + E2E | | Gemini | `BeforeTool` | bridge (`integrations/gemini`) | Ubuntu · macOS · Windows | decoder + E2E | @@ -792,6 +793,13 @@ native integer range. The runtime continues to deliver only ## Controller guarantees +For Go applications, `runtime.Start` validates the protocol and all process +registrations before launching any child. Empty embedded runtimes remain valid; +file configuration still requires at least one registration. An explicit request +can use any configured nonempty kind, and its unavailable/timeout defaults may +allow or deny. `DeliverControlledEvent` separately requires a live Controller +reply for an action event. + Synchronous hooks are control channels: the host is blocked waiting for an answer. Pitot makes that privilege explicit. @@ -865,6 +873,12 @@ a slow operator diagnostic sink can still block existing delivery diagnostics. ## What can you build? +The reference runtime delivers `action.requested` to Consumers and routes +application-issued control requests to registered Controllers. The SDKs can +represent `model.usage`, but the shipped runtime does not collect or deliver +usage events. Token accounting and cross-agent memory need additional data +sources supplied by your application. + - selective cross-agent memory; - session and decision reports; - token and cost attribution; @@ -897,6 +911,10 @@ that boundary ends is part of using it correctly. that is wired but has no runtime. Once a runtime is explicitly selected, transport or authentication failure blocks the controllable action rather than falling open. +- **DeepSeek requires a live Controller reply.** Its native shell middleware + blocks execution without a selected runtime or an explicit matching reply, + even if ordinary runtime fallback defaults allow. This differs from the + observation-only behavior of command hooks with no runtime selected. - **Hosts retain their own bypass options.** Some hosts expose persistent or bypass permissions (for example Devin's ACP `allow_always` and switch-bypass modes). Pitot never selects them — it uses only the one-shot `allow_once` / @@ -917,6 +935,13 @@ that boundary ends is part of using it correctly. passing allow/deny test does not guarantee that hook execution failures block tools. Untrusted or changed hooks can also be skipped until reviewed. +- **Qwen hook failures can leave an action unsupervised.** The pinned Qwen + release treats some hook startup errors, timeouts, and invalid output as + nonblocking. A functioning bridge can return a denial, but that does not + guarantee an action is blocked when the bridge itself fails. Valid structured + denial output and the host's blocking exit status are separate from these + nonblocking failure paths. + None of these change the core contract: within the boundary Pitot mediates, every pending action receives exactly one terminal resolution. **Pitot reports. Your controller decides.** diff --git a/adapters/adapters.go b/adapters/adapters.go index 46d2ced..bed3480 100644 --- a/adapters/adapters.go +++ b/adapters/adapters.go @@ -58,22 +58,20 @@ type ParserConfig struct { ActionKinds map[string]string // hook event name -> normalized action kind ("shell", "mcp") } -// ControlPartition declares the physics of the host's hook boundaries. -// In supervisory control theory (supervisory-control.md), this defines the -// control partition (Σ = Σ_c ∪ Σ_u). +// ControlPartition declares which host events can accept a permission response +// and which are observations. Host failure behavior must be verified separately. type ControlPartition struct { - // Controllable (Σ_c): Synchronous, blocking hook events where the host - // pauses execution and waits for a permission response. The supervisor - // can safely disable these. + // Controllable: Synchronous events where a functioning boundary waits for + // a permission response. This does not promise fail-closed hook failures. Controllable []string - // Uncontrollable (Σ_u): Asynchronous, fire-and-forget hook events where + // Uncontrollable: Asynchronous, fire-and-forget hook events where // the host reports behavior but cannot be prevented or paused. These are // observation-only. Uncontrollable []string } -// HostConfig unites the regular parser with the mathematical plant physics. +// HostConfig pairs payload parsing with the declared host boundary. type HostConfig struct { MainEventName string Parser ParserConfig @@ -263,8 +261,8 @@ func preToolUseHost() HostConfig { } } -// RegisterHost registers a custom host with its configuration, enforcing -// that it complies with the required supervisory control laws. +// RegisterHost validates and registers a custom host's boundary declarations. +// It does not verify that the host actually implements those declarations. func RegisterHost(h Host, config HostConfig) error { registryMu.Lock() defer registryMu.Unlock() @@ -291,9 +289,8 @@ func RegisterHost(h Host, config HostConfig) error { return fmt.Errorf("pitot: host %q declares unsupported transport %q", h, config.Transport) } - // --- Control Laws Verification --- - // To safely supervise a host (maintain nonblocking control under K), - // the host MUST provide at least one controllable (synchronous, blocking) shell hook. + // Custom adapters must declare a controllable shell boundary. This checks + // registration structure, not host failure behavior or progress guarantees. hasControllableShell := false for _, eventName := range config.Partition.Controllable { if config.Parser.ActionKinds[eventName] == "shell" { diff --git a/config/config.go b/config/config.go index b2a8d27..b5db532 100644 --- a/config/config.go +++ b/config/config.go @@ -274,6 +274,18 @@ func (c Config) Validate() error { if len(c.Consumers) == 0 && len(c.Controllers) == 0 { return errors.New("pitot: config requires at least one consumer or controller") } + return c.validate(false) +} + +// ValidateRuntime checks the protocol and every process registration before a +// runtime starts. Unlike file configuration, an embedded runtime may have no +// registrations; its observation-only or strict behavior still applies. +// Runtime working directories may already be resolved to absolute paths. +func (c Config) ValidateRuntime() error { + return c.validate(true) +} + +func (c Config) validate(runtime bool) error { if c.RequiresProtocol != "" && c.RequiresProtocol != schema.Version { return fmt.Errorf("pitot: config requires protocol %q but this pitot speaks protocol %q", c.RequiresProtocol, schema.Version) } @@ -289,8 +301,10 @@ func (c Config) Validate() error { if err := validateCommand("consumer "+consumer.ID, consumer.Command); err != nil { return err } - if err := validateDir("consumer "+consumer.ID, consumer.Dir); err != nil { - return err + if !runtime { + if err := validateDir("consumer "+consumer.ID, consumer.Dir); err != nil { + return err + } } if len(consumer.Events) == 0 { return fmt.Errorf("pitot: consumer %q requires at least one event", consumer.ID) @@ -325,8 +339,10 @@ func (c Config) Validate() error { if err := validateCommand("controller "+controller.ID, controller.Command); err != nil { return err } - if err := validateDir("controller "+controller.ID, controller.Dir); err != nil { - return err + if !runtime { + if err := validateDir("controller "+controller.ID, controller.Dir); err != nil { + return err + } } if controller.DeadlineMS <= 0 { return fmt.Errorf("pitot: controller %q requires a positive deadline_ms", controller.ID) diff --git a/config/config_test.go b/config/config_test.go index 57691ac..5e8c107 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -56,3 +56,16 @@ func TestConfigRejectsUnknownAndAmbiguousRoles(t *testing.T) { }) } } + +func TestValidateRuntimeAllowsResolvedDirectories(t *testing.T) { + cfg := Config{ + Consumers: []ConsumerConfig{{ID: "audit", Command: []string{"consumer"}, Dir: t.TempDir(), Events: []string{"action.requested"}, Projection: ProjectionConfig{Content: "omit"}}}, + Controllers: map[string]ControllerConfig{"custom.approval": {ID: "policy", Command: []string{"controller"}, Dir: t.TempDir(), DeadlineMS: 1000, OnTimeout: "deny", OnUnavailable: "allow"}}, + } + if err := cfg.ValidateRuntime(); err != nil { + t.Fatal(err) + } + if err := cfg.Validate(); err == nil { + t.Fatal("file configuration accepted absolute working directories") + } +} diff --git a/runtime/request.go b/runtime/request.go index f67749a..180ebdf 100644 --- a/runtime/request.go +++ b/runtime/request.go @@ -50,7 +50,9 @@ func NewControlRequest(kind string, payload any) (schema.ControlRequested, error // the correlated response. It is the typed convenience over Request: the caller // supplies a structured payload rather than a pre-framed envelope with raw JSON. // All of Request's guarantees still apply — correlation, the client deadline, and -// the runtime's fail-closed unavailable/timeout defaults. +// the runtime's configured unavailable/timeout defaults, which may allow or deny. +// An application-issued request does not require a live Controller reply; +// DeliverControlledEvent provides that separate behavior for action events. func (c *Client) RequestTyped(ctx context.Context, kind string, payload any) (schema.ControlResponse, error) { req, err := NewControlRequest(kind, payload) if err != nil { diff --git a/runtime/runtime.go b/runtime/runtime.go index c75719c..ad532fc 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -71,6 +71,9 @@ func (m *Manager) reportDecision(kind string, response *schema.ControlResponse) // Start creates the complete configured process boundary. A child that cannot // start remains unavailable so the declared default can resolve requests. func Start(parent context.Context, cfg config.Config, stderr io.Writer) (*Manager, error) { + if err := cfg.ValidateRuntime(); err != nil { + return nil, err + } ctx, cancel := context.WithCancel(parent) manager := &Manager{ ctx: ctx, diff --git a/runtime/startup_test.go b/runtime/startup_test.go new file mode 100644 index 0000000..e7031be --- /dev/null +++ b/runtime/startup_test.go @@ -0,0 +1,122 @@ +package runtime + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/operatorstack/pitot/config" + "github.com/operatorstack/pitot/projection" + "github.com/operatorstack/pitot/schema" +) + +func TestStartValidatesBeforeLaunchingProcesses(t *testing.T) { + cases := []struct { + name string + change func(*config.Config) + }{ + {"protocol", func(c *config.Config) { c.RequiresProtocol = "unsupported" }}, + {"unsupported event", func(c *config.Config) { c.Consumers[0].Events = []string{schema.TypeModelUsage} }}, + {"missing events", func(c *config.Config) { c.Consumers[0].Events = nil }}, + {"duplicate events", func(c *config.Config) { + c.Consumers[0].Events = append(c.Consumers[0].Events, schema.TypeActionRequested) + }}, + {"duplicate consumer", func(c *config.Config) { c.Consumers = append(c.Consumers, c.Consumers[0]) }}, + {"projection", func(c *config.Config) { c.Consumers[0].Projection.Content = "invalid" }}, + {"consumer command", func(c *config.Config) { c.Consumers[0].Command = nil }}, + {"controller command", func(c *config.Config) { + v := c.Controllers["custom.approval"] + v.Command = nil + c.Controllers["custom.approval"] = v + }}, + {"controller deadline", func(c *config.Config) { + v := c.Controllers["custom.approval"] + v.DeadlineMS = 0 + c.Controllers["custom.approval"] = v + }}, + {"controller default", func(c *config.Config) { + v := c.Controllers["custom.approval"] + v.OnUnavailable = "invalid" + c.Controllers["custom.approval"] = v + }}, + {"duplicate controller identity", func(c *config.Config) { c.Controllers["another.kind"] = c.Controllers["custom.approval"] }}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + marker := filepath.Join(t.TempDir(), "started") + command := []string{os.Args[0], "-test.run=^TestStartupMarkerProcess$", "--", marker} + cfg := config.Config{Consumers: []config.ConsumerConfig{{ID: "audit", Command: command, Events: []string{schema.TypeActionRequested}, Projection: config.ProjectionConfig{Content: projection.Omit}}}, Controllers: map[string]config.ControllerConfig{"custom.approval": {ID: "policy", Command: command, DeadlineMS: 1000, OnTimeout: schema.OutcomeDeny, OnUnavailable: schema.OutcomeAllow}}} + tt.change(&cfg) + manager, err := Start(context.Background(), cfg, io.Discard) + if manager != nil { + manager.Close() + t.Fatal("invalid configuration returned a manager") + } + if err == nil { + t.Fatal("invalid configuration accepted") + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("child process started before validation: %v", err) + } + }) + } +} + +func TestStartupMarkerProcess(t *testing.T) { + for i, arg := range os.Args { + if arg == "--" && i+1 < len(os.Args) { + if err := os.WriteFile(os.Args[i+1], []byte("started"), 0600); err != nil { + os.Exit(2) + } + os.Exit(0) + } + } +} + +func TestStartEmptyRuntimeProtocol(t *testing.T) { + for _, strict := range []bool{false, true} { + cfg := config.Config{RequireController: strict, RequiresProtocol: schema.Version} + if cfg.Validate() == nil { + t.Fatal("file configuration unexpectedly accepts no registrations") + } + manager, err := Start(context.Background(), cfg, io.Discard) + if err != nil { + t.Fatal(err) + } + manager.Close() + } + manager, err := Start(context.Background(), config.Config{RequiresProtocol: "unsupported"}, io.Discard) + if manager != nil { + manager.Close() + t.Fatal("unsupported protocol returned manager") + } + if err == nil { + t.Fatal("empty runtime bypassed protocol validation") + } +} + +func TestStartCustomRequestDefaults(t *testing.T) { + for _, outcome := range []string{schema.OutcomeAllow, schema.OutcomeDeny} { + t.Run(outcome, func(t *testing.T) { + cfg := config.Config{Controllers: map[string]config.ControllerConfig{"custom.approval": {ID: "policy", Command: []string{filepath.Join(t.TempDir(), "absent-program")}, DeadlineMS: 1000, OnTimeout: outcome, OnUnavailable: outcome}}} + manager, err := Start(context.Background(), cfg, io.Discard) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + request, err := NewControlRequest("custom.approval", map[string]string{"task": "review"}) + if err != nil { + t.Fatal(err) + } + response, err := manager.Request(context.Background(), request) + if err == nil { + t.Fatal("missing process must retain its diagnostic error alongside the fallback") + } + if response.Outcome != outcome || response.ActionID != request.ActionID { + t.Fatalf("configured custom request fallback changed: %+v", response) + } + }) + } +}