From c836bc176b1bd52c4ec4a412eee190eebcd777a6 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Wed, 27 May 2026 16:19:10 +1000 Subject: [PATCH 1/2] feat(agent-isolation): add Python packaging and pytest test harness Convert tools/agent-isolation/ to a uv Python project by adding pyproject.toml (hatchling build, pytest dev dependency) and a test suite under tests/ that exercises the three core shell scripts: - test_claude_iso.py: verifies env-var stripping (AWS_*, GH_TOKEN, ANTHROPIC_API_KEY, etc. are absent after exec env -i), passthrough of whitelisted vars (HOME, PATH, USER, SHELL, LANG), CLAUDE_ISO_ALLOW explicit injection, and the missing-claude-exits-127 path. - test_sandbox_add_project_root.py: verifies file creation, allowRead / allowWrite content, idempotency, merge with existing content, dry-run (no-write, preview output), non-git-dir graceful exit, and missing-jq graceful exit. - test_sandbox_bypass_warn.py: verifies exit-0 for normal payloads, exit-1 + banner for dangerouslyDisableSandbox:true (with command and description shown), and the grep pattern robustness (whitespace variants, false value, unrelated boolean field). Generated-by: Claude (Opus 4.7) --- tools/agent-isolation/pyproject.toml | 43 +++ .../src/agent_isolation/__init__.py | 20 ++ tools/agent-isolation/tests/__init__.py | 16 ++ .../agent-isolation/tests/test_claude_iso.py | 271 ++++++++++++++++++ .../tests/test_sandbox_add_project_root.py | 268 +++++++++++++++++ .../tests/test_sandbox_bypass_warn.py | 134 +++++++++ tools/agent-isolation/uv.lock | 83 ++++++ 7 files changed, 835 insertions(+) create mode 100644 tools/agent-isolation/pyproject.toml create mode 100644 tools/agent-isolation/src/agent_isolation/__init__.py create mode 100644 tools/agent-isolation/tests/__init__.py create mode 100644 tools/agent-isolation/tests/test_claude_iso.py create mode 100644 tools/agent-isolation/tests/test_sandbox_add_project_root.py create mode 100644 tools/agent-isolation/tests/test_sandbox_bypass_warn.py create mode 100644 tools/agent-isolation/uv.lock diff --git a/tools/agent-isolation/pyproject.toml b/tools/agent-isolation/pyproject.toml new file mode 100644 index 00000000..c3f338c3 --- /dev/null +++ b/tools/agent-isolation/pyproject.toml @@ -0,0 +1,43 @@ +# 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. + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "agent-isolation" +version = "0.1.0" +description = "Test harness for the agent-isolation shell scripts (clean-env wrapper, sandbox profiles)." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# All logic lives in shell scripts; this package ships only tests. +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/agent_isolation"] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] diff --git a/tools/agent-isolation/src/agent_isolation/__init__.py b/tools/agent-isolation/src/agent_isolation/__init__.py new file mode 100644 index 00000000..ba93f994 --- /dev/null +++ b/tools/agent-isolation/src/agent_isolation/__init__.py @@ -0,0 +1,20 @@ +# 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. + +# agent-isolation is a shell-script tool; this package exists solely +# to satisfy the uv/hatchling project layout requirement so that +# ``uv run --project tools/agent-isolation --group dev pytest`` works. diff --git a/tools/agent-isolation/tests/__init__.py b/tools/agent-isolation/tests/__init__.py new file mode 100644 index 00000000..13a83393 --- /dev/null +++ b/tools/agent-isolation/tests/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tools/agent-isolation/tests/test_claude_iso.py b/tools/agent-isolation/tests/test_claude_iso.py new file mode 100644 index 00000000..08ba3798 --- /dev/null +++ b/tools/agent-isolation/tests/test_claude_iso.py @@ -0,0 +1,271 @@ +# 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. + +"""Tests for claude-iso.sh — the clean-environment wrapper. + +Strategy: put a fake ``claude`` binary (``printenv``) on $PATH and run +claude-iso.sh against it. Verify which variables survive the +``env -i`` filter and which are stripped. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "claude-iso.sh" + +# Use the absolute path so tests that restrict PATH still launch bash correctly. +BASH = shutil.which("bash") or "/bin/bash" + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _make_fake_claude(tmp_path: Path) -> Path: + """Return a directory containing a 'claude' binary that prints its env.""" + fake = tmp_path / "claude" + fake.write_text("#!/bin/sh\nprintenv\n") + fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return tmp_path # the directory to prepend to PATH + + +def _run(tmp_path: Path, extra_env: dict | None = None, extra_args: list | None = None) -> subprocess.CompletedProcess: + """Run claude-iso.sh with a fake claude in a non-git temp directory.""" + bin_dir = _make_fake_claude(tmp_path) + env: dict[str, str] = { + "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", + "HOME": "/tmp/testhome", + "USER": "testuser", + "SHELL": "/bin/sh", + "TERM": "xterm", + "LANG": "en_US.UTF-8", + } + if extra_env: + env.update(extra_env) + return subprocess.run( + [BASH, str(SCRIPT)] + (extra_args or []), + env=env, + # Use tmp_path as cwd — outside any git repo, so the script skips + # the sandbox auto-allow injection (keeps test output predictable). + cwd=str(tmp_path), + capture_output=True, + text=True, + ) + + +def _parse_env(stdout: str) -> dict[str, str]: + """Parse printenv output (KEY=value lines) into a dict.""" + result: dict[str, str] = {} + for line in stdout.splitlines(): + if "=" in line: + key, _, val = line.partition("=") + result[key] = val + return result + + +# --------------------------------------------------------------------------- +# credential stripping +# --------------------------------------------------------------------------- + + +class TestCredentialStripping: + def test_aws_access_key_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE"}) + assert res.returncode == 0 + assert "AWS_ACCESS_KEY_ID" not in _parse_env(res.stdout) + + def test_aws_secret_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}) + assert res.returncode == 0 + assert "AWS_SECRET_ACCESS_KEY" not in _parse_env(res.stdout) + + def test_aws_session_token_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"AWS_SESSION_TOKEN": "FQoGZXIvYXdzEJr..."}) + assert res.returncode == 0 + assert "AWS_SESSION_TOKEN" not in _parse_env(res.stdout) + + def test_gh_token_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"GH_TOKEN": "ghp_s3cr3t"}) + assert res.returncode == 0 + assert "GH_TOKEN" not in _parse_env(res.stdout) + + def test_anthropic_api_key_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"ANTHROPIC_API_KEY": "sk-ant-api03-secret"}) + assert res.returncode == 0 + assert "ANTHROPIC_API_KEY" not in _parse_env(res.stdout) + + def test_openai_api_key_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"OPENAI_API_KEY": "sk-proj-secret"}) + assert res.returncode == 0 + assert "OPENAI_API_KEY" not in _parse_env(res.stdout) + + def test_database_url_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"DATABASE_URL": "postgres://user:pass@host/db"}) + assert res.returncode == 0 + assert "DATABASE_URL" not in _parse_env(res.stdout) + + def test_npm_token_stripped(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"NPM_TOKEN": "npm_abc123"}) + assert res.returncode == 0 + assert "NPM_TOKEN" not in _parse_env(res.stdout) + + def test_multiple_credentials_all_stripped(self, tmp_path: Path) -> None: + res = _run( + tmp_path, + extra_env={ + "AWS_ACCESS_KEY_ID": "AKIA...", + "GH_TOKEN": "ghp_...", + "ANTHROPIC_API_KEY": "sk-ant-...", + }, + ) + assert res.returncode == 0 + env = _parse_env(res.stdout) + assert "AWS_ACCESS_KEY_ID" not in env + assert "GH_TOKEN" not in env + assert "ANTHROPIC_API_KEY" not in env + + +# --------------------------------------------------------------------------- +# passthrough of whitelisted variables +# --------------------------------------------------------------------------- + + +class TestPassthrough: + def test_home_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"HOME": "/home/testuser"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("HOME") == "/home/testuser" + + def test_path_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path) + assert res.returncode == 0 + assert "PATH" in _parse_env(res.stdout) + + def test_user_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"USER": "myuser"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("USER") == "myuser" + + def test_shell_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"SHELL": "/bin/zsh"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("SHELL") == "/bin/zsh" + + def test_lang_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"LANG": "en_GB.UTF-8"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("LANG") == "en_GB.UTF-8" + + def test_term_passes_through(self, tmp_path: Path) -> None: + res = _run(tmp_path, extra_env={"TERM": "screen-256color"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("TERM") == "screen-256color" + + +# --------------------------------------------------------------------------- +# CLAUDE_ISO_ALLOW explicit injection +# --------------------------------------------------------------------------- + + +class TestClaudeIsoAllow: + def test_allow_single_var(self, tmp_path: Path) -> None: + res = _run( + tmp_path, + extra_env={ + "CLAUDE_ISO_ALLOW": "GH_TOKEN", + "GH_TOKEN": "ghp_explicit", + }, + ) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("GH_TOKEN") == "ghp_explicit" + + def test_allow_multiple_vars(self, tmp_path: Path) -> None: + res = _run( + tmp_path, + extra_env={ + "CLAUDE_ISO_ALLOW": "GH_TOKEN AWS_PROFILE", + "GH_TOKEN": "ghp_explicit", + "AWS_PROFILE": "read-only", + }, + ) + assert res.returncode == 0 + env = _parse_env(res.stdout) + assert env.get("GH_TOKEN") == "ghp_explicit" + assert env.get("AWS_PROFILE") == "read-only" + + def test_allow_does_not_pass_unlisted_credentials(self, tmp_path: Path) -> None: + """CLAUDE_ISO_ALLOW for GH_TOKEN doesn't accidentally pass other secrets.""" + res = _run( + tmp_path, + extra_env={ + "CLAUDE_ISO_ALLOW": "GH_TOKEN", + "GH_TOKEN": "ghp_explicit", + "ANTHROPIC_API_KEY": "sk-ant-secret", + }, + ) + assert res.returncode == 0 + env = _parse_env(res.stdout) + assert env.get("GH_TOKEN") == "ghp_explicit" + assert "ANTHROPIC_API_KEY" not in env + + +# --------------------------------------------------------------------------- +# error / edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_missing_claude_exits_127(self, tmp_path: Path) -> None: + """When 'claude' is not on PATH the wrapper exits 127.""" + result = subprocess.run( + [BASH, str(SCRIPT)], + env={ + "PATH": str(tmp_path), # tmp_path has no claude binary + "HOME": "/tmp", + "USER": "testuser", + "SHELL": "/bin/sh", + }, + cwd=str(tmp_path), + capture_output=True, + text=True, + ) + assert result.returncode == 127 + assert "not found on PATH" in result.stderr + + def test_isolation_banner_on_stderr(self, tmp_path: Path) -> None: + res = _run(tmp_path) + assert res.returncode == 0 + assert "[claude-iso] running in isolated env" in res.stderr + + def test_no_credential_leakage_into_env(self, tmp_path: Path) -> None: + """Sanity check: the complete env output contains no credential-shaped values.""" + creds = { + "AWS_ACCESS_KEY_ID": "AKIA_LEAK_TEST", + "GH_TOKEN": "ghp_LEAK_TEST", + "ANTHROPIC_API_KEY": "sk-ant-LEAK_TEST", + } + res = _run(tmp_path, extra_env=creds) + assert res.returncode == 0 + # None of the secret values should appear in the captured output + for val in creds.values(): + assert val not in res.stdout diff --git a/tools/agent-isolation/tests/test_sandbox_add_project_root.py b/tools/agent-isolation/tests/test_sandbox_add_project_root.py new file mode 100644 index 00000000..9e78c135 --- /dev/null +++ b/tools/agent-isolation/tests/test_sandbox_add_project_root.py @@ -0,0 +1,268 @@ +# 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. + +"""Tests for sandbox-add-project-root.sh. + +The script adds the current git worktree path to the project-local +.claude/settings.local.json allowlists. Tests use a temporary git repo +with settings.local.json gitignored so the safety check passes. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent.parent / "sandbox-add-project-root.sh" + +# Use the absolute path so tests that restrict PATH still launch bash correctly. +BASH = shutil.which("bash") or "/bin/bash" + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _make_git_repo(tmp_path: Path) -> Path: + """Return an initialised git repo with settings.local.json gitignored.""" + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init", str(repo)], check=True, capture_output=True) + # Write .gitignore so the safety check in the script passes. + (repo / ".gitignore").write_text("/.claude/settings.local.json\n") + return repo + + +def _run(cwd: Path, args: list | None = None, extra_env: dict | None = None) -> subprocess.CompletedProcess: + env = os.environ.copy() + if extra_env: + env.update(extra_env) + return subprocess.run( + [BASH, str(SCRIPT)] + (args or []), + cwd=str(cwd), + env=env, + capture_output=True, + text=True, + ) + + +def _load(settings_file: Path) -> dict: + return json.loads(settings_file.read_text()) + + +# --------------------------------------------------------------------------- +# basic creation +# --------------------------------------------------------------------------- + + +class TestBasicCreation: + def test_creates_settings_file(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + result = _run(repo) + assert result.returncode == 0 + assert (repo / ".claude" / "settings.local.json").exists() + + def test_allow_read_contains_repo_root(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + data = _load(repo / ".claude" / "settings.local.json") + assert str(repo) in data["sandbox"]["filesystem"]["allowRead"] + + def test_allow_write_contains_repo_root(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + data = _load(repo / ".claude" / "settings.local.json") + assert str(repo) in data["sandbox"]["filesystem"]["allowWrite"] + + def test_output_is_valid_json(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + content = (repo / ".claude" / "settings.local.json").read_text() + parsed = json.loads(content) # raises if invalid + assert isinstance(parsed, dict) + + +# --------------------------------------------------------------------------- +# idempotence +# --------------------------------------------------------------------------- + + +class TestIdempotence: + def test_second_run_no_duplicates_in_read(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + _run(repo) + data = _load(repo / ".claude" / "settings.local.json") + read_paths = data["sandbox"]["filesystem"]["allowRead"] + assert read_paths.count(str(repo)) == 1 + + def test_second_run_no_duplicates_in_write(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + _run(repo) + data = _load(repo / ".claude" / "settings.local.json") + write_paths = data["sandbox"]["filesystem"]["allowWrite"] + assert write_paths.count(str(repo)) == 1 + + def test_second_run_exit_zero(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _run(repo) + result = _run(repo) + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# merge with existing content +# --------------------------------------------------------------------------- + + +class TestMerge: + def test_preserves_existing_read_paths(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + claude_dir = repo / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + existing = {"sandbox": {"filesystem": {"allowRead": ["/some/other/path"]}}} + (claude_dir / "settings.local.json").write_text(json.dumps(existing)) + _run(repo) + data = _load(claude_dir / "settings.local.json") + read_paths = data["sandbox"]["filesystem"]["allowRead"] + assert "/some/other/path" in read_paths + + def test_adds_repo_root_to_existing_content(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + claude_dir = repo / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + existing = {"sandbox": {"filesystem": {"allowRead": ["/other"]}}, "someKey": "someValue"} + (claude_dir / "settings.local.json").write_text(json.dumps(existing)) + _run(repo) + data = _load(claude_dir / "settings.local.json") + assert str(repo) in data["sandbox"]["filesystem"]["allowRead"] + assert data.get("someKey") == "someValue" # non-sandbox keys untouched + + +# --------------------------------------------------------------------------- +# dry-run +# --------------------------------------------------------------------------- + + +def _seed_settings(repo: Path, content: dict | None = None) -> Path: + """Pre-create .claude/settings.local.json so dry-run tests can run the full preview path.""" + claude_dir = repo / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + settings = claude_dir / "settings.local.json" + settings.write_text(json.dumps(content or {"sandbox": {"filesystem": {"allowRead": []}}})) + return settings + + +class TestDryRun: + def test_dry_run_does_not_modify_existing_file(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + settings = _seed_settings(repo) + original = settings.read_text() + _run(repo, args=["--dry-run"]) + assert settings.read_text() == original + + def test_dry_run_exits_zero(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _seed_settings(repo) + result = _run(repo, args=["--dry-run"]) + assert result.returncode == 0 + + def test_dry_run_prints_would_update(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + _seed_settings(repo) + result = _run(repo, args=["--dry-run"]) + assert "would update" in result.stderr or "would create" in result.stderr + + +# --------------------------------------------------------------------------- +# non-git directory +# --------------------------------------------------------------------------- + + +class TestNonGitDirectory: + def test_not_in_git_repo_exits_zero(self, tmp_path: Path) -> None: + """Script must exit 0 (not error) when run outside a git repo.""" + non_git = tmp_path / "not_a_repo" + non_git.mkdir() + result = _run(non_git) + assert result.returncode == 0 + + def test_not_in_git_repo_prints_warning(self, tmp_path: Path) -> None: + non_git = tmp_path / "not_a_repo" + non_git.mkdir() + result = _run(non_git) + assert "not inside a git working tree" in result.stderr + + def test_not_in_git_repo_creates_no_file(self, tmp_path: Path) -> None: + non_git = tmp_path / "not_a_repo" + non_git.mkdir() + _run(non_git) + assert not (non_git / ".claude" / "settings.local.json").exists() + + +# --------------------------------------------------------------------------- +# missing jq +# --------------------------------------------------------------------------- + + +def _fake_bin_without_jq(tmp_path: Path) -> Path: + """Build a bin directory that has git but no jq.""" + git_path = shutil.which("git") + if not git_path: + pytest.skip("git not available") + fake_bin = tmp_path / "fake_bin" + fake_bin.mkdir(exist_ok=True) + (fake_bin / "git").symlink_to(git_path) + return fake_bin + + +class TestMissingJq: + def test_no_jq_exits_zero(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + fake_bin = _fake_bin_without_jq(tmp_path) + result = _run(repo, extra_env={"PATH": str(fake_bin)}) + assert result.returncode == 0 + + def test_no_jq_prints_warning(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + fake_bin = _fake_bin_without_jq(tmp_path) + result = _run(repo, extra_env={"PATH": str(fake_bin)}) + assert "jq not on PATH" in result.stderr + + def test_no_jq_creates_no_file(self, tmp_path: Path) -> None: + repo = _make_git_repo(tmp_path) + fake_bin = _fake_bin_without_jq(tmp_path) + _run(repo, extra_env={"PATH": str(fake_bin)}) + assert not (repo / ".claude" / "settings.local.json").exists() + + +# --------------------------------------------------------------------------- +# unknown option +# --------------------------------------------------------------------------- + + +class TestUnknownOption: + def test_unknown_option_exits_nonzero(self, tmp_path: Path) -> None: + result = _run(tmp_path, args=["--bogus-option-xyz"]) + assert result.returncode != 0 diff --git a/tools/agent-isolation/tests/test_sandbox_bypass_warn.py b/tools/agent-isolation/tests/test_sandbox_bypass_warn.py new file mode 100644 index 00000000..9e25c396 --- /dev/null +++ b/tools/agent-isolation/tests/test_sandbox_bypass_warn.py @@ -0,0 +1,134 @@ +# 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. + +"""Tests for sandbox-bypass-warn.sh. + +The hook reads a JSON tool-use payload on stdin and exits 1 (banner on +stderr) when ``dangerouslyDisableSandbox: true`` is present, or 0 +(silent) otherwise. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "sandbox-bypass-warn.sh" + + +def _run(payload: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["bash", str(SCRIPT)], + input=payload, + capture_output=True, + text=True, + ) + + +# --------------------------------------------------------------------------- +# normal (no bypass) +# --------------------------------------------------------------------------- + + +class TestNormalInput: + def test_empty_object_exits_zero(self) -> None: + result = _run("{}") + assert result.returncode == 0 + + def test_normal_bash_call_exits_zero(self) -> None: + payload = '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' + result = _run(payload) + assert result.returncode == 0 + assert result.stderr == "" + + def test_bypass_false_exits_zero(self) -> None: + payload = '{"tool_name":"Bash","tool_input":{"dangerouslyDisableSandbox":false,"command":"ls"}}' + result = _run(payload) + assert result.returncode == 0 + + def test_read_tool_exits_zero(self) -> None: + payload = '{"tool_name":"Read","tool_input":{"file_path":"/etc/hosts"}}' + result = _run(payload) + assert result.returncode == 0 + + +# --------------------------------------------------------------------------- +# bypass detected → exit 1 + banner +# --------------------------------------------------------------------------- + + +class TestBypassDetected: + def test_exits_one_on_bypass_true(self) -> None: + payload = '{"tool_input":{"dangerouslyDisableSandbox":true,"command":"rm -rf /"}}' + result = _run(payload) + assert result.returncode == 1 + + def test_banner_on_stderr(self) -> None: + payload = '{"tool_input":{"dangerouslyDisableSandbox":true,"command":"ls","description":"test reason"}}' + result = _run(payload) + assert "SANDBOX BYPASS REQUESTED" in result.stderr + + def test_command_shown_in_banner(self) -> None: + payload = '{"tool_input":{"dangerouslyDisableSandbox":true,"command":"cat /etc/shadow","description":"read shadow"}}' + result = _run(payload) + assert "cat /etc/shadow" in result.stderr + + def test_description_shown_in_banner(self) -> None: + payload = '{"tool_input":{"dangerouslyDisableSandbox":true,"command":"ls","description":"my stated reason"}}' + result = _run(payload) + assert "my stated reason" in result.stderr + + def test_no_stdout_on_bypass(self) -> None: + """Warning must go to stderr, not stdout (callers read stdout).""" + payload = '{"tool_input":{"dangerouslyDisableSandbox":true,"command":"ls"}}' + result = _run(payload) + assert result.stdout == "" + + +# --------------------------------------------------------------------------- +# pattern robustness +# --------------------------------------------------------------------------- + + +class TestPatternRobustness: + def test_spaces_around_colon(self) -> None: + """grep pattern handles optional whitespace on both sides of ':'.""" + payload = '{"dangerouslyDisableSandbox" : true}' + result = _run(payload) + assert result.returncode == 1 + + def test_no_spaces_around_colon(self) -> None: + payload = '{"dangerouslyDisableSandbox":true}' + result = _run(payload) + assert result.returncode == 1 + + def test_tab_before_colon(self) -> None: + payload = '{"dangerouslyDisableSandbox"\t:\ttrue}' + result = _run(payload) + assert result.returncode == 1 + + def test_does_not_match_dangerously_disable_sandbox_false(self) -> None: + """Must not trigger on the string 'false'.""" + payload = '{"dangerouslyDisableSandbox":false}' + result = _run(payload) + assert result.returncode == 0 + + def test_does_not_match_random_true_field(self) -> None: + """A different boolean field must not trigger the hook.""" + payload = '{"someOtherField":true,"command":"ls"}' + result = _run(payload) + assert result.returncode == 0 diff --git a/tools/agent-isolation/uv.lock b/tools/agent-isolation/uv.lock new file mode 100644 index 00000000..165a1c1f --- /dev/null +++ b/tools/agent-isolation/uv.lock @@ -0,0 +1,83 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + +[[package]] +name = "agent-isolation" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] From 1ea0fef3101a89f437531a9e4b50f141efcd7833 Mon Sep 17 00:00:00 2001 From: Justin McLean Date: Wed, 27 May 2026 22:20:04 +1000 Subject: [PATCH 2/2] add to test matrix in CI --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 663169c3..8908be90 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,6 +56,8 @@ jobs: path: tools/vulnogram/oauth-api - name: sandbox-lint path: tools/sandbox-lint + - name: agent-isolation + path: tools/agent-isolation # GitHub Actions log viewer renders ANSI colour escapes; without # an attached TTY most tools default to monochrome. `FORCE_COLOR` # is the de-facto signal honoured by uv, ruff, mypy, and pytest's