Restore trusted container-mode metadata to /etc and harden CLI fallback#676
Restore trusted container-mode metadata to /etc and harden CLI fallback#676badMade wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances security for container-aware CLI routing by moving the container metadata file from the user-writable HERMES_HOME/.container-mode to a trusted, root-owned system path at /etc/hermes-agent/container-mode. It also restricts allowed backends to Docker and Podman, and ignores runtime_path unless it comes from the trusted system path. The review feedback suggests converting the backend value to lowercase for case-insensitive validation and verifying that the resolved runtime_path exists on disk to prevent raw FileNotFoundError tracebacks.
| backend = info.get("backend", "docker") | ||
| container_name = info.get("container_name", "hermes-agent") | ||
| exec_user = info.get("exec_user", "hermes") | ||
| hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") | ||
| if backend not in _ALLOWED_CONTAINER_BACKENDS: | ||
| backend = "docker" |
There was a problem hiding this comment.
To make the backend selection case-insensitive and more robust against manual configuration variations (e.g., Docker or Podman), we should convert the retrieved backend value to lowercase before validating it against _ALLOWED_CONTAINER_BACKENDS.
| backend = info.get("backend", "docker") | |
| container_name = info.get("container_name", "hermes-agent") | |
| exec_user = info.get("exec_user", "hermes") | |
| hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") | |
| if backend not in _ALLOWED_CONTAINER_BACKENDS: | |
| backend = "docker" | |
| backend = info.get("backend", "docker").lower() | |
| if backend not in _ALLOWED_CONTAINER_BACKENDS: | |
| backend = "docker" |
| runtime = container_info.get("runtime_path") or shutil.which(backend) | ||
| if not runtime: | ||
| print( | ||
| f"Error: {backend} not found on PATH. Cannot route to container.", |
There was a problem hiding this comment.
If a trusted runtime_path is specified in the container metadata but does not exist on the host filesystem (e.g., due to a stale configuration or Nix garbage collection), the CLI will currently proceed and crash with a raw FileNotFoundError traceback during subprocess.run or os.execvp. Checking if the resolved runtime path exists on disk allows us to print a clean, user-friendly error message and exit gracefully.
| runtime = container_info.get("runtime_path") or shutil.which(backend) | |
| if not runtime: | |
| print( | |
| f"Error: {backend} not found on PATH. Cannot route to container.", | |
| runtime = container_info.get("runtime_path") or shutil.which(backend) | |
| if not runtime or not os.path.exists(runtime): | |
| print( | |
| f"Error: Container runtime '{runtime or backend}' not found.", | |
| file=sys.stderr, | |
| ) |
There was a problem hiding this comment.
Pull request overview
This PR restores a trusted host/container routing boundary for NixOS container mode by moving authoritative CLI exec metadata out of writable HERMES_HOME state and into /etc/hermes-agent/container-mode.
Changes:
- Writes root-owned container-mode metadata from Nix activation, including a trusted runtime path.
- Updates CLI metadata loading to prefer
/etc, restrict legacy fallback metadata, and use trustedruntime_pathfor exec routing. - Adds tests and updates user-facing docs/tips for the new trusted metadata location.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
hermes_cli/config.py |
Adds trusted system metadata discovery, legacy fallback restrictions, and optional runtime_path handling. |
hermes_cli/main.py |
Uses trusted runtime_path before resolving Docker/Podman from PATH. |
nix/nixosModules.nix |
Writes/removes root-owned /etc/hermes-agent/container-mode during activation. |
tests/hermes_cli/test_container_aware_cli.py |
Adds coverage for precedence, fallback restrictions, Nix activation metadata, and runtime path exec behavior. |
hermes_cli/tips.py |
Updates the container-mode tip to point at the trusted /etc metadata path. |
website/docs/getting-started/nix-setup.md |
Updates the Nix container architecture diagram to show trusted host routing metadata. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| def test_nixos_activation_writes_trusted_container_metadata(): | ||
| """NixOS container mode writes host routing metadata outside HERMES_HOME.""" | ||
| module_text = (Path(__file__).parents[2] / "nix" / "nixosModules.nix").read_text() |
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
31 |
unresolved-attribute |
6 |
unsupported-operator |
4 |
First entries
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | dict[str, str]`
run_agent.py:8915: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_request_timeout` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:9721: [invalid-argument-type] invalid-argument-type: Argument to function `github_model_reasoning_efforts` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:9546: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_profile` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:14022: [invalid-argument-type] invalid-argument-type: Argument to bound method `ContextCompressor.update_model` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:5845: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["/"]` and `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:13565: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`
run_agent.py:3397: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_stale_timeout` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
cli.py:8659: [invalid-argument-type] invalid-argument-type: Argument to function `estimate_usage_cost` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:13065: [invalid-argument-type] invalid-argument-type: Argument to function `estimate_usage_cost` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:12316: [invalid-argument-type] invalid-argument-type: Argument to function `apply_anthropic_cache_control_long_lived` is incorrect: Expected `bool`, found `int | str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | dict[Unknown, Unknown]`
run_agent.py:11548: [unresolved-attribute] unresolved-attribute: Attribute `strip` is not defined on `dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy`, `int & ~AlwaysFalsy`, `dict[Unknown, Unknown] & ~AlwaysFalsy` in union `(str & ~AlwaysFalsy) | (Unknown & ~AlwaysFalsy) | (dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:7311: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 3 union elements`
tests/run_agent/test_provider_attribution_headers.py:156: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache-TTL"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
run_agent.py:13106: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.update_token_counts` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:9972: [unresolved-attribute] unresolved-attribute: Attribute `lower` is not defined on `dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy`, `int & ~AlwaysFalsy`, `dict[Unknown, Unknown] & ~AlwaysFalsy` in union `(str & ~AlwaysFalsy) | (Unknown & ~AlwaysFalsy) | (dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:2541: [invalid-argument-type] invalid-argument-type: Argument to function `ensure_lmstudio_model_loaded` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:9529: [invalid-argument-type] invalid-argument-type: Argument to function `_get_anthropic_max_output` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:5417: [invalid-argument-type] invalid-argument-type: Argument to function `parse_rate_limit_headers` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:3397: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_stale_timeout` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:7140: [invalid-argument-type] invalid-argument-type: Argument to function `_codex_cloudflare_headers` is incorrect: Expected `str`, found `Unknown | str | dict[str, str] | ... omitted 3 union elements`
run_agent.py:8914: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
tests/run_agent/test_provider_attribution_headers.py:155: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache"]` and `Unknown | str | dict[str, str] | ... omitted 3 union elements`
run_agent.py:13036: [invalid-argument-type] invalid-argument-type: Argument to function `save_context_length` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
run_agent.py:9694: [invalid-argument-type] invalid-argument-type: Argument to function `lmstudio_model_reasoning_options` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown | str, Unknown | str | dict[str, str]] | int | dict[Unknown, Unknown]`
... and 16 more
✅ Fixed issues (46):
| Rule | Count |
|---|---|
invalid-argument-type |
34 |
unresolved-attribute |
7 |
unsupported-operator |
4 |
unresolved-import |
1 |
First entries
run_agent.py:13106: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.update_token_counts` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:13568: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `(str & ~AlwaysFalsy) | (dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy) | (Any & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:7757: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_request_timeout` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:9108: [invalid-argument-type] invalid-argument-type: Argument to function `get_transport` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:13063: [invalid-argument-type] invalid-argument-type: Argument to function `estimate_usage_cost` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
tests/run_agent/test_provider_attribution_headers.py:156: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["X-OpenRouter-Cache-TTL"]` and `Unknown | str | dict[str, str] | ... omitted 4 union elements`
run_agent.py:5417: [invalid-argument-type] invalid-argument-type: Argument to function `parse_rate_limit_headers` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:9694: [invalid-argument-type] invalid-argument-type: Argument to function `lmstudio_model_reasoning_options` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
tests/run_agent/test_provider_attribution_headers.py:90: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | Divergent | dict[str, str]`
run_agent.py:11477: [invalid-argument-type] invalid-argument-type: Argument to function `_fixed_temperature_for_model` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:7140: [invalid-argument-type] invalid-argument-type: Argument to function `_codex_cloudflare_headers` is incorrect: Expected `str`, found `Unknown | str | dict[str, str] | ... omitted 4 union elements`
run_agent.py:5845: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["/"]` and `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:7311: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 4 union elements`
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str & ~AlwaysFalsy`, `int & ~AlwaysFalsy` in union `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | (dict[str, str] & ~AlwaysFalsy) | ... omitted 4 union elements`
run_agent.py:9972: [unresolved-attribute] unresolved-attribute: Attribute `lower` is not defined on `dict[Unknown, Unknown] & ~AlwaysFalsy`, `int & ~AlwaysFalsy`, `dict[Unknown | str, Unknown | str | dict[str, str]] & ~AlwaysFalsy` in union `(str & ~AlwaysFalsy) | (Unknown & ~AlwaysFalsy) | (dict[Unknown, Unknown] & ~AlwaysFalsy) | ... omitted 3 union elements`
run_agent.py:3397: [invalid-argument-type] invalid-argument-type: Argument to function `get_provider_stale_timeout` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:4274: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:13102: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.update_token_counts` is incorrect: Expected `str | None`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:13565: [invalid-argument-type] invalid-argument-type: Argument to function `_is_oauth_token` is incorrect: Expected `str`, found `str | dict[Unknown | str, Unknown | str | dict[str, str]] | Any | ... omitted 4 union elements`
cli.py:8659: [invalid-argument-type] invalid-argument-type: Argument to function `estimate_usage_cost` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:8831: [invalid-argument-type] invalid-argument-type: Argument to bound method `ContextCompressor.update_model` is incorrect: Expected `int`, found `Divergent | Unknown | str | ... omitted 3 union elements`
run_agent.py:8914: [invalid-argument-type] invalid-argument-type: Argument to function `build_anthropic_client` is incorrect: Expected `str`, found `Divergent | Unknown | str | ... omitted 3 union elements`
tests/agent/test_codex_cloudflare_headers.py:163: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `dict[str, str]` in union `Unknown | str | Divergent | dict[str, str]`
run_agent.py:14022: [invalid-argument-type] invalid-argument-type: Argument to bound method `ContextCompressor.update_model` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
run_agent.py:13036: [invalid-argument-type] invalid-argument-type: Argument to function `save_context_length` is incorrect: Expected `str`, found `str | Unknown | dict[Unknown, Unknown] | int | dict[Unknown | str, Unknown | str | dict[str, str]]`
... and 21 more
Unchanged: 4317 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Auto-merge: checks failingThe following checks did not pass:
Please fix the failing checks before this PR can be merged. |
|
@copilot, fix all failing checks. |
Motivation
/etc/hermes-agent/container-modeand the CLI prefers that system file.Description
/etc/hermes-agent/container-modeand remove the writable${cfg.stateDir}/.hermes/.container-modeseed on activation; include a trustedruntime_pathin the system file. (nix/nixosModules.nix)/.container-modeas a legacy fallback with restrictions; only allowdocker/podmanbackends from the fallback and ignore an untrustedruntime_path. (hermes_cli/config.py)runtime_pathis present use it instead of resolving runtime viaPATH; fallback toshutil.whichonly when trusted path is not available. (hermes_cli/main.py)runtime_pathusage; update docs and tips to document the trusted/etclocation. (tests/hermes_cli/test_container_aware_cli.py,website/docs/getting-started/nix-setup.md,hermes_cli/tips.py)Testing
tests/hermes_cli/test_container_aware_cli.pythat exercise system-file precedence, fallback restrictions, and trustedruntime_pathusage; these tests are included in the commit.python -m py_compile) the modified moduleshermes_cli/config.py,hermes_cli/main.py, and the test file without syntax errors._exec_in_containeruses a trustedruntime_pathwhen present (these manual checks passed).scripts/run_tests.sh tests/hermes_cli/test_container_aware_cli.pywas blocked by the execution environment being unable to fetchpytest-splitfrom PyPI, so the CI-like test wrapper could not complete here.Codex Task