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