Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) | `["<policy>"]` (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": "<id>"}` 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]
Expand Down Expand Up @@ -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

Expand Down
16 changes: 7 additions & 9 deletions examples/shell_safer.yaml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
84 changes: 49 additions & 35 deletions pkg/hooks/builtins/safer_shell.go
Original file line number Diff line number Diff line change
@@ -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; `<placeholder>` matches
// one non-whitespace token, `...` matches any tail.
// Compound shell (a && b, a; b, a | b) skips the safe-list.

import (
"context"
Expand Down Expand Up @@ -164,32 +156,46 @@ 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 != "" {
if match := bestDestructiveMatch(command, patterns.destructive); match != nil {
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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
},
Expand Down
Loading
Loading