Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
178687c
Implement Replace strict command prohibition
spider-yamet Feb 19, 2026
ea58d8f
Revert unnecessary test implementations
spider-yamet Feb 19, 2026
feedb69
Revert unnecessary vite dev server configuration
spider-yamet Feb 19, 2026
aaf4432
Restore index.ts file from electron/main
spider-yamet Feb 19, 2026
ae50b21
Fix for pre-commit check
spider-yamet Feb 19, 2026
d92015d
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 19, 2026
7a4809d
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 21, 2026
b53d046
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 22, 2026
8fc5e33
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 22, 2026
7708dca
redesign
bytecii Feb 23, 2026
e0eca63
Merge branch 'main' into feat/replace-strict-command-prohibition
bytecii Feb 23, 2026
778bea2
redesign & fix
bytecii Feb 23, 2026
f05eb7f
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 23, 2026
81cc43d
Merge branch 'main' into feat/replace-strict-command-prohibition
bytecii Feb 24, 2026
702a819
refactor: move HitlOptions and ApprovalRequest to app.hitl package
bytecii Feb 24, 2026
6518d87
refactor: move HITL models to app/hitl/config.py
bytecii Feb 24, 2026
18c2af8
refactor: move _request_user_approval to TerminalToolkit and clean up
bytecii Feb 24, 2026
4db02a6
refactor: clean up docstrings, use enums, and move _thread_local
bytecii Feb 24, 2026
fa22cae
fix: read HITL setting on the fly and send it on follow-up tasks
bytecii Feb 24, 2026
f980daa
Merge branch 'main' into feat/replace-strict-command-prohibition
bytecii Feb 24, 2026
4239088
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 24, 2026
1a21afe
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 24, 2026
9469e7a
feat: replace per-agent Queue with per-call Future for concurrent HIT…
bytecii Feb 24, 2026
395e769
docs: replace 'dangerous' with neutral wording in approval-related do…
bytecii Feb 24, 2026
d90a4fa
i18n: add missing approval-pending key to all locales
bytecii Feb 24, 2026
355bbb7
fix: remove duplicate terminal-approval keys in en-us setting.json
bytecii Feb 24, 2026
cef0239
Merge branch 'main' into feat/replace-strict-command-prohibition
fengju0213 Feb 25, 2026
5bb5aeb
Merge branch 'main' into feat/replace-strict-command-prohibition
fengju0213 Feb 25, 2026
cd43f0b
fix(hitl): make approval future resolution thread-safe and correct co…
fengju0213 Feb 25, 2026
5c2a7ba
Merge branch 'main' into feat/replace-strict-command-prohibition
fengju0213 Feb 26, 2026
57bf7c7
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 26, 2026
6693308
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Feb 26, 2026
c75e5b2
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 3, 2026
2459951
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 3, 2026
fed7e25
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 3, 2026
a1c4242
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 3, 2026
8d10a57
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 4, 2026
0c25112
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 5, 2026
28f545b
Implement fix
spider-yamet Mar 5, 2026
4aef108
Fix unit test
spider-yamet Mar 5, 2026
ac9a436
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 6, 2026
3d5bdc1
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 6, 2026
fdc5b29
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 7, 2026
ec64278
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 9, 2026
e9ae820
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 10, 2026
1288b47
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 11, 2026
3261fdb
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 12, 2026
a43c905
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 17, 2026
9ed63b0
Merge branch 'main' into feat/replace-strict-command-prohibition
spider-yamet Mar 19, 2026
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
1 change: 0 additions & 1 deletion backend/app/agent/factory/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,6 @@ def browser_agent(options: Chat):
options.project_id,
Agents.browser_agent,
working_directory=working_directory,
safe_mode=True,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this as this is not configured. And by default if the HITL not specified, we still use the safe_mode=True for the terminaltoolkit

clone_current_env=True,
)
terminal_toolkit = message_integration.register_functions(
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/developer.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ async def developer_agent(options: Chat):
options.project_id,
Agents.developer_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ async def document_agent(options: Chat):
options.project_id,
Agents.document_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/multi_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ def multi_modal_agent(options: Chat):
options.project_id,
agent_name=Agents.multi_modal_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
161 changes: 151 additions & 10 deletions backend/app/agent/toolkit/terminal_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import shutil
import subprocess
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor

from camel.toolkits.terminal_toolkit import (
Expand All @@ -28,17 +30,25 @@

from app.agent.toolkit.abstract_toolkit import AbstractToolkit
from app.component.environment import env
from app.hitl.terminal_command import (
is_dangerous_command,
validate_cd_within_working_dir,
)
from app.model.enums import ApprovalAction
from app.service.task import (
Action,
ActionCommandApprovalData,
ActionTerminalData,
Agents,
get_task_lock,
get_task_lock_if_exists,
process_task,
)
from app.utils.listen.toolkit_listen import auto_listen_toolkit

logger = logging.getLogger("terminal_toolkit")


# App version - should match electron app version
# TODO: Consider getting this from a shared config
APP_VERSION = "0.0.88"
Expand All @@ -58,7 +68,7 @@ def get_terminal_base_venv_path() -> str:
class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
agent_name: str = Agents.developer_agent
_thread_pool: ThreadPoolExecutor | None = None
_thread_local = threading.local()
_thread_local: threading.local = threading.local()

def __init__(
self,
Expand All @@ -69,7 +79,6 @@ def __init__(
use_docker_backend: bool = False,
docker_container_name: str | None = None,
session_logs_dir: str | None = None,
safe_mode: bool = True,
allowed_commands: list[str] | None = None,
clone_current_env: bool = True,
):
Expand Down Expand Up @@ -100,22 +109,30 @@ def __init__(
max_workers=1, thread_name_prefix="terminal_toolkit"
)

self._use_docker_backend = use_docker_backend
self._working_directory = working_directory

# safe_mode is read fresh from task_lock in shell_exec (see
# _get_terminal_approval), but we need an initial value for
# super().__init__.
task_lock = get_task_lock_if_exists(api_task_id)
terminal_approval = (
task_lock.hitl_options.terminal_approval if task_lock else False
)
camel_safe_mode = not terminal_approval
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=[],
)

# Auto-register with TaskLock for cleanup when task ends
from app.service.task import get_task_lock_if_exists

task_lock = get_task_lock_if_exists(api_task_id)
if task_lock:
task_lock.register_toolkit(self)
logger.info(
Expand Down Expand Up @@ -349,7 +366,96 @@ def _run_coro_in_thread(coro, task_lock):
exc_info=True,
)

def shell_exec(
def _get_terminal_approval(self) -> bool:
"""Read terminal_approval from task_lock on every call.

This ensures the setting takes effect immediately when the user
toggles it between tasks (the task_lock is updated at POST /chat).
Also syncs Camel's safe_mode so it stays consistent.
"""
task_lock = get_task_lock_if_exists(self.api_task_id)
enabled = (
task_lock.hitl_options.terminal_approval if task_lock else False
)
self.safe_mode = not enabled
return enabled

async def _request_user_approval(self, action_data) -> str | None:
"""Send a command approval request to the frontend and wait.

Flow::
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is the flow


_request_user_approval (agent coroutine)
1. create_approval(approval_id) → Future stored, agent will await it
2. put_queue(action_data) → drops SSE event into shared queue
3. await future → agent suspends

SSE generator in chat_service.py (independently)
4. get_queue() → picks up the event
5. yield sse_json(...) → sends command_approval to frontend

Frontend
6. shows approval card

User clicks "Approve Once" / "Auto Approve" / "Reject"
7. POST /approval → resolve_approval() or resolve_all_approvals_for_agent()
8. future.set_result(...) → agent resumes at step 3

Args:
action_data (ActionCommandApprovalData): SSE payload containing
the command and agent name. ``approval_id`` is injected into
``action_data.data`` before the event is queued.

Returns:
None if the command is approved (approve_once or auto_approve).
An error string if the command is rejected.
"""
task_lock = get_task_lock(self.api_task_id)
if task_lock.auto_approve.get(self.agent_name, False):
return None

# Each concurrent call gets its own Future keyed by approval_id.
# Use str concatenation (not f-string) so that Enum values like
# Agents.developer_agent produce "developer_agent_..." instead of
# "Agents.developer_agent_..." — the latter breaks startswith()
# matching in resolve_all_approvals_for_agent.
approval_id = self.agent_name + "_" + uuid.uuid4().hex[:12]
action_data.data["approval_id"] = approval_id
future = task_lock.create_approval(approval_id)

logger.info(
"[APPROVAL] Pushing approval event to SSE queue, "
"api_task_id=%s, agent=%s, approval_id=%s",
self.api_task_id,
self.agent_name,
approval_id,
)

await task_lock.put_queue(action_data)

logger.info("[APPROVAL] Event pushed, waiting for user response")

approval = await future

logger.info("[APPROVAL] Received response: %s", approval)

# Re-check: another concurrent call may have set auto_approve
# while this call was waiting on its Future.
if task_lock.auto_approve.get(self.agent_name, False):
return None

if approval == ApprovalAction.approve_once:
return None
if approval == ApprovalAction.auto_approve:
task_lock.auto_approve[self.agent_name] = True
# Unblock all other pending approvals for this agent
task_lock.resolve_all_approvals_for_agent(
self.agent_name, ApprovalAction.auto_approve
)
return None
return "Operation rejected by user. The task is being stopped."

async def shell_exec(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to async

self,
command: str,
id: str | None = None,
Expand All @@ -358,6 +464,25 @@ def shell_exec(
) -> str:
r"""Executes a shell command in blocking or non-blocking mode.

When HITL terminal approval is on, commands that require user
confirmation trigger an approval request before execution.

.. note:: Async override of a sync base method

Camel's ``BaseTerminalToolkit.shell_exec`` is **sync-only** (no
async variant exists in the upstream library). We override it
as ``async def`` so the HITL approval flow can ``await`` the
SSE queue and the approval-response queue — following the same
asyncio.Queue pattern used by ``HumanToolkit.ask_human_via_gui``.

Because the base method is sync and this override is async, the
``listen_toolkit`` decorator applies a ``__wrapped__`` fix (see
``toolkit_listen.py``) to ensure Camel's ``FunctionTool``
dispatches this method via ``async_call`` on the **main** event
loop, rather than via the sync ``__call__`` path which would run
it on a background loop where cross-loop ``asyncio.Queue`` awaits
silently fail.

Args:
command (str): The shell command to execute.
id (str, optional): A unique identifier for the command's session.
Expand All @@ -368,12 +493,28 @@ def shell_exec(
Returns:
str: The output of the command execution.
"""
# Auto-generate ID if not provided
if id is None:
import time

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

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."

terminal_approval = self._get_terminal_approval()
is_dangerous = (
is_dangerous_command(command) if terminal_approval else False
)
if terminal_approval and is_dangerous:
approval_data = ActionCommandApprovalData(
data={"command": command, "agent": self.agent_name}
)
rejection = await self._request_user_approval(approval_data)
if rejection is not None:
return rejection

result = super().shell_exec(
id=id, command=command, block=block, timeout=timeout
)
Expand Down
40 changes: 40 additions & 0 deletions backend/app/controller/chat_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from app.component import code
from app.component.environment import sanitize_env_path, set_user_env_path
from app.exception.exception import UserException
from app.hitl.config import ApprovalRequest
from app.model.chat import (
AddTaskRequest,
Chat,
Expand All @@ -35,6 +36,7 @@
SupplementChat,
sse_json,
)
from app.model.enums import ApprovalAction
from app.service.chat_service import step_solve
from app.service.task import (
Action,
Expand Down Expand Up @@ -176,6 +178,7 @@ async def post(data: Chat, request: Request):
)

task_lock = get_or_create_task_lock(data.project_id)
task_lock.hitl_options = data.hitl_options

# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
Expand Down Expand Up @@ -258,6 +261,10 @@ def improve(id: str, data: SupplementChat):
)
task_lock = get_task_lock(id)

# Update HITL options if provided (user may have changed settings)
if data.hitl_options is not None:
task_lock.hitl_options = data.hitl_options

# Allow continuing conversation even after task is done
# This supports multi-turn conversation after complex task completion
if task_lock.status == Status.done:
Expand Down Expand Up @@ -416,6 +423,39 @@ def human_reply(id: str, data: HumanReply):
return Response(status_code=201)


@router.post("/chat/{id}/approval")
def approval(id: str, data: ApprovalRequest):
"""Handle user approval response for a command requiring confirmation."""
chat_logger.info(
"Approval received",
extra={
"task_id": id,
"agent": data.agent,
"approval": data.approval,
"approval_id": data.approval_id,
},
)
task_lock = get_task_lock(id)

if data.approval == ApprovalAction.auto_approve:
# Set flag and resolve ALL pending approvals for this agent
task_lock.auto_approve[data.agent] = True
task_lock.resolve_all_approvals_for_agent(
data.agent, ApprovalAction.auto_approve
)
elif data.approval == ApprovalAction.reject:
# Resolve ALL pending for this agent (skip-task cleanup handles rest)
task_lock.resolve_all_approvals_for_agent(
data.agent, ApprovalAction.reject
)
else:
# approve_once: resolve only the specific request
task_lock.resolve_approval(data.approval_id, data.approval)

chat_logger.debug("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
13 changes: 13 additions & 0 deletions backend/app/hitl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
27 changes: 27 additions & 0 deletions backend/app/hitl/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

from pydantic import BaseModel

from app.model.enums import ApprovalAction


class HitlOptions(BaseModel):
terminal_approval: bool = False


class ApprovalRequest(BaseModel):
approval: ApprovalAction
agent: str
approval_id: str = ""
Loading
Loading