Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
30 changes: 29 additions & 1 deletion agent/codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,10 @@ def run_codex_app_server_turn(
Called from run_conversation() when agent.api_mode == "codex_app_server".
Returns the same dict shape as the chat_completions path.
"""
from agent.transports.codex_app_server_session import CodexAppServerSession
from agent.transports.codex_app_server_session import (
CodexAppServerSession,
_ServerRequestRouting,
)

# Lazy session: one CodexAppServerSession per AIAgent instance.
# Spawned on first turn, reused across turns, closed at AIAgent
Expand All @@ -262,6 +265,27 @@ def run_codex_app_server_turn(
except Exception:
approval_callback = None

# Gateway / cron contexts have no UI to surface codex's approval
# requests through, so codex app-server exec / apply_patch requests
# fail closed (silently decline) by default. When the user has
# explicitly opted out of Hermes approvals — via `approvals.mode: off`
# in config, the /yolo session toggle, or --yolo / HERMES_YOLO_MODE —
# honor that and let codex's own sandbox permission profile
# (~/.codex/config.toml) be the policy gate instead of double-gating
# with a missing Hermes UI. Defaults (manual/smart/unset) preserve the
# current fail-closed behavior — this is a no-op for those users.
auto_approve_requests = False
try:
from tools.approval import is_approval_bypass_active

auto_approve_requests = is_approval_bypass_active()
except Exception:
logger.debug(
"codex app-server: approval-bypass lookup failed; "
"keeping fail-closed default",
exc_info=True,
)

def _on_codex_event(note: dict) -> None:
# Bridge Codex app-server item/started notifications to Hermes
# tool-progress so gateways show verbose "running X" breadcrumbs
Expand All @@ -281,6 +305,10 @@ def _on_codex_event(note: dict) -> None:
agent._codex_session = CodexAppServerSession(
cwd=cwd,
approval_callback=approval_callback,
request_routing=_ServerRequestRouting(
auto_approve_exec=auto_approve_requests,
auto_approve_apply_patch=auto_approve_requests,
),
on_event=_on_codex_event,
)

Expand Down
130 changes: 130 additions & 0 deletions tests/run_agent/test_codex_app_server_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,136 @@ def fake_run_turn(self, user_input: str, **kwargs):

assert captured["cwd"] == str(tmp_path)

def _capture_routing_agent(self, monkeypatch):
"""Build a codex agent with a CodexAppServerSession stub that captures
the request_routing passed at construction time, so we can assert how
the gateway-context approval routing was resolved."""
captured: dict = {}

def fake_init(self, **kwargs):
captured.update(kwargs)
self._thread_id = "thread-stub-1"

def fake_run_turn(self, user_input: str, **kwargs):
return TurnResult(
final_text="ok",
projected_messages=[{"role": "assistant", "content": "ok"}],
turn_id="turn-stub-1",
thread_id="thread-stub-1",
)

monkeypatch.setattr(CodexAppServerSession, "__init__", fake_init)
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
monkeypatch.setattr(
CodexAppServerSession, "ensure_started", lambda self: "thread-stub-1"
)
return captured

def test_approvals_mode_off_auto_approves_codex_server_requests(
self, monkeypatch
):
"""When the user disables Hermes approvals, codex app-server approval
requests should not fail closed just because no interactive callback is
wired (the typical gateway path). Codex's own sandbox permission
profile remains the filesystem boundary."""
captured = self._capture_routing_agent(monkeypatch)
with patch(
"hermes_cli.config.load_config",
return_value={"approvals": {"mode": "off"}},
):
agent = _make_codex_agent()
with patch.object(
agent, "_spawn_background_review", return_value=None
):
agent.run_conversation("write something")
routing = captured["request_routing"]
assert routing.auto_approve_exec is True
assert routing.auto_approve_apply_patch is True

def test_yaml_boolean_false_approval_mode_also_auto_approves(
self, monkeypatch
):
"""YAML 1.1 parses unquoted `off` as False; match the normal approval
subsystem's compatibility behavior for codex app-server routing too."""
captured = self._capture_routing_agent(monkeypatch)
with patch(
"hermes_cli.config.load_config",
return_value={"approvals": {"mode": False}},
):
agent = _make_codex_agent()
with patch.object(
agent, "_spawn_background_review", return_value=None
):
agent.run_conversation("write something")
routing = captured["request_routing"]
assert routing.auto_approve_exec is True
assert routing.auto_approve_apply_patch is True

def test_manual_approvals_keep_codex_server_requests_fail_closed(
self, monkeypatch
):
"""Default (manual) approvals must preserve the fail-closed behavior —
this fix is a no-op for users who haven't opted out."""
captured = self._capture_routing_agent(monkeypatch)
with patch(
"hermes_cli.config.load_config",
return_value={"approvals": {"mode": "manual"}},
):
agent = _make_codex_agent()
with patch.object(
agent, "_spawn_background_review", return_value=None
):
agent.run_conversation("write something")
routing = captured["request_routing"]
assert routing.auto_approve_exec is False
assert routing.auto_approve_apply_patch is False

def test_frozen_yolo_env_auto_approves_codex_server_requests(
self, monkeypatch
):
"""--yolo / HERMES_YOLO_MODE (frozen into _YOLO_MODE_FROZEN at import
time — a prompt-injection-safe process-scoped bypass) should flow
through to codex app-server routing so gateway/cron contexts do not
fail closed when the user launched with yolo mode."""
import tools.approval as _approval

captured = self._capture_routing_agent(monkeypatch)
monkeypatch.setattr(_approval, "_YOLO_MODE_FROZEN", True)
with patch(
"hermes_cli.config.load_config",
return_value={"approvals": {"mode": "manual"}},
):
agent = _make_codex_agent()
with patch.object(
agent, "_spawn_background_review", return_value=None
):
agent.run_conversation("write something")
routing = captured["request_routing"]
assert routing.auto_approve_exec is True
assert routing.auto_approve_apply_patch is True

def test_session_yolo_auto_approves_codex_server_requests(
self, monkeypatch
):
"""The /yolo session toggle should be honored at Codex session creation
time, independent of the startup-time approvals config."""
captured = self._capture_routing_agent(monkeypatch)
with patch(
"hermes_cli.config.load_config",
return_value={"approvals": {"mode": "manual"}},
):
agent = _make_codex_agent()
with patch(
"tools.approval.is_current_session_yolo_enabled",
return_value=True,
), patch.object(
agent, "_spawn_background_review", return_value=None
):
agent.run_conversation("write something")
routing = captured["request_routing"]
assert routing.auto_approve_exec is True
assert routing.auto_approve_apply_patch is True


class TestReviewForkApiModeDowngrade:
"""When the parent agent runs on codex_app_server, the background
Expand Down
21 changes: 21 additions & 0 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -1770,6 +1770,27 @@ def _get_approval_mode() -> str:
return _normalize_approval_mode(mode)


def is_approval_bypass_active() -> bool:
"""Return True when the user has opted out of Hermes approval prompts.

Collapses the canonical three-source bypass check used across the codebase
into one place:
- process-scoped ``--yolo`` / ``HERMES_YOLO_MODE`` (frozen at import time
so a mid-process skill can't flip it — a prompt-injection escalation
path; see ``_YOLO_MODE_FROZEN`` above),
- the session-scoped gateway ``/yolo`` toggle,
- ``approvals.mode: off`` in config.

This is the pure-bypass sub-expression only. Callers that also honor a
hardline blocklist / permanent allowlist must check those separately.
"""
return (
_YOLO_MODE_FROZEN
or is_current_session_yolo_enabled()
or _get_approval_mode() == "off"
)


def _get_approval_timeout() -> int:
"""Read the approval timeout from config. Defaults to 60 seconds."""
try:
Expand Down
Loading