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:
+10
-9
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user