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
38 changes: 38 additions & 0 deletions internal/policy/approvals.go
Original file line number Diff line number Diff line change
@@ -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},
)
}
55 changes: 55 additions & 0 deletions internal/policy/budget.go
Original file line number Diff line number Diff line change
@@ -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},
)
}
28 changes: 28 additions & 0 deletions internal/policy/context.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 6 additions & 1 deletion internal/policy/doc.go
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions internal/policy/engine.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions internal/policy/errors.go
Original file line number Diff line number Diff line change
@@ -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}
}
62 changes: 62 additions & 0 deletions internal/policy/evaluator.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading