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:
@@ -33,19 +33,33 @@ class ApprovalAllowlistManager:
|
||||
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]:
|
||||
if not self.path.exists():
|
||||
# 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()
|
||||
return self._normalize_payload(raw)
|
||||
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)
|
||||
@@ -59,6 +73,12 @@ class ApprovalAllowlistManager:
|
||||
),
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user