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
1 change: 1 addition & 0 deletions agent/delegation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
KANBAN_ENV_KEYS: tuple[str, ...] = (
"HERMES_KANBAN_TASK", "HERMES_KANBAN_RUN_ID", "HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_GOAL_MODE", "HERMES_KANBAN_GOAL_MAX_TURNS",
# HERMES_KANBAN_SAFE_ROOT_ACTIVE intentionally survives for delegate children.
)


Expand Down
19 changes: 18 additions & 1 deletion agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,17 @@ def build_write_denied_prefixes(home: str) -> list[str]:
return [os.path.realpath(p) + os.sep for p in paths]


def _write_safe_root_raw() -> str:
"""Process env, or the routed profile scope when bound (multiplex/desktop turns)."""
from tools.write_safe_root_scope import write_safe_root_env

return write_safe_root_env()


def get_safe_write_roots() -> set[str]:
"""Resolved HERMES_WRITE_SAFE_ROOT paths (``os.pathsep``-separated list)."""
roots: set[str] = set()
for path in filter(None, os.getenv("HERMES_WRITE_SAFE_ROOT", "").split(os.pathsep)):
for path in filter(None, _write_safe_root_raw().split(os.pathsep)):
with suppress(OSError, ValueError):
roots.add(os.path.realpath(os.path.expanduser(path)))
return roots
Expand Down Expand Up @@ -250,6 +257,16 @@ def _classify_write_denial(path: str) -> Optional[str]:
if safe_roots and not any(_is_under(resolved, root) for root in safe_roots):
return "safe_root"

from tools.write_safe_root_scope import (
get_process_write_safe_roots,
is_write_safe_root_scope_bound,
)
if is_write_safe_root_scope_bound():
inherited = get_process_write_safe_roots()
if inherited and not any(_is_under(resolved, root) for root in safe_roots):
if any(_is_under(resolved, root) for root in inherited):
return "safe_root"

return None


Expand Down
12 changes: 7 additions & 5 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1726,13 +1726,15 @@ def _profile_runtime_scope(
# Without it terminal_tool reads the process-global TERMINAL_* vars a previous profile's turn may have
# pinned (first-writer-wins backend leak; #68559).
from tools.terminal_scope import install_and_reset_profile_terminal_scope
from tools.write_safe_root_scope import install_and_reset_profile_write_safe_root_scope

with install_and_reset_profile_terminal_scope(Path(profile_home)):
try:
yield
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
with install_and_reset_profile_write_safe_root_scope(Path(profile_home)):
try:
yield
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)


@_asynccontextmanager
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/env_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ def load_hermes_dotenv(
return []

loaded: list[Path] = []
kanban_worker = (
"HERMES_KANBAN_TASK" in os.environ
or "HERMES_KANBAN_SAFE_ROOT_ACTIVE" in os.environ
)
inherited_safe_root = os.environ.get("HERMES_WRITE_SAFE_ROOT")
had_safe_root = "HERMES_WRITE_SAFE_ROOT" in os.environ
user_env = home_path / ".env"
project_env_path = Path(project_env) if project_env else None

Expand Down Expand Up @@ -403,6 +409,13 @@ def load_hermes_dotenv(
# to the stale .env value mid-session (#29186, #67323).
_reapply_terminal_config_bridge(home_path)

# Task-scoped roots must outrank profile and managed env files.
if kanban_worker:
if had_safe_root:
os.environ["HERMES_WRITE_SAFE_ROOT"] = inherited_safe_root or ""
else:
os.environ.pop("HERMES_WRITE_SAFE_ROOT", None)

return loaded


Expand Down
16 changes: 15 additions & 1 deletion hermes_cli/kanban_db_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2572,6 +2572,7 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) -
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
env["HERMES_KANBAN_SAFE_ROOT_ACTIVE"] = "1"
# Tag the session `kanban` so session-browsing surfaces filter it out by
# source instead of rendering one sidebar row per attempt.
env["HERMES_SESSION_SOURCE"] = "kanban"
Expand All @@ -2586,8 +2587,21 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) -
# home) and build_context_files_prompt (#34619 — workers loaded the dispatching gateway's AGENTS.md
# instead of the task's). Setting it to the workspace fixes both: the workspace is where the task's work
# actually happens.
accepted_workspace = None
if workspace and os.path.isabs(workspace) and os.path.isdir(workspace):
env["TERMINAL_CWD"] = workspace
try:
normalized_workspace = os.path.realpath(workspace)
if (
os.path.dirname(normalized_workspace) != normalized_workspace
and os.pathsep not in normalized_workspace
):
accepted_workspace = normalized_workspace
except (OSError, ValueError):
pass
if accepted_workspace is not None:
# Scope native file-tool writes to this task root; terminal and OS access remain outside it.
env["TERMINAL_CWD"] = accepted_workspace
env["HERMES_WRITE_SAFE_ROOT"] = accepted_workspace
if task.branch_name:
env["HERMES_KANBAN_BRANCH"] = task.branch_name
if task.current_run_id is not None:
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def _looks_like_credential(name: str) -> bool:
"HERMES_KANBAN_RUN_ID",
"HERMES_KANBAN_CLAIM_LOCK",
"HERMES_KANBAN_DISPATCH_IN_GATEWAY",
"HERMES_KANBAN_SAFE_ROOT_ACTIVE",
# Pytest is routinely launched from a delegated worker. The worker
# lineage marker must not make parent-state tests run as delegated
# children; tests that exercise child behavior set it explicitly.
Expand Down
Loading