From 35eee2034626c2a50e17fb39a2dad96f274077cf Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 21 Jul 2026 18:43:55 +0100 Subject: [PATCH 1/3] feat(revector): implement Revector v0 core deterministic supervisor --- go.work.sum | 4 + labs/19-revector/README.md | 24 ++ labs/19-revector/docs/00-grounding.md | 28 +++ labs/19-revector/docs/01-specification.md | 28 +++ labs/19-revector/docs/02-policy.md | 26 ++ labs/19-revector/docs/03-validation.md | 20 ++ labs/19-revector/docs/04-harbor-next.md | 16 ++ .../revector/adapter/settle/sensor.go | 43 ++++ .../revector/adapter/settle/sensor_test.go | 58 +++++ .../revector/cmd/revector/decide.go | 122 +++++++++ .../revector/cmd/revector/doctor.go | 40 +++ .../19-revector/revector/cmd/revector/main.go | 28 +++ .../revector/cmd/revector/main_test.go | 10 + .../revector/cmd/revector/policy.go | 20 ++ .../revector/cmd/revector/replay.go | 134 ++++++++++ labs/19-revector/revector/go.mod | 7 + .../revector/policy/fixture-only.json | 14 ++ .../revector/scripts/check-import-boundary.sh | 37 +++ .../revector/scripts/check-zero-inference.sh | 27 ++ labs/19-revector/revector/scripts/validate.sh | 58 +++++ .../revector/supervisor/decision.go | 47 ++++ labs/19-revector/revector/supervisor/event.go | 62 +++++ .../revector/supervisor/golden_test.go | 184 ++++++++++++++ .../19-revector/revector/supervisor/policy.go | 40 +++ .../revector/supervisor/progress.go | 56 +++++ labs/19-revector/revector/supervisor/state.go | 15 ++ .../revector/supervisor/supervisor.go | 235 ++++++++++++++++++ .../supervisor/testdata/clean-progress.ndjson | 7 + .../testdata/diagnosis-advisory.ndjson | 9 + .../testdata/failed-verification.ndjson | 5 + .../testdata/missing-sensors-fail-open.ndjson | 2 + .../testdata/new-evidence-releases.ndjson | 9 + .../testdata/nonblocking-recovery.ndjson | 11 + .../supervisor/testdata/probe-releases.ndjson | 9 + .../testdata/stale-verification.ndjson | 5 + .../revector/supervisor/validate.go | 25 ++ 36 files changed, 1465 insertions(+) create mode 100644 go.work.sum create mode 100644 labs/19-revector/README.md create mode 100644 labs/19-revector/docs/00-grounding.md create mode 100644 labs/19-revector/docs/01-specification.md create mode 100644 labs/19-revector/docs/02-policy.md create mode 100644 labs/19-revector/docs/03-validation.md create mode 100644 labs/19-revector/docs/04-harbor-next.md create mode 100644 labs/19-revector/revector/adapter/settle/sensor.go create mode 100644 labs/19-revector/revector/adapter/settle/sensor_test.go create mode 100644 labs/19-revector/revector/cmd/revector/decide.go create mode 100644 labs/19-revector/revector/cmd/revector/doctor.go create mode 100644 labs/19-revector/revector/cmd/revector/main.go create mode 100644 labs/19-revector/revector/cmd/revector/main_test.go create mode 100644 labs/19-revector/revector/cmd/revector/policy.go create mode 100644 labs/19-revector/revector/cmd/revector/replay.go create mode 100644 labs/19-revector/revector/go.mod create mode 100644 labs/19-revector/revector/policy/fixture-only.json create mode 100755 labs/19-revector/revector/scripts/check-import-boundary.sh create mode 100755 labs/19-revector/revector/scripts/check-zero-inference.sh create mode 100755 labs/19-revector/revector/scripts/validate.sh create mode 100644 labs/19-revector/revector/supervisor/decision.go create mode 100644 labs/19-revector/revector/supervisor/event.go create mode 100644 labs/19-revector/revector/supervisor/golden_test.go create mode 100644 labs/19-revector/revector/supervisor/policy.go create mode 100644 labs/19-revector/revector/supervisor/progress.go create mode 100644 labs/19-revector/revector/supervisor/state.go create mode 100644 labs/19-revector/revector/supervisor/supervisor.go create mode 100644 labs/19-revector/revector/supervisor/testdata/clean-progress.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/diagnosis-advisory.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/failed-verification.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/missing-sensors-fail-open.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/new-evidence-releases.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/nonblocking-recovery.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/probe-releases.ndjson create mode 100644 labs/19-revector/revector/supervisor/testdata/stale-verification.ndjson create mode 100644 labs/19-revector/revector/supervisor/validate.go diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 000000000..cc45ad38c --- /dev/null +++ b/go.work.sum @@ -0,0 +1,4 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/labs/19-revector/README.md b/labs/19-revector/README.md new file mode 100644 index 000000000..3664c0644 --- /dev/null +++ b/labs/19-revector/README.md @@ -0,0 +1,24 @@ +# Lab 19 — Revector + +> A zero-supervisor-inference trajectory governor for existing software agents. + +Revector does not solve the software task, nor does it call a model to judge whether a patch is correct. Instead, it observes an agent's trajectory and governs controllable events using verifiable evidence, ensuring the trajectory remains capable of reaching a verified terminal state. Settle serves as one recurrence sensor inside the wider Revector system. + +## Verified Claims + +- Deterministic core without random behavior +- Verified fixture behavior for canonical approaches and failures +- Exact verification freshness rule +- Nonblocking receipt contract ensuring recovery actions remain open +- No supervisor model dependency +- No external runtime module beyond the local Settle package + +## Being Evaluated + +- Benchmark uplift is not yet established +- Token savings +- Small-model uplift +- Cross-agent generality +- Calibrated approach enforcement (using empirical data) + +**Note:** Harbor data was not processed in this implementation, no benchmark uplift is claimed, no live agent integration was added, and no model or judge call was added. \ No newline at end of file diff --git a/labs/19-revector/docs/00-grounding.md b/labs/19-revector/docs/00-grounding.md new file mode 100644 index 000000000..deecb09b3 --- /dev/null +++ b/labs/19-revector/docs/00-grounding.md @@ -0,0 +1,28 @@ +# Grounding the Plan in the Repository + +## Verified Facts + +| Claim | Exact path | Exact symbol or line evidence | Verification command | +|-------|------------|-------------------------------|----------------------| +| Settle Go module exists and module path is correct | `labs/18-settle/settle/go.mod` | `module github.com/operatorstack/settle` | `cat labs/18-settle/settle/go.mod` | +| `Detector` type exists | `labs/18-settle/settle/detector/detector.go` | `type Detector struct {` (L7) | `grep -E "type Detector" labs/18-settle/settle/detector/detector.go` | +| `New` func exists | `labs/18-settle/settle/detector/detector.go` | `func New(cfg Config) *Detector` (L14) | `grep -E "func New" labs/18-settle/settle/detector/detector.go` | +| `Observe` method exists | `labs/18-settle/settle/detector/detector.go` | `func (d *Detector) Observe(rawCommand string) Decision` (L19) | `grep -E "func .*Observe" labs/18-settle/settle/detector/detector.go` | +| `Record` method exists | `labs/18-settle/settle/detector/detector.go` | `func (d *Detector) Record(rawCommand string, exitCode int, errText, stateHash string) Observation` (L37) | `grep -E "func .*Record" labs/18-settle/settle/detector/detector.go` | +| `State` type exists | `labs/18-settle/settle/detector/detector.go` | `type State struct {` (L59) | `grep -E "type State" labs/18-settle/settle/detector/detector.go` | +| `Decision` type exists | `labs/18-settle/settle/detector/ladder.go` | `type Decision struct {` (L33) | `grep -E "type Decision" labs/18-settle/settle/detector/ladder.go` | +| `Observation` type exists | `labs/18-settle/settle/detector/observation.go` | `type Observation struct {` (L7) | `grep -E "type Observation" labs/18-settle/settle/detector/observation.go` | +| `OutcomeAllow` constant exists | `labs/18-settle/settle/detector/ladder.go` | `OutcomeAllow = "allow"` (L6) | `grep -E "OutcomeAllow" labs/18-settle/settle/detector/ladder.go` | +| `OutcomeDeny` constant exists | `labs/18-settle/settle/detector/ladder.go` | `OutcomeDeny = "deny"` (L7) | `grep -E "OutcomeDeny" labs/18-settle/settle/detector/ladder.go` | +| `ActionRedirect` constant exists | `labs/18-settle/settle/detector/ladder.go` | `ActionRedirect Action = "redirect"` (L27) | `grep -E "ActionRedirect" labs/18-settle/settle/detector/ladder.go` | +| `ActionDeny` constant exists | `labs/18-settle/settle/detector/ladder.go` | `ActionDeny Action = "deny"` (L28) | `grep -E "ActionDeny" labs/18-settle/settle/detector/ladder.go` | +| Baseline tests pass | `labs/18-settle/settle` | `ok github.com/operatorstack/settle/detector 0.418s` | `cd labs/18-settle/settle && go test ./... && go vet ./...` | +| Go version verified | `labs/18-settle/settle` | `go version go1.26.5 darwin/arm64` | `go version` | + +## Grounding Gate Check + +- [x] Settle Go module exists +- [x] Module path is verified (`github.com/operatorstack/settle`) +- [x] Detector package is importable locally +- [x] Observe, Record, State, and Decision surfaces exist +- [x] Baseline tests pass diff --git a/labs/19-revector/docs/01-specification.md b/labs/19-revector/docs/01-specification.md new file mode 100644 index 000000000..9a18b9109 --- /dev/null +++ b/labs/19-revector/docs/01-specification.md @@ -0,0 +1,28 @@ +# Specification + +## Definitions + +* **Plant:** The system under control, consisting of the software agent, the workspace, and the tools. +* **Observable Events:** Concrete, verifiable occurrences in the environment (e.g., test execution result, returned error signature). +* **Controllable Events:** Actions the agent intends to take that Revector can allow, deny, or redirect. +* **Marked State:** A terminal or checkpoint state with verified behavior. +* **Exact Verification Specification:** A strict Boolean rule operating on indisputable, observable facts (e.g., a test pass). +* **Approximate Approach Specification:** A heuristic rule using observable proxies to indicate potential lack of progress (e.g., unchanged errors across mutations). +* **Nonblocking Invariant:** Revector must never deny all possible actions; a denial must leave at least one explicit recovery action enabled. +* **Fail-Open Behavior:** On unknown, malformed, or missing signals, Revector preserves an "allow" outcome to prevent unintended blocking while recording a degraded boundary fault. + +## Exact Verification Freshness + +Completion is allowed if and only if no mutation has occurred or there is a subsequent verification pass after the last mutation: +`completion_allowed iff t_verify_pass > t_last_mutation` + +## Approximate Approach Stagnation + +We track `m_t` = mutations since last new evidence. +New evidence is defined as a new error signature, an expanded frontier of observed targets, a changed verification result, or a successful verification. + +## Core Limits + +* Revector v0 does not estimate P(success). +* Revector v0 does not recover the user's full objective. +* Revector v0 does not judge patch correctness. \ No newline at end of file diff --git a/labs/19-revector/docs/02-policy.md b/labs/19-revector/docs/02-policy.md new file mode 100644 index 000000000..c72d2b477 --- /dev/null +++ b/labs/19-revector/docs/02-policy.md @@ -0,0 +1,26 @@ +# Policy format and Execution + +## Event Protocol +Revector operates on a canonical JSON event stream. The schema `revector.event.v0` defines: +- `action.requested`: An intent to perform an action. +- `action.observed`: The result of an executed action. +- `completion.requested`: An intent to claim task completion. + +## State +A serializable supervisor `State` stores minimal derived variables (e.g., `mutations_since_new_evidence`, `observed_frontier`) ensuring the supervisor remains pure and stateless across CLI invocations. + +## Rule Precedence +1. Validate schema and event ordering. +2. If completion request: check pending actions, then exact verification freshness. +3. If action request: evaluate Settle signals, then apply diagnosis policy, surface lower-force signals, otherwise allow. +4. If action observation: validate match, derive evidence progression, update state, export to Settle. + +## Policy Statuses and Rule Modes +**Statuses**: `fixture_only`, `observed`, `validated` +**Modes**: `off`, `shadow`, `advisory`, `enforce` + +## Decision Receipt +Decisions return an explicit receipt containing the outcome (allow/deny), a directive (allow, inform, probe, verify, revector), state class, policy provenance, and the allowed next recovery actions. No confidence or probability scores are emitted. + +## Release Conditions +A diagnostic state is automatically released upon the observation of new evidence (new error signature, frontier expansion, or verification change). \ No newline at end of file diff --git a/labs/19-revector/docs/03-validation.md b/labs/19-revector/docs/03-validation.md new file mode 100644 index 000000000..84896e2dd --- /dev/null +++ b/labs/19-revector/docs/03-validation.md @@ -0,0 +1,20 @@ +# Validation + +## Fixture Semantics +Validation relies on canonical synthetic NDJSON fixtures covering behaviors such as clean progress, stale verification, failed verification, diagnosis advisory, probe releases, and boundary fault fail-open scenarios. + +## Commands +Validation runs via `./scripts/validate.sh` which executes: +- `gofmt check` +- `go test ./...` and `go test -race ./...` +- `go vet ./...` +- Import and zero-inference bounds checking. +- Deterministic replay checks via diffing runs. +- Policy rejection validation for unvalidated strict enforcement. + +## Boundaries +- **Import Boundary:** Verified via `check-import-boundary.sh`, ensuring `supervisor` only uses standard and isolated packages. +- **Zero-Inference:** Verified via `check-zero-inference.sh`, confirming no external network or model module dependencies are compiled into the core. + +## Scope of Fixture Success +Fixture success proves the architectural separation, determinism, rule strictness, and fail-open guarantees of Revector v0. **It does not establish benchmark uplift or operational Harbor effectiveness.** \ No newline at end of file diff --git a/labs/19-revector/docs/04-harbor-next.md b/labs/19-revector/docs/04-harbor-next.md new file mode 100644 index 000000000..65975c485 --- /dev/null +++ b/labs/19-revector/docs/04-harbor-next.md @@ -0,0 +1,16 @@ +# Harbor: Next Phase + +The integration of Harbor data is deliberately excluded from the current v0 implementation. +The subsequent phase will execute the following pipeline: + +1. **Inventory**: Catalog actual Harbor trajectory schemas and output files. +2. **Adapter Construction**: Create a grounded event adapter operating strictly on empirically observed fields, preserving missing fields as `unknown`. +3. **Semantic Decoupling**: Ensure that a zero exit code from a shell execution is never blindly mapped to a verification pass. +4. **Data Splitting**: Partition empirical trajectories by task into training (calibration) and holdout (evaluation) sets. +5. **Shadow Replay**: Replay the calibration set in shadow mode to analyze hypothetical control outcomes. +6. **Metric Extraction**: Quantify intervention precision, false positive harm, and evidence generation. +7. **Threshold Derivation**: Derive advisory stagnation thresholds driven strictly by recovery probabilities observed in the data. +8. **Policy Freeze**: Freeze the resulting parameterized policy prior to holdout evaluation. +9. **Online Experiment**: Conduct a paired online experiment utilizing the identical model, agent framework, task distribution, random seed, token budget, and trajectory limits. + +*Explicitly: No Harbor data was analyzed, converted, or reasoned over in this PR.* \ No newline at end of file diff --git a/labs/19-revector/revector/adapter/settle/sensor.go b/labs/19-revector/revector/adapter/settle/sensor.go new file mode 100644 index 000000000..3ee27a7fa --- /dev/null +++ b/labs/19-revector/revector/adapter/settle/sensor.go @@ -0,0 +1,43 @@ +package settle + +import ( + "github.com/operatorstack/revector/supervisor" + "github.com/operatorstack/settle/detector" +) + +type Sensor struct { + det *detector.Detector +} + +func New(cfg detector.Config, state *detector.State) *Sensor { + d := detector.New(cfg) + if state != nil { + d.Import(*state) + } + return &Sensor{det: d} +} + +func (s *Sensor) Observe(cmd string) supervisor.ExternalSignal { + if cmd == "" { + return supervisor.ExternalSignal{} + } + dec := s.det.Observe(cmd) + return supervisor.ExternalSignal{ + Source: "settle", + SourceAction: string(dec.Action), + SourceOutcome: dec.Outcome, + SourceClass: string(dec.Class), + SourceSignals: dec.Signals, + } +} + +func (s *Sensor) Record(cmd string, exitCode int, errText string, stateHash string) { + if cmd == "" { + return + } + s.det.Record(cmd, exitCode, errText, stateHash) +} + +func (s *Sensor) Export() detector.State { + return s.det.Export() +} diff --git a/labs/19-revector/revector/adapter/settle/sensor_test.go b/labs/19-revector/revector/adapter/settle/sensor_test.go new file mode 100644 index 000000000..49f61fe94 --- /dev/null +++ b/labs/19-revector/revector/adapter/settle/sensor_test.go @@ -0,0 +1,58 @@ +package settle + +import ( + "testing" + + "github.com/operatorstack/settle/detector" +) + +func TestAdapter(t *testing.T) { + t.Run("empty state imports successfully", func(t *testing.T) { + s := New(detector.DefaultConfig(), &detector.State{}) + state := s.Export() + if len(state.Obs) != 0 || len(state.Cmds) != 0 { + t.Errorf("expected empty state") + } + }) + + t.Run("observe returns translated signal", func(t *testing.T) { + s := New(detector.DefaultConfig(), nil) + sig := s.Observe("ls") + if sig.Source != "settle" { + t.Errorf("expected source settle, got %q", sig.Source) + } + if sig.SourceAction != string(detector.ActionAllow) { + t.Errorf("expected action allow, got %q", sig.SourceAction) + } + if sig.SourceOutcome != detector.OutcomeAllow { + t.Errorf("expected outcome allow, got %q", sig.SourceOutcome) + } + }) + + t.Run("record updates and exports", func(t *testing.T) { + s := New(detector.DefaultConfig(), nil) + s.Record("ls", 1, "error", "hash") + + state := s.Export() + if len(state.Obs) != 1 { + t.Errorf("expected 1 observation, got %d", len(state.Obs)) + } + if state.Obs[0].NormCommand != "ls" { + t.Errorf("expected norm command 'ls', got %q", state.Obs[0].NormCommand) + } + }) + + t.Run("missing command fails open", func(t *testing.T) { + s := New(detector.DefaultConfig(), nil) + sig := s.Observe("") + if sig.Source != "" { + t.Errorf("expected empty signal for empty command") + } + + s.Record("", 1, "error", "hash") + state := s.Export() + if len(state.Obs) != 0 { + t.Errorf("expected no observation recorded for empty command") + } + }) +} diff --git a/labs/19-revector/revector/cmd/revector/decide.go b/labs/19-revector/revector/cmd/revector/decide.go new file mode 100644 index 000000000..a2e2a4be8 --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/decide.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/operatorstack/revector/adapter/settle" + "github.com/operatorstack/revector/supervisor" + settle_detector "github.com/operatorstack/settle/detector" +) + +type RuntimeState struct { + Supervisor supervisor.State `json:"supervisor"` + Settle settle_detector.State `json:"settle"` +} + +type DecideRequest struct { + SchemaVersion string `json:"schema_version"` + State *RuntimeState `json:"state,omitempty"` + Event supervisor.CanonicalEvent `json:"event"` +} + +type DecideResponse struct { + SchemaVersion string `json:"schema_version"` + Decision *supervisor.DecisionReceipt `json:"decision"` + State RuntimeState `json:"state"` +} + +func runDecide(args []string) int { + fs := flag.NewFlagSet("decide", flag.ExitOnError) + policyPath := fs.String("policy", "", "path to policy file") + fs.Parse(args) + + if *policyPath == "" { + fmt.Fprintln(os.Stderr, "revector decide: --policy is required") + return 2 + } + + policy, err := loadPolicy(*policyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "revector decide: error loading policy: %v\n", err) + return 1 + } + + sup, err := supervisor.New(policy) + if err != nil { + fmt.Fprintf(os.Stderr, "revector decide: invalid policy: %v\n", err) + return 1 + } + + b, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "revector decide: error reading stdin: %v\n", err) + return 1 + } + + var req DecideRequest + if err := json.Unmarshal(b, &req); err != nil { + fmt.Fprintf(os.Stderr, "revector decide: invalid JSON: %v\n", err) + return 1 + } + + var supState *supervisor.State + var setDetState *settle_detector.State + if req.State != nil { + supState = &req.State.Supervisor + setDetState = &req.State.Settle + } + + adapter := settle.New(settle_detector.DefaultConfig(), setDetState) + + var extSignals []supervisor.ExternalSignal + if req.Event.Kind == supervisor.KindActionRequested && req.Event.Action != nil { + sig := adapter.Observe(req.Event.Action.Command) + if sig.Source != "" { + extSignals = append(extSignals, sig) + } + } else if req.Event.Kind == supervisor.KindActionObserved && req.Event.Observation != nil { + var pendingCommand string + if supState != nil && supState.PendingAction != nil && supState.PendingAction.ID == req.Event.Observation.ActionID { + pendingCommand = supState.PendingAction.Command + } + if pendingCommand != "" { + exitCode := 0 + if req.Event.Observation.ExitCode != nil { + exitCode = *req.Event.Observation.ExitCode + } + errSig := "" + if req.Event.Observation.ErrorSignature != nil { + errSig = *req.Event.Observation.ErrorSignature + } + stateHash := "" + if req.Event.Observation.StateHash != nil { + stateHash = *req.Event.Observation.StateHash + } + adapter.Record(pendingCommand, exitCode, errSig, stateHash) + } + } + + receipt, nextSupState := sup.Decide(supState, &req.Event, extSignals) + + resp := DecideResponse{ + SchemaVersion: "revector.response.v0", + Decision: receipt, + State: RuntimeState{ + Supervisor: *nextSupState, + Settle: adapter.Export(), + }, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(resp); err != nil { + fmt.Fprintf(os.Stderr, "revector decide: error encoding response: %v\n", err) + return 1 + } + + return 0 +} diff --git a/labs/19-revector/revector/cmd/revector/doctor.go b/labs/19-revector/revector/cmd/revector/doctor.go new file mode 100644 index 000000000..081c1156f --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/doctor.go @@ -0,0 +1,40 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/operatorstack/revector/supervisor" +) + +func runDoctor(args []string) int { + fs := flag.NewFlagSet("doctor", flag.ExitOnError) + policyPath := fs.String("policy", "", "path to policy file") + fs.Parse(args) + + if *policyPath == "" { + fmt.Fprintln(os.Stderr, "revector doctor: --policy is required") + return 2 + } + + policy, err := loadPolicy(*policyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "revector doctor: error parsing policy: %v\n", err) + return 1 + } + + if err := supervisor.ValidatePolicy(policy); err != nil { + fmt.Fprintf(os.Stderr, "revector doctor: policy rejection: %v\n", err) + return 1 + } + + _, err = supervisor.New(policy) + if err != nil { + fmt.Fprintf(os.Stderr, "revector doctor: initialization failed: %v\n", err) + return 1 + } + + fmt.Println("revector doctor: ok") + return 0 +} diff --git a/labs/19-revector/revector/cmd/revector/main.go b/labs/19-revector/revector/cmd/revector/main.go new file mode 100644 index 000000000..30d7ee218 --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: revector [args...]") + os.Exit(2) + } + + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "decide": + os.Exit(runDecide(args)) + case "replay": + os.Exit(runReplay(args)) + case "doctor": + os.Exit(runDoctor(args)) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) + os.Exit(2) + } +} diff --git a/labs/19-revector/revector/cmd/revector/main_test.go b/labs/19-revector/revector/cmd/revector/main_test.go new file mode 100644 index 000000000..3b7d16ccd --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/main_test.go @@ -0,0 +1,10 @@ +package main + +import ( + "os" + "testing" +) + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/labs/19-revector/revector/cmd/revector/policy.go b/labs/19-revector/revector/cmd/revector/policy.go new file mode 100644 index 000000000..92c85df37 --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/policy.go @@ -0,0 +1,20 @@ +package main + +import ( + "encoding/json" + "os" + + "github.com/operatorstack/revector/supervisor" +) + +func loadPolicy(path string) (supervisor.Policy, error) { + b, err := os.ReadFile(path) + if err != nil { + return supervisor.Policy{}, err + } + var p supervisor.Policy + if err := json.Unmarshal(b, &p); err != nil { + return supervisor.Policy{}, err + } + return p, nil +} diff --git a/labs/19-revector/revector/cmd/revector/replay.go b/labs/19-revector/revector/cmd/revector/replay.go new file mode 100644 index 000000000..7e6678901 --- /dev/null +++ b/labs/19-revector/revector/cmd/revector/replay.go @@ -0,0 +1,134 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/operatorstack/revector/adapter/settle" + "github.com/operatorstack/revector/supervisor" + settle_detector "github.com/operatorstack/settle/detector" +) + +type ReplaySummary struct { + EventCount int + AllowCount int + DenyCount int + DirectiveCounts map[supervisor.Directive]int + BoundaryFaultCount int +} + +func runReplay(args []string) int { + fs := flag.NewFlagSet("replay", flag.ExitOnError) + policyPath := fs.String("policy", "", "path to policy file") + fs.Parse(args) + + if *policyPath == "" || fs.NArg() == 0 { + fmt.Fprintln(os.Stderr, "usage: revector replay --policy ...") + return 2 + } + + policy, err := loadPolicy(*policyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "revector replay: error loading policy: %v\n", err) + return 1 + } + + sup, err := supervisor.New(policy) + if err != nil { + fmt.Fprintf(os.Stderr, "revector replay: invalid policy: %v\n", err) + return 1 + } + + for _, file := range fs.Args() { + f, err := os.Open(file) + if err != nil { + fmt.Fprintf(os.Stderr, "error opening %s: %v\n", file, err) + return 1 + } + + var supState *supervisor.State + adapter := settle.New(settle_detector.DefaultConfig(), nil) + + summary := ReplaySummary{ + DirectiveCounts: make(map[supervisor.Directive]int), + } + + scanner := bufio.NewScanner(f) + enc := json.NewEncoder(os.Stdout) + + for scanner.Scan() { + var ev supervisor.CanonicalEvent + if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + fmt.Fprintf(os.Stderr, "invalid JSON: %v\n", err) + f.Close() + return 1 + } + + summary.EventCount++ + + var extSignals []supervisor.ExternalSignal + if ev.Kind == supervisor.KindActionRequested && ev.Action != nil { + sig := adapter.Observe(ev.Action.Command) + if sig.Source != "" { + extSignals = append(extSignals, sig) + } + } else if ev.Kind == supervisor.KindActionObserved && ev.Observation != nil { + var pendingCommand string + if supState != nil && supState.PendingAction != nil && supState.PendingAction.ID == ev.Observation.ActionID { + pendingCommand = supState.PendingAction.Command + } + if pendingCommand != "" { + exitCode := 0 + if ev.Observation.ExitCode != nil { + exitCode = *ev.Observation.ExitCode + } + errSig := "" + if ev.Observation.ErrorSignature != nil { + errSig = *ev.Observation.ErrorSignature + } + stateHash := "" + if ev.Observation.StateHash != nil { + stateHash = *ev.Observation.StateHash + } + adapter.Record(pendingCommand, exitCode, errSig, stateHash) + } + } + + receipt, nextSupState := sup.Decide(supState, &ev, extSignals) + supState = nextSupState + + if receipt.Outcome == supervisor.OutcomeAllow { + summary.AllowCount++ + } else { + summary.DenyCount++ + } + summary.DirectiveCounts[receipt.Directive]++ + if receipt.StateClass == supervisor.StateBoundaryDegraded { + summary.BoundaryFaultCount++ + } + + if err := enc.Encode(receipt); err != nil { + fmt.Fprintf(os.Stderr, "error encoding receipt: %v\n", err) + f.Close() + return 1 + } + } + + f.Close() + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "error reading %s: %v\n", file, err) + return 1 + } + + if err := enc.Encode(summary); err != nil { + fmt.Fprintf(os.Stderr, "error encoding summary: %v\n", err) + return 1 + } + } + + return 0 +} diff --git a/labs/19-revector/revector/go.mod b/labs/19-revector/revector/go.mod new file mode 100644 index 000000000..0b71ff8a3 --- /dev/null +++ b/labs/19-revector/revector/go.mod @@ -0,0 +1,7 @@ +module github.com/operatorstack/revector + +go 1.26 + +replace github.com/operatorstack/settle => ../../18-settle/settle + +require github.com/operatorstack/settle v0.0.0 // indirect diff --git a/labs/19-revector/revector/policy/fixture-only.json b/labs/19-revector/revector/policy/fixture-only.json new file mode 100644 index 000000000..ea1033466 --- /dev/null +++ b/labs/19-revector/revector/policy/fixture-only.json @@ -0,0 +1,14 @@ +{ + "status": "fixture_only", + "verification_freshness": { + "mode": "enforce" + }, + "approach_stagnation": { + "mode": "advisory", + "mutation_limit": 3, + "provenance": "synthetic fixture only" + }, + "settle_recurrence": { + "mode": "advisory" + } +} diff --git a/labs/19-revector/revector/scripts/check-import-boundary.sh b/labs/19-revector/revector/scripts/check-import-boundary.sh new file mode 100755 index 000000000..24dffe0dc --- /dev/null +++ b/labs/19-revector/revector/scripts/check-import-boundary.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "=== check-import-boundary.sh ===" + +# We check that the `supervisor` package does not import forbidden packages. +FORBIDDEN=( + "os" + "io" + "net" + "net/http" + "os/exec" + "time" + "math/rand" + "crypto/rand" + "github.com/operatorstack/settle" + "github.com/operatorstack/revector/adapter" + "github.com/operatorstack/revector/cmd" +) + +# go list -f '{{.Imports}}' github.com/operatorstack/revector/supervisor +IMPORTS=$(go list -f '{{join .Imports "\n"}}' github.com/operatorstack/revector/supervisor) + +FAILED=0 +for pkg in "${FORBIDDEN[@]}"; do + if echo "$IMPORTS" | grep -q "^${pkg}$"; then + echo "ERROR: supervisor package imports forbidden package: $pkg" + FAILED=1 + fi +done + +if [ "$FAILED" -eq 1 ]; then + exit 1 +fi + +echo "Import boundary check passed." diff --git a/labs/19-revector/revector/scripts/check-zero-inference.sh b/labs/19-revector/revector/scripts/check-zero-inference.sh new file mode 100755 index 000000000..b8457c72b --- /dev/null +++ b/labs/19-revector/revector/scripts/check-zero-inference.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "=== check-zero-inference.sh ===" + +MODULES=$(go list -m -f '{{.Path}}' all) + +# Must only contain github.com/operatorstack/revector and github.com/operatorstack/settle +# Actually, go list -m all will list dependencies, but wait. If we only require settle, and standard library is used, we should see only these two. +# Wait, Settle depends on some generic lists if we saw earlier? No, GOWORK=off go list -m all output: +# github.com/operatorstack/revector +# github.com/operatorstack/settle + +EXPECTED="github.com/operatorstack/revector +github.com/operatorstack/settle" + +if [ "$MODULES" != "$EXPECTED" ]; then + echo "ERROR: Zero-inference boundary violated." + echo "Expected modules:" + echo "$EXPECTED" + echo "Actual modules:" + echo "$MODULES" + exit 1 +fi + +echo "Zero-inference check passed." diff --git a/labs/19-revector/revector/scripts/validate.sh b/labs/19-revector/revector/scripts/validate.sh new file mode 100755 index 000000000..f0a373d18 --- /dev/null +++ b/labs/19-revector/revector/scripts/validate.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +export GOWORK=off + +echo "=== gofmt check ===" +if [ -n "$(gofmt -l .)" ]; then + echo "gofmt failed on the following files:" + gofmt -l . + exit 1 +fi + +echo "=== go test ./... ===" +go test ./... + +echo "=== go test -race ./... ===" +go test -race ./... + +echo "=== go vet ./... ===" +go vet ./... + +echo "=== ./scripts/check-import-boundary.sh ===" +./scripts/check-import-boundary.sh + +echo "=== ./scripts/check-zero-inference.sh ===" +./scripts/check-zero-inference.sh + +echo "=== deterministic replay comparison ===" +go build -o revector ./cmd/revector +./revector replay --policy policy/fixture-only.json supervisor/testdata/clean-progress.ndjson > run1.json +./revector replay --policy policy/fixture-only.json supervisor/testdata/clean-progress.ndjson > run2.json +echo "cmp run1.json run2.json" +cmp run1.json run2.json +rm run1.json run2.json + +echo "=== policy rejection test ===" +cat << 'EOF' > policy-rejection-test.json +{"status": "fixture_only", "approach_stagnation": {"mode": "enforce"}} +EOF +set +e +set +o pipefail +OUTPUT=$(./revector doctor --policy policy-rejection-test.json 2>&1) +set -e +set -o pipefail + +if echo "$OUTPUT" | grep -q "requires validated status"; then + echo "Policy rejection test passed" +else + echo "Policy rejection test failed" + echo "$OUTPUT" + rm policy-rejection-test.json revector + exit 1 +fi +rm policy-rejection-test.json revector + +echo "=== git diff --check ===" +git diff --check diff --git a/labs/19-revector/revector/supervisor/decision.go b/labs/19-revector/revector/supervisor/decision.go new file mode 100644 index 000000000..118d53efe --- /dev/null +++ b/labs/19-revector/revector/supervisor/decision.go @@ -0,0 +1,47 @@ +package supervisor + +const DecisionSchemaVersion = "revector.decision.v0" + +type Outcome string + +const ( + OutcomeAllow Outcome = "allow" + OutcomeDeny Outcome = "deny" +) + +type Directive string + +const ( + DirectiveAllow Directive = "allow" + DirectiveInform Directive = "inform" + DirectiveProbe Directive = "probe" + DirectiveVerify Directive = "verify" + DirectiveRevector Directive = "revector" +) + +type StateClass string + +const ( + StateProgressing StateClass = "progressing" + StateDiagnosisRequired StateClass = "diagnosis_required" + StateVerificationRequired StateClass = "verification_required" + StateBoundaryDegraded StateClass = "boundary_degraded" +) + +type DecisionReceipt struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + Sequence int `json:"sequence"` + EventKind EventKind `json:"event_kind"` + Outcome Outcome `json:"outcome"` + Directive Directive `json:"directive"` + StateClass StateClass `json:"state_class"` + RuleID string `json:"rule_id,omitempty"` + PolicyID string `json:"policy_id,omitempty"` + PolicyStatus PolicyStatus `json:"policy_status"` + Signals []string `json:"signals,omitempty"` + Reason string `json:"reason,omitempty"` + AllowedNextActions []ActionClass `json:"allowed_next_actions,omitempty"` + ReleaseConditions []string `json:"release_conditions,omitempty"` + BoundaryFaults []string `json:"boundary_faults,omitempty"` +} diff --git a/labs/19-revector/revector/supervisor/event.go b/labs/19-revector/revector/supervisor/event.go new file mode 100644 index 000000000..7c0c7ad64 --- /dev/null +++ b/labs/19-revector/revector/supervisor/event.go @@ -0,0 +1,62 @@ +package supervisor + +const EventSchemaVersion = "revector.event.v0" + +type EventKind string + +const ( + KindActionRequested EventKind = "action.requested" + KindActionObserved EventKind = "action.observed" + KindCompletionRequested EventKind = "completion.requested" +) + +type ActionClass string + +const ( + ClassUnknown ActionClass = "unknown" + ClassInspect ActionClass = "inspect" + ClassSearch ActionClass = "search" + ClassMutate ActionClass = "mutate" + ClassVerify ActionClass = "verify" + ClassOther ActionClass = "other" +) + +type VerificationResult string + +const ( + VerificationUnknown VerificationResult = "unknown" + VerificationPass VerificationResult = "pass" + VerificationFail VerificationResult = "fail" +) + +type CanonicalEvent struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + Sequence int `json:"sequence"` + Kind EventKind `json:"kind"` + + Action *ActionRequest `json:"action,omitempty"` + Observation *ActionObservation `json:"observation,omitempty"` + Completion *CompletionRequest `json:"completion,omitempty"` +} + +type ActionRequest struct { + ID string `json:"id"` + Class ActionClass `json:"class"` + Command string `json:"command"` + Targets []string `json:"targets"` +} + +type ActionObservation struct { + ActionID string `json:"action_id"` + ExitCode *int `json:"exit_code,omitempty"` + ErrorSignature *string `json:"error_signature,omitempty"` + StateHash *string `json:"state_hash,omitempty"` + Verification VerificationResult `json:"verification"` + ObservedTargets []string `json:"observed_targets"` +} + +type CompletionRequest struct { + ClaimID string `json:"claim_id"` + Summary string `json:"summary"` +} diff --git a/labs/19-revector/revector/supervisor/golden_test.go b/labs/19-revector/revector/supervisor/golden_test.go new file mode 100644 index 000000000..6c0b5e794 --- /dev/null +++ b/labs/19-revector/revector/supervisor/golden_test.go @@ -0,0 +1,184 @@ +package supervisor + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGoldenFixtures(t *testing.T) { + policy := Policy{ + ID: "test-fixture", + Status: StatusFixtureOnly, + VerificationFreshness: VerificationRule{ + Mode: ModeEnforce, + }, + ApproachStagnation: ApproachRule{ + Mode: ModeAdvisory, + MutationLimit: 3, + Provenance: "synthetic fixture only", + }, + SettleRecurrence: SettleRule{ + Mode: ModeAdvisory, + }, + } + + testCases := []struct { + file string + chk func(t *testing.T, receipts []*DecisionReceipt) + }{ + {"clean-progress.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.Outcome != OutcomeAllow { + t.Errorf("expected clean progress to allow, got %s", last.Outcome) + } + }}, + {"stale-verification.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.Outcome != OutcomeDeny { + t.Errorf("expected completion denied, got %s", last.Outcome) + } + if last.Directive != DirectiveVerify { + t.Errorf("expected directive verify, got %s", last.Directive) + } + }}, + {"failed-verification.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.Outcome != OutcomeDeny { + t.Errorf("expected completion denied, got %s", last.Outcome) + } + if last.Directive != DirectiveVerify { + t.Errorf("expected directive verify, got %s", last.Directive) + } + }}, + {"diagnosis-advisory.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.Outcome != OutcomeAllow { + t.Errorf("expected advisory allow, got %s", last.Outcome) + } + if last.Directive != DirectiveRevector { + t.Errorf("expected directive revector, got %s", last.Directive) + } + }}, + {"probe-releases.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.StateClass == StateDiagnosisRequired { + t.Errorf("expected diagnosis required to be released") + } + }}, + {"new-evidence-releases.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.StateClass == StateDiagnosisRequired { + t.Errorf("expected diagnosis required to be released on new evidence") + } + }}, + {"missing-sensors-fail-open.ndjson", func(t *testing.T, receipts []*DecisionReceipt) { + last := receipts[len(receipts)-1] + if last.Outcome != OutcomeAllow { + t.Errorf("expected allow outcome on missing sensors, got %s", last.Outcome) + } + if last.StateClass != StateBoundaryDegraded { + t.Errorf("expected boundary degraded, got %s", last.StateClass) + } + }}, + } + + for _, tc := range testCases { + t.Run(tc.file, func(t *testing.T) { + f, err := os.Open(filepath.Join("testdata", tc.file)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + sup, err := New(policy) + if err != nil { + t.Fatal(err) + } + + var state *State + var receipts []*DecisionReceipt + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var ev CanonicalEvent + if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + t.Fatal(err) + } + receipt, nextState := sup.Decide(state, &ev, nil) + receipts = append(receipts, receipt) + state = nextState + } + + tc.chk(t, receipts) + }) + } + + t.Run("nonblocking-recovery", func(t *testing.T) { + // Custom validated policy + valPolicy := policy + valPolicy.Status = StatusValidated + valPolicy.ApproachStagnation.Mode = ModeEnforce + + f, err := os.Open(filepath.Join("testdata", "nonblocking-recovery.ndjson")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + sup, err := New(valPolicy) + if err != nil { + t.Fatal(err) + } + + var state *State + var receipts []*DecisionReceipt + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var ev CanonicalEvent + if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + t.Fatal(err) + } + receipt, nextState := sup.Decide(state, &ev, nil) + receipts = append(receipts, receipt) + state = nextState + } + + // Sequence 9 requested a mutate, which is denied. + // Sequence 10 requested an inspect, which is allowed. + if receipts[8].Outcome != OutcomeDeny { + t.Errorf("expected seq 9 (index 8) to be denied, got %s", receipts[8].Outcome) + } + + allowed := receipts[8].AllowedNextActions + foundInspect := false + for _, a := range allowed { + if a == ClassInspect { + foundInspect = true + } + } + if !foundInspect { + t.Errorf("expected allowed actions to include inspect") + } + + if receipts[9].Outcome != OutcomeAllow { + t.Errorf("expected seq 10 (index 9) to be allowed, got %s", receipts[9].Outcome) + } + }) + + t.Run("invalid_policy", func(t *testing.T) { + invalidPolicy := policy + invalidPolicy.Status = StatusFixtureOnly + invalidPolicy.ApproachStagnation.Mode = ModeEnforce + + if err := ValidatePolicy(invalidPolicy); err == nil { + t.Errorf("expected validation to reject unvalidated approach enforcement") + } else if !strings.Contains(err.Error(), "requires validated status") { + t.Errorf("unexpected error message: %v", err) + } + }) +} diff --git a/labs/19-revector/revector/supervisor/policy.go b/labs/19-revector/revector/supervisor/policy.go new file mode 100644 index 000000000..b86eba62c --- /dev/null +++ b/labs/19-revector/revector/supervisor/policy.go @@ -0,0 +1,40 @@ +package supervisor + +type PolicyStatus string + +const ( + StatusFixtureOnly PolicyStatus = "fixture_only" + StatusObserved PolicyStatus = "observed" + StatusValidated PolicyStatus = "validated" +) + +type RuleMode string + +const ( + ModeOff RuleMode = "off" + ModeShadow RuleMode = "shadow" + ModeAdvisory RuleMode = "advisory" + ModeEnforce RuleMode = "enforce" +) + +type Policy struct { + ID string `json:"id,omitempty"` + Status PolicyStatus `json:"status"` + VerificationFreshness VerificationRule `json:"verification_freshness"` + ApproachStagnation ApproachRule `json:"approach_stagnation"` + SettleRecurrence SettleRule `json:"settle_recurrence"` +} + +type VerificationRule struct { + Mode RuleMode `json:"mode"` +} + +type ApproachRule struct { + Mode RuleMode `json:"mode"` + MutationLimit int `json:"mutation_limit"` + Provenance string `json:"provenance"` +} + +type SettleRule struct { + Mode RuleMode `json:"mode"` +} diff --git a/labs/19-revector/revector/supervisor/progress.go b/labs/19-revector/revector/supervisor/progress.go new file mode 100644 index 000000000..c21112c51 --- /dev/null +++ b/labs/19-revector/revector/supervisor/progress.go @@ -0,0 +1,56 @@ +package supervisor + +import ( + "sort" +) + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func unionAndSort(a, b []string) []string { + seen := make(map[string]bool) + for _, x := range a { + seen[x] = true + } + for _, x := range b { + seen[x] = true + } + var res []string + for k := range seen { + res = append(res, k) + } + sort.Strings(res) + return res +} + +func pointerEqual(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func EvaluateEvidence(state *State, obs *ActionObservation) (newEvidence bool, frontierExpanded bool, newErr bool, verifChanged bool, verifPass bool) { + newErr = !pointerEqual(state.LastErrorSignature, obs.ErrorSignature) + + newFrontier := unionAndSort(state.ObservedFrontier, obs.ObservedTargets) + frontierExpanded = !sliceEqual(state.ObservedFrontier, newFrontier) + + verifChanged = state.LastVerificationResult != obs.Verification + verifPass = obs.Verification == VerificationPass + + newEvidence = newErr || frontierExpanded || verifChanged || verifPass + return +} diff --git a/labs/19-revector/revector/supervisor/state.go b/labs/19-revector/revector/supervisor/state.go new file mode 100644 index 000000000..dc0275aa7 --- /dev/null +++ b/labs/19-revector/revector/supervisor/state.go @@ -0,0 +1,15 @@ +package supervisor + +type State struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + LastSequence int `json:"last_sequence"` + PendingAction *ActionRequest `json:"pending_action,omitempty"` + LastMutationSequence int `json:"last_mutation_sequence"` + LastVerificationPassSequence int `json:"last_verification_pass_sequence"` + MutationsSinceNewEvidence int `json:"mutations_since_new_evidence"` + LastErrorSignature *string `json:"last_error_signature,omitempty"` + ObservedFrontier []string `json:"observed_frontier"` + LastVerificationResult VerificationResult `json:"last_verification_result"` + DiagnosisRequired bool `json:"diagnosis_required"` +} diff --git a/labs/19-revector/revector/supervisor/supervisor.go b/labs/19-revector/revector/supervisor/supervisor.go new file mode 100644 index 000000000..026eb1a05 --- /dev/null +++ b/labs/19-revector/revector/supervisor/supervisor.go @@ -0,0 +1,235 @@ +package supervisor + +import ( + "fmt" +) + +type ExternalSignal struct { + Source string `json:"source"` + SourceAction string `json:"source_action"` + SourceOutcome string `json:"source_outcome"` + SourceClass string `json:"source_class"` + SourceSignals []string `json:"source_signals"` +} + +type Supervisor struct { + policy Policy +} + +func New(policy Policy) (*Supervisor, error) { + if err := ValidatePolicy(policy); err != nil { + return nil, err + } + return &Supervisor{policy: policy}, nil +} + +func cloneState(s *State) *State { + if s == nil { + return &State{ + ObservedFrontier: []string{}, + LastVerificationResult: VerificationUnknown, + } + } + clone := *s + if s.PendingAction != nil { + p := *s.PendingAction + p.Targets = append([]string(nil), s.PendingAction.Targets...) + clone.PendingAction = &p + } + clone.ObservedFrontier = append([]string(nil), s.ObservedFrontier...) + if s.LastErrorSignature != nil { + e := *s.LastErrorSignature + clone.LastErrorSignature = &e + } + return &clone +} + +func (s *Supervisor) Decide(state *State, event *CanonicalEvent, extSignals []ExternalSignal) (*DecisionReceipt, *State) { + newState := cloneState(state) + receipt := &DecisionReceipt{ + SchemaVersion: DecisionSchemaVersion, + RunID: event.RunID, + Sequence: event.Sequence, + EventKind: event.Kind, + PolicyID: s.policy.ID, + PolicyStatus: s.policy.Status, + } + + faults := validateEvent(event, newState) + if len(faults) > 0 { + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveAllow + receipt.StateClass = StateBoundaryDegraded + receipt.BoundaryFaults = faults + receipt.Reason = "fail open on boundary fault" + + newState.LastSequence = event.Sequence + if newState.RunID == "" { + newState.RunID = event.RunID + } + + if event.Kind == KindActionObserved { + newState.PendingAction = nil + } + + return receipt, newState + } + + newState.LastSequence = event.Sequence + if newState.RunID == "" { + newState.RunID = event.RunID + } + + switch event.Kind { + case KindCompletionRequested: + return s.handleCompletion(event.Completion, newState, receipt) + case KindActionRequested: + return s.handleActionRequest(event.Action, newState, receipt, extSignals) + case KindActionObserved: + return s.handleActionObserved(event.Observation, newState, receipt) + } + + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveAllow + receipt.StateClass = StateBoundaryDegraded + receipt.BoundaryFaults = append(receipt.BoundaryFaults, "unknown event kind") + return receipt, newState +} + +func (s *Supervisor) handleCompletion(req *CompletionRequest, state *State, receipt *DecisionReceipt) (*DecisionReceipt, *State) { + if state.PendingAction != nil { + receipt.Outcome = OutcomeDeny + receipt.Directive = DirectiveVerify + receipt.StateClass = StateVerificationRequired + receipt.RuleID = "verification.pending_action" + receipt.Reason = "completion requested while action is pending" + receipt.AllowedNextActions = []ActionClass{ClassVerify} + return receipt, state + } + + if s.policy.VerificationFreshness.Mode == ModeEnforce { + stale := state.LastVerificationPassSequence <= state.LastMutationSequence + if state.LastMutationSequence == 0 && state.LastVerificationPassSequence == 0 { + stale = false // wait, if there were no mutations and no pass, is it stale? Plan: "no mutation has occurred or t_verify_pass > t_last_mutation". If no mutation has occurred, last_mutation_sequence = 0. We can check if LastMutationSequence == 0. + } + if state.LastMutationSequence > 0 && state.LastVerificationPassSequence <= state.LastMutationSequence { + stale = true + } else { + stale = false + } + if stale { + receipt.Outcome = OutcomeDeny + receipt.Directive = DirectiveVerify + receipt.StateClass = StateVerificationRequired + receipt.RuleID = "verification.stale_after_mutation" + receipt.Reason = "verification is stale after mutation" + receipt.AllowedNextActions = []ActionClass{ClassVerify} + return receipt, state + } + } + + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveAllow + receipt.StateClass = StateProgressing + return receipt, state +} + +func (s *Supervisor) handleActionRequest(req *ActionRequest, state *State, receipt *DecisionReceipt, extSignals []ExternalSignal) (*DecisionReceipt, *State) { + state.PendingAction = req + + // 1. Settle + for _, sig := range extSignals { + if sig.Source == "settle" { + if sig.SourceOutcome == "deny" && s.policy.SettleRecurrence.Mode == ModeEnforce { + receipt.Outcome = OutcomeDeny + receipt.Directive = DirectiveVerify + receipt.StateClass = StateProgressing + receipt.RuleID = "settle_recurrence" + receipt.Reason = "settle hard deny" + receipt.AllowedNextActions = []ActionClass{ClassInspect, ClassSearch, ClassVerify} + receipt.Signals = sig.SourceSignals + return receipt, state + } + if sig.SourceOutcome == "deny" || sig.SourceOutcome == "redirect" { + receipt.Signals = append(receipt.Signals, fmt.Sprintf("settle advisory: %s", sig.SourceAction)) + } + } + } + + // 2. Approach stagnation + if state.DiagnosisRequired { + if req.Class == ClassMutate { + if s.policy.ApproachStagnation.Mode == ModeEnforce { + receipt.Outcome = OutcomeDeny + receipt.Directive = DirectiveRevector + receipt.StateClass = StateDiagnosisRequired + receipt.RuleID = "approach.mutations_without_new_evidence" + receipt.Reason = "too many mutations without new evidence" + receipt.AllowedNextActions = []ActionClass{ClassInspect, ClassSearch, ClassVerify} + return receipt, state + } else if s.policy.ApproachStagnation.Mode == ModeAdvisory { + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveRevector + receipt.StateClass = StateDiagnosisRequired + receipt.RuleID = "approach.mutations_without_new_evidence" + receipt.Reason = "advisory: too many mutations without new evidence" + return receipt, state + } + } + } + + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveAllow + if state.DiagnosisRequired { + receipt.StateClass = StateDiagnosisRequired + } else { + receipt.StateClass = StateProgressing + } + + return receipt, state +} + +func (s *Supervisor) handleActionObserved(obs *ActionObservation, state *State, receipt *DecisionReceipt) (*DecisionReceipt, *State) { + isMutate := state.PendingAction != nil && state.PendingAction.Class == ClassMutate + state.PendingAction = nil + + newEv, _, newErr, _, isPass := EvaluateEvidence(state, obs) + + if newErr && obs.ErrorSignature != nil { + state.LastErrorSignature = obs.ErrorSignature + } + if obs.ObservedTargets != nil { + state.ObservedFrontier = unionAndSort(state.ObservedFrontier, obs.ObservedTargets) + } + state.LastVerificationResult = obs.Verification + + if isMutate { + state.LastMutationSequence = receipt.Sequence + if !newEv { + state.MutationsSinceNewEvidence++ + } + } + + if newEv { + state.MutationsSinceNewEvidence = 0 + state.DiagnosisRequired = false + } + + if isPass { + state.LastVerificationPassSequence = receipt.Sequence + } + + if state.MutationsSinceNewEvidence >= s.policy.ApproachStagnation.MutationLimit && s.policy.ApproachStagnation.MutationLimit > 0 { + state.DiagnosisRequired = true + } + + receipt.Outcome = OutcomeAllow + receipt.Directive = DirectiveAllow + if state.DiagnosisRequired { + receipt.StateClass = StateDiagnosisRequired + } else { + receipt.StateClass = StateProgressing + } + + return receipt, state +} diff --git a/labs/19-revector/revector/supervisor/testdata/clean-progress.ndjson b/labs/19-revector/revector/supervisor/testdata/clean-progress.ndjson new file mode 100644 index 000000000..650c9f066 --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/clean-progress.ndjson @@ -0,0 +1,7 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "inspect", "command": "ls", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a1", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a2", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 5, "action": {"id": "a3", "class": "verify", "command": "test", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 6, "observation": {"action_id": "a3", "verification": "pass", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "completion.requested", "sequence": 7, "completion": {"claim_id": "c1", "summary": "done"}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/diagnosis-advisory.ndjson b/labs/19-revector/revector/supervisor/testdata/diagnosis-advisory.ndjson new file mode 100644 index 000000000..26406fdf4 --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/diagnosis-advisory.ndjson @@ -0,0 +1,9 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a0", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a0", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a1", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 5, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 6, "observation": {"action_id": "a2", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 7, "action": {"id": "a3", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 8, "observation": {"action_id": "a3", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 9, "action": {"id": "a4", "class": "mutate", "command": "sed", "targets": []}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/failed-verification.ndjson b/labs/19-revector/revector/supervisor/testdata/failed-verification.ndjson new file mode 100644 index 000000000..cf462426e --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/failed-verification.ndjson @@ -0,0 +1,5 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a1", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a2", "class": "verify", "command": "test", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a2", "verification": "fail", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "completion.requested", "sequence": 5, "completion": {"claim_id": "c1", "summary": "done"}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/missing-sensors-fail-open.ndjson b/labs/19-revector/revector/supervisor/testdata/missing-sensors-fail-open.ndjson new file mode 100644 index 000000000..b14efcc3a --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/missing-sensors-fail-open.ndjson @@ -0,0 +1,2 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/new-evidence-releases.ndjson b/labs/19-revector/revector/supervisor/testdata/new-evidence-releases.ndjson new file mode 100644 index 000000000..77c913c3a --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/new-evidence-releases.ndjson @@ -0,0 +1,9 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a1", "error_signature": "err1", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a2", "error_signature": "err1", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 5, "action": {"id": "a3", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 6, "observation": {"action_id": "a3", "error_signature": "err1", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 7, "action": {"id": "a4", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 8, "observation": {"action_id": "a4", "error_signature": "err2", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 9, "action": {"id": "a5", "class": "mutate", "command": "sed", "targets": []}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/nonblocking-recovery.ndjson b/labs/19-revector/revector/supervisor/testdata/nonblocking-recovery.ndjson new file mode 100644 index 000000000..ad7d2c322 --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/nonblocking-recovery.ndjson @@ -0,0 +1,11 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a0", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a0", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a1", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 5, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 6, "observation": {"action_id": "a2", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 7, "action": {"id": "a3", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 8, "observation": {"action_id": "a3", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 9, "action": {"id": "a4", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 10, "action": {"id": "a5", "class": "inspect", "command": "ls", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 11, "observation": {"action_id": "a5", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1", "t2"]}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/probe-releases.ndjson b/labs/19-revector/revector/supervisor/testdata/probe-releases.ndjson new file mode 100644 index 000000000..2e7aa0a05 --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/probe-releases.ndjson @@ -0,0 +1,9 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a1", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a2", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 5, "action": {"id": "a3", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 6, "observation": {"action_id": "a3", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 7, "action": {"id": "a4", "class": "inspect", "command": "ls", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 8, "observation": {"action_id": "a4", "error_signature": "err1", "verification": "unknown", "observed_targets": ["t1", "t2"]}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 9, "action": {"id": "a5", "class": "mutate", "command": "sed", "targets": []}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/testdata/stale-verification.ndjson b/labs/19-revector/revector/supervisor/testdata/stale-verification.ndjson new file mode 100644 index 000000000..538022427 --- /dev/null +++ b/labs/19-revector/revector/supervisor/testdata/stale-verification.ndjson @@ -0,0 +1,5 @@ +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 1, "action": {"id": "a1", "class": "verify", "command": "test", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 2, "observation": {"action_id": "a1", "verification": "pass", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.requested", "sequence": 3, "action": {"id": "a2", "class": "mutate", "command": "sed", "targets": []}} +{"schema_version": "revector.event.v0", "kind": "action.observed", "sequence": 4, "observation": {"action_id": "a2", "verification": "unknown", "observed_targets": []}} +{"schema_version": "revector.event.v0", "kind": "completion.requested", "sequence": 5, "completion": {"claim_id": "c1", "summary": "done"}} \ No newline at end of file diff --git a/labs/19-revector/revector/supervisor/validate.go b/labs/19-revector/revector/supervisor/validate.go new file mode 100644 index 000000000..fac939887 --- /dev/null +++ b/labs/19-revector/revector/supervisor/validate.go @@ -0,0 +1,25 @@ +package supervisor + +import "errors" + +func ValidatePolicy(p Policy) error { + if p.ApproachStagnation.Mode == ModeEnforce && p.Status != StatusValidated { + return errors.New("policy rejection: approach stagnation enforce mode requires validated status") + } + return nil +} + +func validateEvent(e *CanonicalEvent, state *State) []string { + var faults []string + if e.Sequence <= state.LastSequence && state.LastSequence != 0 { + faults = append(faults, "event out of order") + } + if e.Kind == KindActionObserved { + if state.PendingAction == nil { + faults = append(faults, "observation without pending action") + } else if e.Observation == nil || e.Observation.ActionID != state.PendingAction.ID { + faults = append(faults, "observation action ID does not match pending action") + } + } + return faults +} From e92755ebc5165450224cc48bbd565d19d5e2b779 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 21 Jul 2026 19:18:11 +0100 Subject: [PATCH 2/3] feat(pitot): integrate Gemini CLI as a supported host adapter - Register `gemini` adapter parsing the synchronous `BeforeTool` hook - Update Pitot CLI `hook` and `doctor` commands to recognize `gemini` - Add `e2e_gemini_cli_test.sh` to satisfy the supervisory control build gate - Update E2E and windtunnel testing coverage to ensure parity with Cursor/Claude/Codex boundaries - Append mandatory release note and sync public distribution JSON --- .../15-pitot/pitot-distribution/UPSTREAM.json | 12 +++++------ .../2026-07-21-gemini-cli-adapter.md | 3 +++ labs/15-pitot/pitot/adapters/adapters.go | 21 +++++++++++++++++++ labs/15-pitot/pitot/cmd/pitot/main.go | 4 ++-- labs/15-pitot/pitot/cmd/pitot/main_test.go | 2 +- labs/15-pitot/pitot/doc.go | 2 +- labs/15-pitot/pitot/e2e/e2e_hook_test.go | 1 + .../pitot/windtunnel/windtunnel_test.go | 1 + labs/15-pitot/tests/e2e_gemini_cli_test.sh | 2 ++ 9 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md create mode 100755 labs/15-pitot/tests/e2e_gemini_cli_test.sh diff --git a/labs/15-pitot/pitot-distribution/UPSTREAM.json b/labs/15-pitot/pitot-distribution/UPSTREAM.json index 220e63615..9d686bc0e 100644 --- a/labs/15-pitot/pitot-distribution/UPSTREAM.json +++ b/labs/15-pitot/pitot-distribution/UPSTREAM.json @@ -2,7 +2,7 @@ "files": { "CONTRIBUTING.md": "0613b71aa497f8ca7d7296bf34ade87bfc7237a664d2e812d9b77b3b6befb0ad", "README.md": "e54193fee53061889b3d5e82df7c76e72e440f265377d260b4a793475c216a7a", - "adapters/adapters.go": "80110df7289ea69f830b48667326ef297770c596e4ddbf1bc66201639f8288ad", + "adapters/adapters.go": "1b09f188ed9d3f8a1b159055556bd2a68153ad6ee9d59810b23c18a96a4a83bd", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", "assets/pitot-boundary.svg": "0c3871d70c84748573f231842091deb38a6def2862403ca34e8cc4493b9c9ebf", "assets/pitot-hero.png": "a73532252b1e66c06273abbf5a4fe6261e98de3133b09e8d550edacfeeab92f8", @@ -13,15 +13,15 @@ "bridge/bridge.go": "79ac2e025e16782f3c283b43cbea5b9ba4f837446583864df8f57d6346cae816", "bridge/bridge_test.go": "23a19b7580d4b1e826224ec8322208ccca44ddd9a97b150efe15cafecc53e47f", "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", - "cmd/pitot/main.go": "a8ef8a789abe13f41d8a1014de3fb4ad46522b558f0ace70786b904e81892b05", - "cmd/pitot/main_test.go": "ab2894327e7a5e72e9f3d83cae637e3ccb2e1ae05f14458be4fe9016149aeef7", + "cmd/pitot/main.go": "266cfdd1b758f2842a3cc232346e4ada4360077b1aa18c3a635697f56cab78a8", + "cmd/pitot/main_test.go": "7281ac0029d5e92c63c9feb1d2095aaef924aacf4e8a3db9af52872174d609ac", "conformance/conformance.go": "43b692114f45c8b52958e34b35aee1cee339d8321c90f92ab4f5b963e79935bb", "conformance/conformance_test.go": "83ab0bcc15371265a954d177e4e97d81ad3ea734bbf736a29a54628ef64b52cd", "conformance/fixtures/negative.jsonl": "383dd001910699886bb1074d9225c91d8a6201e9fb3267d1a1f6e1c2753b0ba6", "conformance/fixtures/positive.jsonl": "d3af0f2529dac9b33fa4900f5938e36fd0b17383088dd4f0de7e6eca269441d1", - "doc.go": "4dcd7a831a0a8ee6c3f6eeb8408c2e6898994509b11209564c0ed6b9a5218fce", + "doc.go": "9b51db0301aa428db731edcf8aa6b5a4fd71ae84f6a0d6cb2fc27656b04b1428", "e2e/e2e_coverage_test.go": "8fac62d6d1c4ced359f8bb070379a88e77ded9912d867c642cda2d4703df23a7", - "e2e/e2e_hook_test.go": "a1c4601964eed674693306450249cf11a50978e5d2e00df152e33dc2c45ee1a0", + "e2e/e2e_hook_test.go": "cb9f27f4471c81c3320ea11be039a19514f5399b5ec5a80ae3b375dad8f8e8b5", "examples/doc.go": "58f3f9eb7d272d7b6eecdb05f43e1613d5e3ef92d15d97c5440bd4b6990c26f9", "examples/local-approval/main.go": "51386af324cd7d3bb07fe3ace53503884b02714b96b83073342fde81ce3b83a5", "examples/token-meter/main.go": "4b1b9c1a43c3cf48b09dba6f607776caced9d2b5b562373496b31ed184582dd1", @@ -42,7 +42,7 @@ "sensor/sensor.go": "939e146eaa51906972f98cf4615747eb75811b0c54d467a938825750336abeae", "sensor/sensor_test.go": "ea9a58d45ad56d29214a40fb2cfa935e769f85334afcb1856a20fb938d486245", "windtunnel/doc.go": "44e0bcde632da73e1f8b98beade3a34ca8e0d0ea79cdfb91d131de290b164fc4", - "windtunnel/windtunnel_test.go": "1c8b6c5d56ff5c4e66403baeae0d98108255d153c1275572b8c68e333e091493" + "windtunnel/windtunnel_test.go": "7e70ddf99411278175d2d2eb8ed73733367c77b3d04d55b4bf00738518673200" }, "schema_version": 1 } diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md new file mode 100644 index 000000000..5e91ed288 --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md @@ -0,0 +1,3 @@ +### Add Gemini CLI as a supported host adapter + +Pitot now supports the `gemini` host adapter. This integration natively parses Gemini CLI's `BeforeTool` synchronous hook events, allowing the Pitot sensor to safely monitor and project Gemini's shell command behavior along with Cursor, Claude, and Codex. \ No newline at end of file diff --git a/labs/15-pitot/pitot/adapters/adapters.go b/labs/15-pitot/pitot/adapters/adapters.go index 401faadbc..3ff0bcbeb 100644 --- a/labs/15-pitot/pitot/adapters/adapters.go +++ b/labs/15-pitot/pitot/adapters/adapters.go @@ -26,6 +26,7 @@ const ( Cursor Host = "cursor" Claude Host = "claude" Codex Host = "codex" + Gemini Host = "gemini" ) // AdapterVersion is the semantic version stamped onto normalized events so @@ -122,6 +123,26 @@ var ( Controllable: []string{"PreToolUse"}, }, }, + Gemini: { + MainEventName: "BeforeTool", + Parser: ParserConfig{ + CanonicalEvent: []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`), + CommandFor: func(raw RawHookEvent) (string, bool) { + if raw.ToolInput == nil { + return "", false + } + value, present := raw.ToolInput["command"].(string) + return value, present && value != "" + }, + ActionKinds: map[string]string{ + "BeforeTool": "shell", + }, + }, + Partition: ControlPartition{ + // In Gemini, the BeforeTool hook is synchronous (blocking). + Controllable: []string{"BeforeTool"}, + }, + }, } ) diff --git a/labs/15-pitot/pitot/cmd/pitot/main.go b/labs/15-pitot/pitot/cmd/pitot/main.go index f41ce6aba..aa8d13555 100644 --- a/labs/15-pitot/pitot/cmd/pitot/main.go +++ b/labs/15-pitot/pitot/cmd/pitot/main.go @@ -53,10 +53,10 @@ func run(args []string, stdout, stderr io.Writer) error { // from stdin, normalizes it, and exits with 0 (allow) or 2 (block/deny). func runHook(args []string, stdout, stderr io.Writer) error { if len(args) == 0 { - return fmt.Errorf("pitot: hook requires a host identifier (cursor, claude, codex)") + return fmt.Errorf("pitot: hook requires a host identifier (cursor, claude, codex, gemini)") } host := adapters.Host(args[0]) - if host != adapters.Cursor && host != adapters.Claude && host != adapters.Codex { + if host != adapters.Cursor && host != adapters.Claude && host != adapters.Codex && host != adapters.Gemini { return fmt.Errorf("pitot: unsupported hook host %q", host) } diff --git a/labs/15-pitot/pitot/cmd/pitot/main_test.go b/labs/15-pitot/pitot/cmd/pitot/main_test.go index 41e00db51..a6d82105c 100644 --- a/labs/15-pitot/pitot/cmd/pitot/main_test.go +++ b/labs/15-pitot/pitot/cmd/pitot/main_test.go @@ -14,7 +14,7 @@ func TestDoctorReportsBoundary(t *testing.T) { t.Fatalf("doctor: %v", err) } out := stdout.String() - for _, want := range []string{"local boundary", "cursor", "claude", "codex", "decoder=PASS", "unauthenticated local socket: none"} { + for _, want := range []string{"local boundary", "cursor", "claude", "codex", "gemini", "decoder=PASS", "unauthenticated local socket: none"} { if !strings.Contains(out, want) { t.Errorf("doctor output missing %q\n%s", want, out) } diff --git a/labs/15-pitot/pitot/doc.go b/labs/15-pitot/pitot/doc.go index 0825540cd..7e86b52c6 100644 --- a/labs/15-pitot/pitot/doc.go +++ b/labs/15-pitot/pitot/doc.go @@ -9,7 +9,7 @@ // // schema/ public event and response types + versioned constants // protocol/ newline-delimited JSON framing and state-machine helpers -// adapters/ Claude Code, Cursor, and Codex host boundaries +// adapters/ Claude Code, Cursor, Codex, and Gemini CLI host boundaries // sensor/ normalization and observation pipeline (decoder) // bridge/ controller routing and single-response transport // projection/ full, sha256, and omit content policies diff --git a/labs/15-pitot/pitot/e2e/e2e_hook_test.go b/labs/15-pitot/pitot/e2e/e2e_hook_test.go index dc0e94e27..ec7dfda5d 100644 --- a/labs/15-pitot/pitot/e2e/e2e_hook_test.go +++ b/labs/15-pitot/pitot/e2e/e2e_hook_test.go @@ -25,6 +25,7 @@ func TestE2ESensorsConformityAcrossAllAdapters(t *testing.T) { adapters.Claude: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, adapters.Cursor: `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, adapters.Codex: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`, } hosts := adapters.Supported() diff --git a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go index 86b4f9616..95bd99d26 100644 --- a/labs/15-pitot/pitot/windtunnel/windtunnel_test.go +++ b/labs/15-pitot/pitot/windtunnel/windtunnel_test.go @@ -29,6 +29,7 @@ var boatstackCanonicalEvents = map[adapters.Host]string{ adapters.Cursor: `{"hook_event_name":"beforeShellExecution","command":"git status --short"}`, adapters.Claude: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, adapters.Codex: `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, + adapters.Gemini: `{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`, } func TestSensorConsumesBoatstackCanonicalEvents(t *testing.T) { diff --git a/labs/15-pitot/tests/e2e_gemini_cli_test.sh b/labs/15-pitot/tests/e2e_gemini_cli_test.sh new file mode 100755 index 000000000..53ab3b9a4 --- /dev/null +++ b/labs/15-pitot/tests/e2e_gemini_cli_test.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +exec labs/15-pitot/tests/e2e_unified_runner.sh "gemini" From a0389d937e02a42a7e77de48d128d13c912b9e30 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 21 Jul 2026 19:22:45 +0100 Subject: [PATCH 3/3] fix(pitot): add missing newline to release note --- .../release-notes/2026-07-21-gemini-cli-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md index 5e91ed288..374d69afa 100644 --- a/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-21-gemini-cli-adapter.md @@ -1,3 +1,3 @@ ### Add Gemini CLI as a supported host adapter -Pitot now supports the `gemini` host adapter. This integration natively parses Gemini CLI's `BeforeTool` synchronous hook events, allowing the Pitot sensor to safely monitor and project Gemini's shell command behavior along with Cursor, Claude, and Codex. \ No newline at end of file +Pitot now supports the `gemini` host adapter. This integration natively parses Gemini CLI's `BeforeTool` synchronous hook events, allowing the Pitot sensor to safely monitor and project Gemini's shell command behavior along with Cursor, Claude, and Codex.