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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions docs/POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 8 additions & 1 deletion internal/approval/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
79 changes: 69 additions & 10 deletions internal/mcp/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,25 +113,47 @@ 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
}

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)
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions internal/mcp/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
23 changes: 15 additions & 8 deletions internal/runs/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,7 @@ type View struct {
ID string
Name string
Status string
PolicyRef string
Budget governor.Budget
Usage governor.Usage
HaltReason governor.HaltReason
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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...),
Expand Down
25 changes: 25 additions & 0 deletions internal/runs/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading