diff --git a/hermes_cli/approvals_test.py b/hermes_cli/approvals_test.py index 0aaafb6a74..8fbbea2dd5 100644 --- a/hermes_cli/approvals_test.py +++ b/hermes_cli/approvals_test.py @@ -50,12 +50,20 @@ def evaluate_command(command: str, env_type: str = "local") -> dict: "normalized_variants": variants, } - # 1. Isolated container backends skip every guard (fires BEFORE the hardline floor at runtime). + # 1. Isolated container backends skip every guard except the operator's approvals.deny + # (fires BEFORE the hardline floor at runtime). if approval._should_skip_container_guards(env_type): + deny_pattern = approval_floors._match_user_deny_rule(command) + if deny_pattern is not None: + return result( + "user-deny", rule=deny_pattern, + detail="matches a user-defined approvals.deny rule in config.yaml " + "(blocked even in an isolated container, under --yolo / mode=off)", + ) return result( "allow", detail=(f"env_type '{env_type}' is an isolated container backend; " - "the runtime skips all command guards for it"), + "the runtime skips all command guards for it except approvals.deny"), ) # 2. Hardline blocklist — never bypassable, even under yolo. diff --git a/tools/approval.py b/tools/approval.py index 646925346f..b9f0dbf606 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -857,6 +857,17 @@ def _should_skip_container_guards(env_type: str, has_host_access: bool = False) return env_type in ("singularity", "modal", "daytona", "vercel_sandbox") +def _user_deny_block(command: str) -> dict | None: + """The operator's ``approvals.deny`` rules are documented as never bypassable — not by yolo, + not by mode=off, and not by an isolated container either: they express intent about what the + agent may DO, not what it can reach, so they are evaluated before the container fast path.""" + deny_pattern = _match_user_deny_rule(command) + if deny_pattern is None: + return None + logger.warning("User deny rule %r blocked command: %s", deny_pattern, command[:200]) + return _user_deny_block_result(deny_pattern) + + def _floor_block(command: str, *, sudo_guard: bool = False) -> dict | None: """Unconditional floors, BEFORE yolo / mode=off / cron approve-mode so no session-level setting can bypass them: hardline catastrophic commands, @@ -871,11 +882,7 @@ def _floor_block(command: str, *, sudo_guard: bool = False) -> dict | None: if is_sudo_guess: logger.warning("Sudo stdin guard block: %s (command: %s)", sudo_guess_desc, command[:200]) return _sudo_stdin_block_result(sudo_guess_desc) - deny_pattern = _match_user_deny_rule(command) - if deny_pattern is not None: - logger.warning("User deny rule %r blocked command: %s", deny_pattern, command[:200]) - return _user_deny_block_result(deny_pattern) - return None + return _user_deny_block(command) def check_dangerous_command(command: str, env_type: str, @@ -885,7 +892,7 @@ def check_dangerous_command(command: str, env_type: str, a Docker sandbox that bind-mounts host paths must not skip approval. Returns ``{"approved": True/False, "message": str or None, ...}``.""" if _should_skip_container_guards(env_type, has_host_access=has_host_access): - return _approved() + return _user_deny_block(command) or _approved() blocked = _floor_block(command) if blocked is not None: return blocked @@ -976,7 +983,7 @@ def check_all_command_guards(command: str, env_type: str, force=True replay cannot bypass one check when only the other was shown to the user. ``has_host_access``: a Docker sandbox with bind-mounted host paths takes the normal flow.""" if _should_skip_container_guards(env_type, has_host_access=has_host_access): - return _approved() + return _user_deny_block(command) or _approved() blocked = _floor_block(command, sudo_guard=True) if blocked is not None: