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>
This commit is contained in:
@@ -6,9 +6,10 @@ import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from opc.core.config import OPCConfig, PermissionsV2Config
|
||||
from opc.core.config import AutonomyConfig, OPCConfig
|
||||
from opc.core.models import ApprovalAction, ApprovalDecision, PermissionResolution, RiskLevel, Task, TaskResult, TaskStatus
|
||||
from opc.layer3_agent.runtime_v2.permissions import ToolPermissionResolver
|
||||
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
|
||||
@@ -31,6 +32,36 @@ class _StubLLM:
|
||||
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()
|
||||
@@ -70,12 +101,12 @@ class RuntimeHookBusTests(unittest.IsolatedAsyncioTestCase):
|
||||
hook_bus = runtime._build_tool_hook_bus(
|
||||
runtime_session_id="rt_hook",
|
||||
task=task,
|
||||
permission_resolver=ToolPermissionResolver(PermissionsV2Config()),
|
||||
permission_resolver=_policy_adapter(),
|
||||
)
|
||||
executor = StreamingToolExecutor(
|
||||
registry=registry,
|
||||
planner=ToolPlanner(registry),
|
||||
permission_resolver=ToolPermissionResolver(PermissionsV2Config()),
|
||||
permission_resolver=_policy_adapter(),
|
||||
hook_bus=hook_bus,
|
||||
)
|
||||
|
||||
@@ -124,12 +155,12 @@ class RuntimeHookBusTests(unittest.IsolatedAsyncioTestCase):
|
||||
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=ToolPermissionResolver(PermissionsV2Config()),
|
||||
permission_resolver=_policy_adapter(),
|
||||
)
|
||||
executor = StreamingToolExecutor(
|
||||
registry=registry,
|
||||
planner=ToolPlanner(registry, max_parallel_read_tools=1),
|
||||
permission_resolver=ToolPermissionResolver(PermissionsV2Config()),
|
||||
permission_resolver=_policy_adapter(),
|
||||
hook_bus=hook_bus,
|
||||
max_parallel_read_tools=1,
|
||||
converge_on_parallel_failure=True,
|
||||
|
||||
Reference in New Issue
Block a user