Skip to content

Commit 3500c3b

Browse files
authored
Merge pull request #47 from LAA-Software-Engineering/issue/13-plan-risk
Issue/13 plan risk
2 parents b186c41 + ec222e5 commit 3500c3b

7 files changed

Lines changed: 395 additions & 5 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
.PHONY: test build
22

33
test:
4-
go test ./...
4+
go test ./... -race
5+
6+
test-coverage:
7+
go test ./... -race -coverprofile=coverage.out
58

69
build:
710
mkdir -p bin

internal/plan/doc.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Package plan computes desired vs current state diffs and risk summaries.
22
//
33
// Deployment comparison uses canonical JSON from encoding/json and spec_hash = SHA-256(hex)
4-
// of those bytes (design doc §14.1, issue #12).
4+
// of those bytes (design doc §14.1, issue #12). [RiskSummary] is filled from Policy, Agent, and
5+
// Tool diffs (issue #13); tool mutating risk uses [ActionSuggestsWriteSideEffects].
56
package plan

internal/plan/output.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,11 @@ func FormatPlan(p *Plan) string {
3636
fmt.Fprintf(&b, "- delete %s\n", op.Target.String())
3737
}
3838
}
39+
if len(p.Risk.Messages) > 0 {
40+
b.WriteString("\nRisk delta:\n")
41+
for _, m := range p.Risk.Messages {
42+
fmt.Fprintf(&b, "- %s\n", m)
43+
}
44+
}
3945
return strings.TrimSuffix(b.String(), "\n")
4046
}

internal/plan/plan.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ type FieldChange struct {
3535
New string
3636
}
3737

38-
// RiskSummary is reserved for richer risk deltas (§12.2); MVP planner leaves it empty.
39-
type RiskSummary struct{}
38+
// RiskSummary carries MVP plan risk signals (design doc §12.2, §10.2).
39+
type RiskSummary struct {
40+
Messages []string
41+
}
4042

4143
// Planner reads deployment state to compare desired vs applied resources (design doc §5.2).
4244
type Planner struct {

internal/plan/planner.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr
8484

8585
sortOperations(ops)
8686

87-
return &Plan{Operations: ops, Risk: RiskSummary{}}, nil
87+
risk := summarizeRisks(appliedByID, desiredByID, ops)
88+
return &Plan{Operations: ops, Risk: risk}, nil
8889
}
8990

9091
func desiredRows(g *spec.ProjectGraph) ([]desiredRow, error) {

internal/plan/risk.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package plan
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"sort"
7+
"strings"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
11+
)
12+
13+
// ActionSuggestsWriteSideEffects is the MVP heuristic for whether a tool permission "allow"
14+
// action may grant mutating side effects. It is used when diffing Tool specs and when
15+
// planning brand-new tools (no prior state). True when s (ASCII case-folding) contains any of:
16+
// - "write" (e.g. issues.write, pull_requests.write)
17+
// - "delete"
18+
// - "merge"
19+
// - ".send" (e.g. slack.message.send)
20+
// - ".post"
21+
func ActionSuggestsWriteSideEffects(action string) bool {
22+
s := strings.ToLower(strings.TrimSpace(action))
23+
if s == "" {
24+
return false
25+
}
26+
return strings.Contains(s, "write") ||
27+
strings.Contains(s, "delete") ||
28+
strings.Contains(s, "merge") ||
29+
strings.Contains(s, ".send") ||
30+
strings.Contains(s, ".post")
31+
}
32+
33+
type policySpecRisk struct {
34+
Execution *struct {
35+
MaxTotalCostUsd float64 `json:"maxTotalCostUsd"`
36+
} `json:"execution"`
37+
Approvals *struct {
38+
RequiredFor []string `json:"requiredFor"`
39+
} `json:"approvals"`
40+
}
41+
42+
type agentSpecRisk struct {
43+
Model string `json:"model"`
44+
}
45+
46+
type toolSpecRisk struct {
47+
Permissions *struct {
48+
Allow []string `json:"allow"`
49+
} `json:"permissions"`
50+
}
51+
52+
type jsonEnvelope struct {
53+
Spec json.RawMessage `json:"spec"`
54+
}
55+
56+
func summarizeRisks(
57+
appliedByID map[string]state.AppliedResource,
58+
desiredByID map[string]desiredRow,
59+
ops []Operation,
60+
) RiskSummary {
61+
seen := map[string]struct{}{}
62+
var msgs []string
63+
add := func(s string) {
64+
s = strings.TrimSpace(s)
65+
if s == "" {
66+
return
67+
}
68+
if _, ok := seen[s]; ok {
69+
return
70+
}
71+
seen[s] = struct{}{}
72+
msgs = append(msgs, s)
73+
}
74+
75+
for _, op := range ops {
76+
key := resourceMapKey(op.Target.Kind, op.Target.Name)
77+
des := desiredByID[key]
78+
prev, hadPrev := appliedByID[key]
79+
80+
var oldJSON string
81+
if hadPrev {
82+
oldJSON = prev.NormalizedSpecJSON
83+
}
84+
85+
switch op.Target.Kind {
86+
case spec.KindPolicy:
87+
summarizePolicyRisk(add, op, oldJSON, des.json, hadPrev)
88+
case spec.KindAgent:
89+
summarizeAgentRisk(add, op, oldJSON, des.json, hadPrev)
90+
case spec.KindTool:
91+
summarizeToolRisk(add, op, oldJSON, des.json, hadPrev)
92+
}
93+
}
94+
95+
sort.Strings(msgs)
96+
return RiskSummary{Messages: msgs}
97+
}
98+
99+
func summarizePolicyRisk(add func(string), op Operation, oldJSON, newJSON string, hadPrev bool) {
100+
newPol, ok := parsePolicySpec(newJSON)
101+
if !ok {
102+
return
103+
}
104+
newCost := policyMaxCost(newPol)
105+
newApprovals := policyApprovals(newPol)
106+
107+
if op.Action == ActionCreate || !hadPrev {
108+
if newCost > 0 {
109+
add(fmt.Sprintf("New policy defines a cost ceiling (Policy/%s).", op.Target.Name))
110+
}
111+
if len(newApprovals) > 0 {
112+
add(fmt.Sprintf("New policy defines approval requirements (Policy/%s).", op.Target.Name))
113+
}
114+
return
115+
}
116+
117+
oldPol, ok := parsePolicySpec(oldJSON)
118+
if !ok {
119+
return
120+
}
121+
oldCost := policyMaxCost(oldPol)
122+
oldApprovals := policyApprovals(oldPol)
123+
124+
if newCost > oldCost+1e-9 {
125+
add(fmt.Sprintf("Cost ceiling increased (Policy/%s).", op.Target.Name))
126+
}
127+
for _, a := range oldApprovals {
128+
if !containsString(newApprovals, a) {
129+
add(fmt.Sprintf("Approval requirements removed for actions (Policy/%s).", op.Target.Name))
130+
break
131+
}
132+
}
133+
}
134+
135+
func summarizeAgentRisk(add func(string), op Operation, oldJSON, newJSON string, hadPrev bool) {
136+
newAg, ok := parseAgentSpec(newJSON)
137+
if !ok {
138+
return
139+
}
140+
newModel := strings.TrimSpace(newAg.Model)
141+
142+
if op.Action == ActionCreate || !hadPrev {
143+
if newModel != "" {
144+
add(fmt.Sprintf("New agent binds a model (Agent/%s).", op.Target.Name))
145+
}
146+
return
147+
}
148+
149+
oldAg, ok := parseAgentSpec(oldJSON)
150+
if !ok {
151+
return
152+
}
153+
oldModel := strings.TrimSpace(oldAg.Model)
154+
if newModel != oldModel && (newModel != "" || oldModel != "") {
155+
add(fmt.Sprintf("Agent model changed (Agent/%s).", op.Target.Name))
156+
}
157+
}
158+
159+
func summarizeToolRisk(add func(string), op Operation, oldJSON, newJSON string, hadPrev bool) {
160+
newTool, ok := parseToolSpec(newJSON)
161+
if !ok {
162+
return
163+
}
164+
newAllows := toolAllows(newTool)
165+
166+
if op.Action == ActionCreate || !hadPrev {
167+
for _, a := range newAllows {
168+
if ActionSuggestsWriteSideEffects(a) {
169+
add(fmt.Sprintf("New tool may grant write-like permissions (Tool/%s); see ActionSuggestsWriteSideEffects.", op.Target.Name))
170+
break
171+
}
172+
}
173+
return
174+
}
175+
176+
oldTool, ok := parseToolSpec(oldJSON)
177+
if !ok {
178+
return
179+
}
180+
oldAllows := toolAllows(oldTool)
181+
oldSet := make(map[string]struct{}, len(oldAllows))
182+
for _, a := range oldAllows {
183+
oldSet[strings.TrimSpace(a)] = struct{}{}
184+
}
185+
for _, a := range newAllows {
186+
a = strings.TrimSpace(a)
187+
if a == "" {
188+
continue
189+
}
190+
if _, ok := oldSet[a]; ok {
191+
continue
192+
}
193+
if ActionSuggestsWriteSideEffects(a) {
194+
add(fmt.Sprintf("New write-like tool permissions added (Tool/%s); see ActionSuggestsWriteSideEffects.", op.Target.Name))
195+
break
196+
}
197+
}
198+
}
199+
200+
func parsePolicySpec(resourceJSON string) (*policySpecRisk, bool) {
201+
var env jsonEnvelope
202+
if err := json.Unmarshal([]byte(resourceJSON), &env); err != nil {
203+
return nil, false
204+
}
205+
var p policySpecRisk
206+
if err := json.Unmarshal(env.Spec, &p); err != nil {
207+
return nil, false
208+
}
209+
return &p, true
210+
}
211+
212+
func parseAgentSpec(resourceJSON string) (*agentSpecRisk, bool) {
213+
var env jsonEnvelope
214+
if err := json.Unmarshal([]byte(resourceJSON), &env); err != nil {
215+
return nil, false
216+
}
217+
var a agentSpecRisk
218+
if err := json.Unmarshal(env.Spec, &a); err != nil {
219+
return nil, false
220+
}
221+
return &a, true
222+
}
223+
224+
func parseToolSpec(resourceJSON string) (*toolSpecRisk, bool) {
225+
var env jsonEnvelope
226+
if err := json.Unmarshal([]byte(resourceJSON), &env); err != nil {
227+
return nil, false
228+
}
229+
var t toolSpecRisk
230+
if err := json.Unmarshal(env.Spec, &t); err != nil {
231+
return nil, false
232+
}
233+
return &t, true
234+
}
235+
236+
func policyMaxCost(p *policySpecRisk) float64 {
237+
if p == nil || p.Execution == nil {
238+
return 0
239+
}
240+
return p.Execution.MaxTotalCostUsd
241+
}
242+
243+
func policyApprovals(p *policySpecRisk) []string {
244+
if p == nil || p.Approvals == nil {
245+
return nil
246+
}
247+
return p.Approvals.RequiredFor
248+
}
249+
250+
func toolAllows(t *toolSpecRisk) []string {
251+
if t == nil || t.Permissions == nil {
252+
return nil
253+
}
254+
return t.Permissions.Allow
255+
}
256+
257+
func containsString(slice []string, want string) bool {
258+
for _, s := range slice {
259+
if s == want {
260+
return true
261+
}
262+
}
263+
return false
264+
}

0 commit comments

Comments
 (0)