Harden M7.6/7.7: recursive restriction accumulation, MCP server enforcement, fast-fail authorization
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user