diff --git a/README.md b/README.md index 99e7280..e7b01af 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,16 @@ into the chosen root (project by default, `-g`/`--global` for user-wide, `remove` to uninstall), and skips helper skills marked `adapter_expose: false`. See `adapters/shared/README.md`. +### Dry-run sessions + +To let skills act for real against a repository — push a branch, create an +issue — without anything being published, run them in a dry-run session. +`adapters/shared/dry-run-session.sh` clones the repository into a directory of +its own and starts the agent inside a process sandbox that writes only to the +clone and reaches only the agent's API; its `check` subcommand attempts every +write path from inside the sandbox and proves on the current machine that none +gets out. It needs the Anthropic Sandbox Runtime. See `adapters/shared/README.md`. + ### Private journal `clock-in` and `clock-out` work across two layers: the project the agent is diff --git a/adapters/shared/README.md b/adapters/shared/README.md index 205d23e..d39d906 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -124,3 +124,153 @@ Because the CLI installs every discovered skill, this also vendors the `grilling helper (which the installer above filters out via `adapter_expose`) — so prefer `npx skills add --list` to preview, then pick exposed skills with `--skill`. + +## Dry-Run Session (`dry-run-session.sh`) + +Cross-engine helper that runs an agent session on a clone of a repository which +**cannot publish anything**, so skills that push branches or create issues can +act for real — in a demonstration, a workshop, or a first look at an unfamiliar +project. The guarantee comes from a process sandbox around the session, not from +an instruction to the agent. + +### Requirements + +- The [Anthropic Sandbox Runtime](https://github.com/anthropics/sandbox-runtime): + `npm install -g @anthropic-ai/sandbox-runtime`, which provides `srt`. Without + it, `start`, `login` and `check` refuse to run. +- Node.js, which writes the sandbox policy (it is already there once `srt` is). +- On macOS, `ripgrep`. On Linux, `bubblewrap`, `socat` and `ripgrep`; see the + runtime's documentation for distribution-specific notes. + +### Usage + +```bash +./dry-run-session.sh setup [target] # clone into a locked-down directory +./dry-run-session.sh login # log Claude Code in, once per clone +./dry-run-session.sh check # prove the boundary on this machine +./dry-run-session.sh start # run Claude Code inside the session +./dry-run-session.sh start -- # ... or any other command +``` + +`` is a local checkout or a clone URL; `` defaults to +`-dry-run` in the current directory. Set `ARCHITECTURE_KNOWLEDGE_TOOLKIT` +before `start` when the clone lives outside the toolkit's parent directory, so +the session inherits it. + +| Variable | Default | Purpose | +|---|---|---| +| `DRY_RUN_ALLOWED_DOMAINS` | `api.anthropic.com *.anthropic.com claude.ai claude.com *.claude.com` | Domains the session may reach, separated by spaces. Set it for an agent other than Claude Code. | +| `DRY_RUN_SESSION_SRT` | `srt` | The sandbox runtime to use. | + +### Behavior + +- **A process sandbox around the whole session.** `start` runs the command + through `srt` with a policy written for this session, outside every writable + path. The session writes only to the clone and to the temporary directories of + `srt` and Claude Code (`/tmp/claude`, `/tmp/claude-` and `/tmp/claude-*`). + It cannot read `~/.ssh`, the `gh` and `glab` configuration, `~/.netrc`, + `~/.git-credentials`, `~/.claude` or `~/.claude.json`, and it reaches only the + allowed domains. GitHub and GitLab are denied explicitly, and a denial wins over + an allowance, so they stay unreachable even with `DRY_RUN_ALLOWED_DOMAINS="*"`. +- **A socket directory of its own.** Claude Code listens for messages from other + local agent sessions in `$XDG_RUNTIME_DIR/cc-socks`, by default `/tmp/cc-socks` + — next to the sockets of sessions that do not run in a dry run. The runtime's + `allowUnixSockets` opens binding *and* connecting for a whole path, so opening + that directory would let the dry run message a session outside it. `start` + instead points `XDG_RUNTIME_DIR` at a short directory created for the session, + opens Unix sockets only there, removes it when the session ends, and does not + pass on `CLAUDE_CODE_MESSAGING_SOCKET` or `CLAUDE_CODE_MESSAGING_TOKEN`. +- **Terminal control only for an interactive session.** Claude Code has to put + its terminal into raw mode, or the prompt shows escape sequences and takes no + input. `start` from a terminal therefore runs with the runtime's `allowPty`; + `login`, `check` and a `start` without a terminal on standard input do not. +- **Claude Code state stays with the clone.** The session runs with + `CLAUDE_CONFIG_DIR` set to `.git/dry-run-session/claude` inside the clone. + Settings, hooks, history and `.claude.json` written during a dry run live there + and are discarded with the clone; no session outside the dry run reads them. + Claude Code ties its login to that directory, so log in once per clone with + `login`. The login is the only run allowed to bind a local port, which the + OAuth callback needs; its token lands in that directory as + `.credentials.json`. `login` then marks Claude Code's onboarding as complete + there, because `claude auth login` does not: the first interactive `start` + would otherwise run the onboarding, ask to log in again, and fail on the port + it may not bind. +- **Only dry-run clones.** `start`, `login` and `check` refuse a directory that + `setup` did not prepare, recognized by its disabled push URL and its hook, so a + session never runs in an original checkout. +- **The session cannot loosen its guards.** The sandbox keeps the clone's hook + directory, its `.git/config` and `.claude/settings.local.json` unwritable. +- **A clone of its own.** The original checkout is never touched. The clone's + push URL is unusable, and a `pre-push` hook installed through a clone-local + `core.hooksPath` rejects every push, including a push to an explicitly named + URL. +- **A session without credentials.** `start` removes `GH_TOKEN`, `GITHUB_TOKEN`, + `GH_ENTERPRISE_TOKEN`, `GITLAB_TOKEN`, `GLAB_TOKEN` and the SSH agent socket, + points `gh` and `glab` at empty configuration, sets `GIT_SSH_COMMAND=false`, + resets every git credential helper, and disables terminal prompts. +- **Deny rules for Claude Code**, written to `.claude/settings.local.json` when + that file does not exist yet. An existing file is reported and left untouched, + never merged. +- **`check` tests the sandbox, not the layers around it.** From inside the + session it pushes to `origin` and to a local repository, with and without the + hook, writes outside the clone, changes the clone's git configuration and hook, + pushes over HTTPS with the credential helpers restored and over SSH with SSH + restored, to GitHub and to GitLab, calls `gh` with its own configuration, reads + `~/.ssh`, writes to and reads `~/.claude`, and connects to a stand-in for another + agent session's message socket. Every target either does not + exist or is thrown away, so even a failing boundary publishes nothing. Each + probe must fail for the right reason: one that fails only because a remote + answered without the target or the key is reported as `OPEN`. `check` also + reports whether Claude Code is logged in for the clone. Use a clone only when + `check` ends with `RESULT: tight`. + +The clone guards, the credential removal and the deny rules are each something a +determined agent could undo from inside an unsandboxed session: `git push +--no-verify` skips the hook, `env -u` restores a credential helper, and the +original checkout is one `cd` away. They stay because they turn a mistake into a +clear message; the guarantee rests on the sandbox. + +### What it does not do + +- **It does not inspect what goes to an allowed domain.** The agent sends what + it reads to its own API, as in any session. That is not a publication to the + project, but anything added to `DRY_RUN_ALLOWED_DOMAINS` is reachable with + whatever the session can read. +- **The macOS login keychain stays readable**, because Claude Code keeps its + login there. A credential found in it cannot reach GitHub or GitLab, but it + could reach an allowed domain. +- **User-level Claude Code configuration does not apply.** Settings, skills, + agents and `CLAUDE.md` from `~/.claude` are not available inside a dry run; the + project's own configuration is. +- **The login token is a file in the clone.** A session can read it, although it + reaches only the allowed domains. End the login with + `start -- claude auth logout` before deleting a clone, and do not copy + a clone that is logged in. +- **The login run can bind local ports**, and on macOS that also lets it reach + services on the loopback interface. It runs only `claude auth login`; log in + right after `setup`, before a session has written to the clone. +- **Claude Code's temporary directory `/tmp/claude-`** is shared with the + user's other Claude Code sessions and stays writable. +- **Other terminals are not isolated.** The runtime grants `allowPty` for every + `/dev/ttys*`, not only the session's own. An interactive session can open the + user's other terminals for writing, and any session can open them for reading + — with or without `allowPty` — and so compete for what is typed there. + Injecting input into another terminal with `TIOCSTI` is refused. While a dry + run is open, keep no other terminal with a shell or an agent session outside + the dry run. +- **Claude Code's version lock under `~/.local/state/claude` stays closed.** + Opening it, or `~/.local/share/claude/versions`, would let a session change the + Claude Code that later sessions outside the dry run execute. Claude Code logs + the failed lock as non-fatal and runs. +- **GitHub and GitLab are not readable either.** `gh`, `glab` and the public APIs + are unreachable from inside the session. Put the text of an issue into the + prompt, or into a file before `start`. +- **It is verified on macOS only**: with version 0.0.76 of the runtime and + Claude Code 2.1.270, including a complete login, and with version 1.0.0 and + Claude Code 2.1.273, including an interactive session. The policy is written for + Linux as well, but the runtime supports path globs only on macOS and blocks Unix + sockets there altogether, so a session cannot listen for messages from other + agent sessions; run `check` before relying on it. Windows is not supported. +- A `check` result holds for the machine it ran on. +- Local commits and file changes inside the clone are not blocked. That is the + point: the clone can act freely and be discarded afterwards. diff --git a/adapters/shared/dry-run-session.sh b/adapters/shared/dry-run-session.sh new file mode 100755 index 0000000..bbc3db3 --- /dev/null +++ b/adapters/shared/dry-run-session.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# +# Run an agent session on a clone of a repository that cannot publish anything. +# +# Usage: +# ./dry-run-session.sh setup [target] # clone into a locked-down directory +# ./dry-run-session.sh check # prove the boundary on this machine +# ./dry-run-session.sh start [-- cmd ...] # run a command in the session (default: claude) +# ./dry-run-session.sh login # log Claude Code in, once per clone +# +# is a local checkout or a clone URL; defaults to +# -dry-run in the current directory. `start`, `login` and `check` need the +# Anthropic Sandbox Runtime (`srt`, npm package @anthropic-ai/sandbox-runtime). +# +# The boundary is a process sandbox around the whole session (srt). The session +# writes only to the clone and to temporary directories, cannot read SSH keys, +# gh, glab and git credential files or Claude Code's own state, and reaches only +# the agent's API. GitHub and GitLab stay denied even when more domains are +# allowed. It listens for messages from other local agent sessions only in a +# socket directory of its own, and cannot connect to theirs. Claude Code keeps +# the session's state inside the clone's git directory, so nothing a session +# leaves behind reaches a session outside the dry run. Log in once per clone +# with the login subcommand. Sessions run only in clones prepared by setup, +# never in an original checkout. +# +# Further layers, each of which a determined agent could undo on its own: +# - A clone of its own with an unusable push URL and a pre-push hook installed +# through a clone-local core.hooksPath. The sandbox keeps both unwritable. +# - A session environment without tokens, SSH agent, SSH or credential helpers. +# - Deny rules for Claude Code in .claude/settings.local.json. +# +# `check` attempts every write path from inside the session, undoes the further +# layers where a probe can, and aims only at targets that do not exist or are +# thrown away, so the sandbox itself is what gets tested and even a failing +# boundary publishes nothing. It requires the right reason for each failure. +set -euo pipefail + +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +PUSH_URL_DISABLED="PUSH-DISABLED-dry-run-session" +HOOK_MESSAGE="dry-run-session: pushing is disabled in this clone" +SETTINGS=".claude/settings.local.json" +CLAUDE_STATE=".git/dry-run-session/claude" +SRT="${DRY_RUN_SESSION_SRT:-srt}" +DEFAULT_DOMAINS="api.anthropic.com *.anthropic.com claude.ai claude.com *.claude.com" +PROBE_REPOSITORY="dry-run-session-probe/does-not-exist.git" + +usage() { + sed -n '3,13p' "$SELF" >&2 + exit 2 +} + +setup() { + local src="${1:-}" dst name + [ -n "$src" ] || usage + name="$(basename "${src%.git}")" + dst="${2:-$PWD/${name}-dry-run}" + if [ -e "$dst" ]; then + echo "Target already exists: $dst" >&2 + exit 1 + fi + + git clone --quiet "$src" "$dst" + cd "$dst" + + git remote set-url --push origin "$PUSH_URL_DISABLED" + mkdir -p .git/dry-run-hooks "$CLAUDE_STATE" + printf '#!/bin/sh\necho "%s" >&2\nexit 1\n' "$HOOK_MESSAGE" >.git/dry-run-hooks/pre-push + chmod +x .git/dry-run-hooks/pre-push + git config core.hooksPath .git/dry-run-hooks + + if [ -e "$SETTINGS" ]; then + echo " $SETTINGS already exists and was left untouched; add the deny rules by hand" >&2 + else + mkdir -p .claude + cat >"$SETTINGS" <<'JSON' +{ + "permissions": { + "deny": [ + "Bash(git push)", + "Bash(git push *)", + "Bash(git remote set-url *)", + "Bash(git config core.hooksPath *)", + "Bash(git config --unset core.hooksPath)", + "Bash(gh issue *)", + "Bash(gh pr *)", + "Bash(gh api *)", + "Bash(gh auth *)", + "Bash(glab *)" + ] + } +} +JSON + if ! git check-ignore -q "$SETTINGS"; then + echo " note: $SETTINGS is not ignored by git in this project" >&2 + fi + fi + + echo "set up: $dst" + echo " check: $SELF check \"$dst\"" + echo " login: $SELF login \"$dst\" (once per clone)" + echo " start: $SELF start \"$dst\"" +} + +require_sandbox() { + if ! command -v "$SRT" >/dev/null 2>&1; then + echo "dry-run-session: the sandbox runtime '$SRT' was not found; no session starts without it" >&2 + echo " install it with: npm install -g @anthropic-ai/sandbox-runtime" >&2 + exit 1 + fi + if ! command -v node >/dev/null 2>&1; then + echo "dry-run-session: node is required to write the sandbox policy" >&2 + exit 1 + fi +} + +# require_clone : refuse anything setup did not prepare, recognized by the +# disabled push URL and the hook, so no session runs in an original checkout. +require_clone() { + local dst="$1" + if [ ! -d "$dst" ] || + [ "$(git -C "$dst" config --get remote.origin.pushurl 2>/dev/null)" != "$PUSH_URL_DISABLED" ] || + [ "$(git -C "$dst" config --get core.hooksPath 2>/dev/null)" != ".git/dry-run-hooks" ] || + [ ! -x "$dst/.git/dry-run-hooks/pre-push" ]; then + echo "dry-run-session: $dst is not a dry-run clone; prepare one with: $SELF setup [target]" >&2 + exit 1 + fi +} + +# write_policy : the srt settings for one +# session. is "login" or empty, the session's own socket +# directory, 1 for an interactive session. The file +# lies outside every writable path, so the session cannot loosen its own policy. +write_policy() { + # The node program is single-quoted on purpose; it reads its input from the environment. + # shellcheck disable=SC2016 + DRY_RUN_CLONE="$1" DRY_RUN_POLICY="$2" DRY_RUN_MODE="${3:-}" DRY_RUN_UID="$(id -u)" \ + DRY_RUN_DOMAINS="${DRY_RUN_ALLOWED_DOMAINS:-$DEFAULT_DOMAINS}" DRY_RUN_SOCKETS="$4" DRY_RUN_PTY="${5:-}" \ + node -e ' +const fs = require("node:fs"); +const clone = process.env.DRY_RUN_CLONE; +const macOS = process.platform === "darwin"; +// srt assigns /tmp/claude. Claude Code keeps its session directories below +// /tmp/claude- and a working-directory file next to it as /tmp/claude-*; +// a glob covers only the latter, a plain path the whole tree. Only macOS +// supports globs. +const uid = process.env.DRY_RUN_UID; +const temporary = macOS + ? ["/tmp/claude", "/private/tmp/claude"].flatMap((dir) => [dir, `${dir}-${uid}`, `${dir}-*`]) + : ["/tmp/claude", `/tmp/claude-${uid}`]; +const sockets = process.env.DRY_RUN_SOCKETS; +const policy = { + // An interactive session has to put its terminal into raw mode; without this + // the ioctl is denied and the prompt takes no input. srt grants it for every + // /dev/ttys*, so only an interactive session gets it (macOS only). + allowPty: process.env.DRY_RUN_PTY === "1", + network: { + allowedDomains: process.env.DRY_RUN_DOMAINS.split(/\s+/).filter(Boolean), + // A denial wins over an allowance, so these hold whatever is allowed. + deniedDomains: [ + "github.com", "*.github.com", "githubusercontent.com", "*.githubusercontent.com", + "gitlab.com", "*.gitlab.com", + ], + // Only the login binds a local port, for the OAuth callback. + allowLocalBinding: process.env.DRY_RUN_MODE === "login", + // Claude Code listens for messages from other local sessions in + // $XDG_RUNTIME_DIR/cc-socks, by default /tmp/cc-socks, next to the sockets of + // sessions outside the dry run. An allowance there would let this session + // connect to them, so it gets a socket directory of its own, and only that + // one. Linux ignores the path and blocks Unix sockets altogether. + allowUnixSockets: [sockets], + }, + filesystem: { + // Credentials, and Claude Code state outside the dry run. + denyRead: [ + "~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials", + "~/.claude", "~/.claude.json", + ], + // The clone, which also holds the Claude Code state of this session, and the temporary directories. + allowWrite: [clone, ...temporary, sockets], + // The clone guards. + denyWrite: [`${clone}/.git/dry-run-hooks`, `${clone}/.claude/settings.local.json`], + }, +}; +fs.writeFileSync(process.env.DRY_RUN_POLICY, JSON.stringify(policy, null, 2) + "\n"); +' +} + +start() { + local dst="${1:-}" + [ -n "$dst" ] || usage + shift + if [ "${1:-}" = "--" ]; then shift; fi + if [ "$#" -eq 0 ]; then set -- claude; fi + session "$dst" "" "$@" +} + +login() { + local dst="${1:-}" state version + [ -n "$dst" ] || usage + require_clone "$dst" + state="$(cd "$dst" && pwd -P)/$CLAUDE_STATE/.claude.json" + session "$dst" login claude auth login + # claude auth login does not complete the onboarding. The first interactive + # start would then run it and ask to log in again, which needs the local port + # only this run may bind, and stop there. + [ -f "$state" ] || return 0 + version="$(claude --version 2>/dev/null | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+' || true)" + DRY_RUN_STATE="$state" DRY_RUN_VERSION="$version" node -e ' +const fs = require("node:fs"); +const file = process.env.DRY_RUN_STATE; +const state = JSON.parse(fs.readFileSync(file, "utf8")); +state.hasCompletedOnboarding = true; +if (process.env.DRY_RUN_VERSION) state.lastOnboardingVersion = process.env.DRY_RUN_VERSION; +fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n"); +' +} + +# session : run the command inside the sandbox. +session() { + local dst="$1" mode="$2" cfg clone sockets pty="" rc=0 + shift 2 + require_clone "$dst" + require_sandbox + clone="$(cd "$dst" && pwd -P)" + cfg="$(mktemp -d)" + # Short on purpose: Claude Code falls back to /tmp/cc-socks-, which the + # policy does not open, when a socket path exceeds 103 bytes. + sockets="$(cd "$(mktemp -d /tmp/dry-run-session.XXXXXX)" && pwd -P)" + # No exec, so both directories are gone when the session ends. Expanded now: + # the locals no longer exist when the trap runs. + # shellcheck disable=SC2064 + trap "rm -rf $(printf '%q %q' "$cfg" "$sockets")" EXIT + if [ -z "$mode" ] && [ -t 0 ]; then pty=1; fi + write_policy "$clone" "$cfg/srt-settings.json" "$mode" "$sockets" "$pty" + mkdir -p /tmp/claude 2>/dev/null || true + mkdir -p "$clone/$CLAUDE_STATE" + cd "$clone" + env \ + -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN \ + -u GITLAB_TOKEN -u GLAB_TOKEN -u SSH_AUTH_SOCK \ + -u CLAUDE_CODE_MESSAGING_SOCKET -u CLAUDE_CODE_MESSAGING_TOKEN \ + XDG_RUNTIME_DIR="$sockets" \ + GH_CONFIG_DIR="$cfg/gh" GLAB_CONFIG_DIR="$cfg/glab" \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=credential.helper GIT_CONFIG_VALUE_0= \ + GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=false SSH_ASKPASS=false \ + GIT_SSH_COMMAND=false \ + CLAUDE_CONFIG_DIR="$clone/$CLAUDE_STATE" \ + "$SRT" --settings "$cfg/srt-settings.json" -- "$@" || rc=$? + return "$rc" +} + +check() { + local dst="${1:-}" out scratch bare refs api code login listener rc=0 + [ -n "$dst" ] || usage + require_clone "$dst" + require_sandbox + out="$(mktemp)" + scratch="$(mktemp -d)" + bare="$scratch/target.git" + git init --bare --quiet "$bare" + + # probe + probe() { + local description="$1" expected="$2" + shift 2 + if "$SELF" start "$dst" -- "$@" >"$out" 2>&1 /dev/null 2>&1; then + probe "gh with its own configuration" "$unreachable|$denied" \ + env -u GH_CONFIG_DIR gh api user + else + echo " blocked gh with its own configuration (gh is not installed)" + fi + if [ -d "$HOME/.ssh" ]; then + probe "read SSH keys" "$denied" ls "$HOME/.ssh" + else + echo " blocked read SSH keys (there is no ~/.ssh)" + fi + if [ -d "$HOME/.claude" ]; then + probe "write Claude Code state outside the dry run" "$denied" touch "$claude_probe" + probe "read Claude Code state outside the dry run" "$denied" ls "$HOME/.claude" + else + echo " blocked Claude Code state outside the dry run (there is no ~/.claude)" + fi + + # Stands in for the message socket of an agent session outside the dry run. + local foreign="$scratch/session.sock" + local connect='const s = require("net").connect(process.argv[1]); s.on("connect", () => process.exit(0)); s.on("error", (e) => { console.error(e.message); process.exit(1); });' + node -e 'require("net").createServer().listen(process.argv[1]);' "$foreign" & + listener=$! + for _ in 1 2 3 4 5 6 7 8 9 10; do [ -S "$foreign" ] && break; sleep 0.2; done + if [ -S "$foreign" ] && node -e "$connect" "$foreign" 2>/dev/null; then + probe "connect to another agent session's socket" "EPERM|$denied" \ + node -e "$connect" "$foreign" + else + echo " UNCLEAR connect to another agent session's socket: no stand-in socket to aim at" + rc=1 + fi + kill "$listener" 2>/dev/null || true + wait "$listener" 2>/dev/null || true + + refs="$(git -C "$bare" for-each-ref | wc -l | tr -d ' ')" + echo " refs in the probe repository afterwards: $refs (must be 0)" + [ "$refs" = 0 ] || rc=1 + if [ -e "$scratch/outside" ]; then + echo " a file was written outside the clone" + rc=1 + fi + if [ -e "$claude_probe" ]; then + echo " a file was written to ~/.claude; it has been removed" + rm -f "$claude_probe" + rc=1 + fi + + echo "Read paths (each must work):" + if "$SELF" start "$dst" -- git log -1 --format=%h >/dev/null 2>&1 /dev/null /dev/null 2>&1 /dev/null 2>&1; then + login="$("$SELF" start "$dst" -- claude auth status --text 2>&1 fs.rmSync(stubDir, { recursive: true, force: true })); +const stubSrt = path.join(stubDir, "srt"); +fs.writeFileSync( + stubSrt, + [ + "#!/bin/sh", + '[ "$1" = "--settings" ] || { echo "stub srt: expected --settings" >&2; exit 97; }', + '[ -z "$DRY_RUN_STUB_POLICY" ] || cp "$2" "$DRY_RUN_STUB_POLICY"', + // Without --, srt would read a command's own options, such as claude -c, as its own. + '[ "$3" = "--" ] || { echo "stub srt: expected -- before the command" >&2; exit 98; }', + "shift 3", + 'exec "$@"', + "", + ].join("\n"), + { mode: 0o755 }, +); + +const hermetic = { + DRY_RUN_SESSION_SRT: stubSrt, + GIT_AUTHOR_NAME: "Dry Run", + GIT_AUTHOR_EMAIL: "dry-run@example.invalid", + GIT_COMMITTER_NAME: "Dry Run", + GIT_COMMITTER_EMAIL: "dry-run@example.invalid", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", +}; + +function workspace(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dry-run-session-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function git(cwd, ...args) { + const result = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...hermetic }, + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +// A source repository with one commit and a remote, so a test can tell whether +// setting up a dry run touched it. +function sourceRepository(dir, files = { "README.md": "source\n" }) { + const src = path.join(dir, "source"); + fs.mkdirSync(src); + git(src, "init", "--quiet", "-b", "main"); + for (const [name, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(src, name)), { recursive: true }); + fs.writeFileSync(path.join(src, name), content); + } + git(src, "add", "-A"); + git(src, "commit", "--quiet", "-m", "initial"); + git(src, "remote", "add", "origin", "https://example.invalid/source.git"); + return src; +} + +function run(args, env = {}) { + const merged = { ...process.env, ...hermetic, ...env }; + if (!("DRY_RUN_ALLOWED_DOMAINS" in env)) delete merged.DRY_RUN_ALLOWED_DOMAINS; + return spawnSync("bash", [helper, ...args], { + cwd: repoRoot, + encoding: "utf8", + env: merged, + }); +} + +// Runs a command in the session and returns the result with the recorded policy. +function inSession(t, clone, command, env = {}) { + const record = path.join(workspace(t), "policy.json"); + const result = run(["start", clone, "--", ...command], { ...env, DRY_RUN_STUB_POLICY: record }); + assert.equal(result.status, 0, result.stderr); + return { result, policy: JSON.parse(fs.readFileSync(record, "utf8")) }; +} + +function dryRunClone(t, files) { + const dir = workspace(t); + const src = sourceRepository(dir, files); + const clone = path.join(dir, "clone"); + const result = run(["setup", src, clone]); + assert.equal(result.status, 0, result.stderr); + return { dir, src, clone, result }; +} + +test("Setting up clones into a directory of its own", (t) => { + // Given: a source repository with a remote + const dir = workspace(t); + const src = sourceRepository(dir); + const pushUrlBefore = git(src, "remote", "get-url", "--push", "origin"); + + // When: a dry-run clone is set up from it + const clone = path.join(dir, "clone"); + const result = run(["setup", src, clone]); + + // Then: the clone exists and the source's push URL and hooks are unchanged + assert.equal(result.status, 0, result.stderr); + assert.ok(fs.existsSync(path.join(clone, ".git"))); + assert.ok(fs.existsSync(path.join(clone, "README.md"))); + assert.equal(git(src, "remote", "get-url", "--push", "origin"), pushUrlBefore); + assert.equal( + spawnSync("git", ["config", "--get", "core.hooksPath"], { cwd: src }).status, + 1, + ); +}); + +test("The session runs inside the sandbox runtime", (t) => { + // Given: a dry-run clone + const { clone } = dryRunClone(t); + const real = fs.realpathSync(clone); + + // When: a command runs in the session + const { result, policy } = inSession(t, clone, ["pwd", "-P"]); + + // Then: it runs in the clone through the sandbox runtime, whose policy allows writes only to the clone and temporary directories and reaches only the agent's API + assert.equal(result.stdout.trim(), real); + // Besides the clone: /tmp/claude, /tmp/claude- and /tmp/claude-*, also under + // /private, and the session's own socket directory. + const temporary = /^(?:\/private)?\/tmp\/(?:claude(?:-(?:\d+|\*))?|dry-run-session\.[A-Za-z0-9]+)$/; + assert.ok(policy.filesystem.allowWrite.includes(real)); + for (const entry of policy.filesystem.allowWrite) { + assert.ok(entry === real || temporary.test(entry), `unexpected writable path ${entry}`); + } + assert.deepEqual(policy.network.allowedDomains, [ + "api.anthropic.com", "*.anthropic.com", "claude.ai", "claude.com", "*.claude.com", + ]); +}); + +test("GitHub and GitLab stay denied when every domain is allowed", (t) => { + // Given: a dry-run clone and an allowlist opened to every domain + const { clone } = dryRunClone(t); + + // When: a command runs in the session + const { policy } = inSession(t, clone, ["true"], { DRY_RUN_ALLOWED_DOMAINS: "*" }); + + // Then: the sandbox policy still denies GitHub and GitLab + assert.deepEqual(policy.network.allowedDomains, ["*"]); + for (const domain of ["github.com", "*.github.com", "gitlab.com", "*.gitlab.com"]) { + assert.ok(policy.network.deniedDomains.includes(domain), `${domain} is not denied`); + } +}); + +test("Credentials and the clone's guards are out of the session's reach", (t) => { + // Given: a dry-run clone + const { clone } = dryRunClone(t); + const real = fs.realpathSync(clone); + + // When: a command runs in the session + const { policy } = inSession(t, clone, ["true"]); + + // Then: the sandbox policy denies reading SSH keys, gh, glab and git credential files and Claude Code state outside the dry run, and writing the hook and the deny rules + for (const entry of [ + "~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials", + "~/.claude", "~/.claude.json", + ]) { + assert.ok(policy.filesystem.denyRead.includes(entry), `${entry} stays readable`); + } + for (const entry of [`${real}/.git/dry-run-hooks`, `${real}/.claude/settings.local.json`]) { + assert.ok(policy.filesystem.denyWrite.includes(entry), `${entry} stays writable`); + } +}); + +test("Claude Code keeps the session's state in the clone", (t) => { + // Given: a dry-run clone and a Claude Code configuration directory in the calling environment + const { clone } = dryRunClone(t); + const real = fs.realpathSync(clone); + + // When: a command runs in the session + const { result } = inSession(t, clone, ["sh", "-c", 'printf "%s" "$CLAUDE_CONFIG_DIR"'], { + CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), "outside-claude-config"), + }); + + // Then: CLAUDE_CONFIG_DIR points to an existing directory inside the clone's git directory + const state = path.join(real, ".git/dry-run-session/claude"); + assert.equal(result.stdout, state); + assert.ok(fs.statSync(state).isDirectory()); +}); + +test("Logging in is the only session that may bind a local port", (t) => { + // Given: a dry-run clone and a stand-in for Claude Code + const { dir, clone } = dryRunClone(t); + const bin = path.join(dir, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, "claude"), '#!/bin/sh\nprintf "claude %s" "$*"\n', { mode: 0o755 }); + const loginPolicy = path.join(dir, "login-policy.json"); + + // When: logging in, and running a regular session + const login = run(["login", clone], { + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + DRY_RUN_STUB_POLICY: loginPolicy, + }); + const { policy: sessionPolicy } = inSession(t, clone, ["true"]); + + // Then: only the login runs claude auth login with a policy that allows local binding + assert.equal(login.status, 0, login.stderr); + assert.equal(login.stdout, "claude auth login"); + assert.equal(JSON.parse(fs.readFileSync(loginPolicy, "utf8")).network.allowLocalBinding, true); + assert.equal(sessionPolicy.network.allowLocalBinding, false); +}); + +test("The session listens for messages only in a socket directory of its own", (t) => { + // Given: a dry-run clone and the message socket of an agent session outside the dry run in the calling environment + const { clone } = dryRunClone(t); + + // When: a command runs in the session + const { result, policy } = inSession( + t, + clone, + ["sh", "-c", 'test -d "$XDG_RUNTIME_DIR" && printf "%s|%s|%s" "$XDG_RUNTIME_DIR" "${CLAUDE_CODE_MESSAGING_SOCKET-unset}" "${CLAUDE_CODE_MESSAGING_TOKEN-unset}"'], + { + CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/cc-socks/1.sock", + CLAUDE_CODE_MESSAGING_TOKEN: "outside-token", + }, + ); + + // Then: the policy opens Unix sockets only in that directory, the outside socket is not handed over, and the directory is gone afterwards + const [sockets, socket, token] = result.stdout.split("|"); + assert.deepEqual(policy.network.allowUnixSockets, [sockets]); + assert.ok(policy.filesystem.allowWrite.includes(sockets)); + assert.doesNotMatch(sockets, /cc-socks/); + // Claude Code falls back to /tmp/cc-socks- for a socket path over 103 bytes. + assert.ok(Buffer.byteLength(path.join(sockets, "cc-socks", "4194304.sock")) <= 103, sockets); + assert.equal(socket, "unset"); + assert.equal(token, "unset"); + assert.ok(!fs.existsSync(sockets), "the socket directory outlived the session"); +}); + +test("Only an interactive session may control its terminal", (t) => { + // Given: a dry-run clone and a pseudo-terminal + const { dir, clone } = dryRunClone(t); + // Node cannot open a pseudo-terminal; Python can, and runs the helper with it as stdin. + if (spawnSync("python3", ["-c", "import os; os.openpty()"]).status !== 0) { + t.skip("python3 is not available to provide a terminal"); + return; + } + const withTerminal = "import os, subprocess, sys; m, s = os.openpty(); sys.exit(subprocess.run(sys.argv[1:], stdin=s).returncode)"; + const interactivePolicy = path.join(dir, "interactive-policy.json"); + + // When: a session starts from a terminal, one starts without, and logging in + const interactive = spawnSync("python3", ["-c", withTerminal, "bash", helper, "start", clone, "--", "true"], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, ...hermetic, DRY_RUN_STUB_POLICY: interactivePolicy }, + }); + const { policy: detached } = inSession(t, clone, ["true"]); + const bin = path.join(dir, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, "claude"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const loginPolicy = path.join(dir, "login-policy.json"); + const login = run(["login", clone], { + PATH: `${bin}${path.delimiter}${process.env.PATH}`, + DRY_RUN_STUB_POLICY: loginPolicy, + }); + + // Then: only the session started from a terminal is allowed to control it + assert.equal(interactive.status, 0, interactive.stdout + interactive.stderr); + assert.equal(login.status, 0, login.stderr); + assert.equal(JSON.parse(fs.readFileSync(interactivePolicy, "utf8")).allowPty, true); + assert.equal(detached.allowPty, false); + assert.equal(JSON.parse(fs.readFileSync(loginPolicy, "utf8")).allowPty, false); +}); + +test("Logging in completes the onboarding", (t) => { + // Given: a dry-run clone and a stand-in for Claude Code whose login leaves the onboarding incomplete + const { dir, clone } = dryRunClone(t); + const bin = path.join(dir, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync( + path.join(bin, "claude"), + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then echo "9.8.7 (Claude Code)"; exit 0; fi', + `printf '{"oauthAccount":{"emailAddress":"dry-run@example.invalid"}}' > "$CLAUDE_CONFIG_DIR/.claude.json"`, + "", + ].join("\n"), + { mode: 0o755 }, + ); + + // When: logging in + const login = run(["login", clone], { PATH: `${bin}${path.delimiter}${process.env.PATH}` }); + + // Then: the clone's Claude Code state keeps the login and records the onboarding as complete for the installed version + assert.equal(login.status, 0, login.stderr); + const state = JSON.parse( + fs.readFileSync(path.join(fs.realpathSync(clone), ".git/dry-run-session/claude/.claude.json"), "utf8"), + ); + assert.deepEqual(state.oauthAccount, { emailAddress: "dry-run@example.invalid" }); + assert.equal(state.hasCompletedOnboarding, true); + assert.equal(state.lastOnboardingVersion, "9.8.7"); +}); + +test("Without the sandbox runtime no session starts", (t) => { + // Given: a dry-run clone and no sandbox runtime + const { dir, clone } = dryRunClone(t); + const marker = path.join(dir, "ran"); + + // When: the session is started + const result = run(["start", clone, "--", "touch", marker], { + DRY_RUN_SESSION_SRT: path.join(dir, "no-such-srt"), + }); + + // Then: it fails, names the missing runtime and runs nothing + assert.notEqual(result.status, 0); + assert.match(result.stderr, /sandbox runtime/); + assert.ok(!fs.existsSync(marker), "the command ran without a sandbox"); +}); + +test("A directory that is not a dry-run clone is refused", (t) => { + // Given: an ordinary checkout that setup did not prepare, and a stand-in for Claude Code + const dir = workspace(t); + const src = sourceRepository(dir); + const bin = path.join(dir, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, "claude"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const marker = path.join(dir, "ran"); + const env = { PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + + // When: a session is started in it, and logging in there + const started = run(["start", src, "--", "touch", marker], env); + const login = run(["login", src], env); + + // Then: both fail, say it is not a dry-run clone, run nothing and leave no Claude Code state behind + for (const result of [started, login]) { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /is not a dry-run clone/); + } + assert.ok(!fs.existsSync(marker), "the command ran in an ordinary checkout"); + assert.ok(!fs.existsSync(path.join(src, ".git/dry-run-session")), "Claude Code state was created"); +}); + +test("A push to the clone's own remote is refused", (t) => { + // Given: a dry-run clone + const { clone } = dryRunClone(t); + + // When: the session pushes to origin + const result = run(["start", clone, "--", "git", "push", "origin", "HEAD"]); + + // Then: the push fails because the push URL is unusable + assert.notEqual(result.status, 0); + assert.match(result.stderr, /PUSH-DISABLED-dry-run-session/); +}); + +test("A push to an explicitly named repository is rejected by the hook", (t) => { + // Given: a dry-run clone and an empty bare repository + const { dir, clone } = dryRunClone(t); + const bare = path.join(dir, "target.git"); + git(dir, "init", "--quiet", "--bare", bare); + + // When: the session pushes to that repository by path + const result = run(["start", clone, "--", "git", "push", bare, "HEAD:refs/heads/probe"]); + + // Then: the pre-push hook rejects it and the repository receives no refs + assert.notEqual(result.status, 0); + assert.match(result.stderr, /dry-run-session: pushing is disabled in this clone/); + assert.equal(git(bare, "for-each-ref"), ""); +}); + +test("A push over SSH never reaches a remote", (t) => { + // Given: a dry-run clone + const { clone } = dryRunClone(t); + + // When: the session pushes to an SSH URL, bypassing hooks + const result = run([ + "start", clone, "--", + "git", "push", "--no-verify", "ssh://git@example.invalid/probe.git", "HEAD", + ]); + + // Then: the push fails before a connection is made + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Could not read from remote repository/); + assert.doesNotMatch(result.stderr, /Repository not found/); +}); + +test("The session carries no credentials", (t) => { + // Given: tokens and an SSH agent socket in the calling environment + const { clone } = dryRunClone(t); + const outside = { + GH_TOKEN: "outside-token", + GITHUB_TOKEN: "outside-token", + GITLAB_TOKEN: "outside-token", + SSH_AUTH_SOCK: path.join(os.tmpdir(), "outside-agent.sock"), + }; + + // When: a command runs inside the session + const env = run(["start", clone, "--", "env"], outside); + const helpers = run(["start", clone, "--", "git", "config", "--get-all", "credential.helper"], outside); + + // Then: it sees no token, no agent socket, no usable SSH, no credential helper and no terminal prompt + assert.equal(env.status, 0, env.stderr); + const seen = Object.fromEntries( + env.stdout + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), + ); + for (const name of ["GH_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", "SSH_AUTH_SOCK"]) { + assert.equal(seen[name], undefined, `${name} leaked into the session`); + } + assert.equal(seen.GIT_SSH_COMMAND, "false"); + assert.equal(seen.GIT_TERMINAL_PROMPT, "0"); + assert.ok(!fs.existsSync(seen.GH_CONFIG_DIR) || fs.readdirSync(seen.GH_CONFIG_DIR).length === 0); + const configured = helpers.stdout.replace(/\n$/, "").split("\n"); + assert.equal(configured.at(-1), "", "a credential helper is still active"); +}); + +test("Claude Code is denied the commands that publish", (t) => { + // Given: a dry-run clone + const { clone } = dryRunClone(t); + + // When: its local Claude Code settings are read + const settings = JSON.parse( + fs.readFileSync(path.join(clone, ".claude/settings.local.json"), "utf8"), + ); + + // Then: they deny git push, gh issue, gh pr, gh api and glab + const deny = settings.permissions.deny; + for (const rule of ["Bash(git push *)", "Bash(gh issue *)", "Bash(gh pr *)", "Bash(gh api *)", "Bash(glab *)"]) { + assert.ok(deny.includes(rule), `missing deny rule ${rule}`); + } +}); + +test("A local settings file the project already has is left untouched", (t) => { + // Given: a source repository that already contains .claude/settings.local.json + const own = '{ "permissions": { "allow": ["Read"] } }\n'; + + // When: a dry-run clone is set up from it + const { clone, result } = dryRunClone(t, { + "README.md": "source\n", + ".claude/settings.local.json": own, + }); + + // Then: the file is not changed and the setup says so + assert.equal(fs.readFileSync(path.join(clone, ".claude/settings.local.json"), "utf8"), own); + assert.match(result.stderr, /left untouched/); +}); + +test("A target that already exists is refused", (t) => { + // Given: a directory already exists at the target path + const dir = workspace(t); + const src = sourceRepository(dir); + const target = path.join(dir, "taken"); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, "keep.txt"), "keep\n"); + + // When: a dry-run clone is set up into it + const result = run(["setup", src, target]); + + // Then: the setup fails and the directory is left as it was + assert.notEqual(result.status, 0); + assert.match(result.stderr, /already exists/); + assert.deepEqual(fs.readdirSync(target), ["keep.txt"]); +});