/spawn, /resume and /fork take an optional scope: the run then launches on --permission-mode dontAsk (allow rules do nothing under bypass) with --permission-prompts none, Edit allowed only under scope.writeDirs, the working tools allowed outright, scope.denyRead refused, the scope's system prompt appended and remote control off. scope.sandbox adds Claude Code's bubblewrap sandbox for Bash (needs bwrap + socat). Verified end to end with a haiku run through this sidecar: a Write inside the project went through, one outside was denied, Bash ran, a read of ~/.ssh was denied, and the model knew whose session it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.2 KiB
Python
103 lines
4.2 KiB
Python
"""Every claude launch path builds on _claude_args: the --settings payload
|
|
(stop guard hook + inbox opt-in) and the headless flags can't be missed by
|
|
one of spawn / resume / fork — the spawn path once was."""
|
|
|
|
import json
|
|
import re
|
|
|
|
import claude_accounts
|
|
import fork
|
|
import launch
|
|
import routes_runs
|
|
from config import CLAUDE_BIN, DISALLOWED_TOOLS, PERMISSION_MODE, STOP_GUARD_TIMEOUT_S
|
|
from launch import _claude_args
|
|
|
|
SID = "11111111-2222-3333-4444-555555555555"
|
|
|
|
|
|
def _settings(args):
|
|
assert args.count("--settings") == 1
|
|
return json.loads(args[args.index("--settings") + 1])
|
|
|
|
|
|
def test_spawn_and_resume_shapes_share_the_flags():
|
|
spawn = _claude_args("hi", "--session-id", SID)
|
|
resume = _claude_args("hi", "--resume", SID)
|
|
assert spawn[:5] == [CLAUDE_BIN, "-p", "hi", "--session-id", SID]
|
|
assert resume[:5] == [CLAUDE_BIN, "-p", "hi", "--resume", SID]
|
|
for a in (spawn, resume):
|
|
assert a[a.index("--output-format") + 1] == "json"
|
|
assert a[a.index("--permission-mode") + 1] == PERMISSION_MODE
|
|
if DISALLOWED_TOOLS:
|
|
assert " ".join(DISALLOWED_TOOLS) in a
|
|
s = _settings(a)
|
|
assert s["crossSessionInbound"] == "accept"
|
|
|
|
|
|
def test_settings_carry_the_stop_guard_hook():
|
|
s = _settings(_claude_args("hi", "--session-id", SID))
|
|
hooks = s["hooks"]["Stop"][0]["hooks"]
|
|
assert len(hooks) == 1
|
|
cmd = hooks[0]["command"]
|
|
assert cmd.endswith("stop_guard.py") and "python" in cmd
|
|
assert hooks[0]["timeout"] == STOP_GUARD_TIMEOUT_S
|
|
assert hooks[0]["type"] == "command"
|
|
|
|
|
|
def test_stop_guard_can_be_switched_off(monkeypatch):
|
|
# _claude_settings reads the flag from launch's own globals (it is bound
|
|
# there at import), so that is the module to patch — not config.
|
|
monkeypatch.setattr(launch, "STOP_GUARD_ENABLED", False)
|
|
s = _settings(_claude_args("hi", "--session-id", SID))
|
|
assert "hooks" not in s and s["crossSessionInbound"] == "accept"
|
|
|
|
|
|
def test_every_launch_site_uses_the_builder():
|
|
# The builder lives in launch.py; the three launch sites are spawn and
|
|
# resume (routes_runs.py) and fork (fork.py).
|
|
src = "".join(open(m.__file__, encoding="utf-8").read()
|
|
for m in (launch, routes_runs, fork))
|
|
# the only literal `CLAUDE_BIN, "-p"` is the builder's own
|
|
assert len(re.findall(r'CLAUDE_BIN, "-p"', src)) == 1
|
|
assert src.count("_claude_args(prompt, ") == 3 # spawn, resume, fork
|
|
|
|
|
|
def test_run_env_exports_the_wait_ceiling(monkeypatch):
|
|
monkeypatch.setattr(claude_accounts, "BG_WAIT_CEILING_MS", "7200000")
|
|
env = claude_accounts._run_env(SID, None)
|
|
assert env["CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS"] == "7200000"
|
|
monkeypatch.setattr(claude_accounts, "BG_WAIT_CEILING_MS", "")
|
|
monkeypatch.delenv("CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS", raising=False)
|
|
assert "CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS" not in claude_accounts._run_env(SID, None)
|
|
|
|
|
|
def test_run_env_never_carries_the_sidecar_token(monkeypatch):
|
|
monkeypatch.setenv("SIDECAR_TOKEN", "secret-bearer")
|
|
for account in (None, "personal"):
|
|
assert "SIDECAR_TOKEN" not in claude_accounts._run_env(SID, account)
|
|
|
|
|
|
def test_a_scope_swaps_bypass_for_dontask_and_confines_edits():
|
|
scope = {"writeDirs": ["~/projects/film", "/tmp"], "denyRead": ["~/.ssh/**"],
|
|
"systemPrompt": "guest rules", "remoteControl": False}
|
|
args = _claude_args("hi", "--session-id", SID, scope)
|
|
assert args[args.index("--permission-mode") + 1] == "dontAsk"
|
|
assert args[args.index("--permission-prompts") + 1] == "none"
|
|
assert args[args.index("--append-system-prompt") + 1] == "guest rules"
|
|
perms = _settings(args)["permissions"]
|
|
home = __import__("os").path.expanduser("~")
|
|
assert f"Edit(/{home}/projects/film/**)" in perms["allow"]
|
|
assert "Edit(//tmp/**)" in perms["allow"] and "Bash" in perms["allow"]
|
|
assert perms["deny"] == [f"Read(/{home}/.ssh/**)"]
|
|
# the stop guard + inbox opt-in survive the merge
|
|
assert _settings(args)["crossSessionInbound"] == "accept"
|
|
assert launch._remote_control("personal", scope) == []
|
|
|
|
|
|
def test_bad_scope_is_refused():
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
for bad in ({"writeDirs": "/"}, {"nope": 1}, {"denyRead": [""]}):
|
|
with pytest.raises(HTTPException):
|
|
launch.valid_scope(bad)
|