Files
ai-agent/sidecar/fork.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

165 lines
7.1 KiB
Python

"""
``POST /fork`` — branch a new session off an existing one at a message
boundary, plus the transcript surgery it needs (``_fork_transcript``).
"""
import json
import pathlib
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from claude_accounts import _account_projects, _require_login, _valid_account
from config import PI_DEFAULT_MODEL, RUNNER_BIN
from launch import _claude_args, _launch, _remote_control, _run_cwd, valid_scope
from validate import _auth, _valid_harness, _valid_model, _valid_uuid
router = APIRouter()
class ForkBody(BaseModel):
sessionId: str # fresh uuid for the fork (backend-minted)
srcSessionId: str # the session to branch off
prompt: str # the edited message — the fork's next turn
cutLine: int # raw JSONL line index of the fork point
cutText: str | None = None # collapsed prefix of that message (canary)
cutUserOrd: int | None = None # its 0-based ordinal among user msgs (pi)
model: str | None = None
cwd: str | None = None # must match the source session's cwd
harness: str | None = None # "claude" (default) | "pi"
account: str | None = None # the source session's account
scope: dict | None = None # a scoped run (see routes_runs.SpawnBody)
def _collapse(s: str) -> str:
return " ".join((s or "").split())
def _record_texts(line: str) -> list[str]:
"""Every text payload in one transcript record (message content as a plain
string, or the text blocks of a content list) — what the fork-point canary
compares against."""
try:
o = json.loads(line)
except ValueError:
return []
c = (o.get("message") or {}).get("content")
if isinstance(c, str):
return [c]
if isinstance(c, list):
return [b.get("text", "") for b in c
if isinstance(b, dict) and b.get("type") == "text"]
return []
def _fork_transcript(src: pathlib.Path, dst: pathlib.Path, new_sid: str,
cut_line: int, cut_text: str | None) -> None:
"""Write the first ``cut_line`` lines of ``src`` as a new session file.
This is the same shape ``claude --resume --fork-session`` produces — the
identical records under a new ``sessionId`` (record uuids stay; they're
only unique within a file's parent chain) — just cut short at the fork
point instead of cloning the whole session. ``cut_text`` is the message
the viewer showed at that ordinal: if the record at ``cut_line`` doesn't
contain it, the archive the backend counted lines in has diverged from
the live file (it never should — the import is append-only) and we refuse
rather than fork at the wrong message."""
try:
lines = src.read_text(encoding="utf-8", errors="ignore") \
.splitlines(keepends=True)
except OSError as e:
raise HTTPException(502, f"cannot read source transcript: {e}")
if not 0 < cut_line < len(lines):
raise HTTPException(409, f"fork point out of range: line {cut_line} "
f"of {len(lines)}")
if cut_text:
want = _collapse(cut_text)
if not any(_collapse(t)[:len(want)] == want
for t in _record_texts(lines[cut_line])):
raise HTTPException(409, "fork point mismatch — the transcript "
"changed since the viewer loaded it; "
"reload the conversation and retry")
keep = lines[:cut_line]
# The records just before a user message are that turn's bookkeeping
# (queue-operation enqueue/dequeue carrying the message text, last-prompt).
# They belong to the message being edited away, so a cut at the user
# record would otherwise leak its text into the fork — trim them.
def _rec_type(ln: str) -> str | None:
try:
return json.loads(ln).get("type")
except ValueError:
return None
while keep and _rec_type(keep[-1]) in ("queue-operation", "last-prompt"):
keep.pop()
if not keep:
raise HTTPException(400, "nothing before the fork point")
out = []
for ln in keep:
s = ln.strip()
if s.startswith("{"):
try:
o = json.loads(s)
if "sessionId" in o:
o["sessionId"] = new_sid
out.append(json.dumps(o, ensure_ascii=False) + "\n")
continue
except ValueError:
pass
out.append(ln if ln.endswith("\n") else ln + "\n")
try:
dst.write_text("".join(out), encoding="utf-8")
except OSError as e:
raise HTTPException(502, f"cannot write forked transcript: {e}")
@router.post("/fork")
def fork(body: ForkBody, authorization: str | None = Header(default=None)) -> dict:
"""Branch a new session off an existing one at a message boundary.
Claude Code has no message-level fork (``--fork-session`` clones whole
sessions), so: copy the source session file truncated at the fork point
under a fresh session id (see ``_fork_transcript``), then ``claude -p
<prompt> --resume <newSid>`` — claude loads the truncated history and
appends the edited turn. The pi harness forks natively: pi sessions are
trees, so the runner branches pi's own session file (``--fork-from``) and
mirrors the truncated prefix itself."""
_auth(authorization)
prompt = (body.prompt or "").strip()
if not prompt:
raise HTTPException(400, "empty prompt")
new_sid = _valid_uuid(body.sessionId)
src_sid = _valid_uuid(body.srcSessionId)
harness = _valid_harness(body.harness)
model = _valid_model(body.model, harness)
cwd = _run_cwd(body.cwd)
if harness == "pi":
model = model or PI_DEFAULT_MODEL
args = [RUNNER_BIN, "-p", prompt, "--session-id", new_sid,
"--fork-from", src_sid, "--cut-line", str(body.cutLine),
"--cut-user-ord", str(body.cutUserOrd or 0), "--model", model]
if body.cutText:
args += ["--cut-text", body.cutText]
return _launch(args, new_sid, cwd, model=model)
# A fork stays on the source's account: the source transcript lives only
# in that account's projects/, and so must the fork for --resume to find it.
account = _valid_account(body.account)
_require_login(account)
projects = _account_projects(account)
src = next(projects.glob(f"*/{src_sid}.jsonl"), None)
if src is None:
raise HTTPException(404, f"source session {src_sid} not found under "
f"{projects}")
dst = src.parent / f"{new_sid}.jsonl"
_fork_transcript(src, dst, new_sid, body.cutLine, body.cutText)
scope = valid_scope(body.scope)
args = _claude_args(prompt, "--resume", new_sid, scope)
if model:
args += ["--model", model]
args += _remote_control(account, scope)
try:
return _launch(args, new_sid, cwd, model=model, account=account)
except HTTPException:
dst.unlink(missing_ok=True) # no headless truncated session left behind
raise