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:
commit
be87900794
35 files changed
+5003
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.pytest_cache/
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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')",
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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 {})
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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*"]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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))
|
||||||
|
'''
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in new issue
Block a user