feat(policies): add tag push protection to GitHub policy (#3620)
* feat(policies): add tag push protection to GitHub policy Add a `deny_tag_push` parameter (default `True`) to the GitHub policy that blocks pushing tags to remotes via `git push --tags`, `git push --follow-tags`, or explicit `refs/tags/` refspecs. Tags are immutable references that downstream CI/CD and release tooling depend on; an agent pushing a tag can trigger releases, deployments, or break semver expectations. Tag refspecs (`refs/tags/v1.0`) are also filtered out of the branch set so they don't pollute `write_branches` checks. The check fires before repo/branch gating so even a tag push to an undeterminable remote alias is denied rather than surfaced as ASK. Set `deny_tag_push=False` to let tag pushes through normal write gating. Signed-off-by: Yuan Tang <terrytangyuan@gmail.com> * style(policies): join tag-push deny message onto one line for ruff format Signed-off-by: Yuan Tang <terrytangyuan@gmail.com> --------- Signed-off-by: Yuan Tang <terrytangyuan@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -673,6 +673,8 @@ class _ShellOp:
|
||||
``"git push"`` or ``"gh pr create"``.
|
||||
:param destructive: Whether the operation is an irreversible delete, gated
|
||||
separately by ``allow_destructive``.
|
||||
:param tag_push: Whether this ``git push`` includes tags (``--tags``,
|
||||
``--follow-tags``, or ``refs/tags/`` refspecs).
|
||||
:param force_push: Whether this ``git push`` uses a force flag
|
||||
(``--force``, ``-f``, ``--force-with-lease``, ``--force-if-includes``)
|
||||
or a ``+refspec`` force prefix.
|
||||
@@ -684,6 +686,7 @@ class _ShellOp:
|
||||
branch_targeted: bool
|
||||
detail: str
|
||||
destructive: bool = False
|
||||
tag_push: bool = False
|
||||
force_push: bool = False
|
||||
|
||||
|
||||
@@ -749,8 +752,13 @@ def _classify_git(tokens: list[str]) -> _ShellOp | None:
|
||||
positionals = [t for t in args if not t.startswith("-")]
|
||||
repo = _repo_from_tokens(args)
|
||||
branches: set[str] = set()
|
||||
tag_push = any(t in ("--tags", "--follow-tags") for t in args)
|
||||
for refspec in positionals[1:]:
|
||||
dest = refspec.split(":", 1)[1] if ":" in refspec else refspec
|
||||
dest = dest.lstrip("+")
|
||||
if dest.startswith("refs/tags/"):
|
||||
tag_push = True
|
||||
continue
|
||||
branch = _normalize_branch(dest)
|
||||
if branch:
|
||||
branches.add(branch)
|
||||
@@ -775,6 +783,7 @@ def _classify_git(tokens: list[str]) -> _ShellOp | None:
|
||||
branch_targeted=True,
|
||||
detail="git push",
|
||||
destructive=is_destructive,
|
||||
tag_push=tag_push,
|
||||
force_push=is_force,
|
||||
)
|
||||
return None
|
||||
@@ -937,6 +946,7 @@ def github_policy(
|
||||
write_repos: list[str] | None = None,
|
||||
write_branches: list[str] | None = None,
|
||||
allow_destructive: bool = False,
|
||||
deny_tag_push: bool = True,
|
||||
deny_force_push: bool = True,
|
||||
mcp_tool_prefixes: list[str] | None = None,
|
||||
shell_tools: list[str] | None = None,
|
||||
@@ -958,6 +968,11 @@ def github_policy(
|
||||
:param allow_destructive: When ``False`` (default), irreversible destructive
|
||||
operations (deletes) are denied even on allowed repos. Set to ``True``
|
||||
to let destructive operations through normal write gating.
|
||||
:param deny_tag_push: When ``True`` (default), pushing tags to remotes via
|
||||
``git push --tags``, ``git push --follow-tags``, or explicit
|
||||
``refs/tags/`` refspecs is denied. Tags are immutable references that
|
||||
downstream CI/CD and release tooling depend on; an agent pushing a tag
|
||||
can trigger releases, deployments, or break semver expectations.
|
||||
:param deny_force_push: When ``True`` (default), ``git push`` with force
|
||||
flags (``--force``, ``-f``, ``--force-with-lease``,
|
||||
``--force-if-includes``), bundled short flags containing ``f``
|
||||
@@ -1161,6 +1176,10 @@ def github_policy(
|
||||
f"{deny_reason} Destructive operation `{op.detail}` is blocked by "
|
||||
f"default. Set allow_destructive=true to permit deletes."
|
||||
)
|
||||
if deny_tag_push and op.tag_push:
|
||||
return _deny(
|
||||
f"{deny_reason} Pushing tags is blocked by policy (deny_tag_push is enabled)."
|
||||
)
|
||||
return _gate_write(
|
||||
{op.repo} if op.repo else set(),
|
||||
set(op.branches),
|
||||
@@ -1269,6 +1288,13 @@ POLICY_REGISTRY: list[dict[str, Any]] = [ # type: ignore[explicit-any]
|
||||
"When false (default), deletes are denied even on allowed repos.",
|
||||
"default": False,
|
||||
},
|
||||
"deny_tag_push": {
|
||||
"type": "boolean",
|
||||
"description": "Block pushing tags to remotes (--tags, --follow-tags, "
|
||||
"refs/tags/ refspecs). Tags are immutable references that downstream "
|
||||
"CI/CD depends on.",
|
||||
"default": True,
|
||||
},
|
||||
"deny_force_push": {
|
||||
"type": "boolean",
|
||||
"description": "Deny git push with force flags (--force, -f, "
|
||||
|
||||
@@ -904,7 +904,82 @@ def test_shell_gh_non_delete_write_unaffected() -> None:
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Layer 1 — force-push protection
|
||||
# Layer 1 — tag push protection
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_tag_push_with_tags_flag_denied() -> None:
|
||||
"""git push --tags is denied by default."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello --tags"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
assert "tag" in result.get("reason", "").lower()
|
||||
|
||||
|
||||
def test_tag_push_follow_tags_denied() -> None:
|
||||
"""git push --follow-tags is denied by default."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello --follow-tags main"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_tag_push_explicit_ref_denied() -> None:
|
||||
"""git push origin refs/tags/v1.0 is denied by default."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello refs/tags/v1.0"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_tag_push_full_refspec_denied() -> None:
|
||||
"""git push origin refs/tags/v1.0:refs/tags/v1.0 is denied by default."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello refs/tags/v1.0:refs/tags/v1.0"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_tag_push_force_prefixed_refspec_denied() -> None:
|
||||
"""``+refs/tags/v1.0`` (force-prefixed) is still detected as a tag push."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello +refs/tags/v1.0"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_tag_push_allowed_when_opt_out() -> None:
|
||||
"""deny_tag_push=False lets tag pushes through normal write gating."""
|
||||
policy = github_policy(write_repos=["octo/hello"], deny_tag_push=False)
|
||||
assert policy(_sh("git push https://github.com/octo/hello --tags")) is None
|
||||
|
||||
|
||||
def test_tag_push_alias_denied() -> None:
|
||||
"""Tag push to an alias is still DENY (not ASK) when deny_tag_push is on."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh("git push origin --tags"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_normal_branch_push_unaffected_by_tag_protection() -> None:
|
||||
"""A normal branch push is not blocked by tag push protection."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
assert policy(_sh("git push https://github.com/octo/hello main")) is None
|
||||
|
||||
|
||||
def test_tag_push_wrapped_in_bash_denied() -> None:
|
||||
"""bash -c wrapper does not bypass tag push detection."""
|
||||
policy = github_policy(write_repos=["octo/hello"])
|
||||
result = policy(_sh('bash -c "git push https://github.com/octo/hello --tags"'))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
|
||||
|
||||
def test_tag_refspec_not_added_to_branches() -> None:
|
||||
"""refs/tags/v1.0 refspec should not pollute the branch set."""
|
||||
policy = github_policy(write_repos=["octo/hello"], write_branches=["main"])
|
||||
result = policy(_sh("git push https://github.com/octo/hello main refs/tags/v1.0"))
|
||||
assert result is not None and result["result"] == "DENY"
|
||||
assert "tag" in result.get("reason", "").lower()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Layer 1b — force-push protection
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user