feat(safety): add restricted mode for unattended runs

This commit is contained in:
Sayt-0 committed 2026-08-13 11:34:36 +02:00
1 parent 54dccbff45
commit 448f1365e9
31 files changed
+420 -88

No files matched your search

+5 -3
View File
@@ -664,9 +664,10 @@
"enum": [
"strict",
"balanced",
"restricted",
"autonomous"
],
"description": "Safety mode new sessions started on this agent default to when the user has not chosen one (no --safety/--yolo flag, no alias option, no user-config setting). Takes precedence over the config-wide runtime.safety. A default only: it never overrides a user choice and never replaces the mode stored on a resumed session. Trust warning: 'autonomous' auto-approves every tool call — review configs from URLs or OCI registries before running them."
"description": "Safety mode new sessions started on this agent default to when the user has not chosen one (no --safety/--yolo flag, no alias option, no user-config setting). Takes precedence over the config-wide runtime.safety. A default only: it never overrides a user choice and never replaces the mode stored on a resumed session. 'restricted' is the fail-closed profile for unattended runs: safe calls auto-approve, every other unmatched call is denied without prompting. Trust warning: 'autonomous' auto-approves every tool call — review configs from URLs or OCI registries before running them."
},
"redact_secrets": {
"type": "boolean",
@@ -1864,9 +1865,10 @@
"enum": [
"strict",
"balanced",
"restricted",
"autonomous"
],
"description": "Safety mode new sessions default to when the user has not chosen one (no --safety/--yolo flag, no alias option, no user-config setting). A per-agent 'safety' takes precedence over this config-wide default. A default only: it never overrides a user choice and never replaces the mode stored on a resumed session. Trust warning: 'autonomous' auto-approves every tool call — review configs from URLs or OCI registries before running them."
"description": "Safety mode new sessions default to when the user has not chosen one (no --safety/--yolo flag, no alias option, no user-config setting). A per-agent 'safety' takes precedence over this config-wide default. A default only: it never overrides a user choice and never replaces the mode stored on a resumed session. 'restricted' is the fail-closed profile for unattended runs: safe calls auto-approve, every other unmatched call is denied without prompting. Trust warning: 'autonomous' auto-approves every tool call — review configs from URLs or OCI registries before running them."
}
},
"additionalProperties": false
@@ -2361,7 +2363,7 @@
},
"safer": {
"type": "boolean",
"description": "Deprecated and ignored (only valid for type 'shell'). The runtime now classifies every shell command natively (safe / destructive / unknown) and gates it through the session's safety mode (strict / balanced / autonomous), so the opt-in is meaningless. Kept so existing YAMLs with safer: true still parse."
"description": "Deprecated and ignored (only valid for type 'shell'). The runtime now classifies every shell command natively (safe / destructive / unknown) and gates it through the session's safety mode (strict / balanced / restricted / autonomous), so the opt-in is meaningless. Kept so existing YAMLs with safer: true still parse."
},
"url": {
"type": "string",
+3 -3
View File
@@ -68,7 +68,7 @@ You can optionally specify runtime options that will be applied whenever
the alias is used:
--yolo Automatically approve all tool calls without prompting
--safety Default safety mode: strict, balanced, or autonomous
--safety Default safety mode: strict, balanced, restricted, or autonomous
--model Override the agent's model (format: [agent=]provider/model)
--hide-tool-results Hide tool call results in the TUI
--sandbox Always run the agent inside a Docker sandbox`,
@@ -99,7 +99,7 @@ the alias is used:
}
cmd.Flags().BoolVar(&flags.yolo, "yolo", false, "Automatically approve all tool calls without prompting")
cmd.Flags().StringVar(&flags.safety, "safety", "", "Default safety mode when running the alias: strict, balanced, or autonomous (wins over --yolo)")
cmd.Flags().StringVar(&flags.safety, "safety", "", "Default safety mode when running the alias: strict, balanced, restricted, or autonomous (wins over --yolo)")
cmd.Flags().StringVar(&flags.model, "model", "", "Override agent model (format: [agent=]provider/model)")
cmd.Flags().BoolVar(&flags.hideToolResults, "hide-tool-results", false, "Hide tool call results in the TUI")
cmd.Flags().BoolVar(&flags.sandbox, "sandbox", false, "Always run the agent inside a Docker sandbox")
@@ -145,7 +145,7 @@ func runAliasAddCommand(cmd *cobra.Command, args []string, flags *aliasAddFlags)
name := args[0]
agentPath := args[1]
// Fail fast on a typo: only the three canonical modes may be stored.
// Fail fast on a typo: only the canonical modes may be stored.
safety := latestcfg.SafetyMode(flags.safety)
if err := safety.Validate(); err != nil {
return fmt.Errorf("invalid --safety value: %w", err)
+9 -1
View File
@@ -80,11 +80,19 @@ func TestRunAliasAddCommand_Safety(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, buf.String(), "Safety: balanced")
err = runAliasAddCommand(cmd, []string{"headless", "myorg/coder"}, &aliasAddFlags{safety: "restricted"})
require.NoError(t, err)
assert.Contains(t, buf.String(), "Safety: restricted")
cfg, err := userconfig.Load()
require.NoError(t, err)
alias, ok := cfg.GetAlias("careful")
require.True(t, ok)
assert.Equal(t, latestcfg.SafetyModeBalanced, alias.Safety)
alias, ok = cfg.GetAlias("headless")
require.True(t, ok)
assert.Equal(t, latestcfg.SafetyModeRestricted, alias.Safety)
}
func TestRunAliasAddCommand_InvalidSafety(t *testing.T) {
@@ -98,7 +106,7 @@ func TestRunAliasAddCommand_InvalidSafety(t *testing.T) {
cmd.SetOut(&buf)
err := runAliasAddCommand(cmd, []string{"careful", "myorg/coder"}, &aliasAddFlags{safety: "yolo"})
require.ErrorContains(t, err, "invalid --safety value")
require.ErrorContains(t, err, "strict, balanced, autonomous")
require.ErrorContains(t, err, "strict, balanced, restricted, autonomous")
cfg, err := userconfig.Load()
require.NoError(t, err)
+3 -3
View File
@@ -168,7 +168,7 @@ func newRunCmd() *cobra.Command {
func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
cmd.PersistentFlags().StringVarP(&flags.agentName, "agent", "a", "", "Name of the agent to run (defaults to the team's first agent)")
cmd.PersistentFlags().BoolVar(&flags.autoApprove, "yolo", false, "Automatically approve all tool calls without prompting (same as --safety autonomous)")
cmd.PersistentFlags().StringVar(&flags.safety, "safety", "", "Safety mode for tool approval: strict (ask for everything), balanced (auto-approve safe calls), or autonomous (approve everything)")
cmd.PersistentFlags().StringVar(&flags.safety, "safety", "", "Safety mode for tool approval: strict (ask for everything), balanced (auto-approve safe calls), restricted (auto-approve safe calls, deny the rest — for unattended runs), or autonomous (approve everything)")
cmd.PersistentFlags().BoolVar(&flags.hideToolResults, "hide-tool-results", false, "Hide tool call results")
cmd.PersistentFlags().StringVar(&flags.attachmentPath, "attach", "", "Attach an image file to the message")
cmd.PersistentFlags().StringArrayVar(&flags.promptFiles, "prompt-file", nil, "Append file contents to the prompt (repeatable)")
@@ -268,9 +268,9 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
// silently collapse to strict. Legacy policy aliases are a wire
// compat concern; the new flag only takes the canonical modes.
switch session.SafetyPolicy(f.safety) {
case "", session.SafetyPolicyStrict, session.SafetyPolicyBalanced, session.SafetyPolicyAutonomous:
case "", session.SafetyPolicyStrict, session.SafetyPolicyBalanced, session.SafetyPolicyRestricted, session.SafetyPolicyAutonomous:
default:
return fmt.Errorf("invalid --safety value %q (valid: strict, balanced, autonomous)", f.safety)
return fmt.Errorf("invalid --safety value %q (valid: strict, balanced, restricted, autonomous)", f.safety)
}
f.safetyChanged = cmd.Flags().Changed("safety")
f.yoloChanged = cmd.Flags().Changed("yolo")
+8
View File
@@ -117,6 +117,14 @@ func TestCreateLocalRuntimeAndSession_FreshSessionSafetyResolution(t *testing.T)
wantStored: session.SafetyPolicyStrict,
wantEffective: session.SafetyPolicyStrict,
},
{
name: "user-owned restricted wins over author defaults",
rootSafety: latest.SafetyModeAutonomous,
runtimeSafety: latest.SafetyModeAutonomous,
req: runtime.CreateSessionRequest{SafetyPolicy: session.SafetyPolicyRestricted},
wantStored: session.SafetyPolicyRestricted,
wantEffective: session.SafetyPolicyRestricted,
},
{
name: "legacy user yolo wins over author defaults",
rootSafety: latest.SafetyModeStrict,
+1 -1
View File
@@ -31,7 +31,7 @@ func TestRunOrExec_InvalidUserConfigFailsClearly(t *testing.T) {
err := f.runOrExec(t.Context(), cli.NewPrinter(io.Discard), nil, false)
require.ErrorContains(t, err, "loading user config")
require.ErrorContains(t, err, "settings.safety")
require.ErrorContains(t, err, "strict, balanced, autonomous")
require.ErrorContains(t, err, "strict, balanced, restricted, autonomous")
}
// Same for an invalid alias safety value: the error names the alias.
+10 -7
View File
@@ -25,13 +25,15 @@ Every session runs in a **safety mode** that decides what happens when no permis
| ---- | ---- | ----------- | ------- |
| `strict` | ask | ask | ask |
| `balanced` | **allow** | ask | ask |
| `restricted` | **allow** | deny | deny |
| `autonomous` | **allow** | **allow** | **allow** |
- **`strict`** prompts for every tool call, read-only ones included. Only an `allow:` rule silences a prompt.
- **`balanced`** runs safe calls silently and asks about everything else.
- **`restricted`** is the fail-closed profile for unattended/headless runs: safe calls run silently and everything else is **denied without asking** — the mode's fallback never prompts. Custom rules still win: an `allow:` rule can approve a destructive/unknown call, a `deny:` rule always blocks, and session-scoped `ask:` rules still prompt (as can a `preempt_yolo` hook). Restricted is defense in depth against unwanted tool calls, not a security boundary — for real isolation use [sandbox mode](../sandbox/index.md).
- **`autonomous`** is the legacy `--yolo` behavior: everything runs. Only `deny:` rules, session-scoped `ask:` rules, and `preempt_yolo` hooks still gate.
Pick a mode with the `--safety` flag (`docker-agent run --safety balanced ...`), the `safety_policy` field on session create (`POST /api/sessions`) or mid-session (`PATCH /api/sessions/:id/safety-policy`), or escalate directly from a confirmation prompt (`B` switches to balanced, `A` to autonomous). Sessions that never choose a mode keep the historical default: read-only tools auto-approve, everything else asks.
Pick a mode with the `--safety` flag (`docker-agent run --safety balanced ...`), the `safety_policy` field on session create (`POST /api/sessions`) or mid-session (`PATCH /api/sessions/:id/safety-policy`), or escalate directly from a confirmation prompt (`B` switches to balanced, `A` to autonomous; the `restricted` fallback never prompts, so the mode is only selected via flag/config/API). Sessions that never choose a mode keep the historical default: read-only tools auto-approve, everything else asks.
### Declarative Safety Defaults
@@ -54,7 +56,7 @@ agents:
safety: strict # overrides runtime.safety for this agent
```
All four fields accept only the three canonical modes — `strict`, `balanced`, `autonomous` (yes, an author may declare `autonomous`) — and any other value fails loading with an error naming the field. The legacy spellings remain as aliases for `autonomous`: `settings.YOLO`, the alias `yolo` option, and the `--yolo` flag. When both are set at the same scope, `safety` wins over the legacy `YOLO`/`yolo`.
All four fields accept only the four canonical modes — `strict`, `balanced`, `restricted`, `autonomous` (yes, an author may declare `autonomous`) — and any other value fails loading with an error naming the field. The legacy spellings remain as aliases for `autonomous`: `settings.YOLO`, the alias `yolo` option, and the `--yolo` flag. When both are set at the same scope, `safety` wins over the legacy `YOLO`/`yolo`.
For a **new** root session the first source in this order wins:
@@ -73,7 +75,7 @@ Sessions created through the API (`POST /api/sessions`) without a `safety_policy
> [!WARNING]
> **Trust: author defaults never outrank you.** `runtime.safety` and `agents.<name>.safety` are written by the agent's author — which may be a config you pulled from a URL or an OCI registry. They only fill the gap when you expressed no preference: any user-owned source (CLI flag, alias option, user settings) always takes precedence, and a resumed session keeps its stored mode. Still, an author default of `autonomous` means a fresh session runs every tool call unprompted — review third-party configs before running them, or pin your own floor with `settings.safety` / `--safety`.
**Custom rules always win over the mode**, with one asymmetry: `ask:` rules written in an agent's YAML (or global config) are agent-author advisories and yield to a user-chosen `balanced`/`autonomous` mode, while `ask:` rules granted at the session level (interactive "always ask" decisions, the session permissions API) always prompt.
**Custom rules always win over the mode**, with one asymmetry: `ask:` rules written in an agent's YAML (or global config) are agent-author advisories and yield to a user-chosen `balanced`/`restricted`/`autonomous` mode (under `restricted` they resolve to the mode's allow-or-deny verdict rather than introducing a prompt), while `ask:` rules granted at the session level (interactive "always ask" decisions, the session permissions API) always prompt.
## Permission Levels
@@ -299,11 +301,12 @@ Permissions work alongside [hooks](../hooks/index.md). The evaluation order is:
1. Run **`preempt_yolo` pre_tool_use hooks** — security-critical checks that no mode or allow rule can bypass
2. Check **deny** patterns — if matched, tool is blocked
3. Check **allow** patterns — if matched, tool is auto-approved
4. Apply the **[safety mode](#safety-modes)** to the call's safety label — may auto-approve
5. Run **pre_tool_use hooks** — hooks can allow, deny, or ask
6. If no decision, **ask user** for confirmation
4. Check **ask** patterns — if matched, the user is prompted directly, skipping the default `pre_tool_use` lane
5. If no rule matched, apply the **[safety mode](#safety-modes)** to the call's safety label — may auto-approve (or, under `restricted`, deny)
6. On a mode "ask", run **pre_tool_use hooks** — hooks can allow, deny, or ask
7. If no decision, **ask user** for confirmation
Default-lane hooks only see calls the rules and the mode routed to "ask"; they cannot override deny decisions.
Default-lane hooks only see calls the mode routed to "ask"; they cannot override deny decisions or explicit `ask:` rules.
> [!WARNING]
> **Security Note**
+1 -1
View File
@@ -44,7 +44,7 @@ You rarely need to hand-edit this file. Most fields are managed from the TUI's `
| `theme_dark` | string | `default` | Theme applied when `theme: auto` and the terminal background is dark. |
| `theme_light` | string | `default-light` | Theme applied when `theme: auto` and the terminal background is light. |
| `YOLO` | boolean | `false` | Auto-approve all tool calls globally, across every agent you run. Mirrors the `--yolo` flag and the `/yolo` command. Legacy alias for `safety: autonomous`; when both are set, `safety` wins. |
| `safety` | string | _unset_ | Default [safety mode](../permissions/index.md#safety-modes) for new sessions: `strict`, `balanced`, or `autonomous` (any other value fails config loading). Wins over the legacy `YOLO` flag. Applied when no explicit `--safety`/`--yolo` flag and no alias safety option was given; wins over the agent YAML's `agents.<name>.safety` / `runtime.safety` defaults. Never changes the mode of a resumed session. |
| `safety` | string | _unset_ | Default [safety mode](../permissions/index.md#safety-modes) for new sessions: `strict`, `balanced`, `restricted`, or `autonomous` (any other value fails config loading). Wins over the legacy `YOLO` flag. Applied when no explicit `--safety`/`--yolo` flag and no alias safety option was given; wins over the agent YAML's `agents.<name>.safety` / `runtime.safety` defaults. Never changes the mode of a resumed session. |
| `lean` | boolean | `false` | Make the [lean TUI](../../features/tui/index.md#lean-tui) (simplified, minimal-chrome interface) the default for interactive runs instead of the full TUI. |
| `tab_title_max_length` | int | `20` | Maximum display length for tab titles; longer titles are truncated with an ellipsis. |
| `restore_tabs` | boolean | `false` | Restore previously open tabs when launching the TUI. |
+2 -2
View File
@@ -29,7 +29,7 @@ $ docker agent run [config] [message...] [flags]
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `-a, --agent <name>` | Run a specific agent from the config |
| `--yolo` | Auto-approve tool calls (unless explicitly denied). Legacy alias for `--safety autonomous`. |
| `--safety <mode>` | Safety mode for tool approval: `strict` (ask for everything), `balanced` (auto-approve safe calls), or `autonomous` (approve everything). Wins over `--yolo` when both are given. Without the flag, the mode falls back to alias/user-config defaults, then the agent YAML's `agents.<name>.safety` / `runtime.safety`; a resumed session keeps its stored mode unless `--safety`/`--yolo` is passed explicitly. See [Safety Modes](../../configuration/permissions/index.md#safety-modes). |
| `--safety <mode>` | Safety mode for tool approval: `strict` (ask for everything), `balanced` (auto-approve safe calls), `restricted` (auto-approve safe calls, deny the rest — fail-closed for unattended runs), or `autonomous` (approve everything). Wins over `--yolo` when both are given. Without the flag, the mode falls back to alias/user-config defaults, then the agent YAML's `agents.<name>.safety` / `runtime.safety`; a resumed session keeps its stored mode unless `--safety`/`--yolo` is passed explicitly. See [Safety Modes](../../configuration/permissions/index.md#safety-modes). |
| `--model <ref>` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. |
| `--session <id>` | Resume a previous session. Supports relative refs (`-1` = newest by creation time, `-2` = second-newest, … — creation order, not last-used). An explicit ID that does not exist yet is created with that ID, so a supervisor can own the session ID upfront and reuse it across runs. |
| `-s, --session-db <path>` | Path to the SQLite session database (default: `<data-dir>/session.db`, so `~/.cagent/session.db` unless `--data-dir` is set) |
@@ -524,7 +524,7 @@ $ docker agent run yolo-coder
**Alias Options:** Aliases can include runtime options that apply automatically when used:
- `--yolo` — Auto-approve tool calls (unless explicitly denied) when running the alias. Legacy alias for `--safety autonomous`.
- `--safety <mode>` — Default [safety mode](../../configuration/permissions/index.md#safety-modes) (`strict`, `balanced`, or `autonomous`) when running the alias. Wins over the alias's `yolo` option; both are stored declaratively in the user config (`aliases.<name>.safety` / `aliases.<name>.yolo`), so you can also edit them there by hand.
- `--safety <mode>` — Default [safety mode](../../configuration/permissions/index.md#safety-modes) (`strict`, `balanced`, `restricted`, or `autonomous`) when running the alias. Wins over the alias's `yolo` option; both are stored declaratively in the user config (`aliases.<name>.safety` / `aliases.<name>.yolo`), so you can also edit them there by hand.
- `--model <ref>` — Override the model for the alias
- `--hide-tool-results` — Hide tool call results in the TUI when running the alias
- `--sandbox` — Always run the alias inside a [Docker sandbox](../../configuration/sandbox/index.md)
+20 -3
View File
@@ -92,7 +92,7 @@ Interactively, the TUI prompts for confirmation before a tool call runs unless i
Two different questions come up here, and it's worth keeping them separate:
- **What is allowed to run without asking?** — the safety mode (`--safety strict|balanced|autonomous`, with `--yolo` as the legacy spelling of `autonomous`) and permission allow-lists answer this.
- **What is allowed to run without asking?** — the safety mode (`--safety strict|balanced|restricted|autonomous`, with `--yolo` as the legacy spelling of `autonomous`) and permission allow-lists answer this.
- **What happens if the model runs something it shouldn't have?** — only `--sandbox` answers that one. The rest of this section explains why, and treats that distinction as the whole point.
### `--sandbox`: the isolation boundary
@@ -114,12 +114,29 @@ Permission allow-lists (`permissions.allow` on the agent, or `settings.permissio
- Both work by matching the shell command **string** (or, for `permissions`, the tool's arguments). The classifier's safe-list refuses to vouch for any command carrying shell metacharacters (`;`, `&`, `|`, `<`, `>`, backticks, `$(`, newlines — spaced or not), so `ls && rm -rf ~`, `grep foo|rm -rf /`, and `grep x > /etc/passwd` all fall through to a confirmation instead of inheriting a safe verdict. But string matching still can't reason about what a command actually *does* — see the next point.
- Command-string and argument matching in general can't reason about what a command actually does; a dynamically built string, an unusual quoting form, or a wrapper script can slip past any fixed set of patterns.
Treat permissions and the balanced mode as a way to reduce prompt fatigue and catch the obvious cases, paired with least-privilege CI credentials — never as the reason a CI job is safe to run unattended. For that, use `--sandbox`.
For unattended runs, the `restricted` safety mode packages this stance as a fail-closed default: classifier-safe calls run, every other unmatched call is **denied outright** instead of falling through to a confirmation prompt nobody will answer. Pair it with an allow-list scoped to what the job actually needs — explicit `allow` rules still win over the mode, so the job's known-good commands run even when the classifier can't vouch for them:
```yaml
# agent.yaml — the allow-list overrides restricted's deny for these calls
permissions:
allow:
- "shell:cmd=go test*"
- "shell:cmd=go build*"
```
```bash
# Safe and allow-listed calls run; every other call is denied without prompting
$ docker agent run --exec --safety restricted agent.yaml --json "Fix the failing test"
```
Like the allow-list itself, `restricted` is defense in depth, not a security boundary: it narrows what runs unattended, but only `--sandbox` contains what a misbehaving agent can do with the calls that are allowed.
Treat permissions and the balanced/restricted modes as a way to reduce prompt fatigue and catch the obvious cases, paired with least-privilege CI credentials — never as the reason a CI job is safe to run unattended. For that, use `--sandbox`.
> [!WARNING]
> **`--yolo` without `--sandbox` runs untrusted, unattended code with no boundary**
>
> A CI job is exactly the environment where a runaway or misled agent does the most damage before anyone notices — no one is at the keyboard to catch a bad `shell` call before it runs, and, per above, a permission allow-list or the shell classifier can't be trusted to catch everything either. If you can't add `--sandbox`, prefer `--safety balanced` or a permission allow-list scoped to what the job actually needs over blanket `--yolo`, and budget for the credentials and blast radius of the agent's toolsets as if the job itself were compromised — see [`examples/permissions.yaml`](https://github.com/docker/docker-agent/blob/main/examples/permissions.yaml) for a worked allow/deny list.
> A CI job is exactly the environment where a runaway or misled agent does the most damage before anyone notices — no one is at the keyboard to catch a bad `shell` call before it runs, and, per above, a permission allow-list or the shell classifier can't be trusted to catch everything either. If you can't add `--sandbox`, prefer `--safety restricted` with a permission allow-list scoped to what the job actually needs over blanket `--yolo`, and budget for the credentials and blast radius of the agent's toolsets as if the job itself were compromised — see [`examples/permissions.yaml`](https://github.com/docker/docker-agent/blob/main/examples/permissions.yaml) for a worked allow/deny list.
> [!NOTE]
> **A worktree is not a security boundary either**
+1 -1
View File
@@ -59,7 +59,7 @@ Every shell command is classified against an embedded taxonomy before the approv
- **Known-safe reads** (`ls`, `cat`, `git status`, `git diff`, `docker ps`, `docker logs`, `kubectl get`, …) are labelled `safe`.
- **Everything else** is labelled `unknown`.
The session's [safety mode](../../configuration/permissions/index.md#safety-modes) decides what each label means: `strict` asks about everything, `balanced` auto-runs safe commands and asks about destructive/unknown ones, `autonomous` runs everything. Custom permission rules always win over the mode.
The session's [safety mode](../../configuration/permissions/index.md#safety-modes) decides what each label means: `strict` asks about everything, `balanced` auto-runs safe commands and asks about destructive/unknown ones, `restricted` auto-runs safe commands and denies destructive/unknown ones without asking (fail-closed for unattended runs), `autonomous` runs everything. Custom permission rules always win over the mode.
Compound shell (`a && b`, `a; b`, `a | b`) is never matched against the safe allowlist; any destructive segment falls through to ask. The full taxonomy lives in [`pkg/safety/safety_patterns.json`](https://github.com/docker/docker-agent/blob/main/pkg/safety/safety_patterns.json).
+1 -1
View File
@@ -68,7 +68,7 @@ Examples that wire up one of the toolsets shipped with docker-agent
| [`background_jobs.yaml`](background_jobs.yaml) | `background_jobs` toolset for servers, watchers, and other long-running commands. |
| [`shell_recall.yaml`](shell_recall.yaml) | `background_jobs` with recall enabled for finite long-running commands. |
| [`docker-wiki.yaml`](docker-wiki.yaml) | OpenWiki-inspired documentation agent that initializes and updates a `docker-wiki/` directory with `/init`, `/update`, and `/status` commands. |
| [`safety_modes.yaml`](safety_modes.yaml) | Shell agents demonstrating the three safety modes (strict / balanced / autonomous) and the declarative YAML defaults: a config-wide `runtime.safety` plus a per-agent `safety` override. User choices (CLI flags, alias options, user settings) always win over the YAML defaults. |
| [`safety_modes.yaml`](safety_modes.yaml) | Shell agents demonstrating the four safety modes (strict / balanced / restricted / autonomous) and the declarative YAML defaults: a config-wide `runtime.safety` plus per-agent `safety` overrides. User choices (CLI flags, alias options, user settings) always win over the YAML defaults. |
| [`filesystem.yaml`](filesystem.yaml) | Plain `filesystem` toolset. |
| [`filesystem_allow_deny.yaml`](filesystem_allow_deny.yaml) | Restricting the filesystem tool with allow/deny path lists. |
| [`script_shell.yaml`](script_shell.yaml) | Defining custom shell commands as named tools via `type: script`. |
+1 -1
View File
@@ -8,7 +8,7 @@ metadata:
- deny: Tools matching these patterns are always rejected
- allow: Tools matching these patterns are auto-approved
- (default): Tools not matching either pattern fall through to the
session's safety mode (strict / balanced / autonomous)
session's safety mode (strict / balanced / restricted / autonomous)
Evaluation order: Deny (first) -> Allow -> Ask -> safety mode
+26 -7
View File
@@ -2,8 +2,9 @@
# runtime.safety — config-wide default for new sessions
# agents.<name>.safety — per-agent override of the config default
#
# Only strict, balanced, and autonomous are accepted. These are DEFAULTS
# only; precedence (highest first) when a new root session starts:
# Only strict, balanced, restricted, and autonomous are accepted. These
# are DEFAULTS only; precedence (highest first) when a new root session
# starts:
#
# --safety flag > --yolo flag > alias safety/yolo option >
# settings.safety/YOLO (user config) > agents.<name>.safety >
@@ -21,7 +22,7 @@ runtime:
agents:
root:
model: anthropic/claude-haiku-4-5
description: Shell agent demonstrating the three safety modes
description: Shell agent demonstrating the safety modes
welcome_message: |
Every shell command is classified (safe / destructive / unknown)
and gated by the session's safety mode:
@@ -29,16 +30,19 @@ agents:
strict — ask for every tool call
balanced — safe commands run silently; destructive and
unknown commands ask
restricted — safe commands run silently; destructive and
unknown commands are denied without asking
(fail-closed, for unattended runs)
autonomous — everything runs (legacy --yolo); only custom
deny/ask rules still gate
This config declares `runtime.safety: balanced`, so fresh sessions
start balanced unless you choose otherwise (`--safety`, `--yolo`,
an alias option, or `settings.safety` in your user config). The
`auditor` agent overrides the config default with `safety: strict`.
You can still escalate from a confirmation prompt ([B] balanced,
[A] autonomous). Custom permissions rules (see permissions.yaml)
always win over the mode.
`auditor` agent overrides the config default with `safety: strict`,
and `ci` with `safety: restricted`. You can still escalate from a
confirmation prompt ([B] balanced, [A] autonomous). Custom
permissions rules (see permissions.yaml) always win over the mode.
instruction: Use the shell tool to run the command the user asks for.
toolsets:
- type: shell
@@ -54,3 +58,18 @@ agents:
and explain what each command does before running it.
toolsets:
- type: shell
# Fail-closed default for unattended runs: safe commands run, anything
# destructive or unknown is denied without prompting. Pair with an
# `allow:` permissions list for the commands the job actually needs
# (explicit rules win over the mode). Defense in depth, not isolation —
# use --sandbox for a real security boundary.
ci:
model: anthropic/claude-haiku-4-5
description: Headless agent that denies unmatched calls instead of prompting (restricted mode)
safety: restricted
instruction: >
Run the checks the user asks for with the shell tool. If a command
is denied, report it instead of working around the denial.
toolsets:
- type: shell
+2 -2
View File
@@ -162,8 +162,8 @@ type UpdateSessionPermissionsRequest struct {
// UpdateSessionSafetyPolicyRequest represents a request to change a
// session's safety mode mid-session. Accepts "strict", "balanced",
// "autonomous", "" (legacy default), and the deprecated aliases
// "unsafe" / "safer" / "safe-auto".
// "restricted", "autonomous", "" (legacy default), and the deprecated
// aliases "unsafe" / "safer" / "safe-auto".
type UpdateSessionSafetyPolicyRequest struct {
SafetyPolicy session.SafetyPolicy `json:"safety_policy"`
}
+12 -7
View File
@@ -130,7 +130,7 @@ func (b *BudgetConfig) validate() error {
// SafetyMode is a declarative safety-mode default that agent authors
// (runtime.safety, agents.<name>.safety) and users (settings.safety,
// alias safety) can put in YAML. Only the three canonical session modes
// alias safety) can put in YAML. Only the four canonical session modes
// are accepted; the legacy aliases the session layer still normalizes
// (unsafe, safer, safe-auto) are not valid in configuration files.
//
@@ -143,17 +143,21 @@ const (
SafetyModeStrict SafetyMode = "strict"
// SafetyModeBalanced auto-approves classifier-safe calls only.
SafetyModeBalanced SafetyMode = "balanced"
// SafetyModeRestricted auto-approves classifier-safe calls and
// denies everything else without prompting (fail-closed default
// for unattended runs; custom permission rules still win).
SafetyModeRestricted SafetyMode = "restricted"
// SafetyModeAutonomous auto-approves every call (legacy yolo).
SafetyModeAutonomous SafetyMode = "autonomous"
)
// Validate accepts the three canonical modes and empty (unset).
// Validate accepts the four canonical modes and empty (unset).
func (m SafetyMode) Validate() error {
switch m {
case "", SafetyModeStrict, SafetyModeBalanced, SafetyModeAutonomous:
case "", SafetyModeStrict, SafetyModeBalanced, SafetyModeRestricted, SafetyModeAutonomous:
return nil
}
return fmt.Errorf("invalid safety mode %q (valid: strict, balanced, autonomous)", string(m))
return fmt.Errorf("invalid safety mode %q (valid: strict, balanced, restricted, autonomous)", string(m))
}
// RuntimeDefaults captures execution-time defaults the agent author
@@ -2488,12 +2492,13 @@ type RAGFusionConfig struct {
}
// PermissionsConfig configures custom per-tool rules that layer on
// top of the session's safety mode (strict / balanced / autonomous):
// top of the session's safety mode (strict / balanced / restricted /
// autonomous):
// - Allow: matching tools auto-approve, even under strict
// - Deny: matching tools are rejected, even under autonomous
// - Ask: matching tools require user approval. Rules from this config
// (the agent-author tier) yield to a user-chosen balanced/autonomous
// mode; session-scoped ask rules always prompt.
// (the agent-author tier) yield to a user-chosen balanced/restricted/
// autonomous mode; session-scoped ask rules always prompt.
//
// Patterns support glob-style matching (e.g., "shell", "read_*",
// "mcp:github:*", "shell:cmd=git status*"). Within a config the
+13 -10
View File
@@ -571,7 +571,7 @@ func TestConfigValidateErrorWrapping(t *testing.T) {
{
name: "runtime safety error",
config: Config{Runtime: &RuntimeDefaults{Safety: "yolo"}},
wantErr: "runtime.safety: invalid safety mode \"yolo\" (valid: strict, balanced, autonomous)",
wantErr: "runtime.safety: invalid safety mode \"yolo\" (valid: strict, balanced, restricted, autonomous)",
},
{
name: "runtime safety legacy alias rejected",
@@ -581,7 +581,7 @@ func TestConfigValidateErrorWrapping(t *testing.T) {
{
name: "agent safety error",
config: Config{Agents: Agents{{Name: "root", Safety: "Strict"}}},
wantErr: "agents.root.safety: invalid safety mode \"Strict\" (valid: strict, balanced, autonomous)",
wantErr: "agents.root.safety: invalid safety mode \"Strict\" (valid: strict, balanced, restricted, autonomous)",
},
}
@@ -622,7 +622,7 @@ func TestConfigValidateValidConfig(t *testing.T) {
Toolsets: map[string]Toolset{
"web": {Type: "fetch", AllowedDomains: []string{"example.com", "*.example.org"}},
},
// All three canonical modes are explicitly permitted at runtime and
// All four canonical modes are explicitly permitted at runtime and
// agent scope, autonomous included.
Runtime: &RuntimeDefaults{Safety: SafetyModeAutonomous},
Agents: Agents{
@@ -640,6 +640,7 @@ func TestConfigValidateValidConfig(t *testing.T) {
Hooks: &HooksConfig{Stop: HookDefinitions{{Type: "command", Command: "echo done"}}},
},
{Name: "careful", Model: "main", Safety: SafetyModeStrict},
{Name: "headless", Model: "main", Safety: SafetyModeRestricted},
},
}
@@ -653,15 +654,17 @@ func TestSafetyModeValidate(t *testing.T) {
"": true,
SafetyModeStrict: true,
SafetyModeBalanced: true,
SafetyModeRestricted: true,
SafetyModeAutonomous: true,
// Legacy session aliases and near-misses are rejected: only the
// three canonical modes may appear in YAML safety fields.
"unsafe": false,
"safer": false,
"safe-auto": false,
"yolo": false,
"Balanced": false, // case-sensitive on purpose
// four canonical modes may appear in YAML safety fields.
"unsafe": false,
"safer": false,
"safe-auto": false,
"yolo": false,
"Balanced": false, // case-sensitive on purpose
"Restricted": false,
}
for in, wantOK := range cases {
@@ -670,7 +673,7 @@ func TestSafetyModeValidate(t *testing.T) {
require.NoErrorf(t, err, "SafetyMode(%q).Validate()", string(in))
} else {
require.ErrorContainsf(t, err, "invalid safety mode", "SafetyMode(%q).Validate()", string(in))
require.ErrorContainsf(t, err, "strict, balanced, autonomous", "SafetyMode(%q).Validate()", string(in))
require.ErrorContainsf(t, err, "strict, balanced, restricted, autonomous", "SafetyMode(%q).Validate()", string(in))
}
}
}
+2 -1
View File
@@ -275,7 +275,8 @@ type Input struct {
ToolInput map[string]any `json:"tool_input,omitempty"`
// SafetyPolicy mirrors the session's effective safety mode
// (strict / balanced / autonomous, empty for the legacy default;
// (strict / balanced / restricted / autonomous, empty for the
// legacy default;
// see [github.com/docker/docker-agent/pkg/session.SafetyPolicy])
// on tool-call events. Typed as string to avoid a session dep.
SafetyPolicy string `json:"safety_policy,omitempty"`
+1
View File
@@ -465,6 +465,7 @@ const (
ApprovalSourcePreToolUseHookDeny = "pre_tool_use_hook_deny"
ApprovalSourceReadOnlyHint = "readonly_hint"
ApprovalSourceModeBalanced = "mode_balanced"
ApprovalSourceModeRestricted = "mode_restricted"
ApprovalSourceModeStrict = "mode_strict"
ApprovalSourceModeLegacy = "mode_legacy"
ApprovalSourceUserApproved = "user_approved"
+1 -1
View File
@@ -57,7 +57,7 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
// permissionCheckers returns the ordered list of permission checkers to
// evaluate (session-level first, then team-level). The tiers matter:
// session ask rules beat every safety mode, team ask rules yield to
// Balanced/Autonomous — see [toolexec.Decide].
// Balanced/Restricted/Autonomous — see [toolexec.Decide].
func (r *LocalRuntime) permissionCheckers(sess *session.Session) []toolexec.NamedChecker {
var checkers []toolexec.NamedChecker
if perms := sess.ClonePermissions(); perms != nil {
+28 -6
View File
@@ -51,10 +51,11 @@ const (
// auto-approve of read-only-annotated tools (sessions that never
// chose an explicit safety mode).
ApprovalSourceReadOnlyHint = "readonly_hint"
// ApprovalSourceModeBalanced / ModeStrict / ModeLegacy identify
// verdicts produced by the (mode × label) table. Autonomous
// allows keep the stable "yolo" source above.
// ApprovalSourceModeBalanced / ModeRestricted / ModeStrict /
// ModeLegacy identify verdicts produced by the (mode × label)
// table. Autonomous allows keep the stable "yolo" source above.
ApprovalSourceModeBalanced = "mode_balanced"
ApprovalSourceModeRestricted = "mode_restricted"
ApprovalSourceModeStrict = "mode_strict"
ApprovalSourceModeLegacy = "mode_legacy"
ApprovalSourceUserApproved = "user_approved"
@@ -478,9 +479,9 @@ func (c *call) approveAndRun(ctx context.Context, runTool func() CallOutcome) Ca
c.notifyApproval(ctx, ApprovalDecisionAllow, allowSourceForDecision(decision))
return runTool()
case OutcomeDeny:
slog.DebugContext(ctx, "Tool denied by permissions", "tool", c.tc.Function.Name, "source", decision.Source, "session_id", c.sess.ID)
c.notifyApproval(ctx, ApprovalDecisionDeny, denySourceForChecker(decision.Source))
c.errorResponse(ctx, fmt.Sprintf("Tool '%s' is denied by %s.", c.tc.Function.Name, decision.Source))
slog.DebugContext(ctx, "Tool denied", "tool", c.tc.Function.Name, "source", decision.Source, "session_id", c.sess.ID)
c.notifyApproval(ctx, ApprovalDecisionDeny, denySourceForDecision(decision))
c.errorResponse(ctx, denyErrorMessage(decision, c.tc.Function.Name))
return CallOutcome{}
case OutcomeAsk:
if decision.Reason == ReasonChecker {
@@ -750,6 +751,27 @@ func denySourceForChecker(checkerSource string) string {
return ApprovalSourceTeamPermissionsDeny
}
// denySourceForDecision mirrors allowSourceForDecision for the deny
// path: mode decisions (only Restricted denies) already carry the
// ApprovalSource* constant in Source.
func denySourceForDecision(d PermissionDecision) string {
if d.Reason == ReasonMode {
return d.Source
}
return denySourceForChecker(d.Source)
}
// denyErrorMessage renders the tool-error text for an [OutcomeDeny]
// decision. Checker denials name the rule source; a mode denial (only
// the restricted mode denies) explains the fail-closed fallback so the
// model can adapt instead of blindly retrying.
func denyErrorMessage(d PermissionDecision, toolName string) string {
if d.Reason == ReasonMode {
return fmt.Sprintf("Tool '%s' is denied by the restricted safety mode: the call is not classified safe, and the mode's fallback denies instead of asking for confirmation.", toolName)
}
return fmt.Sprintf("Tool '%s' is denied by %s.", toolName, d.Source)
}
// askUser sends a confirmation event and waits for the user's response
// on the resume channel or for ctx cancellation. Only called when no
// permission rule auto-approved the tool.
+90
View File
@@ -692,6 +692,96 @@ func TestDispatcher_BalancedClassifiesShellCommands(t *testing.T) {
})
}
// 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
// mode_restricted audit source on both legs.
func TestDispatcher_RestrictedFailClosed(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.SafetyPolicyRestricted))
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
},
}
hd := &stubHookDispatcher{}
d := &toolexec.Dispatcher{
AgentFor: func(*session.Session) *agent.Agent { return a },
Hooks: hd,
}
em := &captureEmitter{}
d.Process(t.Context(), sess, []tools.ToolCall{{
ID: "s",
Function: tools.FunctionCall{Name: "shell", Arguments: `{"cmd":"git status"}`},
}}, []tools.Tool{tool}, em)
assert.True(t, ran, "restricted must auto-approve classifier-safe shell commands")
assert.Empty(t, em.confirmations)
require.Len(t, hd.approvals, 1)
assert.Equal(t, approvalRecord{Decision: toolexec.ApprovalDecisionAllow, Source: toolexec.ApprovalSourceModeRestricted}, hd.approvals[0])
})
denyCases := []struct {
name string
tool string
args string
}{
{name: "destructive command is denied without prompting", tool: "shell", args: `{"cmd":"rm -rf /tmp/x"}`},
{name: "unknown tool is denied without prompting", tool: "custom_tool", args: "{}"},
}
for _, tc := range denyCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sess := session.New(session.WithSafetyPolicy(session.SafetyPolicyRestricted))
tool := tools.Tool{
Name: tc.tool,
Handler: func(context.Context, tools.ToolCall, tools.Runtime) (*tools.ToolCallResult, error) {
panic("must not run under restricted")
},
}
hd := &stubHookDispatcher{}
// No Resume channel: if the dispatcher wrongly asked for
// confirmation it would block, so cancel on the first
// confirmation event to fail cleanly instead of hanging.
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
d := &toolexec.Dispatcher{
AgentFor: func(*session.Session) *agent.Agent { return a },
Hooks: hd,
}
em := &captureEmitter{confirmed: make(chan struct{})}
go func() {
<-em.confirmed
cancel()
}()
d.Process(ctx, sess, []tools.ToolCall{{
ID: "x",
Function: tools.FunctionCall{Name: tc.tool, Arguments: tc.args},
}}, []tools.Tool{tool}, em)
assert.Empty(t, em.confirmations, "restricted must deny without asking for confirmation")
require.Len(t, em.responses, 1)
assert.True(t, em.responses[0].IsError)
assert.Contains(t, em.responses[0].Output, "restricted safety mode")
assert.Contains(t, em.responses[0].Output, "the mode's fallback denies instead of asking",
"deny message must blame the mode fallback, not the whole session — ask rules and preempt hooks can still prompt")
require.Len(t, hd.approvals, 1)
assert.Equal(t, approvalRecord{Decision: toolexec.ApprovalDecisionDeny, Source: toolexec.ApprovalSourceModeRestricted}, hd.approvals[0])
})
}
}
// A custom deny rule blocks a call even under Autonomous.
func TestDispatcher_DenyRuleBlocksUnderAutonomous(t *testing.T) {
t.Parallel()
+13 -1
View File
@@ -38,6 +38,15 @@ type stubHookDispatcher struct {
// in order. Tests assert against this to pin negative cases —
// "this event must NOT have been dispatched in pipeline X."
dispatched []hooks.EventType
// approvals records every NotifyApprovalDecision call so tests can
// assert the audit decision/source the dispatcher resolved.
approvals []approvalRecord
}
// approvalRecord captures one NotifyApprovalDecision call.
type approvalRecord struct {
Decision string
Source string
}
func (s *stubHookDispatcher) Dispatch(_ context.Context, _ *agent.Agent, event hooks.EventType, in *hooks.Input) *hooks.Result {
@@ -51,5 +60,8 @@ func (s *stubHookDispatcher) Dispatch(_ context.Context, _ *agent.Agent, event h
}
func (s *stubHookDispatcher) NotifyUserInput(context.Context, string, string) {}
func (s *stubHookDispatcher) NotifyApprovalDecision(context.Context, *session.Session, *agent.Agent, tools.ToolCall, string, string, string) {
func (s *stubHookDispatcher) NotifyApprovalDecision(_ context.Context, _ *session.Session, _ *agent.Agent, _ tools.ToolCall, decision, source, _ string) {
s.mu.Lock()
defer s.mu.Unlock()
s.approvals = append(s.approvals, approvalRecord{Decision: decision, Source: source})
}
+23 -11
View File
@@ -40,7 +40,7 @@ const (
// permissions API); team rules come from the agent YAML and user-global
// config. The tiers differ in one way: a session `ask:` rule always
// prompts, while a team `ask:` rule yields to a user-chosen
// auto-approving mode (Balanced / Autonomous).
// auto-deciding mode (Balanced / Restricted / Autonomous).
type Tier int
const (
@@ -71,10 +71,12 @@ type PermissionDecision struct {
//
// 1. Custom rules, walked in checker order (session tier first, then
// team tier). Deny and Allow win outright — a deny rule blocks
// even Autonomous, an allow rule silences even Strict. An explicit
// ask rule prompts, except that a team-tier ask yields to
// Balanced/Autonomous (the user's chosen mode outranks the agent
// author's advisory).
// even Autonomous, an allow rule silences even Strict and
// overrides Restricted's fail-closed fallback. An explicit ask
// rule prompts, except that a team-tier ask yields to
// Balanced/Restricted/Autonomous (the user's chosen mode outranks
// the agent author's advisory; under Restricted this keeps the
// profile prompt-free instead of introducing implicit asks).
// 2. No rule matched: the (mode × label) table via [applyMode].
//
// Decide is pure (no I/O, no side effects) so the entire approval
@@ -86,7 +88,9 @@ func Decide(
toolName string,
toolArgs map[string]any,
) PermissionDecision {
autoApproving := mode == session.SafetyPolicyBalanced || mode == session.SafetyPolicyAutonomous
autoDeciding := mode == session.SafetyPolicyBalanced ||
mode == session.SafetyPolicyRestricted ||
mode == session.SafetyPolicyAutonomous
for _, pc := range checkers {
switch pc.Checker.CheckWithArgs(toolName, toolArgs) {
case permissions.Deny:
@@ -94,11 +98,11 @@ func Decide(
case permissions.Allow:
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonChecker, Source: pc.Source}
case permissions.ForceAsk:
if pc.Tier == TierSession || !autoApproving {
if pc.Tier == TierSession || !autoDeciding {
return PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonChecker, Source: pc.Source}
}
// Team-tier ask is advisory under Balanced/Autonomous;
// fall through to the mode.
// Team-tier ask is advisory under Balanced/Restricted/
// Autonomous; fall through to the mode.
case permissions.Ask:
// No explicit match at this level; fall through to next checker.
}
@@ -111,6 +115,7 @@ func Decide(
// safe destructive unknown
// strict ask ask ask
// balanced ALLOW ask ask
// restricted ALLOW DENY DENY
// autonomous ALLOW ALLOW ALLOW
// "" (legacy) ask* ask ask
//
@@ -119,8 +124,10 @@ func Decide(
// hinted) calls, but only AFTER the default pre_tool_use hook chain
// has had its turn; see the dispatcher's approveAndRun. That keeps the
// pre-modes contract: hooks can veto read-only calls, and read-only
// tools never prompt. Unrecognised modes are treated as strict so an
// invalid value can never widen approval.
// tools never prompt. Restricted is the fail-closed profile for
// unattended runs: it never prompts, denying whatever it would not
// allow. Unrecognised modes are treated as strict so an invalid value
// can never widen approval.
func applyMode(mode session.SafetyPolicy, label safety.Label) PermissionDecision {
switch mode {
case session.SafetyPolicyAutonomous:
@@ -130,6 +137,11 @@ func applyMode(mode session.SafetyPolicy, label safety.Label) PermissionDecision
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonMode, Source: ApprovalSourceModeBalanced}
}
return PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonMode, Source: ApprovalSourceModeBalanced}
case session.SafetyPolicyRestricted:
if label.Class == safety.ClassSafe {
return PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}
}
return PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}
case session.SafetyPolicyStrict:
return PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonMode, Source: ApprovalSourceModeStrict}
default:
+76 -2
View File
@@ -50,6 +50,13 @@ func TestDecide_ModeLabelTable(t *testing.T) {
{session.SafetyPolicyBalanced, labelDestructive, OutcomeAsk},
{session.SafetyPolicyBalanced, labelUnknown, OutcomeAsk},
// Restricted is fail-closed: safe allows, everything else is
// denied without prompting.
{session.SafetyPolicyRestricted, labelSafeAnnotation, OutcomeAllow},
{session.SafetyPolicyRestricted, labelSafeClassifier, OutcomeAllow},
{session.SafetyPolicyRestricted, labelDestructive, OutcomeDeny},
{session.SafetyPolicyRestricted, labelUnknown, OutcomeDeny},
{session.SafetyPolicyAutonomous, labelSafeAnnotation, OutcomeAllow},
{session.SafetyPolicyAutonomous, labelSafeClassifier, OutcomeAllow},
{session.SafetyPolicyAutonomous, labelDestructive, OutcomeAllow},
@@ -96,9 +103,61 @@ func TestDecide_SessionAskBeatsAutonomous(t *testing.T) {
assert.Equal(t, PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonChecker, Source: "session permissions"}, d)
}
// Restricted's mode decisions carry the stable mode_restricted audit
// source on both the allow and the deny leg.
func TestDecide_RestrictedModeSource(t *testing.T) {
t.Parallel()
d := Decide(session.SafetyPolicyRestricted, labelSafeClassifier, nil, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d)
d = Decide(session.SafetyPolicyRestricted, labelUnknown, nil, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d)
d = Decide(session.SafetyPolicyRestricted, labelDestructive, nil, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d)
}
// An explicit allow rule overrides restricted's fail-closed fallback:
// custom rules keep winning over the mode, destructive/unknown included.
func TestDecide_AllowOverridesRestrictedFallback(t *testing.T) {
t.Parallel()
for _, tier := range []Tier{TierSession, TierTeam} {
for _, label := range []safety.Label{labelDestructive, labelUnknown} {
d := Decide(session.SafetyPolicyRestricted, label, []NamedChecker{
{Checker: newChecker(t, []string{"shell"}, nil, nil), Source: "rules", Tier: tier},
}, "shell", nil)
assert.Equalf(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonChecker, Source: "rules"}, d,
"tier=%d label=%s", tier, label.Class)
}
}
}
// A deny rule still wins under restricted, even for a call the mode
// itself would allow (safe).
func TestDecide_DenyOverridesRestrictedAllow(t *testing.T) {
t.Parallel()
d := Decide(session.SafetyPolicyRestricted, labelSafeClassifier, []NamedChecker{
{Checker: newChecker(t, nil, nil, []string{"shell"}), Source: "rules", Tier: TierTeam},
}, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonChecker, Source: "rules"}, d)
}
// A session-tier ask rule is direct user intent and still prompts
// under restricted — the mode only replaces its own fallback.
func TestDecide_SessionAskBeatsRestricted(t *testing.T) {
t.Parallel()
for _, label := range []safety.Label{labelSafeClassifier, labelDestructive, labelUnknown} {
d := Decide(session.SafetyPolicyRestricted, label, []NamedChecker{
{Checker: newChecker(t, nil, []string{"shell"}, nil), Source: "session permissions", Tier: TierSession},
}, "shell", nil)
assert.Equalf(t, PermissionDecision{Outcome: OutcomeAsk, Reason: ReasonChecker, Source: "session permissions"}, d,
"label=%s", label.Class)
}
}
// A team-tier ask rule is agent-author advisory: it prompts under
// strict/legacy but yields to a user-chosen auto-approving mode.
func TestDecide_TeamAskYieldsToAutoApprovingModes(t *testing.T) {
// strict/legacy but yields to a user-chosen auto-deciding mode.
func TestDecide_TeamAskYieldsToAutoDecidingModes(t *testing.T) {
t.Parallel()
checkers := []NamedChecker{
{Checker: newChecker(t, nil, []string{"shell"}, nil), Source: "permissions configuration", Tier: TierTeam},
@@ -122,6 +181,21 @@ func TestDecide_TeamAskYieldsToAutoApprovingModes(t *testing.T) {
d = Decide(session.SafetyPolicyAutonomous, labelDestructive, checkers, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonMode, Source: ApprovalSourceYolo}, d,
"team ask yields to autonomous")
// Restricted never prompts: a team ask must yield to its mode
// verdict — allow for safe, deny for destructive/unknown — so the
// profile stays free of implicit prompts.
d = Decide(session.SafetyPolicyRestricted, labelSafeClassifier, checkers, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeAllow, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d,
"team ask yields to restricted for safe calls")
d = Decide(session.SafetyPolicyRestricted, labelDestructive, checkers, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d,
"team ask yields to restricted's deny for destructive calls")
d = Decide(session.SafetyPolicyRestricted, labelUnknown, checkers, "shell", nil)
assert.Equal(t, PermissionDecision{Outcome: OutcomeDeny, Reason: ReasonMode, Source: ApprovalSourceModeRestricted}, d,
"team ask yields to restricted's deny for unknown calls")
}
func TestDecide_FirstCheckerWins_SessionBeforeTeam(t *testing.T) {
+5
View File
@@ -73,6 +73,11 @@ func TestSetSessionSafetyPolicy_NormalizesAndDowngrades(t *testing.T) {
assert.Equal(t, session.SafetyPolicyStrict, sess.GetSafetyPolicy())
assert.False(t, sess.IsToolsApproved())
// Restricted is a first-class mode on the API surface.
require.NoError(t, sm.SetSessionSafetyPolicy(t.Context(), sess.ID, session.SafetyPolicyRestricted))
assert.Equal(t, session.SafetyPolicyRestricted, sess.GetSafetyPolicy())
assert.False(t, sess.IsToolsApproved())
// Invalid values are rejected.
assert.Error(t, sm.SetSessionSafetyPolicy(t.Context(), sess.ID, "bogus"))
}
+21
View File
@@ -13,6 +13,7 @@ func TestSafetyPolicy_IsValid(t *testing.T) {
"": true,
SafetyPolicyStrict: true,
SafetyPolicyBalanced: true,
SafetyPolicyRestricted: true,
SafetyPolicyAutonomous: true,
// Legacy aliases stay accepted on input.
"unsafe": true,
@@ -32,6 +33,7 @@ func TestSafetyPolicy_Normalize(t *testing.T) {
"": "",
SafetyPolicyStrict: SafetyPolicyStrict,
SafetyPolicyBalanced: SafetyPolicyBalanced,
SafetyPolicyRestricted: SafetyPolicyRestricted,
SafetyPolicyAutonomous: SafetyPolicyAutonomous,
"unsafe": SafetyPolicyAutonomous,
"safer": SafetyPolicyBalanced,
@@ -57,6 +59,10 @@ func TestWithSafetyPolicy_SyncsToolsApproved(t *testing.T) {
assert.Equal(t, SafetyPolicyBalanced, s.SafetyPolicy)
assert.False(t, s.ToolsApproved)
s = New(WithSafetyPolicy(SafetyPolicyRestricted))
assert.Equal(t, SafetyPolicyRestricted, s.SafetyPolicy)
assert.False(t, s.ToolsApproved, "restricted must not grant blanket approval")
// Legacy alias normalizes at the boundary.
s = New(WithSafetyPolicy("unsafe"))
assert.Equal(t, SafetyPolicyAutonomous, s.SafetyPolicy)
@@ -166,6 +172,21 @@ func TestToggleYolo_RestoresPriorMode(t *testing.T) {
assert.Equal(t, SafetyPolicy(""), s.PriorSafetyPolicy, "toggle memory must be consumed")
}
// Restricted participates in the yolo toggle like any other explicit
// mode: escalate to autonomous, toggle back restores restricted.
func TestToggleYolo_RestoresRestricted(t *testing.T) {
t.Parallel()
s := New(WithSafetyPolicy(SafetyPolicyRestricted))
s.ToggleYolo()
assert.Equal(t, SafetyPolicyAutonomous, s.GetSafetyPolicy())
assert.True(t, s.IsToolsApproved())
s.ToggleYolo()
assert.Equal(t, SafetyPolicyRestricted, s.GetSafetyPolicy())
assert.False(t, s.IsToolsApproved())
}
// A session that never chose a named mode keeps the historical
// contract: toggle-off lands on the legacy default, not strict.
func TestToggleYolo_LegacyRoundTrip(t *testing.T) {
+14 -8
View File
@@ -34,9 +34,9 @@ const (
)
// SafetyPolicy is the per-session safety mode. The runtime routes
// tool calls to allow/ask through the (mode × safety-label) table in
// pkg/runtime/toolexec; custom permission rules always win over the
// mode.
// tool calls to allow/ask/deny through the (mode × safety-label)
// table in pkg/runtime/toolexec; custom permission rules always win
// over the mode.
//
// Empty means "never explicitly chosen": tool calls behave like the
// pre-modes default (read-only-annotated tools auto-approve, everything
@@ -51,6 +51,12 @@ const (
// (safe-listed shell commands, read-only-annotated tools); asks
// on destructive and unknown.
SafetyPolicyBalanced SafetyPolicy = "balanced"
// SafetyPolicyRestricted is the fail-closed mode for unattended /
// headless runs: classifier-safe calls auto-approve, destructive
// and unknown calls are denied without ever prompting. Custom
// rules still win (an explicit allow can approve a destructive
// call, a deny always blocks, a session ask still prompts).
SafetyPolicyRestricted SafetyPolicy = "restricted"
// SafetyPolicyAutonomous auto-approves every call (legacy yolo).
// Only custom deny/ask rules and preempt hooks still gate.
SafetyPolicyAutonomous SafetyPolicy = "autonomous"
@@ -65,7 +71,7 @@ const (
legacyPolicySafeAuto SafetyPolicy = "safe-auto"
)
// Normalize maps legacy policy values onto the current three-mode
// Normalize maps legacy policy values onto the current mode
// vocabulary: unsafe → autonomous, safer / safe-auto → balanced
// (the cautious mapping: old "safer" also waved unknown calls
// through, balanced asks about them). Current values and empty pass
@@ -73,7 +79,7 @@ const (
// unknown input can never widen approval.
func (p SafetyPolicy) Normalize() SafetyPolicy {
switch p {
case "", SafetyPolicyStrict, SafetyPolicyBalanced, SafetyPolicyAutonomous:
case "", SafetyPolicyStrict, SafetyPolicyBalanced, SafetyPolicyRestricted, SafetyPolicyAutonomous:
return p
case legacyPolicyUnsafe:
return SafetyPolicyAutonomous
@@ -87,7 +93,7 @@ func (p SafetyPolicy) Normalize() SafetyPolicy {
// IsValid accepts current values, the legacy aliases, and empty.
func (p SafetyPolicy) IsValid() bool {
switch p {
case "", SafetyPolicyStrict, SafetyPolicyBalanced, SafetyPolicyAutonomous,
case "", SafetyPolicyStrict, SafetyPolicyBalanced, SafetyPolicyRestricted, SafetyPolicyAutonomous,
legacyPolicyUnsafe, legacyPolicySafer, legacyPolicySafeAuto:
return true
}
@@ -255,7 +261,7 @@ type Session struct {
ToolsApproved bool `json:"tools_approved"`
// SafetyPolicy is the per-session safety preference. See the
// [SafetyPolicy] type doc for the three modes and empty-value semantics.
// [SafetyPolicy] type doc for the modes and empty-value semantics.
SafetyPolicy SafetyPolicy `json:"safety_policy,omitempty"`
// PriorSafetyPolicy remembers the mode that was active before a yolo
@@ -1427,7 +1433,7 @@ func WithToolsApproved(toolsApproved bool) Opt {
}
// WithSafetyPolicy sets the session's safety mode. The input is
// normalized (legacy aliases map onto the three-mode vocabulary) and
// normalized (legacy aliases map onto the current mode vocabulary) and
// ToolsApproved is kept in sync so legacy readers of that flag agree
// with the mode. Empty means "no explicit choice" and is a no-op, so
// the option composes with [WithToolsApproved] regardless of order
+9 -1
View File
@@ -1203,6 +1203,10 @@ agents:
model: openai/gpt-4o
instruction: test
safety: balanced
headless:
model: openai/gpt-4o
instruction: test
safety: restricted
careful:
model: openai/gpt-4o
instruction: test
@@ -1217,6 +1221,10 @@ agents:
require.NoError(t, err)
assert.Equal(t, latest.SafetyModeBalanced, root.Safety())
headless, err := team.Agent("headless")
require.NoError(t, err)
assert.Equal(t, latest.SafetyModeRestricted, headless.Safety())
careful, err := team.Agent("careful")
require.NoError(t, err)
assert.Empty(t, careful.Safety(), "agent without a safety default carries none of its own")
@@ -1260,7 +1268,7 @@ agents:
t.Run(tt.name, func(t *testing.T) {
_, err := Load(t.Context(), config.NewBytesSource("bad.yaml", []byte(tt.data)), &config.RuntimeConfig{}, withTestProviderRegistry()...)
require.ErrorContains(t, err, tt.wantErr)
require.ErrorContains(t, err, "strict, balanced, autonomous")
require.ErrorContains(t, err, "strict, balanced, restricted, autonomous")
})
}
}
+3 -3
View File
@@ -30,8 +30,8 @@ type Alias struct {
// Safety: autonomous; when both are set, Safety wins.
Yolo bool `yaml:"yolo,omitempty" json:"yolo,omitempty"`
// Safety is the default safety mode applied when the alias is run and
// no explicit --safety/--yolo flag was passed: strict, balanced, or
// autonomous. Wins over the legacy Yolo flag.
// no explicit --safety/--yolo flag was passed: strict, balanced,
// restricted, or autonomous. Wins over the legacy Yolo flag.
Safety latest.SafetyMode `yaml:"safety,omitempty" json:"safety,omitempty"`
// Model overrides the agent's model (format: [agent=]provider/model)
Model string `yaml:"model,omitempty" json:"model,omitempty"`
@@ -90,7 +90,7 @@ type Settings struct {
YOLO bool `yaml:"YOLO,omitempty"`
// Safety is the global default safety mode applied when no explicit
// --safety/--yolo flag and no alias safety option was given: strict,
// balanced, or autonomous. Wins over the legacy YOLO flag.
// balanced, restricted, or autonomous. Wins over the legacy YOLO flag.
Safety latest.SafetyMode `yaml:"safety,omitempty"`
// Lean makes the simplified TUI with minimal chrome the default UI.
Lean bool `yaml:"lean,omitempty"`
+16 -1
View File
@@ -1440,6 +1440,21 @@ func TestConfig_Settings_Safety(t *testing.T) {
assert.Equal(t, latest.SafetyModeBalanced, cfg.GetSettings().GetSafety())
}
func TestConfig_Settings_SafetyRestricted(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte(`settings:
safety: restricted
`), 0o644))
cfg, err := loadFrom(configFile, "")
require.NoError(t, err)
assert.Equal(t, latest.SafetyModeRestricted, cfg.GetSettings().Safety)
assert.Equal(t, latest.SafetyModeRestricted, cfg.GetSettings().GetSafety())
}
func TestConfig_Settings_InvalidSafetyFailsLoad(t *testing.T) {
t.Parallel()
@@ -1451,7 +1466,7 @@ func TestConfig_Settings_InvalidSafetyFailsLoad(t *testing.T) {
_, err := loadFrom(configFile, "")
require.ErrorContains(t, err, "settings.safety")
require.ErrorContains(t, err, `invalid safety mode "yolo" (valid: strict, balanced, autonomous)`)
require.ErrorContains(t, err, `invalid safety mode "yolo" (valid: strict, balanced, restricted, autonomous)`)
}
func TestConfig_Alias_InvalidSafetyFailsLoad(t *testing.T) {