diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e89a446 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- **`spec.safety` on Tool resources** (issue #103): optional `trusted`, `sideEffects`, and `requiresApproval` fields. [NormalizeProjectGraph] materializes fail-closed defaults on load. +- **Policy safety fallback**: when no `approvals.requiredFor` entry matches the exact `uses` string, [policy.Derive] consults resolved safety metadata. Unattended mutating tools require `--approve` (exit code **5**, `approval_required`). +- **Plan risk hints** for tools that will require approval at run, including decision source (`explicit_policy_rule`, `safety_metadata`, `fail_closed_default`). + +### Changed + +- **Breaking — tool calls without explicit policy are no longer unrestricted.** Previously, `CheckToolCall` with a nil [spec.PolicySpec] allowed all tools. Now fail-closed safety always applies from the project graph (even when the workflow omits `spec.policy` or the Policy resource is missing). +- Tools with **no** `spec.safety` block behave as **untrusted with side effects** after normalization → require `--approve` unless an explicit `approvals.requiredFor` rule matches. + +### Migration + +1. For **read-only** native or mock tools (echo, fetch, identity), add: + ```yaml + spec: + safety: + sideEffects: false + ``` +2. For tools where you accept **tool-wide** unattended use but still gate specific operations, set `trusted: true` and list write operations under `Policy.spec.approvals.requiredFor` (exact `uses` strings). +3. Do **not** set `trusted: true` unless you intend every operation on that tool to run without safety-derived approval; per-action gating remains `requiredFor` only (exact match at runtime). + +### Not yet wired + +- MCP discovery does **not** yet apply [spec.SafetyFromMCPMeta] / [spec.MergeToolSafety]; author-set `spec.safety` in YAML is the source of truth until MCP merge lands (tracked separately from #103). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17e477b..620f9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ Run **`make`** or **`make help`** for a full list of targets. ## Before you open a pull request +User-visible behavior changes should include an entry under **[Unreleased]** in [`CHANGELOG.md`](CHANGELOG.md) (especially breaking changes and migrations). + 1. **Format** — `make fmt` or ensure `gofmt -l .` prints nothing (same check as CI). 2. **Static analysis** — `make vet` (or `go vet ./...`). 3. **Tests** — `make test` (`go test ./... -race`). diff --git a/examples/example1/tools/helper.yaml b/examples/example1/tools/helper.yaml index d4b6df0..c776a1b 100644 --- a/examples/example1/tools/helper.yaml +++ b/examples/example1/tools/helper.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/examples/pr-review-demo/tools/github.yaml b/examples/pr-review-demo/tools/github.yaml index 4231d77..19fb182 100644 --- a/examples/pr-review-demo/tools/github.yaml +++ b/examples/pr-review-demo/tools/github.yaml @@ -4,3 +4,5 @@ metadata: name: github spec: type: native + safety: + trusted: true diff --git a/examples/pr-review-github-actions/tools/github.yaml b/examples/pr-review-github-actions/tools/github.yaml index 4231d77..19fb182 100644 --- a/examples/pr-review-github-actions/tools/github.yaml +++ b/examples/pr-review-github-actions/tools/github.yaml @@ -4,3 +4,5 @@ metadata: name: github spec: type: native + safety: + trusted: true diff --git a/examples/pr-review-github/tools/github.yaml b/examples/pr-review-github/tools/github.yaml index 4231d77..19fb182 100644 --- a/examples/pr-review-github/tools/github.yaml +++ b/examples/pr-review-github/tools/github.yaml @@ -4,3 +4,5 @@ metadata: name: github spec: type: native + safety: + trusted: true diff --git a/internal/cli/initembed/tools/helper.yaml b/internal/cli/initembed/tools/helper.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/initembed/tools/helper.yaml +++ b/internal/cli/initembed/tools/helper.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 009d238..0c38e33 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -29,6 +29,16 @@ func runPolicyRoot(t *testing.T) string { return abs } +func runSafetyRoot(t *testing.T) string { + t.Helper() + p := filepath.Join("testdata", "run_safety") + abs, err := filepath.Abs(p) + if err != nil { + t.Fatal(err) + } + return abs +} + func TestRun_demo_integration_succeeds(t *testing.T) { db := filepath.Join(t.TempDir(), "run-cli.db") root := runProjRoot(t) @@ -54,6 +64,53 @@ func TestRun_demo_integration_succeeds(t *testing.T) { } } +func TestRun_safetyOnlyDenial_exit5(t *testing.T) { + db := filepath.Join(t.TempDir(), "run-safety.db") + root := runSafetyRoot(t) + + ResetGlobalsForTest() + cmd := NewRootCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{ + "run", "workflow/echo", + "--project", root, + "--state", db, + "--input", "topic=x", + }) + err := cmd.Execute() + if err == nil { + t.Fatal("expected safety-derived policy denial") + } + if ExitCodeOf(err) != ExitPolicyDenied { + t.Fatalf("exit=%d want %d err=%v", ExitCodeOf(err), ExitPolicyDenied, err) + } +} + +func TestRun_safetyOnly_withApprove_succeeds(t *testing.T) { + db := filepath.Join(t.TempDir(), "run-safety-ok.db") + root := runSafetyRoot(t) + + ResetGlobalsForTest() + var out bytes.Buffer + cmd := NewRootCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "run", "workflow/echo", + "--project", root, + "--state", db, + "--input", "topic=x", + "--approve", "tool.helper.echo", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Status: succeeded") { + t.Fatalf("output:\n%s", out.String()) + } +} + func TestRun_policyDenial_exit5(t *testing.T) { db := filepath.Join(t.TempDir(), "run-pol.db") root := runPolicyRoot(t) diff --git a/internal/cli/testdata/fmt_messy/tool.yaml b/internal/cli/testdata/fmt_messy/tool.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/testdata/fmt_messy/tool.yaml +++ b/internal/cli/testdata/fmt_messy/tool.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/cli/testdata/plan_project/tool.yaml b/internal/cli/testdata/plan_project/tool.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/testdata/plan_project/tool.yaml +++ b/internal/cli/testdata/plan_project/tool.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/cli/testdata/run_policy/tools.yaml b/internal/cli/testdata/run_policy/tools.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/testdata/run_policy/tools.yaml +++ b/internal/cli/testdata/run_policy/tools.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/cli/testdata/run_safety/project.yaml b/internal/cli/testdata/run_safety/project.yaml new file mode 100644 index 0000000..3e6634d --- /dev/null +++ b/internal/cli/testdata/run_safety/project.yaml @@ -0,0 +1,12 @@ +apiVersion: agentic.dev/v0 +kind: Project +metadata: + name: safetyproj +spec: + imports: + - ./tools.yaml + - ./workflow.yaml + providers: + models: + mock: + type: mock diff --git a/internal/cli/testdata/run_safety/schemas/in.json b/internal/cli/testdata/run_safety/schemas/in.json new file mode 100644 index 0000000..8da6a82 --- /dev/null +++ b/internal/cli/testdata/run_safety/schemas/in.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["topic"], + "properties": { + "topic": { "type": "string" } + }, + "additionalProperties": true +} diff --git a/internal/cli/testdata/run_safety/tools.yaml b/internal/cli/testdata/run_safety/tools.yaml new file mode 100644 index 0000000..d4b6df0 --- /dev/null +++ b/internal/cli/testdata/run_safety/tools.yaml @@ -0,0 +1,6 @@ +apiVersion: agentic.dev/v0 +kind: Tool +metadata: + name: helper +spec: + type: native diff --git a/internal/cli/testdata/run_safety/workflow.yaml b/internal/cli/testdata/run_safety/workflow.yaml new file mode 100644 index 0000000..e438e87 --- /dev/null +++ b/internal/cli/testdata/run_safety/workflow.yaml @@ -0,0 +1,15 @@ +apiVersion: agentic.dev/v0 +kind: Workflow +metadata: + name: echo +spec: + input: + schema: ./schemas/in.json + steps: + - id: s1 + uses: tool.helper.echo + with: + topic: "${input.topic}" + output: + value: + out: "${steps.s1.output.echo}" diff --git a/internal/cli/testdata/validate_ok/tool.yaml b/internal/cli/testdata/validate_ok/tool.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/testdata/validate_ok/tool.yaml +++ b/internal/cli/testdata/validate_ok/tool.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/cli/testdata/wf_tests/tool.yaml b/internal/cli/testdata/wf_tests/tool.yaml index d4b6df0..c776a1b 100644 --- a/internal/cli/testdata/wf_tests/tool.yaml +++ b/internal/cli/testdata/wf_tests/tool.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/engine/execution_test.go b/internal/engine/execution_test.go index 1c974c7..ffc4571 100644 --- a/internal/engine/execution_test.go +++ b/internal/engine/execution_test.go @@ -47,7 +47,10 @@ func TestRun_sequentialToolAndAgent_mockModel(t *testing.T) { APIVersion: spec.APIVersionV0, Kind: spec.KindTool, Metadata: spec.Metadata{Name: "helper"}, - Spec: spec.ToolSpec{Type: "native"}, + Spec: spec.ToolSpec{ + Type: "native", + Safety: &spec.ToolSafety{SideEffects: spec.BoolPtr(false)}, + }, }, }, Agents: map[string]*spec.AgentResource{ diff --git a/internal/plan/planner.go b/internal/plan/planner.go index 35f397e..98608d5 100644 --- a/internal/plan/planner.go +++ b/internal/plan/planner.go @@ -106,7 +106,7 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr sortOperations(ops) - risk := summarizeRisks(appliedByID, desiredByID, ops) + risk := summarizeRisks(g, appliedByID, desiredByID, ops) fp, err := DeploymentStateFingerprint(ctx, p.Deploy, env, projectName) if err != nil { return nil, err diff --git a/internal/plan/risk.go b/internal/plan/risk.go index b74e876..c6a34fb 100644 --- a/internal/plan/risk.go +++ b/internal/plan/risk.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" "github.com/LAA-Software-Engineering/agentic-control-plane/internal/state" ) @@ -47,6 +48,11 @@ type toolSpecRisk struct { Permissions *struct { Allow []string `json:"allow"` } `json:"permissions"` + Safety *struct { + Trusted *bool `json:"trusted"` + SideEffects *bool `json:"sideEffects"` + RequiresApproval *bool `json:"requiresApproval"` + } `json:"safety"` } type jsonEnvelope struct { @@ -54,6 +60,7 @@ type jsonEnvelope struct { } func summarizeRisks( + g *spec.ProjectGraph, appliedByID map[string]state.AppliedResource, desiredByID map[string]desiredRow, ops []Operation, @@ -88,7 +95,7 @@ func summarizeRisks( case spec.KindAgent: summarizeAgentRisk(add, op, oldJSON, des.json, hadPrev) case spec.KindTool: - summarizeToolRisk(add, op, oldJSON, des.json, hadPrev) + summarizeToolRisk(add, g, op, oldJSON, des.json, hadPrev) } } @@ -156,12 +163,13 @@ func summarizeAgentRisk(add func(string), op Operation, oldJSON, newJSON string, } } -func summarizeToolRisk(add func(string), op Operation, oldJSON, newJSON string, hadPrev bool) { +func summarizeToolRisk(add func(string), g *spec.ProjectGraph, op Operation, oldJSON, newJSON string, hadPrev bool) { newTool, ok := parseToolSpec(newJSON) if !ok { return } newAllows := toolAllows(newTool) + newDecision := toolPlanDecisionFromGraph(g, op.Target.Name) if op.Action == ActionCreate || !hadPrev { for _, a := range newAllows { @@ -170,6 +178,7 @@ func summarizeToolRisk(add func(string), op Operation, oldJSON, newJSON string, break } } + addToolSafetyRisk(add, op.Target.Name, newDecision, nil) return } @@ -195,6 +204,67 @@ func summarizeToolRisk(add func(string), op Operation, oldJSON, newJSON string, break } } + oldDecision := toolDecisionFromParsed(g, op.Target.Name, oldTool) + addToolSafetyRisk(add, op.Target.Name, newDecision, &oldDecision) +} + +func toolPlanDecisionFromGraph(g *spec.ProjectGraph, toolName string) policy.ToolDecision { + if g != nil { + for _, pr := range g.Policies { + if pr == nil { + continue + } + td := policy.EffectiveToolDecision(g, &pr.Spec, toolName) + if td.Decision == policy.DecisionRequireApproval { + return td + } + } + } + return policy.EffectiveToolDecision(g, nil, toolName) +} + +func toolDecisionFromParsed(g *spec.ProjectGraph, toolName string, parsed *toolSpecRisk) policy.ToolDecision { + if g != nil { + for _, pr := range g.Policies { + if pr == nil { + continue + } + td := policy.EffectiveToolDecision(g, &pr.Spec, toolName) + if td.Decision == policy.DecisionRequireApproval { + return td + } + } + } + var safety *spec.ToolSafety + src := policy.SourceFailClosedDefault + if parsed != nil && parsed.Safety != nil { + safety = &spec.ToolSafety{ + Trusted: parsed.Safety.Trusted, + SideEffects: parsed.Safety.SideEffects, + RequiresApproval: parsed.Safety.RequiresApproval, + } + src = policy.SourceSafetyMetadata + } + resolved := spec.ResolveToolSafety(safety) + return policy.ToolDecision{ + Decision: policy.Derive(resolved), + Source: src, + Safety: resolved, + } +} + +func addToolSafetyRisk(add func(string), toolName string, cur policy.ToolDecision, prev *policy.ToolDecision) { + if cur.Decision != policy.DecisionRequireApproval { + return + } + if prev != nil && prev.Decision == policy.DecisionRequireApproval { + return + } + // Plan uses prefix match on tool.. for explicit requiredFor (conservative); runtime matches exact uses. + add(fmt.Sprintf( + "Tool/%s will require approval at run (decision=%s, source=%s).", + toolName, cur.Decision, cur.Source, + )) } func parsePolicySpec(resourceJSON string) (*policySpecRisk, bool) { diff --git a/internal/plan/risk_safety_test.go b/internal/plan/risk_safety_test.go new file mode 100644 index 0000000..36966eb --- /dev/null +++ b/internal/plan/risk_safety_test.go @@ -0,0 +1,62 @@ +package plan + +import ( + "context" + "strings" + "testing" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +func TestRiskSummary_newTool_flagsSafetyApproval(t *testing.T) { + g := minimalGraph() + g.Tools["risky"] = &spec.ToolResource{ + APIVersion: spec.APIVersionV0, + Kind: spec.KindTool, + Metadata: spec.Metadata{Name: "risky"}, + Spec: spec.ToolSpec{Type: "native"}, + } + spec.NormalizeProjectGraph(g) + + p := NewPlanner(&fakeDeploy{list: nil}) + pl, err := p.ComputePlan(context.Background(), "dev", g) + if err != nil { + t.Fatal(err) + } + var found bool + for _, m := range pl.Risk.Messages { + if strings.Contains(m, "Tool/risky") && strings.Contains(m, "require approval") { + found = true + break + } + } + if !found { + t.Fatalf("expected safety approval risk, got %#v", pl.Risk.Messages) + } +} + +func TestRiskSummary_trustedTool_noSafetyApprovalRisk(t *testing.T) { + g := minimalGraph() + trusted := true + g.Tools["safe"] = &spec.ToolResource{ + APIVersion: spec.APIVersionV0, + Kind: spec.KindTool, + Metadata: spec.Metadata{Name: "safe"}, + Spec: spec.ToolSpec{ + Type: "native", + Safety: &spec.ToolSafety{Trusted: &trusted}, + }, + } + spec.NormalizeProjectGraph(g) + + p := NewPlanner(&fakeDeploy{list: nil}) + pl, err := p.ComputePlan(context.Background(), "dev", g) + if err != nil { + t.Fatal(err) + } + for _, m := range pl.Risk.Messages { + if strings.Contains(m, "Tool/safe") && strings.Contains(m, "require approval") { + t.Fatalf("unexpected approval risk: %s", m) + } + } +} diff --git a/internal/policy/approvals.go b/internal/policy/approvals.go index 6db4ab3..bbb75b5 100644 --- a/internal/policy/approvals.go +++ b/internal/policy/approvals.go @@ -23,11 +23,8 @@ func checkApprovalGranted(uses string, approvals *spec.PolicyApprovals, approved if !approvalRequired(uses, approvals) { return nil } - u := strings.TrimSpace(uses) - for _, a := range approved { - if strings.TrimSpace(a) == u { - return nil - } + if actionApproved(uses, approved) { + return nil } return denied( ReasonApprovalRequired, diff --git a/internal/policy/derive.go b/internal/policy/derive.go new file mode 100644 index 0000000..cab7a61 --- /dev/null +++ b/internal/policy/derive.go @@ -0,0 +1,135 @@ +package policy + +import ( + "fmt" + "strings" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools" +) + +// Decision is the effective policy outcome for a tool call when explicit Policy rules do not apply. +type Decision string + +const ( + // DecisionAllow permits unattended execution. + DecisionAllow Decision = "allow" + // DecisionRequireApproval blocks until the full uses string is passed via --approve. + DecisionRequireApproval Decision = "requireApproval" + // DecisionDeny hard-blocks execution (explicit denylist only; metadata fallback uses requireApproval). + DecisionDeny Decision = "deny" +) + +// DecisionSource explains why a [Decision] was chosen (plan risk surfacing, issue #103). +type DecisionSource string + +const ( + SourceExplicitPolicyRule DecisionSource = "explicit_policy_rule" + SourceSafetyMetadata DecisionSource = "safety_metadata" + SourceFailClosedDefault DecisionSource = "fail_closed_default" +) + +// ToolDecision bundles an effective decision and its provenance for a tool resource. +type ToolDecision struct { + Decision Decision + Source DecisionSource + Safety spec.ResolvedToolSafety +} + +// Derive maps resolved safety metadata to a fallback decision (issue #103 truth table). +// Metadata fallback never returns [DecisionDeny]; use explicit policy denylists for hard denies. +func Derive(safety spec.ResolvedToolSafety) Decision { + if safety.RequiresApproval { + return DecisionRequireApproval + } + return DecisionAllow +} + +// EffectiveToolDecision returns the decision for toolName under policy pol (explicit rules first). +func EffectiveToolDecision(graph *spec.ProjectGraph, pol *spec.PolicySpec, toolName string) ToolDecision { + toolName = strings.TrimSpace(toolName) + safety := resolvedSafetyForTool(graph, toolName) + if pol != nil && pol.Approvals != nil { + prefix := toolUsesPrefix(toolName) + for _, r := range pol.Approvals.RequiredFor { + r = strings.TrimSpace(r) + if r == prefix || strings.HasPrefix(r, prefix) { + return ToolDecision{ + Decision: DecisionRequireApproval, + Source: SourceExplicitPolicyRule, + Safety: safety, + } + } + } + } + src := SourceSafetyMetadata + if graph == nil || graph.Tools == nil { + src = SourceFailClosedDefault + } else if tr, ok := graph.Tools[toolName]; !ok || tr == nil || tr.Spec.Safety == nil { + src = SourceFailClosedDefault + } + return ToolDecision{ + Decision: Derive(safety), + Source: src, + Safety: safety, + } +} + +func resolvedSafetyForTool(graph *spec.ProjectGraph, toolName string) spec.ResolvedToolSafety { + if graph == nil || graph.Tools == nil { + return spec.ResolveToolSafety(nil) + } + tr, ok := graph.Tools[toolName] + if !ok || tr == nil { + return spec.ResolveToolSafety(nil) + } + return spec.ResolveToolSafety(tr.Spec.Safety) +} + +func checkSafetyDerived(graph *spec.ProjectGraph, call ToolCallContext) error { + toolName, _, err := tools.ParseUses(call.Uses) + if err != nil { + return denied(ReasonInvalidUses, fmt.Sprintf("policy: %v", err), call.Uses, nil) + } + td := EffectiveToolDecision(graph, nil, toolName) + switch td.Decision { + case DecisionAllow: + return nil + case DecisionRequireApproval: + if actionApproved(call.Uses, call.Run.ApprovedActions) { + return nil + } + return denied( + ReasonApprovalRequired, + "policy: tool requires approval from safety metadata (--approve)", + call.Uses, + map[string]any{ + "tool": toolName, + "source": string(td.Source), + }, + ) + default: + // Derive never returns DecisionDeny; reserved for future explicit denylists. + return denied( + ReasonDenied, + fmt.Sprintf("policy: unexpected tool decision %q", td.Decision), + call.Uses, + map[string]any{"tool": toolName}, + ) + } +} + +// toolUsesPrefix is the plan-risk prefix for tool.. (conservative; runtime uses exact uses). +func toolUsesPrefix(toolName string) string { + return "tool." + strings.TrimSpace(toolName) + "." +} + +func actionApproved(uses string, approved []string) bool { + u := strings.TrimSpace(uses) + for _, a := range approved { + if strings.TrimSpace(a) == u { + return true + } + } + return false +} diff --git a/internal/policy/derive_test.go b/internal/policy/derive_test.go new file mode 100644 index 0000000..5f210fe --- /dev/null +++ b/internal/policy/derive_test.go @@ -0,0 +1,145 @@ +package policy + +import ( + "context" + "testing" + + "github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec" +) + +func TestDerive_truthTable(t *testing.T) { + tests := []struct { + name string + in spec.ResolvedToolSafety + want Decision + }{ + {"trusted_no_explicit_approval", spec.ResolvedToolSafety{Trusted: true, SideEffects: true, RequiresApproval: false}, DecisionAllow}, + {"untrusted_read_only", spec.ResolvedToolSafety{Trusted: false, SideEffects: false, RequiresApproval: false}, DecisionAllow}, + {"untrusted_mutating", spec.ResolvedToolSafety{Trusted: false, SideEffects: true, RequiresApproval: true}, DecisionRequireApproval}, + {"explicit_requires_approval", spec.ResolvedToolSafety{Trusted: true, SideEffects: false, RequiresApproval: true}, DecisionRequireApproval}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Derive(tt.in); got != tt.want { + t.Fatalf("Derive() = %q want %q", got, tt.want) + } + }) + } +} + +func TestCheckToolCall_safetyFallback_requiresApprovalWithoutApprove(t *testing.T) { + g := testGraphWithTools("slack") + g.Tools["slack"].Spec.Safety = &spec.ToolSafety{Trusted: spec.BoolPtr(false), SideEffects: spec.BoolPtr(true)} + ev := NewEvaluator(g, nil) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, + Uses: "tool.slack.message.send", + }) + if err == nil { + t.Fatal("expected denial") + } + d, ok := AsDenied(err) + if !ok || d.Reason != ReasonApprovalRequired { + t.Fatalf("got %v", err) + } +} + +func TestCheckToolCall_safetyFallback_trustedAllows(t *testing.T) { + g := testGraphWithTools("slack") + g.Tools["slack"].Spec.Safety = &spec.ToolSafety{Trusted: spec.BoolPtr(true)} + ev := NewEvaluator(g, nil) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, + Uses: "tool.slack.message.send", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCheckToolCall_safetyFallback_approveGrants(t *testing.T) { + g := testGraphWithTools("slack") + ev := NewEvaluator(g, nil) + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{ApprovedActions: []string{"tool.slack.message.send"}}, + Uses: "tool.slack.message.send", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestCheckToolCall_explicitPolicyRuleBeforeSafety(t *testing.T) { + g := testGraphWithTools("github") + g.Tools["github"].Spec.Safety = &spec.ToolSafety{Trusted: spec.BoolPtr(true)} + 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{}, + Uses: "tool.github.pull_request.merge", + }) + if err == nil { + t.Fatal("expected explicit policy approval denial") + } + d, ok := AsDenied(err) + if !ok || d.Reason != ReasonApprovalRequired { + t.Fatalf("got %v", err) + } + // trusted safety would allow, but explicit rule wins + err = ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, + Uses: "tool.github.pull_request.get", + }) + if err != nil { + t.Fatalf("trusted tool without explicit rule should allow: %v", err) + } +} + +func TestApprovalRequired_exactUsesNotPrefix(t *testing.T) { + g := testGraphWithTools("github") + g.Tools["github"].Spec.Safety = &spec.ToolSafety{Trusted: spec.BoolPtr(true)} + pol := &spec.PolicySpec{ + Approvals: &spec.PolicyApprovals{ + RequiredFor: []string{"tool.github.pull_request.merge"}, + }, + } + ev := NewEvaluator(g, pol) + if approvalRequired("tool.github.pull_request.get", pol.Approvals) { + t.Fatal("approvalRequired must not match by prefix") + } + if !approvalRequired("tool.github.pull_request.merge", pol.Approvals) { + t.Fatal("exact uses should require approval") + } + td := EffectiveToolDecision(g, pol, "github") + if td.Source != SourceExplicitPolicyRule { + t.Fatalf("plan uses prefix conservatively: %+v", td) + } + err := ev.CheckToolCall(context.Background(), ToolCallContext{ + Run: RunContext{}, Uses: "tool.github.pull_request.get", + }) + if err != nil { + t.Fatalf("trusted + no exact requiredFor: %v", err) + } +} + +func TestEffectiveToolDecision_explicitVsDerived(t *testing.T) { + g := testGraphWithTools("github") + g.Tools["github"].Spec.Safety = &spec.ToolSafety{Trusted: spec.BoolPtr(true)} + pol := &spec.PolicySpec{ + Approvals: &spec.PolicyApprovals{ + RequiredFor: []string{"tool.github.pull_request.merge"}, + }, + } + td := EffectiveToolDecision(g, pol, "github") + if td.Decision != DecisionRequireApproval || td.Source != SourceExplicitPolicyRule { + t.Fatalf("prefix rule: %+v", td) + } + td = EffectiveToolDecision(g, nil, "github") + if td.Decision != DecisionAllow || td.Source != SourceSafetyMetadata { + t.Fatalf("trusted metadata: %+v", td) + } +} diff --git a/internal/policy/doc.go b/internal/policy/doc.go index 8190505..7a5cbe2 100644 --- a/internal/policy/doc.go +++ b/internal/policy/doc.go @@ -4,4 +4,20 @@ // 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. +// +// When no explicit approvals.requiredFor rule matches a tool call, [Derive] consults +// [spec.ResolveToolSafety] metadata (fail-closed defaults; issue #103). +// +// # Tool-level safety vs per-action policy +// +// [spec.ToolSafety] applies to the whole Tool resource, not individual operations. Setting +// trusted: true allows unattended calls for every tool.. unless an exact +// approvals.requiredFor entry blocks that full uses string. Gate writes with requiredFor, not +// by assuming trusted means "read-only only." +// +// # Plan vs runtime +// +// [EffectiveToolDecision] uses prefix matching on tool.. for plan risk (conservative: +// any listed action under the tool flags the whole Tool). Runtime [approvalRequired] matches +// the full uses string exactly. package policy diff --git a/internal/policy/engine.go b/internal/policy/engine.go index 25b56e6..b2383ff 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -17,7 +17,8 @@ func NewEngine(g *spec.ProjectGraph) *Engine { } // Evaluator returns a [PolicyEvaluator] for the named Policy resource in the graph. -// If the policy is missing, returns a no-op evaluator (nil spec). +// If the policy name is missing or unknown, pol is nil: run/step budget checks are skipped, but +// tool calls still use safety-metadata fallback from the graph. func (e *Engine) Evaluator(policyName string) PolicyEvaluator { if e == nil { return NewEvaluator(nil, nil) diff --git a/internal/policy/errors.go b/internal/policy/errors.go index 5c463ad..1080ab7 100644 --- a/internal/policy/errors.go +++ b/internal/policy/errors.go @@ -13,6 +13,7 @@ const ( ReasonUnknownTool = "unknown_tool" ReasonApprovalRequired = "approval_required" ReasonInvalidUses = "invalid_uses" + ReasonDenied = "denied" ) // DeniedError is returned when a policy check fails (design doc section 12.2 H). diff --git a/internal/policy/evaluator.go b/internal/policy/evaluator.go index e126f1b..0bc63aa 100644 --- a/internal/policy/evaluator.go +++ b/internal/policy/evaluator.go @@ -19,7 +19,9 @@ type evaluator struct { } // NewEvaluator returns a [PolicyEvaluator] for the given merged policy spec and project graph. -// A nil policy spec applies no limits (all checks no-op). +// +// When pol is nil, [PolicyEvaluator.CheckRun] and [PolicyEvaluator.CheckStep] are no-ops, but +// [PolicyEvaluator.CheckToolCall] still enforces fail-closed [spec.ToolSafety] from graph (issue #103). func NewEvaluator(graph *spec.ProjectGraph, pol *spec.PolicySpec) PolicyEvaluator { return &evaluator{graph: graph, policy: pol} } @@ -52,11 +54,13 @@ func (e *evaluator) CheckStep(ctx context.Context, step StepContext) error { 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 + if p != nil { + if err := checkKnownTool(e.graph, call.Uses, p.Tools); err != nil { + return err + } + if approvalRequired(call.Uses, p.Approvals) { + return checkApprovalGranted(call.Uses, p.Approvals, call.Run.ApprovedActions) + } } - return checkApprovalGranted(call.Uses, p.Approvals, call.Run.ApprovedActions) + return checkSafetyDerived(e.graph, call) } diff --git a/internal/policy/evaluator_test.go b/internal/policy/evaluator_test.go index 41caf0b..d962b2a 100644 --- a/internal/policy/evaluator_test.go +++ b/internal/policy/evaluator_test.go @@ -50,6 +50,7 @@ func TestCheckToolCall_forbidUnknownTools_unknownToolDenied(t *testing.T) { func TestCheckToolCall_forbidUnknownTools_knownToolOK(t *testing.T) { g := testGraphWithTools("slack") + g.Tools["slack"].Spec.Safety = &spec.ToolSafety{SideEffects: spec.BoolPtr(false)} pol := &spec.PolicySpec{ Tools: &spec.PolicyTools{ForbidUnknownTools: true}, } diff --git a/internal/project/testdata/refs_unknown_tool/tools/github.yaml b/internal/project/testdata/refs_unknown_tool/tools/github.yaml index 31c869d..191b873 100644 --- a/internal/project/testdata/refs_unknown_tool/tools/github.yaml +++ b/internal/project/testdata/refs_unknown_tool/tools/github.yaml @@ -4,6 +4,8 @@ metadata: name: github spec: type: mcp + safety: + trusted: true mcp: transport: stdio command: npx diff --git a/internal/runtime/local/testdata/retention/tools.yaml b/internal/runtime/local/testdata/retention/tools.yaml index d4b6df0..c776a1b 100644 --- a/internal/runtime/local/testdata/retention/tools.yaml +++ b/internal/runtime/local/testdata/retention/tools.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/runtime/local/testdata/runproj/tools.yaml b/internal/runtime/local/testdata/runproj/tools.yaml index d4b6df0..c776a1b 100644 --- a/internal/runtime/local/testdata/runproj/tools.yaml +++ b/internal/runtime/local/testdata/runproj/tools.yaml @@ -4,3 +4,5 @@ metadata: name: helper spec: type: native + safety: + sideEffects: false diff --git a/internal/spec/doc.go b/internal/spec/doc.go index f7fac14..c100a60 100644 --- a/internal/spec/doc.go +++ b/internal/spec/doc.go @@ -3,4 +3,7 @@ // and graph validation ([ValidateProjectGraph], §9.1–§9.5 MVP subset). // Environment-specific overrides (§7.6) are applied by [github.com/LAA-Software-Engineering/agentic-control-plane/internal/runtime/local.ApplyEnvironment] // after [NormalizeProjectGraph] in CLI and local-runtime flows. +// +// Tool resources may declare spec.safety (trusted, sideEffects, requiresApproval) for +// fail-closed policy derivation when explicit Policy rules do not apply (issue #103). package spec diff --git a/internal/spec/kinds.go b/internal/spec/kinds.go index af32841..c1c661c 100644 --- a/internal/spec/kinds.go +++ b/internal/spec/kinds.go @@ -83,6 +83,23 @@ type ToolSpec struct { HTTP *ToolHTTP `yaml:"http,omitempty" json:"http,omitempty"` Permissions *ToolPermissions `yaml:"permissions,omitempty" json:"permissions,omitempty"` Retry *ToolRetry `yaml:"retry,omitempty" json:"retry,omitempty"` + // Safety carries blast-radius metadata for fail-closed policy derivation (issue #103). + Safety *ToolSafety `yaml:"safety,omitempty" json:"safety,omitempty"` +} + +// ToolSafety describes trust and side effects for policy fallback when no explicit Policy rule matches. +// Omitted fields resolve to fail-closed defaults via [ResolveToolSafety]. +type ToolSafety struct { + Trusted *bool `yaml:"trusted,omitempty" json:"trusted,omitempty"` + SideEffects *bool `yaml:"sideEffects,omitempty" json:"sideEffects,omitempty"` + RequiresApproval *bool `yaml:"requiresApproval,omitempty" json:"requiresApproval,omitempty"` +} + +// ResolvedToolSafety holds fully resolved safety flags after defaults and derivation. +type ResolvedToolSafety struct { + Trusted bool + SideEffects bool + RequiresApproval bool } type ToolMCP struct { diff --git a/internal/spec/normalize.go b/internal/spec/normalize.go index c6118d0..694853e 100644 --- a/internal/spec/normalize.go +++ b/internal/spec/normalize.go @@ -3,7 +3,8 @@ package spec import "strings" // NormalizeProjectGraph applies Project.spec.defaults to resources that omit matching -// fields and performs trivial string canonicalization (trim surrounding ASCII space). +// fields, materializes Tool safety defaults (issue #103), and performs trivial string +// canonicalization (trim surrounding ASCII space). // // Default application (§7.1 → effective config): // - Agent.spec.model ← defaults.model when the agent omits model (empty / whitespace-only). @@ -34,6 +35,12 @@ func NormalizeProjectGraph(g *ProjectGraph) { } normalizeWorkflowSpec(&w.Spec, def.Policy, def.Runtime) } + for _, tr := range g.Tools { + if tr == nil { + continue + } + NormalizeToolSafety(&tr.Spec) + } } func normalizeAgentSpec(spec *AgentSpec, defModel, defPolicy, defRuntime string) { diff --git a/internal/spec/safety.go b/internal/spec/safety.go new file mode 100644 index 0000000..bcac90c --- /dev/null +++ b/internal/spec/safety.go @@ -0,0 +1,137 @@ +package spec + +// MCP descriptor meta key for tool safety flags (not wired at discovery yet; see CHANGELOG). +const MCPMetaFlagsKey = "mcp_flags" + +// Fail-closed defaults for tool safety (issue #103, WayFind-aligned). +const ( + defaultToolTrusted = false + defaultToolSideEffects = true +) + +// ResolveToolSafety applies fail-closed defaults and derives requiresApproval when unset. +// +// Derivation when requiresApproval is omitted: +// - trusted → does not require approval +// - untrusted and no side effects → does not require approval (read-only) +// - otherwise → requires approval +func ResolveToolSafety(s *ToolSafety) ResolvedToolSafety { + r := ResolvedToolSafety{ + Trusted: defaultToolTrusted, + SideEffects: defaultToolSideEffects, + } + if s != nil { + if s.Trusted != nil { + r.Trusted = *s.Trusted + } + if s.SideEffects != nil { + r.SideEffects = *s.SideEffects + } + if s.RequiresApproval != nil { + r.RequiresApproval = *s.RequiresApproval + return r + } + } + r.RequiresApproval = deriveRequiresApproval(r.Trusted, r.SideEffects) + return r +} + +func deriveRequiresApproval(trusted, sideEffects bool) bool { + if trusted { + return false + } + if !sideEffects { + return false + } + return true +} + +// NormalizeToolSafety mutates spec.Safety so resolved bools are materialized for stable plan output. +// Idempotent when called on an already-normalized safety block. +func NormalizeToolSafety(spec *ToolSpec) { + if spec == nil { + return + } + resolved := ResolveToolSafety(spec.Safety) + spec.Safety = &ToolSafety{ + Trusted: BoolPtr(resolved.Trusted), + SideEffects: BoolPtr(resolved.SideEffects), + RequiresApproval: BoolPtr(resolved.RequiresApproval), + } +} + +// BoolPtr returns a pointer to b (for optional YAML bool fields). +func BoolPtr(b bool) *bool { + v := b + return &v +} + +// MergeToolSafety combines author-set safety with MCP-discovered flags. +// Not called from MCP discovery yet; see CHANGELOG [Unreleased] / issue #103 follow-up. +// Precedence: author (base) wins over MCP for each field that base sets explicitly. +func MergeToolSafety(author, mcp *ToolSafety) *ToolSafety { + if author == nil && mcp == nil { + return nil + } + out := &ToolSafety{} + if mcp != nil { + out.Trusted = mcp.Trusted + out.SideEffects = mcp.SideEffects + out.RequiresApproval = mcp.RequiresApproval + } + if author != nil { + if author.Trusted != nil { + out.Trusted = author.Trusted + } + if author.SideEffects != nil { + out.SideEffects = author.SideEffects + } + if author.RequiresApproval != nil { + out.RequiresApproval = author.RequiresApproval + } + } + if out.Trusted == nil && out.SideEffects == nil && out.RequiresApproval == nil { + return nil + } + return out +} + +// SafetyFromMCPMeta maps MCP tool descriptor meta[MCPMetaFlagsKey] onto [ToolSafety]. +// Not called from MCP discovery yet; see CHANGELOG [Unreleased] / issue #103 follow-up. +// Returns nil when meta is nil or carries no recognized flags. +func SafetyFromMCPMeta(meta map[string]any) *ToolSafety { + if meta == nil { + return nil + } + raw, ok := meta[MCPMetaFlagsKey] + if !ok { + return nil + } + flags, ok := raw.(map[string]any) + if !ok { + return nil + } + var s ToolSafety + if v, ok := boolFromMeta(flags["trusted"]); ok { + s.Trusted = &v + } + if v, ok := boolFromMeta(flags["side_effects"]); ok { + s.SideEffects = &v + } + if v, ok := boolFromMeta(flags["requires_approval"]); ok { + s.RequiresApproval = &v + } + if s.Trusted == nil && s.SideEffects == nil && s.RequiresApproval == nil { + return nil + } + return &s +} + +func boolFromMeta(v any) (bool, bool) { + switch t := v.(type) { + case bool: + return t, true + default: + return false, false + } +} diff --git a/internal/spec/safety_test.go b/internal/spec/safety_test.go new file mode 100644 index 0000000..ad87416 --- /dev/null +++ b/internal/spec/safety_test.go @@ -0,0 +1,130 @@ +package spec + +import "testing" + +func TestResolveToolSafety_truthTable(t *testing.T) { + t.Helper() + boolPtr := func(b bool) *bool { v := b; return &v } + + tests := []struct { + name string + in *ToolSafety + want ResolvedToolSafety + }{ + { + name: "fail_closed_defaults_nil", + in: nil, + want: ResolvedToolSafety{Trusted: false, SideEffects: true, RequiresApproval: true}, + }, + { + name: "trusted_unset_sideEffects", + in: &ToolSafety{Trusted: boolPtr(true)}, + want: ResolvedToolSafety{Trusted: true, SideEffects: true, RequiresApproval: false}, + }, + { + name: "untrusted_read_only", + in: &ToolSafety{SideEffects: boolPtr(false)}, + want: ResolvedToolSafety{Trusted: false, SideEffects: false, RequiresApproval: false}, + }, + { + name: "untrusted_mutating", + in: &ToolSafety{SideEffects: boolPtr(true)}, + want: ResolvedToolSafety{Trusted: false, SideEffects: true, RequiresApproval: true}, + }, + { + name: "explicit_requires_approval", + in: &ToolSafety{RequiresApproval: boolPtr(true), Trusted: boolPtr(true)}, + want: ResolvedToolSafety{Trusted: true, SideEffects: true, RequiresApproval: true}, + }, + { + name: "explicit_no_approval", + in: &ToolSafety{RequiresApproval: boolPtr(false), SideEffects: boolPtr(true)}, + want: ResolvedToolSafety{Trusted: false, SideEffects: true, RequiresApproval: false}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveToolSafety(tt.in) + if got != tt.want { + t.Fatalf("ResolveToolSafety() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestMergeToolSafety_authorOverridesMCP(t *testing.T) { + t.Helper() + f := false + tr := true + author := &ToolSafety{Trusted: &tr} + mcp := &ToolSafety{Trusted: &f, SideEffects: &f} + got := MergeToolSafety(author, mcp) + if got == nil || got.Trusted == nil || *got.Trusted != true { + t.Fatalf("author trusted should win: %+v", got) + } + if got.SideEffects == nil || *got.SideEffects != false { + t.Fatalf("mcp sideEffects when author omits: %+v", got) + } +} + +func TestSafetyFromMCPMeta(t *testing.T) { + meta := map[string]any{ + "mcp_flags": map[string]any{ + "trusted": true, + "side_effects": false, + "requires_approval": true, + }, + } + got := SafetyFromMCPMeta(meta) + if got == nil || got.Trusted == nil || !*got.Trusted { + t.Fatalf("trusted: %+v", got) + } + if got.SideEffects == nil || *got.SideEffects { + t.Fatalf("sideEffects: %+v", got) + } + if got.RequiresApproval == nil || !*got.RequiresApproval { + t.Fatalf("requiresApproval: %+v", got) + } +} + +func TestSafetyFromMCPMeta_nilAndMalformed(t *testing.T) { + if SafetyFromMCPMeta(nil) != nil { + t.Fatal("nil meta") + } + if SafetyFromMCPMeta(map[string]any{"mcp_flags": "nope"}) != nil { + t.Fatal("non-map mcp_flags") + } + if SafetyFromMCPMeta(map[string]any{"mcp_flags": map[string]any{"trusted": "yes"}}) != nil { + t.Fatal("non-bool trusted") + } +} + +func TestNormalizeToolSafety_idempotent(t *testing.T) { + sp := ToolSpec{} + NormalizeToolSafety(&sp) + first := ResolveToolSafety(sp.Safety) + NormalizeToolSafety(&sp) + second := ResolveToolSafety(sp.Safety) + if first != second { + t.Fatalf("not idempotent: %+v vs %+v", first, second) + } +} + +func TestNormalizeProjectGraph_fillsToolSafety(t *testing.T) { + g := &ProjectGraph{ + Tools: map[string]*ToolResource{ + "h": { + Metadata: Metadata{Name: "h"}, + Spec: ToolSpec{Type: "native"}, + }, + }, + } + NormalizeProjectGraph(g) + s := g.Tools["h"].Spec.Safety + if s == nil || s.Trusted == nil || s.SideEffects == nil || s.RequiresApproval == nil { + t.Fatalf("expected materialized safety, got %+v", s) + } + if *s.RequiresApproval != true { + t.Fatalf("fail-closed default requires approval: %+v", s) + } +} diff --git a/internal/spec/validator.go b/internal/spec/validator.go index 9ab7bf7..2860304 100644 --- a/internal/spec/validator.go +++ b/internal/spec/validator.go @@ -176,6 +176,19 @@ func validateToolSpecs(g *ProjectGraph) []error { if tr.Spec.Retry != nil && tr.Spec.Retry.MaxAttempts < 0 { errs = append(errs, fmt.Errorf("Tool/%s: retry.maxAttempts must be non-negative", name)) } + errs = append(errs, validateToolSafety(name, tr.Spec.Safety)...) + } + return errs +} + +func validateToolSafety(toolName string, s *ToolSafety) []error { + if s == nil { + return nil + } + var errs []error + prefix := fmt.Sprintf("Tool/%s: spec.safety", toolName) + if s.Trusted == nil && s.SideEffects == nil && s.RequiresApproval == nil { + errs = append(errs, fmt.Errorf("%s: at least one of trusted, sideEffects, requiresApproval must be set (or omit safety to use fail-closed defaults via normalize)", prefix)) } return errs } diff --git a/internal/spec/validator_test.go b/internal/spec/validator_test.go index 83801e9..d97c950 100644 --- a/internal/spec/validator_test.go +++ b/internal/spec/validator_test.go @@ -307,6 +307,25 @@ func TestValidateProjectGraph_mcpHTTPMissingURL(t *testing.T) { } } +func TestValidateProjectGraph_toolSafetyEmptyBlock(t *testing.T) { + g := &ProjectGraph{ + Tools: map[string]*ToolResource{ + "h": { + Kind: KindTool, + Metadata: Metadata{Name: "h"}, + Spec: ToolSpec{ + Type: "native", + Safety: &ToolSafety{}, + }, + }, + }, + } + err := ValidateProjectGraph(g, t.TempDir()) + if err == nil || !strings.Contains(err.Error(), "at least one of trusted") { + t.Fatalf("expected empty safety error, got %v", err) + } +} + func TestValidateProjectGraph_runtimeLocalAccepted(t *testing.T) { g := &ProjectGraph{ Spec: ProjectSpec{