Files
ECC/tests/test_invariant_runner.py
T
Wen 15819040cf fix(skill-comply): stop setup commands from planting git hooks
Greptile found three ways a generated setup could still place an
executable hook in the sandbox repository, which a later accepted
`git commit` then runs on the host:

- git init --template=<dir> (any unvetted git option)
- option values attached to the flag: cp --target-directory=.git,
  cp -t.git, tar --directory=.git
- archive members: tar -xf / unzip of an archive holding
  .git/hooks/pre-commit

Fixes:
- allowlist options per git subcommand; --template, --separate-git-dir,
  -c, --exec-path and the rest are refused
- treat every suffix of an option argument as a possible attached value
  and apply the in-sandbox / no-.git path check to it
- before tar -x or unzip, open every existing file argument with
  tarfile/zipfile and refuse unknown formats, links, special files and
  members that are absolute, contain .., or have a .git component;
  refuse unzip wildcards and run setup with stdin=/dev/null
- run setup git commands with -c core.hooksPath=/dev/null as defense
  in depth

Each sequence has a real-execution test in tests/test_invariant_runner.py
that fails on the previous head and passes now.

Signed-off-by: Wen <jwcastillo@gmail.com>
2026-10-27 16:52:40 -03:00

140 lines
5.1 KiB
Python

import io
import os
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
_SKILL_COMPLY_ROOT = Path(__file__).resolve().parent.parent / "skills" / "skill-comply"
if str(_SKILL_COMPLY_ROOT) not in sys.path:
sys.path.insert(0, str(_SKILL_COMPLY_ROOT))
from scripts.runner import _setup_sandbox # noqa: E402
from scripts.scenario_generator import Scenario # noqa: E402
_GLOBAL_MARKER = "/tmp/runner_test_pwned_marker"
@pytest.fixture(autouse=True)
def _remove_marker():
if os.path.exists(_GLOBAL_MARKER):
os.remove(_GLOBAL_MARKER)
yield
if os.path.exists(_GLOBAL_MARKER):
os.remove(_GLOBAL_MARKER)
@pytest.mark.parametrize(
"setup_commands,test_id",
[
(
("python -c \"import os; os.system('touch /tmp/runner_test_pwned_marker')\"",),
"python_interpreter",
),
(
("../../../../../../bin/sh -c 'touch /tmp/runner_test_pwned_marker'",),
"path_traversal",
),
(
("bash -c 'touch /tmp/runner_test_pwned_marker'",),
"non_allowlisted_binary",
),
(
("git -c 'alias.pwn=!touch /tmp/runner_test_pwned_marker' pwn",),
"git_alias",
),
(
("tar --checkpoint=1 --checkpoint-action=exec='touch /tmp/runner_test_pwned_marker' -cf out.tar .",),
"tar_checkpoint_action",
),
(
("echo hello",),
"benign_echo",
),
],
ids=[
"python_interpreter", "path_traversal", "non_allowlisted_binary",
"git_alias", "tar_checkpoint_action", "benign_echo",
],
)
def test_setup_sandbox_blocks_dangerous_commands(setup_commands, test_id, tmp_path):
"""Invariant: _setup_sandbox must not execute disallowed commands."""
scenario = Scenario(
id=f"test-{test_id}",
level=1,
level_name="basic",
description="security test scenario",
prompt="",
setup_commands=setup_commands,
)
sandbox_dir = tmp_path / "sandbox"
_setup_sandbox(sandbox_dir, scenario)
assert not os.path.exists(_GLOBAL_MARKER), (
f"Arbitrary command execution detected for '{test_id}': "
f"marker file created at {_GLOBAL_MARKER}"
)
def _plant_registry_package(sandbox_dir):
"""Stand in for `npm install attacker-pkg`: a registry package that ships hook files."""
pkg = sandbox_dir / "node_modules" / "attacker-pkg"
hook = f"#!/bin/sh\ntouch {_GLOBAL_MARKER}\n"
(pkg / "hooks").mkdir(parents=True)
(pkg / "hooks" / "post-commit").write_text(hook)
(pkg / "hooks" / "post-commit").chmod(0o755)
info = tarfile.TarInfo("hooks/post-commit")
info.size, info.mode = len(hook), 0o755
with tarfile.open(pkg / "hooks.tar", "w") as archive:
archive.addfile(info, io.BytesIO(hook.encode()))
info.name = ".git/hooks/post-commit"
with tarfile.open(pkg / "payload.tar", "w") as archive:
archive.addfile(info, io.BytesIO(hook.encode()))
member = zipfile.ZipInfo(".git/hooks/post-commit")
member.external_attr = 0o100755 << 16
with zipfile.ZipFile(pkg / "payload.zip", "w") as archive:
archive.writestr(member, hook)
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell hooks")
@pytest.mark.parametrize(
"plant",
[
("mkdir templates", "cp -r node_modules/attacker-pkg/hooks templates/hooks", "git init --template=templates"),
("cp -r --target-directory=.git node_modules/attacker-pkg/hooks",),
("cp -r -t.git node_modules/attacker-pkg/hooks",),
("tar -xf node_modules/attacker-pkg/hooks.tar --directory=.git",),
("tar -xf node_modules/attacker-pkg/payload.tar",),
("unzip -o node_modules/attacker-pkg/payload.zip",),
],
ids=["git_template", "cp_target_directory", "cp_attached_t", "tar_directory", "tar_member", "unzip_member"],
)
def test_setup_cannot_plant_git_hooks(plant, tmp_path):
"""Invariant: setup must not leave a hook that a later accepted `git commit` runs on the host."""
real_run = subprocess.run
sandbox_dir = tmp_path / "sandbox"
def run(argv, **kwargs):
if argv[0] == "npm":
_plant_registry_package(sandbox_dir)
return subprocess.CompletedProcess(argv, 0)
return real_run(argv, **kwargs)
identity = ("git config user.name t", "git config user.email t@example.com", "git config commit.gpgsign false")
scenario = Scenario(
id="hook-plant", level=1, level_name="basic", description="security test scenario", prompt="",
setup_commands=("npm install attacker-pkg", *plant, *identity, "git commit --allow-empty -m setup"),
)
with patch("scripts.runner.subprocess.run", side_effect=run):
_setup_sandbox(sandbox_dir, scenario)
# The agent (or the user) commits later, without the runner's protections.
real_run(["git", "commit", "--allow-empty", "-m", "later"], cwd=sandbox_dir, capture_output=True)
assert not os.path.exists(_GLOBAL_MARKER), "a planted git hook ran on the host"
assert not (sandbox_dir / ".git" / "hooks" / "post-commit").exists()