Skip to content

Commit 096393f

Browse files
leo-aa88cursoragent
andcommitted
feat(policy): built-in policy presets strict, permissive, shell_safe (#104)
Ship named presets resolvable from Project defaults, direct policy references, or Policy.spec.preset with local overrides layered on top. Expand presets during normalize so validate/plan show effective rules. shell_safe classifies native shell command tokens and integrates with tool safety metadata from #103. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 848451d commit 096393f

20 files changed

Lines changed: 996 additions & 9 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- **Built-in policy presets** (issue #104): `strict`, `permissive`, and `shell_safe`. Select via `Project.spec.defaults.policy`, by referencing a preset name on agents/workflows, or with `Policy.spec.preset` (local rules layer on top). Presets expand during [NormalizeProjectGraph] so plan/validate show effective rules.
12+
- **`shell_safe` token classification** for native `command.run` / `run` / `exec` / `shell` operations: read-only tokens (`ls`, `cat`, …) run unattended; risky/unknown tokens and tools with side-effect metadata require `--approve`.
1113
- **`spec.safety` on Tool resources** (issue #103): optional `trusted`, `sideEffects`, and `requiresApproval` fields. [NormalizeProjectGraph] materializes fail-closed defaults on load.
1214
- **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`).
1315
- **Plan risk hints** for tools that will require approval at run, including decision source (`explicit_policy_rule`, `safety_metadata`, `fail_closed_default`).

internal/cli/initembed/policies/default.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ kind: Policy
33
metadata:
44
name: default
55
spec:
6+
preset: shell_safe
67
execution:
78
maxWallClockSeconds: 300
89
maxTotalCostUsd: 5

internal/engine/steps.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func parseAgentJSONObject(content string) (map[string]any, error) {
4646

4747
func (e *Executor) runToolStep(ctx context.Context, pol policy.PolicyEvaluator, runID string, step spec.WorkflowStep, with map[string]any, pctx policy.RunContext) (map[string]any, tools.ToolCallMeta, error) {
4848
uses := strings.TrimSpace(step.Uses)
49-
if err := pol.CheckToolCall(ctx, policy.ToolCallContext{Run: pctx, StepID: step.ID, Uses: uses}); err != nil {
49+
if err := pol.CheckToolCall(ctx, policy.ToolCallContext{Run: pctx, StepID: step.ID, Uses: uses, With: with}); err != nil {
5050
if e.Trace != nil {
5151
if d, ok := policy.AsDenied(err); ok {
5252
_, _ = e.Trace.Append(ctx, runID, step.ID, trace.EventPolicyDenied, d.TraceData())

internal/policy/context.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ type ToolCallContext struct {
2525
Run RunContext
2626
StepID string
2727
Uses string
28+
// With is the interpolated workflow step input (used by shell_safe token classification).
29+
With map[string]any
2830
}

internal/policy/derive.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,31 @@ func Derive(safety spec.ResolvedToolSafety) Decision {
4949
func EffectiveToolDecision(graph *spec.ProjectGraph, pol *spec.PolicySpec, toolName string) ToolDecision {
5050
toolName = strings.TrimSpace(toolName)
5151
safety := resolvedSafetyForTool(graph, toolName)
52+
if pol != nil && pol.Approvals != nil {
53+
if pol.Approvals.Permissive {
54+
return ToolDecision{
55+
Decision: DecisionAllow,
56+
Source: SourceExplicitPolicyRule,
57+
Safety: safety,
58+
}
59+
}
60+
if pol.Approvals.RequireAllTools {
61+
return ToolDecision{
62+
Decision: DecisionRequireApproval,
63+
Source: SourceExplicitPolicyRule,
64+
Safety: safety,
65+
}
66+
}
67+
if spec.ResolvedPresetName(pol) == spec.PresetShellSafe {
68+
if safety.RequiresApproval || safety.SideEffects {
69+
return ToolDecision{
70+
Decision: DecisionRequireApproval,
71+
Source: SourceExplicitPolicyRule,
72+
Safety: safety,
73+
}
74+
}
75+
}
76+
}
5277
if pol != nil && pol.Approvals != nil {
5378
prefix := toolUsesPrefix(toolName)
5479
for _, r := range pol.Approvals.RequiredFor {

internal/policy/doc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
// Run context should carry elapsed wall clock, accumulated cost, and repeated --approve action strings
66
// matching policy approvals.requiredFor entries.
77
//
8+
// Built-in policy presets (issue #104): strict, permissive, and shell_safe. Select via
9+
// Project.spec.defaults.policy, a Policy resource spec.preset, or by referencing a preset name
10+
// as the workflow/agent policy. [spec.ExpandPresetsInGraph] materializes effective rules during normalize.
11+
//
812
// When no explicit approvals.requiredFor rule matches a tool call, [Derive] consults
913
// [spec.ResolveToolSafety] metadata (fail-closed defaults; issue #103).
1014
//

internal/policy/evaluator.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,60 @@ func (e *evaluator) CheckStep(ctx context.Context, step StepContext) error {
5454
func (e *evaluator) CheckToolCall(ctx context.Context, call ToolCallContext) error {
5555
_ = ctx
5656
p := e.spec()
57+
if p != nil && p.Approvals != nil && p.Approvals.Permissive {
58+
if err := checkKnownTool(e.graph, call.Uses, p.Tools); err != nil {
59+
return err
60+
}
61+
return nil
62+
}
5763
if p != nil {
5864
if err := checkKnownTool(e.graph, call.Uses, p.Tools); err != nil {
5965
return err
6066
}
67+
if p.Approvals != nil && p.Approvals.RequireAllTools {
68+
if actionApproved(call.Uses, call.Run.ApprovedActions) {
69+
return nil
70+
}
71+
return denied(
72+
ReasonApprovalRequired,
73+
"policy: action requires explicit approval (--approve)",
74+
call.Uses,
75+
map[string]any{"requiredFor": call.Uses, "preset": spec.PresetStrict},
76+
)
77+
}
78+
if spec.ResolvedPresetName(p) == spec.PresetShellSafe && !shellSafeRequiresApproval(e.graph, call) {
79+
return nil
80+
}
81+
if presetRequiresApproval(p, e.graph, call) {
82+
if actionApproved(call.Uses, call.Run.ApprovedActions) {
83+
return nil
84+
}
85+
return denied(
86+
ReasonApprovalRequired,
87+
"policy: action requires explicit approval (--approve)",
88+
call.Uses,
89+
map[string]any{
90+
"requiredFor": call.Uses,
91+
"preset": spec.ResolvedPresetName(p),
92+
},
93+
)
94+
}
6195
if approvalRequired(call.Uses, p.Approvals) {
6296
return checkApprovalGranted(call.Uses, p.Approvals, call.Run.ApprovedActions)
6397
}
6498
}
6599
return checkSafetyDerived(e.graph, call)
66100
}
101+
102+
func presetRequiresApproval(p *spec.PolicySpec, graph *spec.ProjectGraph, call ToolCallContext) bool {
103+
if p == nil || p.Approvals == nil {
104+
return false
105+
}
106+
if p.Approvals.RequireAllTools {
107+
return true
108+
}
109+
if spec.ResolvedPresetName(p) == spec.PresetShellSafe {
110+
return shellSafeRequiresApproval(graph, call)
111+
}
112+
return false
113+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package policy
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
8+
)
9+
10+
func shellSafeGraph() *spec.ProjectGraph {
11+
return &spec.ProjectGraph{
12+
Tools: map[string]*spec.ToolResource{
13+
"shell": {
14+
Metadata: spec.Metadata{Name: "shell"},
15+
Spec: spec.ToolSpec{
16+
Type: "native",
17+
Safety: &spec.ToolSafety{
18+
SideEffects: spec.BoolPtr(true),
19+
},
20+
},
21+
},
22+
},
23+
}
24+
}
25+
26+
func TestCheckToolCall_shellSafe_allowsLs(t *testing.T) {
27+
pol, err := spec.BuildPreset(spec.PresetShellSafe)
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
ev := NewEvaluator(shellSafeGraph(), &pol)
32+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
33+
Uses: "tool.shell.command.run",
34+
With: map[string]any{"command": "ls -la"},
35+
})
36+
if err != nil {
37+
t.Fatalf("ls should be allowed: %v", err)
38+
}
39+
}
40+
41+
func TestCheckToolCall_shellSafe_gatesRm(t *testing.T) {
42+
pol, err := spec.BuildPreset(spec.PresetShellSafe)
43+
if err != nil {
44+
t.Fatal(err)
45+
}
46+
ev := NewEvaluator(shellSafeGraph(), &pol)
47+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
48+
Uses: "tool.shell.command.run",
49+
With: map[string]any{"command": "rm -rf /tmp/x"},
50+
})
51+
if err == nil {
52+
t.Fatal("expected rm to require approval")
53+
}
54+
d, ok := AsDenied(err)
55+
if !ok || d.Reason != ReasonApprovalRequired {
56+
t.Fatalf("got %v", err)
57+
}
58+
}
59+
60+
func TestCheckToolCall_shellSafe_unknownTokenGated(t *testing.T) {
61+
pol, err := spec.BuildPreset(spec.PresetShellSafe)
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
ev := NewEvaluator(shellSafeGraph(), &pol)
66+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
67+
Uses: "tool.shell.command.run",
68+
With: map[string]any{"command": "totally-unknown"},
69+
})
70+
if err == nil {
71+
t.Fatal("expected unknown token to gate")
72+
}
73+
}
74+
75+
func TestCheckToolCall_strict_gatesAllTools(t *testing.T) {
76+
g := testGraphWithTools("helper")
77+
g.Tools["helper"].Spec.Safety = &spec.ToolSafety{SideEffects: spec.BoolPtr(false)}
78+
pol, err := spec.BuildPreset(spec.PresetStrict)
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
ev := NewEvaluator(g, &pol)
83+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
84+
Uses: "tool.helper.echo",
85+
})
86+
if err == nil {
87+
t.Fatal("strict should gate all tools")
88+
}
89+
}
90+
91+
func TestCheckToolCall_permissive_allowsMutatingTool(t *testing.T) {
92+
g := testGraphWithTools("slack")
93+
g.Tools["slack"].Spec.Safety = &spec.ToolSafety{SideEffects: spec.BoolPtr(true)}
94+
pol, err := spec.BuildPreset(spec.PresetPermissive)
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
ev := NewEvaluator(g, &pol)
99+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
100+
Uses: "tool.slack.message.send",
101+
})
102+
if err != nil {
103+
t.Fatalf("permissive should allow: %v", err)
104+
}
105+
}
106+
107+
func TestExpandPresetsInGraph_materializesDefault(t *testing.T) {
108+
g := &spec.ProjectGraph{
109+
Spec: spec.ProjectSpec{
110+
Defaults: &spec.ProjectDefaults{Policy: spec.PresetShellSafe},
111+
},
112+
}
113+
spec.ExpandPresetsInGraph(g)
114+
pr, ok := g.Policies[spec.PresetShellSafe]
115+
if !ok || pr == nil {
116+
t.Fatal("expected injected shell_safe policy")
117+
}
118+
if pr.Spec.ResolvedPreset != spec.PresetShellSafe {
119+
t.Fatalf("ResolvedPreset = %q", pr.Spec.ResolvedPreset)
120+
}
121+
}
122+
123+
func TestExpandPresetsInGraph_userPolicyOverridesBuiltin(t *testing.T) {
124+
g := &spec.ProjectGraph{
125+
Spec: spec.ProjectSpec{
126+
Defaults: &spec.ProjectDefaults{Policy: spec.PresetStrict},
127+
},
128+
Policies: map[string]*spec.PolicyResource{
129+
spec.PresetStrict: {
130+
Metadata: spec.Metadata{Name: spec.PresetStrict},
131+
Spec: spec.PolicySpec{
132+
Execution: &spec.PolicyExecution{MaxWallClockSeconds: 99},
133+
},
134+
},
135+
},
136+
}
137+
spec.ExpandPresetsInGraph(g)
138+
if g.Policies[spec.PresetStrict].Spec.Execution.MaxWallClockSeconds != 99 {
139+
t.Fatal("user policy should not be replaced by builtin")
140+
}
141+
}

internal/policy/shell_safe.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package policy
2+
3+
import (
4+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
5+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools"
6+
)
7+
8+
// shellSafeRequiresApproval reports whether a tool call should be gated under shell_safe.
9+
func shellSafeRequiresApproval(graph *spec.ProjectGraph, call ToolCallContext) bool {
10+
toolName, operation, err := tools.ParseUses(call.Uses)
11+
if err != nil {
12+
return true
13+
}
14+
if spec.IsShellCommandOperation(operation) {
15+
cmd := spec.ExtractShellCommand(call.With)
16+
token := spec.FirstShellToken(cmd)
17+
switch spec.ClassifyShellToken(token) {
18+
case spec.ShellTokenReadOnly:
19+
return false
20+
case spec.ShellTokenGate, spec.ShellTokenUnknown:
21+
return true
22+
}
23+
}
24+
safety := resolvedSafetyForTool(graph, toolName)
25+
if safety.RequiresApproval || safety.SideEffects {
26+
return true
27+
}
28+
return false
29+
}

internal/spec/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,7 @@
66
//
77
// Tool resources may declare spec.safety (trusted, sideEffects, requiresApproval) for
88
// fail-closed policy derivation when explicit Policy rules do not apply (issue #103).
9+
//
10+
// Built-in policy presets (strict, permissive, shell_safe) are defined in this package and
11+
// expanded during [NormalizeProjectGraph] via [ExpandPresetsInGraph] (issue #104).
912
package spec

0 commit comments

Comments
 (0)