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.
This commit is contained in:
me committed 2026-10-14 13:27:33 -06:00
commit be87900794
35 files changed
+5003

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.pytest_cache/
+16
View File
@@ -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"
+116
View File
@@ -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 `<mount>/data/<prefix>/<ref>`; 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/<ref> {username, password, totp, uris, notes, ...}
secret/metadata/agent/<ref> 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/<sub>/<ref>`) — 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.
+9
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
@@ -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()
+115
View File
@@ -0,0 +1,115 @@
"""Browser adapter.
Preferred path (native provider): the agent runs
agent-browser auth login <name> --credential-provider broker \\
--item <agent.ref>
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/<pid>/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
@@ -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
+117
View File
@@ -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
+135
View File
@@ -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
+113
View File
@@ -0,0 +1,113 @@
"""HTTP adapter: credential-bearing requests without exposing headers.
Supported auth types (from agent.auth_type):
bearer -> Authorization: Bearer <password-field>
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,
}
+100
View File
@@ -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
+41
View File
@@ -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
@@ -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')",
)
+216
View File
@@ -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")
+377
View File
@@ -0,0 +1,377 @@
"""OpenBao (Vault-compatible) HTTP API backend -- stdlib only.
Talks to the KV v2 engine at ``<openbao_mount>`` over plain
``urllib.request``; there is no ``bao`` CLI dependency.
OpenBao --(HTTPS API)--> broker --> adapters
Layout (design doc sections 3/4/6):
<mount>/data/<prefix>/<ref> secret fields {username, password,
totp, uris, notes, ...}
<mount>/metadata/<prefix>/<ref> 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 "<mount>/data/<prefix>/*" { capabilities = ["read"] }
path "<mount>/metadata/<prefix>/*" { 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/<prefix>/<sub>. 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 {})
+118
View File
@@ -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 <ticket>
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())
+165
View File
@@ -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 <mount>/data/<prefix>/<ref> (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()
+256
View File
@@ -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)
+76
View File
@@ -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)
+172
View File
@@ -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 ``<mount>/data/<path>`` 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
+228
View File
@@ -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 <status|unlock|lock|list-refs>", 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())
+70
View File
@@ -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())
+167
View File
@@ -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)
+71
View File
@@ -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
+170
View File
@@ -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")
+12
View File
@@ -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
}
+16
View File
@@ -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
}
+18
View File
@@ -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*"]
+16
View File
@@ -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
+255
View File
@@ -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/<mount>/metadata/<p>/?list=true (or LIST verb)
GET /v1/<mount>/metadata/<p>/<key>
GET /v1/<mount>/data/<p>/<key>
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/<mount>/{metadata,data}/<rel...>
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"]})
+105
View File
@@ -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))
'''
+597
View File
@@ -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:[email protected]/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/<pid>/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)
+390
View File
@@ -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)
+438
View File
@@ -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)