Skip to content

Commit 185886b

Browse files
leo-aa88cursoragent
andcommitted
docs(policy,spec): address PR #124 review — migration, tests, godoc
- Fix NewEvaluator/Engine.Evaluator godoc for nil-policy safety enforcement - Add CHANGELOG [Unreleased] with breaking-change migration guide - Document tool-level trusted vs requiredFor; plan prefix vs runtime exact match - Mark MCP SafetyFromMCPMeta/MergeToolSafety as not wired yet - Validate non-empty spec.safety blocks; export spec.BoolPtr - Add run_safety CLI fixture: exit 5 from safety only (no Policy) - Revert testGraphWithTools to production fail-closed defaults - Add prefix vs exact approval tests; CONTRIBUTING CHANGELOG note Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a004272 commit 185886b

18 files changed

Lines changed: 239 additions & 24 deletions

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6+
7+
## [Unreleased]
8+
9+
### Added
10+
11+
- **`spec.safety` on Tool resources** (issue #103): optional `trusted`, `sideEffects`, and `requiresApproval` fields. [NormalizeProjectGraph] materializes fail-closed defaults on load.
12+
- **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`).
13+
- **Plan risk hints** for tools that will require approval at run, including decision source (`explicit_policy_rule`, `safety_metadata`, `fail_closed_default`).
14+
15+
### Changed
16+
17+
- **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).
18+
- Tools with **no** `spec.safety` block behave as **untrusted with side effects** after normalization → require `--approve` unless an explicit `approvals.requiredFor` rule matches.
19+
20+
### Migration
21+
22+
1. For **read-only** native or mock tools (echo, fetch, identity), add:
23+
```yaml
24+
spec:
25+
safety:
26+
sideEffects: false
27+
```
28+
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).
29+
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).
30+
31+
### Not yet wired
32+
33+
- 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).

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Run **`make`** or **`make help`** for a full list of targets.
2222

2323
## Before you open a pull request
2424

25+
User-visible behavior changes should include an entry under **[Unreleased]** in [`CHANGELOG.md`](CHANGELOG.md) (especially breaking changes and migrations).
26+
2527
1. **Format**`make fmt` or ensure `gofmt -l .` prints nothing (same check as CI).
2628
2. **Static analysis**`make vet` (or `go vet ./...`).
2729
3. **Tests**`make test` (`go test ./... -race`).

internal/cli/run_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ func runPolicyRoot(t *testing.T) string {
2929
return abs
3030
}
3131

32+
func runSafetyRoot(t *testing.T) string {
33+
t.Helper()
34+
p := filepath.Join("testdata", "run_safety")
35+
abs, err := filepath.Abs(p)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
return abs
40+
}
41+
3242
func TestRun_demo_integration_succeeds(t *testing.T) {
3343
db := filepath.Join(t.TempDir(), "run-cli.db")
3444
root := runProjRoot(t)
@@ -54,6 +64,53 @@ func TestRun_demo_integration_succeeds(t *testing.T) {
5464
}
5565
}
5666

67+
func TestRun_safetyOnlyDenial_exit5(t *testing.T) {
68+
db := filepath.Join(t.TempDir(), "run-safety.db")
69+
root := runSafetyRoot(t)
70+
71+
ResetGlobalsForTest()
72+
cmd := NewRootCmd()
73+
cmd.SetOut(io.Discard)
74+
cmd.SetErr(io.Discard)
75+
cmd.SetArgs([]string{
76+
"run", "workflow/echo",
77+
"--project", root,
78+
"--state", db,
79+
"--input", "topic=x",
80+
})
81+
err := cmd.Execute()
82+
if err == nil {
83+
t.Fatal("expected safety-derived policy denial")
84+
}
85+
if ExitCodeOf(err) != ExitPolicyDenied {
86+
t.Fatalf("exit=%d want %d err=%v", ExitCodeOf(err), ExitPolicyDenied, err)
87+
}
88+
}
89+
90+
func TestRun_safetyOnly_withApprove_succeeds(t *testing.T) {
91+
db := filepath.Join(t.TempDir(), "run-safety-ok.db")
92+
root := runSafetyRoot(t)
93+
94+
ResetGlobalsForTest()
95+
var out bytes.Buffer
96+
cmd := NewRootCmd()
97+
cmd.SetOut(&out)
98+
cmd.SetErr(&out)
99+
cmd.SetArgs([]string{
100+
"run", "workflow/echo",
101+
"--project", root,
102+
"--state", db,
103+
"--input", "topic=x",
104+
"--approve", "tool.helper.echo",
105+
})
106+
if err := cmd.Execute(); err != nil {
107+
t.Fatal(err)
108+
}
109+
if !strings.Contains(out.String(), "Status: succeeded") {
110+
t.Fatalf("output:\n%s", out.String())
111+
}
112+
}
113+
57114
func TestRun_policyDenial_exit5(t *testing.T) {
58115
db := filepath.Join(t.TempDir(), "run-pol.db")
59116
root := runPolicyRoot(t)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Project
3+
metadata:
4+
name: safetyproj
5+
spec:
6+
imports:
7+
- ./tools.yaml
8+
- ./workflow.yaml
9+
providers:
10+
models:
11+
mock:
12+
type: mock
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"type": "object",
4+
"required": ["topic"],
5+
"properties": {
6+
"topic": { "type": "string" }
7+
},
8+
"additionalProperties": true
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Tool
3+
metadata:
4+
name: helper
5+
spec:
6+
type: native
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
apiVersion: agentic.dev/v0
2+
kind: Workflow
3+
metadata:
4+
name: echo
5+
spec:
6+
input:
7+
schema: ./schemas/in.json
8+
steps:
9+
- id: s1
10+
uses: tool.helper.echo
11+
with:
12+
topic: "${input.topic}"
13+
output:
14+
value:
15+
out: "${steps.s1.output.echo}"

internal/engine/execution_test.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import (
1616
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/trace"
1717
)
1818

19-
func boolPtr(b bool) *bool { v := b; return &v }
20-
2119
func testProjectRoot(t *testing.T) string {
2220
t.Helper()
2321
_, file, _, ok := runtime.Caller(0)
@@ -51,7 +49,7 @@ func TestRun_sequentialToolAndAgent_mockModel(t *testing.T) {
5149
Metadata: spec.Metadata{Name: "helper"},
5250
Spec: spec.ToolSpec{
5351
Type: "native",
54-
Safety: &spec.ToolSafety{SideEffects: boolPtr(false)},
52+
Safety: &spec.ToolSafety{SideEffects: spec.BoolPtr(false)},
5553
},
5654
},
5755
},

internal/plan/risk.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ func addToolSafetyRisk(add func(string), toolName string, cur policy.ToolDecisio
260260
if prev != nil && prev.Decision == policy.DecisionRequireApproval {
261261
return
262262
}
263+
// Plan uses prefix match on tool.<name>. for explicit requiredFor (conservative); runtime matches exact uses.
263264
add(fmt.Sprintf(
264265
"Tool/%s will require approval at run (decision=%s, source=%s).",
265266
toolName, cur.Decision, cur.Source,

internal/policy/derive.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func EffectiveToolDecision(graph *spec.ProjectGraph, pol *spec.PolicySpec, toolN
5050
toolName = strings.TrimSpace(toolName)
5151
safety := resolvedSafetyForTool(graph, toolName)
5252
if pol != nil && pol.Approvals != nil {
53-
prefix := "tool." + toolName + "."
53+
prefix := toolUsesPrefix(toolName)
5454
for _, r := range pol.Approvals.RequiredFor {
5555
r = strings.TrimSpace(r)
5656
if r == prefix || strings.HasPrefix(r, prefix) {
@@ -109,15 +109,21 @@ func checkSafetyDerived(graph *spec.ProjectGraph, call ToolCallContext) error {
109109
},
110110
)
111111
default:
112+
// Derive never returns DecisionDeny; reserved for future explicit denylists.
112113
return denied(
113114
ReasonDenied,
114-
"policy: tool denied by safety metadata",
115+
fmt.Sprintf("policy: unexpected tool decision %q", td.Decision),
115116
call.Uses,
116117
map[string]any{"tool": toolName},
117118
)
118119
}
119120
}
120121

122+
// toolUsesPrefix is the plan-risk prefix for tool.<name>. (conservative; runtime uses exact uses).
123+
func toolUsesPrefix(toolName string) string {
124+
return "tool." + strings.TrimSpace(toolName) + "."
125+
}
126+
121127
func actionApproved(uses string, approved []string) bool {
122128
u := strings.TrimSpace(uses)
123129
for _, a := range approved {

0 commit comments

Comments
 (0)