Files
OpenOPC/tests/test_runtime_v2_hooks.py
LZH-YS1998 4b29b89371 refactor: unify tool approval into a single engine and cut prompt storms
Collapse the dual permission stack into one policy. The runtime-side
ToolPermissionResolver (own safe lists, own grant memory, bypassed the
ApprovalEngine whenever it said ALLOW) is deleted; runtime_v2 now consults
ApprovalEngine.predict(), a synchronous fast path reading the same config
and the same persisted allowlist as the async authorize pipeline, so a
grant given anywhere is honored everywhere. permissions.py keeps only a
policy-free adapter; the duplicated permissions_v2 config fields and the
runtime grant persistence loop are removed (stale YAML keys are ignored).

New shell_safety module becomes the single source of truth for shell
classification: flag-audited read-only commands (awk/od/jq/sed -n/diff/
git subcommand table/... auto-allow; find -delete, sort -o, curl -o/-d,
rg --pre still prompt even when the bare name is config-listed),
keyword-aware compound splitting (loop/branch headers no longer poison
grants), expansion-safe $() handling, and fail-closed treatment of
anything unparseable or substitution-bearing.

Grant semantics are rebuilt around derived word-boundary prefixes:
"python3 -c" instead of token bags, interpreter -c/-m kept in the prefix,
bash/eval/sudo never grantable as prefixes, read-only segments exempt
from the every-candidate-must-match rule so a granted command chained
with ls/echo verification passes, and approve-once now records the exact
candidates as a session grant so identical re-runs stop re-prompting.
The authorize heuristic also audits the original command text instead of
the quote-dropping preview (echo "<EOF>" no longer reads as redirection).

Validated live on zz_perm_probe1 (native minimal org): awk/od/ls/cat/
sha256sum ran with zero cards, python3 -c parked once and three different
python3 -c commands then passed via the persisted prefix grant, and an
agent-issued rm -f compound correctly re-prompted showing only the
segments needing approval. Full suite failures are byte-identical to the
pre-change HEAD baseline (27 pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:43:27 +08:00

245 lines
8.8 KiB
Python

from __future__ import annotations
import contextlib
import shutil
import unittest
import uuid
from pathlib import Path
from opc.core.config import AutonomyConfig, OPCConfig
from opc.core.models import ApprovalAction, ApprovalDecision, PermissionResolution, RiskLevel, Task, TaskResult, TaskStatus
from opc.layer2_organization.approval import ApprovalEngine
from opc.layer3_agent.runtime_v2.permissions import RuntimePermissionAdapter
from opc.layer3_agent.runtime_v2.runtime import NativeRuntimeV2
from opc.layer3_agent.runtime_v2.streaming_tool_executor import StreamingToolExecutor
from opc.layer3_agent.runtime_v2.subagents import SubagentManager
from opc.layer3_agent.runtime_v2.tool_planner import ToolPlanner
from opc.layer4_tools.registry import ToolDefinition, ToolRegistry
@contextlib.contextmanager
def _workspace_tempdir() -> Path:
base = Path.cwd() / ".tmp-test" / f"runtime-hooks-{uuid.uuid4().hex}"
base.mkdir(parents=True, exist_ok=True)
try:
yield base
finally:
shutil.rmtree(base, ignore_errors=True)
class _StubLLM:
def __init__(self) -> None:
self.config = type("Cfg", (), {"max_tokens": 2048})()
class _PrefsStub:
def get_autonomy_preferences(self, project_id=None):
_ = project_id
return {"learned_actions": {}}
def record_autonomy_feedback(self, **kwargs):
_ = kwargs
class _StoreStub:
async def record_approval(self, **kwargs):
_ = kwargs
class _MemoryStub:
def append_autonomy_event(self, event, project=False):
_ = (event, project)
def _policy_adapter() -> RuntimePermissionAdapter:
return RuntimePermissionAdapter(ApprovalEngine(
llm=object(),
store=_StoreStub(),
preferences=_PrefsStub(),
memory=_MemoryStub(),
escalation=None,
config=AutonomyConfig(),
))
class RuntimeHookBusTests(unittest.IsolatedAsyncioTestCase):
async def test_pre_hook_permission_gate_blocks_execution(self) -> None:
registry = ToolRegistry()
executed: list[str] = []
async def shell_tool(command: str) -> dict[str, str]:
executed.append(command)
return {"stdout": command}
registry.register(ToolDefinition(
name="shell_exec",
description="shell",
parameters={"type": "object", "properties": {"command": {"type": "string"}}},
func=shell_tool,
concurrency_safe=False,
read_only=False,
requires_confirmation=True,
))
async def approval_callback(tool, arguments, task, on_progress):
_ = (tool, arguments, task, on_progress)
return False, ApprovalDecision(
action=ApprovalAction.ESCALATE,
risk_level=RiskLevel.HIGH,
rationale="Need approval first.",
confidence=1.0,
policy_source="test",
)
runtime = NativeRuntimeV2(
llm=_StubLLM(),
tool_registry=registry,
config=OPCConfig(),
approval_callback=approval_callback,
)
task = Task(id="task-hook", session_id="sess-hook", project_id="proj1")
hook_bus = runtime._build_tool_hook_bus(
runtime_session_id="rt_hook",
task=task,
permission_resolver=_policy_adapter(),
)
executor = StreamingToolExecutor(
registry=registry,
planner=ToolPlanner(registry),
permission_resolver=_policy_adapter(),
hook_bus=hook_bus,
)
results = await executor.execute([
{"id": "call-1", "function": "shell_exec", "arguments": {"command": "git commit -m test"}},
], task=task)
self.assertEqual(executed, [])
self.assertFalse(results[0]["result"]["success"])
self.assertEqual(results[0]["permission_decision"].resolution, PermissionResolution.ASK)
self.assertEqual(results[0]["result"]["approval"]["policy_source"], "test")
async def test_parallel_batch_failure_converges_remaining_calls(self) -> None:
registry = ToolRegistry()
executed: list[str] = []
async def fail_read(path: str) -> dict[str, str]:
raise RuntimeError(f"boom:{path}")
async def second_read(path: str) -> dict[str, str]:
executed.append(path)
return {"content": path}
registry.register(ToolDefinition(
name="file_read",
description="read",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
func=fail_read,
concurrency_safe=True,
read_only=True,
))
registry.register(ToolDefinition(
name="web_fetch",
description="fetch",
parameters={"type": "object", "properties": {"path": {"type": "string"}}},
func=second_read,
concurrency_safe=True,
read_only=True,
))
runtime = NativeRuntimeV2(
llm=_StubLLM(),
tool_registry=registry,
config=OPCConfig(),
)
hook_bus = runtime._build_tool_hook_bus(
runtime_session_id="rt_parallel",
task=Task(id="task-parallel", session_id="sess-parallel", project_id="proj1"),
permission_resolver=_policy_adapter(),
)
executor = StreamingToolExecutor(
registry=registry,
planner=ToolPlanner(registry, max_parallel_read_tools=1),
permission_resolver=_policy_adapter(),
hook_bus=hook_bus,
max_parallel_read_tools=1,
converge_on_parallel_failure=True,
)
results = await executor.execute([
{"id": "call-a", "function": "file_read", "arguments": {"path": "a.txt"}},
{"id": "call-b", "function": "web_fetch", "arguments": {"path": "b.txt"}},
])
self.assertFalse(results[0]["result"]["success"])
self.assertFalse(results[1]["result"]["success"])
self.assertTrue(results[1]["result"]["converged"])
self.assertEqual(executed, [])
class SubagentPermissionBridgeTests(unittest.IsolatedAsyncioTestCase):
async def test_permission_bridge_routes_child_approval_through_parent_task(self) -> None:
captured: dict[str, object] = {}
class _ApprovalEngine:
async def authorize_tool_call(self, **kwargs):
captured["approval_kwargs"] = dict(kwargs)
return True, ApprovalDecision(
action=ApprovalAction.AUTO_APPROVE,
risk_level=RiskLevel.LOW,
rationale="approved",
confidence=1.0,
policy_source="human_escalation",
metadata={"human_reply": "approve_session"},
)
approval_engine = _ApprovalEngine()
class _ChildAgent:
async def execute(self, child_task: Task) -> TaskResult:
captured["child_task_metadata"] = dict(child_task.metadata)
bridge = getattr(child_task, "_runtime_permission_bridge")
tool = ToolDefinition(
name="shell_exec",
description="shell",
parameters={"type": "object", "properties": {"command": {"type": "string"}}},
func=lambda **_: None, # type: ignore[arg-type]
concurrency_safe=False,
read_only=False,
requires_confirmation=True,
)
allowed, decision = await bridge(
tool=tool,
arguments={"command": "git status"},
approval_engine=approval_engine,
on_progress=None,
)
captured["allowed"] = allowed
captured["decision"] = decision
return TaskResult(status=TaskStatus.DONE, content="bridge-ok")
manager = SubagentManager(
parent_task=Task(id="parent-task", session_id="parent-session", project_id="proj1"),
config=OPCConfig(),
child_agent_factory=lambda profile, allowed_tools, prompt, overrides: _ChildAgent(),
runtime_session_id="rt_parent",
)
result = await manager.spawn(
profile="implement",
prompt="Need approval",
background=False,
name="bridge-worker",
)
self.assertTrue(result["success"])
self.assertTrue(captured["allowed"])
approval_kwargs = dict(captured["approval_kwargs"])
self.assertEqual(approval_kwargs["task"].id, "parent-task")
self.assertEqual(approval_kwargs["metadata"]["subagent_name"], "bridge-worker")
self.assertEqual(captured["child_task_metadata"]["_permission_bridge_runtime_session_id"], "rt_parent")
if __name__ == "__main__":
unittest.main()