Skip to content

Commit 46b4467

Browse files
leo-aa88cursoragent
andcommitted
fix(policy): shell_safe requiredFor layering and plan preset path (#126)
Combine shell_safe token gating with explicit approvals.requiredFor at runtime; move EffectiveToolDecision shell_safe check outside Approvals guard; drop unreachable requiresToolCallApproval branch. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 40b9535 commit 46b4467

5 files changed

Lines changed: 55 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ 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.
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]; `strict`/`permissive` materialize approval flags, while `shell_safe` sets `ResolvedPreset` and relies on runtime token classification plus tool safety metadata for plan risk.
1212
- **`shell_safe` token classification** for native `command.run` / `run` / `exec` / `shell` operations: read-only first tokens (`ls`, `cat`, …) run unattended when the command contains no shell metacharacters (`;|&$`, newlines, `` ` ``, `$(…)`); risky tokens, unknown tokens, and side-effecting non-shell tools require `--approve`. **Heuristic only — not a sandbox.**
1313
- **`spec.safety` on Tool resources** (issue #103): optional `trusted`, `sideEffects`, and `requiresApproval` fields. [NormalizeProjectGraph] materializes fail-closed defaults on load.
1414
- **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`).

internal/cli/init_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"path/filepath"
77
"testing"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
810
)
911

1012
func TestInit_thenValidateSucceeds(t *testing.T) {
@@ -58,8 +60,8 @@ func TestInit_defaultPolicyExpandsShellSafePreset(t *testing.T) {
5860
if !ok || pr == nil {
5961
t.Fatal("expected default policy")
6062
}
61-
if pr.Spec.ResolvedPreset != "shell_safe" {
62-
t.Fatalf("default policy ResolvedPreset = %q want shell_safe", pr.Spec.ResolvedPreset)
63+
if pr.Spec.ResolvedPreset != spec.PresetShellSafe {
64+
t.Fatalf("default policy ResolvedPreset = %q want %s", pr.Spec.ResolvedPreset, spec.PresetShellSafe)
6365
}
6466
}
6567

internal/policy/derive.go

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ 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 && spec.ResolvedPresetName(pol) == spec.PresetShellSafe {
53+
// shell_safe plan risk is tool-granular (conservative); runtime uses per-command classification.
54+
if safety.RequiresApproval || safety.SideEffects {
55+
return ToolDecision{
56+
Decision: DecisionRequireApproval,
57+
Source: SourceExplicitPolicyRule,
58+
Safety: safety,
59+
}
60+
}
61+
}
5262
if pol != nil && pol.Approvals != nil {
5363
if spec.ApprovalPermissive(pol.Approvals) {
5464
return ToolDecision{
@@ -64,17 +74,6 @@ func EffectiveToolDecision(graph *spec.ProjectGraph, pol *spec.PolicySpec, toolN
6474
Safety: safety,
6575
}
6676
}
67-
// shell_safe plan risk is tool-granular (conservative): side-effecting tools flag approval;
68-
// runtime applies per-command token classification via shellSafeRequiresApproval.
69-
if spec.ResolvedPresetName(pol) == spec.PresetShellSafe {
70-
if safety.RequiresApproval || safety.SideEffects {
71-
return ToolDecision{
72-
Decision: DecisionRequireApproval,
73-
Source: SourceExplicitPolicyRule,
74-
Safety: safety,
75-
}
76-
}
77-
}
7877
}
7978
if pol != nil && pol.Approvals != nil {
8079
prefix := toolUsesPrefix(toolName)

internal/policy/evaluator.go

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ func (e *evaluator) CheckToolCall(ctx context.Context, call ToolCallContext) err
6464
}
6565
switch {
6666
case p != nil && spec.ResolvedPresetName(p) == spec.PresetShellSafe:
67-
if shellSafeRequiresApproval(e.graph, call) {
67+
needApproval := shellSafeRequiresApproval(e.graph, call) || approvalRequired(call.Uses, p.Approvals)
68+
if needApproval {
6869
if actionApproved(call.Uses, call.Run.ApprovedActions) {
6970
return nil
7071
}
@@ -84,19 +85,10 @@ func (e *evaluator) CheckToolCall(ctx context.Context, call ToolCallContext) err
8485
}
8586

8687
func requiresToolCallApproval(graph *spec.ProjectGraph, pol *spec.PolicySpec, call ToolCallContext) bool {
87-
if pol == nil {
88+
if pol == nil || pol.Approvals == nil {
8889
return false
8990
}
90-
if spec.ResolvedPresetName(pol) == spec.PresetShellSafe {
91-
return shellSafeRequiresApproval(graph, call)
92-
}
93-
if pol.Approvals == nil {
94-
return false
95-
}
96-
if spec.ApprovalRequireAllTools(pol.Approvals) {
97-
return true
98-
}
99-
return false
91+
return spec.ApprovalRequireAllTools(pol.Approvals)
10092
}
10193

10294
func toolCallApprovalDenied(call ToolCallContext, pol *spec.PolicySpec) error {

internal/policy/presets_eval_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ func TestCheckToolCall_shellSafe_gatesRm(t *testing.T) {
5757
}
5858
}
5959

60+
func TestCheckToolCall_shellSafe_requiredForLayering(t *testing.T) {
61+
g := testGraphWithTools("helper")
62+
g.Tools["helper"].Spec.Safety = &spec.ToolSafety{SideEffects: spec.BoolPtr(false)}
63+
base, err := spec.BuildPreset(spec.PresetShellSafe)
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
pol := spec.MergePolicySpec(base, spec.PolicySpec{
68+
Approvals: &spec.PolicyApprovals{
69+
RequiredFor: []string{"tool.helper.echo"},
70+
},
71+
})
72+
ev := NewEvaluator(g, &pol)
73+
err = ev.CheckToolCall(context.Background(), ToolCallContext{
74+
Uses: "tool.helper.echo",
75+
})
76+
if err == nil {
77+
t.Fatal("shell_safe with local requiredFor should gate exact uses")
78+
}
79+
}
80+
6081
func TestCheckToolCall_shellSafe_gatesChainedCommand(t *testing.T) {
6182
pol, err := spec.BuildPreset(spec.PresetShellSafe)
6283
if err != nil {
@@ -207,6 +228,21 @@ func TestExpandPresetsInGraph_userPolicyOverridesBuiltin(t *testing.T) {
207228
}
208229
}
209230

231+
func TestEffectiveToolDecision_shellSafe_builtinPresetNoApprovals(t *testing.T) {
232+
g := shellSafeGraph()
233+
pol, err := spec.BuildPreset(spec.PresetShellSafe)
234+
if err != nil {
235+
t.Fatal(err)
236+
}
237+
if pol.Approvals != nil {
238+
t.Fatal("builtin shell_safe should not set Approvals")
239+
}
240+
td := EffectiveToolDecision(g, &pol, "shell")
241+
if td.Decision != DecisionRequireApproval {
242+
t.Fatalf("plan should flag side-effecting shell tool via ResolvedPreset: %+v", td)
243+
}
244+
}
245+
210246
func TestEffectiveToolDecision_shellSafe_toolGranularPlan(t *testing.T) {
211247
g := shellSafeGraph()
212248
pol, err := spec.BuildPreset(spec.PresetShellSafe)

0 commit comments

Comments
 (0)