fix(workflow-guard): fail closed on ambiguous wrappers and multi/rewritten push URLs

Follow-up hardening from the security re-verification of the prior fix:

- H6: the parser's wrapper skip-loop skipped a wrapper's option flag but not its
  space-separated value, so `nice -n 10 git push`, `sudo -u ci git push`,
  `timeout 5 git push` (and wrappers outside the set) returned None and ran
  unguarded. Rewrite `_parse_git_tokens` to locate the `git` executable and fail
  closed on any unrecognized leading token before a git push, instead of a silent
  pass-through.
- H1: `git remote get-url --push` returns only the first of multiple pushurl
  entries while `git push` writes to ALL of them, and insteadOf/pushInsteadOf can
  rewrite the destination. Use `get-url --push --all` and fail closed unless there
  is exactly one push URL and no URL rewrite is configured.

Adds tests for valued-wrapper-option pushes, multiple push URLs, and URL rewrites.

Claude-Session: https://claude.ai/code/session_01GxSndB7VoGQyeS196eUr5E
This commit is contained in:
Adam Moussa 2026-07-01 18:59:59 -04:00
parent 7ec08d1870
commit abbb10dd00
No known key found for this signature in database
2 changed files with 161 additions and 33 deletions

View file

@ -181,38 +181,33 @@ def _parse_git_push(command: str) -> ParsedGitPush | _BlockedGitPush | None:
return _parse_git_tokens(tokens, repo_dir=None) return _parse_git_tokens(tokens, repo_dir=None)
def _git_invocation_args(tokens: list[str]) -> list[str] | None:
"""Return the args following the `git` executable if these tokens invoke git, else None.
Recognizes bareword `git`, path invocations like `/usr/bin/git`, and simple command
wrappers (`command`/`env`/`nice`/`sudo`/...), including `env NAME=VALUE ...`. This keeps
a wrapped or path-qualified `git push` from being mistaken for a non-git command and
passed through the guard unchecked.
"""
idx = 0
while idx < len(tokens):
tok = tokens[idx]
if tok.rsplit("/", 1)[-1] == "git":
return tokens[idx + 1 :]
if tok in _GIT_WRAPPERS:
idx += 1
# Skip wrapper options and `NAME=VALUE` assignments (e.g. `env FOO=bar git ...`).
while idx < len(tokens) and (tokens[idx].startswith("-") or "=" in tokens[idx]):
idx += 1
continue
return None
return None
def _parse_git_tokens( def _parse_git_tokens(
tokens: list[str], *, repo_dir: str | None tokens: list[str], *, repo_dir: str | None
) -> ParsedGitPush | _BlockedGitPush | None: ) -> ParsedGitPush | _BlockedGitPush | None:
git_args = _git_invocation_args(tokens) """Parse tokens into a supported git push, a block sentinel, or None.
if git_args is None:
Recognizes bareword `git`, path invocations (`/usr/bin/git`), and wrapper-prefixed
forms (`command`/`env`/`nice`/... git). Anything git-push-shaped that cannot be reduced
to `git [-C <dir>] push [-u] origin <refspec>` fails closed to a block rather than a
silent pass-through. Non-push git commands and non-git commands return None.
"""
git_idx = next((idx for idx, tok in enumerate(tokens) if tok.rsplit("/", 1)[-1] == "git"), None)
if git_idx is None:
return None return None
git_args = tokens[git_idx + 1 :]
if "push" not in git_args: if "push" not in git_args:
# A non-push git command (e.g. `git status`) is not our concern. # A non-push git command (e.g. `git status`, `git pull`).
return None return None
# Everything before the `git` executable must be a benign wrapper prefix (a known
# wrapper, an option flag, or a NAME=VALUE assignment). An unrecognized leading token
# (a value-taking wrapper option like the `10` in `nice -n 10`, an unknown wrapper, or
# `git` used as an argument to another program) is ambiguous, so fail closed.
if any(
not (tok in _GIT_WRAPPERS or tok.startswith("-") or "=" in tok) for tok in tokens[:git_idx]
):
return _BlockedGitPush(
"unrecognized wrapper before `git push`; use `git push origin <branch>`"
)
i = 0 i = 0
while i < len(git_args) and git_args[i] != "push": while i < len(git_args) and git_args[i] != "push":
if git_args[i] == "-C" and i + 1 < len(git_args): if git_args[i] == "-C" and i + 1 < len(git_args):
@ -376,12 +371,31 @@ def _fetch_remote_base(backend: Any, repo_dir: str | None, remote: str, branch:
repo. Fetching the base from the push URL keeps the base and the push destination the repo. Fetching the base from the push URL keeps the base and the push destination the
same authenticated repo, so a split cannot hide a workflow change. Uses `FETCH_HEAD` same authenticated repo, so a split cannot hide a workflow change. Uses `FETCH_HEAD`
(freshly fetched), never a local `refs/remotes/origin/*` ref the sandbox could rewrite. (freshly fetched), never a local `refs/remotes/origin/*` ref the sandbox could rewrite.
Returns None (which makes the caller require approval) if the push destination is
ambiguous or rewritten: multiple `pushurl` entries (git pushes to ALL of them, so a
single base cannot represent the destination) or any `insteadOf`/`pushInsteadOf` URL
rewrite the sandbox could use to make the inspected URL differ from the push target.
""" """
push_url_res = _run_git(backend, repo_dir, f"remote get-url --push {shlex.quote(remote)}") rewrites = _run_git(
push_url = _first_line(push_url_res.output) if push_url_res.ok else "" backend, repo_dir, "config --get-regexp " + shlex.quote(r"url\..*\.(push)?insteadof")
if not push_url: )
if rewrites.ok and rewrites.output.strip():
# A URL rewrite means the inspected push URL may not be the real destination.
return None return None
push_url_res = _run_git(backend, repo_dir, f"remote get-url --push --all {shlex.quote(remote)}")
push_urls = (
[line.strip() for line in push_url_res.output.splitlines() if line.strip()]
if push_url_res.ok
else []
)
# `git push` sends to every configured push URL; if there is not exactly one we cannot
# represent the destination with a single base, so fail closed.
if len(push_urls) != 1:
return None
push_url = push_urls[0]
fetch = _run_git(backend, repo_dir, f"fetch {shlex.quote(push_url)} {shlex.quote(branch)}") fetch = _run_git(backend, repo_dir, f"fetch {shlex.quote(push_url)} {shlex.quote(branch)}")
if fetch.ok: if fetch.ok:
return "FETCH_HEAD" return "FETCH_HEAD"

View file

@ -29,6 +29,8 @@ class _Backend:
remote_branches: set[str] | None = None, remote_branches: set[str] | None = None,
default_branch: str = "main", default_branch: str = "main",
push_url: str = "https://github.com/langchain-ai/open-swe.git", push_url: str = "https://github.com/langchain-ai/open-swe.git",
push_urls: list[str] | None = None,
url_rewrites: str = "",
ls_tree_head_ok: bool = True, ls_tree_head_ok: bool = True,
) -> None: ) -> None:
self.workflow_files = workflow_files self.workflow_files = workflow_files
@ -38,6 +40,9 @@ class _Backend:
self.remote_branches = remote_branches or {"feature"} self.remote_branches = remote_branches or {"feature"}
self.default_branch = default_branch self.default_branch = default_branch
self.push_url = push_url self.push_url = push_url
# Effective push URLs (`git remote get-url --push --all`); defaults to [push_url].
self.push_urls = push_urls if push_urls is not None else ([push_url] if push_url else [])
self.url_rewrites = url_rewrites
self.ls_tree_head_ok = ls_tree_head_ok self.ls_tree_head_ok = ls_tree_head_ok
self.commands: list[str] = [] self.commands: list[str] = []
self.head = "a" * 40 self.head = "a" * 40
@ -76,8 +81,12 @@ class _Backend:
self.commands.append(command) self.commands.append(command)
if "rev-parse --show-toplevel" in command: if "rev-parse --show-toplevel" in command:
return _Response("/repo\n") return _Response("/repo\n")
if "config --get-regexp" in command:
return _Response(f"{self.url_rewrites}\n") if self.url_rewrites else _Response("", 1)
if "remote get-url --push" in command: if "remote get-url --push" in command:
return _Response("", 1) if not self.push_url else _Response(f"{self.push_url}\n") if not self.push_urls:
return _Response("", 1)
return _Response("".join(f"{url}\n" for url in self.push_urls))
if "ls-tree -r" in command: if "ls-tree -r" in command:
if "FETCH_HEAD" in command: if "FETCH_HEAD" in command:
return _Response(self._base_tree()) return _Response(self._base_tree())
@ -711,14 +720,21 @@ def test_parse_git_push_guards_wrapped_and_optioned_forms() -> None:
assert isinstance(parsed, guard.ParsedGitPush), command assert isinstance(parsed, guard.ParsedGitPush), command
assert parsed.remote_ref == "feature" assert parsed.remote_ref == "feature"
# A push carrying unsupported global options cannot be normalized, so it is blocked # A push carrying unsupported global options, an unknown wrapper, or a value-taking
# (fail closed) rather than run unguarded. # wrapper option cannot be normalized, so it is blocked (fail closed) rather than run
# unguarded. None of these may return None.
for command in ( for command in (
"git -c protocol.version=2 push origin feature", "git -c protocol.version=2 push origin feature",
"git --git-dir=.git push origin feature", "git --git-dir=.git push origin feature",
"git --no-pager push origin feature", "git --no-pager push origin feature",
"nice -n 10 git push origin feature",
"sudo -u ci git push origin feature",
"timeout 5 git push origin feature",
"ionice -c 2 git push origin feature",
): ):
assert isinstance(guard._parse_git_push(command), guard._BlockedGitPush), command result = guard._parse_git_push(command)
assert result is not None, command
assert isinstance(result, guard._BlockedGitPush), command
# A non-push git command is still ignored. # A non-push git command is still ignored.
assert guard._parse_git_push("/usr/bin/git status") is None assert guard._parse_git_push("/usr/bin/git status") is None
@ -775,6 +791,104 @@ async def test_base_fetched_from_push_url_not_origin_remote(
assert not any(" fetch origin " in cmd for cmd in backend.commands) assert not any(" fetch origin " in cmd for cmd in backend.commands)
async def test_multiple_push_urls_fail_closed(monkeypatch: pytest.MonkeyPatch) -> None:
# `git push` sends to ALL configured push URLs, but `get-url --push` shows only the
# first. If the head workflow tree matches the base fetched from the first (benign)
# URL, the guard must still require approval rather than skip, because a second push
# URL could land the change on the real repo. Head == base here, but multiple push
# URLs force fail-closed (approval required).
backend = _Backend(
tree_entries={".github/workflows/ci.yml": "same-sha"},
base_tree_entries={".github/workflows/ci.yml": "same-sha"},
push_urls=[
"https://github.com/attacker/local.git",
"https://github.com/langchain-ai/open-swe.git",
],
)
guard.SANDBOX_BACKENDS["thread-1"] = backend
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
return False
async def fake_rejected(thread_id: str, fingerprint: str) -> bool:
return False
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
return None
async def fake_pending(thread_id: str, **kwargs: Any) -> tuple[dict[str, Any], bool]:
return {"fingerprint": kwargs["fingerprint"], "status": "pending", "notified": False}, True
async def fake_post(*args: Any, **kwargs: Any) -> tuple[str, None]:
return "1700000000.000300", None
async def fake_notified(*args: Any, **kwargs: Any) -> None:
return None
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
monkeypatch.setattr(guard, "workflow_push_rejected", fake_rejected)
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
monkeypatch.setattr(guard, "ensure_workflow_push_pending", fake_pending)
monkeypatch.setattr(guard, "post_slack_thread_reply_with_ts", fake_post)
monkeypatch.setattr(guard, "mark_workflow_push_notified", fake_notified)
async def handler(_request: Any) -> ToolMessage:
raise AssertionError("push must not run without approval when push URLs are ambiguous")
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
payload = json.loads(str(result.content))
assert payload["workflow_approval_status"] == "approval_required"
async def test_url_rewrite_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
# An insteadOf/pushInsteadOf rewrite means the inspected push URL may not be the real
# destination, so the guard must require approval (never auto-skip).
backend = _Backend(
tree_entries={".github/workflows/ci.yml": "same-sha"},
base_tree_entries={".github/workflows/ci.yml": "same-sha"},
url_rewrites="url.https://evil.example/.insteadof https://github.com/",
)
guard.SANDBOX_BACKENDS["thread-1"] = backend
async def fake_approved(thread_id: str, fingerprint: str) -> bool:
return False
async def fake_rejected(thread_id: str, fingerprint: str) -> bool:
return False
async def fake_find_approval(*args: Any, **kwargs: Any) -> dict[str, Any] | None:
return None
async def fake_pending(thread_id: str, **kwargs: Any) -> tuple[dict[str, Any], bool]:
return {"fingerprint": kwargs["fingerprint"], "status": "pending", "notified": False}, True
async def fake_post(*args: Any, **kwargs: Any) -> tuple[str, None]:
return "1700000000.000300", None
async def fake_notified(*args: Any, **kwargs: Any) -> None:
return None
monkeypatch.setattr(guard, "workflow_push_approved", fake_approved)
monkeypatch.setattr(guard, "workflow_push_rejected", fake_rejected)
monkeypatch.setattr(guard, "find_workflow_push_approval", fake_find_approval)
monkeypatch.setattr(guard, "ensure_workflow_push_pending", fake_pending)
monkeypatch.setattr(guard, "post_slack_thread_reply_with_ts", fake_post)
monkeypatch.setattr(guard, "mark_workflow_push_notified", fake_notified)
async def handler(_request: Any) -> ToolMessage:
raise AssertionError("push must not run without approval when a URL rewrite exists")
result = await guard.WorkflowPushGuardMiddleware().awrap_tool_call(_Request(), handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
payload = json.loads(str(result.content))
assert payload["workflow_approval_status"] == "approval_required"
async def test_unreadable_head_tree_blocks(monkeypatch: pytest.MonkeyPatch) -> None: async def test_unreadable_head_tree_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
# If `ls-tree` on the head cannot be read, the guard must fail closed (block), not # If `ls-tree` on the head cannot be read, the guard must fail closed (block), not
# skip the guard and run the original push. # skip the guard and run the original push.