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" },
+]


Reply via email to