diff --git a/agent-schema.json b/agent-schema.json index 44a6bbc8c..d032bf734 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -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", diff --git a/cmd/root/alias.go b/cmd/root/alias.go index 5246a1850..f56c0c716 100644 --- a/cmd/root/alias.go +++ b/cmd/root/alias.go @@ -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) diff --git a/cmd/root/alias_test.go b/cmd/root/alias_test.go index 1a49c3df7..9f084c361 100644 --- a/cmd/root/alias_test.go +++ b/cmd/root/alias_test.go @@ -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) diff --git a/cmd/root/run.go b/cmd/root/run.go index e015af31f..887ab3d1e 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -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") diff --git a/cmd/root/run_session_test.go b/cmd/root/run_session_test.go index 40773bf61..dd455d53a 100644 --- a/cmd/root/run_session_test.go +++ b/cmd/root/run_session_test.go @@ -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, diff --git a/cmd/root/run_user_config_test.go b/cmd/root/run_user_config_test.go index e663cbf35..7b0b29651 100644 --- a/cmd/root/run_user_config_test.go +++ b/cmd/root/run_user_config_test.go @@ -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. diff --git a/docs/configuration/permissions/index.md b/docs/configuration/permissions/index.md index 58bad45b0..c49f5df55 100644 --- a/docs/configuration/permissions/index.md +++ b/docs/configuration/permissions/index.md @@ -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..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** diff --git a/docs/configuration/user-settings/index.md b/docs/configuration/user-settings/index.md index 4ff74860e..92a050df6 100644 --- a/docs/configuration/user-settings/index.md +++ b/docs/configuration/user-settings/index.md @@ -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..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..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. | diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index db21aa638..dc0f707f7 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -29,7 +29,7 @@ $ docker agent run [config] [message...] [flags] | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `-a, --agent ` | Run a specific agent from the config | | `--yolo` | Auto-approve tool calls (unless explicitly denied). Legacy alias for `--safety autonomous`. | -| `--safety ` | 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..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 ` | 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..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 ` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. | | `--session ` | 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 to the SQLite session database (default: `/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 ` — 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..safety` / `aliases..yolo`), so you can also edit them there by hand. +- `--safety ` — 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..safety` / `aliases..yolo`), so you can also edit them there by hand. - `--model ` — 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) diff --git a/docs/guides/headless/index.md b/docs/guides/headless/index.md index 6be79908d..76a87bd4d 100644 --- a/docs/guides/headless/index.md +++ b/docs/guides/headless/index.md @@ -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** diff --git a/docs/tools/shell/index.md b/docs/tools/shell/index.md index 4649dff3a..5da9fc8f8 100644 --- a/docs/tools/shell/index.md +++ b/docs/tools/shell/index.md @@ -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). diff --git a/examples/README.md b/examples/README.md index 838f288a0..8cd7fa132 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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`. | diff --git a/examples/permissions.yaml b/examples/permissions.yaml index 398ec98d9..6aa59ae40 100644 --- a/examples/permissions.yaml +++ b/examples/permissions.yaml @@ -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 diff --git a/examples/safety_modes.yaml b/examples/safety_modes.yaml index 368a5dba3..7270e690d 100644 --- a/examples/safety_modes.yaml +++ b/examples/safety_modes.yaml @@ -2,8 +2,9 @@ # runtime.safety — config-wide default for new sessions # agents..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..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 diff --git a/pkg/api/types.go b/pkg/api/types.go index 04a79f83a..e0fbbfb5e 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -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"` } diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 3332addf4..74b0d2fc0 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -130,7 +130,7 @@ func (b *BudgetConfig) validate() error { // SafetyMode is a declarative safety-mode default that agent authors // (runtime.safety, agents..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 diff --git a/pkg/config/latest/validate_test.go b/pkg/config/latest/validate_test.go index eddc8c7da..e412c92c7 100644 --- a/pkg/config/latest/validate_test.go +++ b/pkg/config/latest/validate_test.go @@ -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)) } } } diff --git a/pkg/hooks/types.go b/pkg/hooks/types.go index 1a7c2990d..b415bbaa7 100644 --- a/pkg/hooks/types.go +++ b/pkg/hooks/types.go @@ -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"` diff --git a/pkg/runtime/hooks.go b/pkg/runtime/hooks.go index c5c5935e1..b1cc35880 100644 --- a/pkg/runtime/hooks.go +++ b/pkg/runtime/hooks.go @@ -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" diff --git a/pkg/runtime/tool_dispatch.go b/pkg/runtime/tool_dispatch.go index 2ee58c06c..6049b656c 100644 --- a/pkg/runtime/tool_dispatch.go +++ b/pkg/runtime/tool_dispatch.go @@ -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 { diff --git a/pkg/runtime/toolexec/dispatcher.go b/pkg/runtime/toolexec/dispatcher.go index 4a2f154b9..fb08e4d1a 100644 --- a/pkg/runtime/toolexec/dispatcher.go +++ b/pkg/runtime/toolexec/dispatcher.go @@ -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. diff --git a/pkg/runtime/toolexec/dispatcher_test.go b/pkg/runtime/toolexec/dispatcher_test.go index b48339762..eb023ec2c 100644 --- a/pkg/runtime/toolexec/dispatcher_test.go +++ b/pkg/runtime/toolexec/dispatcher_test.go @@ -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() diff --git a/pkg/runtime/toolexec/helpers_test.go b/pkg/runtime/toolexec/helpers_test.go index a924d599e..a953d3931 100644 --- a/pkg/runtime/toolexec/helpers_test.go +++ b/pkg/runtime/toolexec/helpers_test.go @@ -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}) } diff --git a/pkg/runtime/toolexec/permissions.go b/pkg/runtime/toolexec/permissions.go index 1514af813..f96e3294a 100644 --- a/pkg/runtime/toolexec/permissions.go +++ b/pkg/runtime/toolexec/permissions.go @@ -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: diff --git a/pkg/runtime/toolexec/permissions_test.go b/pkg/runtime/toolexec/permissions_test.go index 38d9fc24e..19932c24b 100644 --- a/pkg/runtime/toolexec/permissions_test.go +++ b/pkg/runtime/toolexec/permissions_test.go @@ -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) { diff --git a/pkg/server/safety_policy_test.go b/pkg/server/safety_policy_test.go index 599aedbd7..ceab3bf38 100644 --- a/pkg/server/safety_policy_test.go +++ b/pkg/server/safety_policy_test.go @@ -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")) } diff --git a/pkg/session/safety_policy_test.go b/pkg/session/safety_policy_test.go index f5c9e91b4..21be3456e 100644 --- a/pkg/session/safety_policy_test.go +++ b/pkg/session/safety_policy_test.go @@ -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) { diff --git a/pkg/session/session.go b/pkg/session/session.go index 3382155bd..470c8451d 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -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 diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go index 54a50d40b..1e5cdc21b 100644 --- a/pkg/teamloader/teamloader_test.go +++ b/pkg/teamloader/teamloader_test.go @@ -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") }) } } diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 24d0b4039..e7f1226a4 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -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"` diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go index e73d99f4e..89eb496c5 100644 --- a/pkg/userconfig/userconfig_test.go +++ b/pkg/userconfig/userconfig_test.go @@ -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) {