This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new b333922 feat(agent-isolation): add Python packaging and pytest test
harness (#339)
b333922 is described below
commit b333922af22be3b2aac06081808932f4c80728aa
Author: Justin Mclean <[email protected]>
AuthorDate: Wed May 27 23:43:55 2026 +1000
feat(agent-isolation): add Python packaging and pytest test harness (#339)
* 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)
* add to test matrix in CI
---
.github/workflows/tests.yml | 2 +
tools/agent-isolation/pyproject.toml | 43 ++++
.../src/agent_isolation/__init__.py | 20 ++
tools/agent-isolation/tests/__init__.py | 16 ++
tools/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 +++++++
8 files changed, 837 insertions(+)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 663169c..8908be9 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
diff --git a/tools/agent-isolation/pyproject.toml
b/tools/agent-isolation/pyproject.toml
new file mode 100644
index 0000000..c3f338c
--- /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 0000000..ba93f99
--- /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 0000000..13a8339
--- /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 0000000..08ba379
--- /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 0000000..9e78c13
--- /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 0000000..9e25c39
--- /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 0000000..165a1c1
--- /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" },
+]