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,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
|
||||
Reference in new issue
Block a user