diff --git a/internal/policy/approvals.go b/internal/policy/approvals.go new file mode 100644 index 0000000..6db4ab3 --- /dev/null +++ b/internal/policy/approvals.go @@ -0,0 +1,38 @@ +package policy + +import ( + "strings" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +func approvalRequired(uses string, approvals *spec.PolicyApprovals) bool { + if approvals == nil || len(approvals.RequiredFor) == 0 { + return false + } + u := strings.TrimSpace(uses) + for _, r := range approvals.RequiredFor { + if strings.TrimSpace(r) == u { + return true + } + } + return false +} + +func checkApprovalGranted(uses string, approvals *spec.PolicyApprovals, approved []string) error { + if !approvalRequired(uses, approvals) { + return nil + } + u := strings.TrimSpace(uses) + for _, a := range approved { + if strings.TrimSpace(a) == u { + return nil + } + } + return denied( + ReasonApprovalRequired, + "policy: action requires explicit approval (--approve)", + uses, + map[string]any{"requiredFor": uses}, + ) +} diff --git a/internal/policy/budget.go b/internal/policy/budget.go new file mode 100644 index 0000000..c87247b --- /dev/null +++ b/internal/policy/budget.go @@ -0,0 +1,55 @@ +package policy + +import ( + "fmt" + "time" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +func checkExecutionBudgets(run RunContext, ex *spec.PolicyExecution) error { + if ex == nil { + return nil + } + if ex.MaxWallClockSeconds > 0 { + limit := time.Duration(ex.MaxWallClockSeconds) * time.Second + if run.Elapsed > limit { + return denied( + ReasonMaxWallClock, + fmt.Sprintf("policy: wall clock %s exceeds limit %s", run.Elapsed, limit), + "", + map[string]any{ + "maxWallClockSeconds": ex.MaxWallClockSeconds, + "elapsedSeconds": run.Elapsed.Seconds(), + }, + ) + } + } + if ex.MaxTotalCostUsd > 0 && run.AccumulatedCostUSD > ex.MaxTotalCostUsd { + return denied( + ReasonMaxCost, + fmt.Sprintf("policy: cost $%.4f exceeds ceiling $%.4f", run.AccumulatedCostUSD, ex.MaxTotalCostUsd), + "", + map[string]any{ + "maxTotalCostUsd": ex.MaxTotalCostUsd, + "accumulatedUsd": run.AccumulatedCostUSD, + }, + ) + } + return nil +} + +func checkStructuredOutputRequired(step StepContext, ex *spec.PolicyExecution) error { + if ex == nil || !ex.RequireStructuredOutput { + return nil + } + if step.OutputIsStructured { + return nil + } + return denied( + ReasonStructuredOutput, + "policy: structured output required for this step", + "", + map[string]any{"stepId": step.StepID}, + ) +} diff --git a/internal/policy/context.go b/internal/policy/context.go new file mode 100644 index 0000000..22cdf95 --- /dev/null +++ b/internal/policy/context.go @@ -0,0 +1,28 @@ +package policy + +import "time" + +// RunContext carries execution state for policy checks (wall clock, cost, CLI approvals). +type RunContext struct { + StartedAt time.Time + // Elapsed is wall-clock time since run start (caller supplies). + Elapsed time.Duration + // AccumulatedCostUSD is total cost so far for the run. + AccumulatedCostUSD float64 + // ApprovedActions lists full uses strings passed via repeated --approve on run. + ApprovedActions []string +} + +// StepContext is one workflow step for CheckStep. +type StepContext struct { + StepID string + // OutputIsStructured is true when step output is a structured object (JSON map), not opaque text. + OutputIsStructured bool +} + +// ToolCallContext is a resolved tool step before the executor runs. +type ToolCallContext struct { + Run RunContext + StepID string + Uses string +} diff --git a/internal/policy/doc.go b/internal/policy/doc.go index 0bb5940..8190505 100644 --- a/internal/policy/doc.go +++ b/internal/policy/doc.go @@ -1,2 +1,7 @@ -// Package policy evaluates permissions, budgets, and approval rules. +// Package policy evaluates permissions, budgets, and approval rules for runs, steps, and tool calls +// (design doc section 12.2 H MVP). +// +// Use [NewEngine] with a loaded [spec.ProjectGraph], then [Engine.Evaluator] or [Engine.EvaluatorForSpec]. +// Run context should carry elapsed wall clock, accumulated cost, and repeated --approve action strings +// matching policy approvals.requiredFor entries. package policy diff --git a/internal/policy/engine.go b/internal/policy/engine.go new file mode 100644 index 0000000..25b56e6 --- /dev/null +++ b/internal/policy/engine.go @@ -0,0 +1,48 @@ +package policy + +import ( + "strings" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +// Engine binds a loaded project graph for policy resolution (design doc section 12.2 H MVP). +type Engine struct { + Graph *spec.ProjectGraph +} + +// NewEngine returns an engine with the merged project graph (tools, policies, etc.). +func NewEngine(g *spec.ProjectGraph) *Engine { + return &Engine{Graph: g} +} + +// Evaluator returns a [PolicyEvaluator] for the named Policy resource in the graph. +// If the policy is missing, returns a no-op evaluator (nil spec). +func (e *Engine) Evaluator(policyName string) PolicyEvaluator { + if e == nil { + return NewEvaluator(nil, nil) + } + pol := resolvePolicy(e.Graph, policyName) + return NewEvaluator(e.Graph, pol) +} + +// EvaluatorForSpec returns a [PolicyEvaluator] for an explicit merged [spec.PolicySpec] +// (e.g. after environment overrides). The engine's graph is still used for tool-name checks. +func (e *Engine) EvaluatorForSpec(pol *spec.PolicySpec) PolicyEvaluator { + if e == nil { + return NewEvaluator(nil, pol) + } + return NewEvaluator(e.Graph, pol) +} + +func resolvePolicy(g *spec.ProjectGraph, name string) *spec.PolicySpec { + name = strings.TrimSpace(name) + if name == "" || g == nil || g.Policies == nil { + return nil + } + pr, ok := g.Policies[name] + if !ok || pr == nil { + return nil + } + return &pr.Spec +} diff --git a/internal/policy/errors.go b/internal/policy/errors.go new file mode 100644 index 0000000..5c463ad --- /dev/null +++ b/internal/policy/errors.go @@ -0,0 +1,62 @@ +package policy + +import ( + "errors" + "fmt" +) + +// Reason values for [DeniedError] and trace event data (trace.EventPolicyDenied). +const ( + ReasonMaxWallClock = "max_wall_clock" + ReasonMaxCost = "max_cost" + ReasonStructuredOutput = "structured_output_required" + ReasonUnknownTool = "unknown_tool" + ReasonApprovalRequired = "approval_required" + ReasonInvalidUses = "invalid_uses" +) + +// DeniedError is returned when a policy check fails (design doc section 12.2 H). +type DeniedError struct { + Reason string + Message string + Uses string + Extra map[string]any +} + +func (e *DeniedError) Error() string { + if e == nil { + return "policy: denied" + } + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("policy denied: %s", e.Reason) +} + +// TraceData returns payload suitable for trace.EventPolicyDenied. +func (e *DeniedError) TraceData() map[string]any { + if e == nil { + return map[string]any{"reason": "unknown"} + } + m := map[string]any{"reason": e.Reason} + if e.Uses != "" { + m["uses"] = e.Uses + } + for k, v := range e.Extra { + m[k] = v + } + return m +} + +// AsDenied unwraps a *DeniedError from err. +func AsDenied(err error) (*DeniedError, bool) { + var d *DeniedError + if errors.As(err, &d) { + return d, true + } + return nil, false +} + +func denied(reason, msg string, uses string, extra map[string]any) error { + return &DeniedError{Reason: reason, Message: msg, Uses: uses, Extra: extra} +} diff --git a/internal/policy/evaluator.go b/internal/policy/evaluator.go new file mode 100644 index 0000000..e126f1b --- /dev/null +++ b/internal/policy/evaluator.go @@ -0,0 +1,62 @@ +package policy + +import ( + "context" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +// PolicyEvaluator decides whether run/step/tool actions are allowed (design doc section 12.2 H). +type PolicyEvaluator interface { + CheckRun(ctx context.Context, run RunContext) error + CheckStep(ctx context.Context, step StepContext) error + CheckToolCall(ctx context.Context, call ToolCallContext) error +} + +type evaluator struct { + graph *spec.ProjectGraph + policy *spec.PolicySpec +} + +// NewEvaluator returns a [PolicyEvaluator] for the given merged policy spec and project graph. +// A nil policy spec applies no limits (all checks no-op). +func NewEvaluator(graph *spec.ProjectGraph, pol *spec.PolicySpec) PolicyEvaluator { + return &evaluator{graph: graph, policy: pol} +} + +func (e *evaluator) spec() *spec.PolicySpec { + if e == nil { + return nil + } + return e.policy +} + +func (e *evaluator) CheckRun(ctx context.Context, run RunContext) error { + _ = ctx + p := e.spec() + if p == nil || p.Execution == nil { + return nil + } + return checkExecutionBudgets(run, p.Execution) +} + +func (e *evaluator) CheckStep(ctx context.Context, step StepContext) error { + _ = ctx + p := e.spec() + if p == nil || p.Execution == nil { + return nil + } + return checkStructuredOutputRequired(step, p.Execution) +} + +func (e *evaluator) CheckToolCall(ctx context.Context, call ToolCallContext) error { + _ = ctx + p := e.spec() + if p == nil { + return nil + } + if err := checkKnownTool(e.graph, call.Uses, p.Tools); err != nil { + return err + } + return checkApprovalGranted(call.Uses, p.Approvals, call.Run.ApprovedActions) +} diff --git a/internal/policy/evaluator_test.go b/internal/policy/evaluator_test.go new file mode 100644 index 0000000..41caf0b --- /dev/null +++ b/internal/policy/evaluator_test.go @@ -0,0 +1,212 @@ +package policy + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/state" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/trace" +) + +func testGraphWithTools(names ...string) *spec.ProjectGraph { + tools := make(map[string]*spec.ToolResource) + for _, n := range names { + tools[n] = &spec.ToolResource{ + APIVersion: spec.APIVersionV0, + Kind: spec.KindTool, + Metadata: spec.Metadata{Name: n}, + Spec: spec.ToolSpec{Type: "mock"}, + } + } + return &spec.ProjectGraph{Tools: tools} +} + +func TestCheckToolCall_forbidUnknownTools_unknownToolDenied(t *testing.T) { + g := testGraphWithTools("github") + pol := &spec.PolicySpec{ + Tools: &spec.PolicyTools{ForbidUnknownTools: true}, + } + ev := NewEvaluator(g, pol) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, + StepID: "s1", + Uses: "tool.slack.message.send", + }) + if err == nil { + t.Fatal("expected denial") + } + d, ok := AsDenied(err) + if !ok || d.Reason != ReasonUnknownTool { + t.Fatalf("got %v", err) + } + if d.Uses != "tool.slack.message.send" { + t.Fatalf("uses %q", d.Uses) + } +} + +func TestCheckToolCall_forbidUnknownTools_knownToolOK(t *testing.T) { + g := testGraphWithTools("slack") + pol := &spec.PolicySpec{ + Tools: &spec.PolicyTools{ForbidUnknownTools: true}, + } + ev := NewEvaluator(g, pol) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, + StepID: "s1", + Uses: "tool.slack.message.send", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCheckToolCall_approvalRequired_withoutApprove_policyDeniedTraceAndError(t *testing.T) { + ctx := context.Background() + st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "policy_trace.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + + started := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC) + if err := st.StartRun(ctx, state.Run{ + RunID: "run-policy", + WorkflowName: "wf", + Env: "dev", + Status: "running", + StartedAt: started, + InputJSON: `{}`, + TotalCostUSD: 0, + }); err != nil { + t.Fatal(err) + } + + g := testGraphWithTools("github") + pol := &spec.PolicySpec{ + Tools: &spec.PolicyTools{ForbidUnknownTools: true}, + Approvals: &spec.PolicyApprovals{ + RequiredFor: []string{"tool.github.pull_request.merge"}, + }, + } + ev := NewEvaluator(g, pol) + call := ToolCallContext{ + Run: RunContext{ApprovedActions: nil}, + StepID: "merge-step", + Uses: "tool.github.pull_request.merge", + } + err = ev.CheckToolCall(ctx, call) + if err == nil { + t.Fatal("expected denial") + } + d, ok := AsDenied(err) + if !ok || d.Reason != ReasonApprovalRequired { + t.Fatalf("got %v", err) + } + + data := d.TraceData() + rec := trace.NewRecorder(st) + seq, err := rec.Append(ctx, "run-policy", "merge-step", trace.EventPolicyDenied, data) + if err != nil { + t.Fatal(err) + } + if seq != 1 { + t.Fatalf("seq %d", seq) + } + + rd := trace.NewReader(st) + events, err := rd.ListByRunID(ctx, "run-policy") + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Type != trace.EventPolicyDenied { + t.Fatalf("events %+v", events) + } + if events[0].DataJSON == "" { + t.Fatal("empty data") + } +} + +func TestCheckToolCall_approvalRequired_withApproveOK(t *testing.T) { + g := testGraphWithTools("github") + pol := &spec.PolicySpec{ + Approvals: &spec.PolicyApprovals{ + RequiredFor: []string{"tool.github.pull_request.merge"}, + }, + } + ev := NewEvaluator(g, pol) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{ApprovedActions: []string{"tool.github.pull_request.merge"}}, + Uses: "tool.github.pull_request.merge", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCheckRun_maxWallClock(t *testing.T) { + pol := &spec.PolicySpec{ + Execution: &spec.PolicyExecution{MaxWallClockSeconds: 60}, + } + ev := NewEvaluator(nil, pol) + err := ev.CheckRun(context.Background(), RunContext{Elapsed: 61 * time.Second}) + if err == nil { + t.Fatal("expected denial") + } + d, _ := AsDenied(err) + if d.Reason != ReasonMaxWallClock { + t.Fatalf("%+v", d) + } +} + +func TestCheckRun_maxCost(t *testing.T) { + pol := &spec.PolicySpec{ + Execution: &spec.PolicyExecution{MaxTotalCostUsd: 1.0}, + } + ev := NewEvaluator(nil, pol) + err := ev.CheckRun(context.Background(), RunContext{AccumulatedCostUSD: 1.01}) + if err == nil { + t.Fatal("expected denial") + } + d, _ := AsDenied(err) + if d.Reason != ReasonMaxCost { + t.Fatalf("%+v", d) + } +} + +func TestCheckStep_requireStructuredOutput(t *testing.T) { + pol := &spec.PolicySpec{ + Execution: &spec.PolicyExecution{RequireStructuredOutput: true}, + } + ev := NewEvaluator(nil, pol) + err := ev.CheckStep(context.Background(), StepContext{StepID: "x", OutputIsStructured: false}) + if err == nil { + t.Fatal("expected denial") + } + d, _ := AsDenied(err) + if d.Reason != ReasonStructuredOutput { + t.Fatalf("%+v", d) + } +} + +func TestEngine_Evaluator_resolvesNamedPolicy(t *testing.T) { + g := &spec.ProjectGraph{ + Policies: map[string]*spec.PolicyResource{ + "strict": { + Metadata: spec.Metadata{Name: "strict"}, + Spec: spec.PolicySpec{ + Execution: &spec.PolicyExecution{MaxWallClockSeconds: 10}, + }, + }, + }, + } + eng := NewEngine(g) + ev := eng.Evaluator("strict") + err := ev.CheckRun(context.Background(), RunContext{Elapsed: 30 * time.Second}) + if err == nil { + t.Fatal("expected denial") + } +} diff --git a/internal/policy/permissions.go b/internal/policy/permissions.go new file mode 100644 index 0000000..ae0d2c6 --- /dev/null +++ b/internal/policy/permissions.go @@ -0,0 +1,35 @@ +package policy + +import ( + "fmt" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools" +) + +func checkKnownTool(graph *spec.ProjectGraph, uses string, toolsPol *spec.PolicyTools) error { + if toolsPol == nil || !toolsPol.ForbidUnknownTools { + return nil + } + toolName, _, err := tools.ParseUses(uses) + if err != nil { + return denied(ReasonInvalidUses, fmt.Sprintf("policy: %v", err), uses, nil) + } + if graph == nil || graph.Tools == nil { + return denied( + ReasonUnknownTool, + fmt.Sprintf("policy: unknown tool %q (forbidUnknownTools)", toolName), + uses, + map[string]any{"tool": toolName}, + ) + } + if _, ok := graph.Tools[toolName]; !ok { + return denied( + ReasonUnknownTool, + fmt.Sprintf("policy: unknown tool %q (forbidUnknownTools)", toolName), + uses, + map[string]any{"tool": toolName}, + ) + } + return nil +}