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
+12 -76
View File
@@ -22,7 +22,7 @@ from opc.layer2_organization.work_item_identity import (
turn_type_for_task,
work_item_identity_payload_for_task,
)
from opc.layer3_agent.runtime_v2.permissions import ToolPermissionResolver
from opc.layer3_agent.runtime_v2.permissions import RuntimePermissionAdapter
from opc.layer3_agent.runtime_v2.streaming_tool_executor import StreamingToolExecutor
from opc.layer3_agent.runtime_v2.subagents import ChildAgentFactory, SubagentManager
from opc.layer3_agent.runtime_v2.tool_hooks import RuntimeToolHookBus, RuntimeToolHookContext
@@ -69,6 +69,7 @@ class NativeRuntimeV2:
config: OPCConfig | None = None,
child_agent_factory: ChildAgentFactory | None = None,
approval_callback: ApprovalCallback | None = None,
permission_policy: Any | None = None,
prefetch_provider: PrefetchProvider | None = None,
) -> None:
self.llm = llm
@@ -82,6 +83,9 @@ class NativeRuntimeV2:
self.config = config or OPCConfig()
self.child_agent_factory = child_agent_factory
self.approval_callback = approval_callback
# The single permission policy (ApprovalEngine). Its sync predict()
# gates every tool call; ASK routes into approval_callback.
self.permission_policy = permission_policy
self.prefetch_provider = prefetch_provider
self._pre_tool_hooks: list[tuple[str, Any]] = []
self._post_tool_hooks: list[tuple[str, Any]] = []
@@ -111,7 +115,6 @@ class NativeRuntimeV2:
ensure_task_execution_context(task, self.config)
runtime_session_id = self._runtime_session_id(task)
conversation_turn_id = self._conversation_turn_id(task, runtime_session_id)
permission_session_id = self._permission_session_id(task, runtime_session_id)
user_content = self.llm.prepare_user_message_content(
user_message,
attachment_refs=attachment_refs,
@@ -128,14 +131,10 @@ class NativeRuntimeV2:
self.tools,
max_parallel_read_tools=self.config.system.native_runtime.max_parallel_read_tools,
)
permission_resolver = ToolPermissionResolver(
self.config.autonomy.permissions_v2,
store=getattr(self.memory_manager, "store", None),
runtime_session_id=permission_session_id,
project_id=task.project_id if task else "default",
llm=self.llm,
permission_resolver = RuntimePermissionAdapter(
self.permission_policy,
guardian=self.config.autonomy.permissions_v2.guardian,
)
await permission_resolver.warmup()
todo_state: list[dict[str, Any]] = self._restore_task_ledger(task)
current_runtime_messages: list[dict[str, Any]] = []
runtime_status: dict[str, Any] = {
@@ -797,7 +796,6 @@ class NativeRuntimeV2:
compaction_boundaries=compaction_boundaries,
active_subagents=active_subagents,
)
await self._persist_permission_grants(permission_session_id, task, execution_results)
early_return = self._handle_pause_or_peer_wait(
execution_results,
aggregated_artifacts,
@@ -962,18 +960,12 @@ class NativeRuntimeV2:
normalized_turn_id = f"turn:{uuid.uuid4().hex}"
return f"{normalized_turn_id}:iter:{iteration + 1}"
def _permission_session_id(self, task: Task | None, runtime_session_id: str) -> str:
if not task:
return runtime_session_id
bridged = str(task.metadata.get("_permission_bridge_runtime_session_id", "") or "").strip()
return bridged or runtime_session_id
def _build_tool_hook_bus(
self,
*,
runtime_session_id: str,
task: Task | None,
permission_resolver: ToolPermissionResolver,
permission_resolver: RuntimePermissionAdapter,
on_progress: Any = None,
) -> RuntimeToolHookBus:
hook_bus = RuntimeToolHookBus(
@@ -1008,18 +1000,10 @@ class NativeRuntimeV2:
self,
context: RuntimeToolHookContext,
*,
permission_resolver: ToolPermissionResolver,
permission_resolver: RuntimePermissionAdapter,
on_progress: Any = None,
) -> dict[str, Any] | None:
predicted = context.predicted_permission
if predicted is not None and context.tool is not None:
predicted = await permission_resolver.refine_decision(
predicted,
tool=context.tool,
arguments=context.arguments,
task=context.task,
)
context.predicted_permission = predicted
if predicted is not None and getattr(predicted, "resolution", None) == PermissionResolution.DENY:
return {
"result": permission_resolver.build_blocked_result(
@@ -1502,7 +1486,7 @@ class NativeRuntimeV2:
early_tool_runs: dict[int, dict[str, Any]],
executor: StreamingToolExecutor,
planner: ToolPlanner,
permission_resolver: ToolPermissionResolver,
permission_resolver: RuntimePermissionAdapter,
task: Task | None,
on_progress: Any,
runtime_session_id: str,
@@ -1538,7 +1522,7 @@ class NativeRuntimeV2:
self,
*,
planner: ToolPlanner,
permission_resolver: ToolPermissionResolver,
permission_resolver: RuntimePermissionAdapter,
call: dict[str, Any],
task: Task | None,
) -> bool:
@@ -3852,54 +3836,6 @@ class NativeRuntimeV2:
except TypeError:
await callback(text)
async def _persist_permission_grants(
self,
runtime_session_id: str,
task: Task | None,
execution_results: list[dict[str, Any]],
) -> None:
store = getattr(self.memory_manager, "store", None)
if not store or not hasattr(store, "save_runtime_permission_grant"):
return
for item in execution_results:
decision = item.get("permission_decision")
result = item.get("result", {})
call = item.get("tool_call", {})
if decision is None:
continue
human_reply = str(
(result.get("approval", {}) or {}).get("human_reply")
or ""
).strip().lower()
if human_reply not in {"approve_session", "always_project", "always_global"}:
continue
candidate = (
str(call.get("arguments", {}).get("path", "") or "").strip()
or str(call.get("arguments", {}).get("command", "") or "").strip()
or "*"
)
execution_context = dict((getattr(task, "metadata", {}) or {}).get("_execution_context", {}) or {}) if task else {}
sandbox = dict(execution_context.get("sandbox", {}) or {})
scope = "session"
if human_reply == "always_project":
scope = "project"
elif human_reply == "always_global":
scope = "global"
metadata = dict(result.get("approval", {}) or {})
metadata.update({
"sandbox_mode": str(sandbox.get("mode", "") or "").strip() or "*",
"allow_network": str(bool(sandbox.get("allow_network", True))).lower(),
"workspace_class": "workspace" if str((getattr(task, "metadata", {}) or {}).get("target_output_dir", "") or "").strip() else "default",
})
await store.save_runtime_permission_grant(
runtime_session_id=runtime_session_id,
project_id=task.project_id if task else "default",
scope=scope,
tool_name=str(call.get("function", "") or ""),
candidate=candidate,
metadata=metadata,
)
def _permission_requests_from_results(self, execution_results: list[dict[str, Any]]) -> list[dict[str, Any]]:
requests: list[dict[str, Any]] = []
for item in execution_results: