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:
LZH-YS1998
2026-07-08 18:43:27 +08:00
parent 447516d93c
commit 4b29b89371
12 changed files with 1386 additions and 1039 deletions
@@ -9,7 +9,7 @@ import uuid
from typing import Any, Awaitable, Callable
from opc.core.models import PermissionResolution
from opc.layer3_agent.runtime_v2.permissions import ToolPermissionResolver
from opc.layer3_agent.runtime_v2.permissions import RuntimePermissionAdapter
from opc.layer3_agent.runtime_v2.tool_hooks import RuntimeToolHookBus, RuntimeToolHookContext
from opc.layer3_agent.runtime_v2.tool_planner import ToolBatch, ToolPlanner
from opc.layer4_tools.registry import ToolRegistry
@@ -51,7 +51,7 @@ class StreamingToolExecutor:
*,
registry: ToolRegistry,
planner: ToolPlanner,
permission_resolver: ToolPermissionResolver,
permission_resolver: RuntimePermissionAdapter,
hook_bus: RuntimeToolHookBus | None = None,
runtime_tool_handler: RuntimeToolHandler | None = None,
emit_event: RuntimeEventCallback | None = None,
@@ -381,7 +381,7 @@ class StreamingToolExecutor:
batch_id: str,
call: dict[str, Any],
) -> dict[str, Any]:
guardian = getattr(self.permission_resolver.config, "guardian", None)
guardian = getattr(self.permission_resolver, "guardian", None)
if not guardian or not bool(getattr(guardian, "auto_retry_sandbox", False)):
return result
payload = result.get("result", {})