Support sudo password flow for shell commands
This commit is contained in:
@@ -177,3 +177,149 @@ def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, mon
|
||||
conversation = client.get(f"/v1/conversations/{conversation_id}").json()
|
||||
assert conversation["messages"][-1]["content"] == "continued after approval"
|
||||
assert conversation["messages"][-1]["status"] == "completed"
|
||||
|
||||
|
||||
def test_continue_stream_requests_password_for_approved_sudo(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
assert role == "action"
|
||||
actions = []
|
||||
if not any("tool_observations" in message["content"] for message in messages):
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "sudo apt update"},
|
||||
"reason": "Check updates with root privileges",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "run sudo command",
|
||||
"risk_level": "medium",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
client = TestClient(create_app())
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "run sudo update", "workspace": str(tmp_path), "debug": True},
|
||||
) as response:
|
||||
initial_body = "".join(response.iter_text())
|
||||
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
||||
approval = next(
|
||||
item for item in client.get("/v1/approvals/pending").json()
|
||||
if item["task_id"] == task_id
|
||||
)
|
||||
client.post(f"/v1/approvals/{approval['approval_id']}/allow_once")
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/continue/stream",
|
||||
json={"approval_id": approval["approval_id"]},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event: tool_password_requested" in body
|
||||
assert "waiting_for_password" in body
|
||||
assert "password" in body.lower()
|
||||
|
||||
|
||||
def test_password_stream_runs_sudo_with_password_and_streams_answer(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
calls = []
|
||||
|
||||
class Completed:
|
||||
returncode = 0
|
||||
stdout = "sudo updated\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return Completed()
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
if role == "action":
|
||||
actions = []
|
||||
if not any("tool_observations" in message["content"] for message in messages):
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "sudo apt update"},
|
||||
"reason": "Check updates with root privileges",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "run sudo command",
|
||||
"risk_level": "medium",
|
||||
"actions": actions,
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
raise AssertionError("non-stream thinker should not be used")
|
||||
|
||||
async def fake_stream_chat(self, role, messages):
|
||||
assert role == "thinker"
|
||||
observation = next(message for message in messages if "tool_observations" in message["content"])
|
||||
assert "sudo updated" in observation["content"]
|
||||
assert "secret" not in observation["content"]
|
||||
yield {"type": "content_delta", "delta": "sudo command completed"}
|
||||
|
||||
monkeypatch.setattr("duck_core.tools.shell_exec_safe.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
||||
client = TestClient(create_app())
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "run sudo update", "workspace": str(tmp_path), "debug": True},
|
||||
) as response:
|
||||
initial_body = "".join(response.iter_text())
|
||||
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
||||
approval = next(
|
||||
item for item in client.get("/v1/approvals/pending").json()
|
||||
if item["task_id"] == task_id
|
||||
)
|
||||
client.post(f"/v1/approvals/{approval['approval_id']}/allow_once")
|
||||
with client.stream(
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/continue/stream",
|
||||
json={"approval_id": approval["approval_id"]},
|
||||
) as response:
|
||||
_ = "".join(response.iter_text())
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
f"/v1/tasks/{task_id}/password/stream",
|
||||
json={"approval_id": approval["approval_id"], "password": "secret"},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert calls[0][1]["input"] == "secret\n"
|
||||
assert "event: tool_call_finished" in body
|
||||
assert "event: content_delta" in body
|
||||
assert "sudo command completed" in body
|
||||
assert "secret" not in body
|
||||
|
||||
@@ -106,7 +106,13 @@ class FakeUpdateCheckModelClient:
|
||||
if role == "action":
|
||||
actions = []
|
||||
if not any("tool_observations" in message["content"] for message in messages):
|
||||
actions = [{"tool": "os_update_check", "args": {}, "reason": "Check OS updates"}]
|
||||
actions = [
|
||||
{
|
||||
"tool": "shell_exec_safe",
|
||||
"args": {"command": "apt list --upgradable"},
|
||||
"reason": "Check OS updates",
|
||||
}
|
||||
]
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
@@ -124,7 +130,7 @@ class FakeUpdateCheckModelClient:
|
||||
)
|
||||
assert role == "thinker"
|
||||
observation_text = "\n".join(message["content"] for message in messages)
|
||||
assert "os_update_check" in observation_text
|
||||
assert "apt list --upgradable" in observation_text
|
||||
assert "requires_approval" not in observation_text
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
@@ -191,7 +197,7 @@ async def test_runtime_checks_system_updates_without_approval_loop(tmp_path):
|
||||
assert not any(event.event_type == "tool_approval_requested" for event in events)
|
||||
assert any(
|
||||
event.event_type == "tool_call_finished"
|
||||
and event.payload["tool"] == "os_update_check"
|
||||
and event.payload["tool"] == "shell_exec_safe"
|
||||
for event in events
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import pytest
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.gateway import ToolGateway
|
||||
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
||||
@@ -34,8 +33,45 @@ async def test_shell_tool_blocks_dangerous_commands(tmp_path):
|
||||
assert blocked.metadata.get("requires_approval") is not True
|
||||
assert blocked.metadata["blocked"] is True
|
||||
assert sudo.ok is False
|
||||
assert sudo.metadata.get("requires_approval") is not True
|
||||
assert sudo.metadata["blocked"] is True
|
||||
assert sudo.metadata["requires_approval"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shell_tool_requests_password_for_approved_sudo(tmp_path):
|
||||
shell = ShellExecSafeTool(str(tmp_path))
|
||||
|
||||
result = await shell.run({"command": "sudo apt update", "_approved": True})
|
||||
|
||||
assert result.ok is False
|
||||
assert result.metadata["requires_password"] is True
|
||||
assert "password" in result.error.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shell_tool_passes_password_to_sudo_stdin(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
|
||||
class Completed:
|
||||
returncode = 0
|
||||
stdout = "updated\n"
|
||||
stderr = ""
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return Completed()
|
||||
|
||||
monkeypatch.setattr("duck_core.tools.shell_exec_safe.subprocess.run", fake_run)
|
||||
shell = ShellExecSafeTool(str(tmp_path))
|
||||
|
||||
result = await shell.run(
|
||||
{"command": "sudo apt update", "_approved": True, "_password": "secret"}
|
||||
)
|
||||
|
||||
command, kwargs = calls[0]
|
||||
assert result.ok is True
|
||||
assert "sudo -S -p '' apt update" == command
|
||||
assert kwargs["input"] == "secret\n"
|
||||
assert "secret" not in str(result.model_dump())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -86,23 +122,20 @@ async def test_tool_gateway_searches_file_contents(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_os_update_check_reports_apt_upgradable_packages(monkeypatch, tmp_path):
|
||||
async def test_shell_tool_allows_read_only_apt_update_check(monkeypatch, tmp_path):
|
||||
class Completed:
|
||||
returncode = 0
|
||||
stdout = "Listing...\\nbootlogd/stable 3.14-4 amd64 [upgradable from: 3.06-4]\\n"
|
||||
stderr = "WARNING: apt does not have a stable CLI interface.\\n"
|
||||
|
||||
monkeypatch.setattr("duck_core.tools.os_update_check.shutil.which", lambda name: "/usr/bin/apt")
|
||||
monkeypatch.setattr(
|
||||
"duck_core.tools.os_update_check.subprocess.run",
|
||||
"duck_core.tools.shell_exec_safe.subprocess.run",
|
||||
lambda *args, **kwargs: Completed(),
|
||||
)
|
||||
tool = OsUpdateCheckTool(str(tmp_path))
|
||||
shell = ShellExecSafeTool(str(tmp_path))
|
||||
|
||||
result = await tool.run({})
|
||||
result = await shell.run({"command": "apt list --upgradable"})
|
||||
|
||||
assert result.ok is True
|
||||
assert "bootlogd" in result.output
|
||||
assert result.metadata["package_manager"] == "apt"
|
||||
assert result.metadata["upgradable_count"] == 1
|
||||
assert result.metadata["refreshed_cache"] is False
|
||||
assert result.metadata["command"] == "apt list --upgradable"
|
||||
|
||||
Reference in New Issue
Block a user