"""Permission engine — decides allow / deny / ask-user for each proposed tool call. Modes: Plan (read-only) · Interactive (auto reads, ask on writes/commands) · Auto (allow, still path-scoped). Refined by argument patterns (path-under-root, command prefixes) and a session allowlist. The engine only *decides*; the turn engine routes `needs_user` decisions to a surface for approval and records the outcome. """ from __future__ import annotations import shlex from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Any, Optional # Shell metacharacters that turn one "allowlisted" command into several. Any of these in a # command disqualifies it from allowlist auto-run — approval is required instead. Covers # chaining (`;` `&` `&&` `||`), pipes (`|`), redirection (`>` `<`), command substitution # (`` ` `` `$(`), process substitution / grouping (`(`), and newlines. _SHELL_OPERATORS = (";", "&", "|", ">", "<", "`", "$(", "(", "\n", "\r") def _has_shell_operators(command: str) -> bool: return any(op in command for op in _SHELL_OPERATORS) from .risk import ( # re-exported for back-compat (manager.py imports WRITE_TOOLS) SHELL_TOOL, WRITE_TOOLS, RiskClass, RiskOverrides, classify, is_consequential, ) class Mode(str, Enum): DISCUSS = "discuss" # read-only conversation: no edits, no planning workflow PLAN = ( "plan" # read-only + the planning contract (explore → propose_plan → execute) ) INTERACTIVE = "interactive" # ask for approval (default) AUTO = "auto" # full access CUSTOM = "custom" # interactive + auto-allow the config's `auto_allow` tools # Modes whose enforcement is read-only. DISCUSS and PLAN share the same gate; they differ # only in intent — PLAN additionally drives the agent toward a propose_plan approval. READ_ONLY_MODES = frozenset({Mode.DISCUSS, Mode.PLAN}) @dataclass class Decision: allowed: bool reason: str = "" needs_user: bool = False # True → surface should prompt the user for approval # Set when a task-scoped standing rule allowed the call ("tool → target") so the # engine can audit the exact rule and the tool card can say so (§25). rule: str = "" def standing_rule_candidate( tool_name: str, arguments: dict[str, Any], metadata: Any = None, overrides: Optional[RiskOverrides] = None, ) -> Optional[str]: """The target value iff this call is eligible for a task-scoped standing rule (UX-DECISIONS §25): external-risk only (never exec/write-local — shell asks forever), the tool must declare a target argument, and the call must actually name a target. Returns None otherwise — ineligible calls keep parking approvals as today.""" from .connectors.tool_defs import target_arg_for if classify(tool_name, metadata, overrides) is not RiskClass.EXTERNAL: return None arg = target_arg_for(tool_name) if arg is None: return None value = str((arguments or {}).get(arg) or "").strip() return value or None @dataclass class PermissionEngine: workspace_root: Path mode: Mode = Mode.INTERACTIVE allowed_commands: list[str] = field(default_factory=list) auto_allow_tools: set[str] = field(default_factory=set) session_allow_tools: set[str] = field(default_factory=set) session_allow_commands: set[str] = field(default_factory=set) # Session-wide read-only grant (owner ask 2026-08-11): auto-allow shell commands the # conservative classifier (coworker/readonly.py) accepts. User-elected per session. session_readonly: bool = False # Task-scoped standing rules (§25): {tool: {allowed targets}}, seeded from the owning # ScheduledTask's target-shaped entries. Kept by reference and re-read every check, so a # rule minted mid-run ("Allow every time") applies to the run's next call too. task_rules: dict[str, set[str]] = field(default_factory=dict) # User-local risk override resolver (Phase 2). None → use the base classification. risk_overrides: Optional[RiskOverrides] = None # Shared, possibly-mutable list of roots (RootDir-like / dicts). When omitted, the single # `workspace_root` is the sole writable root (back-compat). Kept by reference and re-read on # every check, so runtime add/remove of folders takes effect without rebuilding the engine. roots: Optional[list] = None def __post_init__(self) -> None: self.workspace_root = Path(self.workspace_root).expanduser().resolve() self.auto_allow_tools = set(self.auto_allow_tools) if self.roots is None: self.roots = [{"path": self.workspace_root, "writable": True}] def _resolved_roots(self) -> list[tuple[Path, bool]]: out: list[tuple[Path, bool]] = [] for r in self.roots or []: if isinstance(r, dict): p, w = r["path"], bool(r.get("writable", False)) elif isinstance(r, (str, Path)): p, w = r, True else: # duck-typed RootDir-like p, w = getattr(r, "path"), bool(getattr(r, "writable", False)) out.append((Path(p).expanduser().resolve(), w)) return out def evaluate( self, tool_name: str, arguments: dict[str, Any], metadata: Any = None ) -> Decision: arguments = arguments or {} is_connector = getattr(metadata, "category", "") == "connector" risk = classify(tool_name, metadata, self.risk_overrides) is_write = risk is RiskClass.WRITE_LOCAL is_shell = risk is RiskClass.EXEC consequential = is_consequential(risk) # Discuss / plan modes: read-only. if self.mode in READ_ONLY_MODES and consequential: return Decision( False, f"{self.mode.value} mode is read-only", needs_user=False ) # Path scoping for writes that name a path (all modes): must land in a writable root. if is_write: path = arguments.get("path") if path is not None and not self._under_writable_root(path): return Decision(False, f"path is not in a writable directory: {path}") # Non-consequential tools always run. if not consequential: return Decision(True, "low risk") # Full access. if self.mode is Mode.AUTO: return Decision(True, "full access") # interactive / custom: allowlists. if is_shell: command = str(arguments.get("command", "")) if self._command_allowed(command): return Decision(True, "command on allowlist") if command and command in self.session_allow_commands: return Decision(True, "command allowed for session") if self.session_readonly and command: from .readonly import is_readonly_command if is_readonly_command(command): return Decision(True, "read-only command (session grant)") if tool_name in self.session_allow_tools and not is_connector: return Decision(True, "tool allowed for session") # Task-scoped standing rules (§25): tool + exact target, owned by the automation. # Deliberately NOT subject to the connector exclusion above — the exact-target # binding is what makes auto-allowing a connector tool safe. Never for exec risk # (candidate extraction is external-risk-only), and additive on top of the mode: # read-only modes already returned before this point. if tool_name in self.task_rules: target = standing_rule_candidate( tool_name, arguments, metadata, self.risk_overrides ) if target and target in self.task_rules[tool_name]: rule = f"{tool_name} → {target}" return Decision(True, f"allowed by standing rule: {rule}", rule=rule) # Custom mode auto-approves the configured tools. if self.mode is Mode.CUSTOM and tool_name in self.auto_allow_tools: return Decision(True, "auto-allowed by config") # Otherwise: ask the user. return Decision(False, "requires approval", needs_user=True) # -- session memory --------------------------------------------------------- def allow_tool_for_session(self, tool_name: str) -> None: self.session_allow_tools.add(tool_name) def allow_command_for_session(self, command: str) -> None: if command: self.session_allow_commands.add(command) def allow_readonly_for_session(self) -> None: self.session_readonly = True # -- helpers ---------------------------------------------------------------- def _candidate(self, path: str) -> Path: # Relative paths resolve against the primary (workspace_root); absolute/`~` taken as-is. p = Path(path).expanduser() return p.resolve() if p.is_absolute() else (self.workspace_root / p).resolve() def _under_root(self, path: str) -> bool: candidate = self._candidate(path) for rp, _ in self._resolved_roots(): try: candidate.relative_to(rp) return True except ValueError: continue return False def _under_writable_root(self, path: str) -> bool: candidate = self._candidate(path) for rp, writable in self._resolved_roots(): if not writable: continue try: candidate.relative_to(rp) return True except ValueError: continue return False def _command_allowed(self, command: str) -> bool: # An allowlist entry auto-runs a command WITHOUT approval, so prefix matching is # unsafe: `git status` would auto-approve `git status && rm -rf ~`. Reject anything # carrying shell operators (chaining/redirection/substitution) up front, then match # the parsed argv against each entry — the entry's own tokens must be an exact # prefix of the command's tokens (so `git status` matches `git status -s` but never # `git statusfoo` or a bare `git`). if _has_shell_operators(command): return False try: argv = shlex.split(command) except ValueError: return False # unbalanced quotes etc. — treat as not-allowlisted if not argv: return False for allowed in self.allowed_commands: try: prefix = shlex.split(allowed) except ValueError: continue if prefix and argv[: len(prefix)] == prefix: return True return False