blob: cb864a7ece90f839bf3ab9350e1b8466831d9ddf [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Engine + bundled-guard tests. The relocated skill-owned guards (mention,
mark-ready, security-language) are tested in test_skill_guards.py through the
same discovery path a real adopter uses."""
import json
import pytest
import agent_guard
@pytest.fixture(autouse=True)
def _clear_env(monkeypatch):
for name in (
"MAGPIE_GUARD_OFF",
"MAGPIE_GUARD_DIRS",
"MAGPIE_ALLOW_COAUTHOR",
"MAGPIE_ALLOW_EMPTY_PUSH",
"MAGPIE_ALLOW_NO_VERIFY",
"MAGPIE_ALLOW_MENTIONS",
"MAGPIE_ALLOW_MARK_READY",
"MAGPIE_ALLOW_SECURITY_LANG",
):
monkeypatch.delenv(name, raising=False)
def fake_run(handler):
"""Install a stub for agent_guard._run that dispatches on the argv."""
def _stub(args, cwd=None):
return handler(args)
return _stub
# --------------------------------------------------------------------------- #
# find_mentions / strip_code (shared helper, stays in the engine)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"text, expected",
[
("hello @alice and @bob-1", ["alice", "bob-1"]),
("email announce@apache.org is not a mention", []),
("team ping @apache/airflow-committers", ["apache/airflow-committers"]),
("code span `@alice` does not notify", []),
("fenced\n```\n@alice\n```\nblock", []),
("user@host.com and plain text", []),
("`backtick login` mention of @carol", ["carol"]),
],
)
def test_find_mentions(text, expected):
assert agent_guard.find_mentions(text) == expected
# --------------------------------------------------------------------------- #
# commit-trailer guard (bundled)
# --------------------------------------------------------------------------- #
def test_commit_coauthor_denied():
reason = dispatch('git commit -m "fix\n\nCo-Authored-By: Someone <x@y.z>"')
assert reason and "Co-Authored-By" in reason
def test_commit_coauthor_case_insensitive():
assert dispatch('git commit -m "x\n\nco-authored-by: a"') is not None
def test_commit_generated_by_allowed():
assert dispatch('git commit -m "fix\n\nGenerated-by: Claude Code"') is None
def test_commit_coauthor_override():
assert dispatch('MAGPIE_ALLOW_COAUTHOR=1 git commit -m "x\nCo-Authored-By: a"') is None
# --------------------------------------------------------------------------- #
# empty-rebase guard (bundled)
# --------------------------------------------------------------------------- #
def _push_handler(count):
def handler(args):
if args[:3] == ["git", "rev-parse", "--abbrev-ref"]:
return "origin/main"
if args[:2] == ["git", "merge-base"]:
return "base1234"
if args[:3] == ["git", "rev-list", "--count"]:
return count
if args[:3] == ["git", "rev-parse", "--verify"]:
return "sha"
return None
return handler
def test_empty_rebase_denied(monkeypatch):
monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("0")))
reason = dispatch("git push --force-with-lease origin mybranch:mybranch")
assert reason and "0 commits" in reason
def test_nonempty_force_push_allowed(monkeypatch):
monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("3")))
assert dispatch("git push --force-with-lease origin mybranch") is None
def test_non_force_push_allowed(monkeypatch):
def boom(args):
raise AssertionError("non-force push is not guarded")
monkeypatch.setattr(agent_guard, "_run", fake_run(boom))
assert dispatch("git push origin mybranch") is None
def test_empty_rebase_failopen_no_base(monkeypatch):
monkeypatch.setattr(agent_guard, "_run", fake_run(lambda a: None))
assert dispatch("git push --force origin mybranch") is None
def test_empty_rebase_override(monkeypatch):
monkeypatch.setattr(agent_guard, "_run", fake_run(_push_handler("0")))
assert dispatch("MAGPIE_ALLOW_EMPTY_PUSH=1 git push --force origin b:b") is None
# --------------------------------------------------------------------------- #
# bundled example contributed guard (guards.d/no_verify_commit.py)
# --------------------------------------------------------------------------- #
def test_bundled_no_verify_guard_discovered():
reason = dispatch('git commit -m "x" --no-verify')
assert reason and "no-verify" in reason
def test_no_verify_override():
assert dispatch('MAGPIE_ALLOW_NO_VERIFY=1 git commit -n -m "x"') is None
def test_plain_commit_not_blocked_by_no_verify_guard():
assert dispatch('git commit -m "ordinary commit"') is None
# --------------------------------------------------------------------------- #
# contributed-guard discovery
# --------------------------------------------------------------------------- #
def test_contributed_guard_from_env_dir(monkeypatch, tmp_path):
gdir = tmp_path / "guards.d"
gdir.mkdir()
(gdir / "block_merge_admin.py").write_text(
'TRIGGERS = ["gh"]\n'
"def guard(ctx):\n"
" sub = ctx.gh_subcommand()\n"
" if sub == ('pr', 'merge') and any(t in ('--admin',) for t in ctx.argv):\n"
" if ctx.override('MAGPIE_ALLOW_ADMIN_MERGE'):\n"
" return None\n"
" return 'contributed[admin-merge]: refusing gh pr merge --admin'\n"
" return None\n",
encoding="utf-8",
)
monkeypatch.setenv("MAGPIE_GUARD_DIRS", str(gdir))
assert dispatch("gh pr merge 5 --admin") is not None
assert dispatch("MAGPIE_ALLOW_ADMIN_MERGE=1 gh pr merge 5 --admin") is None
assert dispatch("gh pr view 5 --json title") is None
def test_broken_contributed_guard_fails_open(monkeypatch, tmp_path):
gdir = tmp_path / "guards.d"
gdir.mkdir()
(gdir / "broken.py").write_text("this is not valid python !!!", encoding="utf-8")
monkeypatch.setenv("MAGPIE_GUARD_DIRS", str(gdir))
assert dispatch("gh pr view 5") is None
# --------------------------------------------------------------------------- #
# dispatch / fast path / compound commands
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"command",
[
"ls -la",
"git status",
"git log --oneline -5",
"grep -r foo .",
"gh pr view 5 --json title",
"",
],
)
def test_fast_path_allows(command):
assert dispatch(command) is None
def test_compound_command_guarded():
# The commit-trailer guard (bundled) fires on the second segment.
assert dispatch('cd /tmp && git commit -m "x\nCo-Authored-By: a"') is not None
def test_malformed_command_allows():
assert dispatch('gh pr comment 5 --body "oops') is None
def test_global_off(monkeypatch):
assert dispatch('MAGPIE_GUARD_OFF=1 git commit -m "x\nCo-Authored-By: a"') is None
# --------------------------------------------------------------------------- #
# main() stdin contract
# --------------------------------------------------------------------------- #
def test_main_emits_deny(monkeypatch, capsys):
event = {
"tool_name": "Bash",
"tool_input": {"command": 'git commit -m "x\nCo-Authored-By: a"'},
}
monkeypatch.setattr("sys.stdin", _Stdin(json.dumps(event)))
rc = agent_guard.main()
payload = json.loads(capsys.readouterr().out)
assert rc == 0
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_main_allows_non_bash(monkeypatch, capsys):
event = {"tool_name": "Read", "tool_input": {"file_path": "x"}}
monkeypatch.setattr("sys.stdin", _Stdin(json.dumps(event)))
assert agent_guard.main() == 0
assert capsys.readouterr().out == ""
def test_main_allows_malformed_stdin(monkeypatch, capsys):
monkeypatch.setattr("sys.stdin", _Stdin("not json"))
assert agent_guard.main() == 0
assert capsys.readouterr().out == ""
# Convenience wrapper so each test reads cleanly.
def dispatch(command):
return agent_guard.dispatch(command, cwd=None)
class _Stdin:
def __init__(self, text):
self._text = text
def read(self):
return self._text
# Regression: a path-qualified command head (e.g. the documented
# ``--exec /usr/bin/git`` wrapper install) must be guarded identically to the
# bare name. Segment normalises argv[0] to its basename, so the guard cannot be
# bypassed by invoking the real binary by absolute or relative path.
def test_abs_path_git_commit_coauthor_denied():
assert dispatch('/usr/bin/git commit -m "x\n\nCo-Authored-By: a <x@y.z>"') is not None
def test_relative_path_git_commit_coauthor_denied():
assert dispatch('./git commit -m "x\n\nCo-Authored-By: a <x@y.z>"') is not None
def test_abs_path_plain_commit_still_allowed():
assert dispatch('/usr/bin/git commit -m "ordinary commit"') is None