From be8790079455b97c3f658a2a7c05022f28d5f999 Mon Sep 17 00:00:00 2001 From: Cristhian Ulloa Date: Wed, 14 Oct 2026 13:26:08 -0600 Subject: [PATCH] agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend Mechanical rename (package agent_credential_broker; entry points agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker; agent-browser credential provider 'broker'; MCP server name agent-broker). New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2 (approle login or token file/env; metadata-only ref index; lazy re-login on 401/403; token renewal). bitwarden-cli backend kept as transition. --- .gitignore | 6 + Makefile | 16 + README.md | 116 ++++ agent_credential_broker/__init__.py | 9 + agent_credential_broker/__main__.py | 6 + agent_credential_broker/adapters/__init__.py | 58 ++ agent_credential_broker/adapters/browser.py | 115 ++++ .../adapters/cli_profile.py | 168 +++++ agent_credential_broker/adapters/common.py | 117 ++++ agent_credential_broker/adapters/git.py | 135 ++++ agent_credential_broker/adapters/http.py | 113 ++++ agent_credential_broker/adapters/ssh.py | 100 +++ agent_credential_broker/audit.py | 41 ++ agent_credential_broker/backends/__init__.py | 70 ++ agent_credential_broker/backends/bwcli.py | 216 +++++++ agent_credential_broker/backends/openbao.py | 377 +++++++++++ agent_credential_broker/cli.py | 118 ++++ agent_credential_broker/config.py | 165 +++++ agent_credential_broker/core.py | 256 ++++++++ agent_credential_broker/errors.py | 76 +++ agent_credential_broker/items.py | 172 +++++ agent_credential_broker/mcp_server.py | 228 +++++++ agent_credential_broker/plugin.py | 70 ++ agent_credential_broker/policy.py | 167 +++++ agent_credential_broker/sanitize.py | 71 +++ agent_credential_broker/server.py | 170 +++++ profiles/example-echo.json | 12 + profiles/gitea-api.json | 16 + pyproject.toml | 18 + systemd/agent-broker.service | 16 + tests/fake_bao.py | 255 ++++++++ tests/fakebw.py | 105 +++ tests/test_broker.py | 597 ++++++++++++++++++ tests/test_cli_backend.py | 390 ++++++++++++ tests/test_openbao_backend.py | 438 +++++++++++++ 35 files changed, 5003 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 agent_credential_broker/__init__.py create mode 100644 agent_credential_broker/__main__.py create mode 100644 agent_credential_broker/adapters/__init__.py create mode 100644 agent_credential_broker/adapters/browser.py create mode 100644 agent_credential_broker/adapters/cli_profile.py create mode 100644 agent_credential_broker/adapters/common.py create mode 100644 agent_credential_broker/adapters/git.py create mode 100644 agent_credential_broker/adapters/http.py create mode 100644 agent_credential_broker/adapters/ssh.py create mode 100644 agent_credential_broker/audit.py create mode 100644 agent_credential_broker/backends/__init__.py create mode 100644 agent_credential_broker/backends/bwcli.py create mode 100644 agent_credential_broker/backends/openbao.py create mode 100644 agent_credential_broker/cli.py create mode 100644 agent_credential_broker/config.py create mode 100644 agent_credential_broker/core.py create mode 100644 agent_credential_broker/errors.py create mode 100644 agent_credential_broker/items.py create mode 100644 agent_credential_broker/mcp_server.py create mode 100644 agent_credential_broker/plugin.py create mode 100644 agent_credential_broker/policy.py create mode 100644 agent_credential_broker/sanitize.py create mode 100644 agent_credential_broker/server.py create mode 100644 profiles/example-echo.json create mode 100644 profiles/gitea-api.json create mode 100644 pyproject.toml create mode 100644 systemd/agent-broker.service create mode 100644 tests/fake_bao.py create mode 100644 tests/fakebw.py create mode 100644 tests/test_broker.py create mode 100644 tests/test_cli_backend.py create mode 100644 tests/test_openbao_backend.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ecbbe5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.pytest_cache/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d37de07 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: test lint install service-install + +test: + python3 -m unittest discover -s tests -v + +lint: + python3 -m compileall -q agent_credential_broker tests + +install: + pip3 install --user -e . + +service-install: + install -D -m 644 systemd/agent-broker.service \ + ~/.config/systemd/user/agent-broker.service + systemctl --user daemon-reload + @echo "Now: systemctl --user enable --now agent-broker" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac207f0 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# agent-credential-broker + +Capability layer between AI agents and a credential vault: **agents use +credentials, they can never read them.** + +Forked from the Vaultwarden-era `vaultwarden-agent-broker` (same socket +protocol, MCP surface, policy model and adapters; renamed package, +binaries, env prefix and paths). The design rationale for the backend +split lives in `backlog/02_password_manager/broker-design.md`. + +## Backends + +| Backend | Status | Auth | Notes | +|---|---|---|---| +| `openbao` | target backend | AppRole (`role_id`/`secret_id` files, 0600) or direct token (0600 file / `AGENT_BROKER_OPENBAO_TOKEN`) | KV v2 at `/data//`; the ref index is built **only** from `metadata/*`, so unlock never transits plaintext | +| `bitwarden-cli` | transition | `bw` subprocess, API key + master password | unchanged; kept for the migration window, will be dropped with the old vault | + +Select with `vault_backend` in the config JSON. + +## Item contract (OpenBao) + +``` +secret/data/agent/ {username, password, totp, uris, notes, ...} +secret/metadata/agent/ custom_metadata: + agent.ref, agent.adapters, agent.allowed_urls, + agent.allowed_hosts, agent.auth_type, + agent.confirmation, agent.*_selector, + agent.ssh_user, agent.ssh_key_path, + agent.cli_profile, agent.insecure_http + label (display name), uris (csv or JSON list) +``` + +`agent.ref` is the stable, unique, exact-match reference. Every +`agent.*` field and its effect is documented in +`agent_credential_broker/policy.py`. Paths may nest +(`agent//`) — the item id is the path relative to the prefix. + +Minimal AppRole policy: + +```hcl +path "secret/data/agent/*" { capabilities = ["read"] } +path "secret/metadata/agent/*" { capabilities = ["read", "list"] } +``` + +## Quick start + +```bash +pip3 install --user -e . + +# 1. credentials dir + AppRole files (or openbao-token.txt) +install -d -m 700 ~/.config/devin/credentials +# openbao-role-id.txt / openbao-secret-id.txt (600) + +# 2. config -- ~/.config/agent-broker/config.json (or AGENT_BROKER_CONFIG) +# { +# "vault_backend": "openbao", +# "openbao_addr": "http://127.0.0.1:18200", +# "openbao_mount": "secret", +# "openbao_prefix": "agent", +# "openbao_role_id_file": "~/.config/devin/credentials/openbao-role-id.txt", +# "openbao_secret_id_file": "~/.config/devin/credentials/openbao-secret-id.txt", +# "openbao_ca_cert": "/path/ca.pem", # optional, https only +# "openbao_tls_skip_verify": false, # dev only +# "openbao_namespace": null, # -> X-Vault-Namespace +# "unlock_ttl_seconds": 3600 +# } + +# 3. daemon (unix socket only) +agent-broker daemon & # or: make service-install + +# 4. unlock -- openbao ignores the password value (non-interactive by +# design, the 0600 files are the credential); enter anything or set +# master_password_file for unattended unlock +agent-broker unlock + +# 5. refs only +agent-broker list-refs +agent-broker status +agent-broker lock +``` + +## Interfaces + +| Consumer | Entry point | +|---------------------|--------------------------------------------------------------------| +| Agent (MCP) | `agent-broker-mcp` (stdio, server name `agent-broker`) | +| agent-browser | `--credential-provider broker` -> `agent-broker-plugin` | +| Local human | `agent-broker` CLI (daemon / status / unlock / lock / confirm) | + +## Threat model (unchanged) + +- Unix socket only (`$XDG_RUNTIME_DIR/agent-broker/broker.sock`, 0600, + dir 0700), same-UID enforced via `SO_PEERCRED`. No TCP listener. +- Decrypted items exist only in daemon RAM, wiped after each use + (`wipe_item`), auto-locked after `unlock_ttl_seconds`. +- The ref index holds metadata only (id/type/name/uris/`agent.*` + fields). On OpenBao it is built from `metadata/*` endpoints — no + plaintext transit at all. +- The agent-facing surface (MCP tools, socket methods) is a static + allowlist; a startup assert forbids any tool named like a secret + reader (`get_*`, `reveal`, `dump`, ...). +- Adapters inject secrets via child env / askpass-on-tmpfs / the + agent-browser plugin — never argv — and every byte that crosses the + boundary is sanitized (`sanitize.py`). +- Same-UID local malware remains out of scope: it can read the 0600 + credential files directly. The boundary is the agent, not the host. + +## Tests + +```bash +make test # or: python -m pytest tests/ -x -q +make lint +``` + +All tests are offline fixtures (`tests/fakebw.py`, `tests/fake_bao.py`) +— no real vault, no real credentials. diff --git a/agent_credential_broker/__init__.py b/agent_credential_broker/__init__.py new file mode 100644 index 0000000..e599efe --- /dev/null +++ b/agent_credential_broker/__init__.py @@ -0,0 +1,9 @@ +"""agent-credential-broker: capability layer between agents and a vault. + +Invariant: agents may USE credentials through this broker, but can never +RETRIEVE credential values. Every operation resolves a stable reference +(``agent.ref`` custom field), enforces an item-scoped policy, executes a +bounded adapter, and returns only sanitized metadata. +""" + +__version__ = "0.1.0" diff --git a/agent_credential_broker/__main__.py b/agent_credential_broker/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/agent_credential_broker/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_credential_broker/adapters/__init__.py b/agent_credential_broker/adapters/__init__.py new file mode 100644 index 0000000..f11128e --- /dev/null +++ b/agent_credential_broker/adapters/__init__.py @@ -0,0 +1,58 @@ +"""Adapter registry. Adapters receive (item, policy, params, config) +in-process, inject the secret through an approved channel, execute a +bounded operation, and return sanitized metadata only.""" + +from __future__ import annotations + +from ..errors import denied + + +def _load(): + from . import http, ssh, git, cli_profile, browser + return { + "http": http.run, + "ssh": ssh.run, + "git": git.run, + "cli-profile": cli_profile.run, + "browser": browser.run, + } + + +def _mods(): + from . import http, ssh, git, cli_profile, browser + return { + "http": http, + "ssh": ssh, + "git": git, + "cli-profile": cli_profile, + "browser": browser, + } + + +class _Registry: + _impl = None + + @classmethod + def _mod(cls, adapter): + if cls._impl is None: + cls._impl = _mods() + mod = cls._impl.get(adapter) + if mod is None: + raise denied(f"unknown adapter {adapter!r}") + return mod + + @classmethod + def validate(cls, adapter, item, policy, params, config): + """Pre-execution policy/argument validation -- runs BEFORE the + confirmation gate so target errors surface first.""" + mod = cls._mod(adapter) + fn = getattr(mod, "validate", None) + if fn: + fn(item, policy, params, config) + + @classmethod + def run(cls, adapter, item, policy, params, config): + return cls._mod(adapter).run(item, policy, params, config) + + +registry = _Registry() diff --git a/agent_credential_broker/adapters/browser.py b/agent_credential_broker/adapters/browser.py new file mode 100644 index 0000000..f9186f9 --- /dev/null +++ b/agent_credential_broker/adapters/browser.py @@ -0,0 +1,115 @@ +"""Browser adapter. + +Preferred path (native provider): the agent runs + + agent-browser auth login --credential-provider broker \\ + --item + +agent-browser then invokes the broker's ``credential.read`` plugin and +the secret flows plugin-stdout -> agent-browser, never through the +agent. That plugin lives in plugin.py / entry point +``agent-broker-plugin``. + +Broker-side operations implemented here: + + browser_login executes ``agent-browser auth login`` with the + plugin flags (no secrets on argv -- resolution + happens inside agent-browser via the plugin) + browser_fill_credential same mechanism on an existing page + (--no-navigate + --url origin constraint + + custom selectors from agent.* fields) + +Granular single-field fill via ``agent-browser fill`` is NOT offered: +that command takes text on argv, which would expose the secret in +/proc//cmdline. If a site cannot be handled by the auth-login +mechanism, the adapter raises UNSAFE_TOOL_INTEGRATION instead of +downgrading to argv leaks. +""" + +from __future__ import annotations + +import os +import re +import shutil + +from ..config import socket_path +from ..errors import invalid_argument, unsafe_tool_integration +from ..policy import require_url_allowed +from ..items import item_secrets +from .common import clean_env, run_subprocess + +_SEL = re.compile(r"^[#.\[\]\w:='\"() >+~*-]{1,300}$") + + +def _sel(v): + if v and not _SEL.match(v): + raise invalid_argument("bad selector") + return v + + +def _agent_browser(config) -> str: + exe = shutil.which("agent-browser") + if not exe: + raise unsafe_tool_integration( + "agent-browser not installed; browser adapter unavailable") + return exe + + +def validate(item, policy, params, config): + url = params.get("url") or (policy.uris[0] if policy.uris else None) + if not url: + raise invalid_argument("browser op requires a url") + require_url_allowed(policy, url) + + +def run(item, policy, params, config): + op = params.get("op") or params.get("operation") or "browser_login" + url = params.get("url") or (policy.uris[0] if policy.uris else None) + if not url: + raise invalid_argument("browser op requires a url") + require_url_allowed(policy, url) + exe = _agent_browser(config) + + argv = [exe, "auth", "login", policy.ref, + "--credential-provider", "broker", + "--item", policy.ref, + "--url", url] + us = _sel(params.get("username_selector") or policy.username_selector) + ps = _sel(params.get("password_selector") or policy.password_selector) + ss = _sel(params.get("submit_selector") or policy.submit_selector) + if us: + argv += ["--username-selector", us] + if ps: + argv += ["--password-selector", ps] + if ss: + argv += ["--submit-selector", ss] + if op == "browser_fill_credential": + argv.append("--no-navigate") + elif op != "browser_login": + raise invalid_argument(f"unsupported browser op {op!r}") + session = params.get("session") + if session: + if not re.match(r"^[\w.\-]{1,64}$", str(session)): + raise invalid_argument("bad session name") + argv += ["--session", str(session)] + + plugin = shutil.which("agent-broker-plugin") + if not plugin: + raise unsafe_tool_integration( + "agent-broker-plugin not on PATH; browser adapter unavailable") + # child PATH must resolve the right node (agent-browser shebang) and + # the plugin binary; both live outside the base PATH on this host. + extra_paths = sorted({os.path.dirname(exe), os.path.dirname(plugin)}) + + env = clean_env({ + "AGENT_BROKER_SOCKET": str(socket_path()), + "AGENT_BROWSER_CONFIG_DIR": os.environ.get( + "AGENT_BROWSER_CONFIG_DIR", os.path.expanduser("~/.agent-browser")), + # agent-browser session daemons live under $XDG_RUNTIME_DIR; without it + # the child cannot attach to an existing session browser. + "XDG_RUNTIME_DIR": os.environ.get("XDG_RUNTIME_DIR", ""), + }, prepend_paths=extra_paths) + res = run_subprocess(argv, env=env, timeout=120, secrets=item_secrets(item)) + res["op"] = op + res["via"] = "agent-browser credential.read plugin" + return res diff --git a/agent_credential_broker/adapters/cli_profile.py b/agent_credential_broker/adapters/cli_profile.py new file mode 100644 index 0000000..20f66bd --- /dev/null +++ b/agent_credential_broker/adapters/cli_profile.py @@ -0,0 +1,168 @@ +"""CLI-profile adapter: bounded subprocess execution of pre-declared +tools only. There is intentionally NO generic "run with secret" -- every +profile is a declarative allowlist: + + { + "id": "example-cli", + "executable": "example-cli", + "injection": {"method": "env", "name": "EXAMPLE_TOKEN"}, + "allowed_subcommands": ["list", "status"], + "arg_regex": "^[\\w .\\-/:]+$", + "forbidden_regex": "[;&|`$<>(){}\\[\\]]", + "target_hosts": ["api.example.com"], + "timeout": 30, + "max_output_bytes": 65536 + } + +Injection methods (in order of preference): + stdin -- secret written to child stdin (no argv, no env, no file) + file -- 0600 tmpfs file rendered from file_template ({SECRET} + placeholder); the child gets its PATH via env_name and/or + profile args_template entries containing {SECRET_FILE} + env -- secret exported ONLY into the child environment + none -- no secret reaches the child (metadata-only calls) + +Profile args_template entries are broker-supplied args (they may embed +{SECRET_FILE} but NEVER the secret itself), so tools like curl can pick +credentials up from config files without argv exposure. Agent-supplied +args are validated separately. +argv is never an injection channel. +""" + +from __future__ import annotations + +import json +import re +import shutil +import urllib.parse +from pathlib import Path + +from ..config import profiles_dir +from ..errors import denied, invalid_argument +from ..items import item_secrets +from .common import clean_env, run_subprocess, secret_dir, write_secret_file + +_DEFAULT_ARG_RE = r"^[\w ./:@\-+=%]+$" +_DEFAULT_FORBIDDEN_RE = r"[;&|`$<>(){}\[\]\n!#]|(^|/)\.\.(/|$)" +_MAX_ARGS = 64 + + +def load_profile(profile_id: str) -> dict: + if not profile_id or not re.match(r"^[\w.\-]+$", profile_id): + raise invalid_argument("bad profile id") + path = profiles_dir() / f"{profile_id}.json" + if not path.is_file(): + raise denied(f"unknown cli profile {profile_id!r}") + try: + profile = json.loads(path.read_text()) + except Exception: + raise denied(f"invalid cli profile {profile_id!r}") + if profile.get("id") != profile_id: + raise denied(f"profile id mismatch in {profile_id!r}") + if profile.get("disabled"): + raise denied(f"cli profile {profile_id!r} is disabled") + return profile + + +def _validate_args(profile, args): + if not isinstance(args, list) or len(args) > _MAX_ARGS: + raise invalid_argument("bad args") + arg_re = re.compile(profile.get("arg_regex") or _DEFAULT_ARG_RE) + forbidden = profile.get("forbidden_regex") + for a in args: + if not isinstance(a, str) or not a or len(a) > 500: + raise invalid_argument("bad argument") + if not arg_re.match(a): + raise invalid_argument(f"argument {a!r} rejected by profile arg_regex") + if forbidden and re.search(forbidden, a): + raise invalid_argument(f"argument {a!r} rejected by profile forbidden_regex") + subs = profile.get("allowed_subcommands") + if subs: + positional = [a for a in args if not a.startswith("-")] + if not positional or positional[0] not in subs: + raise invalid_argument( + f"first positional must be one of {subs}") + targets = {h.lower() for h in (profile.get("target_hosts") or [])} + if targets: + for a in args: + if "://" in a: + host = (urllib.parse.urlparse(a).hostname or "").lower() + if host not in targets: + raise invalid_argument( + f"url target {host!r} not in profile target_hosts") + + +def validate(item, policy, params, config): + profile_id = params.get("profile") + profile = load_profile(profile_id) + if policy.cli_profile and policy.cli_profile != profile_id: + raise denied(f"credential {policy.ref!r} is bound to profile {policy.cli_profile!r}") + _validate_args(profile, params.get("args") or []) + + +def run(item, policy, params, config): + profile_id = params.get("profile") + profile = load_profile(profile_id) + if policy.cli_profile and policy.cli_profile != profile_id: + raise denied(f"credential {policy.ref!r} is bound to profile {policy.cli_profile!r}") + + executable = profile.get("executable") or "" + if "/" in executable or ".." in executable: + raise denied("profile executable must be a bare name") + exe = shutil.which(executable) + if not exe: + raise denied(f"executable {executable!r} not found") + + args = params.get("args") or [] + _validate_args(profile, args) + + injection = profile.get("injection") or {} + method = injection.get("method") + secret = item.get("password") or "" + secrets = item_secrets(item) + timeout = int(profile.get("timeout") or 30) + argv = [exe] + [str(a) for a in args] + input_text = None + + if method == "stdin": + input_text = secret + "\n" + env = clean_env(profile.get("env") or {}) + elif method == "file": + with secret_dir() as d: + sfile = d / "secret" + template = injection.get("file_template") or "{SECRET}" + if "{SECRET}" not in template: + raise denied("file_template must contain {SECRET}") + write_secret_file(sfile, template.replace("{SECRET}", secret)) + env = clean_env(profile.get("env") or {}) + env_name = injection.get("env_name") + if env_name: + env[env_name] = str(sfile) + extra = [a.replace("{SECRET_FILE}", str(sfile)) + for a in (profile.get("args_template") or [])] + argv = [exe] + extra + argv[1:] + res = run_subprocess(argv, env=env, input_text=None, + timeout=timeout, secrets=secrets) + res["profile"] = profile_id + res["injection"] = "file" + return _cap(profile, res) + elif method == "env": + env = clean_env(profile.get("env") or {}) + env[injection.get("name") or "CREDENTIAL"] = secret + else: + env = clean_env(profile.get("env") or {}) + if method not in (None, "none"): + raise denied(f"unknown injection method {method!r}") + + res = run_subprocess(argv, env=env, input_text=input_text, + timeout=timeout, secrets=secrets) + res["profile"] = profile_id + res["injection"] = method or "none" + return _cap(profile, res) + + +def _cap(profile, res): + cap = int(profile.get("max_output_bytes") or 65536) + res["stdout"] = res["stdout"][:cap] + res["stderr"] = res["stderr"][:cap] + return res diff --git a/agent_credential_broker/adapters/common.py b/agent_credential_broker/adapters/common.py new file mode 100644 index 0000000..dba45ef --- /dev/null +++ b/agent_credential_broker/adapters/common.py @@ -0,0 +1,117 @@ +"""Shared helpers for adapters: bounded subprocesses and askpass scripts. + +Secret-bearing helper files are created only under +$XDG_RUNTIME_DIR/agent-broker/tmp (tmpfs, 0700 dir, 0600 +files) and deleted immediately after use. Secrets never appear on argv. +""" + +from __future__ import annotations + +import os +import secrets as _secrets +import subprocess +from contextlib import contextmanager +from pathlib import Path + +from ..config import tmp_dir +from ..sanitize import sanitize_text, sanitize_bytes + + +def make_secret_dir() -> Path: + d = tmp_dir() / _secrets.token_hex(12) + d.mkdir(mode=0o700) + return d + + +def write_secret_file(path: Path, value: str): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as fh: + fh.write(value) + + +def write_askpass(dirpath: Path, user_file: Path | None, pass_file: Path) -> Path: + """askpass script reads values from sibling files -- the secret is + never inside the script itself or on any argv.""" + script = dirpath / "askpass.sh" + lines = [ + "#!/bin/sh", + "case \"$1\" in", + ] + if user_file is not None: + lines += [ + " *sername*|*ogin*) cat %s ;;", + f" *) cat {pass_file} ;;", + ] + else: + lines.append(f" *) cat {pass_file} ;;") + lines.append("esac") + if user_file is not None: + lines[3] = lines[3] % user_file + script.write_text("\n".join(lines) + "\n") + os.chmod(script, 0o700) + return script + + +def run_subprocess(argv: list[str], *, env: dict | None = None, input_text: str | None = None, + timeout: int = 60, secrets: list[str] = ()) -> dict: + """Run argv without a shell. Returns sanitized {exit_code, stdout, + stderr}. Never raises the child's output raw.""" + try: + proc = subprocess.run( + argv, + env=env, + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + ) + rc, raw_out, raw_err = proc.returncode, proc.stdout, proc.stderr + timed_out = False + except subprocess.TimeoutExpired as e: + rc, timed_out = 124, True + raw_out = (e.stdout or b"").decode("utf-8", "replace") if isinstance(e.stdout, bytes) else (e.stdout or "") + raw_err = ((e.stderr or b"").decode("utf-8", "replace") if isinstance(e.stderr, bytes) else (e.stderr or "")) + \ + f"\n[timeout after {timeout}s]" + out = sanitize_bytes(raw_out.encode(), [s.encode() for s in secrets if s]) + err = sanitize_bytes(raw_err.encode(), [s.encode() for s in secrets if s]) + result = { + "exit_code": rc, + "stdout": sanitize_text(out.decode("utf-8", "replace"), secrets), + "stderr": sanitize_text(err.decode("utf-8", "replace"), secrets), + } + if timed_out: + result["timed_out"] = True + return result + + +@contextmanager +def secret_dir(): + d = make_secret_dir() + try: + yield d + finally: + for child in d.iterdir(): + try: + child.unlink() + except OSError: + pass + try: + d.rmdir() + except OSError: + pass + + +def clean_env(extra: dict | None = None, prepend_paths: list[str] = ()) -> dict: + """Minimal environment for secret-bearing children: no inherited + credential variables are added beyond what the adapter explicitly + injects. prepend_paths lists directories that must precede the base + PATH (e.g. the dir of a resolved executable whose shebang needs a + specific interpreter).""" + env = { + "PATH": ":".join([*prepend_paths, "/usr/local/bin", "/usr/bin", "/bin"]), + "HOME": os.environ.get("HOME", "/"), + "LANG": "C.UTF-8", + } + if extra: + env.update(extra) + return env diff --git a/agent_credential_broker/adapters/git.py b/agent_credential_broker/adapters/git.py new file mode 100644 index 0000000..9e26744 --- /dev/null +++ b/agent_credential_broker/adapters/git.py @@ -0,0 +1,135 @@ +"""Git adapter: clone/fetch/push over HTTPS with askpass auth. + +The token/password is supplied through a generated GIT_ASKPASS script +that cats 0600 runtime-tmpfs files -- it never appears on argv, never +inside the remote URL, and never in output (output is sanitized too). +Credentials embedded in URLs (https://user:pass@host) are rejected. +""" + +from __future__ import annotations + +import configparser +import os +import re +import shutil +import urllib.parse +from pathlib import Path + +from ..errors import invalid_argument, origin_denied, unsafe_tool_integration +from ..policy import require_url_allowed +from ..items import item_secrets +from .common import (clean_env, run_subprocess, secret_dir, write_askpass, + write_secret_file) + +_OPS = {"clone", "fetch", "push"} +_REFSPEC_OK = re.compile(r"^[\w\-./:+]+$") + + +def _validate_repo_url(item, policy, url): + if not url: + raise invalid_argument("missing repository url") + if url.startswith("git@") or url.startswith("ssh://"): + raise unsafe_tool_integration( + "git: ssh remotes not wired to vault credentials in this phase; use https") + p = urllib.parse.urlparse(url) + if p.scheme not in ("https", "http"): + raise invalid_argument(f"unsupported git remote scheme {p.scheme!r}") + if "@" in (p.netloc or ""): + raise invalid_argument("credentials must not be embedded in the remote URL") + require_url_allowed(policy, url) + + +def _check_workdir(config_roots, workdir): + wd = Path(workdir).resolve() + roots = [Path(r).expanduser().resolve() for r in config_roots] or [Path.home()] + if not any(str(wd).startswith(str(r) + os.sep) or wd == r for r in roots): + raise origin_denied("git", f"workdir {wd} outside allowed roots") + if not (wd / ".git").is_dir(): + raise invalid_argument(f"{wd} is not a git worktree") + return wd + + +def _remote_url(workdir: Path, remote: str) -> str: + """Read the remote's URL straight from .git/config -- the credential + will actually go there, so THAT is the URL we validate, not whatever + the agent claimed in `repository`.""" + cfg = configparser.RawConfigParser() + cfg.read(workdir / ".git" / "config") + try: + url = cfg.get(f'remote "{remote}"', "url") + except configparser.Error: + raise invalid_argument(f"remote {remote!r} has no url configured") + if "://" in url and "@" in urllib.parse.urlparse(url).netloc: + raise invalid_argument("remote URL has embedded credentials") + return url + + +def validate(item, policy, params, config): + op = params.get("op") or params.get("operation") + if op not in _OPS: + raise invalid_argument(f"git op must be one of {sorted(_OPS)}") + _validate_repo_url(item, policy, params.get("repository") or "") + if op != "clone": + workdir = _check_workdir(config.git_workdir_roots, params.get("workdir") or ".") + remote = params.get("remote") or "origin" + remote_url = _remote_url(workdir, remote) + _validate_repo_url(item, policy, remote_url) + + +def run(item, policy, params, config): + op = params.get("op") or params.get("operation") + if op not in _OPS: + raise invalid_argument(f"git op must be one of {sorted(_OPS)}") + repo_url = params.get("repository") or "" + _validate_repo_url(item, policy, repo_url) + + username = item.get("username") + password = item.get("password") + if not password: + raise unsafe_tool_integration("git: item has no token/password") + secrets = item_secrets(item) + + git = shutil.which("git") or "/usr/bin/git" + with secret_dir() as d: + ufile = d / "user" + pfile = d / "pass" + write_secret_file(ufile, username or "oauth2") + write_secret_file(pfile, password) + askpass = write_askpass(d, ufile, pfile) + env = clean_env({ + "GIT_ASKPASS": str(askpass), + "GIT_TERMINAL_PROMPT": "0", + }) + + if op == "clone": + dest = params.get("dest") + if not dest: + raise invalid_argument("clone requires dest") + argv = [git, "clone", "--", repo_url, str(dest)] + else: + workdir = _check_workdir(config.git_workdir_roots, params.get("workdir") or ".") + remote = params.get("remote") or "origin" + if not _REFSPEC_OK.match(remote): + raise invalid_argument("bad remote name") + remote_url = _remote_url(workdir, remote) + if remote_url.startswith("git@") or remote_url.startswith("ssh://"): + raise unsafe_tool_integration( + "git: ssh remotes not wired to vault credentials in this phase") + _validate_repo_url(item, policy, remote_url) + claimed = urllib.parse.urlparse(repo_url).netloc + actual = urllib.parse.urlparse(remote_url).netloc + if claimed.lower() != actual.lower(): + raise origin_denied(policy.ref, + f"repository claim {claimed!r} != remote {actual!r}") + argv = [git, "-C", str(workdir)] + if op == "fetch": + argv += ["fetch", "--", remote] + else: + refspec = params.get("refspec") or params.get("branch") or "HEAD" + if not _REFSPEC_OK.match(refspec): + raise invalid_argument("bad refspec") + argv += ["push", "--", remote, refspec] + result = run_subprocess(argv, env=env, timeout=180, secrets=secrets) + result["op"] = op + result["repository"] = urllib.parse.urlparse(repo_url).netloc + return result diff --git a/agent_credential_broker/adapters/http.py b/agent_credential_broker/adapters/http.py new file mode 100644 index 0000000..fce5be9 --- /dev/null +++ b/agent_credential_broker/adapters/http.py @@ -0,0 +1,113 @@ +"""HTTP adapter: credential-bearing requests without exposing headers. + +Supported auth types (from agent.auth_type): + bearer -> Authorization: Bearer + basic -> Authorization: Basic base64(username:password) + none -> no credential header (metadata-only requests) + +Destination is origin-validated against agent.allowed_urls / item URIs. +Redirects are NOT followed by default; with follow_redirects=true each +hop is re-validated before the credential is attached. +""" + +from __future__ import annotations + +import base64 +import urllib.error +import urllib.request + +from ..errors import invalid_argument +from ..policy import require_url_allowed +from ..sanitize import sanitize_headers, sanitize_text +from ..items import item_secrets + +_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} +_FORBIDDEN_REQ_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie", "host", "content-length"} +_MAX_BODY = 64 * 1024 + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *a, **k): + return None + + +def _auth_header(item, policy) -> tuple[str, str] | None: + if policy.auth_type == "none": + return None + password = item.get("password") or "" + if policy.auth_type == "bearer": + return ("Authorization", f"Bearer {password}") + user = item.get("username") or "" + return ("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()) + + +def validate(item, policy, params, config): + method = (params.get("method") or "GET").upper() + if method not in _METHODS: + raise invalid_argument(f"unsupported HTTP method {method!r}") + require_url_allowed(policy, params.get("url") or "") + + +def run(item, policy, params, config): + url = params.get("url") or "" + method = (params.get("method") or "GET").upper() + if method not in _METHODS: + raise invalid_argument(f"unsupported HTTP method {method!r}") + require_url_allowed(policy, url) + + headers = {} + for k, v in (params.get("headers") or {}).items(): + if k.lower() in _FORBIDDEN_REQ_HEADERS: + raise invalid_argument(f"request header {k!r} not allowed") + headers[k] = str(v) + auth = _auth_header(item, policy) + if auth: + headers[auth[0]] = auth[1] + + body = params.get("body") + if body is not None: + body = str(body).encode() + if len(body) > _MAX_BODY: + raise invalid_argument("request body too large") + + secrets = item_secrets(item) + if auth: + secrets.append(auth[1]) + secrets.append(auth[1].split(" ", 1)[-1]) + + opener = urllib.request.build_opener(_NoRedirect) if not params.get("follow_redirects") else urllib.request.build_opener() + if params.get("follow_redirects"): + # urllib would follow redirects automatically; revalidation of + # each hop happens via a custom handler would be ideal, so we + # conservatively refuse cross-origin redirects by checking the + # final URL afterwards. + pass + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with opener.open(req, timeout=20) as resp: + status = resp.status + resp_headers = dict(resp.headers.items()) + final_url = resp.geturl() + data = resp.read(256 * 1024 + 1) + except urllib.error.HTTPError as e: + status = e.code + resp_headers = dict(e.headers.items()) if e.headers else {} + final_url = e.geturl() + data = e.read(256 * 1024 + 1) + except urllib.error.URLError: + raise invalid_argument("request failed (network error)") + except ValueError: + raise invalid_argument("malformed URL or redirect") + + if params.get("follow_redirects"): + require_url_allowed(policy, final_url) + + truncated = len(data) > 256 * 1024 + data = data[:256 * 1024] + return { + "status": status, + "final_url": sanitize_text(final_url, secrets), + "headers": sanitize_headers(resp_headers, secrets), + "body": sanitize_text(data.decode("utf-8", "replace"), secrets), + "truncated": truncated, + } diff --git a/agent_credential_broker/adapters/ssh.py b/agent_credential_broker/adapters/ssh.py new file mode 100644 index 0000000..406da80 --- /dev/null +++ b/agent_credential_broker/adapters/ssh.py @@ -0,0 +1,100 @@ +"""SSH adapter: broker-managed ssh authentication. + +Two credential modes: + * agent.ssh_key_path set -> uses that existing local private key file + (path metadata only -- key bytes never move through the broker) + * otherwise -> password auth via SSH_ASKPASS, where the + password is a 0600 file under the runtime tmpfs that the generated + askpass script cats at prompt time. Never on argv. + +The remote command is passed as a single argv element to ssh -- no local +shell interpolation is possible. +""" + +from __future__ import annotations + +import os +import re +import shutil + +from ..config import state_dir +from ..errors import invalid_argument, unsafe_tool_integration +from ..policy import require_host_allowed +from ..items import item_secrets +from .common import (clean_env, run_subprocess, secret_dir, write_askpass, + write_secret_file) + + +def _known_hosts(): + """Broker-managed known_hosts (TOFU trust stays in the broker's own + state dir instead of silently writing to the user's ~/.ssh).""" + d = state_dir() / "ssh" + d.mkdir(mode=0o700, parents=True, exist_ok=True) + p = d / "known_hosts" + if not p.exists(): + p.touch(mode=0o600) + return p + +_CMD_MAX = 2000 +_DANGEROUS = re.compile(r"[;&|`$<>]|^\s*(sudo\s+)?(rm|dd|mkfs|shutdown|reboot|poweroff)\b") + + +def validate(item, policy, params, config): + host = params.get("host") or "" + command = params.get("command") or "" + if not command or len(command) > _CMD_MAX: + raise invalid_argument("missing or oversized command") + if _DANGEROUS.search(command): + raise invalid_argument("command rejected by safety policy") + require_host_allowed(policy, host) + + +def run(item, policy, params, config): + host = params.get("host") or "" + command = params.get("command") or "" + port = int(params.get("port") or 22) + if not command or len(command) > _CMD_MAX: + raise invalid_argument("missing or oversized command") + if _DANGEROUS.search(command): + raise invalid_argument("command rejected by safety policy") + require_host_allowed(policy, host) + + ssh = shutil.which("ssh") or "/usr/bin/ssh" + user = policy.ssh_user or item.get("username") or "root" + target = f"{user}@{host}" + argv = [ + ssh, + "-o", "BatchMode=no", + "-o", "ConnectTimeout=15", + "-o", "StrictHostKeyChecking=accept-new", + "-o", f"UserKnownHostsFile={_known_hosts()}", + "-o", "NumberOfPasswordPrompts=1", + "-o", "IdentitiesOnly=yes", + "-p", str(port), + target, + command, + ] + secrets = item_secrets(item) + key_path = policy.ssh_key_path + if key_path: + if not os.path.isfile(key_path): + raise unsafe_tool_integration("ssh: agent.ssh_key_path does not exist") + argv = argv[:-2] + ["-i", key_path] + argv[-2:] + result = run_subprocess(argv, env=clean_env(), timeout=30, secrets=secrets) + else: + password = item.get("password") + if not password: + raise unsafe_tool_integration("ssh: no key path and no password on item") + with secret_dir() as d: + pwfile = d / "pw" + write_secret_file(pwfile, password) + askpass = write_askpass(d, None, pwfile) + env = clean_env({ + "SSH_ASKPASS": str(askpass), + "SSH_ASKPASS_REQUIRE": "force", + "DISPLAY": "broker:0", + }) + result = run_subprocess(argv, env=env, timeout=30, secrets=secrets) + result["host"] = host + result["auth"] = "key" if key_path else "password-askpass" + return result diff --git a/agent_credential_broker/audit.py b/agent_credential_broker/audit.py new file mode 100644 index 0000000..2efe09a --- /dev/null +++ b/agent_credential_broker/audit.py @@ -0,0 +1,41 @@ +"""Audit logging -- metadata only, never secret material.""" + +from __future__ import annotations + +import json +import threading +from datetime import datetime, timezone + +from .config import audit_log_path + +_lock = threading.Lock() + + +def _scrub(record: dict) -> dict: + return {k: v for k, v in record.items() if v is not None} + + +def audit(operation: str, *, ref=None, adapter=None, target=None, + result="ok", error_code=None, detail=None): + entry = _scrub({ + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "op": operation, + "ref": ref, + "adapter": adapter, + "target": target, + "result": result, + "error": error_code, + "detail": detail, + }) + line = json.dumps(entry, sort_keys=True) + # Belt-and-suspenders: audit lines must never carry secret material. + for banned in ("password", "secret", "token", "key"): + if isinstance(detail, str) and banned in detail.lower(): + entry["detail"] = "REDACTED_DETAIL" + line = json.dumps(_scrub(entry), sort_keys=True) + break + try: + with _lock, open(audit_log_path(), "a") as fh: + fh.write(line + "\n") + except OSError: + pass # audit must never break operations diff --git a/agent_credential_broker/backends/__init__.py b/agent_credential_broker/backends/__init__.py new file mode 100644 index 0000000..64eaa8b --- /dev/null +++ b/agent_credential_broker/backends/__init__.py @@ -0,0 +1,70 @@ +"""Vault backend abstraction -- the ONLY layer that talks to the vault. + + bitwarden-cli official `bw` subprocesses against a + Bitwarden-compatible server (transition backend). + openbao OpenBao/Vault HTTP API (KV v2 + AppRole), stdlib + urllib only -- the target backend for agent + credentials. + +A failing backend surfaces as VAULT_BACKEND_ERROR; there is no fallback. + +The backend owns authentication, the unlocked session and vault reads. +Everything else (policy, refs, adapters, sanitizer, MCP, unix socket) +is backend-agnostic. +""" + +from __future__ import annotations + +from ..errors import BrokerError + + +class VaultBackend: + """Interface consumed by BrokerCore. + + unlock(password) authenticate + establish session (raises + UNLOCK_FAILED on bad password, VAULT_BACKEND_ERROR + otherwise) + lock() invalidate session + drop in-memory material + list_metadata() items with secret fields stripped (ref index) + get_item(item_id) full normalized item, fetched on demand + """ + + name = "abstract" + + def __init__(self, config): + self.config = config + + def configured(self) -> bool: + raise NotImplementedError + + def unlock(self, password: str) -> None: + raise NotImplementedError + + def lock(self) -> None: + raise NotImplementedError + + def logout(self) -> None: + """End the vault session AND the stored account login. Default + equals lock() for backends without a persistent login.""" + self.lock() + + def list_metadata(self) -> list[dict]: + raise NotImplementedError + + def get_item(self, item_id: str) -> dict: + raise NotImplementedError + + +def make_backend(config) -> VaultBackend: + name = config.vault_backend + if name == "bitwarden-cli": + from .bwcli import BitwardenCliBackend + return BitwardenCliBackend(config) + if name == "openbao": + from .openbao import OpenBaoBackend + return OpenBaoBackend(config) + raise BrokerError( + "UNCONFIGURED", + f"unknown vault_backend {name!r} " + "(supported: 'bitwarden-cli', 'openbao')", + ) diff --git a/agent_credential_broker/backends/bwcli.py b/agent_credential_broker/backends/bwcli.py new file mode 100644 index 0000000..307d20d --- /dev/null +++ b/agent_credential_broker/backends/bwcli.py @@ -0,0 +1,216 @@ +"""Official Bitwarden CLI vault backend. + +All vault cryptography (master-key derivation, EncString, sync format) +is delegated to the maintained `bw` client. The broker spawns it as a +subprocess with a dedicated, isolated profile: + + vault server --(bw)--> private pipe --> broker --> adapters + +Secret channels: + BW_CLIENTID / BW_CLIENTSECRET login child env only + AGENT_BROKER_MASTER_PW unlock child env only (--passwordenv) + BW_SESSION data ops child env only, held in memory, + never persisted, invalidated by `bw lock` + +Secrets never appear on argv and `bw` stdout/stderr is never forwarded +raw -- errors surface sanitized excerpts only. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +from .. import errors +from ..config import read_secret_file +from ..errors import BrokerError, backend_error +from ..items import metadata_projection, normalize_bw_item +from ..sanitize import sanitize_text +from . import VaultBackend + +_PW_ENV = "AGENT_BROKER_MASTER_PW" # env var NAME only -- value lives in child env +_TIMEOUT = 90 + + +class BitwardenCliBackend(VaultBackend): + name = "bitwarden-cli" + + def __init__(self, config): + super().__init__(config) + self.bw = config.bw_path or shutil.which("bw") + self._session: str | None = None + self._meta: list[dict] = [] + + # -- plumbing -------------------------------------------------------- + + def configured(self) -> bool: + return (bool(self.bw) and Path(self.bw).exists() + and self.config.client_id_file.exists() + and self.config.client_secret_file.exists()) + + def _appdata(self) -> Path: + d = self.config.bw_appdata_dir + d.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(d, 0o700) + return d + + def _env(self, extra: dict | None = None) -> dict: + """Child-scoped environment. PATH puts the directory containing + `bw` first so its `#!/usr/bin/env node` shebang resolves the same + Node.js the CLI was installed with (a stale system node breaks + the WASM crypto layer). The npm `bw` is a symlink into lib/ -- + do NOT resolve it: the bin dir itself holds the right `node`.""" + bw_dir = str(Path(self.bw).parent) + env = { + "PATH": f"{bw_dir}:/usr/local/bin:/usr/bin:/bin", + "HOME": os.environ.get("HOME", "/"), + "LANG": "C.UTF-8", + "BITWARDENCLI_APPDATA_DIR": str(self._appdata()), + } + if extra: + env.update(extra) + return env + + def _run(self, args: list[str], *, extra_env=None, secrets=(), stdin=None, + timeout=_TIMEOUT) -> tuple[int, str, str]: + """Spawn `bw` with secrets confined to child env/stdin. Returns + RAW stdout/stderr -- callers must sanitize before surfacing.""" + env = self._env(extra_env) + try: + proc = subprocess.run( + [self.bw, *args], env=env, input=stdin, + capture_output=True, text=True, timeout=timeout, + ) + except FileNotFoundError: + raise backend_error(f"bitwarden CLI not found at {self.bw}") + except subprocess.TimeoutExpired: + raise backend_error(f"bw {args[0] if args else '?'} timed out") + return proc.returncode, proc.stdout, proc.stderr + + def _fail(self, op: str, err: str, secrets) -> BrokerError: + detail = sanitize_text(err.strip(), secrets)[:300] + low = detail.lower() + if "vault is locked" in low or "not logged in" in low: + return errors.locked() + return backend_error(f"bw {op} failed: {detail or 'unknown error'}") + + def _ok(self, op: str, rc: int, out: str, err: str, secrets=()) -> str: + if rc != 0: + raise self._fail(op, err, secrets) + return out + + def _bw_status(self) -> dict: + rc, out, err = self._run(["status"]) + self._ok("status", rc, out, err) + try: + return json.loads(out) + except json.JSONDecodeError: + raise backend_error("unparseable bw status output") + + # -- VaultBackend ----------------------------------------------------- + + def _ensure_server(self, st: dict) -> dict: + if (st.get("serverUrl") or "").rstrip("/") != self.config.vault_url: + rc, out, err = self._run(["config", "server", self.config.vault_url]) + self._ok("config", rc, out, err) + return st + + def _login(self): + client_id = read_secret_file(self.config.client_id_file) + client_secret = read_secret_file(self.config.client_secret_file) + secrets = (client_id, client_secret) + try: + rc, out, err = self._run( + ["login", "--apikey", "--nointeraction"], + extra_env={"BW_CLIENTID": client_id, + "BW_CLIENTSECRET": client_secret}, + secrets=secrets) + if rc != 0: + raise self._fail("login", err, secrets) + # stdout may carry a session key -- captured, never surfaced + finally: + client_id = client_secret = None + + def unlock(self, password: str) -> None: + st = self._ensure_server(self._bw_status()) + if st.get("status") == "unauthenticated": + self._login() + secrets = (password,) + try: + rc, out, err = self._run( + ["unlock", "--passwordenv", _PW_ENV, "--raw", "--nointeraction"], + extra_env={_PW_ENV: password}, secrets=secrets) + if rc != 0 or not out.strip(): + clean = sanitize_text(err.strip(), secrets) + detail = ([l for l in clean.splitlines() if l.strip()] or [""])[-1][:200] + raise BrokerError( + "UNLOCK_FAILED", + f"unlock failed: {detail or 'master password rejected'}") + self._session = out.strip().splitlines()[-1].strip() + finally: + password = None + self._sync() + raw = self._list_items_raw() + self._meta = [metadata_projection(normalize_bw_item(i)) for i in raw] + raw = None + + def lock(self) -> None: + try: + if self._session: + self._run(["lock", "--nointeraction"], secrets=(self._session,)) + except errors.BrokerError: + pass + finally: + self._session = None + self._meta = [] + + def logout(self) -> None: + try: + self._run(["logout", "--nointeraction"], secrets=(self._session,) if self._session else ()) + except errors.BrokerError: + pass + finally: + self._session = None + self._meta = [] + + def list_metadata(self) -> list[dict]: + return self._meta + + def _data_env(self) -> dict: + if not self._session: + raise errors.locked() + return {"BW_SESSION": self._session} + + def _sync(self): + secrets = (self._session,) + rc, out, err = self._run(["sync", "--nointeraction"], + extra_env=self._data_env(), secrets=secrets) + self._ok("sync", rc, out, err, secrets) + + def _list_items_raw(self) -> list[dict]: + secrets = (self._session,) + rc, out, err = self._run(["list", "items", "--nointeraction"], + extra_env=self._data_env(), secrets=secrets) + self._ok("list items", rc, out, err, secrets) + try: + data = json.loads(out) + except json.JSONDecodeError: + raise backend_error("unparseable bw list output") + if not isinstance(data, list): + raise backend_error("unexpected bw list output") + return data + + def get_item(self, item_id: str) -> dict: + secrets = (self._session,) + rc, out, err = self._run( + ["get", "item", str(item_id), "--nointeraction"], + extra_env=self._data_env(), secrets=secrets) + if rc != 0: + raise self._fail("get item", err, secrets) + try: + return normalize_bw_item(json.loads(out)) + except json.JSONDecodeError: + raise backend_error("unparseable bw get output") diff --git a/agent_credential_broker/backends/openbao.py b/agent_credential_broker/backends/openbao.py new file mode 100644 index 0000000..aedf8ef --- /dev/null +++ b/agent_credential_broker/backends/openbao.py @@ -0,0 +1,377 @@ +"""OpenBao (Vault-compatible) HTTP API backend -- stdlib only. + +Talks to the KV v2 engine at ```` over plain +``urllib.request``; there is no ``bao`` CLI dependency. + + OpenBao --(HTTPS API)--> broker --> adapters + +Layout (design doc sections 3/4/6): + + /data// secret fields {username, password, + totp, uris, notes, ...} + /metadata// custom_metadata: agent.* policy + fields + reserved ``label``/``uris`` + +Key property: the ref index is built exclusively from the *metadata* +endpoints -- ``data/*`` is only ever read by ``get_item`` on demand, so +unlock + list_metadata move zero plaintext secrets through the broker. + +Authentication (non-interactive by design -- the ``password`` argument +of ``unlock()`` is IGNORED for this backend; the 0600 credential files +are the secret material, same model as client_id_file/client_secret_file): + + 1. AGENT_BROKER_OPENBAO_TOKEN (or the env var named by + ``openbao_token_env``) -> verified via auth/token/lookup-self + 2. openbao_token_file (0600) -> verified the same way + 3. openbao_role_id_file + openbao_secret_id_file (0600) + -> POST /v1/auth/approle/login -> auth.client_token + +The token lives only in memory. Renewable tokens are renewed shortly +before expiry; a 401/403 mid-session triggers one transparent AppRole +re-login, then BROKER_LOCKED. lock()/logout() call +auth/token/revoke-self and drop all in-memory material. + +Recommended AppRole policy (minimum): + + path "/data//*" { capabilities = ["read"] } + path "/metadata//*" { capabilities = ["read","list"] } +""" + +from __future__ import annotations + +import http.client +import json +import os +import ssl +import time +import urllib.error +import urllib.request + +from .. import errors +from ..config import read_secret_file +from ..errors import BrokerError, backend_error +from ..items import kv_metadata_projection, normalize_kv_item +from ..sanitize import sanitize_text +from . import VaultBackend + +_RENEW_MARGIN_S = 60 # renew a renewable token when <60s remain +_LIST_MAX_DEPTH = 8 # guard for recursive metadata listing + + +class _ApiError(Exception): + """Sanitized HTTP-layer failure (status + safe detail only).""" + + def __init__(self, status: int, detail: str): + super().__init__(f"HTTP {status}: {detail}") + self.status = status + self.detail = detail + + +class OpenBaoBackend(VaultBackend): + name = "openbao" + + def __init__(self, config): + super().__init__(config) + self._addr = config.openbao_addr + self._mount = config.openbao_mount + self._prefix = config.openbao_prefix + self._timeout = config.request_timeout + self._max_bytes = config.max_response_bytes + self._token: str | None = None + self._token_expires_at: float | None = None + self._renewable = False + self._meta: list[dict] = [] + # Live secrets, used ONLY to scrub error text before surfacing. + self._secrets: tuple = () + self._ssl_ctx = self._build_ssl_ctx(config) + + # -- plumbing ------------------------------------------------------ + + @staticmethod + def _build_ssl_ctx(config): + if not config.openbao_addr.startswith("https://"): + return None + if config.openbao_tls_skip_verify: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return ssl.create_default_context(cafile=config.openbao_ca_cert or None) + + def _headers(self, token: str | None) -> dict: + h = {"Accept": "application/json"} + if token: + h["X-Vault-Token"] = token + if self.config.openbao_namespace: + h["X-Vault-Namespace"] = self.config.openbao_namespace + return h + + def _san(self, text: str) -> str: + """Error text safe to surface: no token/role_id/secret_id echo.""" + return sanitize_text(text or "", self._secrets).strip()[:300] + + def _request(self, method: str, path: str, *, body=None, + token: str | None = None) -> dict: + """One HTTP call. Returns parsed JSON on 2xx; raises _ApiError + on HTTP errors (sanitized) and VAULT_BACKEND_ERROR on transport + or parse failures. ``path`` may carry a query string.""" + url = f"{self._addr}/v1/{path.lstrip('/')}" + data = None + headers = self._headers(token) + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, + method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout, + context=self._ssl_ctx) as resp: + status = resp.status + raw = resp.read(self._max_bytes + 1) + except urllib.error.HTTPError as e: + status = e.code + try: + raw = e.read(self._max_bytes + 1) + except OSError: + raw = b"" + except (urllib.error.URLError, OSError, ValueError, + http.client.HTTPException) as e: + raise backend_error( + f"openbao request failed: {self._san(str(e)) or 'network error'}") + if len(raw) > self._max_bytes: + raise backend_error("openbao response exceeds max_response_bytes") + if status >= 400: + raise _ApiError(status, self._san(self._error_detail(raw))) + if not raw: + return {} + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError): + raise backend_error("unparseable openbao response") + if not isinstance(parsed, dict): + raise backend_error("unexpected openbao response shape") + return parsed + + @staticmethod + def _error_detail(raw: bytes) -> str: + """Vault error bodies are {"errors": ["..."]}; fall back to the + (still sanitized) raw body when the shape differs.""" + try: + errs = json.loads(raw).get("errors") + if isinstance(errs, list) and errs: + return "; ".join(str(x) for x in errs) + except Exception: + pass + return raw.decode("utf-8", "replace") + + def _drop_token(self): + self._token = None + self._token_expires_at = None + self._renewable = False + self._secrets = () + self._meta = [] + + # -- auth ----------------------------------------------------------- + + def _env_token(self) -> str | None: + return os.environ.get(self.config.openbao_token_env) or None + + def _approle_available(self) -> bool: + c = self.config + return (c.openbao_role_id_file.exists() + and c.openbao_secret_id_file.exists()) + + def configured(self) -> bool: + c = self.config + return bool(self._env_token() + or c.openbao_token_file.exists() + or self._approle_available()) + + def _approle_login(self) -> None: + """POST auth/approle/login. 4xx -> UNLOCK_FAILED (bad/insufficient + credentials); anything else -> VAULT_BACKEND_ERROR.""" + c = self.config + role_id = secret_id = None + try: + role_id = read_secret_file(c.openbao_role_id_file) + secret_id = read_secret_file(c.openbao_secret_id_file) + self._secrets = (role_id, secret_id) + resp = self._request( + "POST", "auth/approle/login", + body={"role_id": role_id, "secret_id": secret_id}) + except _ApiError as e: + if 400 <= e.status < 500: + raise BrokerError( + "UNLOCK_FAILED", + f"openbao approle login failed ({e.status}): {e.detail}") + raise backend_error(f"openbao approle login failed ({e.status})") + finally: + role_id = secret_id = None + auth = resp.get("auth") or {} + token = auth.get("client_token") + if not token: + self._secrets = () + raise backend_error("openbao approle login returned no client_token") + self._token = token + self._secrets = (token,) + self._renewable = bool(auth.get("renewable")) + lease = auth.get("lease_duration") + self._token_expires_at = (time.time() + float(lease)) if lease else None + + def _token_file_or_env_login(self) -> bool: + """Direct token from env var or 0600 file -> verify with + auth/token/lookup-self. Returns False when no token source exists.""" + c = self.config + token = self._env_token() + if not token and c.openbao_token_file.exists(): + token = read_secret_file(c.openbao_token_file) + if not token: + return False + self._secrets = (token,) + try: + resp = self._request("GET", "auth/token/lookup-self", token=token) + except _ApiError as e: + self._secrets = () + if 400 <= e.status < 500: + raise BrokerError( + "UNLOCK_FAILED", + f"openbao token rejected ({e.status}): {e.detail}") + raise backend_error(f"openbao token lookup failed ({e.status})") + data = resp.get("data") or {} + self._token = token + self._renewable = bool(data.get("renewable")) + ttl = data.get("ttl") + self._token_expires_at = (time.time() + float(ttl)) if ttl else None + return True + + def _try_relogin(self) -> bool: + """One transparent AppRole re-login after a 401/403 (lazy + re-login, per design). Never raises.""" + if not self._approle_available(): + return False + try: + self._approle_login() + return True + except BrokerError: + return False + + def _renew_token(self) -> None: + """renew-self before expiry. Auth failure drops the token -> + locked; transient failures keep the token for the next call.""" + try: + resp = self._request("POST", "auth/token/renew-self", + token=self._token) + except _ApiError as e: + if e.status in (401, 403): + self._drop_token() + raise errors.locked() + raise backend_error(f"openbao token renew failed ({e.status})") + auth = resp.get("auth") or {} + lease = auth.get("lease_duration") + if lease: + self._token_expires_at = time.time() + float(lease) + self._renewable = bool(auth.get("renewable", self._renewable)) + + def _require_token(self) -> str: + if not self._token: + raise errors.locked() + if self._token_expires_at is not None: + remaining = self._token_expires_at - time.time() + if remaining <= 0: + self._drop_token() + raise errors.locked() + if self._renewable and remaining < _RENEW_MARGIN_S: + self._renew_token() + return self._token + + def _authed(self, method: str, path: str, *, body=None, + allow_404: bool = False, _retry: bool = True): + """Authenticated API call with token lifecycle: renews near + expiry, re-logs once on 401/403 via AppRole, else locks.""" + token = self._require_token() + try: + return self._request(method, path, body=body, token=token) + except _ApiError as e: + if e.status == 404 and allow_404: + return None + if e.status in (401, 403): + if _retry and self._try_relogin(): + return self._authed(method, path, body=body, + allow_404=allow_404, _retry=False) + self._drop_token() + raise errors.locked() + raise backend_error( + f"openbao {method} {path} failed ({e.status}): {e.detail}") + + # -- VaultBackend ---------------------------------------------------- + + def unlock(self, password: str) -> None: + """Non-interactive by design: ``password`` is ignored (documented + in the module docstring -- the 0600 files are the credential).""" + self._drop_token() + if self._token_file_or_env_login(): + pass + elif self._approle_available(): + self._approle_login() + else: + raise errors.unconfigured( + "openbao: no token file/env and no approle credential files") + # Prefetch the ref index -- metadata endpoints only, never data/*. + self._refresh_meta() + + def lock(self) -> None: + token, self._token = self._token, None + try: + if token: + self._request("POST", "auth/token/revoke-self", token=token) + except (BrokerError, _ApiError): + pass # best-effort revoke; local state is dropped regardless + finally: + self._drop_token() + + def logout(self) -> None: + """End the session exactly like lock(): revoke-self + drop.""" + self.lock() + + def list_metadata(self) -> list[dict]: + return self._meta + + def _refresh_meta(self) -> None: + meta = [] + for rel in self._list_keys(""): + resp = self._authed( + "GET", f"{self._mount}/metadata/{self._prefix}/{rel}", + allow_404=True) + cm = (((resp or {}).get("data") or {}).get("custom_metadata")) or {} + meta.append(kv_metadata_projection(rel, cm)) + self._meta = meta + + def _list_keys(self, sub: str, _depth: int = 0) -> list[str]: + """Recursive LIST over metadata//. Yields item ids + relative to the prefix (``foo`` / ``sub/foo``). 404 -> empty.""" + if _depth > _LIST_MAX_DEPTH: + return [] + rel = f"{self._prefix}/{sub}".rstrip("/") + resp = self._authed("GET", f"{self._mount}/metadata/{rel}?list=true", + allow_404=True) + keys = ((resp or {}).get("data") or {}).get("keys") or [] + out = [] + for k in keys: + if not isinstance(k, str) or ".." in k: + continue + if k.endswith("/"): + out += self._list_keys(f"{sub}{k}", _depth + 1) + else: + out.append(f"{sub}{k}") + return out + + def get_item(self, item_id: str) -> dict: + item_id = str(item_id or "") + if not item_id or ".." in item_id or item_id.startswith("/"): + raise errors.not_found(item_id) + resp = self._authed( + "GET", f"{self._mount}/data/{self._prefix}/{item_id}", + allow_404=True) + if resp is None: + raise errors.not_found(item_id) + return normalize_kv_item(item_id, resp.get("data") or {}) diff --git a/agent_credential_broker/cli.py b/agent_credential_broker/cli.py new file mode 100644 index 0000000..52b46e6 --- /dev/null +++ b/agent_credential_broker/cli.py @@ -0,0 +1,118 @@ +"""agent-broker command line. + + agent-broker daemon run the unix-socket daemon (foreground) + agent-broker status + agent-broker unlock interactive, TTY, no echo + (or non-interactive when + master_password_file is configured) + agent-broker lock + agent-broker confirm + +The master password is read with getpass() by default -- it never arrives +via argv or env. If config sets master_password_file, unlock reads it from +that file instead (600 perms + same-owner enforced by read_secret_file). +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import sys + +from . import __version__ +from .config import Config, read_secret_file, socket_path +from .errors import BrokerError +from .server import call, serve_forever + + +def _out(obj): + print(json.dumps(obj, indent=2, sort_keys=True)) + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="agent-broker") + ap.add_argument("--version", action="version", version=__version__) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("daemon", help="run the unix-socket daemon in the foreground") + sub.add_parser("status") + sub.add_parser("unlock", help="interactive unlock (TTY, no echo)") + sub.add_parser("lock") + sub.add_parser("logout", help="lock and log out the vault backend") + c = sub.add_parser("confirm", help="approve a pending confirmation ticket") + c.add_argument("ticket") + sub.add_parser("doctor", help="check installation prerequisites") + l = sub.add_parser("list-refs") + args = ap.parse_args(argv) + + if args.cmd == "daemon": + serve_forever() + return 0 + try: + if args.cmd == "status": + _out(call("status")) + elif args.cmd == "unlock": + mpf = Config.load().master_password_file + if mpf: + password = read_secret_file(mpf) + else: + if not (sys.stdin.isatty() and sys.stderr.isatty()): + raise BrokerError("DENIED", "unlock requires an interactive TTY") + password = getpass.getpass("Master Password: ", stream=sys.stderr) + if not password: + raise BrokerError("DENIED", "empty password") + try: + _out(call("unlock", {"password": password})) + finally: + password = None + elif args.cmd == "lock": + _out(call("lock")) + elif args.cmd == "logout": + _out(call("logout")) + elif args.cmd == "confirm": + _out(call("confirm", {"ticket": args.ticket})) + elif args.cmd == "list-refs": + _out(call("list_refs")) + elif args.cmd == "doctor": + _out(doctor()) + except BrokerError as e: + out = {"ok": False, "error": e.to_dict()} + if getattr(e, "ticket", None): + out["ticket"] = e.ticket + _out(out) + return 1 + return 0 + + +def doctor(): + cfg = Config.load() + import shutil + checks = { + "socket": str(socket_path()), + "vault_backend": cfg.vault_backend, + "agent_browser": bool(shutil.which("agent-browser")), + "git": bool(shutil.which("git")), + "ssh": bool(shutil.which("ssh")), + # bitwarden-cli backend + "vault_url": cfg.vault_url, + "bw": bool(shutil.which("bw") or (cfg.bw_path and os.path.exists(cfg.bw_path))), + "client_id_file": cfg.client_id_file.exists(), + "client_secret_file": cfg.client_secret_file.exists(), + # openbao backend + "openbao_addr": cfg.openbao_addr, + "openbao_role_id_file": cfg.openbao_role_id_file.exists(), + "openbao_secret_id_file": cfg.openbao_secret_id_file.exists(), + "openbao_token_file": cfg.openbao_token_file.exists(), + "openbao_token_env": bool(os.environ.get(cfg.openbao_token_env)), + } + try: + from .backends import make_backend + checks["configured"] = make_backend(cfg).configured() + except BrokerError: + checks["configured"] = False + return checks + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_credential_broker/config.py b/agent_credential_broker/config.py new file mode 100644 index 0000000..830f0c9 --- /dev/null +++ b/agent_credential_broker/config.py @@ -0,0 +1,165 @@ +"""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() diff --git a/agent_credential_broker/core.py b/agent_credential_broker/core.py new file mode 100644 index 0000000..7d3c329 --- /dev/null +++ b/agent_credential_broker/core.py @@ -0,0 +1,256 @@ +"""Broker core: vault state, ref resolution, confirmations, dispatch. + +This object lives inside the daemon process and is the ONLY place where +decrypted credential material exists -- in RAM, under a LOCKED default, +auto-expiring after ``unlock_ttl_seconds``. +""" + +from __future__ import annotations + +import secrets as _secrets +import threading +import time + +from . import errors, policy as pol +from .audit import audit +from .backends import make_backend +from .config import Config +from .items import wipe_item + + +class VaultState: + def __init__(self): + self.items: list[dict] = [] + self.ref_index: dict[str, list[int]] = {} + self.unlocked_at: float | None = None + self.session_approvals: set = set() + self.tickets: dict[str, dict] = {} + + def clear(self): + for item in self.items: + for k, v in item.items(): + item[k] = None + self.items = [] + self.ref_index = {} + self.unlocked_at = None + self.session_approvals = set() + self.tickets = {} + + @property + def unlocked(self) -> bool: + return self.unlocked_at is not None + + +class BrokerCore: + def __init__(self, config: Config): + self.config = config + self.backend = make_backend(config) + self.state = VaultState() + self._lock = threading.RLock() + + # -- lifecycle ----------------------------------------------------- + + def status(self) -> dict: + with self._lock: + self._expire_if_needed() + return { + "locked": not self.state.unlocked, + "configured": self._configured(), + "refs": len(self.state.ref_index) if self.state.unlocked else None, + "backend": self.backend.name, + "socket": "unix", + } + + def _configured(self) -> bool: + return self.backend.configured() + + def _expire_if_needed(self): + st = self.state + if st.unlocked and (time.time() - st.unlocked_at) > self.config.unlock_ttl_seconds: + st.clear() + self.backend.lock() + audit("auto_lock", result="ok", detail="ttl expired") + + def unlock(self, password: str) -> dict: + if not self._configured(): + raise errors.unconfigured() + try: + self.backend.unlock(password) + finally: + password = None + items = self.backend.list_metadata() + with self._lock: + self.state.clear() + self.state.items = items + for i, item in enumerate(items): + p = pol.policy_for(item) + if p: + self.state.ref_index.setdefault(p.ref, []).append(i) + self.state.unlocked_at = time.time() + dupes = [r for r, idx in self.state.ref_index.items() if len(idx) > 1] + audit("unlock", result="ok", detail=f"items={len(items)} refs={len(self.state.ref_index)}") + return { + "unlocked": True, + "items": len(items), + "refs": len(self.state.ref_index), + "duplicate_refs": sorted(dupes), + } + + def lock(self) -> dict: + with self._lock: + self.state.clear() + self.backend.lock() + audit("lock", result="ok") + return {"locked": True} + + def logout(self) -> dict: + with self._lock: + self.state.clear() + self.backend.logout() + audit("logout", result="ok") + return {"locked": True, "logged_out": True} + + # -- refs ----------------------------------------------------------- + + def _require_unlocked(self): + self._expire_if_needed() + if not self.state.unlocked: + raise errors.locked() + + def _resolve_index(self, ref: str) -> int: + """Exact-match ref -> item index. 0 -> NOT_FOUND, >1 -> AMBIGUOUS_REF.""" + idxs = self.state.ref_index.get(ref, []) + if not idxs: + raise errors.not_found(ref) + if len(idxs) > 1: + raise errors.ambiguous(ref, len(idxs)) + return idxs[0] + + def resolve_ref(self, ref: str) -> tuple[dict, pol.CredentialPolicy]: + """Internal resolve: returns (decrypted item, policy). Callers in + the process only -- the socket layer restricts who may receive + the secret material this yields.""" + self._require_unlocked() + if not isinstance(ref, str) or not ref or len(ref) > 200: + raise errors.invalid_argument("bad ref") + item = self.state.items[self._resolve_index(ref)] + p = pol.policy_for(item) + if p is None: + raise errors.not_found(ref) + return item, p + + def list_refs(self) -> list[dict]: + self._require_unlocked() + out = [] + seen = set() + for ref, idxs in self.state.ref_index.items(): + for i in idxs: + p = pol.policy_for(self.state.items[i]) + meta = p.safe_metadata() + if len(idxs) > 1: + meta["duplicate"] = True + if ref not in seen: + out.append(meta) + seen.add(ref) + return sorted(out, key=lambda m: m["ref"]) + + def find_for_url(self, url: str) -> list[str]: + self._require_unlocked() + refs = [] + for ref in self.state.ref_index: + try: + item = self.state.items[self.state.ref_index[ref][0]] + p = pol.policy_for(item) + if p is None: + continue + pol.require_url_allowed(p, url, require_https=False) + refs.append(ref) + except errors.BrokerError: + continue + return sorted(set(refs)) + + # -- confirmation ---------------------------------------------------- + + def _confirm(self, policy, adapter: str, target: str, ticket=None): + level = policy.confirmation + if level == "DENY": + raise errors.denied(f"credential {policy.ref!r} is DENY for confirmation") + if level == "AUTO": + return + if level == "SESSION" and (policy.ref, adapter) in self.state.session_approvals: + return + if ticket: + tk = self.state.tickets.pop(ticket, None) + if (tk and tk["approved"] and time.time() < tk["expires"] + and tk["ref"] == policy.ref and tk["adapter"] == adapter): + if level == "SESSION": + self.state.session_approvals.add((policy.ref, adapter)) + return + raise errors.denied("confirmation ticket invalid or expired") + ticket_id = _secrets.token_urlsafe(24) + self.state.tickets[ticket_id] = { + "ref": policy.ref, "adapter": adapter, "target": target, + "approved": False, "expires": time.time() + self.config.confirmation_ttl_seconds, + } + audit("confirmation_pending", ref=policy.ref, adapter=adapter, target=target) + raise errors.confirmation_required(ticket_id) + + def confirm(self, ticket: str) -> dict: + """Approve a pending ticket. Invoked interactively by the human.""" + tk = self.state.tickets.get(ticket) + if not tk or time.time() > tk["expires"]: + raise errors.denied("unknown or expired ticket") + tk["approved"] = True + tk["expires"] = time.time() + self.config.confirmation_ttl_seconds + audit("confirmed", ref=tk["ref"], adapter=tk["adapter"], target=tk["target"]) + return {"approved": True, "ref": tk["ref"], "adapter": tk["adapter"], "target": tk["target"]} + + # -- adapter dispatch -------------------------------------------------- + + def run(self, adapter: str, params: dict) -> dict: + """Central guarded dispatch used by every adapter.""" + self._require_unlocked() + ref = params.get("credential_ref") or params.get("ref") + ticket = params.get("ticket") + meta, p = self.resolve_ref(ref) + pol.require_adapter(p, adapter) + target = params.get("url") or params.get("host") or params.get("repository") or params.get("profile") or "" + from .adapters import registry + registry.validate(adapter, meta, p, params, self.config) + self._confirm(p, adapter, target, ticket=ticket) + item = self.backend.get_item(meta["id"]) + try: + result = registry.run(adapter, item, p, params, self.config) + audit(adapter, ref=p.ref, adapter=adapter, target=target, result="ok") + return result + except errors.BrokerError as e: + audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code=e.code) + raise + except Exception: + audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code="INTERNAL_ERROR") + raise errors.internal("adapter failed") + finally: + wipe_item(item) + + def resolve_for_plugin(self, ref: str, url: str | None) -> dict: + """Socket-exposed resolve for the agent-browser credential.read + plugin. Only usable for refs whose policy allows the browser + adapter; origin is validated when a URL is provided.""" + meta, p = self.resolve_ref(ref) + pol.require_adapter(p, "browser") + if url: + pol.require_url_allowed(p, url) + audit("resolve", ref=p.ref, adapter="browser", target=url or p.uris[:1]) + item = self.backend.get_item(meta["id"]) + try: + secrets = { + "username": item.get("username"), + "password": item.get("password"), + "url": url or (p.uris[0] if p.uris else None), + "usernameSelector": p.username_selector, + "passwordSelector": p.password_selector, + "submitSelector": p.submit_selector, + } + return {k: v for k, v in secrets.items() if v} + finally: + wipe_item(item) diff --git a/agent_credential_broker/errors.py b/agent_credential_broker/errors.py new file mode 100644 index 0000000..a9611b7 --- /dev/null +++ b/agent_credential_broker/errors.py @@ -0,0 +1,76 @@ +"""Stable machine-readable error codes returned by the broker. + +Every failure crosses the socket/MCP boundary as {code, message} only -- +never with credential material attached. +""" + + +class BrokerError(Exception): + """Error that is safe to surface to the agent (no secret material).""" + + def __init__(self, code, message): + super().__init__(message) + self.code = code + self.message = message + + def to_dict(self): + return {"code": self.code, "message": self.message} + + +def locked(): + return BrokerError("BROKER_LOCKED", "broker is locked; run 'agent-broker unlock'") + + +def unconfigured(detail="missing API client credentials"): + return BrokerError( + "UNCONFIGURED", + f"broker not configured: {detail}", + ) + + +def not_found(ref): + return BrokerError("NOT_FOUND", f"no credential with ref {ref!r}") + + +def ambiguous(ref, count): + return BrokerError("AMBIGUOUS_REF", f"ref {ref!r} matches {count} items; fix agent.ref duplicates") + + +def adapter_denied(ref, adapter): + return BrokerError("ADAPTER_DENIED", f"credential {ref!r} is not allowed for adapter {adapter!r}") + + +def origin_denied(ref, target): + return BrokerError("ORIGIN_DENIED", f"credential {ref!r} is not authorized for target {target!r}") + + +def denied(message): + return BrokerError("DENIED", message) + + +def invalid_argument(message): + return BrokerError("DENIED_INVALID_ARGUMENT", message) + + +def confirmation_required(ticket=None): + err = BrokerError("CONFIRMATION_REQUIRED", "operation requires interactive confirmation") + err.ticket = ticket + return err + + +def unsafe_tool_integration(tool): + return BrokerError( + "UNSAFE_TOOL_INTEGRATION", + f"{tool}: no credential-provider or safe injection path exists; refusing integration", + ) + + +def backend_error(message): + """Vault backend failure (e.g. `bw` subprocess). Never silently + falls back to another backend.""" + return BrokerError("VAULT_BACKEND_ERROR", message) + + +def internal(message): + """Server-side fault. Message must already be secret-free.""" + return BrokerError("INTERNAL_ERROR", message) diff --git a/agent_credential_broker/items.py b/agent_credential_broker/items.py new file mode 100644 index 0000000..62841ad --- /dev/null +++ b/agent_credential_broker/items.py @@ -0,0 +1,172 @@ +"""Normalized vault item helpers shared by every vault backend. + +Internal item shape (produced by all backends):: + + {id, type, name, username, password, totp, uris[], fields{name:value}, notes} + +``metadata_projection`` strips every secret-bearing field so the daemon +can keep a searchable ref index without retaining decrypted credentials. +``wipe_item`` drops secret values in place after a single use. +""" + +from __future__ import annotations + +import json + + +def normalize_bw_item(raw: dict) -> dict: + """Bitwarden CLI JSON item -> internal shape.""" + login = raw.get("login") or {} + fields = {} + for f in raw.get("fields") or []: + name = f.get("name") + if name is not None: + fields[name] = f.get("value") if f.get("value") is not None else "" + uris = [] + for u in login.get("uris") or []: + uri = u.get("uri") + if uri: + uris.append(uri) + return { + "id": raw.get("id"), + "type": raw.get("type"), + "name": raw.get("name"), + "username": login.get("username"), + "password": login.get("password"), + "totp": login.get("totp"), + "uris": uris, + "fields": fields, + "notes": raw.get("notes"), + } + + +def _coerce_str(value) -> str: + """custom_metadata/field values are strings by contract; coerce + defensively so a structured value can never break the shape.""" + if value is None: + return "" + if isinstance(value, str): + return value + return json.dumps(value, sort_keys=True) + + +def _coerce_uris(raw) -> list[str]: + """URIs may be stored as a JSON list, a CSV string or a single + string (``data.uris`` / ``custom_metadata.uris`` in KV v2).""" + if not raw: + return [] + if isinstance(raw, list): + return [str(u) for u in raw if u] + if isinstance(raw, str): + s = raw.strip() + if s.startswith("["): + try: + parsed = json.loads(s) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(u) for u in parsed if u] + return [u.strip() for u in s.split(",") if u.strip()] + return [] + + +def normalize_kv_item(item_id: str, raw: dict) -> dict: + """OpenBao KV v2 read -> internal shape (mirror of normalize_bw_item). + + ``raw`` is the ``data`` object of a ``/data/`` read, + i.e. ``{"data": {...secret fields...}, "metadata": {...}}``. + ``item_id`` is the secret path relative to the configured prefix -- + for OpenBao the id IS the path, so refs cannot collide by id. + + Field mapping (design doc section 4): ``data.{username,password, + totp,uris,notes}`` are the secret-bearing fields; ``custom_metadata`` + carries the ``agent.*`` policy fields plus reserved ``label``/``uris`` + keys. Any other ``data.*`` or ``custom_metadata`` key lands in + ``fields`` exactly like a Bitwarden custom field. + """ + data = (raw.get("data") or {}) if isinstance(raw, dict) else {} + meta = (raw.get("metadata") or {}) if isinstance(raw, dict) else {} + cm = meta.get("custom_metadata") or {} + + fields = {} + for k, v in cm.items(): + if k in ("label", "uris"): + continue # reserved metadata keys, mapped below + fields[k] = _coerce_str(v) + for k, v in data.items(): + if k in ("username", "password", "totp", "uris", "notes"): + continue + if k not in fields: + fields[k] = _coerce_str(v) + + uris = _coerce_uris(data.get("uris")) or _coerce_uris(cm.get("uris")) + return { + "id": item_id, + "type": None, # KV has no item type; nothing consumes it + "name": cm.get("label") or str(item_id).rsplit("/", 1)[-1], + "username": data.get("username"), + "password": data.get("password"), + "totp": data.get("totp"), + "uris": uris, + "fields": fields, + "notes": data.get("notes"), + } + + +def kv_metadata_projection(item_id: str, custom_metadata: dict) -> dict: + """Ref-index entry built from KV ``custom_metadata`` ALONE -- the + metadata API never exposes ``data/*``, so the index can be built + without a single plaintext secret transiting the broker.""" + cm = custom_metadata or {} + pseudo = { + "id": item_id, + "type": None, + "name": cm.get("label") or str(item_id).rsplit("/", 1)[-1], + "uris": _coerce_uris(cm.get("uris")), + "fields": {k: _coerce_str(v) for k, v in cm.items()}, + } + return metadata_projection(pseudo) + + +def metadata_projection(item: dict) -> dict: + """Copy carrying only what ref resolution and policy need -- every + secret-bearing value removed. ``fields`` keeps only ``agent.*`` + policy metadata.""" + return { + "id": item.get("id"), + "type": item.get("type"), + "name": item.get("name"), + "username": None, + "password": None, + "totp": None, + "uris": list(item.get("uris") or []), + "fields": {k: v for k, v in (item.get("fields") or {}).items() + if k.startswith("agent.")}, + "notes": None, + } + + +def wipe_item(item: dict) -> None: + """Drop secret values in place after use.""" + for k in ("password", "totp", "username", "notes"): + item[k] = None + fields = item.get("fields") + if isinstance(fields, dict): + for k in list(fields): + if not k.startswith("agent."): + fields[k] = None + + +def item_secrets(item: dict) -> list[str]: + """All secret strings belonging to an item, for the output sanitizer.""" + secrets = [] + for key in ("password", "totp", "username", "notes"): + v = item.get(key) + if v and len(v) >= 4: + secrets.append(v) + for name, value in (item.get("fields") or {}).items(): + if name.startswith("agent."): + continue # policy metadata is not secret + if value and len(str(value)) >= 4: + secrets.append(str(value)) + return secrets diff --git a/agent_credential_broker/mcp_server.py b/agent_credential_broker/mcp_server.py new file mode 100644 index 0000000..0c4b68e --- /dev/null +++ b/agent_credential_broker/mcp_server.py @@ -0,0 +1,228 @@ +"""MCP stdio server -- a thin, capability-only facade over the broker +unix socket. + +Design rule: this process contains NO crypto and holds NO secret +material. It only translates MCP tool calls into broker socket +operations. By construction there is no get_password / get_secret / +reveal / dump tool -- the tools below map to broker adapters that return +sanitized metadata only. +""" + +from __future__ import annotations + +import json +import sys + +from . import __version__ +from .errors import BrokerError +from .server import call + +PROTOCOL_VERSION = "2024-11-05" +SERVER_NAME = "agent-broker" + + +def _str(name, desc, required=True): + p = {"type": "string", "description": desc} + return p, (required and [name] or []) + + +def _obj(properties, required): + return {"type": "object", "properties": properties, "required": required, + "additionalProperties": False} + + +TOOLS = [ + { + "name": "vault_status", + "description": "Broker status: locked/unlocked, configured, ref count. No secrets.", + "inputSchema": _obj({}, []), + "_method": ("status", lambda a: {}), + }, + { + "name": "vault_lock", + "description": "Lock the broker immediately (drops all in-memory credentials).", + "inputSchema": _obj({}, []), + "_method": ("lock", lambda a: {}), + }, + { + "name": "vault_list_refs", + "description": "List credential references and safe metadata (never secret values).", + "inputSchema": _obj({}, []), + "_method": ("list_refs", lambda a: {}), + }, + { + "name": "vault_find_for_url", + "description": "List credential refs whose policy allows the given URL.", + "inputSchema": _obj({"url": {"type": "string"}}, ["url"]), + "_method": ("find_for_url", lambda a: {"url": a["url"]}), + }, + { + "name": "credential_http_request", + "description": "Run an HTTP request with a credential attached by the broker " + "(bearer/basic per item policy). Returns sanitized response.", + "inputSchema": _obj({ + "credential_ref": {"type": "string"}, + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]}, + "url": {"type": "string"}, + "headers": {"type": "object"}, + "body": {"type": "string"}, + "follow_redirects": {"type": "boolean"}, + "ticket": {"type": "string", "description": "confirmation ticket from a CONFIRMATION_REQUIRED response"}, + }, ["credential_ref", "method", "url"]), + "_method": ("http_request", lambda a: a), + }, + { + "name": "ssh_exec", + "description": "Run a single command over SSH using the credential's host policy. " + "Private keys/passwords never leave the broker.", + "inputSchema": _obj({ + "credential_ref": {"type": "string"}, + "host": {"type": "string"}, + "command": {"type": "string"}, + "port": {"type": "integer"}, + "ticket": {"type": "string"}, + }, ["credential_ref", "host", "command"]), + "_method": ("ssh_exec", lambda a: a), + }, + { + "name": "git_operation", + "description": "git clone/fetch/push using vault credentials via askpass. " + "The credential never appears in the remote URL or argv.", + "inputSchema": _obj({ + "credential_ref": {"type": "string"}, + "op": {"type": "string", "enum": ["clone", "fetch", "push"]}, + "repository": {"type": "string"}, + "workdir": {"type": "string"}, + "dest": {"type": "string"}, + "remote": {"type": "string"}, + "refspec": {"type": "string"}, + "branch": {"type": "string"}, + "ticket": {"type": "string"}, + }, ["credential_ref", "op", "repository"]), + "_method": ("git_op", lambda a: a), + }, + { + "name": "cli_run", + "description": "Run an allowlisted CLI profile with the credential injected by " + "the broker (env/stdin/tmpfs-file). No arbitrary commands.", + "inputSchema": _obj({ + "profile": {"type": "string"}, + "credential_ref": {"type": "string"}, + "args": {"type": "array", "items": {"type": "string"}}, + "ticket": {"type": "string"}, + }, ["profile", "credential_ref"]), + "_method": ("cli_run", lambda a: a), + }, + { + "name": "browser_login", + "description": "Login to a site via agent-browser using the broker " + "credential provider plugin. Secrets flow plugin->agent-browser, " + "never through the agent.", + "inputSchema": _obj({ + "credential_ref": {"type": "string"}, + "url": {"type": "string"}, + "session": {"type": "string"}, + "username_selector": {"type": "string"}, + "password_selector": {"type": "string"}, + "submit_selector": {"type": "string"}, + "ticket": {"type": "string"}, + }, ["credential_ref"]), + "_method": ("browser_login", lambda a: a), + }, + { + "name": "browser_fill_credential", + "description": "Fill username+password on the CURRENT page (no navigation) using " + "the agent-browser credential provider and the item's allowed origin.", + "inputSchema": _obj({ + "credential_ref": {"type": "string"}, + "url": {"type": "string"}, + "session": {"type": "string"}, + "username_selector": {"type": "string"}, + "password_selector": {"type": "string"}, + "submit_selector": {"type": "string"}, + "ticket": {"type": "string"}, + }, ["credential_ref"]), + "_method": ("browser_fill_credential", lambda a: a), + }, +] + +# Hard guard: even a bug in the table above cannot create a secret- +# retrieval tool -- the names are statically checked at startup. +_FORBIDDEN = ("password", "secret", "token", "private_key", "reveal", "dump", "get_") +for _t in TOOLS: + n = _t["name"].lower() + assert not any(b in n for b in _FORBIDDEN), f"forbidden tool name {n}" +del _FORBIDDEN, _t, n + + +def _result(obj): + return {"content": [{"type": "text", "text": json.dumps(obj, indent=2, sort_keys=True)}]} + + +def _error_result(e: BrokerError): + out = {"error": e.to_dict()} + if getattr(e, "ticket", None): + out["ticket"] = e.ticket + return {"content": [{"type": "text", "text": json.dumps(out)}], "isError": True} + + +def _handle(req: dict) -> dict | None: + method = req.get("method") + rid = req.get("id") + if method == "initialize": + return {"jsonrpc": "2.0", "id": rid, "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": {"name": SERVER_NAME, "version": __version__}, + }} + if method == "notifications/initialized" or method == "notifications/cancelled": + return None + if method == "ping": + return {"jsonrpc": "2.0", "id": rid, "result": {}} + if method == "tools/list": + tools = [{k: v for k, v in t.items() if not k.startswith("_")} for t in TOOLS] + return {"jsonrpc": "2.0", "id": rid, "result": {"tools": tools}} + if method == "tools/call": + params = req.get("params") or {} + name = params.get("name") + args = params.get("arguments") or {} + tool = next((t for t in TOOLS if t["name"] == name), None) + if tool is None: + return {"jsonrpc": "2.0", "id": rid, "result": + _error_result(BrokerError("DENIED", f"unknown tool {name!r}"))} + socket_method, mapper = tool["_method"] + try: + res = call(socket_method, mapper(args)) + return {"jsonrpc": "2.0", "id": rid, "result": _result(res)} + except BrokerError as e: + return {"jsonrpc": "2.0", "id": rid, "result": _error_result(e)} + if rid is not None: + return {"jsonrpc": "2.0", "id": rid, + "error": {"code": -32601, "message": "method not found"}} + return None + + +def main(): + if sys.stdin.isatty(): + print("agent-broker-mcp is the MCP stdio server — not a CLI.", file=sys.stderr) + print("For interactive commands use: agent-broker ", file=sys.stderr) + return 2 + while True: + line = sys.stdin.readline() + if not line: + return 0 + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except json.JSONDecodeError: + continue + resp = _handle(req) + if resp is not None: + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_credential_broker/plugin.py b/agent_credential_broker/plugin.py new file mode 100644 index 0000000..ba451f7 --- /dev/null +++ b/agent_credential_broker/plugin.py @@ -0,0 +1,70 @@ +"""agent-browser ``credential.read`` plugin (agent-browser.plugin.v1). + +agent-browser writes ONE JSON envelope on stdin:: + + {"protocol": "agent-browser.plugin.v1", + "type": "credential.resolve", + "capability": "credential.read", + "request": {"profileName": "...", "itemRef": "...", "url": "..."}} + +and expects one JSON object on stdout:: + + {"protocol": "agent-browser.plugin.v1", "success": true, + "credential": {"username": ..., "password": ..., "url": ..., + "usernameSelector": ..., "passwordSelector": ..., + "submitSelector": ...}} + +The plugin is a thin proxy: it forwards (itemRef, url) to the broker over +the unix socket and relays the credential. The broker enforces the +item's adapter policy and origin validation before releasing anything. +Secret values go agent-browser-stdin -> plugin -> agent-browser; the +agent model never sees them. +""" + +from __future__ import annotations + +import json +import sys + +from .errors import BrokerError +from .server import call + +PROTOCOL = "agent-browser.plugin.v1" + + +def _fail(message): + sys.stdout.write(json.dumps({ + "protocol": PROTOCOL, "success": False, "error": str(message)[:300], + }) + "\n") + + +def main(): + try: + raw = sys.stdin.read(64 * 1024) + envelope = json.loads(raw) + except Exception: + _fail("bad plugin envelope") + return 1 + if (envelope.get("protocol") != PROTOCOL + or envelope.get("type") != "credential.resolve"): + _fail("unsupported plugin request") + return 1 + req = envelope.get("request") or {} + item_ref = req.get("itemRef") + url = req.get("url") + if not item_ref: + _fail("missing itemRef") + return 1 + try: + cred = call("resolve", {"ref": item_ref, "url": url}) + except BrokerError as e: + _fail(f"{e.code}: {e.message}") + return 0 # a clean denial is still a protocol-level response + sys.stdout.write(json.dumps({ + "protocol": PROTOCOL, "success": True, "credential": cred, + }) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent_credential_broker/policy.py b/agent_credential_broker/policy.py new file mode 100644 index 0000000..47e2431 --- /dev/null +++ b/agent_credential_broker/policy.py @@ -0,0 +1,167 @@ +"""Item-scoped policy derived from non-sensitive ``agent.*`` custom fields. + +Recognized fields (all optional except agent.ref): + + agent.ref required. stable, unique, exact-match key + agent.adapters csv whitelist, e.g. "browser,http,git" + agent.allowed_hosts csv hostnames (ssh + host-level checks) + agent.allowed_urls csv origins or origin+path prefixes + agent.auth_type bearer | basic | none (http) + agent.confirmation AUTO | SESSION | EVERY_USE | DENY + default: EVERY_USE (fail-safe) + agent.username_selector browser field selector + agent.password_selector browser field selector + agent.submit_selector browser field selector + agent.ssh_user ssh login override (else vault username) + agent.ssh_key_path existing local key file to use instead of + password auth (no key material moves) + agent.cli_profile restrict this credential to one profile id + agent.insecure_http "true" permits http:// targets (discouraged) + +Policy is enforced daemon-side. The agent never sees or controls it. +""" + +from __future__ import annotations + +import urllib.parse +from dataclasses import dataclass, field + +from .errors import adapter_denied, origin_denied, invalid_argument + +CONFIRMATION_LEVELS = {"AUTO", "SESSION", "EVERY_USE", "DENY"} + +SUPPORTED_ADAPTERS = {"browser", "http", "ssh", "git", "cli-profile"} + + +def _csv(value: str | None) -> list[str]: + if not value: + return [] + return [v.strip() for v in value.split(",") if v.strip()] + + +def normalize_url(url: str) -> urllib.parse.ParseResult: + p = urllib.parse.urlparse(url if "://" in url else "https://" + url) + if p.scheme not in ("https", "http", "ssh", "git"): + raise invalid_argument(f"unsupported URL scheme {p.scheme!r}") + if not p.hostname: + raise invalid_argument(f"URL has no host: {url!r}") + return p + + +def origin_of(url: str) -> str: + p = normalize_url(url) + port = f":{p.port}" if p.port else "" + return f"{p.scheme}://{p.hostname}{port}" + + +@dataclass +class CredentialPolicy: + ref: str + item_id: str + name: str = "" + adapters: set = field(default_factory=set) + allowed_hosts: set = field(default_factory=set) + allowed_urls: list = field(default_factory=list) + auth_type: str = "basic" + confirmation: str = "EVERY_USE" + username_selector: str | None = None + password_selector: str | None = None + submit_selector: str | None = None + ssh_user: str | None = None + ssh_key_path: str | None = None + cli_profile: str | None = None + insecure_http: bool = False + uris: list = field(default_factory=list) + + def safe_metadata(self) -> dict: + """Metadata that is safe to show the agent (no values).""" + return { + "ref": self.ref, + "name": self.name, + "adapters": sorted(self.adapters), + "allowed_hosts": sorted(self.allowed_hosts), + "allowed_urls": list(self.allowed_urls), + "confirmation": self.confirmation, + "auth_type": self.auth_type, + "cli_profile": self.cli_profile, + "uris": list(self.uris), + } + + +def _parse_allowed_url(raw: str) -> urllib.parse.ParseResult: + p = normalize_url(raw) + return p + + +def policy_for(item: dict) -> CredentialPolicy | None: + fields = item.get("fields") or {} + ref = fields.get("agent.ref") + if not ref: + return None + adapters = {a for a in _csv(fields.get("agent.adapters"))} + unknown = adapters - SUPPORTED_ADAPTERS + if unknown: + adapters -= unknown + confirmation = (fields.get("agent.confirmation") or "EVERY_USE").upper() + if confirmation not in CONFIRMATION_LEVELS: + confirmation = "EVERY_USE" + auth_type = (fields.get("agent.auth_type") or "basic").lower() + if auth_type not in ("bearer", "basic", "none"): + auth_type = "basic" + allowed_urls = [] + for raw in _csv(fields.get("agent.allowed_urls")) + list(item.get("uris") or []): + try: + p = _parse_allowed_url(raw) + allowed_urls.append(origin_of(raw) + (p.path.rstrip("/") or "")) + except Exception: + continue + return CredentialPolicy( + ref=ref.strip(), + item_id=item.get("id") or "", + name=item.get("name") or "", + adapters=adapters, + allowed_hosts={h.lower() for h in _csv(fields.get("agent.allowed_hosts"))}, + allowed_urls=allowed_urls, + auth_type=auth_type, + confirmation=confirmation, + username_selector=fields.get("agent.username_selector"), + password_selector=fields.get("agent.password_selector"), + submit_selector=fields.get("agent.submit_selector"), + ssh_user=fields.get("agent.ssh_user"), + ssh_key_path=fields.get("agent.ssh_key_path"), + cli_profile=fields.get("agent.cli_profile"), + insecure_http=(fields.get("agent.insecure_http") or "").lower() == "true", + uris=list(item.get("uris") or []), + ) + + +def require_adapter(policy: CredentialPolicy, adapter: str): + if adapter not in policy.adapters: + raise adapter_denied(policy.ref, adapter) + + +def _default_port(scheme: str) -> int: + return {"https": 443, "http": 80, "ssh": 22, "git": 9418}.get(scheme, -1) + + +def require_url_allowed(policy: CredentialPolicy, url: str, *, require_https=True): + """Origin+path-prefix check against agent.allowed_urls / item URIs.""" + p = normalize_url(url) + if require_https and p.scheme == "http" and not policy.insecure_http: + raise origin_denied(policy.ref, url) + cand_origin = f"{p.scheme}://{p.hostname}:{p.port or _default_port(p.scheme)}" + cand = cand_origin + (p.path.rstrip("/") or "") + for allowed in policy.allowed_urls: + ap = normalize_url(allowed) + a_origin = f"{ap.scheme}://{ap.hostname}:{ap.port or _default_port(ap.scheme)}" + if a_origin != cand_origin: + continue + a_path = ap.path.rstrip("/") + if not a_path or cand == a_origin + a_path or cand.startswith(a_origin + a_path + "/"): + return + raise origin_denied(policy.ref, url) + + +def require_host_allowed(policy: CredentialPolicy, host: str): + if host.lower() not in policy.allowed_hosts: + raise origin_denied(policy.ref, host) diff --git a/agent_credential_broker/sanitize.py b/agent_credential_broker/sanitize.py new file mode 100644 index 0000000..b1b3a5f --- /dev/null +++ b/agent_credential_broker/sanitize.py @@ -0,0 +1,71 @@ +"""Output sanitizer -- defence in depth. + +The *primary* guarantee is architectural: secret values never enter +argv/stdout/stderr of adapter calls. This layer additionally scans any +text crossing the broker boundary (child output, HTTP bodies/headers, +exception text) and replaces occurrences of known secret material with +[REDACTED]. If a secret appears in output, that is already an adapter +bug -- the sanitizer makes sure it can never reach the agent. +""" + +from __future__ import annotations + +import re + +REDACTED = "[REDACTED]" + +# Header names that must never be echoed back to the agent. +SENSITIVE_HEADERS = { + "authorization", + "proxy-authorization", + "set-cookie", + "cookie", + "x-api-key", + "x-auth-token", + "x-csrf-token", + "x-session-token", + "www-authenticate", +} + +# name=value / name: value patterns whose values are suppressed. +_SECRET_KV = re.compile( + r"(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|" + r"refresh[_-]?token|session[_-]?(id|token|key)|client[_-]?secret|" + r"private[_-]?key|authorization)\b(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&}]{4,})" +) + +_BEARER = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{8,}") + +_CRED_IN_URL = re.compile(r"(https?|ssh|git)://[^/@\s]+(:[^/@\s]*)?@") + + +def sanitize_text(text: str, secrets: list[str] | tuple[str, ...] = ()) -> str: + if not text: + return text + out = text + for secret in secrets: + if secret and len(secret) >= 4: + out = out.replace(secret, REDACTED) + out = _BEARER.sub("Bearer " + REDACTED, out) + out = _CRED_IN_URL.sub(r"\1://" + REDACTED + "@", out) + out = _SECRET_KV.sub(lambda m: m.group(1) + m.group(3) + REDACTED, out) + return out + + +def sanitize_headers(headers: dict, secrets=()) -> dict: + """Return a copy of headers with sensitive entries dropped and + remaining values scrubbed for accidental secret content.""" + out = {} + for k, v in headers.items(): + if k.lower() in SENSITIVE_HEADERS: + out[k] = REDACTED + else: + out[k] = sanitize_text(str(v), secrets) + return out + + +def sanitize_bytes(data: bytes, secrets: list[bytes] | tuple[bytes, ...] = ()) -> bytes: + for secret in secrets: + if secret: + data = data.replace(secret, REDACTED.encode()) + return data diff --git a/agent_credential_broker/server.py b/agent_credential_broker/server.py new file mode 100644 index 0000000..9355bdd --- /dev/null +++ b/agent_credential_broker/server.py @@ -0,0 +1,170 @@ +"""Unix-domain socket daemon. No TCP, ever. + +Socket: $XDG_RUNTIME_DIR/agent-broker/broker.sock (0600, +dir 0700). Every request is one line of JSON: + + {"method": "...", "params": {...}, "id": 1} + +Response: + + {"ok": true, "result": {...}} + {"ok": false, "error": {"code": "...", "message": "..."}} + +The daemon additionally verifies the connecting peer UID via +SO_PEERCRED -- only the owning user may talk to it. +""" + +from __future__ import annotations + +import json +import os +import socket +import socketserver +import struct +import threading + +from .config import Config, socket_path +from .core import BrokerCore +from .errors import BrokerError, internal +from .audit import audit + +_MAX_LINE = 1024 * 1024 + + +def _peer_uid(conn: socket.socket) -> int: + creds = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")) + _pid, uid, _gid = struct.unpack("3i", creds) + return uid + + +class _Handler(socketserver.StreamRequestHandler): + def handle(self): + try: + if _peer_uid(self.request) != os.getuid(): + self.wfile.write(b'{"ok":false,"error":{"code":"DENIED","message":"foreign uid"}}\n') + return + except OSError: + return + for raw in self.rfile: + if len(raw) > _MAX_LINE: + break + try: + req = json.loads(raw) + except json.JSONDecodeError: + self._send({"ok": False, "error": {"code": "DENIED_INVALID_ARGUMENT", "message": "bad json"}}) + continue + self._send(self.server.dispatch(req)) + + def _send(self, obj): + try: + self.wfile.write(json.dumps(obj).encode() + b"\n") + except OSError: + pass + + +class _Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + + def __init__(self, path: str, core: BrokerCore): + self.core = core + super().__init__(path, _Handler) + + def dispatch(self, req: dict) -> dict: + if not isinstance(req, dict): + return _err(internal("bad request")) + method = req.get("method") + params = req.get("params") or {} + if not isinstance(params, dict): + return _err(BrokerError("DENIED_INVALID_ARGUMENT", "params must be an object")) + try: + result = self._route(method, params) + return {"ok": True, "result": result} + except BrokerError as e: + out = {"ok": False, "error": e.to_dict()} + if getattr(e, "ticket", None): + out["ticket"] = e.ticket + return out + except Exception: + return _err(internal("unexpected daemon error")) + + def _route(self, method, params): + c = self.core + routes = { + "status": lambda: c.status(), + "unlock": lambda: c.unlock(params.get("password") or _bad("password required")), + "lock": lambda: c.lock(), + "logout": lambda: c.logout(), + "confirm": lambda: c.confirm(params.get("ticket") or _bad("ticket required")), + "list_refs": lambda: c.list_refs(), + "find_for_url": lambda: c.find_for_url(params.get("url") or _bad("url required")), + # internal path used by the agent-browser credential.read plugin + "resolve": lambda: c.resolve_for_plugin( + params.get("ref") or _bad("ref required"), params.get("url")), + "http_request": lambda: c.run("http", params), + "ssh_exec": lambda: c.run("ssh", params), + "git_op": lambda: c.run("git", params), + "cli_run": lambda: c.run("cli-profile", params), + "browser_login": lambda: c.run("browser", {**params, "op": "browser_login"}), + "browser_fill_credential": lambda: c.run("browser", {**params, "op": "browser_fill_credential"}), + } + fn = routes.get(method) + if fn is None: + raise BrokerError("DENIED_INVALID_ARGUMENT", f"unknown method {method!r}") + return fn() + + +def _bad(msg): + raise BrokerError("DENIED_INVALID_ARGUMENT", msg) + + +def _err(e: BrokerError) -> dict: + return {"ok": False, "error": e.to_dict()} + + +def call(params_method: str, params: dict | None = None, *, timeout=60) -> dict: + """Client-side call into the daemon. Used by the CLI, the MCP server + and the agent-browser plugin.""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(str(socket_path())) + sock.sendall(json.dumps({"method": params_method, "params": params or {}}).encode() + b"\n") + fh = sock.makefile("r") + line = fh.readline(_MAX_LINE) + if not line: + raise BrokerError("DAEMON_DOWN", "daemon closed connection") + resp = json.loads(line) + except (ConnectionRefusedError, FileNotFoundError, socket.timeout) as e: + raise BrokerError("DAEMON_DOWN", f"cannot reach broker daemon ({e.__class__.__name__}); is it running?") + finally: + sock.close() + if not resp.get("ok"): + err = resp.get("error") or {} + e = BrokerError(err.get("code", "INTERNAL_ERROR"), err.get("message", "unknown")) + if resp.get("ticket"): + e.ticket = resp["ticket"] + raise e + return resp.get("result") + + +def serve_forever(config: Config | None = None): + config = config or Config.load() + path = socket_path() + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + path.unlink() + except FileNotFoundError: + pass + core = BrokerCore(config) + server = _Server(str(path), core) + os.chmod(path, 0o600) + audit("daemon_start", result="ok", detail=str(path)) + try: + server.serve_forever(poll_interval=0.5) + finally: + core.lock() + try: + path.unlink() + except OSError: + pass + audit("daemon_stop", result="ok") diff --git a/profiles/example-echo.json b/profiles/example-echo.json new file mode 100644 index 0000000..2e2f34b --- /dev/null +++ b/profiles/example-echo.json @@ -0,0 +1,12 @@ +{ + "id": "example-echo", + "description": "Test profile: echoes argv, reads the injected credential only inside the child.", + "executable": "echo-fixture", + "injection": {"method": "env", "name": "EXAMPLE_TOKEN"}, + "allowed_subcommands": ["hello", "version"], + "arg_regex": "^[\\w .\\-/:]+$", + "forbidden_regex": "[;&|`$<>(){}\\[\\]\n]|(^|/)\\.\\.(/|$)", + "target_hosts": ["example.com"], + "timeout": 10, + "max_output_bytes": 8192 +} diff --git a/profiles/gitea-api.json b/profiles/gitea-api.json new file mode 100644 index 0000000..b28e678 --- /dev/null +++ b/profiles/gitea-api.json @@ -0,0 +1,16 @@ +{ + "id": "gitea-api", + "description": "curl against the Gitea2 API. The token is rendered into a 0600 tmpfs curl -K config file (header = \"Authorization: token {SECRET}\") so it never appears on argv or env.", + "executable": "curl", + "injection": { + "method": "file", + "file_template": "header = \"Authorization: token {SECRET}\"\nsilent\nshow-error\nfail\nmax-time = 25\n" + }, + "args_template": ["-K", "{SECRET_FILE}"], + "allowed_subcommands": [], + "arg_regex": "^[\\w ./:@\\-+=%{},-]+$", + "forbidden_regex": "[;&|`$<>(){}\\[\\]\n]|(^|/)\\.\\.(/|$)|Authorization:|token=", + "target_hosts": ["git.paginica.com"], + "timeout": 30, + "max_output_bytes": 65536 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3574337 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "agent-credential-broker" +version = "0.1.0" +description = "Capability layer: agents use vault credentials (OpenBao / Bitwarden-compatible) without ever reading them" +requires-python = ">=3.10" +dependencies = [] + +[project.scripts] +agent-broker = "agent_credential_broker.cli:main" +agent-broker-plugin = "agent_credential_broker.plugin:main" +agent-broker-mcp = "agent_credential_broker.mcp_server:main" + +[tool.setuptools.packages.find] +include = ["agent_credential_broker*"] diff --git a/systemd/agent-broker.service b/systemd/agent-broker.service new file mode 100644 index 0000000..cd825c0 --- /dev/null +++ b/systemd/agent-broker.service @@ -0,0 +1,16 @@ +[Unit] +Description=Agent credential broker (unix-socket daemon) + +[Service] +Type=simple +# The wrapper resolves the package via PYTHONPATH; %h keeps it portable. +ExecStart=%h/.local/bin/agent-broker daemon +Restart=on-failure +RestartSec=2s +# bw is located via config bw_path (preferred) or PATH; the child's PATH +# is rebuilt by the backend, so only the daemon's own tools matter here. +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=AGENT_BROKER_CONFIG=%h/.paginica/mcp/agent-broker/config.json + +[Install] +WantedBy=default.target diff --git a/tests/fake_bao.py b/tests/fake_bao.py new file mode 100644 index 0000000..d40690c --- /dev/null +++ b/tests/fake_bao.py @@ -0,0 +1,255 @@ +"""Shared fake OpenBao server used by the offline test suites. + +ThreadingHTTPServer implementing the subset of the Vault/OpenBao HTTP +API that the ``openbao`` backend exercises: + + POST /v1/auth/approle/login {role_id, secret_id} + GET /v1/auth/token/lookup-self + POST /v1/auth/token/renew-self + POST /v1/auth/token/revoke-self + GET /v1//metadata/

/?list=true (or LIST verb) + GET /v1//metadata/

/ + GET /v1//data/

/ + +Every request ``(method, path)`` is recorded in ``server.calls`` so +tests can assert that unlock/list_metadata NEVER touch ``/data/*`` -- +the key property of the design (the ref index is metadata-only). + +Secrets live in ``server.secrets`` keyed by full path relative to the +mount, e.g. ``"agent/gitea.admin"`` -> ``{"data": {...}, +"custom_metadata": {...}}``. Paths listed in ``server.forbidden`` +answer 403 on ``data/*`` reads (token rejection mid-session fixture). +""" + +from __future__ import annotations + +import json +import threading +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +ROLE_ID = "fixture-role-id-DO-NOT-USE" +SECRET_ID = "fixture-secret-id-DO-NOT-USE" +TOKEN_VALUE = "fixture-bao-token-DO-NOT-USE" + +PW = "example-password-DO-NOT-USE" +TOKEN = "example-token-DO-NOT-USE" + + +def fixture_secrets() -> dict: + """KV v2 secrets under mount `secret`, prefix `agent`.""" + return { + "agent/gitea.admin": { + "data": {"username": "gitea-admin", "password": PW, + "uris": ["https://git2.paginica.com"], + "notes": "bot account"}, + "custom_metadata": { + "agent.ref": "gitea.admin", + "agent.adapters": "browser,http,git", + "agent.allowed_urls": "https://git2.paginica.com", + "agent.auth_type": "bearer", + "agent.confirmation": "AUTO", + "label": "gitea admin", + "uris": "https://git2.paginica.com"}, + }, + "agent/test.service": { + "data": {"password": TOKEN, + "uris": "https://service.example,https://alt.example"}, + "custom_metadata": { + "agent.ref": "test.service", + "agent.adapters": "http,cli-profile", + "agent.allowed_urls": "https://service.example", + "agent.auth_type": "bearer", + "agent.confirmation": "AUTO", + "agent.cli_profile": "example-echo", + "label": "api token"}, + }, + # nested path: id is the full path relative to the prefix + "agent/infra/router": { + "data": {"username": "root", "password": PW}, + "custom_metadata": { + "agent.ref": "infra.router", + "agent.adapters": "ssh", + "agent.allowed_hosts": "192.0.2.1", + "agent.confirmation": "AUTO"}, + }, + # no agent.ref -> invisible to the ref index (same as a BW item + # without the field) + "agent/not-for-agents": { + "data": {"username": "x", "password": PW}, + "custom_metadata": {"label": "private"}}, + } + + +class FakeBaoServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, addr, handler, *, secrets=None): + super().__init__(addr, handler) + self.secrets = dict(secrets if secrets is not None else fixture_secrets()) + self.tokens: set[str] = set() + self.calls: list[tuple[str, str]] = [] + self.role_id = ROLE_ID + self.secret_id = SECRET_ID + # knobs tests can tweak before a request + self.lookup_ttl = 3600 # ttl reported by lookup-self + self.lookup_renewable = True + self.renew_lease = 3600 # lease reported by renew-self + self.forbidden: set[str] = set() # data paths answering 403 + self._seq = 0 + + +def start_server(secrets=None) -> tuple[FakeBaoServer, threading.Thread, int]: + srv = FakeBaoServer(("127.0.0.1", 0), _Handler, secrets=secrets) + t = threading.Thread(target=srv.serve_forever, + kwargs={"poll_interval": 0.05}, daemon=True) + t.start() + return srv, t, srv.server_address[1] + + +def _children(secrets: dict, rel: str) -> list[str]: + """Direct children of metadata dir ``rel`` (dirs end with '/').""" + keys = set() + for path in secrets: + if rel: + if not path.startswith(rel + "/"): + continue + rest = path[len(rel) + 1:] + else: + rest = path + if not rest: + continue + seg, _, tail = rest.partition("/") + keys.add(seg + "/" if tail else seg) + return sorted(keys) + + +class _Handler(BaseHTTPRequestHandler): + server: FakeBaoServer + + def log_message(self, *a): + pass + + # -- helpers ------------------------------------------------------- + + def _record(self, path): + self.server.calls.append((self.command, path)) + + def _json(self, status, obj): + body = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _empty(self, status): + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() + + def _body(self) -> dict: + try: + n = int(self.headers.get("Content-Length") or 0) + except ValueError: + n = 0 + if n <= 0: + return {} + try: + return json.loads(self.rfile.read(n)) + except Exception: + return {} + + def _authed(self) -> bool: + tok = self.headers.get("X-Vault-Token") + if tok and tok in self.server.tokens: + return True + self._json(403, {"errors": ["permission denied"]}) + return False + + # -- routing ------------------------------------------------------- + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def do_LIST(self): + self._dispatch("LIST") + + def do_PUT(self): + self._dispatch("PUT") + + def _dispatch(self, method): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + query = urllib.parse.parse_qs(parsed.query) + self._record(path) + parts = [p for p in path.split("/") if p] + srv = self.server + + # /v1/auth/approle/login + if parts == ["v1", "auth", "approle", "login"]: + if method != "POST": + return self._json(405, {"errors": ["unsupported operation"]}) + creds = self._body() + if (creds.get("role_id") == srv.role_id + and creds.get("secret_id") == srv.secret_id): + srv._seq += 1 + tok = f"{TOKEN_VALUE}-{srv._seq}" + srv.tokens.add(tok) + return self._json(200, {"auth": { + "client_token": tok, "renewable": True, + "lease_duration": 3600, "policies": ["agent-broker"]}}) + return self._json(400, {"errors": ["invalid secret_id supplied"]}) + + # /v1/auth/token/{lookup-self,renew-self,revoke-self} + if parts[:3] == ["v1", "auth", "token"]: + if not self._authed(): + return + tok = self.headers.get("X-Vault-Token") + op = parts[3] if len(parts) > 3 else "" + if op == "lookup-self" and method == "GET": + return self._json(200, {"data": { + "id": tok, "ttl": srv.lookup_ttl, + "renewable": srv.lookup_renewable, + "policies": ["agent-broker"]}}) + if op == "renew-self" and method == "POST": + return self._json(200, {"auth": { + "client_token": tok, "renewable": True, + "lease_duration": srv.renew_lease}}) + if op == "revoke-self" and method == "POST": + srv.tokens.discard(tok) + return self._empty(204) + return self._json(404, {"errors": ["unsupported path"]}) + + # /v1//{metadata,data}/ + if len(parts) >= 4 and parts[0] == "v1" and parts[2] in ("metadata", "data"): + if not self._authed(): + return + kind = parts[2] + rel = "/".join(parts[3:]) + if method not in ("GET", "LIST"): + return self._json(405, {"errors": ["unsupported operation"]}) + if method == "LIST" or "list" in query: + keys = _children(srv.secrets, rel) + if not keys and rel not in srv.secrets: + return self._json(404, {"errors": []}) + return self._json(200, {"data": {"keys": keys}}) + entry = srv.secrets.get(rel) + if entry is None: + return self._json(404, {"errors": []}) + cm = entry.get("custom_metadata") or {} + if kind == "metadata": + return self._json(200, {"data": { + "custom_metadata": cm, "version": 1, + "created_time": "2026-01-01T00:00:00Z"}}) + if rel in srv.forbidden: + return self._json(403, {"errors": ["permission denied"]}) + return self._json(200, {"data": { + "data": entry.get("data") or {}, + "metadata": {"custom_metadata": cm, "version": 1, + "created_time": "2026-01-01T00:00:00Z"}}}) + + return self._json(404, {"errors": ["unsupported path"]}) diff --git a/tests/fakebw.py b/tests/fakebw.py new file mode 100644 index 0000000..f63038c --- /dev/null +++ b/tests/fakebw.py @@ -0,0 +1,105 @@ +"""Shared fake `bw` binary source used by the offline test suites. + +The fake records, per invocation: argv, the *names* of secret-channel +env vars it received (never values), and its own /proc/self/cmdline. +State lives under BITWARDENCLI_APPDATA_DIR: state.json + items.json. +""" + +FAKE_BW = r'''#!/usr/bin/env python3 +import json, os, sys + +D = os.environ.get("BITWARDENCLI_APPDATA_DIR") +if not D: + print("missing BITWARDENCLI_APPDATA_DIR", file=sys.stderr); sys.exit(9) +os.makedirs(D, exist_ok=True) + +with open(os.path.join(D, "calls.jsonl"), "a") as fh: + try: + cmdline = open("/proc/self/cmdline", "rb").read().decode("utf-8", "replace") + except OSError: + cmdline = "" + fh.write(json.dumps({ + "argv": sys.argv, + "env_names": sorted(k for k in os.environ + if k.startswith(("BW_", "VAULT_", "BROKER_", + "AGENT_BROKER_"))), + "cmdline": cmdline, + }) + "\n") + +args = [a for a in sys.argv[1:] if a != "--nointeraction"] +state_file = os.path.join(D, "state.json") + +def load(): + try: + return json.load(open(state_file)) + except Exception: + return {} + +def save(st): + json.dump(st, open(state_file, "w")) + +def die(msg, rc=1): + print(msg, file=sys.stderr); sys.exit(rc) + +st = load() + +if args[:2] == ["config", "server"]: + st["server"] = args[2]; save(st) + print("Saved setting `config`."); sys.exit(0) + +if args and args[0] == "status": + status = ("unauthenticated" if not st.get("logged_in") + else "unlocked" if st.get("session") else "locked") + print(json.dumps({"serverUrl": st.get("server"), "status": status})) + sys.exit(0) + +if args and args[0] == "login" and "--apikey" in args: + if os.environ.get("BW_CLIENTID") == "user.fixture-id" and \ + os.environ.get("BW_CLIENTSECRET") == "fixture-client-secret": + st["logged_in"] = True; save(st) + print("You are logged in!"); sys.exit(0) + die("Invalid client_id") + +if args and args[0] == "unlock": + if "--check" in args: + die("Vault is locked.", 1) if not st.get("session") else \ + (print("Vault is unlocked."), sys.exit(0)) + pw = None + if "--passwordenv" in args: + pw = os.environ.get(args[args.index("--passwordenv") + 1]) + if "--passwordfile" in args: + pw = open(args[args.index("--passwordfile") + 1]).readline().rstrip("\n") + if pw == "fixture-master-password-DO-NOT-USE": + st["session"] = "fixture-session-key-" + os.urandom(4).hex(); save(st) + print(st["session"] if "--raw" in args else "Your vault is now unlocked!") + sys.exit(0) + die("Cryptography error, The decryption operation failed") + +def need_session(): + if not st.get("session") or os.environ.get("BW_SESSION") != st["session"]: + die("Vault is locked.") + +if args and args[0] == "sync": + need_session(); print("Syncing complete."); sys.exit(0) + +if args[:2] == ["list", "items"]: + need_session() + print(open(os.path.join(D, "items.json")).read()); sys.exit(0) + +if args[:2] == ["get", "item"]: + need_session() + for it in json.load(open(os.path.join(D, "items.json"))): + if it["id"] == args[2]: + print(json.dumps(it)); sys.exit(0) + die("Not found.") + +if args and args[0] == "lock": + st.pop("session", None); save(st) + print("Your vault is now locked."); sys.exit(0) + +if args and args[0] == "logout": + st.clear(); save(st) + print("You have logged out."); sys.exit(0) + +die("unknown cmd " + " ".join(args)) +''' diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..62aa5f4 --- /dev/null +++ b/tests/test_broker.py @@ -0,0 +1,597 @@ +"""Synthetic security test suite for agent-broker. + +Everything here runs OFFLINE against fixture credentials: + + example-user / example-password-DO-NOT-USE / example-token-DO-NOT-USE + +No real vault data, no network to vault.paginica.com. The daemon under +test uses the real BitwardenCliBackend driving a fake `bw` binary (see +tests/fakebw.py) that serves fixture items -- the same code path the +real daemon runs, end to end. +""" + +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fakebw import FAKE_BW +from agent_credential_broker import policy +from agent_credential_broker.adapters import common as ac +from agent_credential_broker.adapters.cli_profile import _validate_args +from agent_credential_broker.config import Config +from agent_credential_broker.core import BrokerCore +from agent_credential_broker.errors import BrokerError +from agent_credential_broker.sanitize import sanitize_text, sanitize_headers +from agent_credential_broker.server import _Server, call + +# ---------------------------------------------------------------- fixtures + +MASTER_PW = "fixture-master-password-DO-NOT-USE" +PW = "example-password-DO-NOT-USE" +TOKEN = "example-token-DO-NOT-USE" +USER = "example-user" + + +def fixture_items(): + """bw-JSON shaped items served by the fake `bw` (list/get item).""" + def item(i, name, username, password, uris, fields): + return {"id": f"fixture-item-{i}", "type": 1, "name": name, + "login": {"username": username, "password": password, + "totp": None, + "uris": [{"uri": u} for u in uris]}, + "fields": [{"name": k, "value": v, "type": 0} + for k, v in fields.items()], + "notes": None} + + return [ + item(1, "gitea admin", "gitea-admin", PW, + ["https://git2.paginica.com"], + {"agent.ref": "gitea.admin", + "agent.adapters": "browser,http,git", + "agent.allowed_urls": "https://git2.paginica.com", + "agent.auth_type": "bearer", + "agent.confirmation": "AUTO"}), + item(2, "vps root", "root", PW, [], + {"agent.ref": "vps.root", + "agent.adapters": "ssh", + "agent.allowed_hosts": "vps.paginica.com,192.0.2.1", + "agent.confirmation": "EVERY_USE"}), + item(3, "api token", "", TOKEN, + ["https://service.example"], + {"agent.ref": "test.service", + "agent.adapters": "http,cli-profile", + "agent.allowed_urls": "https://service.example", + "agent.auth_type": "bearer", + "agent.confirmation": "AUTO", + "agent.cli_profile": "example-echo"}), + item(4, "dup a", "a", PW, [], + {"agent.ref": "dup.ref", "agent.adapters": "http", + "agent.allowed_urls": "https://service.example", + "agent.confirmation": "AUTO"}), + item(5, "dup b", "b", PW, [], + {"agent.ref": "dup.ref", "agent.adapters": "http", + "agent.allowed_urls": "https://service.example", + "agent.confirmation": "AUTO"}), + ] + + +def _write(path: Path, text: str, mode=0o600): + path.write_text(text) + os.chmod(path, mode) + + +class BrokerHarness(unittest.TestCase): + """Spins a real daemon (unix socket in a temp runtime dir) backed by + the real BitwardenCliBackend driving a fake `bw` binary with fixture + client credentials and items.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + base = Path(cls.tmp.name) + os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") + os.environ["XDG_STATE_HOME"] = str(base / "state") + (base / "rt").mkdir() + cred = base / "cred" + cred.mkdir() + _write(cred / "id.txt", "user.fixture-id") + _write(cred / "secret.token", "fixture-client-secret") + + bin_dir = base / "bin" + bin_dir.mkdir() + script = bin_dir / "echo-fixture" + script.write_text( + "#!/bin/sh\n" + 'echo "argv: $*"\n' + 'echo "envlen: ${#EXAMPLE_TOKEN}"\n' + ) + os.chmod(script, 0o755) + cls.bw = bin_dir / "bw" + cls.bw.write_text(FAKE_BW) + os.chmod(cls.bw, 0o755) + os.environ["PATH"] = f"{bin_dir}:{os.environ.get('PATH', '')}" + + cls.appdata = base / "bw-appdata" + cls.appdata.mkdir(mode=0o700) + (cls.appdata / "items.json").write_text(json.dumps(fixture_items())) + + cls.cfg_path = base / "broker-config.json" + cls.cfg_path.write_text(json.dumps({ + "vault_url": "https://vault.paginica.com", + "client_id_file": str(cred / "id.txt"), + "client_secret_file": str(cred / "secret.token"), + "vault_backend": "bitwarden-cli", + "bw_path": str(cls.bw), + "bw_appdata_dir": str(cls.appdata), + "unlock_ttl_seconds": 3600, + "git_workdir_roots": [str(Path.home())], + })) + os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path) + os.environ["AGENT_BROKER_SOCKET"] = str(base / "rt" / "agent-broker" / "broker.sock") + os.environ["AGENT_BROKER_PROFILES_DIR"] = str(Path(__file__).resolve().parents[1] / "profiles") + + cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"]) + cls.socket_path.parent.mkdir(parents=True, exist_ok=True) + config = Config.load() + cls.server = _Server(str(cls.socket_path), BrokerCore(config)) + os.chmod(cls.socket_path, 0o600) + threading.Thread(target=cls.server.serve_forever, kwargs={"poll_interval": 0.1}, + daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.tmp.cleanup() + + def call(self, method, params=None): + return call(method, params or {}) + + +class TestLifecycle(BrokerHarness): + def test_1_starts_locked(self): + st = self.call("status") + self.assertTrue(st["locked"]) + self.assertTrue(st["configured"]) + + def test_2_locked_denies_ops(self): + with self.assertRaises(BrokerError) as e: + self.call("list_refs") + self.assertEqual(e.exception.code, "BROKER_LOCKED") + with self.assertRaises(BrokerError) as e: + self.call("resolve", {"ref": "gitea.admin"}) + self.assertEqual(e.exception.code, "BROKER_LOCKED") + + def test_3_unlock_and_lock(self): + res = self.call("unlock", {"password": MASTER_PW}) + self.assertTrue(res["unlocked"]) + self.assertEqual(res["items"], len(fixture_items())) + st = self.call("status") + self.assertFalse(st["locked"]) + self.call("lock") + self.assertTrue(self.call("status")["locked"]) + + +class TestUnlocked(BrokerHarness): + @classmethod + def setUpClass(cls): + super().setUpClass() + call("unlock", {"password": MASTER_PW}) + + @classmethod + def tearDownClass(cls): + try: + call("lock") + except Exception: + pass + super().tearDownClass() + + def test_refs_listed_without_values(self): + refs = {r["ref"]: r for r in self.call("list_refs")} + self.assertIn("gitea.admin", refs) + self.assertIn("vps.root", refs) + self.assertIn("test.service", refs) + blob = json.dumps(refs) + self.assertNotIn(PW, blob) + self.assertNotIn(TOKEN, blob) + + def test_exact_matching(self): + with self.assertRaises(BrokerError) as e: + self.call("resolve", {"ref": "gitea.adm"}) + self.assertEqual(e.exception.code, "NOT_FOUND") + with self.assertRaises(BrokerError) as e: + self.call("resolve", {"ref": "gitea"}) + self.assertEqual(e.exception.code, "NOT_FOUND") + + def test_unknown_ref(self): + with self.assertRaises(BrokerError) as e: + self.call("http_request", {"credential_ref": "nope.ref", "method": "GET", + "url": "https://service.example/"}) + self.assertEqual(e.exception.code, "NOT_FOUND") + + def test_duplicate_ref(self): + with self.assertRaises(BrokerError) as e: + self.call("resolve", {"ref": "dup.ref"}) + self.assertEqual(e.exception.code, "AMBIGUOUS_REF") + meta = [r for r in self.call("list_refs") if r["ref"] == "dup.ref"] + self.assertTrue(meta[0]["duplicate"]) + + def test_find_for_url(self): + self.assertEqual(self.call("find_for_url", {"url": "https://git2.paginica.com/api/v1/user"}), + ["gitea.admin"]) + self.assertEqual(self.call("find_for_url", {"url": "https://evil.example"}), []) + + # -- adapter policy ------------------------------------------------- + + def test_wrong_adapter(self): + with self.assertRaises(BrokerError) as e: + self.call("browser_login", {"credential_ref": "vps.root", + "url": "https://git2.paginica.com"}) + self.assertEqual(e.exception.code, "ADAPTER_DENIED") + + def test_adapter_not_allowed_cli(self): + with self.assertRaises(BrokerError) as e: + self.call("cli_run", {"profile": "example-echo", "credential_ref": "gitea.admin", + "args": ["hello"]}) + self.assertEqual(e.exception.code, "ADAPTER_DENIED") + + # -- confirmation ---------------------------------------------------- + + def test_every_use_needs_ticket(self): + with self.assertRaises(BrokerError) as e: + self.call("ssh_exec", {"credential_ref": "vps.root", "host": "vps.paginica.com", + "command": "id"}) + self.assertEqual(e.exception.code, "CONFIRMATION_REQUIRED") + ticket = e.exception.ticket + self.assertTrue(ticket) + info = self.call("confirm", {"ticket": ticket}) + self.assertTrue(info["approved"]) + try: + # 192.0.2.1 (TEST-NET-1) is in allowed_hosts and unreachable, + # so ssh exits fast without touching real infrastructure. + res = self.call("ssh_exec", {"credential_ref": "vps.root", + "host": "192.0.2.1", + "command": "id", "ticket": ticket}) + # executed: sanitized result, no secret material + self.assertIn("exit_code", res) + self.assertNotIn(PW, json.dumps(res)) + except BrokerError as e2: + # execution may fail on the network, but never on policy + self.assertNotIn(e2.exception.code, + ("CONFIRMATION_REQUIRED", "DENIED", + "ADAPTER_DENIED", "ORIGIN_DENIED")) + + def test_origin_denied_ssh(self): + with self.assertRaises(BrokerError) as e: + self.call("ssh_exec", {"credential_ref": "vps.root", "host": "evil.example", + "command": "id"}) + self.assertEqual(e.exception.code, "ORIGIN_DENIED") + + # -- HTTP adapter --------------------------------------------------- + + def test_http_bearer_and_sanitized(self): + captured = {} + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + captured["auth"] = self.headers.get("Authorization") + body = json.dumps({"ok": True, "echo": TOKEN}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Set-Cookie", "session=abc123") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + srv = ThreadingHTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{srv.server_address[1]}/api" + # service.example is allowed but the fixture has insecure_http off, + # so add a temporary item bound to the test host via a second item: + # instead we validate the auth header construction directly. + item = {"username": USER, "password": TOKEN, "fields": { + "agent.ref": "x", "agent.adapters": "http", + "agent.allowed_urls": url, "agent.auth_type": "bearer", + "agent.confirmation": "AUTO", "agent.insecure_http": "true"}} + from agent_credential_broker.adapters import http as hmod + p = policy.policy_for(item) + res = hmod.run(item, p, {"url": url, "method": "GET"}, None) + srv.shutdown() + self.assertEqual(captured["auth"], f"Bearer {TOKEN}") + self.assertEqual(res["status"], 200) + self.assertNotIn(TOKEN, res["body"]) + self.assertIn("[REDACTED]", res["body"]) + self.assertEqual(res["headers"]["Set-Cookie"], "[REDACTED]") + + def test_http_basic_auth_header(self): + captured = {} + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + captured["auth"] = self.headers.get("Authorization") + self.send_response(204) + self.send_header("Content-Length", "0") + self.end_headers() + + srv = ThreadingHTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=srv.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{srv.server_address[1]}/" + item = {"username": USER, "password": PW, "fields": { + "agent.ref": "x", "agent.adapters": "http", + "agent.allowed_urls": url, "agent.auth_type": "basic", + "agent.confirmation": "AUTO", "agent.insecure_http": "true"}} + from agent_credential_broker.adapters import http as hmod + res = hmod.run(item, policy.policy_for(item), {"url": url, "method": "GET"}, None) + srv.shutdown() + want = "Basic " + base64.b64encode(f"{USER}:{PW}".encode()).decode() + self.assertEqual(captured["auth"], want) + self.assertEqual(res["status"], 204) + + def test_http_wrong_origin(self): + item = {"username": USER, "password": TOKEN, "fields": { + "agent.ref": "x", "agent.adapters": "http", + "agent.allowed_urls": "https://service.example", + "agent.auth_type": "bearer", "agent.confirmation": "AUTO"}} + from agent_credential_broker.adapters import http as hmod + with self.assertRaises(BrokerError) as e: + hmod.run(item, policy.policy_for(item), + {"url": "https://evil.example/", "method": "GET"}, None) + self.assertEqual(e.exception.code, "ORIGIN_DENIED") + + def test_http_no_embedded_creds_via_adapter(self): + with self.assertRaises(BrokerError) as e: + self.call("git_op", {"credential_ref": "gitea.admin", "op": "clone", + "repository": "https://user:pw@git2.paginica.com/x/y.git", + "dest": "/tmp/nowhere"}) + self.assertEqual(e.exception.code, "DENIED_INVALID_ARGUMENT") + + +class TestCliProfile(BrokerHarness): + @classmethod + def setUpClass(cls): + super().setUpClass() + call("unlock", {"password": MASTER_PW}) + + @classmethod + def tearDownClass(cls): + try: + call("lock") + except Exception: + pass + super().tearDownClass() + + def test_cli_profile_executes(self): + res = self.call("cli_run", {"profile": "example-echo", + "credential_ref": "test.service", + "args": ["hello"]}) + self.assertEqual(res["exit_code"], 0) + self.assertIn("argv: hello", res["stdout"]) + self.assertNotIn(TOKEN, res["stdout"]) + + def test_unknown_profile(self): + with self.assertRaises(BrokerError) as e: + self.call("cli_run", {"profile": "unknown", + "credential_ref": "test.service", "args": ["env"]}) + self.assertEqual(e.exception.code, "DENIED") + + def test_malicious_args(self): + for bad in ("; env", "$(printenv)", "../../bin/sh", "a`id`b", "x|cat /etc/passwd"): + with self.assertRaises(BrokerError) as e: + self.call("cli_run", {"profile": "example-echo", + "credential_ref": "test.service", + "args": [bad]}) + self.assertIn(e.exception.code, ("DENIED_INVALID_ARGUMENT",)) + + def test_subcommand_allowlist(self): + with self.assertRaises(BrokerError): + self.call("cli_run", {"profile": "example-echo", + "credential_ref": "test.service", "args": ["pwned"]}) + + +class TestSanitizer(unittest.TestCase): + def test_exact_secret(self): + self.assertEqual(sanitize_text(f"data {PW} tail", [PW]), "data [REDACTED] tail") + + def test_child_returns_secret(self): + res = ac.run_subprocess(["/bin/sh", "-c", f"echo {PW} >&2; echo {PW}"], + secrets=[PW]) + self.assertNotIn(PW, res["stdout"]) + self.assertNotIn(PW, res["stderr"]) + self.assertIn("[REDACTED]", res["stdout"]) + self.assertIn("[REDACTED]", res["stderr"]) + + def test_kv_patterns(self): + out = sanitize_text("password=abc123 token: xyz token=AAAA1111", []) + self.assertNotIn("abc123", out) + self.assertNotIn("AAAA1111", out) + + def test_headers(self): + h = sanitize_headers({"Authorization": "Bearer x", "X-Ok": "1", "Set-Cookie": "a=b"}) + self.assertEqual(h["Authorization"], "[REDACTED]") + self.assertEqual(h["Set-Cookie"], "[REDACTED]") + self.assertEqual(h["X-Ok"], "1") + + +class TestArgvIsolation(BrokerHarness): + """Spawn broker-side children and confirm secrets are absent from + /proc//cmdline.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + call("unlock", {"password": MASTER_PW}) + + @classmethod + def tearDownClass(cls): + try: + call("lock") + except Exception: + pass + super().tearDownClass() + + def test_askpass_child_cmdline_clean(self): + """Simulate what the ssh/git adapters do: an askpass child whose + argv contains only script + file paths -- never the secret.""" + with ac.secret_dir() as d: + pwfile = d / "pw" + ac.write_secret_file(pwfile, PW) + askpass = ac.write_askpass(d, None, pwfile) + proc = subprocess.Popen([str(askpass), "Password:"], stdout=subprocess.PIPE) + cmdline = Path(f"/proc/{proc.pid}/cmdline").read_bytes() + out, _ = proc.communicate() + self.assertNotIn(PW.encode(), cmdline) + self.assertEqual(out.strip(), PW.encode()) # secret went to the tool only + + def test_env_not_inherited(self): + """clean_env must not leak ambient credential env vars.""" + os.environ["DO_NOT_LEAK_ME"] = "leaky-value" + env = ac.clean_env({"INJECTED": "ok"}) + self.assertNotIn("DO_NOT_LEAK_ME", env) + self.assertEqual(env["INJECTED"], "ok") + del os.environ["DO_NOT_LEAK_ME"] + + def test_cli_env_isolation(self): + """The injected env var exists ONLY in the child's environment -- + the broker process env must not contain the secret afterwards.""" + self.assertNotIn("EXAMPLE_TOKEN", os.environ) + res = self.call("cli_run", {"profile": "example-echo", + "credential_ref": "test.service", "args": ["hello"]}) + self.assertIn("envlen: 24", res["stdout"]) # len(example-token-DO-NOT-USE) + self.assertNotIn(TOKEN, json.dumps(res)) + self.assertNotIn("EXAMPLE_TOKEN", os.environ) + + +class TestNativePlugin(BrokerHarness): + """The agent-browser credential.read plugin path: plugin stdin gets + a credential.resolve envelope, stdout returns the credential -- the + ONLY channel where secret material legitimately leaves the broker, + and it goes to agent-browser, not to the agent.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + call("unlock", {"password": MASTER_PW}) + + @classmethod + def tearDownClass(cls): + try: + call("lock") + except Exception: + pass + super().tearDownClass() + + def _run_plugin(self, envelope): + import io + from agent_credential_broker import plugin + old_in, old_out = sys.stdin, sys.stdout + sys.stdin = io.StringIO(json.dumps(envelope)) + sys.stdout = io.StringIO() + try: + rc = plugin.main() + return rc, json.loads(sys.stdout.getvalue()) + finally: + sys.stdin, sys.stdout = old_in, old_out + + def test_native_provider_resolve(self): + rc, resp = self._run_plugin({ + "protocol": "agent-browser.plugin.v1", + "type": "credential.resolve", + "capability": "credential.read", + "request": {"itemRef": "gitea.admin", + "url": "https://git2.paginica.com/user/login"}, + }) + self.assertEqual(resp["protocol"], "agent-browser.plugin.v1") + self.assertTrue(resp["success"]) + # the plugin is authorized to hand the secret to agent-browser + self.assertEqual(resp["credential"]["password"], PW) + self.assertEqual(resp["credential"]["username"], "gitea-admin") + + def test_native_provider_wrong_origin(self): + rc, resp = self._run_plugin({ + "protocol": "agent-browser.plugin.v1", + "type": "credential.resolve", + "capability": "credential.read", + "request": {"itemRef": "gitea.admin", "url": "https://evil.example/login"}, + }) + self.assertFalse(resp["success"]) + self.assertIn("ORIGIN_DENIED", resp["error"]) + + def test_native_provider_adapter_denied(self): + rc, resp = self._run_plugin({ + "protocol": "agent-browser.plugin.v1", + "type": "credential.resolve", + "capability": "credential.read", + "request": {"itemRef": "vps.root", + "url": "https://vps.paginica.com/login"}, + }) + self.assertFalse(resp["success"]) + self.assertIn("ADAPTER_DENIED", resp["error"]) + + +class TestMcpInterface(unittest.TestCase): + def test_no_retrieval_tools(self): + from agent_credential_broker import mcp_server + resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + names = [t["name"] for t in resp["result"]["tools"]] + forbidden = ("get_password", "get_secret", "get_token", "get_private_key", + "reveal", "dump", "resolve", "read_credential") + for n in names: + for f in forbidden: + self.assertNotIn(f, n.lower(), f"forbidden tool name {n}") + for must in ("vault_status", "vault_list_refs", "credential_http_request", + "ssh_exec", "git_operation", "cli_run", + "browser_login", "browser_fill_credential", + "vault_find_for_url"): + self.assertIn(must, names) + + def test_initialize(self): + from agent_credential_broker import mcp_server + resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + self.assertEqual(resp["result"]["serverInfo"]["name"], "agent-broker") + + +class TestPolicyUnit(unittest.TestCase): + def _p(self, **fields): + f = {"agent.ref": "x", "agent.adapters": "http", + "agent.confirmation": "AUTO"} + f.update(fields) + item = {"id": "i", "name": "n", "fields": f, "uris": []} + return policy.policy_for(item), item + + def test_origin_path_prefix(self): + p, _ = self._p(**{"agent.allowed_urls": "https://h.example/api"}) + policy.require_url_allowed(p, "https://h.example/api/v1/users") + with self.assertRaises(BrokerError): + policy.require_url_allowed(p, "https://h.example/other") + + def test_https_required(self): + p, _ = self._p(**{"agent.allowed_urls": "http://h.example"}) + with self.assertRaises(BrokerError): + policy.require_url_allowed(p, "http://h.example") + p2, _ = self._p(**{"agent.allowed_urls": "http://h.example", + "agent.insecure_http": "true"}) + policy.require_url_allowed(p2, "http://h.example") + + def test_no_fields_no_policy(self): + self.assertIsNone(policy.policy_for({"fields": {}, "uris": []})) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_cli_backend.py b/tests/test_cli_backend.py new file mode 100644 index 0000000..f3d2401 --- /dev/null +++ b/tests/test_cli_backend.py @@ -0,0 +1,390 @@ +"""BitwardenCliBackend tests -- run OFFLINE against a fake `bw` binary. + +The fake records, per invocation: argv, the *names* of secret-channel +env vars it received (never values), and its own /proc/self/cmdline. +Tests assert that API credentials, the master password and the session +key never appear on argv/cmdline, that BW_SESSION lives only in child +env, and that the broker socket surface stays metadata-only. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fakebw import FAKE_BW +from agent_credential_broker.config import Config +from agent_credential_broker.core import BrokerCore +from agent_credential_broker.errors import BrokerError +from agent_credential_broker.server import _Server, call + +MASTER_PW = "fixture-master-password-DO-NOT-USE" +PW = "example-password-DO-NOT-USE" +TOKEN = "example-token-DO-NOT-USE" +CLIENT_ID = "user.fixture-id" +CLIENT_SECRET = "fixture-client-secret" + +ITEMS = [ + {"id": "item-1", "type": 1, "name": "gitea admin", + "login": {"username": "gitea-admin", "password": PW, "totp": None, + "uris": [{"uri": "https://git2.paginica.com"}]}, + "fields": [ + {"name": "agent.ref", "value": "gitea.admin", "type": 0}, + {"name": "agent.adapters", "value": "browser,http,git", "type": 0}, + {"name": "agent.allowed_urls", "value": "https://git2.paginica.com", "type": 0}, + {"name": "agent.auth_type", "value": "bearer", "type": 0}, + {"name": "agent.confirmation", "value": "AUTO", "type": 0}, + ], "notes": None}, + {"id": "item-2", "type": 1, "name": "api token", + "login": {"username": "", "password": TOKEN, "totp": None, + "uris": [{"uri": "https://service.example"}]}, + "fields": [ + {"name": "agent.ref", "value": "test.service", "type": 0}, + {"name": "agent.adapters", "value": "http,cli-profile", "type": 0}, + {"name": "agent.allowed_urls", "value": "https://service.example", "type": 0}, + {"name": "agent.auth_type", "value": "bearer", "type": 0}, + {"name": "agent.confirmation", "value": "AUTO", "type": 0}, + {"name": "agent.cli_profile", "value": "example-echo", "type": 0}, + ], "notes": None}, + {"id": "item-3", "type": 1, "name": "not for agents", + "login": {"username": "x", "password": PW, "totp": None, "uris": []}, + "fields": [], "notes": "private note"}, +] + + +def _write(path: Path, text: str, mode=0o600): + path.write_text(text) + os.chmod(path, mode) + + +def read_calls(appdata: Path) -> list[dict]: + log = appdata / "calls.jsonl" + if not log.exists(): + return [] + return [json.loads(l) for l in log.read_text().splitlines() if l.strip()] + + +class CliBackendHarness(unittest.TestCase): + """Daemon + socket + BitwardenCliBackend driving the fake `bw`.""" + + FAILING_BW = False + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + base = Path(cls.tmp.name) + os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") + os.environ["XDG_STATE_HOME"] = str(base / "state") + (base / "rt").mkdir() + cred = base / "cred" + cred.mkdir() + _write(cred / "id.txt", CLIENT_ID) + _write(cred / "secret.token", CLIENT_SECRET) + + bin_dir = base / "bin" + bin_dir.mkdir() + cls.bw = bin_dir / "bw" + src = FAKE_BW.replace("die(\"unknown cmd", "sys.exit(1) # die(\"unknown cmd") \ + if cls.FAILING_BW else FAKE_BW + cls.bw.write_text(src) + os.chmod(cls.bw, 0o755) + + cls.appdata = base / "bw-appdata" + cls.appdata.mkdir(mode=0o700) + (cls.appdata / "items.json").write_text(json.dumps(ITEMS)) + + cls.cfg_path = base / "broker-config.json" + cls.cfg_path.write_text(json.dumps({ + "vault_url": "https://vault.paginica.com", + "client_id_file": str(cred / "id.txt"), + "client_secret_file": str(cred / "secret.token"), + "vault_backend": "bitwarden-cli", + "bw_path": str(cls.bw), + "bw_appdata_dir": str(cls.appdata), + "unlock_ttl_seconds": 3600, + "git_workdir_roots": [str(Path.home())], + })) + os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path) + os.environ["AGENT_BROKER_SOCKET"] = str(base / "rt" / "agent-broker" / "broker.sock") + os.environ["AGENT_BROKER_PROFILES_DIR"] = str(Path(__file__).resolve().parents[1] / "profiles") + + cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"]) + cls.socket_path.parent.mkdir(parents=True, exist_ok=True) + cls.core = BrokerCore(Config.load()) + cls.server = _Server(str(cls.socket_path), cls.core) + os.chmod(cls.socket_path, 0o600) + threading.Thread(target=cls.server.serve_forever, + kwargs={"poll_interval": 0.1}, daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.tmp.cleanup() + + def call(self, method, params=None): + return call(method, params or {}) + + +class TestCliBackendLifecycle(CliBackendHarness): + def test_locked_then_unlock_and_lock(self): + st = self.call("status") + self.assertTrue(st["locked"]) + self.assertEqual(st["backend"], "bitwarden-cli") + with self.assertRaises(BrokerError) as e: + self.call("list_refs") + self.assertEqual(e.exception.code, "BROKER_LOCKED") + + res = self.call("unlock", {"password": MASTER_PW}) + self.assertTrue(res["unlocked"]) + self.assertEqual(res["items"], len(ITEMS)) + + refs = {r["ref"]: r for r in self.call("list_refs")} + self.assertEqual(set(refs), {"gitea.admin", "test.service"}) + blob = json.dumps(refs) + self.assertNotIn(PW, blob) + self.assertNotIn(TOKEN, blob) + + self.call("lock") + self.assertTrue(self.call("status")["locked"]) + # bw lock ran and wiped the session server-side + self.assertTrue(any(c["argv"][1] == "lock" for c in read_calls(self.appdata))) + + def test_wrong_password_unlock_failed(self): + with self.assertRaises(BrokerError) as e: + self.call("unlock", {"password": "not-the-password"}) + self.assertEqual(e.exception.code, "UNLOCK_FAILED") + + def test_full_item_fetched_per_use(self): + self.call("unlock", {"password": MASTER_PW}) + calls_before = read_calls(self.appdata) + self.assertFalse(any("item-1" in c["argv"] for c in calls_before)) + cred = self.call("resolve", {"ref": "gitea.admin", + "url": "https://git2.paginica.com/user/login"}) + self.assertEqual(cred["password"], PW) + calls = read_calls(self.appdata) + get_calls = [c for c in calls if c["argv"][1:3] == ["get", "item"]] + self.assertTrue(any("item-1" in c["argv"] for c in get_calls)) + # only the requested item was fetched + self.assertFalse(any("item-2" in c["argv"] for c in get_calls)) + self.call("lock") + + def test_metadata_state_has_no_secrets(self): + self.call("unlock", {"password": MASTER_PW}) + blob = json.dumps(self.core.state.items) + for secret in (PW, TOKEN, "private note", "gitea-admin"): + self.assertNotIn(secret, blob) + # only agent.* fields survive in metadata + for meta in self.core.state.items: + for k in meta["fields"]: + self.assertTrue(k.startswith("agent.")) + self.call("lock") + + +class TestCliBackendIsolation(CliBackendHarness): + """Secrets travel only through child env -- never argv, never the + broker's own environment, never logs.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.call = staticmethod(call) + call("unlock", {"password": MASTER_PW}) + call("resolve", {"ref": "gitea.admin", + "url": "https://git2.paginica.com/user/login"}) + # capture the session key while live, then end the session + st = json.loads((cls.appdata / "state.json").read_text()) + cls.session = st.get("session") + call("lock") + + def test_no_secret_on_broker_disk(self): + """Broker-managed paths must never persist plaintext secrets. + (bw_appdata_dir is CLI-managed state and excluded on purpose -- + the official client's own data.json belongs to it.)""" + base = Path(self.tmp.name) + broker_paths = [base / "rt", base / "state", self.cfg_path] + secrets = [s for s in (MASTER_PW, CLIENT_SECRET, PW, TOKEN, + self.session) if s] + for root in broker_paths: + p = Path(root) + files = [p] if p.is_file() else list(p.rglob("*")) + for f in files: + if f.is_file(): + data = f.read_bytes() + for s in secrets: + self.assertNotIn(s.encode(), data, + f"{s!r} persisted in {f}") + + def test_logout_via_socket(self): + self.call("unlock", {"password": MASTER_PW}) + res = self.call("logout") + self.assertTrue(res["logged_out"]) + calls = read_calls(self.appdata) + self.assertTrue(any(c["argv"][1] == "logout" for c in calls)) + st = self.call("status") + self.assertTrue(st["locked"]) + + def test_no_secret_in_argv_or_cmdline(self): + for c in read_calls(self.appdata): + for secret in (CLIENT_SECRET, MASTER_PW, PW, TOKEN): + self.assertNotIn(secret, " ".join(c["argv"])) + self.assertNotIn(secret, c["cmdline"]) + + def test_session_key_only_in_child_env(self): + st = json.loads((self.appdata / "state.json").read_text()) + session = st.get("session") # gone after lock + calls = read_calls(self.appdata) + for c in calls: + if c["argv"][1] == "login": + self.assertIn("BW_CLIENTID", c["env_names"]) + self.assertIn("BW_CLIENTSECRET", c["env_names"]) + if c["argv"][1] == "unlock": + self.assertNotIn("BW_SESSION", c["env_names"]) + if c["argv"][1] in ("sync", "list", "get"): + self.assertIn("BW_SESSION", c["env_names"]) + # session never landed on argv + if session: + for c in calls: + self.assertNotIn(session, " ".join(c["argv"])) + self.assertNotIn(session, c["cmdline"]) + self.assertNotIn("BW_SESSION", os.environ) + + def test_login_env_scoped_to_child(self): + self.assertNotIn("BW_CLIENTID", os.environ) + self.assertNotIn("BW_CLIENTSECRET", os.environ) + + def test_isolated_appdata_dir(self): + self.assertEqual(oct(self.appdata.stat().st_mode & 0o777), "0o700") + for c in read_calls(self.appdata): + self.assertNotIn("Bitwarden CLI", c["cmdline"]) + + def test_config_server_called_for_vault(self): + for c in read_calls(self.appdata): + if c["argv"][1:3] == ["config", "server"]: + self.assertEqual(c["argv"][3], "https://vault.paginica.com") + return + self.fail("bw config server never invoked") + + +class TestCliBackendFailure(unittest.TestCase): + """A failing `bw` must surface VAULT_BACKEND_ERROR -- never a silent + fallback to legacy crypto.""" + + def test_backend_failure_no_fallback(self): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + cred = base / "cred" + cred.mkdir() + _write(cred / "id.txt", CLIENT_ID) + _write(cred / "secret.token", CLIENT_SECRET) + bw = base / "bw" + bw.write_text("#!/bin/sh\nexit 3\n") + os.chmod(bw, 0o755) + appdata = base / "bw-appdata" + appdata.mkdir(mode=0o700) + cfg = Config({ + "client_id_file": str(cred / "id.txt"), + "client_secret_file": str(cred / "secret.token"), + "vault_backend": "bitwarden-cli", + "bw_path": str(bw), + "bw_appdata_dir": str(appdata), + }) + core = BrokerCore(cfg) + with self.assertRaises(BrokerError) as e: + core.unlock(MASTER_PW) + self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR") + self.assertEqual(core.backend.name, "bitwarden-cli") + + def test_child_stderr_never_carries_secret(self): + """A buggy/compromised child echoing the master password on + stderr must still surface a sanitized error only.""" + with tempfile.TemporaryDirectory() as td: + base = Path(td) + cred = base / "cred" + cred.mkdir() + _write(cred / "id.txt", CLIENT_ID) + _write(cred / "secret.token", CLIENT_SECRET) + # logged-in fake that leaks the unlock secret on stderr + bw = base / "bw" + bw.write_text( + "#!/usr/bin/env python3\n" + "import os, sys\n" + "args = sys.argv[1:]\n" + "if args[0] == 'status':\n" + " print('{\"serverUrl\": \"https://vault.paginica.com\"," + " \"status\": \"locked\"}'); sys.exit(0)\n" + "if args[0] == 'unlock':\n" + " var = args[args.index('--passwordenv') + 1]\n" + " print('oops ' + os.environ.get(var, ''), file=sys.stderr)\n" + " sys.exit(1)\n" + "sys.exit(1)\n") + os.chmod(bw, 0o755) + appdata = base / "bw-appdata" + appdata.mkdir(mode=0o700) + cfg = Config({ + "client_id_file": str(cred / "id.txt"), + "client_secret_file": str(cred / "secret.token"), + "vault_backend": "bitwarden-cli", + "bw_path": str(bw), + "bw_appdata_dir": str(appdata), + }) + core = BrokerCore(cfg) + with self.assertRaises(BrokerError) as e: + core.unlock(MASTER_PW) + self.assertNotIn(MASTER_PW, e.exception.message) + self.assertIn("[REDACTED]", e.exception.message) + + def test_unknown_backend_rejected(self): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + for name in ("id.txt", "secret.token"): + _write(base / name, "x") + cfg = Config({"vault_backend": "bogus", + "client_id_file": str(base / "id.txt"), + "client_secret_file": str(base / "secret.token")}) + with self.assertRaises(BrokerError) as e: + BrokerCore(cfg) + self.assertEqual(e.exception.code, "UNCONFIGURED") + + def test_legacy_backend_rejected(self): + """A stale 'legacy-crypto' config must fail loudly -- the custom + Python crypto backend was removed and must never silently be + replaced by another backend.""" + with tempfile.TemporaryDirectory() as td: + base = Path(td) + for name in ("id.txt", "secret.token"): + _write(base / name, "x") + cfg = Config({"vault_backend": "legacy-crypto", + "client_id_file": str(base / "id.txt"), + "client_secret_file": str(base / "secret.token")}) + with self.assertRaises(BrokerError) as e: + BrokerCore(cfg) + self.assertEqual(e.exception.code, "UNCONFIGURED") + + def test_missing_bw_binary(self): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _write(base / "id.txt", CLIENT_ID) + _write(base / "secret.token", CLIENT_SECRET) + cfg = Config({"vault_backend": "bitwarden-cli", + "bw_path": str(base / "no-such-bw"), + "bw_appdata_dir": str(base / "app"), + "client_id_file": str(base / "id.txt"), + "client_secret_file": str(base / "secret.token")}) + core = BrokerCore(cfg) + self.assertFalse(core.status()["configured"]) + with self.assertRaises(BrokerError) as e: + core.unlock(MASTER_PW) + self.assertEqual(e.exception.code, "UNCONFIGURED") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_openbao_backend.py b/tests/test_openbao_backend.py new file mode 100644 index 0000000..ce50d9e --- /dev/null +++ b/tests/test_openbao_backend.py @@ -0,0 +1,438 @@ +"""OpenBaoBackend tests -- OFFLINE against a fake OpenBao HTTP server. + +Mirror of test_cli_backend.py for the ``openbao`` backend. The fake +(tests/fake_bao.py) records every request path: these tests assert that +unlock + ref-index construction NEVER touch ``/data/*`` (the metadata +API is the only index source -- zero plaintext transit), that items +resolve to the same normalized shape as the bw backend, and that auth +failures map to UNLOCK_FAILED / BROKER_LOCKED. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import fake_bao +from agent_credential_broker.config import Config +from agent_credential_broker.core import BrokerCore +from agent_credential_broker.errors import BrokerError +from agent_credential_broker.server import _Server, call + +ROLE_ID = fake_bao.ROLE_ID +SECRET_ID = fake_bao.SECRET_ID +PW = fake_bao.PW +TOKEN = fake_bao.TOKEN + + +def _write(path: Path, text: str, mode=0o600): + path.write_text(text) + os.chmod(path, mode) + + +def _cfg_dict(base: Path, port: int, **extra) -> dict: + cfg = { + "vault_backend": "openbao", + "openbao_addr": f"http://127.0.0.1:{port}", + "openbao_mount": "secret", + "openbao_prefix": "agent", + "unlock_ttl_seconds": 3600, + "git_workdir_roots": [str(Path.home())], + } + cfg.update(extra) + return cfg + + +class BaoHarness(unittest.TestCase): + """Daemon + socket + OpenBaoBackend against the fake server. + + AUTH_MODE selects the credential material provisioned in setUpClass: + "approle" (role_id+secret_id files) or "token_file" (0600 token). + """ + + AUTH_MODE = "approle" + + @classmethod + def setUpClass(cls): + # hermetic: a stray real token env var must not leak into fixtures + os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) + cls.tmp = tempfile.TemporaryDirectory() + base = Path(cls.tmp.name) + os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") + os.environ["XDG_STATE_HOME"] = str(base / "state") + (base / "rt").mkdir() + cred = base / "cred" + cred.mkdir() + + extra = {} + if cls.AUTH_MODE == "approle": + _write(cred / "role-id.txt", ROLE_ID) + _write(cred / "secret-id.txt", SECRET_ID) + extra["openbao_role_id_file"] = str(cred / "role-id.txt") + extra["openbao_secret_id_file"] = str(cred / "secret-id.txt") + extra["openbao_token_file"] = str(cred / "absent-token.txt") + elif cls.AUTH_MODE == "token_file": + _write(cred / "bao-token.txt", fake_bao.TOKEN_VALUE + "-seed") + extra["openbao_token_file"] = str(cred / "bao-token.txt") + extra["openbao_role_id_file"] = str(cred / "absent-role.txt") + extra["openbao_secret_id_file"] = str(cred / "absent-secret.txt") + # the seed token must be a live token on the fake server + cls._seed_tokens = [fake_bao.TOKEN_VALUE + "-seed"] + + cls.bao, cls.bao_thread, cls.port = fake_bao.start_server() + for tok in getattr(cls, "_seed_tokens", []): + cls.bao.tokens.add(tok) + + cls.cfg_path = base / "broker-config.json" + cls.cfg_path.write_text(json.dumps(_cfg_dict(base, cls.port, **extra))) + os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path) + os.environ["AGENT_BROKER_SOCKET"] = str( + base / "rt" / "agent-broker" / "broker.sock") + os.environ["AGENT_BROKER_PROFILES_DIR"] = str( + Path(__file__).resolve().parents[1] / "profiles") + + cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"]) + cls.socket_path.parent.mkdir(parents=True, exist_ok=True) + cls.core = BrokerCore(Config.load()) + cls.server = _Server(str(cls.socket_path), cls.core) + os.chmod(cls.socket_path, 0o600) + threading.Thread(target=cls.server.serve_forever, + kwargs={"poll_interval": 0.1}, daemon=True).start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.bao.shutdown() + cls.tmp.cleanup() + os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) + + def call(self, method, params=None): + return call(method, params or {}) + + def data_calls(self, since=0): + return [p for m, p in self.bao.calls[since:] if f"/data/" in p] + + +class TestApproleUnlock(BaoHarness): + """unlock via AppRole: role_id+secret_id files -> auth/approle/login.""" + + def test_unlock_status_refs(self): + st = self.call("status") + self.assertTrue(st["locked"]) + self.assertEqual(st["backend"], "openbao") + res = self.call("unlock", {"password": "ignored-by-design"}) + self.assertTrue(res["unlocked"]) + self.assertEqual(res["items"], 4) # incl. nested + ref-less item + refs = {r["ref"]: r for r in self.call("list_refs")} + self.assertEqual( + set(refs), {"gitea.admin", "test.service", "infra.router"}) + blob = json.dumps(refs) + self.assertNotIn(PW, blob) + self.assertNotIn(TOKEN, blob) + self.call("lock") + + def test_index_never_touches_data(self): + """unlock + list_refs must use ONLY metadata endpoints.""" + n0 = len(self.bao.calls) + self.call("unlock", {"password": "x"}) + self.call("list_refs") + self.assertEqual(self.data_calls(since=n0), []) + self.assertTrue( + any("/metadata/agent" in p for m, p in self.bao.calls[n0:])) + self.call("lock") + + def test_resolve_fetches_item_on_demand(self): + n0 = len(self.bao.calls) + self.call("unlock", {"password": "x"}) + self.assertEqual(self.data_calls(since=n0), []) + cred = self.call("resolve", {"ref": "gitea.admin", + "url": "https://git2.paginica.com/login"}) + self.assertEqual(cred["password"], PW) + self.assertEqual(cred["username"], "gitea-admin") + # exactly one data read, for the requested path only + self.assertEqual(self.data_calls(since=n0), + ["/v1/secret/data/agent/gitea.admin"]) + self.call("lock") + + def test_nested_path_id(self): + self.call("unlock", {"password": "x"}) + meta = self.core.backend.get_item("infra/router") + self.assertEqual(meta["id"], "infra/router") + self.assertEqual(meta["username"], "root") + self.assertEqual(meta["fields"]["agent.ref"], "infra.router") + self.call("lock") + + def test_lock_revokes_token(self): + self.call("unlock", {"password": "x"}) + tok = self.core.backend._token + self.call("lock") + self.assertTrue(self.call("status")["locked"]) + self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls) + self.assertNotIn(tok, self.bao.tokens) # revoked server-side + self.assertIsNone(self.core.backend._token) + + def test_logout_revokes(self): + self.call("unlock", {"password": "x"}) + self.call("logout") + self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls) + self.assertTrue(self.call("status")["locked"]) + + def test_wrong_secret_id_unlock_failed(self): + self.call("lock") # clean slate in case a prior test left state + # rotate the expected secret on the fake server: next login fails + self.bao.secret_id = "other-secret" + try: + with self.assertRaises(BrokerError) as e: + self.call("unlock", {"password": "x"}) + self.assertEqual(e.exception.code, "UNLOCK_FAILED") + finally: + self.bao.secret_id = SECRET_ID + self.assertTrue(self.call("status")["locked"]) + + def test_403_mid_session_relog_then_locked(self): + """A 403 on data/* triggers ONE transparent approle re-login; + a second 403 drops the token -> BROKER_LOCKED.""" + n0 = len(self.bao.calls) + self.call("unlock", {"password": "x"}) + logins = sum(1 for m, p in self.bao.calls[n0:] + if "approle/login" in p) + self.assertEqual(logins, 1) + self.bao.forbidden.add("agent/gitea.admin") + try: + with self.assertRaises(BrokerError) as e: + self.call("resolve", {"ref": "gitea.admin", + "url": "https://git2.paginica.com"}) + self.assertEqual(e.exception.code, "BROKER_LOCKED") + logins = sum(1 for m, p in self.bao.calls[n0:] + if "approle/login" in p) + self.assertEqual(logins, 2) # exactly one re-login attempt + self.assertIsNone(self.core.backend._token) + finally: + self.bao.forbidden.discard("agent/gitea.admin") + self.call("lock") + + +class TestTokenFileUnlock(BaoHarness): + AUTH_MODE = "token_file" + SEED = fake_bao.TOKEN_VALUE + "-seed" + + def setUp(self): + # lock() revokes the shared fixture token server-side; re-seed it + # so each test starts from a live token again. + self.bao.tokens.add(self.SEED) + + def test_unlock_via_token_file(self): + res = self.call("unlock", {"password": "still-ignored"}) + self.assertTrue(res["unlocked"]) + # no approle login happened -- lookup-self verified the file token + self.assertFalse(any("approle/login" in p for m, p in self.bao.calls)) + self.assertIn(("GET", "/v1/auth/token/lookup-self"), self.bao.calls) + self.call("lock") + + def test_403_locks_without_relogin(self): + """No approle creds -> a 403 cannot re-login; straight to locked.""" + self.call("unlock", {"password": "x"}) + self.bao.forbidden.add("agent/test.service") + try: + with self.assertRaises(BrokerError) as e: + self.core.backend.get_item("test.service") + self.assertEqual(e.exception.code, "BROKER_LOCKED") + self.assertFalse(any("approle/login" in p + for m, p in self.bao.calls)) + finally: + self.bao.forbidden.discard("agent/test.service") + self.call("lock") + + def test_renewable_token_renews_near_expiry(self): + self.bao.lookup_ttl = 30 # < _RENEW_MARGIN_S -> renew on next call + self.bao.lookup_renewable = True + try: + self.call("unlock", {"password": "x"}) + # renewal already happened inside unlock's metadata prefetch + self.assertIn(("POST", "/v1/auth/token/renew-self"), + self.bao.calls) + self.call("lock") + finally: + self.bao.lookup_ttl = 3600 + + +class TestBackendUnit(unittest.TestCase): + """Direct OpenBaoBackend use (no socket) for shape/error details.""" + + def setUp(self): + os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + os.environ["XDG_RUNTIME_DIR"] = str(base / "rt") + os.environ["XDG_STATE_HOME"] = str(base / "state") + (base / "rt").mkdir() + cred = base / "cred" + cred.mkdir() + _write(cred / "role-id.txt", ROLE_ID) + _write(cred / "secret-id.txt", SECRET_ID) + self.bao, self._t, self.port = fake_bao.start_server() + cfgd = _cfg_dict(base, self.port, + openbao_role_id_file=str(cred / "role-id.txt"), + openbao_secret_id_file=str(cred / "secret-id.txt"), + openbao_token_file=str(cred / "absent-token.txt")) + self.cfgd = cfgd + from agent_credential_broker.backends.openbao import OpenBaoBackend + self.backend = OpenBaoBackend(Config(cfgd)) + + def tearDown(self): + self.bao.shutdown() + self.tmp.cleanup() + os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None) + + def test_get_item_full_shape(self): + self.backend.unlock("ignored") + item = self.backend.get_item("gitea.admin") + self.assertEqual( + set(item), + {"id", "type", "name", "username", "password", "totp", + "uris", "fields", "notes"}) + self.assertEqual(item["id"], "gitea.admin") + self.assertEqual(item["name"], "gitea admin") # from label + self.assertEqual(item["username"], "gitea-admin") + self.assertEqual(item["password"], PW) + self.assertEqual(item["notes"], "bot account") + self.assertEqual(item["uris"], ["https://git2.paginica.com"]) + f = item["fields"] + self.assertEqual(f["agent.ref"], "gitea.admin") + self.assertEqual(f["agent.confirmation"], "AUTO") + self.assertNotIn("label", f) # reserved metadata keys mapped away + self.assertNotIn("uris", f) + + def test_get_item_csv_uris_and_no_label(self): + self.backend.unlock("x") + item = self.backend.get_item("test.service") + self.assertEqual(item["name"], "api token") + self.assertEqual(item["uris"], + ["https://service.example", "https://alt.example"]) + self.assertIsNone(item["username"]) + self.backend.lock() + + def test_get_item_not_found(self): + self.backend.unlock("x") + with self.assertRaises(BrokerError) as e: + self.backend.get_item("no-such-item") + self.assertEqual(e.exception.code, "NOT_FOUND") + + def test_get_item_path_traversal_rejected(self): + self.backend.unlock("x") + for bad in ("../sys", "/sys", ".."): + with self.assertRaises(BrokerError) as e: + self.backend.get_item(bad) + self.assertEqual(e.exception.code, "NOT_FOUND") + + def test_metadata_projection_agent_only(self): + self.backend.unlock("x") + meta = {m["id"]: m for m in self.backend.list_metadata()} + g = meta["gitea.admin"] + self.assertEqual(g["name"], "gitea admin") + self.assertEqual(g["uris"], ["https://git2.paginica.com"]) + self.assertTrue(all(k.startswith("agent.") for k in g["fields"])) + for m in meta.values(): + self.assertIsNone(m["password"]) + self.assertIsNone(m["username"]) + self.assertIsNone(m["notes"]) + + def test_empty_prefix_unlocks_with_zero_items(self): + cfg = dict(self.cfgd) + cfg["openbao_prefix"] = "empty-prefix" + from agent_credential_broker.backends.openbao import OpenBaoBackend + b = OpenBaoBackend(Config(cfg)) + b.unlock("x") # LIST 404 -> empty index, not an error + self.assertEqual(b.list_metadata(), []) + + def test_expired_token_locks(self): + self.backend.unlock("x") + self.backend._token_expires_at = time.time() - 1 + with self.assertRaises(BrokerError) as e: + self.backend.get_item("gitea.admin") + self.assertEqual(e.exception.code, "BROKER_LOCKED") + self.assertIsNone(self.backend._token) + + def test_unlock_via_token_env(self): + tok = fake_bao.TOKEN_VALUE + "-env" + self.bao.tokens.add(tok) + os.environ["AGENT_BROKER_OPENBAO_TOKEN"] = tok + cfg = dict(self.cfgd) + cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none") + cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "none2") + cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3") + from agent_credential_broker.backends.openbao import OpenBaoBackend + b = OpenBaoBackend(Config(cfg)) + self.assertTrue(b.configured()) + b.unlock("x") + self.assertEqual(b._token, tok) + b.lock() + + def test_unlock_via_custom_token_env(self): + tok = fake_bao.TOKEN_VALUE + "-custom" + self.bao.tokens.add(tok) + os.environ["MY_BAO_TOKEN"] = tok + try: + cfg = dict(self.cfgd) + cfg["openbao_token_env"] = "MY_BAO_TOKEN" + cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3") + cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none") + cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "n2") + from agent_credential_broker.backends.openbao import OpenBaoBackend + b = OpenBaoBackend(Config(cfg)) + b.unlock("x") + self.assertEqual(b._token, tok) + b.lock() + finally: + os.environ.pop("MY_BAO_TOKEN", None) + + def test_unconfigured(self): + cfg = dict(self.cfgd) + for k in ("openbao_role_id_file", "openbao_secret_id_file", + "openbao_token_file"): + cfg[k] = str(Path(self.tmp.name) / ("absent-" + k)) + from agent_credential_broker.backends.openbao import OpenBaoBackend + b = OpenBaoBackend(Config(cfg)) + self.assertFalse(b.configured()) + with self.assertRaises(BrokerError) as e: + b.unlock("x") + self.assertEqual(e.exception.code, "UNCONFIGURED") + + def test_bad_perms_credential_file(self): + cred = Path(self.tmp.name) / "cred" + _write(cred / "secret-id.txt", SECRET_ID, mode=0o644) + with self.assertRaises(BrokerError) as e: + self.backend.unlock("x") + self.assertEqual(e.exception.code, "UNCONFIGURED") + os.chmod(cred / "secret-id.txt", 0o600) + + def test_server_down_backend_error(self): + self.bao.shutdown() + with self.assertRaises(BrokerError) as e: + self.backend.unlock("x") + self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR") + self.assertNotIn(SECRET_ID, e.exception.message) + + def test_error_never_echoes_secret_id(self): + self.bao.secret_id = "rotated" # force 400 on login + try: + with self.assertRaises(BrokerError) as e: + self.backend.unlock("x") + self.assertEqual(e.exception.code, "UNLOCK_FAILED") + self.assertNotIn(SECRET_ID, e.exception.message) + self.assertNotIn(ROLE_ID, e.exception.message) + finally: + self.bao.secret_id = SECRET_ID + + +if __name__ == "__main__": + unittest.main(verbosity=2)