diff --git a/predicators/agent_sdk/sandbox_setup.py b/predicators/agent_sdk/sandbox_setup.py index fef782e7b..aa887a41f 100644 --- a/predicators/agent_sdk/sandbox_setup.py +++ b/predicators/agent_sdk/sandbox_setup.py @@ -9,6 +9,7 @@ import logging import os import pickle +import shlex import shutil import subprocess from dataclasses import dataclass @@ -93,7 +94,13 @@ def _mount_spellings(path): if other != holder[0] and (src, fs) == holder[1:]) -sandbox = os.path.realpath(os.getcwd()) +# The sandbox is the directory this script guards: the one holding its +# .claude/, wherever the session has since cd'ed. Relative paths resolve +# against the session's working directory, which the hook input names. +_here = os.path.dirname(os.path.realpath(__file__)) +sandbox = (os.path.dirname(_here) + if os.path.basename(_here) == ".claude" else _here) +session_cwd = os.path.realpath(data.get("cwd") or sandbox) SANDBOX_SPELLINGS = [sandbox] + _mount_spellings(sandbox) try: _SANDBOX_STAT = os.stat(sandbox) @@ -125,7 +132,7 @@ def _inside_by_inode(path): def within(path): resolved = os.path.realpath( - path if os.path.isabs(path) else os.path.join(sandbox, path)) + path if os.path.isabs(path) else os.path.join(session_cwd, path)) return (any(resolved == s or resolved.startswith(s + os.sep) for s in SANDBOX_SPELLINGS) or _inside_by_inode(resolved)) @@ -493,18 +500,29 @@ def write_pyguard(sandbox_dir: str, return guard -SANDBOX_SETTINGS: Dict[str, Any] = { - "hooks": { - "PreToolUse": [{ - "matcher": - "Read|Write|Edit|Glob|Grep|Bash", - "hooks": [{ - "type": "command", - "command": "python3 .claude/validate_sandbox.py", - }], - }] +def sandbox_settings(sandbox_dir: str) -> Dict[str, Any]: + """The sandbox's ``.claude/settings.json``: the PreToolUse guard. + + The guard runs by absolute path: a hook command runs in the + session's current directory, so a relative one stops resolving once + the agent cd's into a subdirectory, and every guarded tool, the Bash + call that would cd back included, then fails for the rest of the + session. + """ + script = os.path.join(os.path.realpath(sandbox_dir), ".claude", + "validate_sandbox.py") + return { + "hooks": { + "PreToolUse": [{ + "matcher": + "Read|Write|Edit|Glob|Grep|Bash", + "hooks": [{ + "type": "command", + "command": "python3 " + shlex.quote(script), + }], + }] + } } -} def find_repo_root() -> Path: @@ -648,7 +666,8 @@ def setup_sandbox_directory( claude_dir = sandbox / ".claude" claude_dir.mkdir(exist_ok=True) (claude_dir / "settings.json").write_text( - json.dumps(SANDBOX_SETTINGS, indent=2) + "\n", encoding="utf-8") + json.dumps(sandbox_settings(str(sandbox)), indent=2) + "\n", + encoding="utf-8") # 4. validate_sandbox.py hook script, and the python-level guard # (loaded through the session env, see pyguard_env). diff --git a/tests/agent_sdk/test_sandbox_guard.py b/tests/agent_sdk/test_sandbox_guard.py index a8974ec75..9648904ee 100644 --- a/tests/agent_sdk/test_sandbox_guard.py +++ b/tests/agent_sdk/test_sandbox_guard.py @@ -13,7 +13,8 @@ import subprocess import sys -from predicators.agent_sdk.sandbox_setup import VALIDATE_SANDBOX_SCRIPT +from predicators.agent_sdk.sandbox_setup import VALIDATE_SANDBOX_SCRIPT, \ + sandbox_settings from predicators.agent_sdk.tools.results import _make_spilling_text_result from predicators.agent_sdk.tools.sandbox_guard import \ _screen_text_for_sandbox_escape @@ -224,13 +225,14 @@ def test_hook_reads_other_spellings_from_the_mount_table(tmp_path) -> None: f"MOUNTS_FILE = {str(mounts)!r}")) # Run the script's definitions with the request stubbed out (the # tool dispatch after the "# File-path tools" marker is dropped). - probe = ("import json, sys; sys.argv = ['x']; " + probe = ("import json, os, sys; sys.argv = ['x']; " + "g = {'__file__': os.path.abspath('validate_sandbox.py')}; " "exec(open('validate_sandbox.py').read().replace(" "'data = json.load(sys.stdin)', 'data = {}').split(" - "'\\n# File-path tools')[0]); " - "print(json.dumps({'spellings': SANDBOX_SPELLINGS, " - f"'inside': within({str(mnt_b / 'sb' / 'a.txt')!r}), " - f"'outside': within({str(mnt_b / 'secret.txt')!r})}}))") + "'\\n# File-path tools')[0], g); " + "print(json.dumps({'spellings': g['SANDBOX_SPELLINGS'], " + f"'inside': g['within']({str(mnt_b / 'sb' / 'a.txt')!r}), " + f"'outside': g['within']({str(mnt_b / 'secret.txt')!r})}}))") out = subprocess.run([sys.executable, "-c", probe], cwd=str(sandbox), capture_output=True, @@ -275,3 +277,41 @@ def test_spilling_noop_without_sandbox() -> None: text = _make_spilling_text_result(None) big = "z" * 50000 assert text(big) == {"content": [{"type": "text", "text": big}]} + + +def test_hook_survives_a_cd_into_a_subdirectory(tmp_path) -> None: + """The settings name the guard by absolute path and the guard keeps the + sandbox as its root, so a session that cd'ed into a subdirectory (a from- + assets agent ran ``cd reference/base_sim``) can still use its tools and cd + back, and still cannot leave.""" + sandbox = tmp_path / "sandbox" + (sandbox / ".claude").mkdir(parents=True) + (sandbox / ".claude" / + "validate_sandbox.py").write_text(VALIDATE_SANDBOX_SCRIPT) + subdir = sandbox / "reference" / "base_sim" + subdir.mkdir(parents=True) + (tmp_path / "outside.txt").write_text("x") + command = sandbox_settings( + str(sandbox))["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + + def denied(tool_name, tool_input) -> bool: + payload = json.dumps({ + "tool_name": tool_name, + "tool_input": tool_input, + "cwd": str(subdir), + }) + # A hook command runs through the shell in the session's directory. + out = subprocess.run(command, + shell=True, + cwd=str(subdir), + input=payload, + capture_output=True, + text=True, + check=True) + return '"deny"' in out.stdout + + assert not denied("Bash", {"command": "cd ../.. && ls"}) + assert not denied("Read", {"file_path": "../../simulator.py"}) + assert not denied("Read", {"file_path": str(sandbox / "simulator.py")}) + assert denied("Read", {"file_path": "../../../outside.txt"}) + assert denied("Bash", {"command": f"cat {tmp_path / 'outside.txt'}"})