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
+4
View File
@@ -284,6 +284,7 @@ class NativeAgent:
config: OPCConfig | None = None,
communication: Any | None = None,
approval_callback: Any = None,
permission_policy: Any = None,
) -> None:
self.role = role
self.llm = llm
@@ -297,6 +298,7 @@ class NativeAgent:
self.config = config or OPCConfig()
self.communication = communication
self.approval_callback = approval_callback
self.permission_policy = permission_policy
self.prompt_profiles = PromptProfileManager(role, self.config)
max_iter = self.config.system.max_agent_iterations
comp_threshold = self.config.system.context_compression_threshold
@@ -312,6 +314,7 @@ class NativeAgent:
config=self.config,
child_agent_factory=self._create_child_agent,
approval_callback=approval_callback,
permission_policy=permission_policy,
prefetch_provider=self._build_runtime_prefetch_payload,
)
@@ -741,4 +744,5 @@ class NativeAgent:
config=child_config,
communication=self.communication,
approval_callback=self.approval_callback,
permission_policy=self.permission_policy,
)
+92 -724
View File
@@ -1,553 +1,118 @@
"""Permission helpers for Native Runtime V2."""
"""Runtime-side permission adapter for Native Runtime V2.
This module intentionally contains NO permission policy. All decisions come
from the single ApprovalEngine (``opc/layer2_organization/approval.py``):
its synchronous ``predict()`` is the fast path consulted before every tool
call, and its async ``authorize_tool_call()`` (reached via the runtime's
approval callback) is the escalation path. The adapter only bridges the
engine into the executor and maps tool results back into permission events.
"""
from __future__ import annotations
import json
import os
import re
import shlex
from pathlib import Path
from typing import Any
from opc.core.config import PermissionsV2Config, get_opc_home
from loguru import logger
from opc.core.models import PermissionResolution, PermissionScope, RiskLevel, RuntimePermissionDecision
from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
from opc.layer2_organization.data_acquisition_policy import (
ACQUISITION_SHELL_PREFIXES,
is_projection_scoped_acquisition_shell_command,
)
from opc.layer4_tools.registry import ToolDefinition
_DEFAULT_PATH_KEYS = (
_CANDIDATE_KEYS = (
"path",
"file_path",
"directory",
"working_directory",
"target_output_dir",
"workspace_path",
"command",
"cmd",
"url",
)
_DEFAULT_COMMAND_KEYS = ("command", "cmd")
_DEFAULT_URL_KEYS = ("url",)
_READ_ONLY_PREFIXES = {
"cat",
"echo",
"find",
"git diff",
"git log",
"git show",
"git status",
"head",
"ls",
"node -v",
"npm -v",
"pwd",
"python -V",
"python3 -V",
"rg",
"tail",
"wc",
}
_RISKY_SHELL_KEYWORDS = (
"curl ",
"wget ",
"invoke-webrequest",
"invoke-restmethod",
"mv ",
"cp ",
"rm ",
"del ",
"remove-item",
"git commit",
"git push",
"npm install",
"pip install",
"pnpm install",
"cargo test",
"pytest",
"tee ",
"sed -i",
">",
">>",
)
_ACQUISITION_SHELL_PREFIXES = {str(item) for item in ACQUISITION_SHELL_PREFIXES}
_ANY_GRANT_VALUE = "*"
class ToolPermissionResolver:
"""Runtime permission gate with persisted session/project/global grants."""
def _candidate(arguments: dict[str, Any] | None) -> str:
for key in _CANDIDATE_KEYS:
value = str((arguments or {}).get(key, "") or "").strip()
if value:
return value
return "*"
def __init__(
def _risk(value: Any, default: RiskLevel) -> RiskLevel:
try:
return RiskLevel(str(value or default.value))
except Exception:
return default
class RuntimePermissionAdapter:
"""Thin, policy-free bridge between the tool executor and ApprovalEngine.
``policy`` is the ApprovalEngine (duck-typed: ``predict`` and
``record_denial``). Without a policy (bare runtimes, unit tests) the
adapter falls back to a static conservative default: unknown tools and
confirmation-required tools ask, everything else runs — matching the
pre-unification behavior of a runtime without an approval callback.
"""
def __init__(self, policy: Any = None, *, guardian: Any = None) -> None:
self.policy = policy
self.guardian = guardian if guardian is not None else getattr(
getattr(getattr(policy, "config", None), "permissions_v2", None), "guardian", None
)
def predicted_decision(
self,
config: PermissionsV2Config | None = None,
*,
store: Any = None,
runtime_session_id: str = "",
project_id: str = "default",
llm: Any | None = None,
) -> None:
self.config = config or PermissionsV2Config()
self.store = store
self.runtime_session_id = runtime_session_id
self.project_id = project_id or "default"
self.llm = llm
self._loaded = False
self._session_grants: set[tuple[str, str, str, str, str]] = set()
self._project_grants: set[tuple[str, str, str, str, str]] = set()
self._global_grants: set[tuple[str, str, str, str, str]] = set()
self._denial_counts: dict[str, int] = {}
async def warmup(self) -> None:
if self._loaded or not self.store or not hasattr(self.store, "list_runtime_permission_grants"):
self._loaded = True
return
session_rows = await self.store.list_runtime_permission_grants(
runtime_session_id=self.runtime_session_id or None,
scopes=["session"],
)
project_rows = await self.store.list_runtime_permission_grants(
project_id=self.project_id,
scopes=["project"],
)
global_rows = await self.store.list_runtime_permission_grants(scopes=["global"])
self._session_grants = {self._grant_key_from_row(row) for row in session_rows}
self._project_grants = {self._grant_key_from_row(row) for row in project_rows}
self._global_grants = {self._grant_key_from_row(row) for row in global_rows}
self._loaded = True
def _candidate_extractors(self) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
keys = [str(item or "").strip() for item in self.config.candidate_extractors if str(item or "").strip()]
if not keys:
keys = [*_DEFAULT_PATH_KEYS, *_DEFAULT_COMMAND_KEYS, *_DEFAULT_URL_KEYS]
path_keys = tuple(item for item in keys if item in _DEFAULT_PATH_KEYS)
command_keys = tuple(item for item in keys if item in _DEFAULT_COMMAND_KEYS)
url_keys = tuple(item for item in keys if item in _DEFAULT_URL_KEYS)
return (
path_keys or _DEFAULT_PATH_KEYS,
command_keys or _DEFAULT_COMMAND_KEYS,
url_keys or _DEFAULT_URL_KEYS,
)
def _grant_key_from_row(self, row: dict[str, Any]) -> tuple[str, str, str, str, str]:
tool_name = str(row.get("tool_name", "") or "").strip()
candidate = str(row.get("candidate", "") or "").strip()
metadata = dict(row.get("metadata", {}) or {})
sandbox_mode = str(metadata.get("sandbox_mode", "") or "").strip() or _ANY_GRANT_VALUE
allow_network = str(metadata.get("allow_network", "") or "").strip().lower() or _ANY_GRANT_VALUE
workspace_class = str(metadata.get("workspace_class", "") or "").strip() or _ANY_GRANT_VALUE
return self._grant_key(
tool_name,
candidate,
sandbox_mode=sandbox_mode,
allow_network=allow_network,
workspace_class=workspace_class,
)
def _grant_key(
self,
tool_name: str,
candidate: str,
*,
sandbox_mode: str,
allow_network: str,
workspace_class: str,
) -> tuple[str, str, str, str, str]:
normalized = candidate.strip() or _ANY_GRANT_VALUE
return (
tool_name,
normalized,
sandbox_mode.strip() or _ANY_GRANT_VALUE,
allow_network.strip().lower() or _ANY_GRANT_VALUE,
workspace_class.strip() or _ANY_GRANT_VALUE,
)
def _candidate(self, arguments: dict[str, Any] | None = None) -> str:
if not arguments:
return _ANY_GRANT_VALUE
path_keys, command_keys, url_keys = self._candidate_extractors()
for key in (*path_keys, *command_keys, *url_keys):
value = str(arguments.get(key, "") or "").strip()
if value:
return value
return _ANY_GRANT_VALUE
def _grant_context(self, task: Any = None) -> tuple[str, str, str]:
sandbox_mode = _ANY_GRANT_VALUE
allow_network = _ANY_GRANT_VALUE
workspace_class = _ANY_GRANT_VALUE
if task is None:
return sandbox_mode, allow_network, workspace_class
metadata = getattr(task, "metadata", {}) or {}
execution_context = dict(metadata.get("_execution_context", {}) or {})
sandbox = dict(execution_context.get("sandbox", {}) or {})
sandbox_mode = str(sandbox.get("mode", "") or "").strip() or _ANY_GRANT_VALUE
allow_network = str(bool(sandbox.get("allow_network", True))).lower()
workspace_root = (
str(execution_context.get("workspace_root", "") or "").strip()
or str(metadata.get("workspace_root", "") or "").strip()
or str(metadata.get("comms_workspace_root", "") or "").strip()
or str(metadata.get("target_output_dir", "") or "").strip()
)
workspace_class = "workspace" if workspace_root else "default"
return sandbox_mode, allow_network, workspace_class
@staticmethod
def _risk(value: Any, default: RiskLevel) -> RiskLevel:
try:
return RiskLevel(str(value or default.value))
except Exception:
return default
@staticmethod
def _looks_like_shell_tool(tool_name: str) -> bool:
return tool_name in {"shell_exec", "python_exec", "git_commit"}
def _normalized_tool_set(self, values: list[str]) -> set[str]:
return {str(item or "").strip() for item in values if str(item or "").strip()}
def _matches_path_rule(self, candidate: str, rules: list[str]) -> bool:
if not candidate or candidate == "*":
return False
raw = str(candidate).strip()
for rule in rules:
token = str(rule or "").strip()
if not token:
continue
if token == "*" or raw == token:
return True
try:
rule_path = Path(token).resolve()
candidate_path = Path(raw).resolve()
except Exception:
if raw.startswith(token.rstrip("\\/")):
return True
continue
if candidate_path == rule_path or rule_path in candidate_path.parents:
return True
return False
def _workspace_paths(self, task: Any = None) -> list[Path]:
roots: list[Path] = []
metadata = getattr(task, "metadata", {}) or {} if task else {}
for raw in (
str(metadata.get("workspace_root", "") or "").strip(),
str(metadata.get("comms_workspace_root", "") or "").strip(),
str(metadata.get("output_root", "") or "").strip(),
str(metadata.get("target_output_dir", "") or "").strip(),
):
if not raw:
continue
try:
path = Path(raw).resolve()
except Exception:
continue
if path not in roots:
roots.append(path)
try:
memory_root = (Path(get_opc_home()) / "memory").resolve()
if memory_root not in roots:
roots.append(memory_root)
except Exception:
pass
if not roots:
try:
roots.append(Path.cwd().resolve())
except Exception:
pass
return roots
def _path_decision(
self,
tool: ToolDefinition,
arguments: dict[str, Any] | None,
task: Any = None,
) -> RuntimePermissionDecision | None:
if not arguments:
return None
path_keys, _, _ = self._candidate_extractors()
candidate = ""
for key in path_keys:
value = str(arguments.get(key, "") or "").strip()
if value:
candidate = value
break
if not candidate:
return None
if self._matches_path_rule(candidate, self.config.denied_paths):
return RuntimePermissionDecision(
resolution=PermissionResolution.DENY,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.HIGH,
rationale="Target path matches a denied runtime permission rule.",
source="permission_rules",
)
if self._matches_path_rule(candidate, self.config.allowed_paths):
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.PROJECT,
risk_level=RiskLevel.LOW,
rationale="Target path matches an explicit runtime allow rule.",
source="permission_rules",
)
try:
resolved = Path(candidate).resolve()
except Exception:
return None
if tool.read_only:
return None
for root in self._workspace_paths(task):
if resolved == root or root in resolved.parents:
return None
risk = RiskLevel.HIGH if self.config.sandbox_policy.treat_external_paths_as_high_risk else RiskLevel.MEDIUM
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
scope=PermissionScope.ONCE,
risk_level=risk,
rationale="Target path is outside the current runtime workspace roots.",
source="path_guard",
metadata={"candidate": candidate},
)
def _split_command_prefix(self, command: str) -> str:
text = str(command or "").strip()
if not text:
return ""
try:
parts = shlex.split(text, posix=os.name != "nt")
except Exception:
parts = text.split()
if not parts:
return ""
if len(parts) >= 2:
return f"{parts[0]} {parts[1]}".strip()
return parts[0]
def _matches_command_prefix(self, command: str, prefixes: list[str]) -> bool:
raw = str(command or "").strip()
prefix = self._split_command_prefix(raw)
candidates = {raw, prefix}
for item in prefixes:
token = str(item or "").strip()
if not token:
continue
if raw == token or prefix == token:
return True
if raw.startswith(f"{token} ") or prefix.startswith(f"{token} "):
return True
return False
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
if not text:
return []
try:
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except Exception:
try:
tokens = shlex.split(text, posix=os.name != "nt")
except Exception:
tokens = text.split()
segments: list[list[str]] = []
current: list[str] = []
for token in tokens:
if token in {"&&", "||", ";", "|", "&"}:
if current:
segments.append(current)
current = []
continue
current.append(token)
if current:
segments.append(current)
return segments
def _command_has_redirection(self, command: str) -> bool:
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
if not text:
return False
try:
lexer = shlex.shlex(text, posix=os.name != "nt", punctuation_chars=";&|<>")
lexer.whitespace_split = True
lexer.commenters = ""
tokens = list(lexer)
except Exception:
return any(marker in text for marker in (">", "<"))
return any(token in {">", ">>", "<", "<<"} for token in tokens)
def _matches_safe_shell_prefix(self, command: str, prefixes: list[str]) -> bool:
cleaned = " ".join(str(command or "").split()).strip()
if not cleaned or self._command_has_redirection(cleaned):
return False
segments = self._split_shell_command_segments(cleaned)
if len(segments) != 1:
return False
return self._matches_command_prefix(" ".join(segments[0]).strip(), prefixes)
def _shell_ast_reason(self, command: str) -> tuple[RiskLevel, str] | None:
lowered = str(command or "").strip().lower()
if not lowered:
return None
if lowered in _READ_ONLY_PREFIXES or self._matches_command_prefix(lowered, list(_READ_ONLY_PREFIXES)):
return RiskLevel.LOW, "Command matches a read-only shell prefix."
for keyword in _RISKY_SHELL_KEYWORDS:
if keyword in lowered:
risk = RiskLevel.HIGH
if keyword in {"curl ", "wget ", "invoke-webrequest", "invoke-restmethod"} and self.config.sandbox_policy.treat_network_as_risky:
risk = RiskLevel.CRITICAL
return risk, f"Command contains risky shell operation `{keyword.strip()}`."
return RiskLevel.MEDIUM, "Shell AST classifier could not prove the command is read-only."
def _shell_decision(
self,
tool: ToolDefinition,
arguments: dict[str, Any] | None,
tool: Any,
arguments: dict[str, Any] | None = None,
*,
task: Any = None,
) -> RuntimePermissionDecision | None:
if not self._looks_like_shell_tool(tool.name) or not arguments:
return None
_, command_keys, _ = self._candidate_extractors()
command = ""
for key in command_keys:
value = str(arguments.get(key, "") or "").strip()
if value:
command = value
break
if not command:
return None
projection_scoped_low_risk = is_projection_scoped_acquisition_shell_command(
command=command,
task=task,
working_directory=str(arguments.get("working_directory", "") or arguments.get("workdir", "") or "").strip(),
target_output_dir=str(getattr(task, "metadata", {}).get("target_output_dir", "") or "").strip() if task else "",
)
if projection_scoped_low_risk:
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.LOW,
rationale="Command matches a work-item-scoped acquisition prefix inside the assigned workspace.",
source="shell_prefix",
metadata={"candidate": command},
)
for pattern in self.config.dangerous_shell_patterns:
if pattern and re.search(pattern, command, flags=re.IGNORECASE):
) -> RuntimePermissionDecision:
if self.policy is not None:
try:
return self.policy.predict(tool, arguments, task=task)
except Exception:
logger.opt(exception=True).warning(
"Permission predictor failed; falling back to ask-first default"
)
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.CRITICAL,
rationale=f"Command matched dangerous shell pattern `{pattern}`.",
source="shell_pattern",
metadata={"candidate": command},
risk_level=RiskLevel.MEDIUM,
rationale="Permission predictor failed; requiring explicit review.",
source="runtime_prediction",
)
filtered_safe_prefixes = [
item for item in self.config.safe_shell_prefixes
if str(item or "").strip() not in _ACQUISITION_SHELL_PREFIXES
]
if self._matches_safe_shell_prefix(command, filtered_safe_prefixes):
if tool is None:
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.LOW,
rationale="Command matches a safe shell prefix.",
source="shell_prefix",
metadata={"candidate": command},
risk_level=RiskLevel.HIGH,
rationale="Unknown tool requires manual review.",
source="runtime_prediction",
)
if self._matches_command_prefix(command, self.config.ask_shell_prefixes):
if bool(getattr(tool, "requires_confirmation", False)):
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.MEDIUM,
rationale="Command matches an ask-first shell prefix.",
source="shell_prefix",
metadata={"candidate": command},
rationale="Tool is marked as requiring confirmation.",
source="runtime_prediction",
)
if self.config.shell_ast_validation:
risk, rationale = self._shell_ast_reason(command) or (RiskLevel.MEDIUM, "Shell command requires manual review.")
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW if risk == RiskLevel.LOW else PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=risk,
rationale=rationale,
source="shell_ast",
metadata={"candidate": command},
)
if tool.requires_confirmation or self.config.fail_closed:
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.HIGH if not tool.read_only else RiskLevel.MEDIUM,
rationale="Shell command requires explicit approval under runtime_v2.",
source="shell_guard",
metadata={"candidate": command},
)
return None
def _candidate_matches(self, candidate: str, granted_candidate: str) -> bool:
if granted_candidate == _ANY_GRANT_VALUE:
return True
if candidate == granted_candidate:
return True
return candidate.startswith(granted_candidate.rstrip("\\/"))
@staticmethod
def _sandbox_rank(mode: str) -> int:
return {
"workspace-write": 1,
"elevated": 2,
"off": 3,
}.get(str(mode or "").strip().lower(), 0)
def _match_grant(self, grants: set[tuple[str, str, str, str, str]], tool_name: str, candidate: str, *, task: Any = None) -> bool:
if not grants:
return False
sandbox_mode, allow_network, workspace_class = self._grant_context(task)
for grant_tool, grant_candidate, grant_sandbox_mode, grant_allow_network, grant_workspace_class in grants:
if grant_tool != tool_name:
continue
if not self._candidate_matches(candidate, grant_candidate):
continue
if grant_sandbox_mode not in {_ANY_GRANT_VALUE, sandbox_mode}:
if not (
self.config.guardian.cache_upgrade_context
and self._sandbox_rank(sandbox_mode) >= self._sandbox_rank(grant_sandbox_mode)
):
continue
if grant_allow_network not in {_ANY_GRANT_VALUE, allow_network}:
continue
if grant_workspace_class not in {_ANY_GRANT_VALUE, workspace_class}:
continue
return True
return False
def _denial_memory_key(self, tool_name: str, arguments: dict[str, Any] | None) -> str:
return f"{tool_name}:{self._candidate(arguments)}"
def record_denial(self, tool_name: str, arguments: dict[str, Any] | None) -> None:
if not self.config.denial_memory.enabled:
return
key = self._denial_memory_key(tool_name, arguments)
self._denial_counts[key] = self._denial_counts.get(key, 0) + 1
def _repeat_denial_decision(self, tool_name: str, arguments: dict[str, Any] | None) -> RuntimePermissionDecision | None:
if not self.config.denial_memory.enabled:
return None
key = self._denial_memory_key(tool_name, arguments)
repeats = self._denial_counts.get(key, 0)
if repeats < max(1, self.config.denial_memory.repeat_threshold):
return None
return RuntimePermissionDecision(
resolution=PermissionResolution.DENY,
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.HIGH,
rationale="Repeated denial memory indicates this action should stop and ask for a new plan.",
source="denial_memory",
metadata={"repeated_denials": repeats},
risk_level=RiskLevel.LOW,
rationale="No permission policy configured.",
source="runtime_prediction",
)
def record_denial(self, tool_name: str, arguments: dict[str, Any] | None = None) -> None:
if self.policy is not None and hasattr(self.policy, "record_denial"):
try:
self.policy.record_denial(tool_name, arguments)
except Exception:
logger.opt(exception=True).debug("Failed to record permission denial")
def build_blocked_result(
self,
decision: RuntimePermissionDecision,
@@ -556,7 +121,7 @@ class ToolPermissionResolver:
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
action = "reject" if decision.resolution == PermissionResolution.DENY else "require_input"
candidate = self._candidate(arguments)
candidate = _candidate(arguments)
return {
"error": decision.rationale or f"Runtime permission blocked `{tool_name}`.",
"success": False,
@@ -578,188 +143,24 @@ class ToolPermissionResolver:
},
}
def predicted_decision(
self,
tool: ToolDefinition | None,
arguments: dict[str, Any] | None = None,
*,
task: Any = None,
) -> RuntimePermissionDecision:
if tool is None:
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK if self.config.fail_closed else PermissionResolution.DENY,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.HIGH,
rationale="Unknown tool requires manual review.",
source="runtime_prediction",
)
tool_name = tool.name
candidate = self._candidate(arguments)
repeated_denial = self._repeat_denial_decision(tool_name, arguments)
if repeated_denial is not None:
return repeated_denial
if tool_name in self._normalized_tool_set(self.config.deny_tools):
return RuntimePermissionDecision(
resolution=PermissionResolution.DENY,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.HIGH,
rationale="Tool is explicitly denied by runtime permission rules.",
source="permission_rules",
)
if self._match_grant(self._session_grants, tool_name, candidate, task=task):
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.SESSION,
risk_level=RiskLevel.LOW,
rationale="Allowed by runtime session grant.",
source="runtime_session_grant",
)
if self._match_grant(self._project_grants, tool_name, candidate, task=task):
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.PROJECT,
risk_level=RiskLevel.LOW,
rationale="Allowed by persisted project grant.",
source="runtime_project_grant",
)
if self._match_grant(self._global_grants, tool_name, candidate, task=task):
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.GLOBAL,
risk_level=RiskLevel.LOW,
rationale="Allowed by persisted global grant.",
source="runtime_global_grant",
)
if tool_name in self._normalized_tool_set(self.config.allow_tools):
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.PROJECT,
risk_level=RiskLevel.LOW,
rationale="Tool is explicitly allowed by runtime permission rules.",
source="permission_rules",
)
path_decision = self._path_decision(tool, arguments, task=task)
if path_decision is not None:
return path_decision
shell_decision = self._shell_decision(tool, arguments, task=task)
if shell_decision is not None:
return shell_decision
if tool.requires_confirmation:
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.MEDIUM,
rationale="Tool is marked as requiring confirmation.",
source="runtime_prediction",
metadata={"candidate": candidate},
)
if self.config.guardian.enabled and self.config.guardian.auto_allow_read_only and tool.read_only:
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.LOW,
rationale="Guardian pre-check marked the tool as deterministic read-only.",
source="guardian",
metadata={"candidate": candidate},
)
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.ONCE,
risk_level=RiskLevel.LOW,
rationale="No runtime permission warning triggered.",
source="runtime_prediction",
metadata={"candidate": candidate},
)
async def refine_decision(
self,
decision: RuntimePermissionDecision,
*,
tool: ToolDefinition | None,
arguments: dict[str, Any] | None,
task: Any = None,
) -> RuntimePermissionDecision:
if decision.resolution != PermissionResolution.ASK:
return decision
if not self.config.classifier_enabled or not self.config.llm_classifier_model or self.llm is None or tool is None:
return decision
payload = {
"tool_name": tool.name,
"arguments": arguments or {},
"candidate": self._candidate(arguments),
"project_id": getattr(task, "project_id", self.project_id),
"heuristic_rationale": decision.rationale,
}
def _validate_classifier(parsed: Any) -> str | None:
if not isinstance(parsed, dict):
return "Top-level response must be a JSON object."
try:
score_val = float(parsed.get("score", 0.5) or 0.5)
except (TypeError, ValueError):
return "`score` must be a number between 0 and 1."
if score_val < 0 or score_val > 1:
return "`score` must be a number between 0 and 1."
return None
try:
parsed = await call_llm_json_with_retry(
self.llm,
system=(
"You are a runtime permission classifier.\n"
"Return strict JSON with keys `score` and `reason`.\n"
"`score` is a float between 0 and 1 where 0 is clearly safe and 1 is clearly unsafe.\n"
"Classify file mutation, shell execution, path escape risk, and network side effects conservatively."
),
payload=payload,
task_type="quick_tasks",
validator=_validate_classifier,
label="runtime_permission_classifier",
)
except LLMRetryError:
return decision
score = float(parsed.get("score", 0.5) or 0.5)
reason = str(parsed.get("reason", "") or decision.rationale)
thresholds = self.config.classifier_thresholds
if score <= thresholds.allow:
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=decision.scope,
risk_level=RiskLevel.LOW,
rationale=reason or "Classifier marked the action safe.",
source="llm_classifier",
metadata={**dict(decision.metadata or {}), "classifier_score": score},
)
if score >= thresholds.deny:
return RuntimePermissionDecision(
resolution=PermissionResolution.DENY,
scope=decision.scope,
risk_level=RiskLevel.HIGH,
rationale=reason or "Classifier marked the action unsafe.",
source="llm_classifier",
metadata={**dict(decision.metadata or {}), "classifier_score": score},
)
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=decision.scope,
risk_level=RiskLevel.MEDIUM if score < thresholds.ask else RiskLevel.HIGH,
rationale=reason or decision.rationale,
source="llm_classifier",
metadata={**dict(decision.metadata or {}), "classifier_score": score},
)
def decision_from_result(
self,
tool_name: str,
arguments: dict[str, Any] | None,
result: dict[str, Any],
) -> RuntimePermissionDecision:
"""Map a tool result back to the permission decision it reflects.
Pure event classification — grants are persisted by ApprovalEngine at
decision time, never here.
"""
approval = dict(result.get("approval", {}) or {})
action = str(approval.get("action", "") or "").strip().lower()
if action in {"require_input", "escalate"}:
return RuntimePermissionDecision(
resolution=PermissionResolution.ASK,
scope=PermissionScope.ONCE,
risk_level=self._risk(approval.get("risk_level"), RiskLevel.MEDIUM),
risk_level=_risk(approval.get("risk_level"), RiskLevel.MEDIUM),
rationale=str(result.get("error", "") or "Awaiting explicit permission."),
source="approval_engine",
metadata=approval,
@@ -769,53 +170,20 @@ class ToolPermissionResolver:
return RuntimePermissionDecision(
resolution=PermissionResolution.DENY,
scope=PermissionScope.ONCE,
risk_level=self._risk(approval.get("risk_level"), RiskLevel.HIGH),
risk_level=_risk(approval.get("risk_level"), RiskLevel.HIGH),
rationale=str(result.get("error", "") or "Permission denied."),
source="approval_engine",
metadata=approval,
)
human_reply = str(approval.get("human_reply") or result.get("human_reply") or "").strip().lower()
candidate = self._candidate(arguments)
grant = self._grant_key(
tool_name,
candidate,
sandbox_mode=_ANY_GRANT_VALUE,
allow_network=_ANY_GRANT_VALUE,
workspace_class=_ANY_GRANT_VALUE,
)
if human_reply == "approve_session":
self._session_grants.add(grant)
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.SESSION,
risk_level=RiskLevel.LOW,
rationale="Approved for this runtime session.",
source="human_escalation",
metadata=approval,
)
if human_reply == "always_project":
self._project_grants.add(grant)
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.PROJECT,
risk_level=RiskLevel.LOW,
rationale="Approved for this project.",
source="human_escalation",
metadata=approval,
)
if human_reply == "always_global":
self._global_grants.add(grant)
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.GLOBAL,
risk_level=RiskLevel.LOW,
rationale="Approved globally.",
source="human_escalation",
metadata=approval,
)
scope = {
"approve_session": PermissionScope.SESSION,
"always_project": PermissionScope.PROJECT,
"always_global": PermissionScope.GLOBAL,
}.get(human_reply, PermissionScope.ONCE)
return RuntimePermissionDecision(
resolution=PermissionResolution.ALLOW,
scope=PermissionScope.ONCE,
scope=scope,
risk_level=RiskLevel.LOW,
rationale="Tool execution allowed.",
source="approval_engine",
+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:
@@ -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", {})