agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend

Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).

New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
This commit is contained in:
me committed 2026-10-14 13:27:33 -06:00
commit be87900794
35 files changed
+5003

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
"""Broker core: vault state, ref resolution, confirmations, dispatch.
This object lives inside the daemon process and is the ONLY place where
decrypted credential material exists -- in RAM, under a LOCKED default,
auto-expiring after ``unlock_ttl_seconds``.
"""
from __future__ import annotations
import secrets as _secrets
import threading
import time
from . import errors, policy as pol
from .audit import audit
from .backends import make_backend
from .config import Config
from .items import wipe_item
class VaultState:
def __init__(self):
self.items: list[dict] = []
self.ref_index: dict[str, list[int]] = {}
self.unlocked_at: float | None = None
self.session_approvals: set = set()
self.tickets: dict[str, dict] = {}
def clear(self):
for item in self.items:
for k, v in item.items():
item[k] = None
self.items = []
self.ref_index = {}
self.unlocked_at = None
self.session_approvals = set()
self.tickets = {}
@property
def unlocked(self) -> bool:
return self.unlocked_at is not None
class BrokerCore:
def __init__(self, config: Config):
self.config = config
self.backend = make_backend(config)
self.state = VaultState()
self._lock = threading.RLock()
# -- lifecycle -----------------------------------------------------
def status(self) -> dict:
with self._lock:
self._expire_if_needed()
return {
"locked": not self.state.unlocked,
"configured": self._configured(),
"refs": len(self.state.ref_index) if self.state.unlocked else None,
"backend": self.backend.name,
"socket": "unix",
}
def _configured(self) -> bool:
return self.backend.configured()
def _expire_if_needed(self):
st = self.state
if st.unlocked and (time.time() - st.unlocked_at) > self.config.unlock_ttl_seconds:
st.clear()
self.backend.lock()
audit("auto_lock", result="ok", detail="ttl expired")
def unlock(self, password: str) -> dict:
if not self._configured():
raise errors.unconfigured()
try:
self.backend.unlock(password)
finally:
password = None
items = self.backend.list_metadata()
with self._lock:
self.state.clear()
self.state.items = items
for i, item in enumerate(items):
p = pol.policy_for(item)
if p:
self.state.ref_index.setdefault(p.ref, []).append(i)
self.state.unlocked_at = time.time()
dupes = [r for r, idx in self.state.ref_index.items() if len(idx) > 1]
audit("unlock", result="ok", detail=f"items={len(items)} refs={len(self.state.ref_index)}")
return {
"unlocked": True,
"items": len(items),
"refs": len(self.state.ref_index),
"duplicate_refs": sorted(dupes),
}
def lock(self) -> dict:
with self._lock:
self.state.clear()
self.backend.lock()
audit("lock", result="ok")
return {"locked": True}
def logout(self) -> dict:
with self._lock:
self.state.clear()
self.backend.logout()
audit("logout", result="ok")
return {"locked": True, "logged_out": True}
# -- refs -----------------------------------------------------------
def _require_unlocked(self):
self._expire_if_needed()
if not self.state.unlocked:
raise errors.locked()
def _resolve_index(self, ref: str) -> int:
"""Exact-match ref -> item index. 0 -> NOT_FOUND, >1 -> AMBIGUOUS_REF."""
idxs = self.state.ref_index.get(ref, [])
if not idxs:
raise errors.not_found(ref)
if len(idxs) > 1:
raise errors.ambiguous(ref, len(idxs))
return idxs[0]
def resolve_ref(self, ref: str) -> tuple[dict, pol.CredentialPolicy]:
"""Internal resolve: returns (decrypted item, policy). Callers in
the process only -- the socket layer restricts who may receive
the secret material this yields."""
self._require_unlocked()
if not isinstance(ref, str) or not ref or len(ref) > 200:
raise errors.invalid_argument("bad ref")
item = self.state.items[self._resolve_index(ref)]
p = pol.policy_for(item)
if p is None:
raise errors.not_found(ref)
return item, p
def list_refs(self) -> list[dict]:
self._require_unlocked()
out = []
seen = set()
for ref, idxs in self.state.ref_index.items():
for i in idxs:
p = pol.policy_for(self.state.items[i])
meta = p.safe_metadata()
if len(idxs) > 1:
meta["duplicate"] = True
if ref not in seen:
out.append(meta)
seen.add(ref)
return sorted(out, key=lambda m: m["ref"])
def find_for_url(self, url: str) -> list[str]:
self._require_unlocked()
refs = []
for ref in self.state.ref_index:
try:
item = self.state.items[self.state.ref_index[ref][0]]
p = pol.policy_for(item)
if p is None:
continue
pol.require_url_allowed(p, url, require_https=False)
refs.append(ref)
except errors.BrokerError:
continue
return sorted(set(refs))
# -- confirmation ----------------------------------------------------
def _confirm(self, policy, adapter: str, target: str, ticket=None):
level = policy.confirmation
if level == "DENY":
raise errors.denied(f"credential {policy.ref!r} is DENY for confirmation")
if level == "AUTO":
return
if level == "SESSION" and (policy.ref, adapter) in self.state.session_approvals:
return
if ticket:
tk = self.state.tickets.pop(ticket, None)
if (tk and tk["approved"] and time.time() < tk["expires"]
and tk["ref"] == policy.ref and tk["adapter"] == adapter):
if level == "SESSION":
self.state.session_approvals.add((policy.ref, adapter))
return
raise errors.denied("confirmation ticket invalid or expired")
ticket_id = _secrets.token_urlsafe(24)
self.state.tickets[ticket_id] = {
"ref": policy.ref, "adapter": adapter, "target": target,
"approved": False, "expires": time.time() + self.config.confirmation_ttl_seconds,
}
audit("confirmation_pending", ref=policy.ref, adapter=adapter, target=target)
raise errors.confirmation_required(ticket_id)
def confirm(self, ticket: str) -> dict:
"""Approve a pending ticket. Invoked interactively by the human."""
tk = self.state.tickets.get(ticket)
if not tk or time.time() > tk["expires"]:
raise errors.denied("unknown or expired ticket")
tk["approved"] = True
tk["expires"] = time.time() + self.config.confirmation_ttl_seconds
audit("confirmed", ref=tk["ref"], adapter=tk["adapter"], target=tk["target"])
return {"approved": True, "ref": tk["ref"], "adapter": tk["adapter"], "target": tk["target"]}
# -- adapter dispatch --------------------------------------------------
def run(self, adapter: str, params: dict) -> dict:
"""Central guarded dispatch used by every adapter."""
self._require_unlocked()
ref = params.get("credential_ref") or params.get("ref")
ticket = params.get("ticket")
meta, p = self.resolve_ref(ref)
pol.require_adapter(p, adapter)
target = params.get("url") or params.get("host") or params.get("repository") or params.get("profile") or ""
from .adapters import registry
registry.validate(adapter, meta, p, params, self.config)
self._confirm(p, adapter, target, ticket=ticket)
item = self.backend.get_item(meta["id"])
try:
result = registry.run(adapter, item, p, params, self.config)
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="ok")
return result
except errors.BrokerError as e:
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code=e.code)
raise
except Exception:
audit(adapter, ref=p.ref, adapter=adapter, target=target, result="error", error_code="INTERNAL_ERROR")
raise errors.internal("adapter failed")
finally:
wipe_item(item)
def resolve_for_plugin(self, ref: str, url: str | None) -> dict:
"""Socket-exposed resolve for the agent-browser credential.read
plugin. Only usable for refs whose policy allows the browser
adapter; origin is validated when a URL is provided."""
meta, p = self.resolve_ref(ref)
pol.require_adapter(p, "browser")
if url:
pol.require_url_allowed(p, url)
audit("resolve", ref=p.ref, adapter="browser", target=url or p.uris[:1])
item = self.backend.get_item(meta["id"])
try:
secrets = {
"username": item.get("username"),
"password": item.get("password"),
"url": url or (p.uris[0] if p.uris else None),
"usernameSelector": p.username_selector,
"passwordSelector": p.password_selector,
"submitSelector": p.submit_selector,
}
return {k: v for k, v in secrets.items() if v}
finally:
wipe_item(item)