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:
LZH-YS1998
2026-07-07 22:19:22 +08:00
parent e1c28c3889
commit c901800062
6 changed files with 682 additions and 60 deletions
+61
View File
@@ -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