Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion backend/app/agent/toolkit/terminal_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from app.component.environment import env
from app.service.task import (
Action,
ActionTerminalCommandApprovalData,
ActionTerminalData,
Agents,
get_task_lock,
Expand All @@ -39,6 +40,109 @@

logger = logging.getLogger("terminal_toolkit")

# Full dangerous-command set for HITL (issue #1306)
_DANGEROUS_COMMAND_TOKENS = frozenset(
{
# System Administration
"sudo",
"su",
"reboot",
"shutdown",
"halt",
"poweroff",
"init",
# File System
"rm",
"chown",
"chgrp",
"umount",
"mount",
# Disk Operations
"dd",
"mkfs",
"fdisk",
"parted",
"fsck",
"mkswap",
"swapon",
"swapoff",
# Process Management
"service",
"systemctl",
"systemd",
# Network Configuration
"iptables",
"ip6tables",
"ifconfig",
"route",
"iptables-save",
# Cron/Scheduling
"crontab",
"at",
"batch",
# User/Kernel Management
"useradd",
"userdel",
"usermod",
"passwd",
"chpasswd",
"newgrp",
"modprobe",
"rmmod",
"insmod",
"lsmod",
}
)


def _is_dangerous_command(command: str) -> bool:
"""Return True if the command is considered dangerous and requires HITL."""
parts = command.strip().split()
if not parts:
return False
token = parts[0]
# Strip path prefix (e.g. /usr/bin/sudo -> sudo)
if "/" in token:
token = token.split("/")[-1]
return token in _DANGEROUS_COMMAND_TOKENS


def _validate_cd_within_working_dir(
command: str, working_directory: str
) -> tuple[bool, str | None]:
"""Validate that a cd command does not escape working_directory.

Returns (True, None) if allowed, (False, error_message) if not.
"""
parts = command.strip().split()
if not parts or parts[0].split("/")[-1] != "cd":
return True, None
target = parts[1] if len(parts) > 1 else ""
# cd with no args or "cd ~" -> home; we treat as potential escape
if not target or target == "~":
target = os.path.expanduser("~")
elif target == "-":
# "cd -" is previous dir; we cannot validate, allow base to run it
return True, None
try:
work_real = os.path.realpath(os.path.abspath(working_directory))
if os.path.isabs(target):
resolved = os.path.realpath(os.path.abspath(target))
else:
resolved = os.path.realpath(
os.path.abspath(os.path.join(work_real, target))
)
if os.path.commonpath([resolved, work_real]) != work_real:
return (
False,
f"cd not allowed: path would escape working directory "
f"({working_directory}).",
)
except (OSError, ValueError):
return False, "cd not allowed: invalid path."
return True, None


# App version - should match electron app version
# TODO: Consider getting this from a shared config
APP_VERSION = "0.0.84"
Expand Down Expand Up @@ -100,13 +204,18 @@ def __init__(
max_workers=1, thread_name_prefix="terminal_toolkit"
)

self._safe_mode = safe_mode
self._use_docker_backend = use_docker_backend
self._working_directory = working_directory
# When user enables Safe Mode (HITL), we intercept; don't let Camel block.
camel_safe_mode = not safe_mode
super().__init__(
timeout=timeout,
working_directory=working_directory,
use_docker_backend=use_docker_backend,
docker_container_name=docker_container_name,
session_logs_dir=session_logs_dir,
safe_mode=safe_mode,
safe_mode=camel_safe_mode,
allowed_commands=allowed_commands,
clone_current_env=True,
install_dependencies=[],
Expand Down Expand Up @@ -358,6 +467,9 @@ def shell_exec(
) -> str:
r"""Executes a shell command in blocking or non-blocking mode.

When Safe Mode is on, dangerous commands (e.g. rm) trigger HITL
approval before execution.

Args:
command (str): The shell command to execute.
id (str, optional): A unique identifier for the command's session.
Expand All @@ -374,6 +486,30 @@ def shell_exec(

id = f"auto_{int(time.time() * 1000)}"

# Non-Docker: validate cd does not escape working_directory (issue #1306)
if not self._use_docker_backend:
ok, err = _validate_cd_within_working_dir(
command, self._working_directory
)
if not ok:
return err or "cd not allowed."

if self._safe_mode and _is_dangerous_command(command):
task_lock = get_task_lock(self.api_task_id)
if getattr(task_lock, "approved_all_dangerous_commands", False):
pass
else:
approval_data = ActionTerminalCommandApprovalData(
data={"command": command}
)
coro = task_lock.put_queue(approval_data)
self._run_coro_in_thread(coro)
approval = task_lock.terminal_approval_response.get(block=True)
if approval == "reject":
return "Command rejected by user."
if approval == "approve_all_in_task":
task_lock.approved_all_dangerous_commands = True

result = super().shell_exec(
id=id, command=command, block=block, timeout=timeout
)
Expand Down
14 changes: 14 additions & 0 deletions backend/app/controller/chat_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
McpServers,
Status,
SupplementChat,
TerminalApprovalRequest,
sse_json,
)
from app.service.chat_service import step_solve
Expand Down Expand Up @@ -416,6 +417,19 @@ def human_reply(id: str, data: HumanReply):
return Response(status_code=201)


@router.post("/chat/{id}/terminal-approval")
def terminal_approval(id: str, data: TerminalApprovalRequest):
"""Accept user approval for a dangerous terminal command (HITL)."""
chat_logger.info(
"Terminal approval received",
extra={"task_id": id, "approval": data.approval},
)
task_lock = get_task_lock(id)
task_lock.terminal_approval_response.put(data.approval)
chat_logger.debug("Terminal approval processed", extra={"task_id": id})
return Response(status_code=201)


@router.post("/chat/{id}/install-mcp")
def install_mcp(id: str, data: McpServers):
chat_logger.info(
Expand Down
8 changes: 8 additions & 0 deletions backend/app/model/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class Chat(BaseModel):
browser_port: int = 9222
max_retries: int = 3
allow_local_system: bool = False
safe_mode: bool = False
"""When True, require explicit user approval for dangerous terminal commands (HITL)."""
installed_mcp: McpServers = {"mcpServers": {}}
bun_mirror: str = ""
uvx_mirror: str = ""
Expand Down Expand Up @@ -153,6 +155,12 @@ class HumanReply(BaseModel):
reply: str


class TerminalApprovalRequest(BaseModel):
"""User response for dangerous terminal command approval (HITL)."""

approval: Literal["approve_once", "approve_all_in_task", "reject"]


class TaskContent(BaseModel):
id: str
content: str
Expand Down
8 changes: 7 additions & 1 deletion backend/app/service/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1071,6 +1071,9 @@ async def run_decomposition():
# questions (don't break, don't
# delete task_lock)
elif item.action == Action.start:
# Reset HITL "approve all in task" for the new task (issue #1306)
if hasattr(task_lock, "approved_all_dangerous_commands"):
task_lock.approved_all_dangerous_commands = False
# Check conversation history length before starting task
is_exceeded, total_length = check_conversation_history_length(
task_lock
Expand Down Expand Up @@ -1568,6 +1571,8 @@ def on_stream_text(chunk):
"process_task_id": item.process_task_id,
},
)
elif item.action == Action.terminal_command_approval:
yield sse_json("terminal_command_approval", item.data)
elif item.action == Action.pause:
if workforce is not None:
workforce.pause()
Expand Down Expand Up @@ -2422,11 +2427,12 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat):
for item in data.tools:
tool_names.append(titleize(item))
# Always include terminal_toolkit with proper working directory
safe_mode = getattr(options, "safe_mode", False)
terminal_toolkit = TerminalToolkit(
options.project_id,
agent_name=data.name,
working_directory=working_directory,
safe_mode=True,
safe_mode=safe_mode,
clone_current_env=True,
)
tools.extend(terminal_toolkit.get_tools())
Expand Down
21 changes: 21 additions & 0 deletions backend/app/service/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import asyncio
import logging
import queue as stdlib_queue
import weakref
from contextlib import contextmanager
from contextvars import ContextVar
Expand Down Expand Up @@ -58,6 +59,9 @@ class Action(str, Enum):
search_mcp = "search_mcp" # backend -> user
install_mcp = "install_mcp" # backend -> user
terminal = "terminal" # backend -> user
terminal_command_approval = (
"terminal_command_approval" # backend -> user (HITL)
)
end = "end" # backend -> user
stop = "stop" # user -> backend
supplement = "supplement" # user -> backend
Expand Down Expand Up @@ -219,6 +223,15 @@ class ActionTerminalData(BaseModel):
data: str


class ActionTerminalCommandApprovalData(BaseModel):
"""Request user approval for a dangerous terminal command (HITL)."""

action: Literal[Action.terminal_command_approval] = (
Action.terminal_command_approval
)
data: dict[Literal["command"], str]


class ActionStopData(BaseModel):
action: Literal[Action.stop] = Action.stop

Expand Down Expand Up @@ -296,6 +309,7 @@ class ActionSkipTaskData(BaseModel):
| ActionSearchMcpData
| ActionInstallMcpData
| ActionTerminalData
| ActionTerminalCommandApprovalData
| ActionStopData
| ActionEndData
| ActionTimeoutData
Expand Down Expand Up @@ -370,6 +384,13 @@ def __init__(
self.question_agent = None
self.current_task_id = None

# HITL: queue for terminal dangerous-command approval (thread-safe)
self.terminal_approval_response: stdlib_queue.Queue[str] = (
stdlib_queue.Queue()
)
# HITL: "All Yes in this task" - skip further prompts for this task
self.approved_all_dangerous_commands: bool = False

logger.info(
"Task lock initialized",
extra={"task_id": id, "created_at": self.created_at.isoformat()},
Expand Down
62 changes: 62 additions & 0 deletions src/components/ChatBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,68 @@ export default function ChatBox(): JSX.Element {
onSkip={handleSkip}
isPauseResumeLoading={isPauseResumeLoading}
/>
{/* HITL: dangerous terminal command approval (no 30s auto-skip) */}
{chatStore.activeTaskId &&
chatStore.tasks[chatStore.activeTaskId]?.activeTerminalApproval && (
<div className="border-border-default mx-4 mb-2 flex flex-col gap-3 rounded-xl border bg-surface-secondary px-4 py-3">
<div className="text-body-sm font-medium text-text-heading">
{t('chat.terminal-approval-prompt')}
</div>
<code className="break-all rounded bg-surface-tertiary px-2 py-1 text-body-sm text-text-secondary">
{
chatStore.tasks[chatStore.activeTaskId]
.activeTerminalApproval?.command
}
</code>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="bg-bg-fill-info-primary text-white rounded-lg px-3 py-1.5 text-body-sm font-medium hover:opacity-90"
onClick={async () => {
const taskId = chatStore.activeTaskId as string;
const projectId = projectStore.activeProjectId;
if (!projectId) return;
await fetchPost(`/chat/${projectId}/terminal-approval`, {
approval: 'approve_once',
});
chatStore.clearActiveTerminalApproval(taskId);
}}
>
{t('chat.terminal-approval-yes')}
</button>
<button
type="button"
className="bg-bg-fill-info-primary text-white rounded-lg px-3 py-1.5 text-body-sm font-medium hover:opacity-90"
onClick={async () => {
const taskId = chatStore.activeTaskId as string;
const projectId = projectStore.activeProjectId;
if (!projectId) return;
await fetchPost(`/chat/${projectId}/terminal-approval`, {
approval: 'approve_all_in_task',
});
chatStore.clearActiveTerminalApproval(taskId);
}}
>
{t('chat.terminal-approval-all-yes')}
</button>
<button
type="button"
className="border-border-default rounded-lg border px-3 py-1.5 text-body-sm font-medium text-text-secondary hover:bg-surface-tertiary"
onClick={async () => {
const taskId = chatStore.activeTaskId as string;
const projectId = projectStore.activeProjectId;
if (!projectId) return;
await fetchPost(`/chat/${projectId}/terminal-approval`, {
approval: 'reject',
});
chatStore.clearActiveTerminalApproval(taskId);
}}
>
{t('chat.terminal-approval-no')}
</button>
</div>
</div>
)}
{chatStore.activeTaskId && (
<BottomBox
state={getBottomBoxState()}
Expand Down
Loading