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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user