diff --git a/agent-schema.json b/agent-schema.json index 731c133a5c..c5d26db120 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -2199,7 +2199,7 @@ }, "safer": { "type": "boolean", - "description": "Opt in to destructive-command detection for the shell toolset (only valid for type 'shell'). When enabled, the agent auto-registers the safer_shell builtin under the pre_tool_use hook event with preempt_yolo:true. Destructive commands (rm -rf, docker volume rm, mkfs, ...) get an Ask verdict with a blast-radius classification; known-safe reads (ls, git status, docker ps, ...) flow through silently; everything else asks with blast_radius=unknown so the user sees the prompt before --yolo or permission allow-rules can auto-approve it. Default false." + "description": "Opt in to the safer_shell classifier for the shell toolset (only valid for type 'shell'). When enabled, the agent auto-registers the safer_shell builtin under pre_tool_use with preempt_yolo:true. Verdict adapts to the session's SafetyPolicy (unsafe / safer / safe-auto / strict). See docs/configuration/hooks/index.md for the per-policy behaviour matrix. Default false." }, "url": { "type": "string", diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md index 05454ec943..fa73663bf9 100644 --- a/docs/configuration/hooks/index.md +++ b/docs/configuration/hooks/index.md @@ -217,7 +217,7 @@ Built-ins are typically zero-config and faster than equivalent shell hooks becau | `snapshot` | `session_start`, `turn_start`, `turn_end`, `pre_tool_use`, `post_tool_use`, `session_end` | _none_ | Records filesystem snapshots in a shadow git repo under the docker-agent data directory. No-op outside git repos; respects the source repo's ignore rules and skips newly-added files larger than 2 MiB. | | `redact_secrets` | `pre_tool_use`, `before_llm_call`, `tool_response_transform` | _none_ | Scrubs detected secrets (API keys, tokens, private keys, …) out of tool call arguments, outgoing chat content, and tool output. The same builtin handles all three events and dispatches on the event name. Auto-registered on all three events by `redact_secrets: true` on the agent — see [`examples/redact_secrets_hooks.yaml`](https://github.com/docker/docker-agent/blob/main/examples/redact_secrets_hooks.yaml) for the manual wiring. | | `limit_large_tool_results` | `tool_response_transform`, `session_end` | _none_ | **Always-on safety hook** — automatically injected by the runtime, no configuration required. When a tool result from the `filesystem`, `shell`, `mcp`, or `a2a` categories exceeds 2,000 lines or 50 KiB, the full payload is written to a per-session temp file and replaced in the conversation with a notice plus a bounded tail (last 2,000 lines, up to 50 KiB). The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. | -| `safer_shell` | `pre_tool_use` (with `preempt_yolo: true`) | _none_ | Classifies shell commands against an embedded taxonomy. Destructive matches (rm -rf, docker volume rm, mkfs, …) get an Ask verdict with `blast_radius` / `category` metadata; known-safe reads (ls, git status, docker ps, …) flow through silently; everything else asks with `blast_radius=unknown`. Filters by tool name internally (no-op for non-shell calls). Registered with `preempt_yolo: true` so the entry fires before `Decide()` / `--yolo`. Auto-registered by `safer: true` on a shell toolset — see [`examples/shell_safer.yaml`](https://github.com/docker/docker-agent/blob/main/examples/shell_safer.yaml). | +| `safer_shell` | `pre_tool_use` (with `preempt_yolo: true`) | `[""]` (optional pin) | Classifies shell commands against an embedded taxonomy. Verdict adapts to the session's `SafetyPolicy`. `unsafe`: silent. `safer`: destructive matches Ask with `blast_radius`/`category` metadata; safe and unknown flow through silently. `safe-auto`: safe matches auto-Allow with `blast_radius=safe`; destructive and unknown Ask. `strict` (default): safe, destructive, and unknown all Ask with `blast_radius` metadata (safe/low/medium/high/unknown). Filters by tool name internally (no-op for non-shell calls). Auto-registered by `safer: true` on a shell toolset — see [`examples/shell_safer.yaml`](https://github.com/docker/docker-agent/blob/main/examples/shell_safer.yaml). | | `unload` | `on_agent_switch` | _none_ | POSTs `{"model": ""}` to each of the previous agent's DMR model endpoints (`/_unload` by default, overridable per-model via `unload_api`) to free the GPU/RAM the just-departing model was holding. Pure HTTP — reads the model snapshot the runtime ships on `on_agent_switch` and depends on no provider-specific runtime state. Non-DMR providers (OpenAI, Anthropic, …) are silently skipped, so cross-provider chains are safe. Errors are logged and swallowed; agent switching never blocks on a slow or unreachable engine (each call has a 10 s timeout). See [`examples/unload_on_switch.yaml`](https://github.com/docker/docker-agent/blob/main/examples/unload_on_switch.yaml). | > [!NOTE] @@ -350,7 +350,7 @@ Notes: - For [harness agents](../../features/harnesses/index.md), `cost` is the harness's own reported total for the call rather than a computed price, and is present only when the harness reported a non-zero cost (some harnesses, e.g. `codex`, report token counts but no cost — those turns carry `usage` with `cost` absent, even though the recorded message stores `0`). - `after_llm_call` fires for **every** model call, including calls made inside sub-sessions (transferred tasks, background agents, skills). For those, `session_id` is the sub-session's id. Summing `cost` across `after_llm_call` events therefore captures **all** spend, including sub-sessions (and even sub-sessions that error before their cost is persisted). Do **not** add a separately-queried session cost total on top: the runtime's own total already recurses into and includes completed sub-session spend, so combining the two double-counts. Pick one source — the summed hook costs — as the authoritative ledger. - `context_limit` is `0` when the model definition is unavailable (treat `0` as "unknown", not as a real limit). -- `approval_decision` is one of `allow`, `deny`, `canceled`. `approval_source` is a stable classifier of which step decided (e.g. `yolo`, `session_permissions_allow`, `session_permissions_deny`, `team_permissions_allow`, `team_permissions_deny`, `pre_tool_use_hook_allow`, `pre_tool_use_hook_deny`, `readonly_hint`, `user_approved`, `user_approved_session`, `user_approved_tool`, `user_rejected`, `context_canceled`). +- `approval_decision` is one of `allow`, `deny`, `canceled`. `approval_source` is a stable classifier of which step decided (e.g. `yolo`, `session_permissions_allow`, `session_permissions_deny`, `team_permissions_allow`, `team_permissions_deny`, `pre_tool_use_hook_allow`, `pre_tool_use_hook_deny`, `readonly_hint`, `user_approved`, `user_approved_session`, `user_approved_safe`, `user_approved_tool`, `user_rejected`, `context_canceled`). ## Hook Output diff --git a/examples/shell_safer.yaml b/examples/shell_safer.yaml index 4c40a2106c..1893220dfe 100644 --- a/examples/shell_safer.yaml +++ b/examples/shell_safer.yaml @@ -1,16 +1,14 @@ agents: root: model: anthropic/claude-haiku-4-5 - description: Shell agent with safer mode; enable snapshots in user config + description: Shell agent with the safer_shell classifier enabled welcome_message: | - Shell safer mode is enabled. To capture snapshots for /undo, enable `settings.snapshot: true` in ~/.config/cagent/config.yaml. + The safer_shell classifier is enabled. Its verdict follows the + session's SafetyPolicy (unsafe / safer / safe-auto / strict, set + by the caller — CLI `--yolo` maps to unsafe, the API accepts + `safety_policy` on session create, and users can opt into + `safe-auto` from a confirmation prompt). instruction: Use the shell tool to run the command the user asks for. toolsets: - type: shell - hooks: - pre_tool_use: - - matcher: "*" - preempt_yolo: true - hooks: - - type: builtin - command: safer_shell + safer: true diff --git a/pkg/api/types.go b/pkg/api/types.go index a0e772031d..b117b3a61c 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -147,6 +147,7 @@ type SessionResponse struct { Messages []session.Message `json:"messages,omitempty"` CreatedAt time.Time `json:"created_at"` ToolsApproved bool `json:"tools_approved"` + SafetyPolicy session.SafetyPolicy `json:"safety_policy,omitempty"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` WorkingDir string `json:"working_dir,omitempty"` @@ -158,6 +159,12 @@ type UpdateSessionPermissionsRequest struct { Permissions *session.PermissionsConfig `json:"permissions"` } +// UpdateSessionSafetyPolicyRequest represents a request to change a +// session's SafetyPolicy mid-session. +type UpdateSessionSafetyPolicyRequest struct { + SafetyPolicy session.SafetyPolicy `json:"safety_policy"` +} + // ResumeSessionRequest represents a request to resume a session type ResumeSessionRequest struct { Confirmation string `json:"confirmation"` @@ -335,6 +342,7 @@ type SessionSnapshotResponse struct { WorkingDir string `json:"working_dir,omitempty"` Messages []session.Message `json:"messages"` ToolsApproved bool `json:"tools_approved"` + SafetyPolicy session.SafetyPolicy `json:"safety_policy,omitempty"` Permissions *session.PermissionsConfig `json:"permissions,omitempty"` InputTokens int64 `json:"input_tokens"` OutputTokens int64 `json:"output_tokens"` diff --git a/pkg/hooks/builtins/safer_shell.go b/pkg/hooks/builtins/safer_shell.go index d7192fcbdf..cd764dbf98 100644 --- a/pkg/hooks/builtins/safer_shell.go +++ b/pkg/hooks/builtins/safer_shell.go @@ -1,24 +1,16 @@ package builtins -// safer_shell classifies shell tool calls and adapts its verdict to -// the session's SafetyPolicy (read from hooks.Input.SafetyPolicy). -// Registered on pre_tool_use with preempt_yolo:true so it runs before -// Decide()/--yolo. +// safer_shell classifies shell tool calls against a taxonomy and +// adapts its verdict to hooks.Input.SafetyPolicy. Registered on +// pre_tool_use with preempt_yolo:true so it runs before --yolo. // -// Modes: -// unsafe — silent; returns nil (restores --yolo semantics). -// safer — destructive matches ask; safe-list and unknown fall through. -// strict — destructive matches ask; safe-list returns nil; unknown -// asks with blast_radius=unknown. Empty SafetyPolicy is -// treated as strict. +// Per-policy verdict: +// unsafe — silent. +// safer — destructive ask; safe/unknown silent. +// safe-auto — safe allow; destructive/unknown ask. +// strict — safe/destructive/unknown all ask (with metadata). // -// The legacy --yolo boolean is backfilled to SafetyPolicy=unsafe by -// pkg/session.WithToolsApproved so existing callers stay silent. -// -// Compound shell (a && b, a; b, a | b) skips the safe-list and falls -// through to the destructive scan. The matcher is regex-based on a -// whitespace-normalised form of the command; `` matches -// one non-whitespace token, `...` matches any tail. +// Compound shell (a && b, a; b, a | b) skips the safe-list. import ( "context" @@ -164,7 +156,7 @@ func saferShell(_ context.Context, in *hooks.Input, args []string) (*hooks.Outpu patterns, err := loadSafetyPatterns() if err != nil { - return askWithMetadata("unknown", "", "Safety pattern load failed: "+err.Error()), nil + return askWithMetadata(radiusUnknown, "", "Safety pattern load failed: "+err.Error()), nil } if command != "" { @@ -172,24 +164,38 @@ func saferShell(_ context.Context, in *hooks.Input, args []string) (*hooks.Outpu return askWithMetadata(match.BlastRadius, match.Category, "Command matches destructive operation: "+match.Pattern), nil } - if matchesSafe(command, patterns.safe) { - return nil, nil + if match := bestSafeMatch(command, patterns.safe); match != nil { + reason := "Command matches safe read-only pattern: " + match.Pattern + switch policy { + case policySafer: + return nil, nil + case policySafeAuto: + return allowWithMetadata(radiusSafe, match.Category, reason), nil + default: + return askWithMetadata(radiusSafe, match.Category, reason), nil + } } } - // Unknown command: safer defers, strict asks. + // Unknown command: safer defers, strict / safe-auto ask. if policy == policySafer { return nil, nil } - return askWithMetadata("unknown", "", + return askWithMetadata(radiusUnknown, "", "Shell command requires safer-mode confirmation."), nil } // Mirrors pkg/session.SafetyPolicy strings; the hooks package must // stay free of a session dependency. const ( - policyUnsafe = "unsafe" - policySafer = "safer" - policyStrict = "strict" + policyUnsafe = "unsafe" + policySafer = "safer" + policySafeAuto = "safe-auto" + policyStrict = "strict" +) + +const ( + radiusSafe = "safe" + radiusUnknown = "unknown" ) // effectiveSafetyPolicy picks the policy for one invocation. @@ -199,12 +205,12 @@ const ( func effectiveSafetyPolicy(sessionPolicy string, args []string) string { if len(args) > 0 { switch args[0] { - case policyUnsafe, policySafer, policyStrict: + case policyUnsafe, policySafer, policySafeAuto, policyStrict: return args[0] } } switch sessionPolicy { - case policyUnsafe, policySafer, policyStrict: + case policyUnsafe, policySafer, policySafeAuto, policyStrict: return sessionPolicy default: return policyStrict @@ -239,20 +245,20 @@ func bestDestructiveMatch(command string, patterns []safetyPattern) *safetyPatte return best } -func matchesSafe(command string, patterns []safePattern) bool { +// bestSafeMatch returns the first matching safe-list pattern, or nil. +// Refuses to match compound shell (approximated via separator tokens) +// so `ls && rm -rf ~` doesn't inherit `ls`'s safe verdict. +func bestSafeMatch(command string, patterns []safePattern) *safePattern { normalized := normalizeCommand(command) - // Compound shell falls through to ask — refuse to safe-match anything - // that smells like a pipeline or chain. The matcher does not parse - // shell syntax, so we approximate via separator tokens. if containsShellSeparator(normalized) { - return false + return nil } for i := range patterns { if patterns[i].regexp.MatchString(normalized) { - return true + return &patterns[i] } } - return false + return nil } // containsShellSeparator returns true when the normalised command @@ -270,6 +276,14 @@ func containsShellSeparator(command string) bool { } func askWithMetadata(blastRadius, category, reason string) *hooks.Output { + return verdictWithMetadata(hooks.DecisionAsk, blastRadius, category, reason) +} + +func allowWithMetadata(blastRadius, category, reason string) *hooks.Output { + return verdictWithMetadata(hooks.DecisionAllow, blastRadius, category, reason) +} + +func verdictWithMetadata(decision hooks.Decision, blastRadius, category, reason string) *hooks.Output { meta := map[string]string{ metaBlastRadius: blastRadius, metaReason: reason, @@ -280,7 +294,7 @@ func askWithMetadata(blastRadius, category, reason string) *hooks.Output { return &hooks.Output{ HookSpecificOutput: &hooks.HookSpecificOutput{ HookEventName: hooks.EventPreToolUse, - PermissionDecision: hooks.DecisionAsk, + PermissionDecision: decision, PermissionDecisionReason: reason, Metadata: meta, }, diff --git a/pkg/hooks/builtins/safer_shell_test.go b/pkg/hooks/builtins/safer_shell_test.go index 3d7af8456d..650ecb912d 100644 --- a/pkg/hooks/builtins/safer_shell_test.go +++ b/pkg/hooks/builtins/safer_shell_test.go @@ -52,13 +52,10 @@ func TestSaferShell_MatchesDestructivePatterns(t *testing.T) { } } -// TestSaferShell_SafeCommandsBypassPrompt pins the safe-allowlist -// contract: each fixture is a known-safe read that should return nil -// (no opinion → fall through to the regular approval pipeline). -// Without the safe list, every one of these would trigger an -// unknown-radius prompt — that's the prompt-fatigue trap the safe -// list exists to avoid. -func TestSaferShell_SafeCommandsBypassPrompt(t *testing.T) { +// Under strict (default), safe-list matches Ask with blast_radius=safe +// so the UI can render a SAFE chip. The verdict is still Ask — safe +// metadata is decoration, not a bypass. +func TestSaferShell_SafeCommandsUnderStrictAskWithSafeRadius(t *testing.T) { t.Parallel() safeCases := []string{ @@ -122,8 +119,13 @@ func TestSaferShell_SafeCommandsBypassPrompt(t *testing.T) { ToolName: shellToolName, ToolInput: map[string]any{"cmd": cmd}, }, nil) - require.NoError(t, err) - assert.Nil(t, out, "safe command %q must produce no verdict (no opinion); got %+v", cmd, out) + require.NoError(t, err, "safe command %q must produce a verdict", cmd) + require.NotNil(t, out, "safe command %q must produce a verdict under strict", cmd) + require.NotNil(t, out.HookSpecificOutput) + assert.Equal(t, hooks.DecisionAsk, out.HookSpecificOutput.PermissionDecision, + "strict must still ask; the safe verdict is UI decoration, not a bypass") + assert.Equal(t, radiusSafe, out.HookSpecificOutput.Metadata[metaBlastRadius]) + assert.Contains(t, out.HookSpecificOutput.PermissionDecisionReason, "safe read-only pattern") }) } } @@ -287,9 +289,6 @@ func TestSaferShell_ApplyAgentDefaultsAutoInjectsBuiltin(t *testing.T) { assert.Equal(t, SaferShell, mc.Hooks[0].Command) } -// Per-policy classifier behaviour matrix. Docker Desktop flips -// SafetyPolicy per-session; the classifier MUST follow without any -// other code changing. func TestSaferShell_PolicyMatrix(t *testing.T) { const destructive = "rm -rf /tmp/x" const safe = "docker ps" @@ -297,6 +296,7 @@ func TestSaferShell_PolicyMatrix(t *testing.T) { type want struct { emit bool + decision hooks.Decision blastRadius string } @@ -306,25 +306,26 @@ func TestSaferShell_PolicyMatrix(t *testing.T) { cmd string want want }{ - // unsafe: silent (restores --yolo semantics). {"unsafe + destructive → silent", policyUnsafe, destructive, want{emit: false}}, {"unsafe + safe → silent", policyUnsafe, safe, want{emit: false}}, {"unsafe + unknown → silent", policyUnsafe, unknown, want{emit: false}}, - // safer: only classified destructive gates. - {"safer + destructive → ask (high)", policySafer, destructive, want{emit: true, blastRadius: "high"}}, + {"safer + destructive → ask (high)", policySafer, destructive, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "high"}}, {"safer + safe → silent", policySafer, safe, want{emit: false}}, - {"safer + unknown → silent (no actionable opinion)", policySafer, unknown, want{emit: false}}, + {"safer + unknown → silent", policySafer, unknown, want{emit: false}}, + + {"safe-auto + destructive → ask (high)", policySafeAuto, destructive, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "high"}}, + {"safe-auto + safe → allow (safe)", policySafeAuto, safe, want{emit: true, decision: hooks.DecisionAllow, blastRadius: "safe"}}, + {"safe-auto + unknown → ask (unknown)", policySafeAuto, unknown, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "unknown"}}, - // strict: destructive + unknown gate; matches PR #3273. - {"strict + destructive → ask (high)", policyStrict, destructive, want{emit: true, blastRadius: "high"}}, - {"strict + safe → silent", policyStrict, safe, want{emit: false}}, - {"strict + unknown → ask (unknown)", policyStrict, unknown, want{emit: true, blastRadius: "unknown"}}, + {"strict + destructive → ask (high)", policyStrict, destructive, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "high"}}, + {"strict + safe → ask (safe)", policyStrict, safe, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "safe"}}, + {"strict + unknown → ask (unknown)", policyStrict, unknown, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "unknown"}}, - // empty / unrecognised → strict (forward-compat fallback). - {"empty + destructive → ask (high)", "", destructive, want{emit: true, blastRadius: "high"}}, - {"empty + unknown → ask (unknown)", "", unknown, want{emit: true, blastRadius: "unknown"}}, - {"unrecognised policy + destructive → ask (strict fallback)", "future", destructive, want{emit: true, blastRadius: "high"}}, + {"empty + destructive → ask (high)", "", destructive, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "high"}}, + {"empty + safe → ask (safe)", "", safe, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "safe"}}, + {"empty + unknown → ask (unknown)", "", unknown, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "unknown"}}, + {"unrecognised policy → strict fallback", "future", destructive, want{emit: true, decision: hooks.DecisionAsk, blastRadius: "high"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -341,17 +342,14 @@ func TestSaferShell_PolicyMatrix(t *testing.T) { } require.NotNil(t, out, "policy=%q cmd=%q must produce a verdict", tc.policy, tc.cmd) require.NotNil(t, out.HookSpecificOutput) - assert.Equal(t, hooks.DecisionAsk, out.HookSpecificOutput.PermissionDecision) + assert.Equal(t, tc.want.decision, out.HookSpecificOutput.PermissionDecision) assert.Equal(t, tc.want.blastRadius, out.HookSpecificOutput.Metadata[metaBlastRadius]) }) } } -// YAML args override: `args: ["safer"|"strict"|"unsafe"]` on a hook -// entry forces the classifier's mode regardless of session policy. -// Enables eval harnesses (which hardcode --yolo/unsafe) to exercise -// safer-mode gating. Precedence: args[0] > Input.SafetyPolicy > strict. -// Unrecognised args fall through to the session (typo tolerance). +// args[0] pins the mode regardless of Input.SafetyPolicy; +// unrecognised args fall through to the session policy. func TestSaferShell_ArgsOverrideSessionPolicy(t *testing.T) { cases := []struct { name string @@ -383,6 +381,30 @@ func TestSaferShell_ArgsOverrideSessionPolicy(t *testing.T) { cmd: "docker ps", wantEmit: false, }, + { + name: "args=safe-auto overrides unsafe session: safe allow", + sessionPolicy: policyUnsafe, + args: []string{"safe-auto"}, + cmd: "docker ps", + wantEmit: true, + wantRadius: "safe", + }, + { + name: "args=safe-auto overrides unsafe session: destructive ask", + sessionPolicy: policyUnsafe, + args: []string{"safe-auto"}, + cmd: "rm -rf /tmp/x", + wantEmit: true, + wantRadius: "high", + }, + { + name: "args=safe-auto overrides unsafe session: unknown ask", + sessionPolicy: policyUnsafe, + args: []string{"safe-auto"}, + cmd: "myproject-cli deploy", + wantEmit: true, + wantRadius: "unknown", + }, { name: "args=strict overrides safer session: unknown now gates", sessionPolicy: policySafer, @@ -437,15 +459,17 @@ func TestSaferShell_ArgsOverrideSessionPolicy(t *testing.T) { } require.NotNil(t, out) require.NotNil(t, out.HookSpecificOutput) - assert.Equal(t, hooks.DecisionAsk, out.HookSpecificOutput.PermissionDecision) + wantDecision := hooks.DecisionAsk + if tc.wantRadius == "safe" { + wantDecision = hooks.DecisionAllow + } + assert.Equal(t, wantDecision, out.HookSpecificOutput.PermissionDecision) assert.Equal(t, tc.wantRadius, out.HookSpecificOutput.Metadata[metaBlastRadius]) }) } } -// unsafe short-circuits before the taxonomy loader — the one -// exception to fail-closed on load failure. The embedded JSON can't -// be corrupted from a test, so this pins the early-return path. +// unsafe short-circuits before the taxonomy loader is called. func TestSaferShell_UnsafeReturnsBeforeTaxonomyLoad(t *testing.T) { out, err := saferShell(t.Context(), &hooks.Input{ HookEventName: hooks.EventPreToolUse, diff --git a/pkg/runtime/hooks.go b/pkg/runtime/hooks.go index 501d4d0b41..990f9da1f9 100644 --- a/pkg/runtime/hooks.go +++ b/pkg/runtime/hooks.go @@ -450,6 +450,8 @@ const ( ApprovalSourceReadOnlyHint = "readonly_hint" ApprovalSourceUserApproved = "user_approved" ApprovalSourceUserApprovedSession = "user_approved_session" + ApprovalSourceUserApprovedSafe = "user_approved_safe" + ApprovalSourceUserApprovedSafer = "user_approved_safer" ApprovalSourceUserApprovedTool = "user_approved_tool" ApprovalSourceUserRejected = "user_rejected" ApprovalSourceContextCanceled = "context_canceled" diff --git a/pkg/runtime/resume.go b/pkg/runtime/resume.go index 3e159acaae..e7f318a373 100644 --- a/pkg/runtime/resume.go +++ b/pkg/runtime/resume.go @@ -19,6 +19,14 @@ const ( // ResumeTypeApproveSession approves the pending tool call and every // subsequent permission-gated call for the rest of the session. ResumeTypeApproveSession = toolexec.ResumeTypeApproveSession + // ResumeTypeApproveSafe approves the pending call and flips the + // session to SafetyPolicySafeAuto so subsequent classifier-verified + // safe calls auto-approve. + ResumeTypeApproveSafe = toolexec.ResumeTypeApproveSafe + // ResumeTypeApproveSafer approves the pending call and flips the + // session to SafetyPolicySafer so subsequent non-destructive calls + // auto-approve (superset of safe-auto — includes unclassified). + ResumeTypeApproveSafer = toolexec.ResumeTypeApproveSafer // ResumeTypeApproveTool approves the pending call and every future // call to the same tool name within the session. ResumeTypeApproveTool = toolexec.ResumeTypeApproveTool @@ -42,6 +50,18 @@ func ResumeApproveSession() ResumeRequest { return ResumeRequest{Type: ResumeTypeApproveSession} } +// ResumeApproveSafe creates a ResumeRequest that approves the pending +// call and flips the session to SafetyPolicySafeAuto. +func ResumeApproveSafe() ResumeRequest { + return ResumeRequest{Type: ResumeTypeApproveSafe} +} + +// ResumeApproveSafer creates a ResumeRequest that approves the pending +// call and flips the session to SafetyPolicySafer. +func ResumeApproveSafer() ResumeRequest { + return ResumeRequest{Type: ResumeTypeApproveSafer} +} + // ResumeApproveTool creates a ResumeRequest to always approve a specific tool for the session. func ResumeApproveTool(toolName string) ResumeRequest { return ResumeRequest{Type: ResumeTypeApproveTool, ToolName: toolName} @@ -62,6 +82,8 @@ func IsValidResumeType(t ResumeType) bool { switch t { case ResumeTypeApprove, ResumeTypeApproveSession, + ResumeTypeApproveSafe, + ResumeTypeApproveSafer, ResumeTypeApproveTool, ResumeTypeReject: return true @@ -75,6 +97,8 @@ func ValidResumeTypes() []ResumeType { return []ResumeType{ ResumeTypeApprove, ResumeTypeApproveSession, + ResumeTypeApproveSafe, + ResumeTypeApproveSafer, ResumeTypeApproveTool, ResumeTypeReject, } diff --git a/pkg/runtime/resume_test.go b/pkg/runtime/resume_test.go index f373bb91e1..7e78b3bf3c 100644 --- a/pkg/runtime/resume_test.go +++ b/pkg/runtime/resume_test.go @@ -16,6 +16,8 @@ func TestIsValidResumeType(t *testing.T) { }{ {"approve", ResumeTypeApprove, true}, {"approve-session", ResumeTypeApproveSession, true}, + {"approve-safe", ResumeTypeApproveSafe, true}, + {"approve-safer", ResumeTypeApproveSafer, true}, {"approve-tool", ResumeTypeApproveTool, true}, {"reject", ResumeTypeReject, true}, {"empty", ResumeType(""), false}, @@ -40,10 +42,11 @@ func TestValidResumeTypes(t *testing.T) { assert.Truef(t, IsValidResumeType(rt), "ValidResumeTypes() returned %q which IsValidResumeType rejects", rt) } - // And the canonical four must all be present. assert.ElementsMatch(t, []ResumeType{ ResumeTypeApprove, ResumeTypeApproveSession, + ResumeTypeApproveSafe, + ResumeTypeApproveSafer, ResumeTypeApproveTool, ResumeTypeReject, }, got) @@ -68,6 +71,22 @@ func TestResumeApproveHelpers(t *testing.T) { assert.Empty(t, r.ToolName) }) + t.Run("approve-safe", func(t *testing.T) { + t.Parallel() + r := ResumeApproveSafe() + assert.Equal(t, ResumeTypeApproveSafe, r.Type) + assert.Empty(t, r.Reason) + assert.Empty(t, r.ToolName) + }) + + t.Run("approve-safer", func(t *testing.T) { + t.Parallel() + r := ResumeApproveSafer() + assert.Equal(t, ResumeTypeApproveSafer, r.Type) + assert.Empty(t, r.Reason) + assert.Empty(t, r.ToolName) + }) + t.Run("approve-tool", func(t *testing.T) { t.Parallel() r := ResumeApproveTool("read_file") diff --git a/pkg/runtime/toolexec/dispatcher.go b/pkg/runtime/toolexec/dispatcher.go index 190f18c629..b1460c3ff0 100644 --- a/pkg/runtime/toolexec/dispatcher.go +++ b/pkg/runtime/toolexec/dispatcher.go @@ -45,6 +45,8 @@ const ( ApprovalSourceReadOnlyHint = "readonly_hint" ApprovalSourceUserApproved = "user_approved" ApprovalSourceUserApprovedSession = "user_approved_session" + ApprovalSourceUserApprovedSafe = "user_approved_safe" + ApprovalSourceUserApprovedSafer = "user_approved_safer" ApprovalSourceUserApprovedTool = "user_approved_tool" ApprovalSourceUserRejected = "user_rejected" ApprovalSourceContextCanceled = "context_canceled" @@ -136,6 +138,8 @@ type ResumeType string const ( ResumeTypeApprove ResumeType = "approve" ResumeTypeApproveSession ResumeType = "approve-session" + ResumeTypeApproveSafe ResumeType = "approve-safe" + ResumeTypeApproveSafer ResumeType = "approve-safer" ResumeTypeApproveTool ResumeType = "approve-tool" ResumeTypeReject ResumeType = "reject" ) @@ -408,6 +412,14 @@ func (c *call) approveAndRun(ctx context.Context, runTool func() CallOutcome) Ca // DecisionAllow / "" → advisory; fall through to Decide(). } + // safe-auto / safer opt-ins bypass the permissions layer for the + // calls each policy considers auto-approvable. Otherwise + // permissions.ask: "*" would defeat the opt-in. + if source, ok := c.policyAutoApprove(); ok { + c.notifyApproval(ctx, ApprovalDecisionAllow, source) + return runTool() + } + // readOnlyHint is intentionally false here so the pre_tool_use hook // gets a turn before the read-only fast-path applies. decision := c.permissionDecision(false) @@ -509,6 +521,30 @@ func (c *call) sessionPermissionsAllow() bool { return true } +// policyAutoApprove returns (source, true) when the session's safety +// policy allows this call to skip the permissions layer. Under +// safe-auto that requires a positive safe verdict (preempt-yolo Allow +// or ReadOnlyHint). Under safer it just requires the tool not to +// declare itself destructive — destructive preempt-yolo verdicts +// already short-circuited to askUser at Stage 0. +func (c *call) policyAutoApprove() (string, bool) { + switch c.sess.SafetyPolicy { + case session.SafetyPolicySafeAuto: + if c.preYoloResult != nil && c.preYoloResult.Decision == hooks.DecisionAllow { + return ApprovalSourcePreToolUseHookAllow, true + } + if c.tool.Annotations.ReadOnlyHint { + return ApprovalSourceReadOnlyHint, true + } + case session.SafetyPolicySafer: + if c.tool.Annotations.DestructiveHint != nil && *c.tool.Annotations.DestructiveHint { + return "", false + } + return ApprovalSourcePreToolUseHookAllow, true + } + return "", false +} + func (c *call) cancellationMessage(ctx context.Context) string { switch { case errors.Is(context.Cause(ctx), errBatchCanceledByUser): @@ -825,16 +861,32 @@ func (c *call) confirmationMetadata(permissionMeta map[string]string) map[string if c.preYoloResult != nil { safetyMeta = c.preYoloResult.Metadata } - if len(c.tool.Metadata) == 0 && len(permissionMeta) == 0 && len(safetyMeta) == 0 { + annotationMeta := blastRadiusFromAnnotations(c.tool.Annotations) + if len(annotationMeta) == 0 && len(c.tool.Metadata) == 0 && len(permissionMeta) == 0 && len(safetyMeta) == 0 { return nil } - merged := make(map[string]string, len(c.tool.Metadata)+len(permissionMeta)+len(safetyMeta)) + merged := make(map[string]string, len(annotationMeta)+len(c.tool.Metadata)+len(permissionMeta)+len(safetyMeta)) + maps.Copy(merged, annotationMeta) maps.Copy(merged, c.tool.Metadata) maps.Copy(merged, permissionMeta) maps.Copy(merged, safetyMeta) return merged } +// blastRadiusFromAnnotations derives a blast_radius verdict from a tool's +// MCP annotations. ReadOnlyHint → safe; DestructiveHint == true → high. +// Returns nil when neither hint applies so callers keep the empty-map +// short-circuit path. +func blastRadiusFromAnnotations(a tools.ToolAnnotations) map[string]string { + if a.ReadOnlyHint { + return map[string]string{"blast_radius": "safe"} + } + if a.DestructiveHint != nil && *a.DestructiveHint { + return map[string]string{"blast_radius": "high"} + } + return nil +} + // handleResume applies the user's confirmation decision: run the tool // (with optional session/tool-wide approval persistence) or emit a // rejection error response. @@ -849,6 +901,16 @@ func (c *call) handleResume(ctx context.Context, req ResumeRequest, runTool func c.sess.SetToolsApproved(true) c.notifyApproval(ctx, ApprovalDecisionAllow, ApprovalSourceUserApprovedSession) return runTool() + case ResumeTypeApproveSafe: + slog.DebugContext(ctx, "Resume signal received, opting into safe-auto", "tool", c.tc.Function.Name, "session_id", c.sess.ID) + c.sess.SetSafetyPolicy(session.SafetyPolicySafeAuto) + c.notifyApproval(ctx, ApprovalDecisionAllow, ApprovalSourceUserApprovedSafe) + return runTool() + case ResumeTypeApproveSafer: + slog.DebugContext(ctx, "Resume signal received, opting into safer", "tool", c.tc.Function.Name, "session_id", c.sess.ID) + c.sess.SetSafetyPolicy(session.SafetyPolicySafer) + c.notifyApproval(ctx, ApprovalDecisionAllow, ApprovalSourceUserApprovedSafer) + return runTool() case ResumeTypeApproveTool: approvedTool := req.ToolName if approvedTool == "" { diff --git a/pkg/runtime/toolexec/dispatcher_test.go b/pkg/runtime/toolexec/dispatcher_test.go index 7c56a3965e..21d794e652 100644 --- a/pkg/runtime/toolexec/dispatcher_test.go +++ b/pkg/runtime/toolexec/dispatcher_test.go @@ -431,6 +431,40 @@ func TestDispatcher_ResumeRejectEmitsErrorResponseWithReason(t *testing.T) { assert.Contains(t, em.responses[0].Output, "wrong arguments") } +func TestDispatcher_ResumeApproveSafeFlipsSessionToSafeAuto(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New() + + var ran bool + tool := tools.Tool{ + Name: "shell", + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + ran = true + return tools.ResultSuccess("ok"), nil + }, + } + + resume := make(chan toolexec.ResumeRequest, 1) + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Resume: resume, + } + em := &captureEmitter{} + + resume <- toolexec.ResumeRequest{Type: toolexec.ResumeTypeApproveSafe} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "shell", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + assert.True(t, ran, "approve-safe must run the pending tool") + assert.Equal(t, session.SafetyPolicySafeAuto, sess.SafetyPolicy, + "approve-safe must persist safe-auto on the session") + assert.False(t, sess.ToolsApproved, "safe-auto must not backfill ToolsApproved") +} + func TestDispatcher_ResumeApproveToolPersistsToSessionPermissions(t *testing.T) { t.Parallel() a := newAgent() @@ -497,6 +531,223 @@ func TestDispatcher_ReadOnlyHintAutoApproves(t *testing.T) { assert.False(t, em.responses[0].IsError) } +// DestructiveHint on a non-approved tool must surface as +// blast_radius=high in the confirmation event metadata so the UI can +// render a warning tier without duplicating the classification logic. +func TestDispatcher_DestructiveHintEmitsHighBlastRadiusMetadata(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New() + + destructive := true + tool := tools.Tool{ + Name: "delete_task", + Annotations: tools.ToolAnnotations{ + DestructiveHint: &destructive, + }, + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + return tools.ResultSuccess("ok"), nil + }, + } + + resume := make(chan toolexec.ResumeRequest, 1) + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Resume: resume, + } + em := &captureEmitter{} + + resume <- toolexec.ResumeRequest{Type: toolexec.ResumeTypeReject} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "delete_task", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + require.Len(t, em.confirmations, 1) + require.Len(t, em.confirmationMeta, 1) + assert.Equal(t, "high", em.confirmationMeta[0]["blast_radius"]) +} + +// A tool without ReadOnlyHint / DestructiveHint must not leak a +// blast_radius verdict into the confirmation event. +func TestDispatcher_UnannotatedToolEmitsNoBlastRadius(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New() + + tool := tools.Tool{ + Name: "custom_tool", + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + return tools.ResultSuccess("ok"), nil + }, + } + + resume := make(chan toolexec.ResumeRequest, 1) + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Resume: resume, + } + em := &captureEmitter{} + + resume <- toolexec.ResumeRequest{Type: toolexec.ResumeTypeReject} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "custom_tool", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + require.Len(t, em.confirmations, 1) + if len(em.confirmationMeta) > 0 { + _, present := em.confirmationMeta[0]["blast_radius"] + assert.False(t, present, "unannotated tools must not emit blast_radius") + } +} + +// Under safe-auto, a ReadOnlyHint tool must auto-approve even when the +// session-level permissions checker asks for confirmation. Without this, +// callers configuring permissions.ask: "*" defeat the safe-auto opt-in. +func TestDispatcher_SafeAutoBypassesAskForReadOnlyTools(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New(session.WithSafetyPolicy(session.SafetyPolicySafeAuto)) + perms := session.PermissionsConfig{Ask: []string{"*"}} + sess.Permissions = &perms + + var ran bool + tool := tools.Tool{ + Name: "read_file", + Annotations: tools.ToolAnnotations{ + ReadOnlyHint: true, + }, + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + ran = true + return tools.ResultSuccess("contents"), nil + }, + } + + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Permissions: func(s *session.Session) []toolexec.NamedChecker { + return []toolexec.NamedChecker{{ + Checker: permissions.NewCheckerFromRules(nil, s.Permissions.Ask, nil), + Source: "session", + }} + }, + } + em := &captureEmitter{} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "r", + Function: tools.FunctionCall{Name: "read_file", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + assert.True(t, ran, "safe-auto + ReadOnlyHint must auto-approve") + assert.Empty(t, em.confirmations, + "safe-auto + ReadOnlyHint must not prompt the user") +} + +func TestDispatcher_SaferBypassesAskForUnclassifiedTools(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New(session.WithSafetyPolicy(session.SafetyPolicySafer)) + perms := session.PermissionsConfig{Ask: []string{"*"}} + sess.Permissions = &perms + + var ran bool + tool := tools.Tool{ + Name: "custom_tool", + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + ran = true + return tools.ResultSuccess("ok"), nil + }, + } + + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Permissions: func(s *session.Session) []toolexec.NamedChecker { + return []toolexec.NamedChecker{{ + Checker: permissions.NewCheckerFromRules(nil, s.Permissions.Ask, nil), + Source: "session", + }} + }, + } + em := &captureEmitter{} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "custom_tool", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + assert.True(t, ran, "safer + unclassified tool must auto-approve") + assert.Empty(t, em.confirmations) +} + +func TestDispatcher_SaferPromptsForDestructiveHintTools(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New(session.WithSafetyPolicy(session.SafetyPolicySafer)) + + destructive := true + tool := tools.Tool{ + Name: "delete_task", + Annotations: tools.ToolAnnotations{ + DestructiveHint: &destructive, + }, + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + return tools.ResultSuccess("ok"), nil + }, + } + + resume := make(chan toolexec.ResumeRequest, 1) + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Resume: resume, + } + em := &captureEmitter{} + + resume <- toolexec.ResumeRequest{Type: toolexec.ResumeTypeReject} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "delete_task", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + require.Len(t, em.confirmations, 1) +} + +func TestDispatcher_ResumeApproveSaferFlipsSessionToSafer(t *testing.T) { + t.Parallel() + a := newAgent() + sess := session.New() + + var ran bool + tool := tools.Tool{ + Name: "shell", + Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) { + ran = true + return tools.ResultSuccess("ok"), nil + }, + } + + resume := make(chan toolexec.ResumeRequest, 1) + d := &toolexec.Dispatcher{ + AgentFor: func(*session.Session) *agent.Agent { return a }, + Resume: resume, + } + em := &captureEmitter{} + + resume <- toolexec.ResumeRequest{Type: toolexec.ResumeTypeApproveSafer} + + d.Process(t.Context(), sess, []tools.ToolCall{{ + ID: "x", + Function: tools.FunctionCall{Name: "shell", Arguments: "{}"}, + }}, []tools.Tool{tool}, em) + + assert.True(t, ran) + assert.Equal(t, session.SafetyPolicySafer, sess.SafetyPolicy) +} + func TestDispatcher_DenyByPermissionsEmitsErrorResponse(t *testing.T) { t.Parallel() a := newAgent() diff --git a/pkg/server/server.go b/pkg/server/server.go index 88827fd48a..22500e50d8 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -82,6 +82,7 @@ func (s *Server) registerRoutes() { group.GET("/sessions/:id/snapshot", s.getSessionSnapshot) group.POST("/sessions/:id/resume", s.resumeSession) group.POST("/sessions/:id/tools/toggle", s.toggleSessionYolo) + group.PATCH("/sessions/:id/safety-policy", s.updateSessionSafetyPolicy) group.PATCH("/sessions/:id/permissions", s.updateSessionPermissions) group.PATCH("/sessions/:id/title", s.updateSessionTitle) group.PATCH("/sessions/:id/tokens", s.updateSessionTokens) @@ -286,6 +287,7 @@ func (s *Server) forkSession(c echo.Context) error { CreatedAt: forked.CreatedAt, Messages: forked.GetAllMessages(), ToolsApproved: forked.ToolsApproved, + SafetyPolicy: forked.SafetyPolicy, InputTokens: forked.InputTokens, OutputTokens: forked.OutputTokens, WorkingDir: forked.WorkingDir, @@ -307,6 +309,7 @@ func (s *Server) getSession(c echo.Context) error { CreatedAt: sess.CreatedAt, Messages: sess.GetAllMessages(), ToolsApproved: sess.ToolsApproved, + SafetyPolicy: sess.SafetyPolicy, InputTokens: inputTokens, OutputTokens: outputTokens, WorkingDir: sess.WorkingDir, @@ -376,6 +379,17 @@ func (s *Server) toggleSessionYolo(c echo.Context) error { return c.JSON(http.StatusOK, nil) } +func (s *Server) updateSessionSafetyPolicy(c echo.Context) error { + var req api.UpdateSessionSafetyPolicyRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err)) + } + if err := s.sm.SetSessionSafetyPolicy(c.Request().Context(), c.Param("id"), req.SafetyPolicy); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + return c.JSON(http.StatusOK, map[string]string{"message": "session safety policy updated"}) +} + func (s *Server) getAgentToolCount(c echo.Context) error { count, err := s.sm.GetAgentToolCount(c.Request().Context(), c.Param("id"), c.Param("agent_name")) if err != nil { diff --git a/pkg/server/session_manager.go b/pkg/server/session_manager.go index a08125c326..7d421a7830 100644 --- a/pkg/server/session_manager.go +++ b/pkg/server/session_manager.go @@ -354,6 +354,7 @@ func (sm *SessionManager) GetSessionSnapshot(ctx context.Context, id string) (*a WorkingDir: sess.WorkingDir, Messages: sess.GetAllMessages(), ToolsApproved: sess.ToolsApproved, + SafetyPolicy: sess.SafetyPolicy, Permissions: sess.Permissions, InputTokens: inputTokens, OutputTokens: outputTokens, @@ -374,6 +375,9 @@ func (sm *SessionManager) CreateSession(ctx context.Context, sessionTemplate *se session.WithMaxToolResultTokens(sessionTemplate.MaxToolResultTokens), session.WithToolsApproved(sessionTemplate.ToolsApproved), ) + if sessionTemplate.SafetyPolicy != "" { + opts = append(opts, session.WithSafetyPolicy(sessionTemplate.SafetyPolicy)) + } // Carry a caller-supplied title (from the POST /api/sessions request body) // into the new session. When set, RunSession's needsTitle check skips the @@ -956,6 +960,21 @@ func (sm *SessionManager) ToggleToolApproval(ctx context.Context, sessionID stri return sm.sessionStore.UpdateSession(ctx, sess) } +// SetSessionSafetyPolicy updates the SafetyPolicy for a session. +func (sm *SessionManager) SetSessionSafetyPolicy(ctx context.Context, sessionID string, policy session.SafetyPolicy) error { + if !policy.IsValid() { + return fmt.Errorf("invalid safety_policy: %q", policy) + } + sm.mux.Lock() + defer sm.mux.Unlock() + sess, err := sm.sessionStore.GetSession(ctx, sessionID) + if err != nil { + return err + } + sess.SetSafetyPolicy(policy) + return sm.sessionStore.UpdateSession(ctx, sess) +} + // UpdateSessionPermissions updates the permissions for a session. func (sm *SessionManager) UpdateSessionPermissions(ctx context.Context, sessionID string, perms *session.PermissionsConfig) error { sm.mux.Lock() diff --git a/pkg/session/safety_policy_test.go b/pkg/session/safety_policy_test.go index 5e7ad21acf..9843574a46 100644 --- a/pkg/session/safety_policy_test.go +++ b/pkg/session/safety_policy_test.go @@ -12,6 +12,7 @@ func TestSafetyPolicy_IsValid(t *testing.T) { "": true, SafetyPolicyUnsafe: true, SafetyPolicySafer: true, + SafetyPolicySafeAuto: true, SafetyPolicyStrict: true, SafetyPolicy("yolo"): false, SafetyPolicy("Safer"): false, // case-sensitive on purpose @@ -60,3 +61,26 @@ func TestWithSafetyPolicy_ExplicitWinsOverToolsApproved(t *testing.T) { assert.True(t, s.ToolsApproved) assert.Equal(t, SafetyPolicySafer, s.SafetyPolicy) } + +// SetSafetyPolicy mid-session mirrors WithSafetyPolicy: setting +// unsafe backfills ToolsApproved; other modes leave it alone. +// Used by the dispatcher's approve-safe resume handler to opt an +// existing session into safe-auto without recreating it. +func TestSetSafetyPolicy_MidSession(t *testing.T) { + t.Parallel() + s := New() + assert.Equal(t, SafetyPolicy(""), s.SafetyPolicy) + assert.False(t, s.ToolsApproved) + + s.SetSafetyPolicy(SafetyPolicySafeAuto) + assert.Equal(t, SafetyPolicySafeAuto, s.SafetyPolicy) + assert.False(t, s.ToolsApproved, "safe-auto must not backfill ToolsApproved") + + s.SetSafetyPolicy(SafetyPolicyUnsafe) + assert.Equal(t, SafetyPolicyUnsafe, s.SafetyPolicy) + assert.True(t, s.ToolsApproved, "unsafe must backfill ToolsApproved for legacy branches") + + s.SetSafetyPolicy(SafetyPolicyStrict) + assert.Equal(t, SafetyPolicyStrict, s.SafetyPolicy) + assert.True(t, s.ToolsApproved, "SetSafetyPolicy does not un-flip ToolsApproved") +} diff --git a/pkg/session/session.go b/pkg/session/session.go index a320fe104e..9e5bcfdeca 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -46,6 +46,11 @@ const ( // SafetyPolicySafer: auto-approve except classifier-flagged // destructive calls (blast_radius low/medium/high). SafetyPolicySafer SafetyPolicy = "safer" + // SafetyPolicySafeAuto: auto-approve shell calls the classifier + // positively recognises as safe (blast_radius=safe); ask on + // destructive and unknown. Sits between safer and strict: + // safer waves through unknown too, strict prompts for safe. + SafetyPolicySafeAuto SafetyPolicy = "safe-auto" // SafetyPolicyStrict: today's no-yolo CLI default — prompt for // anything not auto-approved by a checker rule. SafetyPolicyStrict SafetyPolicy = "strict" @@ -53,7 +58,7 @@ const ( func (p SafetyPolicy) IsValid() bool { switch p { - case "", SafetyPolicyUnsafe, SafetyPolicySafer, SafetyPolicyStrict: + case "", SafetyPolicyUnsafe, SafetyPolicySafer, SafetyPolicySafeAuto, SafetyPolicyStrict: return true } return false @@ -1401,6 +1406,20 @@ func (s *Session) SetToolsApproved(approved bool) { } } +// SetSafetyPolicy updates the session's SafetyPolicy under s.mu. +// Mirrors WithSafetyPolicy: setting unsafe also flips ToolsApproved +// so legacy branches on ToolsApproved keep working. Runtime callers +// use this to persist a user's mid-session mode change (e.g. opting +// into safe-auto from a confirmation prompt). +func (s *Session) SetSafetyPolicy(policy SafetyPolicy) { + s.mu.Lock() + defer s.mu.Unlock() + s.SafetyPolicy = policy + if policy == SafetyPolicyUnsafe { + s.ToolsApproved = true + } +} + // AppendPermissionAllow adds toolName to the session's Allow list if not // already present, initializing Permissions when nil. Guarded by s.mu so // concurrent readers (ClonePermissions, permissionCheckers) see a diff --git a/pkg/tui/dialog/tool_confirmation.go b/pkg/tui/dialog/tool_confirmation.go index 21a67a03d6..156a5b4bc1 100644 --- a/pkg/tui/dialog/tool_confirmation.go +++ b/pkg/tui/dialog/tool_confirmation.go @@ -145,6 +145,8 @@ var safetyConventionKeys = map[string]struct{}{ func blastRadiusBadge(value string) string { style := styles.BaseStyle.Bold(true) switch value { + case "safe": + style = style.Foreground(styles.Success) case "low": style = style.Foreground(styles.Success) case "medium": @@ -159,19 +161,24 @@ func blastRadiusBadge(value string) string { return style.Render(value) } -// renderSafetyWarning composes the destructive-command warning block -// when the confirmation event carries the safer_shell builtin's -// `blast_radius` metadata. The block names the severity prominently -// (with a colored badge) and surfaces the matched reason as -// supporting context. Returns "" when no blast radius is present. +// renderSafetyWarning composes the classifier's verdict block from +// the safer_shell metadata. Safe verdicts render a reassuring +// heading; destructive / unknown verdicts render a warning. Returns +// "" when no blast_radius is present. func (d *toolConfirmationDialog) renderSafetyWarning(contentWidth int) string { radius, ok := d.msg.Metadata["blast_radius"] if !ok { return "" } - heading := styles.BaseStyle.Bold(true).Foreground(styles.Warning). - Render(fmt.Sprintf("⚠ Destructive command — %s blast radius", blastRadiusBadge(radius))) + var heading string + if radius == "safe" { + heading = styles.BaseStyle.Bold(true).Foreground(styles.Success). + Render("✓ Read-only command — " + blastRadiusBadge(radius)) + } else { + heading = styles.BaseStyle.Bold(true).Foreground(styles.Warning). + Render(fmt.Sprintf("⚠ Destructive command — %s blast radius", blastRadiusBadge(radius))) + } lines := []string{heading} if reason := d.msg.Metadata["reason"]; reason != "" {