Skip to content

Commit e44c55c

Browse files
authored
Merge pull request #153 from LAA-Software-Engineering/feat/118-policy-snapshot
feat(policy): compile immutable digest-checked policy snapshots (#118)
2 parents 25e0257 + 2232ff6 commit e44c55c

37 files changed

Lines changed: 1440 additions & 39 deletions

internal/cli/apply.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
9797
if err := writeApplyEmptyOutput(cmd, env, dsn, pl, rc, g); err != nil {
9898
return err
9999
}
100-
return config.WriteSnapshot(rc)
100+
return persistSnapshots(rc)
101101
}
102102

103103
if g.Output != render.FormatTable {
@@ -137,7 +137,7 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
137137
if err := writeApplySuccessOutput(cmd, env, dsn, pl, rc, g, at); err != nil {
138138
return err
139139
}
140-
return config.WriteSnapshot(rc)
140+
return persistSnapshots(rc)
141141
}
142142

143143
func readApplyConfirmation(r io.Reader) (bool, error) {

internal/cli/golden_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ func TestGolden_plan_first_table(t *testing.T) {
101101
assertGoldenOutput(t, "plan_first.table.golden.txt", out.String())
102102
}
103103

104+
func TestGolden_plan_policy_compile_table(t *testing.T) {
105+
root := t.TempDir()
106+
copyPolicyCompileFixture(t, root)
107+
db := filepath.Join(t.TempDir(), "golden-policy-compile.db")
108+
109+
ResetGlobalsForTest()
110+
cmd := NewRootCmd()
111+
var out bytes.Buffer
112+
cmd.SetOut(&out)
113+
cmd.SetErr(&out)
114+
cmd.SetArgs([]string{"plan", "--project", root, "--state", db})
115+
if err := cmd.Execute(); err != nil {
116+
t.Fatal(err)
117+
}
118+
assertGoldenOutput(t, "plan_policy_compile.table.golden.txt", out.String())
119+
}
120+
104121
func TestGolden_plan_noop_after_apply_table(t *testing.T) {
105122
ctx := context.Background()
106123
root := t.TempDir()

internal/cli/load_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010

1111
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/config"
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
1213
)
1314

1415
func writeFile(t *testing.T, path, content string) {
@@ -150,3 +151,61 @@ spec:
150151
t.Fatalf("exit code = %d, want %d", code, ExitPlanApplyConflict)
151152
}
152153
}
154+
155+
func TestRun_policySnapshotDrift_exit3(t *testing.T) {
156+
root := t.TempDir()
157+
writeFile(t, filepath.Join(root, "project.yaml"), `apiVersion: agentic.dev/v0
158+
kind: Project
159+
metadata:
160+
name: demo
161+
spec:
162+
imports:
163+
- ./policy.yaml
164+
state:
165+
backend: sqlite
166+
dsn: .agentic/state.db
167+
`)
168+
writeFile(t, filepath.Join(root, "policy.yaml"), `apiVersion: agentic.dev/v0
169+
kind: Policy
170+
metadata:
171+
name: default
172+
spec:
173+
execution:
174+
maxTotalCostUsd: 3
175+
`)
176+
177+
ResetGlobalsForTest()
178+
global = Global{ProjectRoot: root}
179+
rc, err := prepareResolvedConfig(&global)
180+
if err != nil {
181+
t.Fatal(err)
182+
}
183+
if err := persistSnapshots(rc); err != nil {
184+
t.Fatal(err)
185+
}
186+
187+
policyPath := filepath.Join(root, "policy.yaml")
188+
b, err := os.ReadFile(policyPath)
189+
if err != nil {
190+
t.Fatal(err)
191+
}
192+
updated := strings.Replace(string(b), "maxTotalCostUsd: 3", "maxTotalCostUsd: 10", 1)
193+
if err := os.WriteFile(policyPath, []byte(updated), 0o600); err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
rc2, err := prepareResolvedConfig(&global)
198+
if err != nil {
199+
t.Fatal(err)
200+
}
201+
err = assertPolicySnapshotMatches(rc2)
202+
if err == nil {
203+
t.Fatal("expected policy drift")
204+
}
205+
if !errors.Is(err, policy.ErrPolicySnapshotDrift) {
206+
t.Fatalf("want ErrPolicySnapshotDrift, got %v", err)
207+
}
208+
if code := ExitCodeOf(NewExitError(ExitPlanApplyConflict, err)); code != ExitPlanApplyConflict {
209+
t.Fatalf("exit code = %d, want %d", code, ExitPlanApplyConflict)
210+
}
211+
}

internal/cli/plan.go

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/config"
1212
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
1314
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
1415
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
1516
"github.com/spf13/cobra"
@@ -28,8 +29,10 @@ unless overridden by global --state.
2829
2930
Environment for stored rows is taken from -e / --env when set, otherwise "local".
3031
31-
Writes .agentic/resolved-config.json (digest of resolved graph + env + state path) for plan→run
32-
contract checks. JSON/YAML output includes resolvedConfigDigest alongside deploymentBaseline.
32+
Writes .agentic/resolved-config.json (digest of resolved graph + env + state path) and
33+
.agentic/policy-snapshot.json (compiled effective policy digest) for plan→run contract checks.
34+
JSON/YAML output includes resolvedConfigDigest, policyDigest, and effectivePolicy (project
35+
default policy only; workflows/agents may bind other policy names) alongside deploymentBaseline.
3336
3437
Exit codes (section 11.2):
3538
0 — success
@@ -80,8 +83,8 @@ func runPlan(cmd *cobra.Command, args []string) error {
8083
if err := writePlanOutput(cmd, env, dsn, pl, rc, g); err != nil {
8184
return err
8285
}
83-
if err := config.WriteSnapshot(rc); err != nil {
84-
return fmt.Errorf("plan: write resolved config snapshot: %w", err)
86+
if err := persistSnapshots(rc); err != nil {
87+
return fmt.Errorf("plan: %w", err)
8588
}
8689
return nil
8790
}
@@ -97,8 +100,18 @@ func writePlanOutput(cmd *cobra.Command, env, dsn string, p *plan.Plan, rc *conf
97100
if _, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\n", env, dsn); err != nil {
98101
return err
99102
}
100-
_, err := fmt.Fprintf(out, "%s\n", plan.FormatPlan(p))
101-
return err
103+
if _, err := fmt.Fprintf(out, "%s", plan.FormatPlan(p)); err != nil {
104+
return err
105+
}
106+
if section := compiledPolicyPlanSection(rc); section != "" {
107+
if _, err := fmt.Fprintf(out, "%s\n", section); err != nil {
108+
return err
109+
}
110+
} else {
111+
_, err := fmt.Fprintln(out)
112+
return err
113+
}
114+
return nil
102115
}
103116
}
104117

@@ -158,9 +171,52 @@ func planJSONModel(env, dsn string, p *plan.Plan, rc *config.ResolvedConfig) map
158171
if rc != nil && rc.Digest() != "" {
159172
m["resolvedConfigDigest"] = rc.Digest()
160173
}
174+
if rc != nil {
175+
if cp, digest, err := compiledPolicySummary(rc); err == nil && cp != nil {
176+
m["policyDigest"] = digest
177+
m["effectivePolicy"] = effectivePolicyJSON(cp)
178+
}
179+
}
161180
return m
162181
}
163182

183+
// compiledPolicySummary returns the snapshot-set digest and the compiled project-default policy.
184+
// Workflows and agents may reference other policy names; only the default is shown in plan output.
185+
func compiledPolicySummary(rc *config.ResolvedConfig) (*policy.CompiledPolicy, string, error) {
186+
graph := rc.Graph()
187+
policies, err := policy.CompileReferenced(graph)
188+
if err != nil {
189+
return nil, "", err
190+
}
191+
digest, err := policy.SnapshotSetDigest(policies)
192+
if err != nil {
193+
return nil, "", err
194+
}
195+
name := policy.DefaultPolicyName(graph)
196+
return policies[name], digest, nil
197+
}
198+
199+
func compiledPolicyPlanSection(rc *config.ResolvedConfig) string {
200+
cp, _, err := compiledPolicySummary(rc)
201+
if err != nil || cp == nil {
202+
return ""
203+
}
204+
return plan.FormatEffectivePolicy(policy.DefaultPolicyName(rc.Graph()), cp)
205+
}
206+
207+
func effectivePolicyJSON(cp *policy.CompiledPolicy) []map[string]string {
208+
entries := cp.EffectivePolicyEntries()
209+
out := make([]map[string]string, len(entries))
210+
for i, e := range entries {
211+
out[i] = map[string]string{
212+
"tool": e.Tool,
213+
"decision": string(e.Decision),
214+
"source": string(e.Source),
215+
}
216+
}
217+
return out
218+
}
219+
164220
func riskStrings(p *plan.Plan) []string {
165221
if p == nil || len(p.Risk.Messages) == 0 {
166222
return []string{}

internal/cli/plan_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import (
1515
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
1616
)
1717

18-
func copyPlanFixture(t *testing.T, dstDir string) {
18+
func copyFixtureDir(t *testing.T, dstDir, fixtureName string) {
1919
t.Helper()
20-
src := filepath.Join("testdata", "plan_project")
20+
src := filepath.Join("testdata", fixtureName)
2121
entries, err := os.ReadDir(src)
2222
if err != nil {
2323
t.Fatal(err)
@@ -36,6 +36,16 @@ func copyPlanFixture(t *testing.T, dstDir string) {
3636
}
3737
}
3838

39+
func copyPlanFixture(t *testing.T, dstDir string) {
40+
t.Helper()
41+
copyFixtureDir(t, dstDir, "plan_project")
42+
}
43+
44+
func copyPolicyCompileFixture(t *testing.T, dstDir string) {
45+
t.Helper()
46+
copyFixtureDir(t, dstDir, "plan_policy_compile")
47+
}
48+
3949
func TestPlan_json_includesResolvedConfigDigest(t *testing.T) {
4050
root := t.TempDir()
4151
copyPlanFixture(t, root)
@@ -60,6 +70,34 @@ func TestPlan_json_includesResolvedConfigDigest(t *testing.T) {
6070
}
6171
}
6272

73+
func TestPlan_json_includesPolicyDigest(t *testing.T) {
74+
root := t.TempDir()
75+
copyPolicyCompileFixture(t, root)
76+
db := filepath.Join(t.TempDir(), "plan-policy-json.db")
77+
78+
ResetGlobalsForTest()
79+
var out bytes.Buffer
80+
cmd := NewRootCmd()
81+
cmd.SetOut(&out)
82+
cmd.SetErr(&out)
83+
cmd.SetArgs([]string{"plan", "--project", root, "--state", db, "-o", "json"})
84+
if err := cmd.Execute(); err != nil {
85+
t.Fatal(err)
86+
}
87+
var payload map[string]any
88+
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
89+
t.Fatalf("json: %v\nbody=%s", err, out.String())
90+
}
91+
d, ok := payload["policyDigest"].(string)
92+
if !ok || strings.TrimSpace(d) == "" {
93+
t.Fatalf("policyDigest missing or empty: %#v", payload["policyDigest"])
94+
}
95+
effective, ok := payload["effectivePolicy"].([]any)
96+
if !ok || len(effective) < 3 {
97+
t.Fatalf("effectivePolicy missing entries: %#v", payload["effectivePolicy"])
98+
}
99+
}
100+
63101
func TestPlan_firstPlan_allCreates(t *testing.T) {
64102
root := t.TempDir()
65103
copyPlanFixture(t, root)

internal/cli/policy_snapshot.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/config"
7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
8+
)
9+
10+
// persistSnapshots writes resolved-config and compiled policy snapshots for plan→run checks.
11+
func persistSnapshots(rc *config.ResolvedConfig) error {
12+
if err := config.WriteSnapshot(rc); err != nil {
13+
return fmt.Errorf("write resolved config snapshot: %w", err)
14+
}
15+
if err := writePolicySnapshot(rc); err != nil {
16+
return err
17+
}
18+
return nil
19+
}
20+
21+
func writePolicySnapshot(rc *config.ResolvedConfig) error {
22+
if rc == nil {
23+
return fmt.Errorf("policy snapshot: nil resolved config")
24+
}
25+
graph := rc.Graph()
26+
policies, err := policy.CompileReferenced(graph)
27+
if err != nil {
28+
return fmt.Errorf("policy snapshot: compile: %w", err)
29+
}
30+
if err := policy.WriteSnapshotSet(rc.ProjectRoot(), rc.Digest(), policies); err != nil {
31+
return fmt.Errorf("policy snapshot: %w", err)
32+
}
33+
return nil
34+
}
35+
36+
func assertPolicySnapshotMatches(rc *config.ResolvedConfig) error {
37+
if rc == nil {
38+
return fmt.Errorf("policy snapshot: nil resolved config")
39+
}
40+
return policy.AssertSnapshotMatchesCompiled(rc.ProjectRoot(), rc.Graph(), rc.Digest())
41+
}

internal/cli/run.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,16 @@ Examples:
6767
agentctl run workflow/demo --input-file input.json
6868
agentctl run --resume run-abc123
6969
70-
When .agentic/resolved-config.json exists (from a prior validate/plan/apply), run compares
71-
the resolved-config digest and fails with exit 3 if inputs changed (e.g. user-local overlay,
72-
--state, or project YAML). Re-run validate or plan after changing config.
70+
When .agentic/resolved-config.json or .agentic/policy-snapshot.json exists (from a prior
71+
validate/plan/apply), run compares those digests and fails with exit 3 if inputs changed
72+
(e.g. user-local overlay, --state, project YAML, or policy YAML). Re-run validate or plan
73+
after changing config or policy.
7374
7475
Exit codes (section 11.2):
7576
0 — success (including interrupted runs awaiting resume)
7677
1 — generic failure (e.g. cannot open SQLite, start run, trace)
7778
2 — validation failure (project, workflow ref, input, input-file)
78-
3 — resolved-config drift (config changed since last validate/plan/apply; issue #112)
79+
3 — resolved-config or policy snapshot drift (changed since last validate/plan/apply; issues #112, #118)
7980
4 — execution failure (step/engine error after the run row exists)
8081
5 — policy denial`,
8182
Args: func(cmd *cobra.Command, args []string) error {
@@ -235,6 +236,12 @@ func runRun(cmd *cobra.Command, wfName, resumeRunID, inputFile string, inputPair
235236
}
236237
return fmt.Errorf("run: resolved config snapshot: %w", err)
237238
}
239+
if err := assertPolicySnapshotMatches(rc); err != nil {
240+
if errors.Is(err, policy.ErrPolicySnapshotDrift) {
241+
return NewExitError(ExitPlanApplyConflict, err)
242+
}
243+
return fmt.Errorf("run: policy snapshot: %w", err)
244+
}
238245
env := rc.Environment()
239246
dsn := rc.StatePath()
240247

0 commit comments

Comments
 (0)