Files
OpenOPC/opc/layer2_organization/approval.py
T
LZH-YS1998 4b29b89371 refactor: unify tool approval into a single engine and cut prompt storms
Collapse the dual permission stack into one policy. The runtime-side
ToolPermissionResolver (own safe lists, own grant memory, bypassed the
ApprovalEngine whenever it said ALLOW) is deleted; runtime_v2 now consults
ApprovalEngine.predict(), a synchronous fast path reading the same config
and the same persisted allowlist as the async authorize pipeline, so a
grant given anywhere is honored everywhere. permissions.py keeps only a
policy-free adapter; the duplicated permissions_v2 config fields and the
runtime grant persistence loop are removed (stale YAML keys are ignored).

New shell_safety module becomes the single source of truth for shell
classification: flag-audited read-only commands (awk/od/jq/sed -n/diff/
git subcommand table/... auto-allow; find -delete, sort -o, curl -o/-d,
rg --pre still prompt even when the bare name is config-listed),
keyword-aware compound splitting (loop/branch headers no longer poison
grants), expansion-safe $() handling, and fail-closed treatment of
anything unparseable or substitution-bearing.

Grant semantics are rebuilt around derived word-boundary prefixes:
"python3 -c" instead of token bags, interpreter -c/-m kept in the prefix,
bash/eval/sudo never grantable as prefixes, read-only segments exempt
from the every-candidate-must-match rule so a granted command chained
with ls/echo verification passes, and approve-once now records the exact
candidates as a session grant so identical re-runs stop re-prompting.
The authorize heuristic also audits the original command text instead of
the quote-dropping preview (echo "<EOF>" no longer reads as redirection).

Validated live on zz_perm_probe1 (native minimal org): awk/od/ls/cat/
sha256sum ran with zero cards, python3 -c parked once and three different
python3 -c commands then passed via the persisted prefix grant, and an
agent-issued rm -f compound correctly re-prompted showing only the
segments needing approval. Full suite failures are byte-identical to the
pre-change HEAD baseline (27 pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:43:27 +08:00

2138 lines
87 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 import shell_safety
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
_LOW_RISK_SHELL_PREFIXES = set(ACQUISITION_SHELL_PREFIXES)
_SHELL_LIKE_TOOL_NAMES = {"shell_exec", "python_exec", "git_commit"}
_PREDICT_PATH_KEYS = (
"path",
"file_path",
"directory",
"working_directory",
"target_output_dir",
"workspace_path",
)
_PREDICT_COMMAND_KEYS = ("command", "cmd")
_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]]]] = {}
self._denial_counts: dict[str, int] = {}
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
# ------------------------------------------------------------------
# Synchronous permission prediction (runtime fast path)
#
# The native runtime consults predict() before every tool call: ALLOW
# executes immediately, DENY blocks, ASK routes into the full async
# authorize_tool_call() pipeline (allowlist, heuristics, LLM review,
# escalation card). predict() reads the same config and the same
# persisted allowlist as authorize, so there is exactly one policy.
# ------------------------------------------------------------------
def predict(
self,
tool: Any,
arguments: dict[str, Any] | None = None,
*,
task: Task | None = None,
) -> RuntimePermissionDecision:
p2 = self.config.permissions_v2
if tool is None:
return self._predict_decision(
PermissionResolution.ASK if p2.fail_closed else PermissionResolution.DENY,
RiskLevel.HIGH,
"Unknown tool requires manual review.",
source="runtime_prediction",
)
if not self.config.enabled or not p2.enabled:
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Autonomy policy is disabled.", source="config",
)
tool_name = str(getattr(tool, "name", "") or "")
args = dict(arguments or {})
repeated = self._repeated_denial_decision(tool_name, args)
if repeated is not None:
return repeated
if tool_name in {str(item or "").strip() for item in p2.deny_tools if str(item or "").strip()}:
return self._predict_decision(
PermissionResolution.DENY, RiskLevel.HIGH,
"Tool is explicitly denied by permission rules.", source="permission_rules",
)
if tool_name in COMPANY_APPROVAL_EXEMPT_TOOL_NAMES:
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Built-in company collaboration tool is always auto-approved.",
source="company_tool_policy",
)
if self._memory_path_decision("tool", tool_name, {"arguments": args}):
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Direct agent access to canonical OpenOPC memory files.",
source="memory_path_policy",
)
if tool_name in {str(item or "").strip() for item in p2.allow_tools if str(item or "").strip()}:
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Tool is explicitly allowed by permission rules.", source="permission_rules",
)
# Persisted human grants win before path/shell heuristics, matching
# the order of the async authorize pipeline. Unauditable commands
# cannot ride through: their candidates degrade to the exact string.
metadata = {"arguments": args}
session_hit = self._lookup_session_allowlist_policy(
task=task, action_kind="tool", action_name=tool_name, metadata=metadata,
)
if session_hit:
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
f"Allowed by session approval ({session_hit['scope']}).",
source="session_approval", scope=PermissionScope.SESSION,
)
persisted_hit = self._lookup_allowlist_policy(
action_kind="tool", action_name=tool_name, metadata=metadata,
project_id=task.project_id if task else None,
)
if persisted_hit:
scope = PermissionScope.GLOBAL if persisted_hit["scope"] is None else PermissionScope.PROJECT
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Allowed by persisted allowlist grant.",
source="approval_allowlist", scope=scope,
)
path_decision = self._predict_path_decision(tool, args, task)
if path_decision is not None:
return path_decision
if tool_name in _SHELL_LIKE_TOOL_NAMES:
shell_decision = self._predict_shell_decision(tool_name, args, task)
if shell_decision is not None:
return shell_decision
if bool(getattr(tool, "requires_confirmation", False)):
return self._predict_decision(
PermissionResolution.ASK, RiskLevel.MEDIUM,
"Tool is marked as requiring confirmation.", source="runtime_prediction",
)
guardian = p2.guardian
if guardian.enabled and guardian.auto_allow_read_only and bool(getattr(tool, "read_only", False)):
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Deterministic read-only tool.", source="guardian",
)
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"No permission warning triggered.", source="runtime_prediction",
)
def record_denial(self, tool_name: str, arguments: dict[str, Any] | None = None) -> None:
if not self.config.permissions_v2.denial_memory.enabled:
return
key = self._denial_memory_key(tool_name, arguments)
self._denial_counts[key] = self._denial_counts.get(key, 0) + 1
def _denial_memory_key(self, tool_name: str, arguments: dict[str, Any] | None) -> str:
args = dict(arguments or {})
for key in (*_PREDICT_PATH_KEYS, *_PREDICT_COMMAND_KEYS, "url"):
value = str(args.get(key, "") or "").strip()
if value:
return f"{tool_name}:{value}"
return f"{tool_name}:*"
def _repeated_denial_decision(
self, tool_name: str, arguments: dict[str, Any] | None
) -> RuntimePermissionDecision | None:
memory = self.config.permissions_v2.denial_memory
if not memory.enabled:
return None
repeats = self._denial_counts.get(self._denial_memory_key(tool_name, arguments), 0)
if repeats < max(1, memory.repeat_threshold):
return None
return self._predict_decision(
PermissionResolution.DENY, RiskLevel.HIGH,
"Repeated denials indicate this action should stop and ask for a new plan.",
source="denial_memory",
metadata={"repeated_denials": repeats},
)
def _predict_shell_decision(
self,
tool_name: str,
args: dict[str, Any],
task: Task | None,
) -> RuntimePermissionDecision | None:
command = ""
for key in _PREDICT_COMMAND_KEYS:
value = str(args.get(key, "") or "").strip()
if value:
command = value
break
if not command:
return None
for pattern in self.config.permissions_v2.dangerous_shell_patterns:
if pattern and re.search(pattern, command, flags=re.IGNORECASE):
return self._predict_decision(
PermissionResolution.ASK, RiskLevel.CRITICAL,
f"Command matched dangerous shell pattern `{pattern}`.",
source="shell_pattern",
)
if is_projection_scoped_acquisition_shell_command(
command=command,
task=task,
working_directory=str(args.get("working_directory", "") or args.get("workdir", "") or "").strip(),
target_output_dir=str((getattr(task, "metadata", {}) or {}).get("target_output_dir", "") or "").strip() if task else "",
):
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Work-item-scoped acquisition command inside the assigned workspace.",
source="shell_prefix",
)
safe_prefixes = [
item for item in self.config.safe_command_prefixes
if str(item or "").strip() not in _LOW_RISK_SHELL_PREFIXES
]
safe, reason = shell_safety.is_read_only_shell_command(command, safe_prefixes)
if safe:
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
reason, source="shell_read_only",
)
return self._predict_decision(
PermissionResolution.ASK, RiskLevel.MEDIUM,
f"Shell command requires approval review: {reason}",
source="shell_guard",
)
def _predict_path_decision(
self,
tool: Any,
args: dict[str, Any],
task: Task | None,
) -> RuntimePermissionDecision | None:
if not args:
return None
p2 = self.config.permissions_v2
candidate = ""
for key in _PREDICT_PATH_KEYS:
value = str(args.get(key, "") or "").strip()
if value:
candidate = value
break
if not candidate:
return None
if self._matches_path_rule(candidate, p2.denied_paths):
return self._predict_decision(
PermissionResolution.DENY, RiskLevel.HIGH,
"Target path matches a denied permission rule.", source="permission_rules",
)
if self._matches_path_rule(candidate, p2.allowed_paths):
return self._predict_decision(
PermissionResolution.ALLOW, RiskLevel.LOW,
"Target path matches an explicit allow rule.", source="permission_rules",
scope=PermissionScope.PROJECT,
)
if bool(getattr(tool, "read_only", False)):
return None
try:
resolved = Path(candidate).resolve()
except Exception:
return None
for root in self._predict_workspace_roots(task):
if resolved == root or root in resolved.parents:
return None
return self._predict_decision(
PermissionResolution.ASK if p2.fail_closed else PermissionResolution.DENY,
RiskLevel.HIGH,
"Target path is outside the current workspace roots.",
source="path_guard",
metadata={"candidate": candidate},
)
@staticmethod
def _matches_path_rule(candidate: str, rules: list[str]) -> bool:
raw = str(candidate or "").strip()
if not raw or raw == "*":
return False
for rule in rules:
token = str(rule or "").strip()
if not token:
continue
if token == "*" or raw == token:
return True
try:
rule_path = Path(token).resolve()
candidate_path = Path(raw).resolve()
except Exception:
if raw.startswith(token.rstrip("\\/")):
return True
continue
if candidate_path == rule_path or rule_path in candidate_path.parents:
return True
return False
@staticmethod
def _predict_workspace_roots(task: Task | None) -> list[Path]:
roots: list[Path] = []
metadata = getattr(task, "metadata", {}) or {} if task else {}
for raw in (
str(metadata.get("workspace_root", "") or "").strip(),
str(metadata.get("comms_workspace_root", "") or "").strip(),
str(metadata.get("output_root", "") or "").strip(),
str(metadata.get("target_output_dir", "") or "").strip(),
):
if not raw:
continue
try:
path = Path(raw).resolve()
except Exception:
continue
if path not in roots:
roots.append(path)
try:
memory_root = (Path(get_opc_home()) / "memory").resolve()
if memory_root not in roots:
roots.append(memory_root)
except Exception:
pass
if not roots:
try:
roots.append(Path.cwd().resolve())
except Exception:
pass
return roots
@staticmethod
def _predict_decision(
resolution: PermissionResolution,
risk: RiskLevel,
rationale: str,
*,
source: str,
scope: PermissionScope = PermissionScope.ONCE,
metadata: dict[str, Any] | None = None,
) -> RuntimePermissionDecision:
return RuntimePermissionDecision(
resolution=resolution,
scope=scope,
risk_level=risk,
rationale=rationale,
source=source,
metadata=dict(metadata or {}),
)
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._shell_grant_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._shell_grant_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 _shell_grant_targets(self, command: str) -> tuple[list[str], list[str]]:
"""Derive allowlist candidates (full per-segment commands) and grant
patterns (word-boundary prefixes) for a shell command.
Only the segments that actually need approval are returned: read-only
safe segments (`ls` / `echo` / verification `cat`s chained after a
granted command) pass on their own merit and must neither break the
every-candidate-must-match rule nor be persisted as grants.
Fail closed: a command containing substitution we cannot audit, or one
that does not tokenize, is only ever grantable as its exact normalized
string — never as a broad prefix.
"""
raw = " ".join(str(command or "").split()).strip()
if not raw:
return [], []
sanitized, expansions_safe = shell_safety.sanitize_expansions(raw)
sanitized = shell_safety.strip_safe_redirections(sanitized)
segments = shell_safety.split_shell_segments(sanitized) if expansions_safe else None
if not segments:
return [raw], [raw]
safe_prefixes = [
item for item in self.config.safe_command_prefixes
if str(item or "").strip() not in _LOW_RISK_SHELL_PREFIXES
]
commands: list[str] = []
prefixes: list[str] = []
all_commands: list[str] = []
all_prefixes: list[str] = []
for tokens in segments:
full = " ".join(tokens).strip()
if not full:
continue
prefix_tokens = self._shell_command_prefix(tokens)
prefix = " ".join(prefix_tokens).strip()
if prefix_tokens and prefix_tokens[0] in shell_safety.UNGRANTABLE_PREFIX_HEADS:
# "always allow bash/eval/sudo ..." would be a blank check;
# degrade to the exact command.
prefix = full
all_commands.append(full)
all_prefixes.append(prefix or full)
if not shell_safety.is_read_only_shell_command(full, safe_prefixes)[0]:
commands.append(full)
prefixes.append(prefix or full)
if not commands:
# Fully read-only command: grants are moot, but keep the raw
# targets so callers still have a meaningful display candidate.
commands, prefixes = all_commands, all_prefixes
return list(dict.fromkeys(commands)), list(dict.fromkeys(prefixes))
def _command_has_shell_substitution(self, command: str) -> bool:
"""True when a command contains dynamic constructs (unauditable
``$(...)``, backticks, process substitution, ``eval``/``source``) that
must never ride through on a safe prefix or a persisted grant."""
if shell_safety.has_blocked_substitution(command):
return True
segments = shell_safety.split_shell_segments(command)
if segments is None:
return True
return any(tokens and tokens[0] in {"eval", "source", "."} for tokens in segments)
def _command_matches_safe_prefix(self, command: str, prefixes: list[str]) -> bool:
safe, _ = shell_safety.is_read_only_shell_command(command, prefixes)
return safe
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 _shell_command_prefix(self, tokens: list[str]) -> list[str]:
# Interpreter inline-code / module runs keep the flag in the prefix so
# a grant reads `python3 -c` (all inline snippets) or `python -m pip`
# (that module) instead of a blanket `python3`.
if tokens and tokens[0] in {"python", "python3", "python2", "node", "bun", "deno", "ruby", "perl"}:
for index in (1, 2):
if index >= len(tokens):
break
if tokens[index] in {"-c", "-e"}:
return [tokens[0], tokens[index]]
if tokens[index] == "-m" and index + 1 < len(tokens):
return [tokens[0], "-m", tokens[index + 1]]
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
]
# The read-only audit must see the ORIGINAL command text: the
# preview used for keyword scans re-joins shlex tokens and drops
# quotes, turning e.g. `echo "<EOF>"` into `echo <EOF>` where the
# bare `<` reads as a redirection and misclassifies the command.
arguments = metadata.get("arguments", {})
raw_command = (
str(arguments.get("command", "") or arguments.get("cmd", "") or "").strip()
if isinstance(arguments, dict)
else ""
) or command
if projection_scoped_low_risk:
reasons.append("Command matches a projection-scoped acquisition prefix inside the assigned workspace.")
elif self._command_matches_safe_prefix(raw_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 == "approve_once" and allowlist_enabled and action_kind == "tool":
# "Approve once" still records the exact blocked candidates as a
# session grant: repeating the identical action in this session
# must not re-prompt, but nothing broader is granted.
once_patterns = approval_context.get("candidates") or allowlist_patterns
if once_patterns:
saved_patterns = self._add_session_patterns(
task=task,
action_kind=action_kind,
action_name=action_name,
patterns=list(once_patterns),
)
session_scope_id = self._approval_session_scope_id(task)
if saved_patterns and 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 and action_kind == "tool":
# 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))