mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 07:20:10 +00:00
Boundary-hardening pass addressing three audit findings on the local sidecar. Shell command allowlist (andrewyng/openworker#28): - Replace prefix-string matching in PermissionEngine._command_allowed with argv-aware matching: reject any command containing shell operators (; & | > < ` $( ( and newlines) before consulting the allowlist, then require the allowlisted entry's tokens to be an exact argv prefix. This closes the auto-run bypass where an allowlisted "git status" also auto-ran "git status && rm -rf ~", pipes, redirection, and command substitution. - Drop language interpreters / package managers (python, python3, node, npm, npx) from DEFAULT_ALLOWED_COMMANDS — allowlisting an interpreter allowlists arbitrary code (python3 -c "..."), defeating approval gating. Read-only inspection commands and pytest remain. MCP OAuth loopback (andrewyng/openworker#29): - Verify the OAuth state at the loopback boundary. The MCP SDK already validates state (compare_digest), so this is not a CSRF fix but defense-in-depth: capture the state from the authorize URL and have deliver_callback ignore a callback whose state does not match WITHOUT consuming the pending future, so a stray or forged local hit can no longer abort a user's in-progress sign-in. Falls back to prior accept-any behavior when no state was captured. WebSocket ingestion caps (andrewyng/openworker#38): - Bound a single user_message frame in the session WS loop: max text length, max attachment count, and max total attachment bytes. Oversized frames get a visible error frame and are dropped instead of being buffered into a turn; the socket stays alive. Guards the unauthenticated loopback socket against cheap memory spikes. Tests: - Allowlist: reject operator chaining (8 variants), argv-boundary matching, and interpreters-not-auto-allowed-by-default. - OAuth: state extraction, and mismatched/missing state ignored without consuming the flow while the matching state still resolves it. - WS: oversized text and too-many-attachments rejected with an error frame, and a normal message still works afterwards. Full suite: 865 passed (1 pre-existing unrelated failure in test_provider_router::test_manager_curated_models, present on origin/main).
121 lines
3.8 KiB
Python
121 lines
3.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)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tomllib
|
|
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. Deliberately limited to read-only
|
|
# inspection: language interpreters and package managers (python/node/npm/npx) are NOT
|
|
# here — allowlisting an interpreter allowlists arbitrary code (`python3 -c "..."`), which
|
|
# defeats the point of approval gating. Users can still add them via config if they accept
|
|
# that trade-off. Matching is exact-argv-prefix and rejects shell operators — see
|
|
# PermissionEngine._command_allowed.
|
|
DEFAULT_ALLOWED_COMMANDS = [
|
|
"ls",
|
|
"cat",
|
|
"pwd",
|
|
"echo",
|
|
"head",
|
|
"tail",
|
|
"grep",
|
|
"find",
|
|
"wc",
|
|
"git status",
|
|
"git diff",
|
|
"git log",
|
|
"git show",
|
|
"pytest",
|
|
]
|
|
|
|
|
|
@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)
|
|
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",
|
|
"host",
|
|
"port",
|
|
"web_search_provider",
|
|
"cloud_base_url",
|
|
"cloud_auth_domain",
|
|
"cloud_client_id",
|
|
"cloud_audience",
|
|
"cloud_relay_ws_url",
|
|
}
|
|
|
|
|
|
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 load_config(
|
|
workspace: Optional[str | Path] = None, *, global_path: Optional[Path] = None
|
|
) -> Config:
|
|
cfg = Config()
|
|
data: dict[str, Any] = {}
|
|
|
|
g = Path(global_path) if global_path is not None else global_config_path()
|
|
if g.is_file():
|
|
data.update(_read(g))
|
|
if workspace:
|
|
w = Path(workspace).expanduser() / ".coworker" / "config.toml"
|
|
if w.is_file():
|
|
data.update(_read(w))
|
|
|
|
for key, value in data.items():
|
|
if key in _FIELDS:
|
|
setattr(cfg, key, value)
|
|
return cfg
|