Support sudo password flow for shell commands

This commit is contained in:
2026-05-20 22:45:19 +08:00
parent 12bed7b6ba
commit 45b70d2800
11 changed files with 546 additions and 87 deletions
+186 -1
View File
@@ -41,6 +41,11 @@ class ContinueRequest(BaseModel):
approval_id: str
class PasswordRequest(BaseModel):
approval_id: str
password: str
def create_app() -> FastAPI:
get_settings.cache_clear()
settings = get_settings()
@@ -423,7 +428,7 @@ def create_app() -> FastAPI:
)
messages = runtime.context_builder.build_basic_messages(task)
tool_observations = [tool_observation]
if approval.decision != "deny":
if approval.decision != "deny" and not has_password_request(tool_observations):
tool_observations = await runtime._run_action_loop(
task_id,
messages,
@@ -432,6 +437,30 @@ def create_app() -> FastAPI:
)
async for tool_event in emit_tool_events(task_id, continued_event.sequence):
yield tool_event
if has_password_request(tool_observations):
await task_store.update_status(task_id, "waiting_for_password")
password_event = await event_store.append(
task_id,
"tool_password_requested",
{
"approval_id": body.approval_id,
"tool": approval.normalized_action.get("tool"),
"action": redacted_action(approval.normalized_action),
"reason": "Sudo password is required to continue.",
},
)
yield sse("tool_password_requested", password_event.model_dump())
yield sse(
"done",
{
"task_id": task_id,
"conversation_id": conversation_id,
"status": "waiting_for_password",
"final_response": "Waiting for sudo password.",
"reasoning_content": None,
},
)
return
if any(observation.get("requires_approval") for observation in tool_observations):
await task_store.waiting_for_approval(task_id)
await event_store.append(
@@ -538,6 +567,162 @@ def create_app() -> FastAPI:
return StreamingResponse(generator(), media_type="text/event-stream")
def has_password_request(observations: list[dict[str, Any]]) -> bool:
return any(
observation.get("result", {}).get("metadata", {}).get("requires_password")
for observation in observations
)
def redacted_action(action: dict[str, Any]) -> dict[str, Any]:
redacted = json.loads(json.dumps(action))
args = redacted.get("args")
if isinstance(args, dict):
args.pop("_password", None)
return redacted
@app.post("/v1/tasks/{task_id}/password/stream")
async def password_task_stream(task_id: str, body: PasswordRequest) -> StreamingResponse:
task = await task_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
approval = await approvals.get(body.approval_id)
if approval is None or approval.task_id != task_id:
raise HTTPException(status_code=404, detail="Approval not found for task")
if approval.decision is None:
raise HTTPException(status_code=409, detail="Approval is still pending")
conversation_id = await conversations.get_conversation_id_for_task(task_id)
async def generator():
reasoning_parts: list[str] = []
content_parts: list[str] = []
try:
await task_store.update_status(task_id, "running")
continued_event = await event_store.append(
task_id,
"task_password_submitted",
{"approval_id": body.approval_id},
)
tool_observation = await runtime._run_approved_or_denied_action(
task_id,
approval.normalized_action,
approval.decision,
password=body.password,
)
messages = runtime.context_builder.build_basic_messages(task)
tool_observations = [tool_observation]
if not has_password_request(tool_observations):
tool_observations = await runtime._run_action_loop(
task_id,
messages,
task.workspace,
initial_observations=tool_observations,
)
async for tool_event in emit_tool_events(task_id, continued_event.sequence):
yield tool_event
if has_password_request(tool_observations):
await task_store.update_status(task_id, "waiting_for_password")
password_event = await event_store.append(
task_id,
"tool_password_requested",
{
"approval_id": body.approval_id,
"tool": approval.normalized_action.get("tool"),
"action": redacted_action(approval.normalized_action),
"reason": "Sudo password is required to continue.",
},
)
yield sse("tool_password_requested", password_event.model_dump())
yield sse(
"done",
{
"task_id": task_id,
"conversation_id": conversation_id,
"status": "waiting_for_password",
"final_response": "Waiting for sudo password.",
"reasoning_content": None,
},
)
return
messages = [
*messages,
{
"role": "user",
"content": "tool_observations:\n"
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
},
]
await event_store.append(task_id, "model_call_started", {"role": "thinker"})
async for chunk in model_client.stream_chat("thinker", messages):
delta = str(chunk.get("delta") or "")
if chunk.get("type") == "reasoning_delta":
reasoning_parts.append(delta)
yield sse("reasoning_delta", {"task_id": task_id, "delta": delta})
elif chunk.get("type") == "content_delta":
content_parts.append(delta)
yield sse("content_delta", {"task_id": task_id, "delta": delta})
content = "".join(content_parts)
reasoning_content = "".join(reasoning_parts) or None
await event_store.append(
task_id,
"cognition_response",
{
"role": "thinker",
"content": content,
"reasoning_content": reasoning_content,
},
)
await event_store.append(
task_id,
"model_call_finished",
{
"role": "thinker",
"model": model_client.get_role_config("thinker").model,
},
)
await task_store.complete_task(task_id, content)
if conversation_id:
await conversations.add_message(
conversation_id,
"assistant",
content,
reasoning_content=reasoning_content,
task_id=task_id,
status="completed",
)
await event_store.append(
task_id,
"task_completed",
{
"final_response": content,
"reasoning_content": reasoning_content,
},
)
yield sse(
"done",
{
"task_id": task_id,
"conversation_id": conversation_id,
"status": "completed",
"final_response": content,
"reasoning_content": reasoning_content,
},
)
except Exception as exc:
await task_store.fail_task(task_id, str(exc))
await event_store.append(task_id, "task_failed", {"error": str(exc)})
yield sse(
"error",
{
"task_id": task_id,
"status": "failed",
"error": str(exc),
},
)
return StreamingResponse(generator(), media_type="text/event-stream")
@app.post("/v1/tasks/{task_id}/cancel")
async def cancel_task(task_id: str) -> dict[str, str]:
await task_store.cancel_task(task_id)
+6 -2
View File
@@ -248,7 +248,11 @@ class RuntimeLoop:
)
async def _run_approved_or_denied_action(
self, task_id: str, action: dict[str, Any], decision: str
self,
task_id: str,
action: dict[str, Any],
decision: str,
password: str | None = None,
) -> dict[str, Any]:
tool_name = str(action.get("tool", ""))
index = await self._approval_action_index(task_id, action)
@@ -260,7 +264,7 @@ class RuntimeLoop:
)
else:
gateway = ToolGateway.default((await self.task_store.get_task(task_id)).workspace or ".")
result = await gateway.run_action(action, approved=True)
result = await gateway.run_action(action, approved=True, password=password)
result_payload = result.model_dump()
await self.event_store.append(
+5 -3
View File
@@ -4,7 +4,6 @@ from duck_core.tools.base import Tool, ToolResult
from duck_core.tools.file_read import FileReadTool
from duck_core.tools.file_write import FileWriteTool
from duck_core.tools.list_dir import ListDirTool
from duck_core.tools.os_update_check import OsUpdateCheckTool
from duck_core.tools.search_files import SearchFilesTool
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
@@ -21,12 +20,13 @@ class ToolGateway:
FileWriteTool(workspace),
ListDirTool(workspace),
SearchFilesTool(workspace),
OsUpdateCheckTool(workspace),
ShellExecSafeTool(workspace),
]
)
async def run_action(self, action: dict[str, Any], approved: bool = False) -> ToolResult:
async def run_action(
self, action: dict[str, Any], approved: bool = False, password: str | None = None
) -> ToolResult:
tool_name = str(action.get("tool", ""))
tool = self.tools.get(tool_name)
if tool is None:
@@ -36,4 +36,6 @@ class ToolGateway:
return ToolResult(ok=False, error="Tool args must be an object")
if approved:
args = {**args, "_approved": True}
if password is not None:
args = {**args, "_password": password}
return await tool.run(args)
-58
View File
@@ -1,58 +0,0 @@
import shutil
import subprocess
from typing import Any
from duck_core.tools.base import ToolResult
class OsUpdateCheckTool:
name = "os_update_check"
risk_level = "low"
def __init__(self, workspace: str, timeout_seconds: int = 60, max_lines: int = 300):
self.workspace = workspace
self.timeout_seconds = timeout_seconds
self.max_lines = max_lines
async def run(self, args: dict[str, Any]) -> ToolResult:
if shutil.which("apt"):
return self._run_apt()
return ToolResult(
ok=False,
error="No supported package manager found for update checks.",
metadata={"supported_package_managers": ["apt"]},
)
def _run_apt(self) -> ToolResult:
try:
completed = subprocess.run(
["apt", "list", "--upgradable"],
cwd=self.workspace,
text=True,
capture_output=True,
timeout=self.timeout_seconds,
check=False,
)
except subprocess.SubprocessError as exc:
return ToolResult(ok=False, error=str(exc), metadata={"package_manager": "apt"})
lines = completed.stdout.splitlines()
package_lines = [line for line in lines if "/" in line and "upgradable from:" in line]
output_lines = lines[: self.max_lines]
truncated = len(lines) > self.max_lines
if truncated:
output_lines.append(f"... truncated after {self.max_lines} lines")
return ToolResult(
ok=completed.returncode == 0,
output="\n".join(output_lines),
error=completed.stderr or None,
metadata={
"package_manager": "apt",
"command": "apt list --upgradable",
"returncode": completed.returncode,
"upgradable_count": len(package_lines),
"refreshed_cache": False,
"truncated": truncated,
},
)
+26 -2
View File
@@ -13,6 +13,8 @@ ALLOWLIST = {
"tail",
"grep",
"find",
"apt list",
"apt-cache policy",
"pytest",
"python -m pytest",
"python3 -m pytest",
@@ -23,7 +25,6 @@ ALLOWLIST = {
BLOCKLIST = {
"rm",
"sudo",
"su",
"dd",
"mkfs",
@@ -58,17 +59,27 @@ class ShellExecSafeTool:
async def run(self, args: dict[str, Any]) -> ToolResult:
command = str(args.get("command", "")).strip()
approved = bool(args.get("_approved"))
password = args.get("_password")
allowed, reason, blocked = self._is_allowed(command, approved=approved)
if not allowed:
metadata = {"blocked": True} if blocked else {"requires_approval": True}
return ToolResult(ok=False, error=reason, metadata=metadata)
if self._is_sudo_command(command) and not password:
return ToolResult(
ok=False,
error="Sudo password is required to run this command.",
metadata={"requires_password": True},
)
run_command = self._sudo_stdin_command(command) if self._is_sudo_command(command) else command
input_text = f"{password}\n" if self._is_sudo_command(command) else None
try:
completed = subprocess.run(
command,
run_command,
cwd=self.workspace,
shell=True,
text=True,
capture_output=True,
input=input_text,
timeout=self.timeout_seconds,
check=False,
)
@@ -93,6 +104,8 @@ class ShellExecSafeTool:
return False, f"Command is blocked: {blocked}", True
if approved:
return True, None, False
if self._is_sudo_command(command):
return False, "Sudo command requires approval.", False
prefix1 = parts[0] if parts else ""
prefix2 = " ".join(parts[:2])
prefix3 = " ".join(parts[:3])
@@ -107,3 +120,14 @@ class ShellExecSafeTool:
if " " in lowered_blocked or "|" in lowered_blocked:
return lowered_command.startswith(lowered_blocked) or lowered_blocked in lowered_command
return bool(parts) and parts[0].lower() == lowered_blocked
def _is_sudo_command(self, command: str) -> bool:
try:
parts = shlex.split(command)
except ValueError:
return False
return bool(parts) and parts[0] == "sudo"
def _sudo_stdin_command(self, command: str) -> str:
parts = shlex.split(command)
return shlex.join(["sudo", "-S", "-p", "", *parts[1:]])
+86 -1
View File
@@ -145,7 +145,6 @@ function formatToolCommand(tool, args) {
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
if (tool === "list_dir") return `$ list_dir ${args.path || "."}`.trim();
if (tool === "search_files") return `$ search_files ${args.query || ""}`.trim();
if (tool === "os_update_check") return "$ os_update_check";
return `$ ${tool || "tool"}`;
}
@@ -213,6 +212,41 @@ function appendApprovalTerminal(article, eventPayload) {
terminal?.append(createInlineApprovalActions(payload.approval_id, command));
}
function appendPasswordPrompt(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
const terminal = article?.querySelector(".tool-terminal.is-error") || article?.querySelector(".tool-terminal");
if (!terminal) return;
const body = terminal.querySelector(".tool-terminal-body");
const status = terminal.querySelector(".tool-terminal-status");
terminal.classList.add("is-waiting");
terminal.dataset.approvalId = payload.approval_id || terminal.dataset.approvalId || "";
terminal.dataset.taskId = eventPayload.task_id || terminal.dataset.taskId || "";
if (status) status.textContent = "password";
if (body) {
body.textContent = [
body.textContent.trim(),
"",
payload.reason || "Sudo password is required to continue.",
].filter(Boolean).join("\n");
}
if (!terminal.querySelector(".tool-password-form")) terminal.append(createPasswordForm());
}
function createPasswordForm() {
const form = document.createElement("form");
form.className = "tool-password-form";
const input = document.createElement("input");
input.type = "password";
input.autocomplete = "current-password";
input.placeholder = "Sudo password";
input.required = true;
const button = document.createElement("button");
button.type = "submit";
button.textContent = "Continue";
form.append(input, button);
return form;
}
function createInlineApprovalActions(approvalId, command) {
const actions = document.createElement("div");
actions.className = "tool-approval-actions";
@@ -267,6 +301,22 @@ async function resolveInlineApproval(button) {
}
}
async function submitToolPassword(form) {
const terminal = form.closest(".tool-terminal");
const article = terminal?.closest(".message");
const taskId = terminal?.dataset.taskId;
const approvalId = terminal?.dataset.approvalId;
const input = form.querySelector("input");
const password = input?.value || "";
if (!terminal || !article || !taskId || !approvalId || !password) return;
form.querySelectorAll("input, button").forEach((item) => {
item.disabled = true;
});
await continueAfterPassword(article, taskId, approvalId, password);
input.value = "";
form.remove();
}
function humanApprovalDecision(action) {
if (action === "allow_once") return "разрешено один раз";
if (action === "allow_forever") return "разрешено навсегда";
@@ -423,6 +473,11 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
appendApprovalTerminal(pending, data);
return;
}
if (name === "tool_password_requested") {
pending.querySelector(".message-meta span").textContent = "password";
appendPasswordPrompt(pending, data);
return;
}
if (name === "content_delta") {
if (!context.contentStarted) {
context.contentStarted = true;
@@ -473,6 +528,30 @@ async function continueAfterInlineApproval(article, taskId, approvalId) {
}
}
async function continueAfterPassword(article, taskId, approvalId, password) {
if (!article || state.running) return;
state.running = true;
document.querySelector("#run").disabled = true;
setStatus("#task-status", taskId, "warn");
const context = {taskId, contentStarted: false};
try {
await streamSse(
`/v1/tasks/${encodeURIComponent(taskId)}/password/stream`,
{approval_id: approvalId, password},
async ({name, data}) => handleAssistantStreamEvent(article, name, data, context),
);
} catch (error) {
setMessagePending(article, error.message);
article.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
await refreshEvents(taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;
document.querySelector("#message")?.focus();
}
}
async function refreshConversations() {
const list = document.querySelector("#conversation-list");
if (!list) return;
@@ -611,6 +690,12 @@ function bindChat() {
const button = event.target.closest(".message-reasoning-toggle");
if (button) toggleInlineReasoning(button);
});
document.querySelector("#messages")?.addEventListener("submit", (event) => {
const form = event.target.closest(".tool-password-form");
if (!form) return;
event.preventDefault();
submitToolPassword(form).catch(console.error);
});
document.querySelector("#debug")?.addEventListener("change", (event) => {
document.querySelector("#debug-panel").hidden = !event.target.checked;
});
+32
View File
@@ -635,6 +635,38 @@ dd {
opacity: 0.65;
}
.tool-password-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid #1e293b;
background: #111827;
}
.tool-password-form input {
min-width: 0;
border-color: #334155;
background: #0f172a;
color: #f8fafc;
}
.tool-password-form button {
border: 0;
border-radius: 7px;
padding: 8px 10px;
background: #2563eb;
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.tool-password-form input:disabled,
.tool-password-form button:disabled {
cursor: wait;
opacity: 0.65;
}
.message-meta {
display: flex;
align-items: center;