Skip to content

Commit a78eb5b

Browse files
aorumbayevclaude
andcommitted
feat(chat): add --yolo flag to auto-approve tool calls
Gated by a typed I ACCEPT acknowledgement at boot; surfaces a red banner and toolbar badge while active and logs each auto-approved call. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c2d0dfd commit a78eb5b

8 files changed

Lines changed: 126 additions & 12 deletions

File tree

docs/guides/chat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@ kagan chat # interactive REPL
2121
kagan chat --prompt "Plan a refactor" # single-shot (send, print, exit)
2222
kagan chat --session-id <id> # resume a previous session
2323
kagan chat --agent opencode # override agent backend
24+
kagan chat --yolo # auto-approve every tool call
2425
```
2526

2627
The REPL persists conversation history across restarts. Type a message and press Enter to send. `Ctrl+D` or `/exit` to quit.
2728

29+
### Yolo mode
30+
31+
`--yolo` skips the per-tool-call permission prompt and auto-approves every request for the session. On boot it shows a disclaimer and requires you to type `I ACCEPT` exactly; anything else aborts. The boot banner border turns red and a `YOLO` badge appears in the bottom toolbar while it is active. Each auto-approved call is still logged as `● yolo auto-approve: <tool>` so you can see what ran. Use only inside disposable worktrees or sandboxes you trust the agent to operate on unattended.
32+
2833
______________________________________________________________________
2934

3035
## AI Panel

docs/reference/cli.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ Slash commands and usage: [Chat guide](../guides/chat.md).
5757

5858
| Option | Description |
5959
| --------------- | ------------------------------------------ |
60-
| `--prompt TEXT` | Single-shot mode (send once, print, exit) |
61-
| `--session-id` | Attach to an existing chat or task session |
62-
| `--agent` | Override default orchestrator backend |
60+
| `--prompt TEXT` | Single-shot mode (send once, print, exit) |
61+
| `--session-id` | Attach to an existing chat or task session |
62+
| `--agent` | Override default orchestrator backend |
63+
| `--yolo` | Auto-approve every tool call (typed ack required) |
6364

6465
______________________________________________________________________
6566

src/kagan/cli/chat/_chat_acp.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,10 @@ def _prompt_for_permission_option(options: list[Any], tool_call: Any) -> Any | N
185185

186186

187187
class _OrchestratorACPClient(ACPClientBase):
188-
def __init__(self) -> None:
188+
def __init__(self, *, yolo: bool = False) -> None:
189189
self._conn: Any = None
190190
self._streaming = False
191+
self._yolo = yolo
191192
self._show_thoughts = _env_flag_enabled("KAGAN_CHAT_SHOW_THOUGHTS", default=False)
192193
self._tool_runs = ToolRunTracker()
193194
self._response_chunks = ResponseChunkBuffer()
@@ -304,6 +305,17 @@ async def request_permission(self, options: Any, session_id: str, tool_call: Any
304305
return _cancelled_permission_response()
305306

306307
self._output_flusher.flush(force=True)
308+
309+
if self._yolo:
310+
for option in permission_options:
311+
if getattr(option, "kind", None) == "allow_once":
312+
title = _format_permission_tool(tool_call)
313+
_console.print(
314+
f" [red]● yolo auto-approve:[/red] [dim]{_rich_escape(title)}[/dim]",
315+
highlight=False,
316+
)
317+
return _selected_permission_response(option)
318+
307319
if not _stdio_is_interactive():
308320
_console.print("[yellow]Permission request denied in non-interactive mode.[/yellow]")
309321
return _cancelled_permission_response()

src/kagan/cli/chat/_yolo.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Yolo-mode disclaimer prompt — explicit acknowledgement gate."""
2+
3+
import sys
4+
from typing import Any
5+
6+
_DISCLAIMER_TITLE = "[bold red]:warning: YOLO MODE :warning:[/bold red]"
7+
_DISCLAIMER_BODY = (
8+
"You are about to start the chat with [bold]--yolo[/bold] enabled.\n\n"
9+
"Every tool call requested by the agent will be [bold red]auto-approved "
10+
"without prompting[/bold red]. This includes:\n"
11+
" • Editing or deleting files\n"
12+
" • Running shell commands\n"
13+
" • Making network requests\n"
14+
" • Calling MCP tools and external integrations\n\n"
15+
"Use this only inside disposable sandboxes or worktrees you trust the\n"
16+
"agent to operate on unattended. You assume full responsibility for any\n"
17+
"destructive actions taken on your behalf."
18+
)
19+
_ACK_PHRASE = "I ACCEPT"
20+
21+
22+
def confirm_yolo_disclaimer(console: Any) -> bool:
23+
"""Display the yolo disclaimer and require an explicit typed acknowledgement.
24+
25+
Returns True only when the user types the acknowledgement phrase exactly.
26+
Aborts (returns False) on EOF, Ctrl+C, non-interactive stdio, or any other
27+
response.
28+
"""
29+
console.print()
30+
console.print(_DISCLAIMER_TITLE)
31+
console.print(_DISCLAIMER_BODY)
32+
console.print()
33+
34+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
35+
console.print(
36+
"[yellow]--yolo requires an interactive terminal for "
37+
"acknowledgement; aborting.[/yellow]"
38+
)
39+
return False
40+
41+
console.print(
42+
f"[bold]Type [red]{_ACK_PHRASE}[/red] to continue, anything else to abort:[/bold]"
43+
)
44+
try:
45+
answer = input("> ").strip()
46+
except (EOFError, KeyboardInterrupt):
47+
console.print("[yellow]Yolo mode declined.[/yellow]")
48+
return False
49+
50+
if answer != _ACK_PHRASE:
51+
console.print("[yellow]Yolo mode declined.[/yellow]")
52+
return False
53+
54+
console.print("[bold red]Yolo mode active — all tool calls will be auto-approved.[/bold red]")
55+
console.print()
56+
return True

src/kagan/cli/chat/controller.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,13 @@ def __init__(
158158
agent_backend: str = "claude-code",
159159
mcp_session_id: str | None = None,
160160
prefer_session_backend: bool = True,
161+
yolo: bool = False,
161162
) -> None:
162163
self.client = client
163164
self.agent_backend = agent_backend
164165
self._mcp_session_id = mcp_session_id
165166
self._prefer_session_backend = prefer_session_backend
167+
self._yolo = yolo
166168
self._acp_conn: Any | None = None
167169
self._acp_client: _OrchestratorACPClient | None = None
168170
self._acp_session_id: str | None = None
@@ -669,7 +671,7 @@ async def _run_agent_session(self, *, prompt: str | None = None) -> None:
669671
backend_env_vars=backend.env_vars,
670672
)
671673

672-
self._acp_client = _OrchestratorACPClient()
674+
self._acp_client = _OrchestratorACPClient(yolo=self._yolo)
673675

674676
try:
675677
async with acp.spawn_agent_process(

src/kagan/cli/chat/repl.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from dataclasses import dataclass
1111
from importlib.metadata import version
1212
from pathlib import Path
13-
from typing import Final, Literal
13+
from typing import Any, Final, Literal
1414

1515
from prompt_toolkit import PromptSession
1616
from prompt_toolkit.application.current import get_app
@@ -68,6 +68,7 @@ class ToolbarState:
6868
context_pct: float | None = None
6969
workspace_label: str = ""
7070
is_streaming: bool = False
71+
yolo: bool = False
7172

7273

7374
_TOOLBAR_STATE = ToolbarState()
@@ -500,6 +501,8 @@ def _cycle_history(event, direction: Literal["up", "down"]) -> None:
500501
def _bottom_toolbar() -> FormattedText:
501502
status_left = _TOOLBAR_STATE.workspace_label or _display_path(Path.cwd())
502503
status_right_parts: list[str] = []
504+
if _TOOLBAR_STATE.yolo:
505+
status_right_parts.append("YOLO")
503506
if _TOOLBAR_STATE.agent_backend:
504507
status_right_parts.append(_TOOLBAR_STATE.agent_backend)
505508
if _TOOLBAR_STATE.context_pct is not None:
@@ -609,7 +612,10 @@ def _get_prompt_session() -> PromptSession[str]:
609612

610613

611614
def _write_boot_banner(
612-
project_root: Path | None = None, *, agent_backend: str | None = None
615+
project_root: Path | None = None,
616+
*,
617+
agent_backend: str | None = None,
618+
yolo: bool = False,
613619
) -> None:
614620
ver = version("kagan")
615621
cols = shutil.get_terminal_size().columns
@@ -622,11 +628,16 @@ def _write_boot_banner(
622628
(f" {_BOOT_TIP_TEXT}", "dim"),
623629
)
624630
safety = Text("Review agent output before you apply it.", style="dim")
631+
body: list[Any] = [title, subtitle, tip, safety]
632+
if yolo:
633+
body.append(
634+
Text("YOLO MODE — every tool call auto-approved.", style="bold red"),
635+
)
625636

626637
banner = Panel(
627-
Group(title, subtitle, tip, safety),
638+
Group(*body),
628639
box=box.ROUNDED,
629-
border_style="green",
640+
border_style="red" if yolo else "green",
630641
padding=(0, 2),
631642
expand=False,
632643
width=panel_width,
@@ -678,10 +689,15 @@ async def run_chat_async(
678689
prompt: str | None = None,
679690
session_id: str | None = None,
680691
agent: str | None = None,
692+
yolo: bool = False,
681693
) -> str | None:
694+
from kagan.cli.chat._yolo import confirm_yolo_disclaimer
682695
from kagan.cli.chat.controller import ChatController
683696
from kagan.core import KaganCore, resolve_default_agent_backend
684697

698+
if yolo and not confirm_yolo_disclaimer(_console):
699+
return None
700+
685701
async with KaganCore() as client:
686702
backend = agent
687703
if not backend:
@@ -693,6 +709,7 @@ async def run_chat_async(
693709
agent_backend=backend,
694710
mcp_session_id=session_id,
695711
prefer_session_backend=agent is None,
712+
yolo=yolo,
696713
)
697714

698715
if not await controller.ensure_project():
@@ -704,8 +721,9 @@ async def run_chat_async(
704721
_TOOLBAR_STATE.agent_backend = controller.agent_backend
705722
_TOOLBAR_STATE.turn_count = controller._turn_count
706723
_TOOLBAR_STATE.context_pct = None
724+
_TOOLBAR_STATE.yolo = yolo
707725

708-
_write_boot_banner(Path.cwd(), agent_backend=controller.agent_backend)
726+
_write_boot_banner(Path.cwd(), agent_backend=controller.agent_backend, yolo=yolo)
709727

710728
await controller.run(prompt=prompt)
711729

@@ -716,5 +734,6 @@ def run_chat(
716734
prompt: str | None = None,
717735
session_id: str | None = None,
718736
agent: str | None = None,
737+
yolo: bool = False,
719738
) -> str | None:
720-
return asyncio.run(run_chat_async(prompt=prompt, session_id=session_id, agent=agent))
739+
return asyncio.run(run_chat_async(prompt=prompt, session_id=session_id, agent=agent, yolo=yolo))

src/kagan/cli/chat_cmd.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,22 @@
4747
@click.option(
4848
"--agent", "agent", type=str, default=None, help="Override the default agent backend."
4949
)
50+
@click.option(
51+
"--yolo",
52+
"yolo",
53+
is_flag=True,
54+
default=False,
55+
help=(
56+
"Auto-approve every tool call without prompting. Requires explicit "
57+
"acknowledgement of a safety disclaimer at startup."
58+
),
59+
)
5060
def chat(
5161
prompt_argument: str | None,
5262
prompt_text: str | None,
5363
session_id: str | None,
5464
agent: str | None,
65+
yolo: bool,
5566
) -> None:
5667
"""Start an interactive chat or run a single prompt."""
5768
logger.debug("Chat command invoked")
@@ -66,6 +77,7 @@ def chat(
6677
prompt=prompt_text if prompt_text is not None else prompt_argument,
6778
session_id=session_id,
6879
agent=agent,
80+
yolo=yolo,
6981
)
7082
)
7183
except KeyboardInterrupt:

tests/core/test_cli_surface.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,18 +445,25 @@ async def _fake_run_chat_async(
445445
prompt: str | None = None,
446446
session_id: str | None = None,
447447
agent: str | None = None,
448+
yolo: bool = False,
448449
) -> None:
449450
captured["prompt"] = prompt
450451
captured["session_id"] = session_id
451452
captured["agent"] = agent
453+
captured["yolo"] = yolo
452454

453455
monkeypatch.setattr("kagan.cli.chat.run_chat_async", _fake_run_chat_async)
454456

455457
runner = CliRunner()
456458
result = runner.invoke(cli, ["chat", "fix the bug"], env=_runner_env(tmp_path))
457459

458460
assert result.exit_code == 0
459-
assert captured == {"prompt": "fix the bug", "session_id": None, "agent": None}
461+
assert captured == {
462+
"prompt": "fix the bug",
463+
"session_id": None,
464+
"agent": None,
465+
"yolo": False,
466+
}
460467

461468

462469
def test_chat_rejects_positional_prompt_with_prompt_option(tmp_path: Path) -> None:

0 commit comments

Comments
 (0)