Files
LZH-YS1998 4b29b89371 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>
2026-07-08 18:43:27 +08:00

344 lines
12 KiB
Python

"""YAML-backed allowlist for persisted tool and command approvals."""
from __future__ import annotations
from copy import deepcopy
from fnmatch import fnmatchcase
from pathlib import Path
from typing import Any
import yaml
def _empty_scope() -> dict[str, dict[str, list[str]]]:
return {
"tool": {},
"external_agent": {},
"work_item_projection_title": {},
}
def _empty_payload() -> dict[str, Any]:
return {
"version": 1,
"global": _empty_scope(),
"projects": {},
"sessions": {},
}
class ApprovalAllowlistManager:
"""Persists reusable approval rules in a user-editable YAML file."""
def __init__(self, opc_home: str | Path) -> None:
self.opc_home = Path(opc_home)
self.path = self.opc_home / "config" / "approval_allowlist.yaml"
self._cache: dict[str, Any] | None = None
self._cache_mtime_ns: int = -1
def ensure_file(self) -> None:
if not self.path.exists():
self.save(_empty_payload())
def load(self) -> dict[str, Any]:
# The permission predictor consults the allowlist on every tool call;
# cache by mtime so repeated loads do not re-read and re-parse the
# YAML. External edits to the file are picked up via the mtime change.
try:
mtime_ns = self.path.stat().st_mtime_ns
except OSError:
self._cache = None
self._cache_mtime_ns = -1
return _empty_payload()
if self._cache is not None and mtime_ns == self._cache_mtime_ns:
return deepcopy(self._cache)
try:
raw = yaml.safe_load(self.path.read_text(encoding="utf-8")) or {}
except Exception:
return _empty_payload()
normalized = self._normalize_payload(raw)
self._cache = deepcopy(normalized)
self._cache_mtime_ns = mtime_ns
return normalized
def save(self, payload: dict[str, Any]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
normalized = self._normalize_payload(payload)
self.path.write_text(
yaml.safe_dump(
normalized,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
),
encoding="utf-8",
)
try:
self._cache = deepcopy(normalized)
self._cache_mtime_ns = self.path.stat().st_mtime_ns
except OSError:
self._cache = None
self._cache_mtime_ns = -1
def list_patterns(
self,
action_kind: str,
action_name: str,
project_id: str | None = None,
) -> list[str]:
payload = self.load()
patterns: list[str] = []
if project_id:
patterns.extend(self._scope_patterns(payload["projects"].get(project_id, {}), action_kind, action_name))
patterns.extend(self._scope_patterns(payload["global"], action_kind, action_name))
return list(dict.fromkeys(patterns))
def add_patterns(
self,
action_kind: str,
action_name: str,
patterns: list[str],
project_id: str | None = None,
) -> list[str]:
normalized_patterns = [
self._normalize_pattern(pattern)
for pattern in patterns
if self._normalize_pattern(pattern)
]
if not normalized_patterns:
return []
payload = self.load()
scope = payload["global"]
if project_id:
scope = payload["projects"].setdefault(project_id, _empty_scope())
scope = self._normalize_scope(scope)
payload["projects"][project_id] = scope
action_bucket = scope.setdefault(action_kind, {})
existing = [
self._normalize_pattern(pattern)
for pattern in action_bucket.get(action_name, [])
if self._normalize_pattern(pattern)
]
added: list[str] = []
for pattern in normalized_patterns:
if pattern in existing:
continue
existing.append(pattern)
added.append(pattern)
action_bucket[action_name] = existing
if added:
self.save(payload)
return added
# "Allow for this session" grants used to live only in ApprovalEngine
# memory, so a `opc ui` restart or re-entering the session re-prompted for
# commands the user had already approved. They are now persisted here,
# keyed by the session scope id, capped to the most recent entries.
_MAX_SESSION_SCOPES = 200
def session_scope(self, session_id: str) -> dict[str, dict[str, list[str]]]:
key = str(session_id or "").strip()
if not key:
return _empty_scope()
payload = self.load()
return self._normalize_scope(payload["sessions"].get(key, {}))
def add_session_patterns(
self,
session_id: str,
action_kind: str,
action_name: str,
patterns: list[str],
) -> list[str]:
key = str(session_id or "").strip()
normalized_patterns = [
self._normalize_pattern(pattern)
for pattern in patterns
if self._normalize_pattern(pattern)
]
if not key or not normalized_patterns:
return []
payload = self.load()
sessions = payload["sessions"]
# Re-inserting moves an active session to the newest position so the
# recency cap below always evicts the longest-idle session first.
scope = self._normalize_scope(sessions.pop(key, {}))
sessions[key] = scope
action_bucket = scope.setdefault(action_kind, {})
existing = self._normalize_pattern_list(action_bucket.get(action_name, []))
added: list[str] = []
for pattern in normalized_patterns:
if pattern in existing:
continue
existing.append(pattern)
added.append(pattern)
action_bucket[action_name] = existing
while len(sessions) > self._MAX_SESSION_SCOPES:
sessions.pop(next(iter(sessions)))
if added:
self.save(payload)
return added
def reset(self, project_id: str | None = None) -> None:
payload = self.load()
if project_id:
payload["projects"].pop(project_id, None)
else:
payload["global"] = _empty_scope()
self.save(payload)
def is_allowed(
self,
action_kind: str,
action_name: str,
candidates: list[str],
project_id: str | None = None,
) -> tuple[bool, list[str], str | None]:
normalized_candidates = [
self._normalize_candidate(candidate)
for candidate in candidates
if self._normalize_candidate(candidate)
]
if not normalized_candidates:
return False, [], None
payload = self.load()
scopes: list[tuple[str | None, dict[str, Any]]] = []
if project_id:
scopes.append((project_id, payload["projects"].get(project_id, {})))
scopes.append((None, payload["global"]))
for scope_id, scope in scopes:
patterns = self._scope_patterns(scope, action_kind, action_name)
matched: list[str] = []
all_matched = True
for candidate in normalized_candidates:
candidate_patterns = [
pattern
for pattern in patterns
if self._matches(pattern, candidate)
]
if not candidate_patterns:
all_matched = False
break
matched.extend(candidate_patterns)
if all_matched:
return True, list(dict.fromkeys(matched)), scope_id
return False, [], None
def summarize(self, project_id: str | None = None, limit: int = 20) -> list[str]:
payload = self.load()
lines: list[str] = []
if project_id:
lines.extend(self._summarize_scope(payload["projects"].get(project_id, {}), scope_label=f"project:{project_id}"))
lines.extend(self._summarize_scope(payload["global"], scope_label="global"))
return lines[:limit]
@staticmethod
def _normalize_payload(payload: Any) -> dict[str, Any]:
data = deepcopy(payload) if isinstance(payload, dict) else {}
normalized = _empty_payload()
normalized["version"] = int(data.get("version", 1) or 1)
normalized["global"] = ApprovalAllowlistManager._normalize_scope(data.get("global", {}))
projects = data.get("projects", {})
if isinstance(projects, dict):
for project_id, scope in projects.items():
key = str(project_id).strip()
if not key:
continue
normalized["projects"][key] = ApprovalAllowlistManager._normalize_scope(scope)
sessions = data.get("sessions", {})
if isinstance(sessions, dict):
for session_id, scope in sessions.items():
key = str(session_id).strip()
if not key:
continue
normalized["sessions"][key] = ApprovalAllowlistManager._normalize_scope(scope)
return normalized
@staticmethod
def _normalize_scope(scope: Any) -> dict[str, dict[str, list[str]]]:
normalized = _empty_scope()
if not isinstance(scope, dict):
return normalized
for action_kind, entries in scope.items():
kind = str(action_kind).strip()
if not kind:
continue
bucket: dict[str, list[str]] = {}
if isinstance(entries, dict):
for action_name, patterns in entries.items():
name = str(action_name).strip()
if not name:
continue
bucket[name] = ApprovalAllowlistManager._normalize_pattern_list(patterns)
normalized[kind] = bucket
return normalized
@staticmethod
def _normalize_pattern_list(patterns: Any) -> list[str]:
if isinstance(patterns, str):
pattern_list = [patterns]
elif isinstance(patterns, list):
pattern_list = patterns
else:
pattern_list = []
result: list[str] = []
for pattern in pattern_list:
normalized = ApprovalAllowlistManager._normalize_pattern(pattern)
if normalized:
result.append(normalized)
return list(dict.fromkeys(result))
@staticmethod
def _normalize_pattern(pattern: Any) -> str:
return " ".join(str(pattern).strip().split())
@staticmethod
def _normalize_candidate(candidate: Any) -> str:
return " ".join(str(candidate).strip().split()).casefold()
@staticmethod
def _scope_patterns(scope: Any, action_kind: str, action_name: str) -> list[str]:
if not isinstance(scope, dict):
return []
entries = scope.get(action_kind, {})
if not isinstance(entries, dict):
return []
return ApprovalAllowlistManager._normalize_pattern_list(entries.get(action_name, []))
@staticmethod
def _matches(pattern: str, candidate: str) -> bool:
normalized_pattern = ApprovalAllowlistManager._normalize_candidate(pattern)
if not normalized_pattern or normalized_pattern == "*":
return True
if any(token in normalized_pattern for token in "*?[]"):
return fnmatchcase(candidate, normalized_pattern)
return candidate == normalized_pattern or candidate.startswith(normalized_pattern + " ")
@staticmethod
def _summarize_scope(scope: Any, *, scope_label: str) -> list[str]:
if not isinstance(scope, dict):
return []
lines: list[str] = []
for action_kind in sorted(scope.keys()):
entries = scope.get(action_kind, {})
if not isinstance(entries, dict):
continue
for action_name in sorted(entries.keys()):
patterns = ApprovalAllowlistManager._normalize_pattern_list(entries.get(action_name, []))
if not patterns:
continue
joined = ", ".join(patterns[:4])
if len(patterns) > 4:
joined += ", ..."
lines.append(f"- [{scope_label}] {action_kind}:{action_name} -> {joined}")
return lines