From c4f376c19a0c7a127b0cad6c9cfac52efdf4e20b Mon Sep 17 00:00:00 2001 From: unsupportedpastels Date: Fri, 21 Aug 2026 15:13:54 +0000 Subject: [PATCH] fix(config): block generic Copilot ACP controls --- hermes_cli/config.py | 3 + tests/hermes_cli/test_config.py | 17 ++++- .../test_mcp_catalog_env_boundary.py | 64 ++++++++++++++++++- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cb39d48e21..32d6cbf936 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -219,6 +219,9 @@ _ENV_VAR_NAME_DENYLIST: frozenset[str] = frozenset({ # MCP catalog trust root. Package-manager wrappers may still provide this # in the process environment; only generic persistence writes are blocked. "HERMES_OPTIONAL_MCPS", + # Local ACP subprocess selection. Existing operator/package-manager values + # remain readable; generic writers cannot acquire executable/argv authority. + "HERMES_COPILOT_ACP_COMMAND", "HERMES_COPILOT_ACP_ARGS", # Hermes security policy / approval-routing context. These remain available # through their dedicated CLI/config/session controls, but a generic # credential writer must not persist them for the next process startup. diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 3d04b4e0ab..97dd40d6cf 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -1095,6 +1095,8 @@ class TestEnvWriteDenylist: "HERMES_CONFIG_PATH", "HERMES_ENV_PATH", "HERMES_OPTIONAL_MCPS", + "HERMES_COPILOT_ACP_COMMAND", + "HERMES_COPILOT_ACP_ARGS", "HERMES_YOLO_MODE", "HERMES_ACCEPT_HOOKS", "HERMES_REDACT_SECRETS", @@ -1133,6 +1135,8 @@ class TestEnvWriteDenylist: ("Path", "PATH"), ("Hermes_Yolo_Mode", "HERMES_YOLO_MODE"), ("Hermes_Optional_Mcps", "HERMES_OPTIONAL_MCPS"), + ("Hermes_Copilot_Acp_Command", "HERMES_COPILOT_ACP_COMMAND"), + ("Hermes_Copilot_Acp_Args", "HERMES_COPILOT_ACP_ARGS"), ], ) def test_windows_policy_names_are_case_insensitive(self, key, expected): @@ -1154,9 +1158,18 @@ class TestEnvWriteDenylist: assert not _env_line_defines_key(line, "PATH", is_windows=False) @pytest.mark.windows_only - def test_windows_writer_rejects_mixed_case_protected_name(self): + @pytest.mark.parametrize( + "protected_key", + [ + "Hermes_Yolo_Mode", + "Hermes_Optional_Mcps", + "Hermes_Copilot_Acp_Command", + "Hermes_Copilot_Acp_Args", + ], + ) + def test_windows_writer_rejects_mixed_case_protected_name(self, protected_key): with pytest.raises(ValueError, match="denylist"): - save_env_value("Hermes_Yolo_Mode", "1") + save_env_value(protected_key, "1") diff --git a/tests/hermes_cli/test_mcp_catalog_env_boundary.py b/tests/hermes_cli/test_mcp_catalog_env_boundary.py index a1256e4c8d..7e131f83ed 100644 --- a/tests/hermes_cli/test_mcp_catalog_env_boundary.py +++ b/tests/hermes_cli/test_mcp_catalog_env_boundary.py @@ -166,7 +166,12 @@ def test_catalog_accepts_declared_credential( @pytest.mark.parametrize( "protected_key", - ["HERMES_YOLO_MODE", "HERMES_OPTIONAL_MCPS"], + [ + "HERMES_YOLO_MODE", + "HERMES_OPTIONAL_MCPS", + "HERMES_COPILOT_ACP_COMMAND", + "HERMES_COPILOT_ACP_ARGS", + ], ) def test_generic_env_endpoint_rejects_protected_key( client: TestClient, @@ -190,3 +195,60 @@ def test_process_supplied_catalog_root_remains_supported(catalog_env: Path): from hermes_cli.mcp_catalog import get_entry assert get_entry("demo") is not None + + +def test_rejected_copilot_controls_do_not_change_live_resolvers( + client: TestClient, + catalog_env: Path, + monkeypatch: pytest.MonkeyPatch, +): + from agent.copilot_acp_client import _resolve_args, _resolve_command + + monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", "/opt/trusted/copilot") + monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio") + expected_command = _resolve_command() + expected_args = _resolve_args() + + attempts = { + "HERMES_COPILOT_ACP_COMMAND": "/tmp/attacker-command", + "HERMES_COPILOT_ACP_ARGS": "--malicious-transport", + } + for key, value in attempts.items(): + response = client.put( + "/api/env", + headers=HEADERS, + json={"key": key, "value": value}, + ) + assert response.status_code == 400 + + assert _resolve_command() == expected_command + assert _resolve_args() == expected_args + env_path = catalog_env / ".env" + if env_path.exists(): + env_text = env_path.read_text(encoding="utf-8") + assert "/tmp/attacker-command" not in env_text + assert "--malicious-transport" not in env_text + + +def test_preexisting_copilot_controls_remain_usable( + catalog_env: Path, + monkeypatch: pytest.MonkeyPatch, +): + from agent.copilot_acp_client import _resolve_args, _resolve_command + from hermes_cli.env_loader import load_hermes_dotenv + + monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", "parent-placeholder") + monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--parent-placeholder") + (catalog_env / ".env").write_text( + "HERMES_COPILOT_ACP_COMMAND=/opt/operator/copilot\n" + "HERMES_COPILOT_ACP_ARGS=--acp --stdio --operator-mode\n", + encoding="utf-8", + ) + + load_hermes_dotenv( + hermes_home=catalog_env, + load_external_secrets=False, + ) + + assert _resolve_command() == "/opt/operator/copilot" + assert _resolve_args() == ["--acp", "--stdio", "--operator-mode"]