diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 5437a403c3..d301ccb748 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -3711,8 +3711,10 @@ export interface ApprovalResult { choice: ApprovalChoice all?: boolean | null } -export interface EmptyRequestParams { +/** Original command, redacted server-side before any password-injection rewrite. */ +export interface SudoRequestParams { session_id: string + command?: string } /** The answer to any one-string prompt (sudo, secret, vault prompts, desktop bridges): ``''`` means skipped / declined. */ export interface ValueResult { @@ -3744,6 +3746,9 @@ export interface ReadRangeRequestParams { start?: number | null count?: number | null } +export interface EmptyRequestParams { + session_id: string +} /** ``tools/drive_preview_tool.py`` and ``tools/annotate_preview_tool.py`` field sets. */ export interface PreviewActRequestParams { session_id: string @@ -4823,7 +4828,7 @@ export interface ServerRequestMap { /** Masked value for a named env var (skills / setup flows). */ secret: { params: SecretRequestParams; result: ValueResult } /** Masked sudo password for the terminal tool. */ - sudo: { params: EmptyRequestParams; result: ValueResult } + sudo: { params: SudoRequestParams; result: ValueResult } /** Read the visible in-app terminal buffer (JSON text answer). */ 'terminal.read': { params: ReadRangeRequestParams; result: ValueResult } /** Drive a guided tour highlight in the desktop renderer. */ diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 1ddc336d5e..c92821e7bc 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -29975,6 +29975,26 @@ "title": "SubscriptionUpgradeResult", "type": "object" }, + "SudoRequestParams": { + "additionalProperties": false, + "description": "Original command, redacted server-side before any password-injection rewrite.", + "properties": { + "session_id": { + "title": "Session Id", + "type": "string" + }, + "command": { + "default": "", + "title": "Command", + "type": "string" + } + }, + "required": [ + "session_id" + ], + "title": "SudoRequestParams", + "type": "object" + }, "SystemBatteryParams": { "additionalProperties": false, "properties": { @@ -33458,7 +33478,7 @@ { "name": "params", "schema": { - "$ref": "#/components/schemas/EmptyRequestParams" + "$ref": "#/components/schemas/SudoRequestParams" } } ], diff --git a/tests/tools/test_subagent_sudo_prompt.py b/tests/tools/test_subagent_sudo_prompt.py index 36fc508f97..66f74fd771 100644 --- a/tests/tools/test_subagent_sudo_prompt.py +++ b/tests/tools/test_subagent_sudo_prompt.py @@ -67,7 +67,7 @@ class TestDelegatedChildNeverPrompts: monkeypatch.setattr( tts, "_prompt_for_sudo_password", - lambda timeout_seconds=45: calls.append(1) or "hunter2", + lambda timeout_seconds=45, *, command="": calls.append(1) or "hunter2", ) transformed, sudo_stdin = _transform_in_child("sudo apt-get update") @@ -100,7 +100,7 @@ class TestDelegatedChildNeverPrompts: """The fix must not break interactive prompting outside children.""" monkeypatch.setenv("HERMES_INTERACTIVE", "1") monkeypatch.setattr( - tts, "_prompt_for_sudo_password", lambda timeout_seconds=45: "hunter2" + tts, "_prompt_for_sudo_password", lambda timeout_seconds=45, *, command="": "hunter2" ) transformed, sudo_stdin = tts._transform_sudo_command("sudo whoami") diff --git a/tests/tui_gateway/test_sudo_command_context.py b/tests/tui_gateway/test_sudo_command_context.py new file mode 100644 index 0000000000..25532291d7 --- /dev/null +++ b/tests/tui_gateway/test_sudo_command_context.py @@ -0,0 +1,80 @@ +"""Sudo questions carry the original, redacted command through the real request path.""" + +import sys +import threading + + +def test_sudo_request_preserves_command_without_leaking_prompt_context(monkeypatch): + from hermes_cli import banner + + # The real server binds callbacks on import; isolate only its process-wide side effects. + monkeypatch.setattr(banner, "prefetch_update_check", lambda: None) + monkeypatch.setattr(sys, "stdout", sys.stdout) + monkeypatch.setattr(sys, "excepthook", sys.excepthook) + monkeypatch.setattr(threading, "excepthook", threading.excepthook) + from tui_gateway import server, server_requests + from tui_gateway.contracts.registry import SERVER_REQUESTS + from gateway.run import _redact_approval_command + from agent import redact + from agent.vault_backends import unlock + from tools import project_tools, skills_tool, terminal_tool, terminal_tool_sudo + + # Restore the real registrations, including unrelated callbacks wired by the gateway. + monkeypatch.setattr(terminal_tool, "_callback_tls", threading.local()) + monkeypatch.setattr(unlock, "_callback_tls", threading.local()) + monkeypatch.setattr(unlock, "_current_session_tls", threading.local()) + monkeypatch.setattr(project_tools, "_workspace_callback", None) + monkeypatch.setattr(skills_tool, "_secret_capture_callback", None) + monkeypatch.setattr(terminal_tool_sudo, "_sudo_password_cache", {}) + monkeypatch.setattr(redact, "_REDACT_ENABLED", False) + + sid, session_key = "sudo-dialog", "sudo-conversation" + monkeypatch.setitem(server._sessions, sid, {"session_key": session_key, "source": "desktop"}) + command = ( + "printf '%s\\n' '" + "full context Ω " * 1000 + "' &&\n" + "sudo env API_TOKEN=ghp_" + "X" * 36 + " first-command | sort;\n" + "sudo second-command --keep-final-argument" + ) + expected = _redact_approval_command(command) + assert expected != command + frames, replays = [], [] + + def refuse(frame): + frames.append(frame) + replays.append(server_requests.open_requests(sid)) + assert server_requests.resolve_response( + {"jsonrpc": "2.0", "id": frame["id"], "result": {"value": ""}} + ) + return True + + monkeypatch.setattr(server, "write_json", refuse) + tokens = server._set_session_context(session_key, ui_session_id=sid) + try: + server._wire_callbacks(sid) + assert terminal_tool_sudo._transform_sudo_command(command) == (command, None) + assert len(frames) == 1 + frame = frames[0] + assert frame["method"] == "sudo" + assert frame["params"] == {"session_id": sid, "command": expected} + assert replays == [[{key: frame[key] for key in ("id", "method", "params")}]] + assert server_requests.open_requests(sid) == [] + assert terminal_tool_sudo._get_cached_sudo_password() == "" + assert terminal_tool_sudo.get_sudo_prompt_command() == "" + + # A legacy zero-argument caller must not inherit the preceding command. + assert terminal_tool._get_sudo_password_callback()() == "" + assert frames[-1]["params"] == {"session_id": sid, "command": ""} + assert terminal_tool_sudo._prompt_for_sudo_password() == "" + assert frames[-1]["params"] == {"session_id": sid, "command": ""} + assert SERVER_REQUESTS["sudo"].params(session_id=sid).command == "" + + def failed_legacy_callback(): + assert terminal_tool_sudo.get_sudo_prompt_command() == command + raise RuntimeError("prompt unavailable") + + terminal_tool.set_sudo_password_callback(failed_legacy_callback) + assert terminal_tool_sudo._prompt_for_sudo_password(command=command) == "" + assert terminal_tool_sudo.get_sudo_prompt_command() == "" + finally: + server._clear_session_context(tokens) + server_requests.reset_for_tests() diff --git a/tools/terminal_tool_sudo.py b/tools/terminal_tool_sudo.py index 15c64c3a1c..4f1b78949b 100644 --- a/tools/terminal_tool_sudo.py +++ b/tools/terminal_tool_sudo.py @@ -14,6 +14,7 @@ import sys import threading import time from collections.abc import Callable, Iterator +from contextvars import ContextVar from utils import env_var_enabled @@ -26,6 +27,14 @@ logger = logging.getLogger("tools.terminal_tool") _sudo_password_cache: dict[str, str] = {} _sudo_password_cache_lock = threading.Lock() +# Only populated while invoking a UI callback; preserve the zero-argument callback API. +_sudo_prompt_command: ContextVar[str] = ContextVar("sudo_prompt_command", default="") + + +def get_sudo_prompt_command() -> str: + """Original command for the current sudo password prompt, or '' outside its callback.""" + return _sudo_prompt_command.get() + def _get_sudo_password_cache_scope() -> str: """Return the cache scope for interactive sudo passwords.""" @@ -173,19 +182,22 @@ def _read_hidden_password(result: dict) -> None: result["done"] = True -def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str: +def _prompt_for_sudo_password(timeout_seconds: int = 45, *, command: str = "") -> str: """Prompt for a sudo password; "" on skip (empty Enter), timeout, or error. Prefers the CLI-registered callback (prompt_toolkit-integrated); otherwise reads /dev/tty (msvcrt on Windows) with echo disabled. Human wait time is excluded from tool deadlines (``human_wait_window``).""" from tools.terminal_tool import _get_sudo_password_callback _sudo_cb = _get_sudo_password_callback() if _sudo_cb is not None: + token = _sudo_prompt_command.set(command) try: from tools.approval_human_wait import human_wait_window with human_wait_window(): return _sudo_cb() or "" except Exception: return "" + finally: + _sudo_prompt_command.reset(token) result = {"password": None, "done": False} try: @@ -462,7 +474,7 @@ def _transform_sudo_command( # way. Re-probed every call so an expired sudo timestamp cannot silently block. if sudo_nopasswd_check is not None and sudo_nopasswd_check(): return command, None - sudo_password = _prompt_for_sudo_password(timeout_seconds=45) + sudo_password = _prompt_for_sudo_password(timeout_seconds=45, command=command) if sudo_password: _set_cached_sudo_password(sudo_password) diff --git a/tui_gateway/agent_callbacks.py b/tui_gateway/agent_callbacks.py index 53c8dacb42..b78d9b134c 100644 --- a/tui_gateway/agent_callbacks.py +++ b/tui_gateway/agent_callbacks.py @@ -156,6 +156,8 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None: def _wire_callbacks(sid: str): from tools.terminal_tool import set_sudo_password_callback + from tools.terminal_tool_sudo import get_sudo_prompt_command + from gateway.run import _redact_approval_command from tools.skills_tool import set_secret_capture_callback from tools.project_tools import set_project_workspace_callback @@ -167,7 +169,8 @@ def _wire_callbacks(sid: str): from hermes_cli.config import save_env_value_secure return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} - set_sudo_password_callback(lambda: _ask("sudo", sid, {}, timeout=120)) + set_sudo_password_callback(lambda: _ask( + "sudo", sid, {"command": _redact_approval_command(get_sudo_prompt_command())}, timeout=120)) set_project_workspace_callback(_apply_project_workspace) set_secret_capture_callback(secret_cb) # External password-manager unlock: the renderer shows a masked master-password card; the diff --git a/tui_gateway/contracts/server_requests.py b/tui_gateway/contracts/server_requests.py index 04ffde2d26..3a57dc4a6f 100644 --- a/tui_gateway/contracts/server_requests.py +++ b/tui_gateway/contracts/server_requests.py @@ -102,7 +102,13 @@ class EmptyRequestParams(ServerRequestParams): pass -server_request("sudo", params=EmptyRequestParams, result=ValueResult, +class SudoRequestParams(ServerRequestParams): + """Original command, redacted server-side before any password-injection rewrite.""" + + command: str = "" + + +server_request("sudo", params=SudoRequestParams, result=ValueResult, doc="Masked sudo password for the terminal tool.")