From a958271129d2401cacb44de82ad4c93882bc1b53 Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Sat, 4 Apr 2026 12:33:51 +0000 Subject: [PATCH 1/2] fix(gateway): /btw bypasses running-agent interrupt path /btw is designed to answer ephemeral side questions concurrently while an agent is working. When a running agent existed, the gateway was sending /btw as an interrupt to that agent (lines 1871-1876) and returning before ever reaching the command dispatch at line 1878. /approve and /deny already had this bypass pattern (they need it because the agent thread is blocked on a threading.Event). /btw needs the same treatment for the opposite reason: it should run concurrently, not interrupt. Fix: add /btw to the early-intercept check block, routing directly to _handle_btw_command regardless of running-agent state. Adds regression test: tests/gateway/test_btw_bypass.py --- gateway/run.py | 6 ++ tests/gateway/test_btw_bypass.py | 102 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 tests/gateway/test_btw_bypass.py diff --git a/gateway/run.py b/gateway/run.py index 58c52f4b43536..2e27e6ddf155a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1831,6 +1831,12 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: return await self._handle_approve_command(event) return await self._handle_deny_command(event) + # /btw must also bypass the running-agent interrupt path. + # It's designed to run concurrently as an ephemeral side question — + # sending it as an interrupt to the running agent defeats the purpose. + if _cmd_def_inner and _cmd_def_inner.name == "btw": + return await self._handle_btw_command(event) + if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) diff --git a/tests/gateway/test_btw_bypass.py b/tests/gateway/test_btw_bypass.py new file mode 100644 index 0000000000000..81d98eac4487f --- /dev/null +++ b/tests/gateway/test_btw_bypass.py @@ -0,0 +1,102 @@ +"""Tests for /btw bypass of the running-agent interrupt path. + +Verifies that /btw is dispatched directly to _handle_btw_command even when +an agent is currently running — not sent as an interrupt to the active agent. + +Regression test for: /btw treated as interrupt when agent is running. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.platforms.base import ( + MessageEvent, + MessageType, + Platform, +) +from gateway.session import SessionSource + + +def _make_source(chat_id="c1", user_id="u1"): + return SessionSource(platform=Platform.TELEGRAM, user_id=user_id, chat_id=chat_id) + + +def _make_btw_event(question: str = "what is 2+2") -> MessageEvent: + return MessageEvent( + text=f"/btw {question}", + source=_make_source(), + message_id="m1", + message_type=MessageType.TEXT, + ) + + +def _make_running_agent_mock() -> MagicMock: + agent = MagicMock() + agent.interrupt = MagicMock() + return agent + + +# Key format: agent:main::: +_SESSION_KEY = "agent:main:telegram:dm:c1" + + +def _make_runner_with_running_agent(): + """Build a minimal GatewayRunner stub with one active agent.""" + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.adapters = {} + runner.hooks = MagicMock() + runner.hooks.emit = AsyncMock() + runner._pending_messages = {} + runner._running_agents_ts = {} + + running_agent = _make_running_agent_mock() + runner._running_agents = {_SESSION_KEY: running_agent} + return runner, running_agent + + +class TestBtwBypassesRunningAgent: + """/btw must be dispatched directly even when an agent is running.""" + + @pytest.mark.asyncio + async def test_btw_does_not_interrupt_running_agent(self): + """Sending /btw while an agent is running should NOT call agent.interrupt().""" + runner, running_agent = _make_runner_with_running_agent() + event = _make_btw_event("what is 2+2") + + with patch.object(runner, "_is_user_authorized", return_value=True), \ + patch.object( + runner, "_handle_btw_command", new=AsyncMock(return_value="💬 answer") + ) as mock_btw: + await runner._handle_message(event) + + # /btw handler was called + mock_btw.assert_called_once_with(event) + # Running agent was NOT interrupted + running_agent.interrupt.assert_not_called() + # /btw message was NOT queued as a pending message + assert _SESSION_KEY not in runner._pending_messages + + @pytest.mark.asyncio + async def test_regular_message_still_interrupts_running_agent(self): + """Non-/btw messages while agent is running should still trigger interrupt.""" + runner, running_agent = _make_runner_with_running_agent() + + event = MessageEvent( + text="just a normal message", + source=_make_source(), + message_id="m2", + message_type=MessageType.TEXT, + ) + + with patch.object(runner, "_is_user_authorized", return_value=True), \ + patch.object(runner, "_handle_btw_command", new=AsyncMock()) as mock_btw: + await runner._handle_message(event) + + # Normal message should interrupt the agent + running_agent.interrupt.assert_called_once() + # /btw handler should NOT have been called + mock_btw.assert_not_called() From fcf323587050c9bcf36ed5b85f794481da83713b Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Sat, 4 Apr 2026 11:35:35 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(cred-proxy):=20Phase=201=20credential?= =?UTF-8?q?=20proxy=20=E2=80=94=20stdlib-only,=20no=20external=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sidecar daemon that intercepts outbound HTTP from tool subprocesses and substitutes hermes-proxy:// credential placeholders with real values at the transport layer. The agent process structurally cannot read credential values — they live only in the proxy's heap. Phase 1 scope: - Plain HTTP: headers and body inspected; placeholders substituted - HTTPS (CONNECT): blind TCP relay — no interception (Phase 2 scope) - Store: in-memory dict — cleared on restart (Phase 3 adds persistence) - Zero external dependencies — stdlib asyncio only Usage: hermes cred-proxy start hermes cred-proxy add # prompts securely, never echoes hermes cred-proxy list hermes cred-proxy stop Tool subprocesses receive http_proxy automatically when the daemon is running. Standard clients (requests, httpx, curl) honor it. Co-Authored-By: Claude Opus 4.6 (1M context) --- cred_proxy/__init__.py | 0 cred_proxy/__main__.py | 10 + cred_proxy/cli.py | 135 ++++++++++ cred_proxy/daemon.py | 209 +++++++++++++++ cred_proxy/server.py | 375 +++++++++++++++++++++++++++ cred_proxy/store.py | 60 +++++ cred_proxy/substitutor.py | 34 +++ hermes_cli/config.py | 5 + hermes_cli/main.py | 29 ++- pyproject.toml | 4 +- tests/cred_proxy/__init__.py | 0 tests/cred_proxy/conftest.py | 6 + tests/cred_proxy/test_fixes.py | 118 +++++++++ tests/cred_proxy/test_integration.py | 77 ++++++ tests/cred_proxy/test_store.py | 54 ++++ tests/cred_proxy/test_substitutor.py | 59 +++++ tests/tools/test_env_passthrough.py | 20 ++ tools/environments/local.py | 61 ++++- 18 files changed, 1251 insertions(+), 5 deletions(-) create mode 100644 cred_proxy/__init__.py create mode 100644 cred_proxy/__main__.py create mode 100644 cred_proxy/cli.py create mode 100644 cred_proxy/daemon.py create mode 100644 cred_proxy/server.py create mode 100644 cred_proxy/store.py create mode 100644 cred_proxy/substitutor.py create mode 100644 tests/cred_proxy/__init__.py create mode 100644 tests/cred_proxy/conftest.py create mode 100644 tests/cred_proxy/test_fixes.py create mode 100644 tests/cred_proxy/test_integration.py create mode 100644 tests/cred_proxy/test_store.py create mode 100644 tests/cred_proxy/test_substitutor.py diff --git a/cred_proxy/__init__.py b/cred_proxy/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/cred_proxy/__main__.py b/cred_proxy/__main__.py new file mode 100644 index 0000000000000..f8709a31ac32e --- /dev/null +++ b/cred_proxy/__main__.py @@ -0,0 +1,10 @@ +"""Entry point for ``python -m cred_proxy``. + +Used by daemon.start() to spawn the server as a background process. +PID and port files are written inside _run_server() after both sockets +are bound, ensuring callers only see the daemon as ready once it is live. +""" + +from cred_proxy.daemon import _run_server + +_run_server() diff --git a/cred_proxy/cli.py b/cred_proxy/cli.py new file mode 100644 index 0000000000000..976d04b0e66c1 --- /dev/null +++ b/cred_proxy/cli.py @@ -0,0 +1,135 @@ +"""CLI for the credential proxy daemon. + +Standalone entry point (``hermes-cred-proxy``): + hermes-cred-proxy start + hermes-cred-proxy stop + hermes-cred-proxy status + hermes-cred-proxy add (prompts for value, never echoes it) + hermes-cred-proxy list + +Also callable from the main hermes CLI as ``hermes cred-proxy ``. +Use dispatch(args) for that path where args.cred_proxy_command is set. +""" + +import argparse +import getpass +import sys + + +# --------------------------------------------------------------------------- +# Individual command implementations +# --------------------------------------------------------------------------- + +def cmd_start(args=None) -> None: + from cred_proxy.daemon import start + start() + + +def cmd_stop(args=None) -> None: + from cred_proxy.daemon import stop + stop() + + +def cmd_status(args=None) -> None: + from cred_proxy.daemon import status + info = status() + if info["running"]: + print(f"running (PID {info['pid']})") + else: + print("stopped") + print(f"address: {info['address']}") + + +def cmd_add(args) -> None: + name = args.name + try: + value = getpass.getpass(f"Value for {name!r}: ") + except (KeyboardInterrupt, EOFError): + print("\nCancelled.") + sys.exit(1) + if not value: + print("Error: empty value not allowed.") + sys.exit(1) + from cred_proxy.store import CredStore + store = CredStore() + store.set(name, value) + print(f"Stored credential {name!r}.") + + +def cmd_list(args=None) -> None: + from cred_proxy.store import CredStore + store = CredStore() + names = store.list() + if not names: + print("(no credentials stored)") + else: + for n in names: + print(n) + + +# --------------------------------------------------------------------------- +# Dispatcher (used by hermes cred-proxy subcommand in main.py) +# --------------------------------------------------------------------------- + +def dispatch(args) -> None: + """Route args.cred_proxy_command to the appropriate handler.""" + cmd = getattr(args, "cred_proxy_command", None) + if cmd == "start": + cmd_start(args) + elif cmd == "stop": + cmd_stop(args) + elif cmd == "status": + cmd_status(args) + elif cmd == "add": + cmd_add(args) + elif cmd == "list": + cmd_list(args) + else: + # No subcommand: print help + print("Usage: hermes cred-proxy {start,stop,status,add,list}") + print(" hermes-cred-proxy {start,stop,status,add,list}") + + +# --------------------------------------------------------------------------- +# Standalone argparse CLI (hermes-cred-proxy entry point) +# --------------------------------------------------------------------------- + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="hermes-cred-proxy", + description="Hermes credential proxy — store and inject secrets into tool subprocesses", + ) + subs = parser.add_subparsers(dest="subcommand", help="Command") + + subs.add_parser("start", help="Start the credential proxy daemon") + subs.add_parser("stop", help="Stop the credential proxy daemon") + subs.add_parser("status", help="Show daemon status") + + add_p = subs.add_parser("add", help="Add or update a named credential") + add_p.add_argument("name", help="Credential name (used in hermes-proxy://)") + + subs.add_parser("list", help="List stored credential names") + + return parser + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + + if args.subcommand == "start": + cmd_start(args) + elif args.subcommand == "stop": + cmd_stop(args) + elif args.subcommand == "status": + cmd_status(args) + elif args.subcommand == "add": + cmd_add(args) + elif args.subcommand == "list": + cmd_list(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/cred_proxy/daemon.py b/cred_proxy/daemon.py new file mode 100644 index 0000000000000..83e0c8289f3e4 --- /dev/null +++ b/cred_proxy/daemon.py @@ -0,0 +1,209 @@ +"""Credential proxy daemon lifecycle management. + +start() — spawn the proxy as a detached background process, wait for ready +stop() — SIGTERM the daemon, clean up state files +status() — return {running, pid, address, port} +is_running()— quick bool check used by other components +""" + +import asyncio +import logging +import os +import signal +import sys + +from hermes_constants import get_hermes_home + +_STATE_DIR = get_hermes_home() / "state" +_PID_FILE = _STATE_DIR / "cred-proxy.pid" +_PORT_FILE = _STATE_DIR / "cred-proxy.port" +_LOG_FILE = _STATE_DIR / "cred-proxy.log" + + +# --------------------------------------------------------------------------- +# PID file helpers +# --------------------------------------------------------------------------- + +def _write_pid() -> None: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + _PID_FILE.write_text(str(os.getpid())) + + +def _read_pid() -> int | None: + try: + return int(_PID_FILE.read_text().strip()) + except (FileNotFoundError, ValueError, OSError): + return None + + +def _remove_pid() -> None: + try: + _PID_FILE.unlink() + except FileNotFoundError: + pass + + +# --------------------------------------------------------------------------- +# Port file helpers +# --------------------------------------------------------------------------- + +def _write_port(port: int) -> None: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + _PORT_FILE.write_text(str(port)) + + +def _read_port() -> int | None: + try: + return int(_PORT_FILE.read_text().strip()) + except (FileNotFoundError, ValueError, OSError): + return None + + +def _remove_port() -> None: + try: + _PORT_FILE.unlink() + except FileNotFoundError: + pass + + +# --------------------------------------------------------------------------- +# State cleanup +# --------------------------------------------------------------------------- + +def _cleanup_state_files() -> None: + _remove_pid() + _remove_port() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def is_running() -> bool: + """Return True if the credential proxy daemon is running. + + Checks that both PID file and port file exist and that the process is + alive. Removes stale files if the process is dead. + """ + pid = _read_pid() + if pid is None: + return False + port = _read_port() + if port is None: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + _cleanup_state_files() + return False + except PermissionError: + # Process exists but we can't send signals — still running + return True + + +def status() -> dict: + """Return {running: bool, pid: int|None, address: str, port: int|None}.""" + running = is_running() + port = _read_port() if running else None + return { + "running": running, + "pid": _read_pid() if running else None, + "address": f"127.0.0.1:{port}" if port else "127.0.0.1:?", + "port": port, + } + + +def stop() -> None: + """Send SIGTERM to the daemon and remove the PID and port files.""" + pid = _read_pid() + if pid is None: + print("Credential proxy is not running.") + return + try: + os.kill(pid, signal.SIGTERM) + print(f"Stopped credential proxy (PID {pid}).") + except ProcessLookupError: + print("Credential proxy process not found (already stopped?).") + except PermissionError: + print(f"Permission denied when signalling PID {pid}.") + return # Process is still alive — leave state files intact + _cleanup_state_files() + + +def start() -> None: + """Start the credential proxy daemon as a detached background process. + + Spawns ``python -m cred_proxy`` with start_new_session=True so it + survives the calling process exiting. Waits up to 3 s for the daemon + to write its PID and port files before returning. + """ + if is_running(): + print("Credential proxy is already running.") + return + + import subprocess + import time + + cmd = [sys.executable, "-m", "cred_proxy"] + try: + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception as exc: + print(f"Failed to start credential proxy: {exc}") + return + + # Wait up to 3 s for the daemon to write its PID and port files + for _ in range(30): + time.sleep(0.1) + if is_running(): + pid = _read_pid() + print(f"Credential proxy started (PID {pid}).") + return + + print("Warning: Could not confirm credential proxy started. Check logs at:") + print(f" {_LOG_FILE}") + + +# --------------------------------------------------------------------------- +# Internal: run the server (called from __main__.py) +# --------------------------------------------------------------------------- + +def _run_server() -> None: + """Configure logging and run the asyncio HTTP proxy (blocks forever). + + Passes port=0 so the OS picks a free port atomically. The on_started + callback writes PID and port files once the server is bound, so callers + polling is_running() only see the daemon as ready once it is live. + """ + from .server import run_proxy + + logging.basicConfig( + filename=str(_LOG_FILE), + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + def _on_sigterm(signum, frame): + # sys.exit() raises SystemExit, unwinding asyncio.run() into the finally block. + sys.exit(0) + + try: + signal.signal(signal.SIGTERM, _on_sigterm) + except (OSError, ValueError): + pass # Windows or restricted environment + + def _on_started(port: int) -> None: + """Called once the proxy server is bound and listening.""" + _write_pid() + _write_port(port) + + try: + asyncio.run(run_proxy(port=0, on_started=_on_started)) + finally: + _cleanup_state_files() diff --git a/cred_proxy/server.py b/cred_proxy/server.py new file mode 100644 index 0000000000000..309ce53d64fad --- /dev/null +++ b/cred_proxy/server.py @@ -0,0 +1,375 @@ +"""asyncio HTTP proxy for credential placeholder substitution (Phase 1 — HTTP only). + +For plain HTTP requests, the proxy substitutes ``hermes-proxy://`` tokens +in request headers and the request body before forwarding to the upstream server. + +For CONNECT tunnels (HTTPS), the proxy establishes a blind TCP relay without +interception. Credential substitution inside HTTPS traffic is Phase 2 scope +and requires MITM CA generation — not implemented here. + +No external dependencies — stdlib asyncio only. + +``CredentialProxyAddon`` is retained as a thin, mitmproxy-free substitution +helper so that existing unit tests can exercise header/body rewriting without +spinning up a proxy server. +""" + +from __future__ import annotations + +import asyncio +import logging +import urllib.parse + +from .store import CredStore +from .substitutor import substitute + +logger = logging.getLogger(__name__) + +_MAX_HEADER_SIZE = 65_536 # 64 KiB — refuse oversized headers +_READ_CHUNK = 65_536 +_CONNECT_TIMEOUT = 10.0 + + +# --------------------------------------------------------------------------- +# CredentialProxyAddon — pure-Python substitution hook (no mitmproxy dep) +# --------------------------------------------------------------------------- + +class CredentialProxyAddon: + """Substitutes ``hermes-proxy://`` credential placeholders in HTTP flows. + + Accepts any duck-typed flow object with: + - ``flow.request.headers`` — dict-like mapping of header name → value + - ``flow.request.content`` — bytes or None + + This interface is intentionally kept compatible with mitmproxy's Flow so + that existing tests work unchanged. The proxy itself (``run_proxy``) uses + this class internally for substitution; it does not require mitmproxy at + runtime. + """ + + def __init__(self, store: CredStore | None = None) -> None: + self._store = store if store is not None else CredStore() + + def request(self, flow) -> None: + """Substitute credential placeholders in request headers and body.""" + # Headers + for key in list(flow.request.headers.keys()): + val = flow.request.headers[key] + new_val = substitute(val, self._store) + if new_val != val: + logger.debug("Substituted credential in request header %r", key) + flow.request.headers[key] = new_val + + # Body — caller is responsible for Content-Length recalculation when + # using this method directly; the asyncio proxy handles it automatically. + if flow.request.content: + text = flow.request.content.decode("utf-8", errors="replace") + new_text = substitute(text, self._store) + if new_text != text: + logger.debug("Substituted credential in request body") + flow.request.content = new_text.encode("utf-8") + + +# --------------------------------------------------------------------------- +# asyncio proxy internals +# --------------------------------------------------------------------------- + +async def _pipe( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, +) -> None: + """Copy bytes from *reader* to *writer* until EOF or connection reset.""" + try: + while True: + data = await reader.read(_READ_CHUNK) + if not data: + break + writer.write(data) + await writer.drain() + except (asyncio.CancelledError, ConnectionResetError, BrokenPipeError): + pass + finally: + try: + writer.close() + except Exception: + pass + + +def _parse_request_line(line: bytes) -> tuple[str, str, str]: + """Parse ``b'METHOD URL HTTP/1.x'`` → ``(method, url, version)``.""" + parts = line.decode("latin-1").strip().split(" ", 2) + if len(parts) != 3: + raise ValueError(f"Malformed request line: {line!r}") + return parts[0], parts[1], parts[2] + + +def _parse_headers(raw: bytes) -> list[tuple[str, str]]: + """Parse raw header block into list of ``(name, value)`` tuples.""" + headers: list[tuple[str, str]] = [] + for line in raw.split(b"\r\n"): + if b":" not in line: + continue + name, _, value = line.partition(b":") + headers.append(( + name.decode("latin-1").strip(), + value.decode("latin-1").strip(), + )) + return headers + + +def _headers_to_bytes(headers: list[tuple[str, str]]) -> bytes: + return ( + b"\r\n".join( + f"{name}: {value}".encode("latin-1") for name, value in headers + ) + + b"\r\n" + ) + + +async def _read_request( + reader: asyncio.StreamReader, +) -> tuple[bytes, bytes, bytes] | None: + """Read a complete HTTP request from *reader*. + + Returns ``(request_line, raw_headers, body)`` bytes or ``None`` on EOF. + Raises ``ValueError`` for requests with oversized headers. + """ + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = await reader.read(_READ_CHUNK) + if not chunk: + return None + buf += chunk + if len(buf) > _MAX_HEADER_SIZE: + raise ValueError("Request headers too large") + + split = buf.index(b"\r\n\r\n") + header_block = buf[:split] + leftover = buf[split + 4:] + + first_line_end = header_block.index(b"\r\n") + request_line = header_block[:first_line_end] + raw_headers = header_block[first_line_end + 2:] + + # Read body based on Content-Length (if present) + content_length = 0 + for line in raw_headers.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + try: + content_length = int(line.split(b":", 1)[1].strip()) + except ValueError: + pass + break + + body = leftover + remaining = content_length - len(leftover) + while remaining > 0: + chunk = await reader.read(min(remaining, _READ_CHUNK)) + if not chunk: + break + body += chunk + remaining -= len(chunk) + + return request_line, raw_headers, body[:content_length] if content_length else body + + +async def _handle_connect( + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + host: str, + port: int, +) -> None: + """Handle a CONNECT tunnel (HTTPS pass-through — no MITM in Phase 1).""" + try: + up_reader, up_writer = await asyncio.wait_for( + asyncio.open_connection(host, port), + timeout=_CONNECT_TIMEOUT, + ) + except (OSError, asyncio.TimeoutError) as exc: + logger.warning("CONNECT %s:%d failed: %s", host, port, exc) + client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + await client_writer.drain() + return + + client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await client_writer.drain() + + # Blind bidirectional relay — no inspection + await asyncio.gather( + _pipe(client_reader, up_writer), + _pipe(up_reader, client_writer), + return_exceptions=True, + ) + try: + up_writer.close() + except Exception: + pass + + +async def _handle_http( + client_writer: asyncio.StreamWriter, + request_line: bytes, + raw_headers: bytes, + body: bytes, + store: CredStore, +) -> None: + """Handle a plain HTTP proxy request with credential substitution.""" + try: + method, url, version = _parse_request_line(request_line) + except ValueError as exc: + logger.warning("Bad request line: %s", exc) + client_writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") + await client_writer.drain() + return + + parsed = urllib.parse.urlparse(url) + host = parsed.hostname or "" + port = parsed.port or 80 + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + + # Substitute credentials in headers + headers = _parse_headers(raw_headers) + new_headers: list[tuple[str, str]] = [] + for name, value in headers: + new_val = substitute(value, store) + if new_val != value: + logger.debug("Substituted credential in header %r", name) + new_headers.append((name, new_val)) + + # Strip proxy-specific hop-by-hop headers before forwarding + new_headers = [ + (n, v) for n, v in new_headers + if n.lower() not in ("proxy-connection", "proxy-authorization") + ] + + # Substitute credentials in body + new_body = body + if body: + try: + text = body.decode("utf-8", errors="replace") + new_text = substitute(text, store) + if new_text != text: + logger.debug("Substituted credential in request body") + new_body = new_text.encode("utf-8") + except Exception: + pass + + # Recalculate Content-Length if body was rewritten + if new_body is not body: + new_headers = [ + (n, v) for n, v in new_headers if n.lower() != "content-length" + ] + new_headers.append(("Content-Length", str(len(new_body)))) + + new_request_line = f"{method} {path} {version}".encode("latin-1") + outgoing = ( + new_request_line + b"\r\n" + + _headers_to_bytes(new_headers) + b"\r\n" + + new_body + ) + + try: + up_reader, up_writer = await asyncio.wait_for( + asyncio.open_connection(host, port), + timeout=_CONNECT_TIMEOUT, + ) + except (OSError, asyncio.TimeoutError) as exc: + logger.warning("Connection to %s:%d failed: %s", host, port, exc) + client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + await client_writer.drain() + return + + up_writer.write(outgoing) + await up_writer.drain() + + try: + while True: + data = await up_reader.read(_READ_CHUNK) + if not data: + break + client_writer.write(data) + await client_writer.drain() + except (ConnectionResetError, BrokenPipeError): + pass + finally: + try: + up_writer.close() + except Exception: + pass + + +async def _handle_client( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + store: CredStore, +) -> None: + """Dispatch a single proxy client connection.""" + peer = writer.get_extra_info("peername", "") + try: + result = await _read_request(reader) + if result is None: + return + request_line, raw_headers, body = result + + try: + method, url, _version = _parse_request_line(request_line) + except ValueError: + writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") + await writer.drain() + return + + if method.upper() == "CONNECT": + host, _, port_str = url.rpartition(":") + try: + port = int(port_str) + except ValueError: + writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") + await writer.drain() + return + await _handle_connect(reader, writer, host, port) + else: + await _handle_http(writer, request_line, raw_headers, body, store) + + except Exception as exc: + logger.warning("Error handling client %s: %s", peer, exc) + finally: + try: + writer.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +async def run_proxy( + port: int = 0, + unix_socket=None, # API-compatible; Phase 1 uses TCP only + on_started=None, + store: CredStore | None = None, +) -> None: + """Start the asyncio HTTP proxy on ``127.0.0.1:`` and block until shutdown. + + Pass ``port=0`` (the default) to let the OS pick a free port — the actual + port is passed to ``on_started(port)`` once the server is bound. + + ``unix_socket`` is accepted for API compatibility but ignored — Phase 1 + binds a TCP port only. + """ + if store is None: + store = CredStore() + + server = await asyncio.start_server( + lambda r, w: _handle_client(r, w, store), + host="127.0.0.1", + port=port, + ) + + async with server: + actual_port = server.sockets[0].getsockname()[1] + if on_started is not None: + on_started(actual_port) + await server.serve_forever() diff --git a/cred_proxy/store.py b/cred_proxy/store.py new file mode 100644 index 0000000000000..f824b1dff7d01 --- /dev/null +++ b/cred_proxy/store.py @@ -0,0 +1,60 @@ +"""Credential store — in-memory for Phase 1. + +All secrets live in a plain dict in the proxy process's heap. They do not +persist across proxy restarts — users must re-add credentials after each +``hermes cred-proxy start``. + +Phase 3 will add AES-256-GCM encrypted persistence (unlocked by a master +passphrase at daemon start, never stored on disk). + +No external dependencies — stdlib only. + +Public API: set(), list(), delete() +Internal: _get() — used only by the substitutor, never exposed to callers. +""" + + +class CredStore: + """In-memory credential store. + + Thread-safety note: the proxy runs as a single-threaded asyncio event loop, + so no locking is required. + """ + + def __init__(self) -> None: + self._store: dict[str, str] = {} + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def set(self, name: str, value: str) -> None: + """Store a credential under *name*.""" + self._store[name] = value + + def list(self) -> list[str]: + """Return sorted list of stored credential names (no values).""" + return sorted(self._store.keys()) + + def delete(self, name: str) -> None: + """Remove credential *name* from the store. + + Raises KeyError if the name does not exist. + """ + if name not in self._store: + raise KeyError(f"Credential {name!r} not found") + del self._store[name] + + # ------------------------------------------------------------------ + # Internal-only access (used by substitutor — NOT part of public API) + # ------------------------------------------------------------------ + + def _get(self, name: str) -> str: + """Return the value for *name*. + + Intentionally private: agent processes must not be able to call + this through any public interface. Raises KeyError if not found. + """ + if name not in self._store: + raise KeyError(f"Credential {name!r} not found") + return self._store[name] diff --git a/cred_proxy/substitutor.py b/cred_proxy/substitutor.py new file mode 100644 index 0000000000000..a436dc50301d1 --- /dev/null +++ b/cred_proxy/substitutor.py @@ -0,0 +1,34 @@ +"""Credential placeholder substitution. + +Replaces ``hermes-proxy://`` tokens in strings with the real credential +values from the store. Unknown names are left unchanged so that unset +credentials surface as visible errors in downstream requests rather than +silently sending the placeholder string. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .store import CredStore + +_PLACEHOLDER_RE = re.compile(r"hermes-proxy://([A-Za-z0-9_\-\.]+)") + + +def substitute(data: str, store: "CredStore") -> str: + """Replace ``hermes-proxy://`` in *data* with real credential values. + + Works on any string: header values, JSON bodies, query strings, etc. + Unknown credential names are left as-is (placeholder unchanged). + """ + + def _replacer(match: re.Match) -> str: + name = match.group(1) + try: + return store._get(name) + except KeyError: + return match.group(0) + + return _PLACEHOLDER_RE.sub(_replacer, data) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index da266eedac289..0267bebc6d85e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -220,6 +220,11 @@ def ensure_hermes_home(): # (terminal and execute_code). Skill-declared required_environment_variables # are passed through automatically; this list is for non-skill use cases. "env_passthrough": [], + # Env var names whose values are hermes-proxy:// placeholders (not real secrets). + # These bypass the provider-secret blocklist so the cred-proxy daemon can + # intercept and substitute the real credential at the transport layer. + # Example: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"] + "proxy_credentials": [], "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "docker_forward_env": [], "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0f1f4aa513a18..d4823be77db00 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -661,6 +661,12 @@ def cmd_gateway(args): gateway_command(args) +def cmd_cred_proxy(args): + """Credential proxy management commands.""" + from cred_proxy.cli import dispatch + dispatch(args) + + def cmd_whatsapp(args): """Set up WhatsApp: choose mode, configure, install bridge, pair via QR.""" _require_tty("whatsapp") @@ -4203,7 +4209,28 @@ def main(): gateway_setup = gateway_subparsers.add_parser("setup", help="Configure messaging platforms") gateway_parser.set_defaults(func=cmd_gateway) - + + # ========================================================================= + # cred-proxy command + # ========================================================================= + cred_proxy_parser = subparsers.add_parser( + "cred-proxy", + help="Credential proxy management (store secrets, inject into tool subprocesses)", + description="Manage the Hermes credential proxy daemon and secret store", + ) + cred_proxy_subparsers = cred_proxy_parser.add_subparsers( + dest="cred_proxy_command", help="Sub-command" + ) + cred_proxy_subparsers.add_parser("start", help="Start the credential proxy daemon") + cred_proxy_subparsers.add_parser("stop", help="Stop the credential proxy daemon") + cred_proxy_subparsers.add_parser("status", help="Show credential proxy status") + cp_add = cred_proxy_subparsers.add_parser( + "add", help="Add or update a named credential (prompts for value)" + ) + cp_add.add_argument("name", help="Credential name used in hermes-proxy://") + cred_proxy_subparsers.add_parser("list", help="List stored credential names") + cred_proxy_parser.set_defaults(func=cmd_cred_proxy) + # ========================================================================= # setup command # ========================================================================= diff --git a/pyproject.toml b/pyproject.toml index 36506c20f8bd9..1c5af4339c7e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ sms = ["aiohttp>=3.9.0,<4"] acp = ["agent-client-protocol>=0.8.1,<0.9"] dingtalk = ["dingtalk-stream>=0.1.0,<1"] feishu = ["lark-oapi>=1.5.3,<2"] +cred-proxy = [] # Phase 1: stdlib only — no external dependencies rl = [ "atroposlib @ git+https://github.com/NousResearch/atropos.git", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git", @@ -100,12 +101,13 @@ all = [ hermes = "hermes_cli.main:main" hermes-agent = "run_agent:main" hermes-acp = "acp_adapter.entry:main" +hermes-cred-proxy = "cred_proxy.cli:main" [tool.setuptools] py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "rl_cli", "utils"] [tool.setuptools.packages.find] -include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] +include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "cred_proxy"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/cred_proxy/__init__.py b/tests/cred_proxy/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/tests/cred_proxy/conftest.py b/tests/cred_proxy/conftest.py new file mode 100644 index 0000000000000..79b42fd5a0300 --- /dev/null +++ b/tests/cred_proxy/conftest.py @@ -0,0 +1,6 @@ +"""Shared fixtures for cred_proxy tests. + +The credential store is now a pure in-memory dict (no keyring dependency), +so no backend mocking is required here. Individual test modules provide +their own ``CredStore`` fixtures as needed. +""" diff --git a/tests/cred_proxy/test_fixes.py b/tests/cred_proxy/test_fixes.py new file mode 100644 index 0000000000000..43765691b988e --- /dev/null +++ b/tests/cred_proxy/test_fixes.py @@ -0,0 +1,118 @@ +"""Tests for specific bug fixes in the credential proxy.""" + +import asyncio +import os +from unittest.mock import patch + +import pytest + + +# --------------------------------------------------------------------------- +# Fix 6: is_running() returns False and cleans up stale PID file +# --------------------------------------------------------------------------- + +def test_is_running_cleans_up_stale_pid_and_port_files(tmp_path, monkeypatch): + """is_running() returns False and removes stale PID + port files.""" + import cred_proxy.daemon as daemon_module + + pid_file = tmp_path / "cred-proxy.pid" + port_file = tmp_path / "cred-proxy.port" + + monkeypatch.setattr(daemon_module, "_PID_FILE", pid_file) + monkeypatch.setattr(daemon_module, "_PORT_FILE", port_file) + + # Find a PID that definitely does not exist on this system + dead_pid = 999999 + try: + os.kill(dead_pid, 0) + pytest.skip("PID 999999 unexpectedly exists on this system") + except ProcessLookupError: + pass + + pid_file.write_text(str(dead_pid)) + port_file.write_text("12345") + + assert daemon_module.is_running() is False + assert not pid_file.exists(), "Stale PID file was not removed" + assert not port_file.exists(), "Stale port file was not removed" + + +# --------------------------------------------------------------------------- +# Fix: stop() preserves state files on PermissionError +# --------------------------------------------------------------------------- + +def test_stop_preserves_files_on_permission_error(tmp_path, monkeypatch): + """stop() leaves PID/port files intact when os.kill raises PermissionError.""" + import cred_proxy.daemon as daemon_module + + pid_file = tmp_path / "cred-proxy.pid" + port_file = tmp_path / "cred-proxy.port" + + monkeypatch.setattr(daemon_module, "_PID_FILE", pid_file) + monkeypatch.setattr(daemon_module, "_PORT_FILE", port_file) + monkeypatch.setattr(daemon_module, "_STATE_DIR", tmp_path) + + pid_file.write_text("12345") + port_file.write_text("8080") + + with patch("os.kill", side_effect=PermissionError("Operation not permitted")): + daemon_module.stop() + + assert pid_file.exists(), "PID file should be preserved on PermissionError" + assert port_file.exists(), "Port file should be preserved on PermissionError" + + +def test_stop_cleans_files_on_success(tmp_path, monkeypatch): + """stop() removes PID/port files after a successful SIGTERM.""" + import cred_proxy.daemon as daemon_module + + pid_file = tmp_path / "cred-proxy.pid" + port_file = tmp_path / "cred-proxy.port" + + monkeypatch.setattr(daemon_module, "_PID_FILE", pid_file) + monkeypatch.setattr(daemon_module, "_PORT_FILE", port_file) + monkeypatch.setattr(daemon_module, "_STATE_DIR", tmp_path) + + pid_file.write_text("12345") + port_file.write_text("8080") + + with patch("os.kill"): + daemon_module.stop() + + assert not pid_file.exists(), "PID file should be removed after successful stop" + assert not port_file.exists(), "Port file should be removed after successful stop" + + +# --------------------------------------------------------------------------- +# Fix: run_proxy port=0 passes actual port to on_started callback +# --------------------------------------------------------------------------- + +def test_run_proxy_port_zero_reports_actual_port(): + """run_proxy(port=0) passes a real port number to the on_started callback.""" + from cred_proxy.server import run_proxy + from cred_proxy.store import CredStore + + reported_port = None + + def on_started(port: int) -> None: + nonlocal reported_port + reported_port = port + + async def _run(): + store = CredStore() + server_task = asyncio.create_task( + run_proxy(port=0, on_started=on_started, store=store) + ) + # Give the server a moment to bind + await asyncio.sleep(0.1) + server_task.cancel() + try: + await server_task + except asyncio.CancelledError: + pass + + asyncio.run(_run()) + + assert reported_port is not None, "on_started was never called" + assert isinstance(reported_port, int) + assert reported_port > 0, f"Expected a real port, got {reported_port}" diff --git a/tests/cred_proxy/test_integration.py b/tests/cred_proxy/test_integration.py new file mode 100644 index 0000000000000..8d3029697d57d --- /dev/null +++ b/tests/cred_proxy/test_integration.py @@ -0,0 +1,77 @@ +"""Integration tests: CredentialProxyAddon substitutes credentials in request flows.""" + +from unittest.mock import MagicMock + +import pytest + +from cred_proxy.server import CredentialProxyAddon +from cred_proxy.store import CredStore + + +@pytest.fixture +def store() -> CredStore: + return CredStore() + + +def _make_flow(headers: dict, body: bytes = b"") -> MagicMock: + flow = MagicMock() + flow.request.headers = dict(headers) + flow.request.content = body + return flow + + +def test_addon_request_substitutes_header(store: CredStore) -> None: + """Addon replaces a hermes-proxy:// placeholder in a request header.""" + store.set("mytoken", "real-secret-value") + addon = CredentialProxyAddon(store) + + flow = _make_flow({"Authorization": "Bearer hermes-proxy://mytoken"}) + addon.request(flow) + + assert flow.request.headers["Authorization"] == "Bearer real-secret-value" + + +def test_addon_request_substitutes_body(store: CredStore) -> None: + """Addon replaces a hermes-proxy:// placeholder in the request body.""" + store.set("tok", "real-value") + addon = CredentialProxyAddon(store) + + flow = _make_flow({}, body=b"secret=hermes-proxy://tok") + addon.request(flow) + + assert flow.request.content == b"secret=real-value" + + +def test_addon_request_leaves_unknown_placeholder(store: CredStore) -> None: + """Unknown credential names are left unchanged in headers.""" + addon = CredentialProxyAddon(store) + + flow = _make_flow({"Authorization": "Bearer hermes-proxy://unknown"}) + addon.request(flow) + + assert flow.request.headers["Authorization"] == "Bearer hermes-proxy://unknown" + + +def test_addon_request_no_substitution_needed(store: CredStore) -> None: + """Addon leaves headers and body untouched when no placeholders are present.""" + addon = CredentialProxyAddon(store) + + flow = _make_flow({"Authorization": "Bearer plain-token"}, body=b"plain body") + addon.request(flow) + + assert flow.request.headers["Authorization"] == "Bearer plain-token" + assert flow.request.content == b"plain body" + + +def test_no_public_get_api(store: CredStore) -> None: + """CredStore has no public method to retrieve stored credential values.""" + store.set("secret", "sensitive-value") + + public_methods = { + m + for m in dir(store) + if not m.startswith("_") and callable(getattr(store, m)) + } + assert public_methods == {"set", "list", "delete"}, ( + f"Unexpected public methods on CredStore: {public_methods - {'set', 'list', 'delete'}}" + ) diff --git a/tests/cred_proxy/test_store.py b/tests/cred_proxy/test_store.py new file mode 100644 index 0000000000000..1b63f501e7371 --- /dev/null +++ b/tests/cred_proxy/test_store.py @@ -0,0 +1,54 @@ +"""Tests for cred_proxy.store (keyring-backed credential store).""" + +import pytest + +from cred_proxy.store import CredStore + + +@pytest.fixture +def store() -> CredStore: + return CredStore() + + +def test_set_and_list_shows_name(store: CredStore) -> None: + store.set("mytoken", "secret-value") + assert "mytoken" in store.list() + + +def test_list_empty_by_default(store: CredStore) -> None: + assert store.list() == [] + + +def test_list_multiple_names_sorted(store: CredStore) -> None: + store.set("zebra", "v1") + store.set("alpha", "v2") + store.set("middle", "v3") + assert store.list() == ["alpha", "middle", "zebra"] + + +def test_get_returns_correct_value(store: CredStore) -> None: + store.set("api_key", "top-secret-123") + assert store._get("api_key") == "top-secret-123" + + +def test_delete_removes_name(store: CredStore) -> None: + store.set("tok", "val") + store.delete("tok") + assert "tok" not in store.list() + + +def test_delete_raises_key_error_for_missing(store: CredStore) -> None: + with pytest.raises(KeyError): + store.delete("nonexistent") + + +def test_get_raises_key_error_for_missing(store: CredStore) -> None: + with pytest.raises(KeyError): + store._get("nonexistent") + + +def test_overwrite_updates_value(store: CredStore) -> None: + store.set("key", "old-value") + store.set("key", "new-value") + assert store._get("key") == "new-value" + assert store.list().count("key") == 1 # no duplicates diff --git a/tests/cred_proxy/test_substitutor.py b/tests/cred_proxy/test_substitutor.py new file mode 100644 index 0000000000000..cc513fbbf82d4 --- /dev/null +++ b/tests/cred_proxy/test_substitutor.py @@ -0,0 +1,59 @@ +"""Tests for cred_proxy.substitutor placeholder substitution.""" + +import pytest + +from cred_proxy.store import CredStore +from cred_proxy.substitutor import substitute + + +@pytest.fixture +def store() -> CredStore: + s = CredStore() + s.set("mytoken", "real-token-value") + s.set("apikey", "sk-12345") + return s + + +def test_substitute_in_header_value(store: CredStore) -> None: + result = substitute("Bearer hermes-proxy://mytoken", store) + assert result == "Bearer real-token-value" + + +def test_substitute_in_json_body(store: CredStore) -> None: + result = substitute('{"api_key": "hermes-proxy://apikey"}', store) + assert result == '{"api_key": "sk-12345"}' + + +def test_unknown_name_left_unchanged(store: CredStore) -> None: + result = substitute("hermes-proxy://does-not-exist", store) + assert result == "hermes-proxy://does-not-exist" + + +def test_non_placeholder_string_unchanged(store: CredStore) -> None: + original = "Authorization: Basic dXNlcjpwYXNz" + assert substitute(original, store) == original + + +def test_multiple_placeholders_in_one_string(store: CredStore) -> None: + result = substitute( + "Bearer hermes-proxy://mytoken key=hermes-proxy://apikey", + store, + ) + assert result == "Bearer real-token-value key=sk-12345" + + +def test_substitute_in_query_string(store: CredStore) -> None: + result = substitute("?token=hermes-proxy://mytoken&other=value", store) + assert result == "?token=real-token-value&other=value" + + +def test_mixed_known_and_unknown(store: CredStore) -> None: + result = substitute( + "hermes-proxy://mytoken and hermes-proxy://missing", + store, + ) + assert result == "real-token-value and hermes-proxy://missing" + + +def test_empty_string(store: CredStore) -> None: + assert substitute("", store) == "" diff --git a/tests/tools/test_env_passthrough.py b/tests/tools/test_env_passthrough.py index 1670c202cb460..d4b399535df21 100644 --- a/tests/tools/test_env_passthrough.py +++ b/tests/tools/test_env_passthrough.py @@ -183,6 +183,26 @@ def test_passthrough_allows_blocklisted_var(self): assert blocked_var in result assert result[blocked_var] == "secret_value" + def test_proxy_placeholder_passes_through_blocklist(self): + from tools.environments.local import _sanitize_subprocess_env, _HERMES_PROVIDER_ENV_BLOCKLIST + + # A normally-blocked var whose value is a hermes-proxy:// placeholder + # should pass through so the cred-proxy daemon can substitute it. + blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST)) + env = {blocked_var: "hermes-proxy://my_token", "PATH": "/usr/bin"} + result = _sanitize_subprocess_env(env) + assert blocked_var in result + assert result[blocked_var] == "hermes-proxy://my_token" + + def test_proxy_placeholder_not_real_secret_still_blocked(self): + from tools.environments.local import _sanitize_subprocess_env, _HERMES_PROVIDER_ENV_BLOCKLIST + + # A real secret value (not a placeholder) on a blocked key stays blocked. + blocked_var = next(iter(_HERMES_PROVIDER_ENV_BLOCKLIST)) + env = {blocked_var: "sk-realtoken123", "PATH": "/usr/bin"} + result = _sanitize_subprocess_env(env) + assert blocked_var not in result + def test_make_run_env_passthrough(self, monkeypatch): from tools.environments.local import _make_run_env, _HERMES_PROVIDER_ENV_BLOCKLIST diff --git a/tools/environments/local.py b/tools/environments/local.py index 27282b6ef67a1..563795a438681 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -130,6 +130,35 @@ def _build_provider_env_blocklist() -> frozenset: _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() +# Guard so proxy_credentials registration runs only once, not on every subprocess call. +_proxy_credentials_registered = False + + +def _ensure_proxy_credentials_registered() -> None: + """Register proxy_credentials from config into env_passthrough (once). + + Called from both _sanitize_subprocess_env and _make_run_env so that + whichever path runs first triggers registration. + """ + global _proxy_credentials_registered + if _proxy_credentials_registered: + return + try: + from cli import CLI_CONFIG + proxy_creds = CLI_CONFIG.get("terminal", {}).get("proxy_credentials", []) + if proxy_creds: + from tools.env_passthrough import register_env_passthrough + register_env_passthrough(proxy_creds) + except ImportError: + pass + _proxy_credentials_registered = True + + +def _reset_proxy_credentials_registered() -> None: + """Reset the one-shot guard — for use in tests only.""" + global _proxy_credentials_registered + _proxy_credentials_registered = False + def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict: """Filter Hermes-managed secrets from a subprocess environment. @@ -139,6 +168,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non :mod:`tools.env_passthrough` (skill-declared or user-configured) also bypass the blocklist. """ + _ensure_proxy_credentials_registered() + try: from tools.env_passthrough import is_env_passthrough as _is_passthrough except Exception: @@ -149,14 +180,18 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non for key, value in (base_env or {}).items(): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): continue - if key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key): + if (key not in _HERMES_PROVIDER_ENV_BLOCKLIST + or _is_passthrough(key) + or value.startswith("hermes-proxy://")): sanitized[key] = value for key, value in (extra_env or {}).items(): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): real_key = key[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):] sanitized[real_key] = value - elif key not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(key): + elif (key not in _HERMES_PROVIDER_ENV_BLOCKLIST + or _is_passthrough(key) + or value.startswith("hermes-proxy://")): sanitized[key] = value return sanitized @@ -271,6 +306,7 @@ def _is_noise(line: str) -> bool: def _make_run_env(env: dict) -> dict: """Build a run environment with a sane PATH and provider-var stripping.""" + _ensure_proxy_credentials_registered() try: from tools.env_passthrough import is_env_passthrough as _is_passthrough except Exception: @@ -282,11 +318,30 @@ def _make_run_env(env: dict) -> dict: if k.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): real_key = k[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):] run_env[real_key] = v - elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k): + elif (k not in _HERMES_PROVIDER_ENV_BLOCKLIST + or _is_passthrough(k) + or v.startswith("hermes-proxy://")): run_env[k] = v existing_path = run_env.get("PATH", "") if "/usr/bin" not in existing_path.split(":"): run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH + + # Inject credential proxy env vars if proxy is running + try: + from cred_proxy.daemon import ( + is_running as _cred_proxy_running, + _read_port as _cred_proxy_read_port, + ) + if _cred_proxy_running(): + _tcp_port = _cred_proxy_read_port() + if _tcp_port is not None: + run_env["http_proxy"] = f"http://127.0.0.1:{_tcp_port}" + # Phase 1: HTTPS is tunnelled via blind CONNECT relay — not intercepted. + # Credential substitution inside HTTPS traffic requires Phase 2 (MITM CA). + run_env["https_proxy"] = f"http://127.0.0.1:{_tcp_port}" + except ImportError: + pass + return run_env