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 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 excerpt (2,000 lines, up to 50 KiB): the tail for most tools, but the head for the built-in filesystem `read_file`, whose notice suggests a follow-up call with `line`/`limit` to continue reading. The `session_end` leg deletes the temp directory. Internal toolsets (`memory`, `plan`, `tasks`, `think`, …) are not affected. |
| `safer_shell` | `pre_tool_use` | _none_ | **Deprecated compatibility shim.** The runtime now classifies every shell command natively (`safe` / `destructive` / `unknown`) and gates it through the session's [safety mode](../permissions/index.md#safety-modes), so this builtin no longer emits verdicts. Pinned entries keep working as pure labellers that attach classification metadata (`safety_label`, `blast_radius`, `category`, `reason`) to the call. Filters by tool name internally (no-op for non-shell calls). |
| `safer_shell` | `pre_tool_use` | _none_ | **Deprecated compatibility shim.** The runtime now classifies every shell command natively (`safe` / `destructive` / `unknown`) and gates it through the session's [safety mode](../permissions/index.md#safety-modes), so this builtin no longer emits verdicts. Pinned entries keep working as pure labellers that attach classification metadata (`safety_label`, `blast_radius`, `category`, `reason`) to the call. Filters by tool name internally (no-op for calls other than `shell` and `run_background_job`). |
| `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
2 changes: 1 addition & 1 deletion docs/configuration/permissions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Permissions provide fine-grained control over tool execution. You can configure

## Safety Modes

Every session runs in a **safety mode** that decides what happens when no permission rule matched a tool call. The runtime labels each call `safe` (safe-listed shell command such as `ls` or `git status`, or a read-only-annotated tool), `destructive` (destructive shell command such as `rm -rf`, or a destructive-annotated tool), or `unknown` — and the mode gates on that label:
Every session runs in a **safety mode** that decides what happens when no permission rule matched a tool call. The runtime labels each call `safe` (safe-listed shell command such as `ls` or `git status`, or a read-only-annotated tool), `destructive` (destructive shell command such as `rm -rf`, or a destructive-annotated tool), or `unknown` — and the mode gates on that label. Command classification applies to both `shell` and `run_background_job`:

| Mode | safe | destructive | unknown |
| ---- | ---- | ----------- | ------- |
Expand Down
6 changes: 6 additions & 0 deletions docs/tools/background-jobs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ The background jobs toolset exposes five tools:
> **Safety**
>
> Background jobs run shell commands with the same access as the agent process. Stop servers and watchers when they are no longer needed, and use [Sandbox Mode](../../configuration/sandbox/index.md) for additional isolation.

### Command classification

The `cmd` passed to `run_background_job` goes through the same [command classification](../shell/index.md#command-classification) as the `shell` tool: it is labelled `safe`, `destructive` (with a `blast_radius`) or `unknown`, and the session's [safety mode](../../configuration/permissions/index.md#safety-modes) decides whether to run, ask or deny. A destructive command cannot bypass its confirmation badge by being started in the background. Most long-running commands (`npm run dev`, `go run .`, `docker compose up`) are `unknown` and prompt under `strict` / `balanced`.

The interactive "always allow" decision grants the first word of the command (e.g. `run_background_job:cmd=npm*`), scoped to this tool — it does not cover the same command run through `shell`, and vice versa.
2 changes: 1 addition & 1 deletion docs/tools/shell/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ toolsets:

### Command classification

Every shell command is classified against an embedded taxonomy before the approval decision — no opt-in required:
Every shell command is classified against an embedded taxonomy before the approval decision — no opt-in required. The same classification applies to the `cmd` of [`run_background_job`](../background-jobs/index.md#command-classification):

- **Destructive matches** (`rm -rf <path>`, `docker volume rm`, `mkfs`, `dd if=… of=/dev/<disk>`, …) are labelled `destructive` with a `blast_radius` (`low` / `medium` / `high`) and a `category` tag. The TUI confirmation dialog renders the blast radius with a color badge.
- **Known-safe reads** (`ls`, `cat`, `git status`, `git diff`, `docker ps`, `docker logs`, `kubectl get`, …) are labelled `safe`.
Expand Down
2 changes: 1 addition & 1 deletion pkg/hooks/builtins/safer_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func saferShell(_ context.Context, in *hooks.Input, _ []string) (*hooks.Output,
return nil, nil
}
warnSaferShellOnce()
if in.ToolName != safety.ShellToolName {
if !safety.IsCommandTool(in.ToolName) {
return nil, nil
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/hooks/builtins/safer_shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ func TestSaferShell_AcceptsCommandAliasKey(t *testing.T) {
assert.Equal(t, "destructive", out.HookSpecificOutput.Metadata[safety.MetaSafetyLabel])
}

func TestSaferShell_LabelsBackgroundJobCommand(t *testing.T) {
out, err := saferShell(t.Context(), &hooks.Input{
HookEventName: hooks.EventPreToolUse,
ToolName: safety.BackgroundJobToolName,
ToolInput: map[string]any{"cmd": "rm -rf /tmp/x"},
}, nil)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, "destructive", out.HookSpecificOutput.Metadata[safety.MetaSafetyLabel])
}

func TestSaferShell_NoOpForNonShellTool(t *testing.T) {
out, err := saferShell(t.Context(), &hooks.Input{
HookEventName: hooks.EventPreToolUse,
Expand Down
41 changes: 30 additions & 11 deletions pkg/runtime/toolexec/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,8 @@ type call struct {
label safety.Label
}

// safetyLabel labels the call for the (mode × label) table: shell
// commands via the pattern classifier, everything else via MCP
// safetyLabel labels the call for the (mode × label) table: command
// tools via the pattern classifier, everything else via MCP
// annotation hints. Cached after the first call.
func (c *call) safetyLabel() safety.Label {
if c.labelComputed {
Expand Down Expand Up @@ -524,10 +524,28 @@ func (c *call) permissionDecision() PermissionDecision {
c.safetyLabel(),
checkers,
c.tc.Function.Name,
ParseToolInput(c.tc.Function.Arguments),
c.permissionArgs(),
)
}

// permissionArgs is the parsed tool input as permission rules see it.
// For command tools the command the handler will actually run is
// mirrored under the canonical "cmd" key, so a `shell:cmd=rm*` rule
// cannot be sidestepped by sending the "command" alias instead.
func (c *call) permissionArgs() map[string]any {
args := ParseToolInput(c.tc.Function.Arguments)
if !safety.IsCommandTool(c.tc.Function.Name) {
return args
}
cmd, ok := safety.CommandArg(args)
if !ok {
return args
}
normalized := maps.Clone(args)
normalized["cmd"] = cmd
return normalized
}

func (c *call) autoApprovalAfterConfirmationWait() (PermissionDecision, bool) {
if c.preYoloResult != nil && c.preYoloResult.Decision == hooks.DecisionAsk {
// Even under a preempt-yolo Ask, a session-scoped allow grant that
Expand Down Expand Up @@ -563,23 +581,24 @@ func (c *call) autoApprovalAfterConfirmationWait() (PermissionDecision, bool) {
// [permissions.Checker.CheckWithArgs] ordering (Deny > Allow > Ask), so a
// session-level Deny or explicit Ask never yields an allow here.
//
// For the shell tool a matching allow pattern is necessary but not
// sufficient: the generic matcher's trailing-* patterns are plain prefix
// matches, so the "T = always allow mkdir*" grant would also cover
// "mkdir x && rm -rf ~". Silencing a safety verdict demands the stricter
// word-boundary, no-metacharacter reading — see shellGrantCoversCommand.
// For the command tools (shell, run_background_job) a matching allow
// pattern is necessary but not sufficient: the generic matcher's
// trailing-* patterns are plain prefix matches, so the "T = always allow
// mkdir*" grant would also cover "mkdir x && rm -rf ~". Silencing a
// safety verdict demands the stricter word-boundary, no-metacharacter
// reading — see commandGrantCoversCall.
func (c *call) sessionPermissionsAllow() bool {
perms := c.sess.ClonePermissions()
if perms == nil {
return false
}
args := ParseToolInput(c.tc.Function.Arguments)
args := c.permissionArgs()
checker := permissions.NewCheckerFromRules(perms.Allow, perms.Ask, perms.Deny)
if checker.CheckWithArgs(c.tc.Function.Name, args) != permissions.Allow {
return false
}
if c.tc.Function.Name == shellToolName {
return shellGrantCoversCommand(perms.Allow, args)
if safety.IsCommandTool(c.tc.Function.Name) {
return commandGrantCoversCall(c.tc.Function.Name, perms.Allow, args)
}
return true
}
Expand Down
136 changes: 135 additions & 1 deletion pkg/runtime/toolexec/dispatcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,71 @@ func TestDispatcher_BalancedClassifiesShellCommands(t *testing.T) {
})
}

// TestDispatcher_BalancedClassifiesBackgroundJobCommands pins that the
// command carried by run_background_job is classified exactly like a
// shell command: a destructive command cannot be laundered through the
// background-job tool to lose its badge, and a safe one auto-runs.
func TestDispatcher_BalancedClassifiesBackgroundJobCommands(t *testing.T) {
t.Parallel()
a := newAgent()

t.Run("safe command auto-approves", func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithSafetyPolicy(session.SafetyPolicyBalanced))

var ran bool
tool := tools.Tool{
Name: "run_background_job",
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 },
}
em := &captureEmitter{}

d.Process(t.Context(), sess, []tools.ToolCall{{
ID: "b",
Function: tools.FunctionCall{Name: "run_background_job", Arguments: `{"cmd":"git log --oneline"}`},
}}, []tools.Tool{tool}, em)

assert.True(t, ran, "balanced must auto-approve classifier-safe background commands")
assert.Empty(t, em.confirmations)
})

t.Run("destructive command prompts with label", func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithSafetyPolicy(session.SafetyPolicyBalanced))

tool := tools.Tool{
Name: "run_background_job",
Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) {
panic("must not run")
},
}
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: "b",
Function: tools.FunctionCall{Name: "run_background_job", Arguments: `{"cmd":"rm -rf /tmp/x"}`},
}}, []tools.Tool{tool}, em)

require.Len(t, em.confirmations, 1)
require.Len(t, em.confirmationMeta, 1)
assert.Equal(t, "destructive", em.confirmationMeta[0]["safety_label"])
assert.Equal(t, "high", em.confirmationMeta[0]["blast_radius"])
})
}

// Restricted is the fail-closed profile for unattended runs: safe
// calls auto-run, everything else is rejected outright — no
// confirmation prompt, no tool execution — with the stable
Expand Down Expand Up @@ -883,6 +948,75 @@ func TestDispatcher_DenyRuleBlocksUnderAutonomous(t *testing.T) {
assert.Contains(t, em.responses[0].Output, "denied")
}

// TestDispatcher_CommandRulesSeeTheExecutedCommand pins that permission
// rules written against the canonical "cmd" key match whatever command
// the handler will actually run: the "command" alias cannot dodge a
// deny rule, and a stored "always allow" grant keeps covering alias
// calls. Both command tools are exercised.
func TestDispatcher_CommandRulesSeeTheExecutedCommand(t *testing.T) {
t.Parallel()
a := newAgent()

for _, toolName := range []string{"shell", "run_background_job"} {
t.Run(toolName+" alias cannot dodge a deny rule", func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithSafetyPolicy(session.SafetyPolicyAutonomous))
tool := tools.Tool{
Name: toolName,
Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) {
panic("must not run")
},
}
d := &toolexec.Dispatcher{
AgentFor: func(*session.Session) *agent.Agent { return a },
Permissions: staticCheckers(toolexec.NamedChecker{
Checker: permissions.NewCheckerFromRules(nil, nil, []string{toolName + ":cmd=sudo *"}),
Source: "permissions configuration",
Tier: toolexec.TierTeam,
}),
}
em := &captureEmitter{}

d.Process(t.Context(), sess, []tools.ToolCall{{
ID: "s",
Function: tools.FunctionCall{Name: toolName, Arguments: `{"command":"sudo rm -rf /"}`},
}}, []tools.Tool{tool}, em)

require.Len(t, em.responses, 1)
assert.True(t, em.responses[0].IsError)
assert.Contains(t, em.responses[0].Output, "denied")
})

t.Run(toolName+" always-allow grant covers alias calls", func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithSafetyPolicy(session.SafetyPolicyStrict))
sess.Permissions = &session.PermissionsConfig{Allow: []string{toolName + ":cmd=git*"}}

ran := false
tool := tools.Tool{
Name: toolName,
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: sessionCheckers,
}
em := &captureEmitter{}

d.Process(t.Context(), sess, []tools.ToolCall{{
ID: "s",
Function: tools.FunctionCall{Name: toolName, Arguments: `{"command":"git fetch"}`},
}}, []tools.Tool{tool}, em)

assert.True(t, ran, "the grant was built from the executed command and must match it again")
assert.Empty(t, em.confirmations)
})
}
}

// 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.
Expand Down Expand Up @@ -1557,7 +1691,7 @@ func TestDispatcher_PreToolUsePreYoloAskHonorsSessionAllow(t *testing.T) {
// generic matcher and would also cover "mkdir x && rm -rf ~" — a
// compound command that smuggles a destructive call behind the
// approved word. Overriding a preempt-yolo Ask therefore requires the
// word-boundary, no-metacharacter reading (shellGrantCoversCommand):
// word-boundary, no-metacharacter reading (commandGrantCoversCall):
// the compound call must still prompt.
func TestDispatcher_PreToolUsePreYoloAskSessionAllowRefusesCompound(t *testing.T) {
t.Parallel()
Expand Down
Loading
Loading