Harden M7.6/7.7: recursive restriction accumulation, MCP server enforcement, fast-fail authorization

This commit is contained in:
rob
2026-07-20 02:33:44 -03:00
parent 93ea80950e
commit 8366ef798b
8 changed files with 435 additions and 67 deletions
+39
View File
@@ -172,6 +172,20 @@ class TestProviderPersistence:
assert oct(temp_providers_file.stat().st_mode & 0o777) == "0o600"
def test_malformed_access_policy_fails_closed(self, temp_providers_file, capsys):
temp_providers_file.parent.mkdir(parents=True)
temp_providers_file.write_text(yaml.safe_dump({
"version": 2,
"providers": [{
"name": "claude",
"command": "claude -p",
"tools": "should-have-been-a-list",
}],
}))
assert load_providers() == []
assert "Failed to load provider configuration" in capsys.readouterr().err
def test_legacy_config_gains_missing_defaults_without_losing_custom_provider(self, temp_providers_file):
temp_providers_file.parent.mkdir(parents=True)
temp_providers_file.write_text(yaml.safe_dump({
@@ -810,3 +824,28 @@ class TestProviderAccessControl:
provider = Provider("test", "cmd")
assert "tools" not in provider.to_dict()
assert "mcp_servers" not in provider.to_dict()
def test_empty_allowlists_survive_roundtrip(self):
provider = Provider("locked", "cmd", tools=[], mcp_servers=[])
serialized = provider.to_dict()
assert serialized["tools"] == []
assert serialized["mcp_servers"] == []
restored = Provider.from_dict(serialized)
assert restored.tools == []
assert restored.mcp_servers == []
@pytest.mark.parametrize(
("field", "value"),
[
("tools", "tool-a"),
("tools", [""]),
("tools", [1]),
("mcp_servers", "filesystem"),
("mcp_servers", [""]),
("mcp_servers", [1]),
],
)
def test_rejects_invalid_access_policies(self, field, value):
with pytest.raises(ValueError, match=field):
Provider("test", "cmd", **{field: value})
+179 -2
View File
@@ -1,18 +1,20 @@
"""Tests for runner.py - Tool execution engine."""
import pytest
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
from cmdforge.runner import (
substitute_variables,
execute_prompt_step,
execute_code_step,
execute_tool_step,
run_tool,
create_argument_parser,
collect_custom_args
)
from cmdforge.tool import Tool, ToolArgument, PromptStep, CodeStep
from cmdforge.providers import ProviderResult
from cmdforge.tool import Tool, ToolArgument, PromptStep, CodeStep, ToolStep, McpStep
from cmdforge.providers import Provider, ProviderResult
class TestSubstituteVariables:
@@ -1010,3 +1012,178 @@ class TestCreateArgumentParser:
"max_size": "50",
"format": "json",
}
class TestProviderExecutionPolicies:
def test_denied_tool_is_not_resolved(self, monkeypatch):
resolver = MagicMock()
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolver)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", tools=[]),
)
output, success = execute_tool_step(
ToolStep(tool="forbidden", output_var="out", provider="locked"),
{"input": ""},
)
assert (output, success) == ("", False)
resolver.assert_not_called()
def test_unknown_provider_is_rejected_before_resolution(self, monkeypatch):
resolver = MagicMock()
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolver)
monkeypatch.setattr("cmdforge.providers.get_provider", lambda name: None)
_, success = execute_tool_step(
ToolStep(tool="anything", output_var="out", provider="missing"),
{"input": ""},
)
assert success is False
resolver.assert_not_called()
def test_provider_and_delegated_allowlists_both_apply(self, monkeypatch):
leaf = Tool(name="safe-lint", output="completed")
resolver = MagicMock(return_value=SimpleNamespace(tool=leaf))
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolver)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", tools=["safe-*"]),
)
output, success = execute_tool_step(
ToolStep(tool="safe-lint", output_var="out", provider="restricted"),
{"input": ""},
agent_tool_policies=[["*-lint"]],
)
assert (output, success) == ("completed", True)
resolver.assert_called_once_with("safe-lint")
def test_delegated_allowlist_can_further_restrict_provider(self, monkeypatch):
resolver = MagicMock()
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolver)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", tools=["*"]),
)
_, success = execute_tool_step(
ToolStep(tool="unsafe-delete", output_var="out", provider="broad"),
{"input": ""},
agent_tool_policies=[["safe-*"]],
)
assert success is False
resolver.assert_not_called()
def test_mcp_server_policy_denies_before_manager_creation(self, monkeypatch):
manager = MagicMock()
monkeypatch.setattr("cmdforge.runner.McpClientManager", manager)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", mcp_servers=[]),
)
tool = Tool(
name="mcp-tool",
steps=[McpStep(server="database", tool="query", output_var="result")],
output="{result}",
)
output, exit_code = run_tool(
tool, "", {}, provider_override="restricted"
)
assert (output, exit_code) == ("", 4)
manager.assert_not_called()
def test_mcp_server_policy_allows_matching_server(self, monkeypatch):
class FakeManager:
def call_tool(self, server, tool, arguments, result_mode="auto"):
return "allowed"
monkeypatch.setattr("cmdforge.runner.McpClientManager", FakeManager)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", mcp_servers=["data-*"]),
)
tool = Tool(
name="mcp-tool",
steps=[McpStep(server="data-local", tool="query", output_var="result")],
output="{result}",
)
assert run_tool(tool, "", {}, provider_override="restricted") == (
"allowed", 0
)
def test_agent_context_propagates_through_nested_tools(self, monkeypatch):
captured = {}
leaf = Tool(
name="leaf",
steps=[PromptStep("prompt", "original", "answer")],
output="{answer}",
)
middle = Tool(
name="middle",
steps=[ToolStep(tool="leaf", output_var="child")],
output="{child}",
)
root = Tool(
name="root",
steps=[
ToolStep(
tool="middle",
output_var="delegated",
provider="delegate",
profile="architect",
skills=["python"],
tools=["leaf"],
)
],
output="{delegated}",
)
def resolve(name):
return SimpleNamespace(tool={"middle": middle, "leaf": leaf}[name])
def execute_prompt(step, *args, **kwargs):
captured["step"] = step
captured["provider_override"] = args[1]
return "done", True
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolve)
monkeypatch.setattr("cmdforge.runner.execute_prompt_step", execute_prompt)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", tools=["middle", "leaf"]),
)
assert run_tool(root, "", {}) == ("done", 0)
assert captured["step"].profile == "architect"
assert captured["step"].skills == ["python"]
assert captured["provider_override"] == "delegate"
def test_descendant_cannot_broaden_ancestor_tool_policy(self, monkeypatch):
resolver = MagicMock()
monkeypatch.setattr("cmdforge.runner.resolve_tool", resolver)
monkeypatch.setattr(
"cmdforge.providers.get_provider",
lambda name: Provider(name, "cmd", tools=["*"]),
)
_, success = execute_tool_step(
ToolStep(
tool="dangerous",
output_var="out",
provider="delegate",
tools=["*"],
),
{"input": ""},
agent_tool_policies=[["safe-*"]],
)
assert success is False
resolver.assert_not_called()
+29
View File
@@ -609,6 +609,35 @@ class TestAgentContext:
assert "skills" not in d
assert "profile" not in d
def test_toolstep_tools_roundtrip(self):
step = ToolStep(
tool="my-tool", output_var="out", tools=["safe-*", "official/lint"]
)
assert ToolStep.from_dict(step.to_dict()).tools == [
"safe-*", "official/lint"
]
def test_toolstep_empty_tools_roundtrip(self):
step = ToolStep(tool="my-tool", output_var="out", tools=[])
serialized = step.to_dict()
assert serialized["tools"] == []
assert ToolStep.from_dict(serialized).tools == []
@pytest.mark.parametrize(
"data",
[
{"skills": "python"},
{"skills": ["*", "python"]},
{"skills": ["Python"]},
{"tools": "safe-tool"},
{"tools": [""]},
{"profile": ""},
],
)
def test_toolstep_rejects_invalid_agent_context(self, data):
with pytest.raises(ValueError):
ToolStep(tool="my-tool", output_var="out", **data)
def test_promptstep_skills_roundtrip(self):
step = PromptStep(
prompt="Test", provider="claude", output_var="out",