From 16c42e930eadfcb77826c24e6e573a8b9aeed350 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Sun, 13 Sep 2026 17:18:59 +0200 Subject: [PATCH 1/6] issue_98: Add a dry-run session helper Skills that push branches or create issues should be able to act for real in a demonstration or a workshop without anything being published. An instruction to the agent is not a guarantee, and a permission rule only matches a command as it is written, so the guarantee now comes from the environment. dry-run-session.sh sets up a clone of its own with an unusable push URL and a pre-push hook that also rejects pushes to explicitly named URLs, and runs the session without credentials. Deny rules for Claude Code are the soft third layer. check proves on the current machine that every write path fails for the right reason, against targets that do not exist. The behaviour is specified in features/dry-run-session.feature and bridged to hermetic node tests. The tests isolate git from the developer's global and system configuration: a global excludes file that ignores .claude/settings.local.json made one of them depend on the machine it ran on. --- README.md | 9 + adapters/shared/README.md | 54 ++++ adapters/shared/dry-run-session.sh | 184 ++++++++++++++ build.sh | 2 +- features/dry-run-session.feature | 54 ++++ .../README.md | 8 + test/dry-run-session.test.mjs | 231 ++++++++++++++++++ 7 files changed, 541 insertions(+), 1 deletion(-) create mode 100755 adapters/shared/dry-run-session.sh create mode 100644 features/dry-run-session.feature create mode 100644 test/dry-run-session.test.mjs diff --git a/README.md b/README.md index 99e7280..0c280d2 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,15 @@ 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 locked-down +directory and starts the agent without credentials; its `check` subcommand +proves on the current machine that no write path gets out. 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..be5b285 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -124,3 +124,57 @@ 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 which can **read a repository +but 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 the environment, not from an instruction to +the agent. + +### Usage + +```bash +./dry-run-session.sh setup [target] # clone into a locked-down directory +./dry-run-session.sh check # prove that nothing gets out +./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. + +### Behavior + +- **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, which an unusable push URL alone would not stop. +- **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. This layer is soft — it matches commands as they + are written — and an existing file is reported and left untouched, never + merged. The guarantee rests on the two layers above. +- **`check` requires the right reason.** It attempts a push to `origin`, to a + local repository, and over SSH and HTTPS to GitHub and GitLab, plus an + authenticated `gh` call — always against targets that do not exist, so even a + failing layer publishes nothing. A probe that fails only because its target is + missing is reported as `OPEN`, not as blocked. Use a clone only when `check` + ends with `RESULT: tight`. + +### What it does not do + +- `gh` reads nothing inside the session, not even public issues. Read them + through the public REST API — 60 unauthenticated requests per hour per IP — or + paste the text into the prompt. +- The deny rules apply to Claude Code only; other agents get the two hard + layers. +- 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..7d1255a --- /dev/null +++ b/adapters/shared/dry-run-session.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# +# Run an agent session that can read a repository but cannot publish anything. +# +# Usage: +# ./dry-run-session.sh setup [target] # clone into a locked-down directory +# ./dry-run-session.sh check # prove that nothing gets out +# ./dry-run-session.sh start [-- cmd ...] # run a command in the session (default: claude) +# +# is a local checkout or a clone URL; defaults to +# -dry-run in the current directory. +# +# Hard layers: +# - A clone of its own. Its 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, which an unusable push URL alone would not stop. +# - A session without credentials: no gh or glab login, no SSH agent, no SSH, +# every git credential helper reset, no terminal prompts. +# Soft layer: +# - Deny rules for Claude Code in .claude/settings.local.json. They match +# commands as written; the guarantee rests on the hard layers. +# +# `check` attempts every write path against a target that does not exist, so even +# a failing layer publishes nothing, and requires the right reason for each +# failure: a probe that fails only because its target is missing is OPEN. +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" + +usage() { + sed -n '3,11p' "$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 + 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 " start: $SELF start \"$dst\"" +} + +start() { + local dst="${1:-}" cfg + [ -n "$dst" ] || usage + shift + if [ "${1:-}" = "--" ]; then shift; fi + if [ "$#" -eq 0 ]; then set -- claude; fi + cfg="$(mktemp -d)" + cd "$dst" + exec env \ + -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN \ + -u GITLAB_TOKEN -u GLAB_TOKEN -u SSH_AUTH_SOCK \ + 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 \ + "$@" +} + +check() { + local dst="${1:-}" out bare refs rc=0 + [ -n "$dst" ] || usage + out="$(mktemp)" + bare="$(mktemp -d)/target.git" + git init --bare --quiet "$bare" + + # probe + probe() { + local description="$1" expected="$2" + shift 2 + if "$SELF" start "$dst" -- "$@" >"$out" 2>&1; then + echo " OPEN $description: the command succeeded" + rc=1 + elif grep -qiE "Repository not found|returned error: 403|denied to|Authentication failed for|could not be found|HTTP Basic: Access denied|not allowed to push" "$out"; then + echo " OPEN $description: not blocked, only the target was missing" + rc=1 + elif grep -qiE "$expected" "$out"; then + echo " blocked $description" + else + echo " UNCLEAR $description: unexpected failure: $(tail -n 1 "$out")" + rc=1 + fi + } + + echo "Write paths (each must fail, and for the right reason):" + probe "push to origin" "$PUSH_URL_DISABLED|does not appear to be a git repository" \ + git push origin HEAD + probe "push to a local repository (hook)" "$HOOK_MESSAGE" \ + git push "$bare" HEAD:refs/heads/probe + probe "push over SSH to GitHub" "Could not read from remote repository" \ + git push --no-verify git@github.com:dry-run-session-probe/does-not-exist.git HEAD + probe "push over HTTPS to GitHub" "terminal prompts disabled|could not read Username" \ + git push --no-verify https://github.com/dry-run-session-probe/does-not-exist.git HEAD + probe "push over SSH to GitLab" "Could not read from remote repository" \ + git push --no-verify git@gitlab.com:dry-run-session-probe/does-not-exist.git HEAD + probe "push over HTTPS to GitLab" "terminal prompts disabled|could not read Username" \ + git push --no-verify https://gitlab.com/dry-run-session-probe/does-not-exist.git HEAD + if command -v gh >/dev/null 2>&1; then + probe "authenticated gh call" "gh auth login" gh api user + else + echo " blocked authenticated gh call (gh is not installed)" + fi + + 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 + + echo "Read paths (each must work):" + if "$SELF" start "$dst" -- git log -1 --format=%h >/dev/null 2>&1; then + echo " ok git log in the clone" + else + echo " FAILED git log in the clone" + rc=1 + fi + if "$SELF" start "$dst" -- curl -sf https://api.github.com/rate_limit >/dev/null 2>&1; then + echo " ok public GitHub API without a token" + else + echo " FAILED public GitHub API without a token" + rc=1 + fi + + rm -f "$out" + if [ "$rc" = 0 ]; then + echo "RESULT: tight" + else + echo "RESULT: NOT TIGHT, do not use this clone" + fi + return "$rc" +} + +case "${1:-}" in + setup) shift; setup "$@" ;; + start) shift; start "$@" ;; + check) shift; check "$@" ;; + *) usage ;; +esac diff --git a/build.sh b/build.sh index 2a1a561..8bac880 100755 --- a/build.sh +++ b/build.sh @@ -87,7 +87,7 @@ run_local_test_ruby() { } run_local_test_js() { - node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs test/skill-wiring.test.mjs + node --test test/build-agent-adapters.test.mjs test/build-agent-adapters-template.test.mjs test/build-sh-template.test.mjs test/dry-run-session.test.mjs test/install-skills.test.mjs test/journal-config.test.mjs test/skill-wiring.test.mjs } run_local_test() { diff --git a/features/dry-run-session.feature b/features/dry-run-session.feature new file mode 100644 index 0000000..e7dabad --- /dev/null +++ b/features/dry-run-session.feature @@ -0,0 +1,54 @@ +# Living documentation for adapters/shared/dry-run-session.sh. +# Bridged to: test/dry-run-session.test.mjs (node:test, classic runner, no native +# BDD). Each scenario maps to one test named after the scenario title, with +# Given/When/Then comment anchors inside the test body. Traceability is a +# reviewer-verifiable convention, not a build-enforced link. +# +# The tests are hermetic. The helper's `check` subcommand deliberately contacts +# real hosts to prove, on the machine it runs on, that nothing can be published; +# it is the live counterpart to these scenarios and is not exercised here. + +Feature: Dry-run session + As someone running agent skills against a real repository + I want a session that can read but cannot publish + So that skills which push branches or create issues can run for real without anything leaving the machine + + Scenario: Setting up clones into a directory of its own + Given a source repository with a remote + When a dry-run clone is set up from it + Then the clone exists and the source's push URL and hooks are unchanged + + Scenario: A push to the clone's own remote is refused + Given a dry-run clone + When the session pushes to origin + Then the push fails because the push URL is unusable + + Scenario: A push to an explicitly named repository is rejected by the hook + Given a dry-run clone and an empty bare repository + When the session pushes to that repository by path + Then the pre-push hook rejects it and the repository receives no refs + + Scenario: A push over SSH never reaches a remote + Given a dry-run clone + When the session pushes to an SSH URL, bypassing hooks + Then the push fails before a connection is made + + Scenario: The session carries no credentials + Given tokens and an SSH agent socket in the calling environment + When a command runs inside the session + Then it sees no token, no agent socket, no usable SSH, no credential helper and no terminal prompt + + Scenario: Claude Code is denied the commands that publish + Given a dry-run clone + When its local Claude Code settings are read + Then they deny git push, gh issue, gh pr, gh api and glab + + Scenario: A local settings file the project already has is left untouched + Given a source repository that already contains .claude/settings.local.json + When a dry-run clone is set up from it + Then the file is not changed and the setup says so + + Scenario: A target that already exists is refused + Given a directory already exists at the target path + When a dry-run clone is set up into it + Then the setup fails and the directory is left as it was diff --git a/talks/ai-assisted-coding-meetup-envite/README.md b/talks/ai-assisted-coding-meetup-envite/README.md index 9ef1831..18b0b12 100644 --- a/talks/ai-assisted-coding-meetup-envite/README.md +++ b/talks/ai-assisted-coding-meetup-envite/README.md @@ -55,6 +55,14 @@ docker run --rm \ Then open `http://localhost:8000/index.html`. +## Running the demo against a real repository + +To let the demo's skills act for real without publishing anything, run them in a +dry-run session: see [`adapters/shared/dry-run-session.sh`](../../adapters/shared/dry-run-session.sh) +and its documentation in [`adapters/shared/README.md`](../../adapters/shared/README.md). +The helper is part of the toolkit and licensed under the repository's MIT +License, not under this talk's license. + ## Source The first proposal is based on diff --git a/test/dry-run-session.test.mjs b/test/dry-run-session.test.mjs new file mode 100644 index 0000000..04e8794 --- /dev/null +++ b/test/dry-run-session.test.mjs @@ -0,0 +1,231 @@ +// Behaviour specification for adapters/shared/dry-run-session.sh, the helper that +// runs an agent session which can read a repository but cannot publish anything. +// +// The tests are hermetic: every repository is a throwaway directory, and no probe +// needs the network. The helper's `check` subcommand is the live counterpart on a +// user's machine — it contacts real hosts on purpose — and is not exercised here. +// +// Bridged from: features/dry-run-session.feature + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const helper = path.join(repoRoot, "adapters/shared/dry-run-session.sh"); + +// Git runs without the developer's global and system configuration. A global +// excludes file that ignores .claude/settings.local.json — a common setup for +// Claude Code users — would otherwise keep that file out of the source +// repository one scenario commits it to, and the test would depend on the +// machine it runs on. +const hermetic = { + 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 = {}) { + return spawnSync("bash", [helper, ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, ...hermetic, ...env }, + }); +} + +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("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"]); +}); From 89a729743ca5f4c884070d0ca36c60938decdb4f Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Sun, 13 Sep 2026 17:52:10 +0200 Subject: [PATCH 2/6] issue_98: Run the dry-run session inside a sandbox The review of #99 found that the promise "cannot publish anything" did not hold. The session kept full network and filesystem access: git push --no-verify skipped the hook, env -u restored a credential helper, and the original checkout was one cd away. check still reported tight, because it only tested the layers it knew about. start now runs the command through the Anthropic Sandbox Runtime (srt) with a policy written for each session, outside every writable path: - writes only to the clone, the temporary directories of srt and Claude Code, and Claude Code state - no reads of SSH keys or gh, glab and git credential files - network only to the agent's API; GitHub and GitLab are denied even when more domains are allowed - the clone guards, and the settings that would change a later session, are unwritable Without srt no session starts. The command follows --, because srt would otherwise take options such as claude -c as its own. check now probes from inside the sandbox and undoes the other layers where it can. It pushes with the hook bypassed, writes outside the clone, changes the git config and the hook, pushes over HTTPS with the credential helpers restored and over SSH with SSH restored, calls gh with its own configuration, and reads ~/.ssh. Public GitHub reads are blocked by design, so the read check now targets the agent's API. The tests replace srt with a stand-in that records the policy and requires --. They specify the policy; enforcement is check's job. --- README.md | 9 +- adapters/shared/README.md | 91 ++++++++++---- adapters/shared/dry-run-session.sh | 190 ++++++++++++++++++++++------- features/dry-run-session.feature | 28 ++++- test/dry-run-session.test.mjs | 118 +++++++++++++++++- 5 files changed, 361 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 0c280d2..e7b01af 100644 --- a/README.md +++ b/README.md @@ -411,10 +411,11 @@ into the chosen root (project by default, `-g`/`--global` for user-wide, 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 locked-down -directory and starts the agent without credentials; its `check` subcommand -proves on the current machine that no write path gets out. See -`adapters/shared/README.md`. +`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 diff --git a/adapters/shared/README.md b/adapters/shared/README.md index be5b285..30ad953 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -127,17 +127,26 @@ helper (which the installer above filters out via `adapter_expose`) — so prefe ## Dry-Run Session (`dry-run-session.sh`) -Cross-engine helper that runs an agent session which can **read a repository -but cannot publish anything**, so skills that push branches or create issues can +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 the environment, not from an instruction to -the agent. +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` 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 check # prove that nothing gets out +./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 ``` @@ -147,34 +156,74 @@ the agent. 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` | 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, to the temporary directories of + `srt` and Claude Code (`/tmp/claude`, `/tmp/claude-` and `/tmp/claude-*`), + and to Claude Code's own state in `~/.claude` and `~/.claude.json`. + It cannot read `~/.ssh`, the `gh` and `glab` configuration, `~/.netrc` or + `~/.git-credentials`, 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="*"`. +- **The session cannot loosen its guards.** The sandbox keeps the clone's hook + directory, its `.git/config` and `.claude/settings.local.json` unwritable, and + so is everything in `~/.claude` that would change a later session outside the + dry run: `settings.json`, `settings.local.json`, `CLAUDE.md`, `hooks`, `skills`, + `agents`, `commands` and `plugins`. - **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, which an unusable push URL alone would not stop. + `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. This layer is soft — it matches commands as they - are written — and an existing file is reported and left untouched, never - merged. The guarantee rests on the two layers above. -- **`check` requires the right reason.** It attempts a push to `origin`, to a - local repository, and over SSH and HTTPS to GitHub and GitLab, plus an - authenticated `gh` call — always against targets that do not exist, so even a - failing layer publishes nothing. A probe that fails only because its target is - missing is reported as `OPEN`, not as blocked. Use a clone only when `check` - ends with `RESULT: tight`. + 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, and + reads `~/.ssh`. 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`. 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 -- `gh` reads nothing inside the session, not even public issues. Read them - through the public REST API — 60 unauthenticated requests per hour per IP — or - paste the text into the prompt. -- The deny rules apply to Claude Code only; other agents get the two hard - layers. +- **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. +- **Session state persists.** `~/.claude.json` stays writable, and so do session + transcripts and history in `~/.claude`; a later session outside the dry run + reads them. Claude Code's temporary directory `/tmp/claude-` is shared + with the user's other Claude Code sessions and writable as well. +- **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, whose Bash tool runs inside the sandbox. The policy is + written for Linux as well, but the runtime supports path globs only on macOS; + 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 index 7d1255a..54d839c 100755 --- a/adapters/shared/dry-run-session.sh +++ b/adapters/shared/dry-run-session.sh @@ -1,37 +1,43 @@ #!/usr/bin/env bash # -# Run an agent session that can read a repository but cannot publish anything. +# 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 that nothing gets out +# ./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) # # is a local checkout or a clone URL; defaults to -# -dry-run in the current directory. +# -dry-run in the current directory. `start` and `check` need the +# Anthropic Sandbox Runtime (`srt`, npm package @anthropic-ai/sandbox-runtime). # -# Hard layers: -# - A clone of its own. Its 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, which an unusable push URL alone would not stop. -# - A session without credentials: no gh or glab login, no SSH agent, no SSH, -# every git credential helper reset, no terminal prompts. -# Soft layer: -# - Deny rules for Claude Code in .claude/settings.local.json. They match -# commands as written; the guarantee rests on the hard layers. +# The boundary is a process sandbox around the whole session (srt). The session +# writes only to the clone and to the agent's own state, cannot read SSH keys or +# gh, glab and git credential files, and reaches only the agent's API. GitHub and +# GitLab stay denied even when more domains are allowed. # -# `check` attempts every write path against a target that does not exist, so even -# a failing layer publishes nothing, and requires the right reason for each -# failure: a probe that fails only because its target is missing is OPEN. +# 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" +SRT="${DRY_RUN_SESSION_SRT:-srt}" +DEFAULT_DOMAINS="api.anthropic.com *.anthropic.com claude.ai" +PROBE_REPOSITORY="dry-run-session-probe/does-not-exist.git" usage() { - sed -n '3,11p' "$SELF" >&2 + sed -n '3,12p' "$SELF" >&2 exit 2 } @@ -86,14 +92,87 @@ JSON 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 +} + +# write_policy : the srt settings for one 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_UID="$(id -u)" \ + DRY_RUN_DOMAINS="${DRY_RUN_ALLOWED_DOMAINS:-$DEFAULT_DOMAINS}" \ + 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 policy = { + 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", + ], + }, + filesystem: { + denyRead: ["~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials"], + // The clone, the temporary directories srt and Claude Code use, and Claude Code state. + allowWrite: [ + clone, + ...temporary, + "~/.claude", + "~/.claude.json", + ...(macOS ? ["~/.claude.json.*"] : []), + ], + // The clone guards, and everything that would change a later session. + denyWrite: [ + `${clone}/.git/dry-run-hooks`, + `${clone}/.claude/settings.local.json`, + "~/.claude/settings.json", + "~/.claude/settings.local.json", + "~/.claude/CLAUDE.md", + "~/.claude/hooks", + "~/.claude/skills", + "~/.claude/agents", + "~/.claude/commands", + "~/.claude/plugins", + ], + }, +}; +fs.writeFileSync(process.env.DRY_RUN_POLICY, JSON.stringify(policy, null, 2) + "\n"); +' +} + start() { - local dst="${1:-}" cfg + local dst="${1:-}" cfg clone [ -n "$dst" ] || usage shift if [ "${1:-}" = "--" ]; then shift; fi if [ "$#" -eq 0 ]; then set -- claude; fi + require_sandbox + clone="$(cd "$dst" && pwd -P)" cfg="$(mktemp -d)" - cd "$dst" + write_policy "$clone" "$cfg/srt-settings.json" + mkdir -p /tmp/claude 2>/dev/null || true + cd "$clone" exec env \ -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN \ -u GITLAB_TOKEN -u GLAB_TOKEN -u SSH_AUTH_SOCK \ @@ -102,25 +181,27 @@ start() { 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 \ - "$@" + "$SRT" --settings "$cfg/srt-settings.json" -- "$@" } check() { - local dst="${1:-}" out bare refs rc=0 + local dst="${1:-}" out scratch bare refs api code rc=0 [ -n "$dst" ] || usage + require_sandbox out="$(mktemp)" - bare="$(mktemp -d)/target.git" + 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; then + if "$SELF" start "$dst" -- "$@" >"$out" 2>&1 /dev/null 2>&1; then - probe "authenticated gh call" "gh auth login" gh api user + 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 authenticated gh call (gh is not installed)" + echo " blocked read SSH keys (there is no ~/.ssh)" fi 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 echo "Read paths (each must work):" - if "$SELF" start "$dst" -- git log -1 --format=%h >/dev/null 2>&1; then + if "$SELF" start "$dst" -- git log -1 --format=%h >/dev/null 2>&1 /dev/null 2>&1; then - echo " ok public GitHub API without a token" - else - echo " FAILED public GitHub API without a token" - rc=1 + api="$(printf '%s\n' "${DRY_RUN_ALLOWED_DOMAINS:-$DEFAULT_DOMAINS}" | tr -s '[:blank:]' '\n' | grep -v '[*]' | head -n 1 || true)" + if [ -n "$api" ]; then + code="$("$SELF" start "$dst" -- curl -s -o /dev/null -m 10 -w '%{http_code}' "https://$api/" 2>/dev/null 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", @@ -70,13 +94,23 @@ function sourceRepository(dir, files = { "README.md": "source\n" }) { } 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: { ...process.env, ...hermetic, ...env }, + 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); @@ -107,6 +141,78 @@ test("Setting up clones into a directory of its own", (t) => { ); }); +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 the agent's own state 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), ~/.claude and ~/.claude.json. + const agentState = /^(?:(?:\/private)?\/tmp\/claude(?:-(?:\d+|\*))?|~\/\.claude(?:\.json(?:\.\*)?)?)$/; + assert.ok(policy.filesystem.allowWrite.includes(real)); + for (const entry of policy.filesystem.allowWrite) { + assert.ok(entry === real || agentState.test(entry), `unexpected writable path ${entry}`); + } + assert.deepEqual(policy.network.allowedDomains, ["api.anthropic.com", "*.anthropic.com", "claude.ai"]); +}); + +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 and gh, glab and git credential files, and writing the hook, the deny rules and the agent's settings + for (const entry of ["~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials"]) { + assert.ok(policy.filesystem.denyRead.includes(entry), `${entry} stays readable`); + } + for (const entry of [ + `${real}/.git/dry-run-hooks`, + `${real}/.claude/settings.local.json`, + "~/.claude/settings.json", + "~/.claude/hooks", + ]) { + assert.ok(policy.filesystem.denyWrite.includes(entry), `${entry} stays writable`); + } +}); + +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 push to the clone's own remote is refused", (t) => { // Given: a dry-run clone const { clone } = dryRunClone(t); From 022d1d8219187e8b6701476f780d65baa7174745 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Sun, 13 Sep 2026 18:55:49 +0200 Subject: [PATCH 3/6] issue_98: Keep Claude Code state in the clone The second review of #99 found a persistent way out. The sandbox allowed writes to the real Claude Code state, so a skill could leave settings, hooks or ~/.claude.json entries behind that a later, unsandboxed session reads. The session now runs with CLAUDE_CONFIG_DIR in the clone's git directory (.git/dry-run-session/claude). Only the clone and the temporary directories are writable. ~/.claude and ~/.claude.json are neither writable nor readable, which retires the deny list for ~/.claude. Claude Code ties its login to CLAUDE_CONFIG_DIR, so a temporary directory per session would need a login per session. The state therefore lives with the clone, and a new login subcommand logs in once per clone. It is the only run allowed to bind a local port, which the OAuth callback needs. claude.com joins the default domains for the authorization URL. check adds probes that write to and read ~/.claude, and reports whether Claude Code is logged in for the clone. --- adapters/shared/README.md | 60 +++++++++++-------- adapters/shared/dry-run-session.sh | 95 +++++++++++++++++++----------- features/dry-run-session.feature | 14 ++++- test/dry-run-session.test.mjs | 65 +++++++++++++++----- 4 files changed, 159 insertions(+), 75 deletions(-) diff --git a/adapters/shared/README.md b/adapters/shared/README.md index 30ad953..b5b4703 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -137,7 +137,7 @@ an instruction to the agent. - The [Anthropic Sandbox Runtime](https://github.com/anthropics/sandbox-runtime): `npm install -g @anthropic-ai/sandbox-runtime`, which provides `srt`. Without - it, `start` and `check` refuse to run. + 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. @@ -146,6 +146,7 @@ an instruction to the agent. ```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 @@ -158,25 +159,28 @@ the session inherits it. | Variable | Default | Purpose | |---|---|---| -| `DRY_RUN_ALLOWED_DOMAINS` | `api.anthropic.com *.anthropic.com claude.ai` | Domains the session may reach, separated by spaces. Set it for an agent other than Claude Code. | +| `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, to the temporary directories of - `srt` and Claude Code (`/tmp/claude`, `/tmp/claude-` and `/tmp/claude-*`), - and to Claude Code's own state in `~/.claude` and `~/.claude.json`. - It cannot read `~/.ssh`, the `gh` and `glab` configuration, `~/.netrc` or - `~/.git-credentials`, 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="*"`. + 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="*"`. +- **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. - **The session cannot loosen its guards.** The sandbox keeps the clone's hook - directory, its `.git/config` and `.claude/settings.local.json` unwritable, and - so is everything in `~/.claude` that would change a later session outside the - dry run: `settings.json`, `settings.local.json`, `CLAUDE.md`, `hooks`, `skills`, - `agents`, `commands` and `plugins`. + 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 @@ -192,11 +196,13 @@ the session inherits it. 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, and - reads `~/.ssh`. 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`. Use a clone only when `check` ends with `RESULT: tight`. + restored, to GitHub and to GitLab, calls `gh` with its own configuration, reads + `~/.ssh`, and writes to and reads `~/.claude`. 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 @@ -213,17 +219,21 @@ clear message; the guarantee rests on the sandbox. - **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. -- **Session state persists.** `~/.claude.json` stays writable, and so do session - transcripts and history in `~/.claude`; a later session outside the dry run - reads them. Claude Code's temporary directory `/tmp/claude-` is shared - with the user's other Claude Code sessions and writable as well. +- **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 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. - **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, whose Bash tool runs inside the sandbox. The policy is - written for Linux as well, but the runtime supports path globs only on macOS; - run `check` before relying on it. Windows is not supported. + Claude Code 2.1.270. The policy is written for Linux as well, but the runtime + supports path globs only on macOS; 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 index 54d839c..e49e7aa 100755 --- a/adapters/shared/dry-run-session.sh +++ b/adapters/shared/dry-run-session.sh @@ -6,15 +6,19 @@ # ./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` and `check` need the +# -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 the agent's own state, cannot read SSH keys or -# gh, glab and git credential files, and reaches only the agent's API. GitHub and -# GitLab stay denied even when more domains are allowed. +# 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. 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. # # 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 @@ -32,12 +36,13 @@ 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" +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,12p' "$SELF" >&2 + sed -n '3,13p' "$SELF" >&2 exit 2 } @@ -55,7 +60,7 @@ setup() { cd "$dst" git remote set-url --push origin "$PUSH_URL_DISABLED" - mkdir -p .git/dry-run-hooks + 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 @@ -89,6 +94,7 @@ JSON echo "set up: $dst" echo " check: $SELF check \"$dst\"" + echo " login: $SELF login \"$dst\" (once per clone)" echo " start: $SELF start \"$dst\"" } @@ -104,12 +110,12 @@ require_sandbox() { fi } -# write_policy : the srt settings for one session. The file lies -# outside every writable path, so the session cannot loosen its own policy. +# write_policy [login]: the srt settings for one 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_UID="$(id -u)" \ + 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}" \ node -e ' const fs = require("node:fs"); @@ -131,30 +137,19 @@ const policy = { "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", }, filesystem: { - denyRead: ["~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials"], - // The clone, the temporary directories srt and Claude Code use, and Claude Code state. - allowWrite: [ - clone, - ...temporary, - "~/.claude", - "~/.claude.json", - ...(macOS ? ["~/.claude.json.*"] : []), - ], - // The clone guards, and everything that would change a later session. - denyWrite: [ - `${clone}/.git/dry-run-hooks`, - `${clone}/.claude/settings.local.json`, - "~/.claude/settings.json", - "~/.claude/settings.local.json", - "~/.claude/CLAUDE.md", - "~/.claude/hooks", - "~/.claude/skills", - "~/.claude/agents", - "~/.claude/commands", - "~/.claude/plugins", + // 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], + // 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"); @@ -162,16 +157,30 @@ fs.writeFileSync(process.env.DRY_RUN_POLICY, JSON.stringify(policy, null, 2) + " } start() { - local dst="${1:-}" cfg clone + 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:-}" + [ -n "$dst" ] || usage + session "$dst" login claude auth login +} + +# session : run the command inside the sandbox. +session() { + local dst="$1" mode="$2" cfg clone + shift 2 require_sandbox clone="$(cd "$dst" && pwd -P)" cfg="$(mktemp -d)" - write_policy "$clone" "$cfg/srt-settings.json" + write_policy "$clone" "$cfg/srt-settings.json" "$mode" mkdir -p /tmp/claude 2>/dev/null || true + mkdir -p "$clone/$CLAUDE_STATE" cd "$clone" exec env \ -u GH_TOKEN -u GITHUB_TOKEN -u GH_ENTERPRISE_TOKEN \ @@ -181,11 +190,12 @@ start() { 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" -- "$@" } check() { - local dst="${1:-}" out scratch bare refs api code rc=0 + local dst="${1:-}" out scratch bare refs api code login rc=0 [ -n "$dst" ] || usage require_sandbox out="$(mktemp)" @@ -215,6 +225,7 @@ check() { local restore_ssh=(env "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10") local denied="Operation not permitted|Permission denied|Read-only file system" local unreachable="CONNECT tunnel failed|Could not resolve host|Operation not permitted|Connection refused" + local claude_probe="$HOME/.claude/dry-run-session-probe" echo "Write paths (each must fail, and for the right reason):" probe "push to origin" "$PUSH_URL_DISABLED|does not appear to be a git repository" \ @@ -248,6 +259,12 @@ check() { 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 refs="$(git -C "$bare" for-each-ref | wc -l | tr -d ' ')" echo " refs in the probe repository afterwards: $refs (must be 0)" @@ -256,6 +273,11 @@ check() { 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 2>&1; then + login="$("$SELF" start "$dst" -- claude auth status --text 2>&1 { // 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 the agent's own state and reaches only the agent's API + // 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), ~/.claude and ~/.claude.json. - const agentState = /^(?:(?:\/private)?\/tmp\/claude(?:-(?:\d+|\*))?|~\/\.claude(?:\.json(?:\.\*)?)?)$/; + // Besides the clone: /tmp/claude, /tmp/claude- and /tmp/claude-*, also under /private. + const temporary = /^(?:\/private)?\/tmp\/claude(?:-(?:\d+|\*))?$/; assert.ok(policy.filesystem.allowWrite.includes(real)); for (const entry of policy.filesystem.allowWrite) { - assert.ok(entry === real || agentState.test(entry), `unexpected writable path ${entry}`); + assert.ok(entry === real || temporary.test(entry), `unexpected writable path ${entry}`); } - assert.deepEqual(policy.network.allowedDomains, ["api.anthropic.com", "*.anthropic.com", "claude.ai"]); + 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) => { @@ -183,20 +184,56 @@ test("Credentials and the clone's guards are out of the session's reach", (t) => // When: a command runs in the session const { policy } = inSession(t, clone, ["true"]); - // Then: the sandbox policy denies reading SSH keys and gh, glab and git credential files, and writing the hook, the deny rules and the agent's settings - for (const entry of ["~/.ssh", "~/.config/gh", "~/.config/glab-cli", "~/.netrc", "~/.git-credentials"]) { - assert.ok(policy.filesystem.denyRead.includes(entry), `${entry} stays readable`); - } + // 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 [ - `${real}/.git/dry-run-hooks`, - `${real}/.claude/settings.local.json`, - "~/.claude/settings.json", - "~/.claude/hooks", + "~/.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("Without the sandbox runtime no session starts", (t) => { // Given: a dry-run clone and no sandbox runtime const { dir, clone } = dryRunClone(t); From 24570b22b5ce52c651eaf7214ef635f45d05fdac Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Mon, 14 Sep 2026 20:14:28 +0200 Subject: [PATCH 4/6] issue_98: Refuse sessions outside dry-run clones A complete login inside the sandbox works, and the token lands as .credentials.json in the clone's state directory. The same test showed that start, login and check accepted any directory: the login ran in an ordinary checkout and left a login there. start, login and check now refuse a directory that setup did not prepare, recognized by the disabled push URL and the hook. The docs describe the token file, and how to end the login before a clone is deleted. --- adapters/shared/README.md | 12 ++++++++++-- adapters/shared/dry-run-session.sh | 18 +++++++++++++++++- features/dry-run-session.feature | 5 +++++ test/dry-run-session.test.mjs | 23 +++++++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/adapters/shared/README.md b/adapters/shared/README.md index b5b4703..89fd1ec 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -178,7 +178,11 @@ the session inherits it. 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. + OAuth callback needs; its token lands in that directory as + `.credentials.json`. +- **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 @@ -222,6 +226,10 @@ clear message; the guarantee rests on the sandbox. - **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. @@ -231,7 +239,7 @@ clear message; the guarantee rests on the sandbox. 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. The policy is written for Linux as well, but the runtime + Claude Code 2.1.270, including a complete login. The policy is written for Linux as well, but the runtime supports path globs only on macOS; run `check` before relying on it. Windows is not supported. - A `check` result holds for the machine it ran on. diff --git a/adapters/shared/dry-run-session.sh b/adapters/shared/dry-run-session.sh index e49e7aa..3238ec6 100755 --- a/adapters/shared/dry-run-session.sh +++ b/adapters/shared/dry-run-session.sh @@ -18,7 +18,8 @@ # the agent's API. GitHub and GitLab stay denied even when more domains are # allowed. 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. +# 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 @@ -110,6 +111,19 @@ require_sandbox() { 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 [login]: the srt settings for one session. The file # lies outside every writable path, so the session cannot loosen its own policy. write_policy() { @@ -175,6 +189,7 @@ login() { session() { local dst="$1" mode="$2" cfg clone shift 2 + require_clone "$dst" require_sandbox clone="$(cd "$dst" && pwd -P)" cfg="$(mktemp -d)" @@ -197,6 +212,7 @@ session() { check() { local dst="${1:-}" out scratch bare refs api code login rc=0 [ -n "$dst" ] || usage + require_clone "$dst" require_sandbox out="$(mktemp)" scratch="$(mktemp -d)" diff --git a/features/dry-run-session.feature b/features/dry-run-session.feature index d2b1ffe..4502847 100644 --- a/features/dry-run-session.feature +++ b/features/dry-run-session.feature @@ -50,6 +50,11 @@ Feature: Dry-run session When the session is started Then it fails, names the missing runtime and runs nothing + Scenario: A directory that is not a dry-run clone is refused + Given an ordinary checkout that setup did not prepare, and a stand-in for Claude Code + When a session is started in it, and logging in there + Then both fail, say it is not a dry-run clone, run nothing and leave no Claude Code state behind + Scenario: A push to the clone's own remote is refused Given a dry-run clone When the session pushes to origin diff --git a/test/dry-run-session.test.mjs b/test/dry-run-session.test.mjs index 400b810..2075de1 100644 --- a/test/dry-run-session.test.mjs +++ b/test/dry-run-session.test.mjs @@ -250,6 +250,29 @@ test("Without the sandbox runtime no session starts", (t) => { 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); From 0f0f09eac31ec48d2571fa25ab290f4e2c09b076 Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Wed, 16 Sep 2026 18:57:43 +0200 Subject: [PATCH 5/6] issue_98: Make the interactive session usable without a bridge out An interactive start stalled in two places the non-interactive runs did not reach. Claude Code listens for messages from other local agent sessions in /tmp/cc-socks, next to the sockets of sessions outside the dry run. The runtime's allowUnixSockets opens binding and connecting for a whole path, so allowing that directory would let a dry run message a session outside it; measured with srt 1.0.0, a connect there goes through. Each session now gets a short socket directory of its own through XDG_RUNTIME_DIR, opened only for that session and removed when it ends, and the calling environment's messaging socket and token are not passed on. check connects to a stand-in socket outside the session and must be refused. The prompt then showed escape sequences and took no input, because the runtime denies the terminal ioctl. A start from a terminal now runs with allowPty; login, check and a start without a terminal do not. The runtime grants it for every /dev/ttys*, so the README names what that leaves open: other terminals can be opened for writing, and any session can open them for reading. TIOCSTI into another terminal is refused. The session no longer execs the runtime, so its policy and socket directories are removed afterwards. The version lock under ~/.local/state/claude stays closed; Claude Code logs it as non-fatal. Verified on macOS with srt 1.0.0 and Claude Code 2.1.273: check ends with RESULT: tight, and an interactive session answered from the clone. --- adapters/shared/README.md | 36 +++++++++++++-- adapters/shared/dry-run-session.sh | 74 +++++++++++++++++++++++++----- features/dry-run-session.feature | 10 ++++ test/dry-run-session.test.mjs | 67 ++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 19 deletions(-) diff --git a/adapters/shared/README.md b/adapters/shared/README.md index 89fd1ec..daa4ac8 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -172,6 +172,18 @@ the session inherits it. `~/.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 @@ -201,7 +213,8 @@ the session inherits it. 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`, and writes to and reads `~/.claude`. Every target either does not + `~/.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 @@ -235,13 +248,26 @@ clear message; the guarantee rests on the sandbox. 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. The policy is written for Linux as well, but the runtime - supports path globs only on macOS; run `check` before relying on it. Windows is - not supported. +- **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 index 3238ec6..dc3bbf1 100755 --- a/adapters/shared/dry-run-session.sh +++ b/adapters/shared/dry-run-session.sh @@ -16,10 +16,12 @@ # 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. 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. +# 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 @@ -124,13 +126,15 @@ require_clone() { fi } -# write_policy [login]: the srt settings for one session. The file +# 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_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; @@ -143,7 +147,12 @@ 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. @@ -153,6 +162,12 @@ const policy = { ], // 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. @@ -161,7 +176,7 @@ const policy = { "~/.claude", "~/.claude.json", ], // The clone, which also holds the Claude Code state of this session, and the temporary directories. - allowWrite: [clone, ...temporary], + allowWrite: [clone, ...temporary, sockets], // The clone guards. denyWrite: [`${clone}/.git/dry-run-hooks`, `${clone}/.claude/settings.local.json`], }, @@ -187,30 +202,41 @@ login() { # session : run the command inside the sandbox. session() { - local dst="$1" mode="$2" cfg clone + 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)" - write_policy "$clone" "$cfg/srt-settings.json" "$mode" + # 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" - exec env \ + 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" -- "$@" + "$SRT" --settings "$cfg/srt-settings.json" -- "$@" || rc=$? + return "$rc" } check() { - local dst="${1:-}" out scratch bare refs api code login rc=0 + local dst="${1:-}" out scratch bare refs api code login listener rc=0 [ -n "$dst" ] || usage require_clone "$dst" require_sandbox @@ -282,6 +308,22 @@ check() { 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 @@ -312,6 +354,14 @@ check() { rc=1 fi fi + # Claude Code runs without it, so a failure is reported, not counted. Linux + # blocks Unix sockets inside the sandbox altogether. + # shellcheck disable=SC2016 + if "$SELF" start "$dst" -- node -e 'const p = require("path").join(process.env.XDG_RUNTIME_DIR, "probe.sock"); require("net").createServer().listen(p, function () { this.close(); });' >/dev/null 2>&1 /dev/null 2>&1; then login="$("$SELF" start "$dst" -- claude auth status --text 2>&1 { // 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. - const temporary = /^(?:\/private)?\/tmp\/claude(?:-(?:\d+|\*))?$/; + // 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}`); @@ -234,6 +235,68 @@ test("Logging in is the only session that may bind a local port", (t) => { 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("Without the sandbox runtime no session starts", (t) => { // Given: a dry-run clone and no sandbox runtime const { dir, clone } = dryRunClone(t); From 18e932d278bdf8770286704bf0c448605cf98c5e Mon Sep 17 00:00:00 2001 From: Dieter Baier Date: Wed, 16 Sep 2026 19:13:11 +0200 Subject: [PATCH 6/6] issue_98: Complete the onboarding when logging in claude auth login leaves hasCompletedOnboarding unset. The first interactive start then runs the onboarding, asks to log in again, and fails with "Failed to start OAuth callback server", because only the login run may bind a local port. That was finding 1 on #99 and it recurred with a fresh clone on 16 September. After a successful login, login now marks the onboarding complete in the clone's .claude.json, with the installed Claude Code version as lastOnboardingVersion. The login itself is kept. Verified by hand on a fresh clone of budget: with the flag set, an interactive start takes input and answers from the clone. --- adapters/shared/README.md | 5 ++++- adapters/shared/dry-run-session.sh | 17 ++++++++++++++++- features/dry-run-session.feature | 5 +++++ test/dry-run-session.test.mjs | 29 +++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/adapters/shared/README.md b/adapters/shared/README.md index daa4ac8..d39d906 100644 --- a/adapters/shared/README.md +++ b/adapters/shared/README.md @@ -191,7 +191,10 @@ the session inherits it. 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`. + `.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. diff --git a/adapters/shared/dry-run-session.sh b/adapters/shared/dry-run-session.sh index dc3bbf1..bbc3db3 100755 --- a/adapters/shared/dry-run-session.sh +++ b/adapters/shared/dry-run-session.sh @@ -195,9 +195,24 @@ start() { } login() { - local dst="${1:-}" + 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. diff --git a/features/dry-run-session.feature b/features/dry-run-session.feature index 60ad3a1..bb547ed 100644 --- a/features/dry-run-session.feature +++ b/features/dry-run-session.feature @@ -55,6 +55,11 @@ Feature: Dry-run session When a session starts from a terminal, one starts without, and logging in Then only the session started from a terminal is allowed to control it + Scenario: Logging in completes the onboarding + Given a dry-run clone and a stand-in for Claude Code whose login leaves the onboarding incomplete + When logging in + Then the clone's Claude Code state keeps the login and records the onboarding as complete for the installed version + Scenario: Without the sandbox runtime no session starts Given a dry-run clone and no sandbox runtime When the session is started diff --git a/test/dry-run-session.test.mjs b/test/dry-run-session.test.mjs index 29b3259..2cc220c 100644 --- a/test/dry-run-session.test.mjs +++ b/test/dry-run-session.test.mjs @@ -297,6 +297,35 @@ test("Only an interactive session may control its terminal", (t) => { 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);