Files
agent-credential-broker/agent_credential_broker/adapters/cli_profile.py
T
me be87900794 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.
2026-10-14 13:27:33 -06:00

169 lines
6.5 KiB
Python

"""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