Skip to content
Closed
6 changes: 1 addition & 5 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1363,7 +1363,7 @@
},
"preempt_yolo": {
"type": "boolean",
"description": "Opt a pre_tool_use entry into firing BEFORE the deterministic approval pipeline (--yolo, permission allow/deny patterns). A deny/ask verdict from a preempting hook cannot be bypassed by --yolo or permission allow-rules; an allow verdict is advisory (the pipeline still runs Decide() and the rest of pre_tool_use). Default pre_tool_use entries fire AFTER Decide(). Only meaningful on pre_tool_use; ignored on other events. Used by the safer_shell builtin (auto-registered with this flag when a shell toolset has `safer: true`)."
"description": "Opt a pre_tool_use entry into firing BEFORE the deterministic approval pipeline (custom allow/ask/deny rules + safety mode). A deny/ask verdict from a preempting hook cannot be bypassed by the safety mode (including Autonomous); an allow verdict is advisory. Default pre_tool_use entries fire AFTER the safety mode's verdict. Only meaningful on pre_tool_use; ignored on other events. Used by the safer_shell builtin (auto-registered whenever an agent has a shell toolset) as a pure labeller — it always returns Allow with classification metadata that the runtime's (mode × label) table consumes."
}
},
"required": [
Expand Down Expand Up @@ -2330,10 +2330,6 @@
"type": "boolean",
"description": "Opt in to background-job recall for the background_jobs toolset (only valid for type 'background_jobs'). When enabled, run_background_job exposes a recall boolean parameter. If the agent sets recall=true for a background job, the runtime injects a steering message with a short completion sentence and the job output when the job finishes. Default false."
},
"safer": {
"type": "boolean",
"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",
"description": "URL for the a2a, openapi or open_url tool",
Expand Down
6 changes: 3 additions & 3 deletions cmd/root/run_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TestCreateLocalRuntimeAndSession_ResumeWithYoloBackfillsSafetyPolicy(t *tes
_, sess, err := f.createLocalRuntimeAndSession(t.Context(), newSessionTestLoadResult(), req, store)
require.NoError(t, err)
assert.True(t, sess.ToolsApproved)
assert.Equal(t, session.SafetyPolicyUnsafe, sess.SafetyPolicy)
assert.Equal(t, session.SafetyPolicyAutonomous, sess.SafetyPolicy)
}

// An explicit stored SafetyPolicy survives a --yolo resume: the backfill
Expand All @@ -77,15 +77,15 @@ func TestCreateLocalRuntimeAndSession_ResumeKeepsExplicitSafetyPolicy(t *testing
store := session.NewInMemorySessionStore()
require.NoError(t, store.AddSession(t.Context(), session.New(
session.WithID("existing"),
session.WithSafetyPolicy(session.SafetyPolicySafer),
session.WithSafetyPolicy(session.SafetyPolicyBalanced),
)))

f := &runExecFlags{}
req := runtime.CreateSessionRequest{AgentName: "root", ResumeSessionID: "existing", ToolsApproved: true}

_, sess, err := f.createLocalRuntimeAndSession(t.Context(), newSessionTestLoadResult(), req, store)
require.NoError(t, err)
assert.Equal(t, session.SafetyPolicySafer, sess.SafetyPolicy)
assert.Equal(t, session.SafetyPolicyBalanced, sess.SafetyPolicy)
}

// A relative ref (e.g. -1) is resume-only: it must resolve against existing
Expand Down
2 changes: 1 addition & 1 deletion examples/golibrary/stream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func run(ctx context.Context) error {
case *runtime.StreamStoppedEvent:
log.Println("Stream stopped for session")
case *runtime.ToolCallConfirmationEvent:
rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeApproveSession})
rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeApproveAutonomous})
case *runtime.ToolCallEvent:
log.Printf("Tool call: %s\n", e.ToolCall.Function.Name)
case *runtime.ToolCallResponseEvent:
Expand Down
12 changes: 6 additions & 6 deletions examples/shell_safer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ agents:
model: anthropic/claude-haiku-4-5
description: Shell agent with the safer_shell classifier enabled
welcome_message: |
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).
The safer_shell classifier runs automatically for every shell
toolset. It labels each shell command (safe / destructive /
unknown) and attaches the label as tool-call metadata; the
runtime's safety mode (strict / balanced / autonomous) is what
actually gates the call. Confirmation prompts render a
destructive-tier badge when the classifier flagged the command.
instruction: Use the shell tool to run the command the user asks for.
toolsets:
- type: shell
safer: true
2 changes: 1 addition & 1 deletion pkg/a2a/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string,
if existing, err := sessStore.GetSession(ctx, sessionID); err == nil && existing != nil {
sess = existing
sess.AddMessage(session.UserMessage(message))
sess.ToolsApproved = true
sess.SetSafetyPolicy(session.SafetyPolicyAutonomous)
sess.NonInteractive = true
} else {
workingDir, _ := os.Getwd()
Expand Down
2 changes: 1 addition & 1 deletion pkg/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@ func (a *Agent) handleToolCallConfirmation(ctx context.Context, acpSess *Session
case "allow":
acpSess.rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeApprove})
case "allow-always":
acpSess.rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeApproveSession})
acpSess.rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeApproveAutonomous})
case "reject":
acpSess.rt.Resume(ctx, runtime.ResumeRequest{Type: runtime.ResumeTypeReject})
default:
Expand Down
2 changes: 1 addition & 1 deletion pkg/acp/runagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ func TestRunAgent_ToolCallConfirmationOutcomes(t *testing.T) {
{
name: "allow-always approves session",
result: permissionSelected("allow-always"),
wantResume: []runtime.ResumeRequest{{Type: runtime.ResumeTypeApproveSession}},
wantResume: []runtime.ResumeRequest{{Type: runtime.ResumeTypeApproveAutonomous}},
},
{
name: "reject rejects",
Expand Down
8 changes: 3 additions & 5 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,9 @@ func WithRedactSecrets(redactSecrets bool) Opt {
}
}

// WithSaferShell registers the safer_shell builtin under pre_tool_use
// with preempt_yolo:true when any of the agent's shell toolsets has
// `safer: true`. The builtin runs pre-Decide() and routes destructive
// shell commands to user confirmation regardless of --yolo or
// permission allow-rules. See [builtins.SaferShell].
// WithSaferShell registers the safer_shell classifier as a
// preempt_yolo pre_tool_use hook. Pure labeller: always allows,
// tags blast_radius metadata for the mode × label table.
func WithSaferShell(saferShell bool) Opt {
return func(a *Agent) {
a.saferShell = saferShell
Expand Down
12 changes: 6 additions & 6 deletions pkg/cli/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import (
type ConfirmationResult string

const (
ConfirmationApprove ConfirmationResult = "approve"
ConfirmationApproveSession ConfirmationResult = "approve_session"
ConfirmationReject ConfirmationResult = "reject"
ConfirmationAbort ConfirmationResult = "abort"
ConfirmationApprove ConfirmationResult = "approve"
ConfirmationApproveAutonomous ConfirmationResult = "approve_autonomous"
Comment thread
trungutt marked this conversation as resolved.
ConfirmationReject ConfirmationResult = "reject"
ConfirmationAbort ConfirmationResult = "abort"
)

var bold = color.New(color.Bold).SprintfFunc()
Expand Down Expand Up @@ -111,7 +111,7 @@ func (p *Printer) PrintToolCallWithConfirmation(ctx context.Context, toolCall to
return ConfirmationApprove
case 'a', 'A':
p.Print(bold("Yes to all 👍"))
return ConfirmationApproveSession
return ConfirmationApproveAutonomous
case 'n', 'N':
p.Print(bold("No 👎"))
return ConfirmationReject
Expand All @@ -135,7 +135,7 @@ func (p *Printer) PrintToolCallWithConfirmation(ctx context.Context, toolCall to
case "y":
return ConfirmationApprove
case "a":
return ConfirmationApproveSession
return ConfirmationApproveAutonomous
case "n":
return ConfirmationReject
default:
Expand Down
6 changes: 3 additions & 3 deletions pkg/cli/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
switch result {
case ConfirmationApprove:
rt.Resume(ctx, runtime.ResumeApprove())
case ConfirmationApproveSession:
sess.ToolsApproved = true
rt.Resume(ctx, runtime.ResumeApproveSession())
case ConfirmationApproveAutonomous:
sess.SetSafetyPolicy(session.SafetyPolicyAutonomous)
rt.Resume(ctx, runtime.ResumeApproveAutonomous())
case ConfirmationReject:
rt.Resume(ctx, runtime.ResumeReject(""))
lastConfirmedToolCallID = "" // Clear on reject since tool won't execute
Expand Down
74 changes: 18 additions & 56 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -996,28 +996,14 @@ func (a *AgentConfig) SessionCompactionEnabled() bool {
return *a.SessionCompaction
}

// SaferShellEnabled reports whether any of the agent's shell toolsets
// has opted into destructive-command detection. The flag lives on the
// toolset (not the agent), so this aggregates across all of an agent's
// toolsets — one match anywhere flips the agent-level flag the runtime
// uses to register the safer_shell builtin.
//
// Off by default: a missing pointer or explicit `safer: false` does
// not enable the feature. Only an explicit `safer: true` on at least
// one shell toolset switches it on.
//
// Multi-toolset note: when an agent declares more than one shell
// toolset and only some opt in, the safer_shell builtin still runs
// for every shell call on this agent (it filters by ToolName, not
// by toolset identity). Author one shell toolset per agent when you
// need toolset-scoped granularity.
func (a *AgentConfig) SaferShellEnabled() bool {
// HasShellToolset gates auto-injection of the safer_shell classifier
// (a pure labeller consumed by the runtime's mode × label table).
func (a *AgentConfig) HasShellToolset() bool {
if a == nil {
return false
}
for i := range a.Toolsets {
ts := &a.Toolsets[i]
if ts.Type == "shell" && ts.Safer != nil && *ts.Safer {
if a.Toolsets[i].Type == "shell" {
return true
}
}
Expand Down Expand Up @@ -1614,17 +1600,6 @@ type Toolset struct {
// finishes.
Recall *bool `json:"recall,omitempty" yaml:"recall,omitempty"`

// For the `shell` toolset — opt in to destructive-command detection.
// When enabled, the agent auto-registers the safer_shell builtin under
// pre_tool_use with preempt_yolo:true. Destructive commands (rm -rf, docker
// volume rm, mkfs, …) get an Ask verdict carrying 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. nil/false leaves the agent's shell calls subject
// only to the regular approval pipeline.
Safer *bool `json:"safer,omitempty" yaml:"safer,omitempty"`

// For the `rag` tool
RAGConfig *RAGConfig `json:"rag_config,omitempty" yaml:"rag_config,omitempty"`

Expand Down Expand Up @@ -2439,15 +2414,11 @@ type RAGFusionConfig struct {
Weights map[string]float64 `json:"weights,omitempty"` // Strategy weights for weighted fusion
}

// PermissionsConfig represents tool permission configuration.
// Allow/Ask/Deny model. This controls tool call approval behavior:
// - Allow: Tools matching these patterns are auto-approved (like --yolo for specific tools)
// - Ask: Tools matching these patterns always require user approval, even if the tool is read-only
// - Deny: Tools matching these patterns are always rejected, even with --yolo
//
// Patterns support glob-style matching (e.g., "shell", "read_*", "mcp:github:*")
// The evaluation order is: Deny (checked first), then Allow, then Ask (explicit), then default
// (read-only tools auto-approved, others ask)
// PermissionsConfig configures per-tool overrides that layer on top
// of the session's SafetyPolicy. Allow/Deny always win; Ask beats the
// mode only for session-scoped rules (YAML `ask:` is advisory — see
// [toolexec.Decide]). Patterns are glob-style ("shell", "read_*",
// "mcp:github:*").
type PermissionsConfig struct {
// Allow lists tool name patterns that are auto-approved without user confirmation
Allow []string `json:"allow,omitempty"`
Expand All @@ -2472,12 +2443,10 @@ type HooksConfig struct {
// in the handler when you only want to act on one of them.
PostToolUse HookMatcherConfigs `json:"post_tool_use,omitempty" yaml:"post_tool_use,omitempty"`

// PermissionRequest hooks run just before the runtime would prompt
// the user to approve a tool call (i.e. when neither --yolo nor a
// permissions rule short-circuited the decision). Hooks may auto-allow
// or auto-deny via hook_specific_output.permission_decision so the
// user is not prompted; otherwise the runtime falls through to the
// usual interactive confirmation. Tool-matched, like pre_tool_use.
// PermissionRequest hooks fire right before an interactive
// confirmation prompt. Return DecisionAllow/DecisionDeny to
// short-circuit; anything else falls through to the prompt.
// Tool-matched, like pre_tool_use.
PermissionRequest HookMatcherConfigs `json:"permission_request,omitempty" yaml:"permission_request,omitempty"`

// SessionStart hooks run when a session begins
Expand Down Expand Up @@ -2683,18 +2652,11 @@ type HookMatcherConfig struct {
// Hooks are the hooks to execute when the matcher matches
Hooks HookDefinitions `json:"hooks" yaml:"hooks"`

// PreemptYolo opts a pre_tool_use entry into firing BEFORE the
// deterministic approval pipeline (--yolo, permission patterns).
// A deny/ask verdict from a preempting hook cannot be bypassed by
// auto-approval rules; an allow verdict is advisory (the pipeline
// still runs Decide() and the rest of pre_tool_use). Default
// pre_tool_use entries fire AFTER Decide(), as before. Only valid
// on pre_tool_use; ignored on other events.
//
// Used by the safer_shell builtin (auto-registered with this flag
// when a shell toolset has `safer: true`). Custom hooks set it to
// true when they implement a security-critical check that must
// not be bypassed by --yolo.
// PreemptYolo fires the entry BEFORE the mode/custom-rules
// pipeline. Deny/Ask verdicts cannot be bypassed by any mode
// (including Autonomous); Allow is advisory. Only valid on
// pre_tool_use. Used by the safer_shell classifier (labeller
// only) and by user-authored security-critical checks.
PreemptYolo *bool `json:"preempt_yolo,omitempty" yaml:"preempt_yolo,omitempty"`
}

Expand Down
3 changes: 0 additions & 3 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,9 +298,6 @@ func (t *Toolset) validate() error {
if t.Recall != nil && t.Type != "background_jobs" {
return errors.New("recall can only be used with type 'background_jobs'")
}
if t.Safer != nil && t.Type != "shell" {
return errors.New("safer can only be used with type 'shell'")
}
if len(t.AllowedDomains) > 0 && len(t.BlockedDomains) > 0 {
return errors.New("allowed_domains and blocked_domains are mutually exclusive")
}
Expand Down
3 changes: 1 addition & 2 deletions pkg/config/latest/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ func TestToolsetValidateAttributeTypeMismatch(t *testing.T) {
{name: "allow_private_ips on wrong type", toolset: Toolset{Type: "shell", AllowPrivateIPs: new(true)}, wantErr: "allow_private_ips can only be used with type 'fetch', 'api', 'openapi', 'a2a' or remote MCP toolsets"},
{name: "sudo_askpass on non-shell", toolset: Toolset{Type: "fetch", SudoAskpass: new(true)}, wantErr: "sudo_askpass can only be used with type 'shell'"},
{name: "recall on non-background_jobs", toolset: Toolset{Type: "shell", Recall: new(true)}, wantErr: "recall can only be used with type 'background_jobs'"},
{name: "safer on non-shell", toolset: Toolset{Type: "fetch", Safer: new(true)}, wantErr: "safer can only be used with type 'shell'"},
{name: "allowed and blocked domains", toolset: Toolset{Type: "fetch", AllowedDomains: []string{"a.example.com"}, BlockedDomains: []string{"b.example.com"}}, wantErr: "allowed_domains and blocked_domains are mutually exclusive"},
{name: "invalid allowed_domains pattern", toolset: Toolset{Type: "fetch", AllowedDomains: []string{"foo.*"}}, wantErr: `allowed_domains[0] "foo.*" is invalid`},
{name: "invalid blocked_domains pattern", toolset: Toolset{Type: "fetch", BlockedDomains: []string{"10.0.0.0/33"}}, wantErr: `blocked_domains[0] "10.0.0.0/33" is invalid: not a valid CIDR`},
Expand Down Expand Up @@ -299,7 +298,7 @@ func TestToolsetValidateValidToolsets(t *testing.T) {
name string
toolset Toolset
}{
{name: "shell", toolset: Toolset{Type: "shell", Env: map[string]string{"A": "b"}, SudoAskpass: new(true), Safer: new(true)}},
{name: "shell", toolset: Toolset{Type: "shell", Env: map[string]string{"A": "b"}, SudoAskpass: new(true)}},
{name: "background_jobs", toolset: Toolset{Type: "background_jobs", Env: map[string]string{"A": "b"}, Recall: new(true)}},
{name: "memory with path", toolset: Toolset{Type: "memory", Path: "/tmp/memory.db"}},
{name: "memory without path", toolset: Toolset{Type: "memory"}},
Expand Down
Loading
Loading