Skip to content

Restore trusted container-mode metadata to /etc and harden CLI fallback#676

Open
badMade wants to merge 5 commits into
mainfrom
badmade/fix-container-routing-metadata-vulnerability
Open

Restore trusted container-mode metadata to /etc and harden CLI fallback#676
badMade wants to merge 5 commits into
mainfrom
badmade/fix-container-routing-metadata-vulnerability

Conversation

@badMade
Copy link
Copy Markdown
Owner

@badMade badMade commented Jun 1, 2026

Motivation

  • Prevent a regression that moved trusted container routing metadata into writable HERMES_HOME state, which allowed untrusted actors to control host-side container exec routing.
  • Re-establish a clear trust boundary where activation writes root-owned metadata under /etc/hermes-agent/container-mode and the CLI prefers that system file.

Description

  • Nix activation: write root-owned /etc/hermes-agent/container-mode and remove the writable ${cfg.stateDir}/.hermes/.container-mode seed on activation; include a trusted runtime_path in the system file. (nix/nixosModules.nix)
  • CLI metadata loading: prefer the trusted system file and treat HERMES_HOME /.container-mode as a legacy fallback with restrictions; only allow docker/podman backends from the fallback and ignore an untrusted runtime_path. (hermes_cli/config.py)
  • Exec routing: when trusted runtime_path is present use it instead of resolving runtime via PATH; fallback to shutil.which only when trusted path is not available. (hermes_cli/main.py)
  • Tests/docs: add/adjust unit tests to cover trusted-vs-fallback precedence, fallback restrictions, and trusted runtime_path usage; update docs and tips to document the trusted /etc location. (tests/hermes_cli/test_container_aware_cli.py, website/docs/getting-started/nix-setup.md, hermes_cli/tips.py)

Testing

  • Added unit tests and assertions in tests/hermes_cli/test_container_aware_cli.py that exercise system-file precedence, fallback restrictions, and trusted runtime_path usage; these tests are included in the commit.
  • Performed static validation: successfully compiled (python -m py_compile) the modified modules hermes_cli/config.py, hermes_cli/main.py, and the test file without syntax errors.
  • Executed manual/isolated checks that simulate system-file vs fallback behavior and verified _exec_in_container uses a trusted runtime_path when present (these manual checks passed).
  • Full pytest run via scripts/run_tests.sh tests/hermes_cli/test_container_aware_cli.py was blocked by the execution environment being unable to fetch pytest-split from PyPI, so the CI-like test wrapper could not complete here.

Codex Task

Copilot AI review requested due to automatic review settings June 1, 2026 01:06
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread hermes_cli/config.py
Comment on lines 301 to +303
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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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"

Comment thread hermes_cli/main.py
Comment on lines +698 to 701
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.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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.

Suggested change
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,
)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 trusted runtime_path for 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()
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

🔎 Lint report: badmade/fix-container-routing-metadata-vulnerability vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8254 on HEAD, 8258 on base (✅ -4)

🆕 New issues (41):

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

Auto-merge: checks failing

The following checks did not pass:

  • test (failure)

Please fix the failing checks before this PR can be merged.

View workflow run

@badMade
Copy link
Copy Markdown
Owner Author

badMade commented Jun 1, 2026

@copilot, fix all failing checks.

Copy link
Copy Markdown

Copilot AI commented Jun 1, 2026

@copilot, fix all failing checks.

Addressed in 68bf275. I restored the missing get_terminal_cwd() helper, added regression coverage, pinned the source-file read to UTF-8, and revalidated the affected tests/lint checks locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants