4b29b89371
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>
2138 lines
87 KiB
Python
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))
|