Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions predicators/agent_sdk/sandbox_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import logging
import os
import pickle
import shlex
import shutil
import subprocess
from dataclasses import dataclass
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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).
Expand Down
52 changes: 46 additions & 6 deletions tests/agent_sdk/test_sandbox_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'}"})
Loading