Skip to content
Merged
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
28 changes: 22 additions & 6 deletions plugins/magpie-setup/skills/setup/setup_preflight/isolated.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,32 @@ def current(root: Path) -> str | None:
return shipped()


def _sandbox_enabled(paths: list[Path]) -> bool:
def _sandbox_settings(paths: list[Path]) -> list[bool]:
"""Every explicit `sandbox.enabled` in `paths`, in the order given."""
settings = []
for path in paths:
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
sandbox = loaded.get("sandbox") if isinstance(loaded, dict) else None
if isinstance(sandbox, dict) and sandbox.get("enabled") is True:
return True
return False
enabled = sandbox.get("enabled") if isinstance(sandbox, dict) else None
if isinstance(enabled, bool):
settings.append(enabled)
return settings


def _project_settings(root: Path) -> list[Path]:
return [root / ".claude" / name for name in ("settings.local.json", "settings.json")]


def sandbox_enabled_in_project(root: Path) -> bool:
return _sandbox_enabled([root / ".claude" / name for name in ("settings.json", "settings.local.json")])
"""Whether any project settings file enables the sandbox.

This asks whether the project uses the setup at all, so a `/sandbox`
toggle-off in `settings.local.json` does not hide a shared `true`.
"""
return True in _sandbox_settings(_project_settings(root))


def configured(root: Path, home: Path | None = None) -> bool:
Expand All @@ -175,12 +187,16 @@ def configured(root: Path, home: Path | None = None) -> bool:
that cannot be read counts as not configured: inside the sandbox the
user's settings are read-denied, so callers that run there see `False` and
must treat it as "not known to be configured", never as proof of absence.

The most specific explicit setting wins, as it does for Claude Code and
`sandbox-status-line.sh`: `/sandbox` writes `false` to the project's
`settings.local.json`, and that switches off a sandbox a wider scope enables.
"""
block = _load_stamp(root).get(BLOCK)
if isinstance(block, dict) and block:
return block.get("enabled") is not False
user = (home if home is not None else Path.home()) / ".claude"
return sandbox_enabled_in_project(root) or _sandbox_enabled([user / "settings.json"])
return next(iter(_sandbox_settings([*_project_settings(root), user / "settings.json"])), False)


#: Shown to the user once per session, at session start, while the isolated
Expand Down
36 changes: 36 additions & 0 deletions tools/setup-preflight/tests/test_isolated.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ def test_a_sandboxed_project_never_recorded_is_asked_to_run_the_update(project:
assert finding.facts["recorded"] is None


def test_a_sandbox_toggled_off_locally_still_gets_the_update_findings(project: Path) -> None:
framework_tree(project)
enable_sandbox(project)
(project / ".claude" / "settings.local.json").write_text('{"sandbox": {"enabled": false}}')
assert codes(isolated_setup_findings(project, today=TODAY)) == ["isolated-setup-changed"]


def test_an_upgrade_that_changes_setup_files_proposes_the_update(project: Path) -> None:
framework_tree(project)
before = isolated.current(project)
Expand Down Expand Up @@ -241,6 +248,35 @@ def test_a_sandbox_in_the_project_settings_is_configured(project: Path) -> None:
assert isolated.configured(project) is True


@pytest.mark.parametrize(
("local", "shared", "user", "expected"),
[
(False, None, True, False), # /sandbox switched off, whole-user install on
(None, False, True, False),
(False, True, None, False),
(True, False, None, True),
(None, None, True, True), # the project says nothing, so the user file decides
],
)
def test_the_most_specific_sandbox_setting_wins(
project: Path,
isolated_home: Path,
local: bool | None,
shared: bool | None,
user: bool | None,
expected: bool,
) -> None:
(project / ".claude").mkdir()
(isolated_home / ".claude").mkdir()
for path, enabled in (
(project / ".claude" / "settings.local.json", local),
(project / ".claude" / "settings.json", shared),
(isolated_home / ".claude" / "settings.json", user),
):
path.write_text(json.dumps({} if enabled is None else {"sandbox": {"enabled": enabled}}))
assert isolated.configured(project) is expected


def test_unreadable_user_settings_are_not_configured(project: Path, isolated_home: Path) -> None:
(isolated_home / ".claude").mkdir()
(isolated_home / ".claude" / "settings.json").write_text("{not json")
Expand Down
Loading