core/cli: YOLO mode on|off|once for soft destructive confirms

Session /yolo and --yes skip classify_danger prompts; once expires after the
next user turn. Hard path/command denylists unchanged.
This commit is contained in:
2026-07-17 14:55:34 +08:00
parent 6a1160f1e3
commit 2761877460
8 changed files with 175 additions and 17 deletions
+10
View File
@@ -56,4 +56,14 @@ async def test_confirm_deny() -> None:
async def test_no_hooks_skips_confirm() -> None:
registry = ToolRegistry([delete_path])
assert registry.soft_confirm is False
assert await registry.execute("delete_path", '{"path": "a.txt"}') == "deleted a.txt"
async def test_soft_confirm_property() -> None:
def on_confirm(name: str, args: Mapping[str, object], reason: str) -> bool:
del name, args, reason
return True
gated = ToolRegistry([delete_path], danger=classify_danger, on_confirm=on_confirm)
assert gated.soft_confirm is True
+2
View File
@@ -181,3 +181,5 @@ def test_complete_slash_args_from_registry(tmp_path: object) -> None:
assert complete_slash_args(state, "/model", "a") == ["alpha"]
assert complete_slash_args(state, "/export", "j") == ["json"]
assert complete_slash_args(state, "/help", "st") == ["status", "stream"]
assert complete_slash_args(state, "/yolo", "") == ["on", "off", "once"]
assert complete_slash_args(state, "/yolo", "o") == ["on", "off", "once"]
+58
View File
@@ -341,6 +341,63 @@ async def test_verbose_toggle(state: ReplState) -> None:
assert get_verbose_tool_results() is False
async def test_yolo_toggle(state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
assert await handle_slash(state, "/yolo") is True
assert "yolo=off" in capsys.readouterr().out
assert await handle_slash(state, "/yolo on") is True
assert state.effective_yolo() == "on"
assert state.soft_confirm_enabled() is False
assert "yolo=on" in capsys.readouterr().out
assert await handle_slash(state, "/yolo once") is True
assert state.effective_yolo() == "once"
assert state.soft_confirm_enabled() is False
state.expire_yolo_once(quiet=True)
assert state.effective_yolo() == "off"
assert state.soft_confirm_enabled() is True
assert await handle_slash(state, "/yolo once") is True
state.expire_yolo_once()
err = capsys.readouterr().err
assert "yolo=off (once expired)" in err
assert state.effective_yolo() == "off"
async def test_yolo_rebuilds_tool_registry(tmp_path: Path) -> None:
_ = set_workspace_root(tmp_path)
memory = await MemoryStore.open(DatabaseConfig())
provider = OpenAIProvider(access_key_or_token="sk-test")
config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document())
config.providers = {"local": provider}
st = ReplState(
config=config,
memory=memory,
workspace=tmp_path,
provider_name="local",
provider=provider,
model="gpt-test",
tools_enabled=True,
)
try:
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
st.set_yolo("on")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("once")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is False
st.set_yolo("off")
assert st.agent.tools is not None
assert st.agent.tools.soft_confirm is True
finally:
await memory.close()
async def test_resume(state: ReplState) -> None:
sid = state.session_id
assert sid is not None
@@ -378,6 +435,7 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
assert "(est)" in out # no API usage yet
assert "context_chars=" in out
assert "tool_result_max=" in out
assert "yolo=off" in out
assert str(state.workspace) in out
state.agent.last_request_usage = TokenUsage(