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
+7
View File
@@ -37,10 +37,14 @@ class EscalationEngine:
message: str,
options: list[dict[str, str]] | None = None,
default_action: str | None = None,
context: dict[str, Any] | None = None,
) -> str | None:
"""Escalate to the user and wait for a reply.
Returns the user's reply or the default action on timeout.
``context`` carries structured approval data (action, allowlist
patterns, scopes) into the UI card so a decision can still be applied
after this inline wait has expired.
"""
# Use a unique escalation id per prompt so repeated approvals for the
# same task do not alias to older UI cards or stale pending state.
@@ -55,6 +59,7 @@ class EscalationEngine:
"message": message,
"options": options or [],
"default_action": default_action,
"approval_context": dict(context or {}),
},
))
@@ -97,6 +102,7 @@ class EscalationEngine:
question: str,
options: list[dict[str, str]],
default_action: str | None = None,
context: dict[str, Any] | None = None,
) -> str | None:
metadata = dict(getattr(task, "metadata", {}) or {})
execution_mode = str(metadata.get("execution_mode", "") or "").strip()
@@ -118,6 +124,7 @@ class EscalationEngine:
message=f"[DECISION NEEDED] Task: {task_label}\n{question}",
options=options,
default_action=default_action,
context=context,
)
async def escalate_risk(self, task: Task, risk_description: str) -> str | None: