mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
feat(safety): add restricted mode for unattended runs
This commit is contained in:
31 files changed
+420
-88
No files matched your search
+5
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user