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
+193 -20
View File
@@ -40,6 +40,10 @@ from opc.llm.retry import LLMRetryError, call_llm_json_with_retry
_SHELL_CONTROL_TOKENS = {"&&", "||", ";", "|", "&"}
# Redirections that cannot write to a real file: fd duplication (2>&1, >&2)
# and discarding output into /dev/null. Everything else keeps counting as a
# redirection for the safe-prefix check.
_SAFE_REDIRECTION_RE = re.compile(r"(?:\d?>>?\s*/dev/null\b|\d?>&\d|&>>?\s*/dev/null\b)")
_LOW_RISK_SHELL_PREFIXES = set(ACQUISITION_SHELL_PREFIXES)
_EXTERNAL_AGENT_DIRECT_HUMAN_MARKERS = (
"--dangerously-bypass-approvals-and-sandbox",
@@ -495,7 +499,11 @@ class ApprovalEngine:
metadata=metadata,
)
if tool_requires_allowlist:
if tool_requires_allowlist and heuristic.risk_level != RiskLevel.LOW:
# First-use approval exists to catch unfamiliar, potentially risky
# actions. Actions the heuristic already classified LOW (read-only
# safe-prefix shell commands, clean tool arguments) proceed without
# a card; MEDIUM and above still require the human gate.
decision = self._force_first_use_approval(heuristic)
elif external_direct_prompt_reason:
decision = ApprovalDecision(
@@ -666,7 +674,20 @@ class ApprovalEngine:
session_scope_id = self._approval_session_scope_id(task)
if not session_scope_id:
return None
scope = self._session_allowlist.get(session_scope_id, {})
scope = self._session_allowlist.get(session_scope_id)
if scope is None:
# Hydrate from the persisted allowlist so "Allow for this session"
# grants survive `opc ui` restarts and re-entering the session.
scope = {}
if self.allowlist:
try:
scope = self.allowlist.session_scope(session_scope_id)
except Exception:
logger.opt(exception=True).debug(
"Failed to hydrate persisted session allowlist; using empty scope"
)
scope = {}
self._session_allowlist[session_scope_id] = scope
patterns = ApprovalAllowlistManager._scope_patterns(scope, action_kind, action_name)
if not patterns:
return None
@@ -716,7 +737,21 @@ class ApprovalEngine:
action_name: str,
patterns: list[str],
) -> list[str]:
session_scope_id = self._approval_session_scope_id(task)
return self._add_session_patterns_by_scope(
session_scope_id=self._approval_session_scope_id(task),
action_kind=action_kind,
action_name=action_name,
patterns=patterns,
)
def _add_session_patterns_by_scope(
self,
*,
session_scope_id: str,
action_kind: str,
action_name: str,
patterns: list[str],
) -> list[str]:
if not session_scope_id:
return []
normalized_patterns = ApprovalAllowlistManager._normalize_pattern_list(patterns)
@@ -735,6 +770,13 @@ class ApprovalEngine:
existing.append(pattern)
added.append(pattern)
action_bucket[action_name] = existing
if added and self.allowlist:
try:
self.allowlist.add_session_patterns(session_scope_id, action_kind, action_name, added)
except Exception:
logger.opt(exception=True).debug(
"Failed to persist session allowlist patterns; grant remains in-memory only"
)
return added
def _tool_requires_first_use_approval(
@@ -875,29 +917,48 @@ class ApprovalEngine:
text = str(command or "")
if "$(" in text or "`" in text:
return True
# ``eval`` / ``source`` let a "safe" prefix execute an arbitrary follow-up arg.
tokens = text.split()
if tokens and tokens[0] in {"eval", "source", "."}:
return True
return any(tok in {"eval", "source"} for tok in tokens)
# ``eval`` / ``source`` let a "safe" prefix execute an arbitrary follow-up
# arg, but only when they are the command itself. As ordinary arguments
# (``grep source config.py``) they are inert; flagging them there only
# produces false approval prompts.
for tokens in self._split_shell_command_segments(text):
if tokens and tokens[0] in {"eval", "source", "."}:
return True
return False
def _command_matches_safe_prefix(self, command: str, prefixes: list[str]) -> bool:
cleaned = " ".join(str(command or "").split()).strip()
if not cleaned or self._command_has_redirection(cleaned):
if not cleaned:
return False
if self._command_has_shell_substitution(cleaned):
# Discarding stderr or duplicating fds writes nothing, and agents
# habitually append `2>&1` / `2>/dev/null` to read-only commands; strip
# those before the redirection check so they alone do not disqualify a
# command. Anything else touching `>`/`>>`/`<` still fails.
sanitized = _SAFE_REDIRECTION_RE.sub(" ", cleaned)
if self._command_has_redirection(sanitized):
return False
commands, command_prefixes = self._extract_shell_command_targets(cleaned)
if len(commands) != 1 or len(command_prefixes) != 1:
if self._command_has_shell_substitution(sanitized):
return False
prefix = command_prefixes[0].casefold()
for item in prefixes:
candidate = str(item or "").strip().casefold()
if not candidate:
continue
if prefix == candidate or prefix.startswith(f"{candidate} "):
return True
return False
commands, command_prefixes = self._extract_shell_command_targets(sanitized)
if not commands or not command_prefixes:
return False
candidates = [
candidate
for candidate in (str(item or "").strip().casefold() for item in prefixes)
if candidate
]
if not candidates:
return False
# A compound command (`ls a && echo --- && ls b`, `git status | head`)
# qualifies only when every segment independently matches a safe prefix.
for prefix in command_prefixes:
normalized_prefix = prefix.casefold()
if not any(
normalized_prefix == candidate or normalized_prefix.startswith(f"{candidate} ")
for candidate in candidates
):
return False
return True
def _is_low_risk_shell_first_use_exempt(self, action_name: str, metadata: dict[str, Any]) -> bool:
if action_name != "shell_exec":
@@ -1503,11 +1564,25 @@ class ApprovalEngine:
{"id": "always_project", "label": "Always allow for this project"},
{"id": "always_global", "label": "Always allow globally"},
])
approval_context = {
"action_kind": action_kind,
"action_name": action_name,
"project_id": str(task.project_id or "") if task else "",
"session_scope_id": self._approval_session_scope_id(task),
"allowlist_enabled": allowlist_enabled,
"allowlist_patterns": list(allowlist_patterns),
"candidates": self._build_allowlist_candidates(
action_kind=action_kind,
action_name=action_name,
metadata=metadata,
),
}
reply = await self.escalation.escalate_decision(
task,
question,
options,
default_action=None,
context=approval_context,
)
if reply is None:
return False, ApprovalDecision(
@@ -1574,6 +1649,104 @@ class ApprovalEngine:
metadata=result_metadata,
)
def apply_deferred_escalation_decision(
self,
reply: str,
context: dict[str, Any],
) -> dict[str, Any]:
"""Apply a decision clicked on an approval card after its inline wait
expired (the blocked task has parked on AWAITING_HUMAN by then).
Persists the same allowlist grant the live path would have applied, so
the re-run of the blocked action passes automatically. ``context`` is
the ``approval_context`` the card was created with. Returns a summary
{approved, scope, patterns} for UI messaging.
"""
normalized_reply = str(reply or "").strip()
context = dict(context or {})
action_kind = str(context.get("action_kind", "") or "").strip()
action_name = str(context.get("action_name", "") or "").strip()
project_id = str(context.get("project_id", "") or "").strip() or None
session_scope_id = str(context.get("session_scope_id", "") or "").strip()
allowlist_enabled = bool(context.get("allowlist_enabled", False))
allowlist_patterns = [
str(item).strip() for item in list(context.get("allowlist_patterns", []) or [])
if str(item).strip()
]
exact_candidates = [
str(item).strip() for item in list(context.get("candidates", []) or [])
if str(item).strip()
]
if not allowlist_enabled and normalized_reply in {"approve_session", "always_project", "always_global"}:
normalized_reply = "approve_once"
approved = normalized_reply in {"approve_once", "approve_session", "always_project", "always_global"}
saved_patterns: list[str] = []
scope: str | None = None
if normalized_reply == "approve_session" and session_scope_id and allowlist_patterns:
saved_patterns = self._add_session_patterns_by_scope(
session_scope_id=session_scope_id,
action_kind=action_kind,
action_name=action_name,
patterns=allowlist_patterns,
)
scope = f"session:{session_scope_id}"
elif normalized_reply == "approve_once" and session_scope_id:
# No one-shot grant store exists; the narrowest durable equivalent
# is a session grant for the exact blocked command(s), so the
# resumed run passes without widening approval to the whole family.
once_patterns = exact_candidates or allowlist_patterns
if once_patterns:
saved_patterns = self._add_session_patterns_by_scope(
session_scope_id=session_scope_id,
action_kind=action_kind,
action_name=action_name,
patterns=once_patterns,
)
scope = f"session:{session_scope_id}"
elif normalized_reply == "always_project" and self.allowlist and project_id and allowlist_patterns:
saved_patterns = self.allowlist.add_patterns(
action_kind=action_kind,
action_name=action_name,
patterns=allowlist_patterns,
project_id=project_id,
)
scope = f"project:{project_id}"
elif normalized_reply == "always_global" and self.allowlist and allowlist_patterns:
saved_patterns = self.allowlist.add_patterns(
action_kind=action_kind,
action_name=action_name,
patterns=allowlist_patterns,
project_id=None,
)
scope = "global"
if action_name:
try:
self.preferences.record_autonomy_feedback(
action_name=action_name,
approved=approved,
project_id=project_id if normalized_reply == "always_project" else None,
explicit=normalized_reply in {"approve_session", "always_project", "always_global"},
notes=(
"User approved via deferred escalation card."
if approved
else "User denied via deferred escalation card."
),
)
except Exception:
logger.opt(exception=True).debug(
"Failed to record autonomy feedback for deferred escalation decision"
)
return {
"approved": approved,
"reply": normalized_reply,
"scope": scope,
"patterns": saved_patterns,
"action_name": action_name,
}
async def _record(
self,
task: Task | None,
+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: