Files
ai-agent/sidecar/test_claude_args.py
Gabriel Vidal 1ef6364067 feat(sidecar): per-run scope — dontAsk, confined edits, a guest's prompt
/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>
2026-10-11 15:27:43 +02:00

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)