Files
openworker/coworker/config.py
T
Devika Verma c958d6f262 Step 2: Auto-Approve mode - the reviewer, the hook, and the renames
The mode from ocw-context/docs/reviewed-auto-mode.md (rev. 4), v1 scope.

coworker/reviewer.py (new)
- The 8.3 prompt verbatim, cache-shaped: instructions + known world (folders
  and remotes only) + user-message history in the stable prefix; this turn's
  request and ONE action in the suffix.
- parse_verdict: any defect (empty, non-JSON, unknown verdict) -> unsure.
  There is no parse path that results in execution (8.5).
- Reviewer.review never raises: provider errors and timeouts -> unsure.
  Metering counters (checks / verdicts / tokens) for 1.7.
- AGENT_DENY_MESSAGE: the terse, non-diagnostic refusal the agent gets on a
  deny; the full reason goes to the user only (8.4 asymmetry).

coworker/engine.py
- Reviewer consulted ONLY when: attached, mode is AUTO_APPROVE, session
  explicitly attended (unset is_attended counts as NOT attended, so
  automations can never be reviewed), fewer than two denials this turn.
- Consulted ONLY on decisions the gate marked needs_user - hard denies
  never reach it, so it can only turn "ask" into "allow" (1.2).
- One action per request, fired concurrently for all of a turn's escalating
  calls before the sequential authorize loop (8.6): a verdict cannot land
  on the wrong action, and approval cards still reach the human one at a
  time in call order.
- allow -> runs, audited with the reason. deny -> blocked; user event
  carries the full reviewer reason + allow_anyway; agent message carries
  only AGENT_DENY_MESSAGE. unsure -> today's card.
- Reviewer sees the user's words only, extracted mechanically from
  role=user messages - never agent output, never tool results (4.4).

coworker/permissions.py
- Mode.AUTO renamed Mode.BYPASS_APPROVALS ("bypass-approvals"); legacy
  "auto" still parses via _missing_ so configs, saved sessions, and the
  golden decision table are untouched.
- Mode.AUTO_APPROVE ("auto-approve"): gate-identical to INTERACTIVE except
  session grants ("always allow this ...") no longer auto-allow - they
  route to the reviewer instead (1.5: out-of-band standing policy may skip
  the judge; an in-flow click may not). Config allowlists still skip.
- _domain_allowed(include_session=False) checks the user-settings list only.

coworker/config.py: auto_approve flag, off by default, _GLOBAL_ONLY (a
cloned repo cannot hand itself a looser reviewer). agent.py attaches the
Reviewer only when the flag is on; without it AUTO_APPROVE behaves exactly
like INTERACTIVE.

server/manager.py: autonomy audit ranks auto-approve above interactive
(turning the reviewer on IS raising autonomy) and below bypass.

GUI: mode picker label "Full access" -> "Bypass approvals" (wire value
"auto" kept). Verified live against the real sidecar; e2e spec updated;
tsc and all 111 GUI unit tests pass.

Tests: tests/test_auto_approve.py (33) - gate behaviour per mode, fail-
closed parsing, prompt shape, deny asymmetry, retry guard, attended
gating, hard-deny isolation, per-action verdict landing, and that the
reviewer never sees agent prose. Permission suites + golden table: 146
passing unchanged.
2026-08-12 12:42:17 -07:00

145 lines
5.8 KiB
Python

"""Configuration — layered TOML: built-in defaults < global < per-workspace.
Global: <state-dir>/config.toml (see `secrets.state_dir`; platform-native)
Workspace: <workspace>/.coworker/config.toml (overrides global)
Workspace command allowances apply only after the user trusts that exact canonical
workspace path. Other permission grants remain global-only.
"""
from __future__ import annotations
try:
import tomllib # stdlib since 3.11
except ModuleNotFoundError: # 3.10, the floor requires-python declares
import tomli as tomllib # type: ignore[no-redef]
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from .secrets import state_dir
# Commands auto-run WITHOUT an approval prompt. There is no generally safe executable:
# nominally read-only programs can read secrets outside the workspace, expand environment
# variables, load project-controlled config/plugins, or execute helpers (for example
# `find -exec` and pytest collection). Keep the built-in list empty. A user may explicitly
# opt into command prefixes in their user-owned global config, accepting that authority.
DEFAULT_ALLOWED_COMMANDS: list[str] = []
@dataclass
class Config:
model: str = "gpt-5.6-sol"
mode: str = "interactive"
max_iterations: int = 150
allowed_commands: list[str] = field(
default_factory=lambda: list(DEFAULT_ALLOWED_COMMANDS)
)
# In "custom" permission mode, these tools are auto-approved (e.g. file edits)
# while everything else still asks.
auto_allow: list[str] = field(default_factory=list)
# Egress destinations `web_fetch` may reach WITHOUT an approval prompt (exact host or
# subdomain). Empty by default — the first fetch to any host asks. A power-user opt-in,
# like `allowed_commands`; user-global only, so a repo can't widen the agent's network reach.
allowed_domains: list[str] = field(default_factory=list)
# Auto-Approve mode's feature flag (spec §1.5): when true, sessions get an LLM reviewer
# that judges would-be approval cards in Mode.AUTO_APPROVE. Off by default; user-global
# only — a cloned repo must not be able to hand itself a looser reviewer.
auto_approve: bool = False
host: str = "127.0.0.1"
port: int = 8765
# Web search provider: "duckduckgo" (keyless default) | "tavily" | "brave" (need a key).
web_search_provider: str = "duckduckgo"
# OpenWorker Cloud (sign-in + managed connectors). Config, never constants:
# dev/staging/BYO-VPC deployments point these at their own instances.
cloud_base_url: str = "https://api.openworker.com"
# Auth0 tenant + API audience are registered identifiers, not branding: the
# tenant name can never be renamed, and the audience must match the API
# identifier registered in Auth0 — both keep the legacy value on purpose.
cloud_auth_domain: str = "opencoworker.us.auth0.com"
cloud_client_id: str = "g1l4Q1lhYWmyS03qPSf4KEJGrgq02Qam"
cloud_audience: str = "https://api.opencoworker.app"
# Managed relay WebSocket endpoint (Slack/GitHub inbound). Defaults to the
# PRODUCTION relay so a fresh install relays out of the box — an empty
# default shipped once as "connected but relay OFF" on every machine
# without a hand-edited config.toml. Empty override ⇒ relay disabled
# (manual Socket Mode still works); dev/BYO deployments point elsewhere.
cloud_relay_ws_url: str = (
"wss://l4z1paxb83.execute-api.us-east-1.amazonaws.com/ocw-connect"
)
_FIELDS = {
"model",
"mode",
"max_iterations",
"allowed_commands",
"auto_allow",
"allowed_domains",
"auto_approve",
"host",
"port",
"web_search_provider",
"cloud_base_url",
"cloud_auth_domain",
"cloud_client_id",
"cloud_audience",
"cloud_relay_ws_url",
}
# These fields change what consequential actions can run without a prompt, so the normal
# workspace override pass never applies them. `allowed_commands` is added separately only
# for a canonically trusted workspace; `auto_allow` and `allowed_domains` remain user-global
# only (a repo must not be able to widen the agent's command or network reach).
_GLOBAL_ONLY_FIELDS = {"allowed_commands", "auto_allow", "allowed_domains", "auto_approve"}
_WORKSPACE_FIELDS = _FIELDS - _GLOBAL_ONLY_FIELDS
def global_config_path() -> Path:
return state_dir() / "config.toml"
def _read(path: Path) -> dict[str, Any]:
try:
with open(path, "rb") as f:
return tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return {}
def workspace_allowed_commands(workspace: str | Path) -> list[str]:
"""Command prefixes requested by repository config; advisory until workspace trust."""
path = Path(workspace).expanduser() / ".coworker" / "config.toml"
value = _read(path).get("allowed_commands", [])
if not isinstance(value, list):
return []
return list(dict.fromkeys(v.strip() for v in value if isinstance(v, str) and v.strip()))
def load_config(
workspace: Optional[str | Path] = None,
*,
global_path: Optional[Path] = None,
workspace_trusted: bool = False,
) -> Config:
cfg = Config()
g = Path(global_path) if global_path is not None else global_config_path()
if g.is_file():
for key, value in _read(g).items():
if key in _FIELDS:
setattr(cfg, key, value)
if workspace:
w = Path(workspace).expanduser() / ".coworker" / "config.toml"
if w.is_file():
for key, value in _read(w).items():
if key in _WORKSPACE_FIELDS:
setattr(cfg, key, value)
if workspace_trusted:
cfg.allowed_commands = list(
dict.fromkeys(
[*cfg.allowed_commands, *workspace_allowed_commands(workspace)]
)
)
return cfg