"""Broker configuration: files, paths and hardening defaults. No secret material ever lives in this module -- only *paths* to secret files and socket endpoints. """ from __future__ import annotations import json import os import stat from pathlib import Path from .errors import BrokerError DEFAULT_VAULT_URL = "https://vault.paginica.com" CREDENTIALS_DIR = Path.home() / ".config" / "devin" / "credentials" CLIENT_ID_FILE = CREDENTIALS_DIR / "bitwarden-client-id.txt" CLIENT_SECRET_FILE = CREDENTIALS_DIR / "bitwarden-client-secret.token" # OpenBao defaults: loopback dev agent (see backlog 03); TLS via # openbao_ca_cert / openbao_tls_skip_verify when the listener is exposed. DEFAULT_OPENBAO_ADDR = "http://127.0.0.1:18200" OPENBAO_ROLE_ID_FILE = CREDENTIALS_DIR / "openbao-role-id.txt" OPENBAO_SECRET_ID_FILE = CREDENTIALS_DIR / "openbao-secret-id.txt" OPENBAO_TOKEN_FILE = CREDENTIALS_DIR / "openbao-token.txt" OPENBAO_TOKEN_ENV = "AGENT_BROKER_OPENBAO_TOKEN" def runtime_dir() -> Path: base = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" p = Path(base) / "agent-broker" p.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(p, 0o700) return p def socket_path() -> Path: return Path(os.environ.get("AGENT_BROKER_SOCKET", str(runtime_dir() / "broker.sock"))) def state_dir() -> Path: base = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state") p = Path(base) / "agent-broker" p.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(p, 0o700) return p def audit_log_path() -> Path: p = state_dir() / "audit.jsonl" if not p.exists(): p.touch(mode=0o600) os.chmod(p, 0o600) return p def tmp_dir() -> Path: """Secret-bearing temp files live ONLY on the runtime tmpfs.""" p = runtime_dir() / "tmp" p.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(p, 0o700) return p def config_path() -> Path: return Path(os.environ.get( "AGENT_BROKER_CONFIG", str(Path.home() / ".config" / "agent-broker" / "config.json"), )) def profiles_dir() -> Path: env = os.environ.get("AGENT_BROKER_PROFILES_DIR") if env: return Path(env) default = Path(__file__).resolve().parent.parent / "profiles" if default.is_dir(): return default return Path.home() / ".config" / "agent-broker" / "profiles" class Config: def __init__(self, data: dict): self.vault_url = data.get("vault_url", DEFAULT_VAULT_URL).rstrip("/") self.client_id_file = Path(data.get("client_id_file", str(CLIENT_ID_FILE))) self.client_secret_file = Path(data.get("client_secret_file", str(CLIENT_SECRET_FILE))) self.request_timeout = float(data.get("request_timeout", 15)) self.unlock_ttl_seconds = float(data.get("unlock_ttl_seconds", 3600)) self.confirmation_ttl_seconds = float(data.get("confirmation_ttl_seconds", 120)) self.max_response_bytes = int(data.get("max_response_bytes", 256 * 1024)) self.git_workdir_roots = data.get("git_workdir_roots", []) # Optional non-interactive unlock: when set, `unlock` reads the # master password from this file (same rules as credential files: # 600 perms, owned by this user) instead of prompting via getpass. self.master_password_file = ( Path(data["master_password_file"]) if data.get("master_password_file") else None ) # Vault backend selection. "bitwarden-cli" (transition backend) # and "openbao" are supported; anything else (including stale # "legacy-crypto") fails loudly at daemon start -- no fallback. self.vault_backend = data.get("vault_backend", "bitwarden-cli") self.bw_path = data.get("bw_path") # None -> PATH lookup self.bw_appdata_dir = Path(data.get( "bw_appdata_dir", str(Path.home() / ".local" / "share" / "agent-broker" / "bitwarden-cli"))) # --- OpenBao backend (vault_backend = "openbao") --------------- # Items live at /data// (KV v2). Auth is # non-interactive by design: an AppRole (role_id+secret_id read # from 0600 files, same rules as client_id_file) or a direct # token from a 0600 file / env var. self.openbao_addr = data.get( "openbao_addr", DEFAULT_OPENBAO_ADDR).rstrip("/") self.openbao_mount = str(data.get("openbao_mount", "secret")).strip("/") self.openbao_prefix = str(data.get("openbao_prefix", "agent")).strip("/") # Optional namespace -> X-Vault-Namespace header on every request. self.openbao_namespace = data.get("openbao_namespace") or None # TLS: CA bundle path, or dev-only verification skip. self.openbao_ca_cert = data.get("openbao_ca_cert") or None self.openbao_tls_skip_verify = bool( data.get("openbao_tls_skip_verify", False)) self.openbao_role_id_file = Path(data.get( "openbao_role_id_file", str(OPENBAO_ROLE_ID_FILE))) self.openbao_secret_id_file = Path(data.get( "openbao_secret_id_file", str(OPENBAO_SECRET_ID_FILE))) self.openbao_token_file = Path(data.get( "openbao_token_file", str(OPENBAO_TOKEN_FILE))) # Name of the env var holding a direct token (override with # openbao_token_env; default AGENT_BROKER_OPENBAO_TOKEN). self.openbao_token_env = data.get( "openbao_token_env", OPENBAO_TOKEN_ENV) or OPENBAO_TOKEN_ENV @classmethod def load(cls) -> "Config": path = config_path() if path.exists(): try: data = json.loads(path.read_text()) except Exception as e: raise BrokerError("UNCONFIGURED", f"invalid config file {path}: {e}") if not isinstance(data, dict): raise BrokerError("UNCONFIGURED", f"invalid config file {path}") else: data = {} return cls(data) def read_secret_file(path: Path) -> str: """Read a local credential file for internal use only. Enforces that the file is not group/other-readable.""" if not path.exists(): raise BrokerError("UNCONFIGURED", f"credential file not found: {path}") st = path.stat() if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO): raise BrokerError( "UNCONFIGURED", f"credential file {path} has unsafe permissions {oct(st.st_mode & 0o777)}; chmod 600", ) if st.st_uid not in (os.getuid(), 0): raise BrokerError("UNCONFIGURED", f"credential file {path} not owned by this user") return path.read_text().strip()