From 21a960f7b892851db43fa5b181fe9bf128bac238 Mon Sep 17 00:00:00 2001 From: Adarsh Prashar Date: Sat, 13 Jun 2026 17:29:14 +0530 Subject: [PATCH] feat(policy): enforce a run's policy bundle per-run (allowlist + approval) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run created under a bundle (policyRef) is now governed by that bundle, not just its budget — closing the seam the policy-bundle work left open. - Persist policyRef on the run (migration 00006 adds runs.policy_ref; threaded through RunRecord, the runs manager, View/record, and reload — so it survives a restart and the MCP gateway sees it even for a reused run-id). - MCP gateway: the run's bundle allowlist governs its tools/call (a tool outside it is blocked even if the global allowlist would allow it; empty bundle allowlist falls back to global). The bundle's approval rules apply on top of the global fail-safe gating — a bundle can ADD a requirement (e.g. naming a normally read-only tool) but never silently drop the fail-closed gating of side effects. - approval.Gate.RequestUnder(ctx, req, policy) evaluates a supplied policy instead of the gate's default; Request delegates to it. Tests: per-run allowlist (a bundle restricts a run while the global allowlist is allow-all; a no-bundle run still uses global), a bundle rule gating an otherwise read-only tool, and policyRef persist+reload across a restart. docs/POLICY.md gains a "Per-run enforcement" section. Also collapses a duplicated [0.6.0] CHANGELOG header left by an earlier merge and moves the post-release Ollama entry to [Unreleased] where it belongs. --- CHANGELOG.md | 16 ++- docs/POLICY.md | 20 +++- internal/approval/gate.go | 9 +- internal/httpapi/runs.go | 2 +- internal/mcp/gateway.go | 79 ++++++++++++-- internal/mcp/gateway_test.go | 102 ++++++++++++++++++ internal/runs/manager.go | 23 ++-- internal/runs/manager_test.go | 25 +++++ internal/storage/checkpoints.go | 2 +- .../migrations/00006_run_policy_ref.sql | 9 ++ internal/storage/sqlite.go | 24 ++--- internal/storage/store.go | 3 + 12 files changed, 264 insertions(+), 50 deletions(-) create mode 100644 internal/storage/migrations/00006_run_policy_ref.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea0d4a..a4dff18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,14 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md). ## [Unreleased] -## [0.6.0] - 2026-06-13 - -Governance and compliance. Approvals can route to **Slack**, policy is now -**code** — reusable bundles via `POST /v1/policies` or a reviewed `riskkernel.yaml`, -with a dry-run against recorded runs — and a new **compliance evidence export** maps -RiskKernel's recorded controls to OWASP / EU AI Act references with a tamper-evident -event log. Plus the published enforcement-overhead number (~150 ns, zero allocations) -and a public roadmap. No breaking API changes; forward-compatible with v0.5.x state. - ### Added +- **Per-run policy enforcement.** A run created under a policy bundle (`policyRef`) + is now governed by that bundle, not just its budget: the MCP gateway enforces the + bundle's tool **allowlist** for that run (a tool outside it is blocked even if the + global allowlist would allow it) and its **approval rules** on top of the global + fail-safe gating — a bundle can *add* a requirement, never silently drop one. The + run's `policyRef` is persisted, so enforcement survives a daemon restart. See + [`docs/POLICY.md`](docs/POLICY.md#per-run-enforcement). - **Native Ollama provider.** Run local models through RiskKernel — set `RISKKERNEL_DEFAULT_PROVIDER=ollama` (key-free) and budgets, the proxy, the audit trail, and crash-resume all work the same as for a hosted provider. Talks to diff --git a/docs/POLICY.md b/docs/POLICY.md index e7bf7b5..fdf4eb3 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -86,7 +86,19 @@ A trust-builder for tightening a policy without breaking a working agent. The same fields are the `POST /v1/policies` body — see [`api/v1/openapi.yaml`](../api/v1/openapi.yaml). -> The bundle's **budget** is enforced per-run today (via `policyRef`). Per-run -> enforcement of the allowlist and approval rules (they apply globally today) is the -> next step on this seam; the dry-run already evaluates all three so you can author -> the policy now. +## Per-run enforcement + +A run created under a bundle (via `policyRef`) is governed by that bundle, not just +the daemon-global config: + +- **Budget** — applied to the run (an inline `budget` overrides it field-by-field). +- **Tool allowlist** — the MCP gateway enforces the bundle's `toolAllowlist` for that + run's `tools/call`s: a tool outside it is blocked, even if the global allowlist + would allow it. An empty bundle allowlist falls back to the global one. +- **Approval rules** — the bundle's `approvalPolicy` rules apply to that run *on top + of* the global fail-safe gating (side-effecting tools still gate; the bundle can + add a requirement, e.g. naming a normally read-only tool). A run's bundle can only + *add* gating, never silently drop it. + +The run's `policyRef` is persisted, so enforcement continues after a daemon restart. +Runs created without a `policyRef` keep using the global config. diff --git a/internal/approval/gate.go b/internal/approval/gate.go index d7fa705..fe75fa3 100644 --- a/internal/approval/gate.go +++ b/internal/approval/gate.go @@ -70,7 +70,14 @@ func (g *Gate) Required(tool, sideEffect string) bool { // notifies push channels, and BLOCKS until the request is resolved or ctx is // done (run cancel / time budget). This is how a side-effecting call "pauses". func (g *Gate) Request(ctx context.Context, req Request) (Decision, string, error) { - if !g.policy.Requires(req.Tool, req.SideEffect) { + return g.RequestUnder(ctx, req, g.policy) +} + +// RequestUnder is Request, but evaluates the supplied policy instead of the gate's +// default. It lets a run enforce its own policy bundle's approval rules per-run +// (a referenced policyRef) rather than only the daemon-global policy. +func (g *Gate) RequestUnder(ctx context.Context, req Request, policy Policy) (Decision, string, error) { + if !policy.Requires(req.Tool, req.SideEffect) { return Decision{Approved: true}, "", nil } diff --git a/internal/httpapi/runs.go b/internal/httpapi/runs.go index a251409..edda923 100644 --- a/internal/httpapi/runs.go +++ b/internal/httpapi/runs.go @@ -39,7 +39,7 @@ func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, http.StatusBadRequest, "bad_request", err.Error()) return } - opts := runs.CreateOptions{Name: body.Name, Metadata: body.Metadata} + opts := runs.CreateOptions{Name: body.Name, PolicyRef: body.PolicyRef, Metadata: body.Metadata} // A referenced policy bundle supplies the run's default budget; an inline // budget then overrides it field-by-field (a non-zero inline field wins). diff --git a/internal/mcp/gateway.go b/internal/mcp/gateway.go index 74a5c1f..37d1ed9 100644 --- a/internal/mcp/gateway.go +++ b/internal/mcp/gateway.go @@ -113,10 +113,15 @@ func (g *Gateway) handle(w http.ResponseWriter, r *http.Request) { run := g.resolveRun(r) stepIdx := run.View().Usage.Loops + // If the run was created under a policy bundle (policyRef), that bundle's tool + // allowlist and approval rules govern this call — not just the daemon-global + // config. Bundle missing/unknown → fall back to the global config. + bundle := g.runPolicy(r.Context(), run) + // 1) Allowlist (deterministic). A blocked attempt is recorded too — a refused // tool call is part of the audit trail, not a silent drop. - if !g.allowed(tool) { - g.log.Warn("mcp tool blocked by allowlist", "tool", tool) + if !g.allowedFor(tool, bundle) { + g.log.Warn("mcp tool blocked by allowlist", "tool", tool, "policy", run.PolicyRef) g.recordToolCall(r.Context(), start, run.ID, stepIdx, tool, "", params.Arguments, "blocked") writeRPCError(w, req.ID, -32001, "tool not allowed by policy: "+tool) return @@ -124,14 +129,31 @@ func (g *Gateway) handle(w http.ResponseWriter, r *http.Request) { sideEffect := g.sideEffect(tool) - // 2) Approval gate for side-effecting tools (blocks until resolved or timeout). - if sideEffect != "" { + // 2) Approval gate. Side-effecting tools gate under the global policy by + // default; a run's bundle can ADD a requirement (e.g. naming a normally + // read-only tool), so consult the bundle's rules too. + needsApproval := sideEffect != "" + var bundlePol approval.Policy + if bundle != nil { + bundlePol = bundlePolicy(bundle) + if bundlePol.Requires(tool, sideEffect) { + needsApproval = true + } + } + if needsApproval { ctx, cancel := context.WithTimeout(r.Context(), g.approvalTimeout) defer cancel() - decision, _, aerr := g.gate.Request(ctx, approval.Request{ + areq := approval.Request{ RunID: run.ID, StepIndex: stepIdx, Tool: tool, SideEffect: sideEffect, Arguments: params.Arguments, - }) + } + var decision approval.Decision + var aerr error + if bundle != nil { + decision, _, aerr = g.gate.RequestUnder(ctx, areq, bundlePol) + } else { + decision, _, aerr = g.gate.Request(ctx, areq) // daemon-global policy + } if aerr != nil { g.recordToolCall(r.Context(), start, run.ID, stepIdx, tool, sideEffect, params.Arguments, "timeout") writeRPCError(w, req.ID, -32002, "approval timed out or run cancelled for tool: "+tool) @@ -149,12 +171,38 @@ func (g *Gateway) handle(w http.ResponseWriter, r *http.Request) { g.forward(w, r, body) } -// allowed reports whether the tool passes the allowlist (empty = allow all). -func (g *Gateway) allowed(tool string) bool { - if len(g.allow) == 0 { +// runPolicy returns the policy bundle a run was created under, or nil if the run +// has no policyRef, there's no store, or the bundle is unknown (fall back to the +// daemon-global config). +func (g *Gateway) runPolicy(ctx context.Context, run *runs.Run) *storage.PolicyRecord { + if run.PolicyRef == "" || g.store == nil { + return nil + } + p, err := g.store.GetPolicy(ctx, run.PolicyRef) + if err != nil { + if !errors.Is(err, storage.ErrNotFound) { + g.log.Error("mcp policy lookup failed", "run", run.ID, "policy", run.PolicyRef, "err", err) + } + return nil + } + return &p +} + +// allowedFor reports whether the tool passes the effective allowlist: the run's +// bundle allowlist when it has one, else the daemon-global allowlist (empty = all). +func (g *Gateway) allowedFor(tool string, bundle *storage.PolicyRecord) bool { + allow := g.allow + if bundle != nil && len(bundle.ToolAllowlist) > 0 { + allow = bundle.ToolAllowlist + } + return matchAllow(allow, tool) +} + +func matchAllow(allow []string, tool string) bool { + if len(allow) == 0 { return true } - for _, pat := range g.allow { + for _, pat := range allow { if pat == tool { return true } @@ -165,6 +213,17 @@ func (g *Gateway) allowed(tool string) bool { return false } +// bundlePolicy is the run bundle's approval policy. DefaultSafe stays on: a run's +// bundle can ADD approval requirements but never silently drop the fail-closed +// gating of side-effecting tools. +func bundlePolicy(bundle *storage.PolicyRecord) approval.Policy { + rules := make([]approval.Rule, 0, len(bundle.ApprovalRules)) + for _, r := range bundle.ApprovalRules { + rules = append(rules, approval.Rule{Tool: r.Tool, SideEffect: r.SideEffect}) + } + return approval.Policy{RequireFor: rules, DefaultSafe: true} +} + // sideEffect returns "" for read-only tools (no approval) and "tool" otherwise, // so the approval policy (default-safe) decides whether to gate it. func (g *Gateway) sideEffect(tool string) string { diff --git a/internal/mcp/gateway_test.go b/internal/mcp/gateway_test.go index f9232e4..ccae548 100644 --- a/internal/mcp/gateway_test.go +++ b/internal/mcp/gateway_test.go @@ -245,3 +245,105 @@ func TestToolCallEmitsSpan(t *testing.T) { t.Fatalf("tool span attrs = %v", a) } } + +// --- per-run policy enforcement (#28 follow-on) --- + +// newPolicyGateway is like newTestGateway but returns the store and manager (to +// seed a policy bundle + a run) and uses a short approval timeout so a gated call +// fails fast instead of waiting the full window. +func newPolicyGateway(t *testing.T, globalAllow, readonly []string) (*Gateway, storage.Store, *runs.Manager, *int32) { + t.Helper() + var hits int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}`)) + })) + t.Cleanup(upstream.Close) + + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "mcp-policy.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + log := slog.New(slog.NewTextHandler(discard{}, nil)) + gate := approval.NewGate(store, approval.Policy{DefaultSafe: true}, nil, log) + mgr := runs.NewManager(governor.Budget{}).WithStore(store, log) + g := New(upstream.URL, globalAllow, readonly, gate, mgr, store, otel.Disabled(), 200*time.Millisecond, log) + return g, store, mgr, &hits +} + +func toolsCallFor(runID, tool string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/mcp", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+tool+`"}}`)) + r.Header.Set(HeaderRunID, runID) + r.Header.Set("Content-Type", "application/json") + return r +} + +func TestPerRunPolicyAllowlist(t *testing.T) { + // Global allowlist is empty (allow-all), but the run's bundle restricts to github. + g, store, mgr, hits := newPolicyGateway(t, nil, []string{"mcp://github"}) + ctx := context.Background() + if err := store.UpsertPolicy(ctx, storage.PolicyRecord{ + Name: "restricted", ToolAllowlist: []string{"mcp://github"}, + }); err != nil { + t.Fatal(err) + } + mgr.Create(runs.CreateOptions{ID: "scoped", PolicyRef: "restricted"}) + + // A tool outside the bundle's allowlist is blocked, even though the global + // allowlist would allow everything. + w := httptest.NewRecorder() + g.handle(w, toolsCallFor("scoped", "mcp://shell")) + if e := rpcError(t, w); e == nil || !strings.Contains(e["message"].(string), "not allowed") { + t.Fatalf("shell should be blocked by the run's bundle allowlist, got %v", e) + } + if atomic.LoadInt32(hits) != 0 { + t.Fatal("a blocked call must not reach the upstream") + } + + // A tool the bundle allows (and that is read-only, so no approval) is forwarded. + w = httptest.NewRecorder() + g.handle(w, toolsCallFor("scoped", "mcp://github")) + if e := rpcError(t, w); e != nil { + t.Fatalf("github is allowed by the bundle; got error %v", e) + } + if atomic.LoadInt32(hits) != 1 { + t.Fatalf("allowed call should reach upstream, hits=%d", atomic.LoadInt32(hits)) + } + + // A run WITHOUT a bundle falls back to the global allow-all: github forwards too. + mgr.Create(runs.CreateOptions{ID: "unscoped"}) + w = httptest.NewRecorder() + g.handle(w, toolsCallFor("unscoped", "mcp://github")) + if e := rpcError(t, w); e != nil { + t.Fatalf("unscoped run should allow github, got %v", e) + } +} + +func TestPerRunPolicyApprovalRule(t *testing.T) { + // A bundle rule gates a read-only tool that would otherwise pass without + // approval — proving the run's bundle approval rules are enforced. With no + // resolver, the gated call fails fast (short approval timeout). + g, store, mgr, hits := newPolicyGateway(t, nil, []string{"mcp://github"}) + ctx := context.Background() + if err := store.UpsertPolicy(ctx, storage.PolicyRecord{ + Name: "gated", + ApprovalRules: []storage.ApprovalRule{{Tool: "mcp://github"}}, + }); err != nil { + t.Fatal(err) + } + mgr.Create(runs.CreateOptions{ID: "needs-approval", PolicyRef: "gated"}) + + w := httptest.NewRecorder() + g.handle(w, toolsCallFor("needs-approval", "mcp://github")) + e := rpcError(t, w) + if e == nil || !strings.Contains(e["message"].(string), "approval") { + t.Fatalf("github should require approval under the bundle rule, got %v", e) + } + if atomic.LoadInt32(hits) != 0 { + t.Fatal("a gated, unapproved call must not reach the upstream") + } +} diff --git a/internal/runs/manager.go b/internal/runs/manager.go index 92a3c23..88a1894 100644 --- a/internal/runs/manager.go +++ b/internal/runs/manager.go @@ -25,10 +25,11 @@ import ( // Run is a governed run: identity and metadata wrapped around a live governor. type Run struct { - ID string - Name string - Budget governor.Budget - Metadata map[string]string + ID string + Name string + Budget governor.Budget + PolicyRef string // name of the policy bundle this run was created under ("" = none) + Metadata map[string]string mgr *Manager gov *governor.Run @@ -57,6 +58,7 @@ type View struct { ID string Name string Status string + PolicyRef string Budget governor.Budget Usage governor.Usage HaltReason governor.HaltReason @@ -147,6 +149,7 @@ func (r *Run) View() View { ID: r.ID, Name: r.Name, Status: statusFor(halt), + PolicyRef: r.PolicyRef, Budget: r.Budget, Usage: usage, HaltReason: halt, @@ -163,6 +166,7 @@ func (r *Run) record() storage.RunRecord { ID: v.ID, Name: v.Name, Status: v.Status, + PolicyRef: v.PolicyRef, HaltReason: string(v.HaltReason), BudgetTokens: v.Budget.Tokens, BudgetDollars: v.Budget.Dollars, @@ -231,10 +235,11 @@ func (m *Manager) Store() storage.Store { return m.store } // CreateOptions configures a new run. type CreateOptions struct { - ID string // optional; a UUID is minted when empty - Name string - Budget *governor.Budget // nil → manager default - Metadata map[string]string + ID string // optional; a UUID is minted when empty + Name string + Budget *governor.Budget // nil → manager default + PolicyRef string // optional policy bundle name applied to this run + Metadata map[string]string } // Create starts a new governed run and registers it. @@ -248,6 +253,7 @@ func (m *Manager) Create(opts CreateOptions) *Run { rid = id.NewUUID() } r := m.newRun(rid, opts.Name, budget, opts.Metadata) + r.PolicyRef = opts.PolicyRef m.mu.Lock() m.runs[rid] = r m.mu.Unlock() @@ -412,6 +418,7 @@ func (m *Manager) reloadRun(ctx context.Context, rec storage.RunRecord) *Run { ID: rec.ID, Name: rec.Name, Budget: budget, + PolicyRef: rec.PolicyRef, Metadata: rec.Metadata, mgr: m, gov: governor.New(context.Background(), budget, opts...), diff --git a/internal/runs/manager_test.go b/internal/runs/manager_test.go index 7e303eb..5b8c5d7 100644 --- a/internal/runs/manager_test.go +++ b/internal/runs/manager_test.go @@ -363,3 +363,28 @@ func TestManager_GetOrCreateRestoresCancelledRun(t *testing.T) { t.Fatalf("restored run = status %q reason %q; want cancelled", v.Status, v.HaltReason) } } + +// A run's policyRef persists and is restored when the run is reloaded from the +// store after a restart — so the MCP gateway can enforce the run's bundle even for +// a run created before the daemon restarted. (#28 per-run enforcement) +func TestManager_PolicyRefPersistsAndReloads(t *testing.T) { + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "policyref.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + noop := slog.New(slog.NewTextHandler(noopWriter{}, nil)) + + mA := NewManager(governor.Budget{}).WithStore(store, noop) + rA := mA.Create(CreateOptions{ID: "with-policy", PolicyRef: "developer"}) + if rA.PolicyRef != "developer" { + t.Fatalf("create: policyRef = %q", rA.PolicyRef) + } + + // Restart: a fresh manager over the same store restores the run via GetOrCreate. + mB := NewManager(governor.Budget{}).WithStore(store, noop) + rB := mB.GetOrCreate("with-policy") + if rB.PolicyRef != "developer" { + t.Fatalf("reloaded policyRef = %q, want developer", rB.PolicyRef) + } +} diff --git a/internal/storage/checkpoints.go b/internal/storage/checkpoints.go index 6dedabe..34be350 100644 --- a/internal/storage/checkpoints.go +++ b/internal/storage/checkpoints.go @@ -10,7 +10,7 @@ import ( const runColumns = `id, name, status, halt_reason, budget_tokens, budget_dollars, budget_loops, budget_seconds, usage_prompt_tokens, usage_completion_tokens, usage_dollars, usage_loops, - metadata, created_at, updated_at` + metadata, created_at, updated_at, policy_ref` // ListRunsByStatus returns runs in the given lifecycle status, newest first. func (s *SQLite) ListRunsByStatus(ctx context.Context, status string) ([]RunRecord, error) { diff --git a/internal/storage/migrations/00006_run_policy_ref.sql b/internal/storage/migrations/00006_run_policy_ref.sql new file mode 100644 index 0000000..47693d6 --- /dev/null +++ b/internal/storage/migrations/00006_run_policy_ref.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Record which policy bundle a run was created under (POST /v1/runs policyRef), so +-- the bundle's tool allowlist and approval rules can be enforced per-run — not just +-- its budget. Empty means no bundle (global config applies). + +ALTER TABLE runs ADD COLUMN policy_ref TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Forward-only migrations (COMPATIBILITY.md). No down migration is provided. diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 754589b..7c166b8 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -128,8 +128,8 @@ func (s *SQLite) UpsertRun(ctx context.Context, r RunRecord) error { INSERT INTO runs (id, name, status, halt_reason, budget_tokens, budget_dollars, budget_loops, budget_seconds, usage_prompt_tokens, usage_completion_tokens, usage_dollars, usage_loops, - metadata, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + metadata, created_at, updated_at, policy_ref) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, status=excluded.status, halt_reason=excluded.halt_reason, budget_tokens=excluded.budget_tokens, budget_dollars=excluded.budget_dollars, @@ -141,7 +141,7 @@ func (s *SQLite) UpsertRun(ctx context.Context, r RunRecord) error { r.ID, r.Name, r.Status, r.HaltReason, r.BudgetTokens, r.BudgetDollars, r.BudgetLoops, r.BudgetSeconds, r.UsagePromptTokens, r.UsageCompletionTokens, r.UsageDollars, r.UsageLoops, - meta, fmtTime(r.CreatedAt), fmtTime(r.UpdatedAt)) + meta, fmtTime(r.CreatedAt), fmtTime(r.UpdatedAt), r.PolicyRef) if err != nil { return fmt.Errorf("storage: upsert run: %w", err) } @@ -149,12 +149,8 @@ func (s *SQLite) UpsertRun(ctx context.Context, r RunRecord) error { } func (s *SQLite) GetRun(ctx context.Context, id string) (RunRecord, error) { - row := s.db.QueryRowContext(ctx, ` - SELECT id, name, status, halt_reason, - budget_tokens, budget_dollars, budget_loops, budget_seconds, - usage_prompt_tokens, usage_completion_tokens, usage_dollars, usage_loops, - metadata, created_at, updated_at - FROM runs WHERE id = ?`, id) + row := s.db.QueryRowContext(ctx, + `SELECT `+runColumns+` FROM runs WHERE id = ?`, id) r, err := scanRun(row) if err == sql.ErrNoRows { return RunRecord{}, ErrNotFound @@ -163,12 +159,8 @@ func (s *SQLite) GetRun(ctx context.Context, id string) (RunRecord, error) { } func (s *SQLite) ListRuns(ctx context.Context) ([]RunRecord, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT id, name, status, halt_reason, - budget_tokens, budget_dollars, budget_loops, budget_seconds, - usage_prompt_tokens, usage_completion_tokens, usage_dollars, usage_loops, - metadata, created_at, updated_at - FROM runs ORDER BY created_at DESC`) + rows, err := s.db.QueryContext(ctx, + `SELECT `+runColumns+` FROM runs ORDER BY created_at DESC`) if err != nil { return nil, fmt.Errorf("storage: list runs: %w", err) } @@ -428,7 +420,7 @@ func scanRun(row rowScanner) (RunRecord, error) { if err := row.Scan(&r.ID, &r.Name, &r.Status, &r.HaltReason, &r.BudgetTokens, &r.BudgetDollars, &r.BudgetLoops, &r.BudgetSeconds, &r.UsagePromptTokens, &r.UsageCompletionTokens, &r.UsageDollars, &r.UsageLoops, - &meta, &created, &updated); err != nil { + &meta, &created, &updated, &r.PolicyRef); err != nil { return RunRecord{}, err } r.Metadata = unmarshalMeta(meta) diff --git a/internal/storage/store.go b/internal/storage/store.go index 4721331..48addf7 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -20,6 +20,9 @@ type RunRecord struct { Name string Status string HaltReason string + // PolicyRef is the name of the policy bundle the run was created under (empty + // = none). Its tool allowlist and approval rules are enforced per-run. + PolicyRef string BudgetTokens int64 BudgetDollars float64