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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user