Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
One language-neutral boundary for the coding agents your team already uses.
</p>

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.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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` /
Expand All @@ -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.**
Expand Down
23 changes: 10 additions & 13 deletions adapters/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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" {
Expand Down
24 changes: 20 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 3 additions & 1 deletion runtime/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
122 changes: 122 additions & 0 deletions runtime/startup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading