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.
136 lines
5.5 KiB
Python
136 lines
5.5 KiB
Python
"""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
|