fix(coding-agent/tools): reworked yolo approval resolution to honor user tool policies

- In `resolveApproval`, yolo mode now returns the user policy directly (`allow`/`prompt`/`deny`) and ignores tool `override` prompts.
- Updated approval-mode and approval unit tests to match the new behavior for critical bash patterns under yolo and auto-approve.
- Updated docs and settings metadata to describe yolo as user-policy-driven rather than override-driven.
This commit is contained in:
can1357
2026-05-27 00:21:33 +02:00
parent d269a782ed
commit 535f7cfa89
9 changed files with 56 additions and 39 deletions
+10 -9
View File
@@ -14,13 +14,15 @@ Tools without an `approval` declaration are treated as `exec`. This is the safe
Configure with `tools.approvalMode`:
## Modes
| Mode | Auto-approves | Prompts for |
| --- | --- | --- |
| `always-ask` | `read` | `write`, `exec` |
| `write` | `read`, `write` | `exec` |
| `yolo` (default) | `read`, `write`, `exec` | nothing unless a tool declares `override: true` |
| `yolo` (default) | `read`, `write`, `exec` | none |
`--auto-approve` and `--yolo` force `tools.approvalMode: yolo` for the session. They do **not** bypass tool safety overrides.
`--auto-approve` and `--yolo` force `tools.approvalMode: yolo` for the session.
## User overrides
@@ -38,11 +40,10 @@ tools:
Resolution per tool call:
1. Compute the tool's approval decision from `tool.approval(args)`; omitted means `exec`.
2. If the decision has `override: true`:
- `tools.approval.<tool>: deny` blocks the call.
- every other policy prompts, even in `yolo`.
3. Otherwise, a valid `tools.approval.<tool>` value wins.
4. Otherwise, the active mode auto-approves or prompts by tier.
2. A user policy in `tools.approval.<tool>` is always applied.
3. In `yolo` mode, with no user policy, the call is auto-approved.
4. In non-yolo modes, if the tool sets `override: true`, `deny` is blocked and all other cases prompt.
5. Otherwise, the active mode auto-approves or prompts by tier.
Invalid policy values are ignored and fall back to the tool tier/mode decision.
@@ -54,7 +55,7 @@ A tool can force a prompt with object-form approval:
approval: { tier: "exec", override: true, reason: "Critical pattern detected" }
```
`bash` uses this for critical destructive patterns such as `rm -rf /`, fork bombs, remote-fetch-then-execute, writes to `/etc/passwd`, and host shutdown commands. These prompt even in `yolo`; in non-interactive/headless sessions they fail instead of running unattended.
`bash` uses this for critical destructive patterns such as `rm -rf /`, fork bombs, remote-fetch-then-execute, writes to `/etc/passwd`, and host shutdown commands. These surface as `reason` in the approval prompt, but in `yolo` mode they are auto-approved unless a user policy for the tool is set to `prompt` or `deny`.
## Per-tool prompt details
@@ -92,4 +93,4 @@ approval: args => isCritical(args.command)
## Subagents
Subagents run headless with `tools.approvalMode: yolo` so they do not stall waiting for UI. The parent `task` approval is the authorization boundary. Tool-level safety overrides still apply; a critical override inside a headless subagent fails rather than running without confirmation.
Subagents run headless with `tools.approvalMode: yolo` so they do not stall waiting for UI. The parent `task` approval is the authorization boundary. User `tools.approval.<tool>` settings continue to control whether a tool is allowed, prompted, or blocked.
+2 -1
View File
@@ -4,7 +4,8 @@
### Changed
- Changed the default `task.simple` mode from `default` to `schema-free`, so task-call `schema` inputs are disabled by default while shared `context` and agent/session-defined output schemas remain available
- Changed the default `task.simple` mode from `default` to `schema-free`, so task-call `schema` inputs are disabled by default while shared `context` and user prompt/session-defined output schemas remain available
- Changed `tools.approvalMode: yolo` to auto-approve tool calls even when a tool marks `override: true`; user `tools.approval.<tool>` policies (`allow`/`prompt`/`deny`) now remain the only controls for yolo mode.
## [15.5.1] - 2026-05-26
@@ -1805,7 +1805,7 @@ export const SETTINGS_SCHEMA = {
// Default tool approval mode (interaction tab, but governs the tool wrapper).
// "always-ask" — auto-approves read-tier tools only; prompts for write/exec.
// "write" — auto-approves read and write-tier tools; prompts for exec.
// "yolo" — auto-approves every tier unless a tool declares `override: true`.
// "yolo" — auto-approves every tier.
"tools.approvalMode": {
type: "enum",
values: ["always-ask", "write", "yolo"] as const,
@@ -1814,7 +1814,7 @@ export const SETTINGS_SCHEMA = {
tab: "interaction",
label: "Tool Approval",
description:
"Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves every tier unless a tool declares a safety override. `tools.approval.<tool>` overrides are honored in every mode.",
"Default approval behaviour for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
options: [
{
value: "always-ask",
@@ -1831,7 +1831,7 @@ export const SETTINGS_SCHEMA = {
value: "yolo",
label: "Yolo",
description:
"Auto-approve read, write, and exec tools. Safety overrides declared by tools (for example critical bash patterns) still require confirmation.",
"Auto-approve read, write, and exec tools. User policy can still require confirmation or block calls.",
},
],
},
@@ -111,9 +111,8 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
context?: AgentToolContext,
) {
// 1. Check approval policy (before extension handlers).
// CLI `--auto-approve` / `--yolo` forces yolo mode for the session, but
// tool-level safety overrides still prompt. User `tools.approval.<tool>`
// policies are honored in every mode.
// CLI `--auto-approve` / `--yolo` sets approval mode to yolo.
// User `tools.approval.<tool>` policies are still applied in all modes.
const cliAutoApprove = context?.autoApprove === true;
const settings: Settings | undefined = context?.settings;
const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
+2 -2
View File
@@ -532,10 +532,10 @@ function createSubagentSettings(baseSettings: Settings): Settings {
...snapshot,
"async.enabled": false,
"bash.autoBackground.enabled": false,
// Subagents run headless — there is no UI to confirm prompts against, so
// the parent task approval is the authorization boundary. Use yolo mode
// to preserve unattended subagent execution while still honoring any
// tool-level safety override that can be handled before execution.
// to preserve unattended subagent execution. User `tools.approval` policies still apply.
"tools.approvalMode": "yolo",
});
}
+6 -2
View File
@@ -87,8 +87,8 @@ function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
* 2. User per-tool override, if set and valid.
* 3. Active mode tier comparison.
*
* Tool decisions with `override: true` force a prompt in every mode unless the
* user explicitly denies the tool; deny remains the strongest policy.
* In yolo mode, override-based tool prompts are ignored; user `tools.approval`
* settings remain authoritative.
*/
export function resolveApproval(
tool: ApprovalSubject,
@@ -99,6 +99,10 @@ export function resolveApproval(
const decision = getToolDecision(tool, args);
const userPolicy = Object.hasOwn(userConfig, tool.name) ? normalizePolicy(userConfig[tool.name]) : undefined;
if (mode === "yolo") {
return { policy: userPolicy ?? "allow", tier: decision.tier, override: false };
}
if (decision.override) {
if (userPolicy === "deny") {
return { policy: "deny", tier: decision.tier, override: true };
+4 -4
View File
@@ -42,11 +42,11 @@ const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
/**
* Bash patterns that force an approval prompt even in yolo mode.
* Bash patterns flagged as safety critical for approval policy.
*
* Kept intentionally tight — the cost of a false positive is one extra prompt;
* the cost of a false negative is data loss or a compromised host. New patterns
* should target shapes that are virtually never legitimate in automation.
* Kept intentionally tight — the cost of a false negative is data loss or a compromised host,
* while false positives remain actionable through user policy control.
* New patterns should target shapes that are virtually never legitimate in automation.
*/
export const CRITICAL_BASH_PATTERNS = [
// Recursive destruction.
@@ -156,7 +156,7 @@ describe("tools.approvalMode setting", () => {
}
});
it("critical bash patterns prompt even in yolo mode when bash is user-allowed", async () => {
it("critical bash patterns do not prompt in yolo mode with bash allowed", async () => {
const { tempDir, session, settings } = await makeSession({
"tools.approvalMode": "yolo",
"tools.approval": { bash: "allow" },
@@ -165,11 +165,17 @@ describe("tools.approvalMode setting", () => {
try {
const bash = session.getToolByName("bash");
if (!bash) throw new Error("Expected bash tool");
await expect(
bash.execute("critical", { command: "rm -rf /" }, undefined, undefined, {
const result = await bash.execute(
"critical",
{ command: "rm -f /tmp/bun-fake-timer-probe.test.ts" },
undefined,
undefined,
{
settings,
} as AgentToolContext),
).rejects.toThrow(/requires approval but no interactive UI available/);
} as AgentToolContext,
);
expect(textOf(result)).toBe("(no output)");
} finally {
await session.dispose();
}
@@ -193,7 +199,7 @@ describe("tools.approvalMode setting", () => {
}
});
it("CLI --auto-approve does not bypass tool safety overrides", async () => {
it("CLI --auto-approve also bypasses safety-override patterns", async () => {
const { tempDir, session, settings } = await makeSession({
"tools.approvalMode": "always-ask",
});
@@ -201,12 +207,17 @@ describe("tools.approvalMode setting", () => {
try {
const bash = session.getToolByName("bash");
if (!bash) throw new Error("Expected bash tool");
await expect(
bash.execute("cli-critical", { command: "rm -rf /" }, undefined, undefined, {
const result = await bash.execute(
"cli-critical",
{ command: "rm -f /tmp/bun-fake-timer-probe.test.ts" },
undefined,
undefined,
{
settings,
autoApprove: true,
} as AgentToolContext),
).rejects.toThrow(/requires approval but no interactive UI available/);
} as AgentToolContext,
);
expect(textOf(result)).toBe("(no output)");
} finally {
await session.dispose();
}
@@ -79,14 +79,15 @@ describe("resolveApproval tier matrix", () => {
describe("resolveApproval override and user policy", () => {
const dangerous = tool("bash", { tier: "exec", override: true, reason: "Critical pattern detected" });
it("tool override prompts even in yolo mode", () => {
it("ignores override-based prompts in yolo mode", () => {
const result = resolveApproval(dangerous, {}, "yolo");
expect(result).toMatchObject({ policy: "prompt", tier: "exec", override: true });
expect(result.reason).toBe("Critical pattern detected");
expect(result).toMatchObject({ policy: "allow", tier: "exec", override: false });
expect(result.reason).toBeUndefined();
});
it("deny wins over a tool override", () => {
expect(resolveApproval(dangerous, {}, "yolo", { bash: "allow" }).policy).toBe("prompt");
it("user policy still controls execution in yolo mode", () => {
expect(resolveApproval(dangerous, {}, "yolo", { bash: "allow" }).policy).toBe("allow");
expect(resolveApproval(dangerous, {}, "yolo", { bash: "prompt" }).policy).toBe("prompt");
expect(resolveApproval(dangerous, {}, "yolo", { bash: "deny" }).policy).toBe("deny");
expect(() => requiresApproval(dangerous, {}, "yolo", { bash: "deny" })).toThrow(
'Tool "bash" is blocked by user policy',