fix(approval): reduce prompt friction and make approval cards answerable forever
Approval friction (harmless commands kept prompting): - Persist "Allow for this session" grants to approval_allowlist.yaml under a new sessions scope (capped LRU), hydrated lazily, so they survive `opc ui` restarts and re-entering the session instead of living only in memory. - Safe-prefix matching now accepts compound read-only commands: every segment must match a safe prefix, and fd-duplication / /dev/null redirections (2>&1, 2>/dev/null) no longer disqualify a command; real write redirections (>, >>, <) still do. Default safe prefixes gain common read-only commands (cd, cat, head, grep, git log, ...). - First-use approval now gates only MEDIUM+ risk; heuristically LOW actions proceed without a card. - Shell-substitution detection flags eval/source only at command position of a segment (no more false positives on `grep source file`); $(...) and backticks still flag anywhere. Approval card timeout redesign (deferred decisions): - The card's structured approval context (action, allowlist patterns, scopes) now travels through the escalation event into the persisted card metadata. - Timeout without a default action no longer marks the card timed out, and the session-detail reconciler no longer stales deferred-capable cards: the card stays pending and clickable indefinitely, including across restarts. - Clicking after the inline wait expired applies the allowlist grant (approve-once grants the exact command at session scope), resolves the card, and rewrites the reply to target the parked AWAITING_HUMAN checkpoint so the task resumes through the normal message pipeline and the retried command auto-approves. With no parked checkpoint the grant still lands and a helper reply explains the state. Verified: approval engine suite (40) incl. new deferred-decision and compound-command tests, ws_handler + runtime suites green, real escalated commands from project 999 replayed against the user's config now auto-approve while pip install / $(...) / rm -rf / write redirects still prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ def _empty_payload() -> dict[str, Any]:
|
||||
"version": 1,
|
||||
"global": _empty_scope(),
|
||||
"projects": {},
|
||||
"sessions": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +112,58 @@ class ApprovalAllowlistManager:
|
||||
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:
|
||||
@@ -180,6 +233,14 @@ class ApprovalAllowlistManager:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user