From 8638e423fe850644b6ca122b9fb4ee63559a49e1 Mon Sep 17 00:00:00 2001 From: Yichao Liang Date: Sat, 10 Oct 2026 18:52:42 -0400 Subject: [PATCH] Keep the sandbox guard working after the agent changes directory The PreToolUse guard ran as "python3 .claude/validate_sandbox.py", and a hook command runs in the session's current directory. Domino seed 0 of the from-assets round fixes_r5 ran "cd reference/base_sim" early in level 1; from then on the hook could not be found, every Read, Write, Edit, Glob, Grep and Bash call failed, the Bash call that would have cd'ed back included, and the agent worked through run_python alone for the rest of the run. The guard also took the current directory as the sandbox root, so from a subdirectory it would have denied even "cd ..". The settings now name the guard by absolute path, the guard takes the sandbox from its own location, and relative paths resolve against the working directory the hook input names. --- predicators/agent_sdk/sandbox_setup.py | 47 ++++++++++++++++------- tests/agent_sdk/test_sandbox_guard.py | 52 +++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/predicators/agent_sdk/sandbox_setup.py b/predicators/agent_sdk/sandbox_setup.py index fef782e7b9..aa887a41f8 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 a8974ec75d..9648904ee2 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'}"})