Skip to content

Commit 848451d

Browse files
authored
Merge pull request #124 from LAA-Software-Engineering/feat/tool-safety-metadata-103
feat(spec,policy): tool safety metadata + fail-closed policy derivation
2 parents 430e251 + 185886b commit 848451d

39 files changed

Lines changed: 939 additions & 18 deletions

File tree

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`).

examples/example1/tools/helper.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: helper
55
spec:
66
type: native
7+
safety:
8+
sideEffects: false

examples/pr-review-demo/tools/github.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: github
55
spec:
66
type: native
7+
safety:
8+
trusted: true

examples/pr-review-github-actions/tools/github.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: github
55
spec:
66
type: native
7+
safety:
8+
trusted: true

examples/pr-review-github/tools/github.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: github
55
spec:
66
type: native
7+
safety:
8+
trusted: true

internal/cli/initembed/tools/helper.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: helper
55
spec:
66
type: native
7+
safety:
8+
sideEffects: false

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)

internal/cli/testdata/fmt_messy/tool.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: helper
55
spec:
66
type: native
7+
safety:
8+
sideEffects: false

internal/cli/testdata/plan_project/tool.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ metadata:
44
name: helper
55
spec:
66
type: native
7+
safety:
8+
sideEffects: false

0 commit comments

Comments
 (0)