Skip to content
Open
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
2 changes: 1 addition & 1 deletion agent/transports/codex_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def initialize(
client_title: str = "Hermes Agent",
client_version: str = "0.1",
capabilities: Optional[dict] = None,
timeout: float = 10.0,
timeout: float = 30.0,
) -> dict:
"""Send `initialize` + `initialized` handshake. Returns the server's
InitializeResponse (userAgent, codexHome, platformFamily, platformOs)."""
Expand Down
111 changes: 106 additions & 5 deletions agent/transports/codex_app_server_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional

from agent.redact import redact_sensitive_text
Expand Down Expand Up @@ -117,6 +118,38 @@ class TurnResult:
)


def _codex_configured_writable_roots() -> list[Path]:
"""Return Codex sandbox writable roots from ~/.codex/config.toml.

This intentionally reads only the local Codex sandbox stanza and ignores
malformed config. It lets Hermes safely answer Codex app-server permission
requests for roots the operator has already marked writable.
"""
config_path = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) / "config.toml"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This bypasses the session's explicit codex_home override. CodexAppServerSession already stores that override and passes it to the app-server client, so parse writable roots from self._codex_home when present and only fall back to CODEX_HOME / ~/.codex otherwise.

try:
import tomllib
except Exception: # pragma: no cover - Python < 3.11 fallback path
return []

try:
data = tomllib.loads(config_path.read_text())
except Exception:
return []

sandbox_cfg = data.get("sandbox_workspace_write")
if not isinstance(sandbox_cfg, dict):
return []
roots = sandbox_cfg.get("writable_roots")
if not isinstance(roots, list):
return []

out: list[Path] = []
for root in roots:
if isinstance(root, str) and root.strip():
out.append(Path(root).expanduser())
return out


def _classify_oauth_failure(*parts: str) -> Optional[str]:
"""Return a user-friendly re-auth hint if any of the provided strings
look like a codex OAuth/token-refresh failure; otherwise None.
Expand Down Expand Up @@ -213,6 +246,7 @@ def ensure_started(self) -> str:
client_name="hermes",
client_title="Hermes Agent",
client_version=_get_hermes_version(),
timeout=30.0,
)
# Permission selection is intentionally NOT sent on thread/start.
# Two reasons (live-tested against codex 0.130.0):
Expand Down Expand Up @@ -619,11 +653,8 @@ def _handle_server_request(self, req: dict) -> None:
decision = self._decide_apply_patch_approval(params)
self._client.respond(rid, {"decision": decision})
elif method == "item/permissions/requestApproval":
# Codex sometimes asks to escalate permissions mid-turn. We
# always decline — the user already chose their permission
# profile in ~/.codex/config.toml and surprise escalations
# shouldn't be silently accepted.
self._client.respond(rid, {"decision": "decline"})
decision = self._decide_permissions_approval(params)
self._client.respond(rid, {"decision": decision})
elif method == "mcpServer/elicitation/request":
# Codex's MCP layer asks the user for structured input on
# behalf of an MCP server (e.g. tool-call confirmation,
Expand Down Expand Up @@ -716,6 +747,76 @@ def _decide_apply_patch_approval(self, params: dict) -> str:
return "decline"
return "decline"

def _decide_permissions_approval(self, params: dict) -> str:
"""Decide Codex mid-turn sandbox permission requests.

Codex asks for this when a command needs write access outside the
current sandbox or needs a broader workspace grant. Declining every
request made Hermes-created Codex turns unusable for normal agentic
coding flows: `apply_patch`, Playwright, Vite, and build tools can all
request a workspace grant after the turn has already started.

If an interactive Hermes approval callback exists, route the request to
the user. In non-interactive contexts, only auto-accept grants rooted in
the current session cwd or in Codex's configured
`sandbox_workspace_write.writable_roots`; everything else still fails
closed.
"""
reason = params.get("reason") or params.get("description") or "Codex requests sandbox permissions"
grant_root = params.get("grantRoot") or params.get("root") or params.get("cwd")
permission_summary = self._format_permissions_request(params)

description_parts = [str(reason)]
if permission_summary:
description_parts.append(permission_summary)
if grant_root:
description_parts.append(f"grant root: {grant_root}")
description = "; ".join(description_parts)

if self._approval_callback is not None:
try:
choice = self._approval_callback(
f"codex permissions: {grant_root or permission_summary or reason}",
description,
allow_permanent=False,
)
return _approval_choice_to_codex_decision(choice)
except Exception:
logger.exception("approval_callback raised on permissions request")
return "decline"

if grant_root and self._is_allowed_permission_root(str(grant_root)):
logger.info("auto-approved codex permission grant for configured root: %s", grant_root)
return "accept"

logger.warning("declined codex permission request without approval callback: %s", description)
return "decline"

def _format_permissions_request(self, params: dict) -> str:
permissions = params.get("permissions") or params.get("requestedPermissions") or params.get("permission")
if isinstance(permissions, list):
return "permissions: " + ", ".join(map(str, permissions))
if permissions:
return f"permissions: {permissions}"
return ""

def _is_allowed_permission_root(self, raw_root: str) -> bool:
try:
root = Path(raw_root).expanduser().resolve()
except Exception:
return False

allowed_roots = [Path(self._cwd).expanduser()]
allowed_roots.extend(_codex_configured_writable_roots())
for allowed in allowed_roots:
try:
allowed_resolved = allowed.resolve()
except Exception:
continue
if root == allowed_resolved or allowed_resolved in root.parents:
return True
return False

def _track_pending_file_change(self, note: dict) -> None:
"""Maintain self._pending_file_changes from item/started + item/completed
notifications. Lets the apply_patch approval prompt show what's
Expand Down
7 changes: 6 additions & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,10 +314,15 @@ async def auth_middleware(request: Request, call_next):
"description": "Memory provider plugin",
"options": ["builtin", "honcho"],
},
"model.openai_runtime": {
"type": "select",
"description": "OpenAI/Codex runtime path",
"options": ["auto", "codex_app_server"],
},
"approvals.mode": {
"type": "select",
"description": "Dangerous command approval mode",
"options": ["ask", "yolo", "deny"],
"options": ["manual", "smart", "off"],
},
"context.engine": {
"type": "select",
Expand Down
30 changes: 22 additions & 8 deletions website/docs/user-guide/features/codex-app-server-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,20 +225,34 @@ Codex requests approval before executing commands or applying patches. These get

For `apply_patch` (file edit) approvals, Hermes shows a summary of what changed (`1 add, 1 update: /tmp/new.py, /tmp/old.py`) when codex provides the data via the corresponding `fileChange` item.

## Permission profiles
## Sandbox and writable roots

Codex has three built-in permission profiles:
- `:read-only` — no writes; every shell command requires approval
- `:workspace` — writes within the current workspace allowed without prompts (Hermes' default when you enable the runtime)
- `:danger-no-sandbox` — no sandbox at all (don't use this unless you understand it)
Codex owns the filesystem sandbox for the `codex_app_server` runtime. Hermes forwards Codex's command/file-change approvals, but the sandbox still decides which paths can be written.

You can override the default in `~/.codex/config.toml` outside Hermes' managed block:
For normal coding work, configure Codex like this in `~/.codex/config.toml` outside Hermes' managed block:

```toml
default_permissions = ":read-only"
approval_policy = "on-request"
sandbox_mode = "workspace-write"

[sandbox_workspace_write]
writable_roots = [
"/Users/you/src/primary-repo",
"/Users/you/src/related-repo",
]
network_access = true
```

(Hermes will preserve your override on re-migration as long as it lives outside the `# managed by hermes-agent` markers.)
Why this matters:

- `apply_patch` needs file-write permission for the target repo.
- Vite may write a timestamped config bundle next to `vite.config.ts`.
- Playwright writes `test-results/` and `playwright-report/`.
- Cross-repo work, such as editing `../related-repo`, needs that sibling repo in `writable_roots` or Codex will correctly deny it.

Hermes now handles Codex `item/permissions/requestApproval` requests by asking the user when an approval callback is available. In non-interactive contexts, Hermes only auto-accepts permission grants rooted in the current session cwd or in `sandbox_workspace_write.writable_roots`; unknown roots still fail closed.

If you want maximum prompting instead, use `sandbox_mode = "read-only"`. If you want no sandbox at all, Codex also supports `danger-full-access` / bypass flags, but do not use them unless the whole environment is externally sandboxed.

## Auxiliary tasks and ChatGPT subscription token cost

Expand Down