Skip to content

issue_98: Add a dry-run session helper - #99

Open
dieterbaier wants to merge 6 commits into
mainfrom
issue_98
Open

dieterbaier wants to merge 6 commits into
mainfrom
issue_98

Conversation

@dieterbaier

@dieterbaier dieterbaier commented Sep 13, 2026

Copy link
Copy Markdown
Member

Closes #98.

What

A cross-engine helper in adapters/shared/ 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 or a workshop.

  • setup <source> [target]: a clone of its own, with an unusable push URL and a pre-push hook installed through a clone-local core.hooksPath.

  • login <target>: logs Claude Code in for this clone, once. It is the only run allowed to bind a local port, which the OAuth callback needs, and it runs nothing but claude auth login. The token lands in the clone as .credentials.json.

  • Only dry-run clones: start, login and check refuse a directory that setup did not prepare, recognized by the disabled push URL and the hook.

  • start <target> [-- command]: runs the command through the Anthropic Sandbox Runtime (srt), with a policy written for each session and kept outside every writable path. The policy:

    • allows writes only to the clone and the temporary directories of srt and Claude Code;
    • blocks reads of SSH keys, of the gh, glab and git credential files, and of ~/.claude and ~/.claude.json;
    • allows network access only to the agent's API (DRY_RUN_ALLOWED_DOMAINS), and denies GitHub and GitLab explicitly, so they stay unreachable even with *;
    • keeps the clone's hook, its .git/config and the deny rules unwritable.

    Claude Code runs with CLAUDE_CONFIG_DIR set to .git/dry-run-session/claude inside the clone, so its state is discarded with the clone and never reaches a session outside the dry run. The session environment also carries no tokens, SSH agent, SSH or credential helpers. Without srt, no session starts.

  • check <target>: attempts every write path from inside the sandbox and undoes the other layers where it can, so the sandbox itself is what gets tested. Every target either does not exist or is thrown away. Each probe must fail for the right reason. It also reports whether Claude Code is logged in for the clone.

  • Deny rules for Claude Code in .claude/settings.local.json as a soft layer. An existing file is reported and left untouched.

Requirements: srt and Node.js; on macOS also ripgrep. Documented in adapters/shared/README.md and referenced from README.md and from the talk README, which states that the helper is MIT-licensed and not covered by the talk's own license.

Review

First review, on 16c42e9: the promise did not hold. Before the change, I reproduced what the review describes:

  • git push --no-verify to a local repository went through;
  • env -u brought back the keychain credential helper;
  • the original checkout was writable;
  • github.com answered from inside the session.

check reported tight because it only tested the layers it knew about. 89a7297 moved the guarantee to a process sandbox. The git and environment layers stay, documented as layers a determined agent could undo: they turn a mistake into a clear message.

Second review, on 89a7297: the sandbox allowed writes to the real Claude Code state. That was a bridge into later, unsandboxed sessions. 022d1d8 redirects CLAUDE_CONFIG_DIR and makes ~/.claude neither writable nor readable.

One deviation from the suggestion: a separate CLAUDE_CONFIG_DIR does not keep the keychain login. Claude Code reports Not logged in with any value of the variable, outside the sandbox and even with CLAUDE_CONFIG_DIR=~/.claude. A temporary directory per session would therefore need a login per session. So the directory lives with the clone instead, and you log in once per clone.

Login test, on 022d1d8: a complete login succeeded inside the sandbox, and the token landed as .credentials.json in the state directory. The run targeted an ordinary checkout, and the helper accepted it. 24570b2 refuses every directory that setup did not prepare.

Behaviour specification

features/dry-run-session.feature holds fifteen scenarios, bridged one-to-one by title to test/dry-run-session.test.mjs, which is part of ./build.sh test-js.

The tests are hermetic: no network, throwaway repositories, and git isolated from the developer's global and system configuration. They replace srt with a stand-in that records the policy it receives and requires -- before the command. So the tests specify the policy, not its enforcement; enforcement is check's job.

Verification

Run locally with DOCS_TOOLBOX_LOCAL=1, that is against the host toolchain rather than the pinned docs-toolbox image; the CI run is authoritative.

  • shellcheck adapters/shared/dry-run-session.sh: clean
  • ./build.sh test: Ruby and JS pass; JS 56 of 56 (41 before, 15 new)
  • ./build.sh check-adapters: generated agent adapters are current
  • ./build.sh validate: validation passed
  • ./build.sh generate: working tree clean

Live on macOS with srt 0.0.76 and Claude Code 2.1.270:

  • check against a fresh dry-run clone: RESULT: tight. Each of these was blocked for the right reason: a push to origin; a push to a local repository, with and without the hook; a write outside the clone; a change to the clone's git configuration and to its hook; HTTPS to GitHub and to GitLab with the credential helpers restored; SSH to both with SSH restored; gh with its own configuration; reading ~/.ssh; writing to and reading ~/.claude. The probe repository received no refs, and ~/.claude received no probe file. git log in the clone and the agent's API worked.
  • The real Claude Code state stayed unchanged while sessions ran with the clone's state: the size and modification time of ~/.claude.json and the top level of ~/.claude were the same before and after.
  • login completes inside the sandbox, run interactively; the token lands as .credentials.json in the clone's state directory. Without local binding it fails at once with Failed to start OAuth callback server.
  • An ordinary checkout is refused by start, login and check, and receives no state.
  • Claude Code inside the session, verified under 89a7297 with the real configuration: claude -p answered. Its Bash tool wrote a file in the clone, and curl https://github.com/ from that tool failed with CONNECT tunnel failed, response 403.
  • Found while verifying:
    • srt reads a wrapped command's options as its own (curl -sS became --settings S), hence the --.
    • Claude Code writes below /tmp/claude-<uid> and to /tmp/claude-*.
    • Claude Code ties its login to CLAUDE_CONFIG_DIR.

Architecture impact

None on the architecture documentation. The shared helpers are documented in adapters/shared/README.md, not in src/docs. No ADR, quality scenario or risk is affected.

Not covered

  • A logged-in session with the clone's state. The login is verified; a session after it is not.
  • The login token is a file in the clone that a session can read; it reaches only the allowed domains.
  • Linux and Windows are not verified. The runtime supports path globs only on macOS, and Windows is not supported by the helper.
  • Traffic to the allowed domains is not inspected. The login keychain stays readable, and /tmp/claude-<uid> stays writable.
  • User-level configuration from ~/.claude does not apply inside a dry run.
  • GitHub and GitLab are not readable inside the session; issue text has to go into the prompt or into a file before start.
  • The deny rules are not tested, and they apply to Claude Code only.
  • A check result holds for the machine it ran on.
  • The Convergence Check has not been run on this pull request; per implement-issue-workflow, the authoritative run belongs before integration.

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.

@dieterbaier dieterbaier left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Die Umsetzung ist gut dokumentiert und die Git-spezifischen Schutzschichten sind nachvollziehbar getestet. Die zentrale Zusage „cannot publish anything“ wird durch start aber noch nicht hergestellt: Der Kindprozess ist weder vom Dateisystem noch vom Netzwerk isoliert und übernimmt – abgesehen von wenigen Variablen – die komplette Umgebung. Das ist ein Blocker, weil genau diese Zusage das Sicherheitsmodell und den vorgesehenen Workshop-Einsatz trägt.

[ -n "$dst" ] || usage
shift
if [ "${1:-}" = "--" ]; then shift; fi
if [ "$#" -eq 0 ]; then set -- claude; fi

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: env -u entfernt nur einige bekannte GitHub-/GitLab-Zugangsdaten; der gestartete Agent behält zugleich vollen Netzwerk- und Dateisystemzugriff sowie alle übrigen Umgebungsvariablen und Credential-Dateien. Er kann daher z. B. mit einem anderen Token/Webhook per curl publizieren, Zugangsdaten aus Dateien verwenden oder einfach ein benachbartes/originales Checkout verändern und dessen Git-Konfiguration nutzen. Auch der pre-push-Hook ist mit --no-verify umgehbar. check prüft nur ausgewählte Git-/gh-Pfade und kann deshalb trotz dieser offenen Wege RESULT: tight melden.

Damit tragen die beiden als „hard“ bezeichneten Schichten die dokumentierte Garantie „cannot publish anything“ nicht. Für diese Garantie braucht die Session eine echte Prozessgrenze mit eingeschränktem Dateisystem und kontrolliertem/unterbundenem Egress (öffentliche Reads ggf. über einen read-only Proxy oder vorab bereitgestellte Daten). Falls das bewusst nicht Ziel ist, müssten Scope, Name, Dokumentation und check deutlich auf „blockiert die getesteten GitHub-/GitLab-Schreibwege bei den bekannten Credential-Quellen“ begrenzt werden; für den angekündigten sicheren Dry Run wäre das allerdings erheblich schwächer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stimmt. Ich habe es vor der Änderung nachgestellt:

  • git push --no-verify an ein lokales Repository ging durch;
  • env -u holte den Keychain-Credential-Helper zurück;
  • der Original-Checkout war beschreibbar;
  • github.com antwortete aus der Sitzung.

check hatte nur die Schichten geprüft, die es kannte.

89a7297 stellt die Zusage auf eine Prozessgrenze. start läuft durch die Anthropic Sandbox Runtime (srt), mit einer Policy je Sitzung:

  • Schreiben nur in den Klon, in die Temp-Verzeichnisse von srt und Claude Code und in den Claude-Code-Zustand;
  • kein Lesen von SSH-Keys und von gh-, glab- und git-Zugangsdateien;
  • Netz nur zur Agenten-API; GitHub und GitLab sind ausdrücklich gesperrt, auch bei DRY_RUN_ALLOWED_DOMAINS="*";
  • Hook, .git/config, die Deny-Regeln und die ~/.claude-Einstellungen, die eine spätere Sitzung ändern würden, sind nicht beschreibbar.

Ohne srt startet keine Sitzung.

check prüft jetzt die Sandbox selbst. Die Proben laufen aus der Sitzung heraus, und die übrigen Schichten sind dabei zurückgenommen: --no-verify, Credential-Helper und SSH wiederhergestellt, gh mit eigener Konfiguration, Schreiben außerhalb des Klons, Lesen von ~/.ssh.

Live auf macOS sind alle zwölf Wege aus dem richtigen Grund blockiert: RESULT: tight. Claude Codes Bash-Tool läuft in der Sandbox; curl https://github.com/ scheitert dort mit 403 vom Proxy.

Was bewusst offen bleibt, steht in adapters/shared/README.md unter „What it does not do“. Dazu gehört: Die Keychain bleibt lesbar, weil Claude Code dort sein Login hält, und Linux ist nur geschrieben, nicht verifiziert.

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.

@dieterbaier dieterbaier left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Der ursprüngliche Blocker ist mit srt, der expliziten Netzwerk-Policy und den erweiterten Gegenproben grundsätzlich behoben. Die neue Prozessgrenze ist der richtige Ansatz, und die dokumentierte Live-Verifikation deckt die zuvor offenen Wege ab. Es bleibt jedoch noch ein persistenter Ausstiegspfad über den beschreibbaren Claude-Zustand; deshalb ist der PR für den Einsatz mit potenziell unvertrauten Skills noch nicht freigabefähig.

Comment thread adapters/shared/dry-run-session.sh Outdated
clone,
...temporary,
"~/.claude",
"~/.claude.json",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: persistente Manipulation einer späteren, unsandboxed Sitzung. Die Sandbox erlaubt hier Schreibzugriff auf den realen Claude-Code-Zustand. Damit endet die Sicherheitsgrenze nicht zuverlässig mit dem Dry Run: Ein ausgeführter Skill kann persistente Zustands- oder Konfigurationsdaten hinterlassen, die eine spätere normale Claude-Sitzung wieder liest. Besonders kritisch ist ~/.claude.json; außerdem macht das pauschale ~/.claude-Allowlisting die Absicherung von einer vollständigen, versionsabhängigen Liste nachgelagerter denyWrite-Pfade abhängig.

Dass dieser Zustand persistiert, ist dokumentiert, passt aber nicht zum Einsatzzweck „unbekanntes Projekt/Workshop“ und zur Zusage, die Session könne ihre Guards nicht lockern. Claude Code unterstützt dafür CLAUDE_CONFIG_DIR: Bitte den Dry Run auf ein eigenes temporäres Konfigurations-/State-Verzeichnis umleiten und ausschließlich dieses beschreibbar machen. Die Sandbox-Policy selbst muss weiterhin außerhalb dieses Schreibbereichs liegen. So bleiben Login über die Keychain und die eigentliche Session möglich, ohne den realen Claude-Zustand als Brücke in spätere Sitzungen offenzulassen.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umgesetzt in 022d1d8, mit einer Abweichung, die sich beim Nachstellen gezeigt hat.

Die Sitzung läuft jetzt mit CLAUDE_CONFIG_DIR=<klon>/.git/dry-run-session/claude. Beschreibbar sind nur der Klon, samt diesem Verzeichnis, und die Temp-Verzeichnisse. ~/.claude und ~/.claude.json sind weder beschreibbar noch lesbar; die denyWrite-Liste für ~/.claude entfällt damit. Die Policy liegt weiterhin außerhalb jedes Schreibbereichs.

check probiert zusätzlich Schreiben und Lesen in ~/.claude. Beides ist aus dem richtigen Grund blockiert, das Ergebnis ist RESULT: tight. Während Sitzungen mit dem Klon-Zustand liefen, blieb der echte Zustand unverändert.

Abweichung: Mit eigenem CLAUDE_CONFIG_DIR bleibt der Keychain-Login nicht erhalten. Claude Code bindet den Login an diese Variable. Nachgestellt habe ich das auch außerhalb der Sandbox und sogar mit CLAUDE_CONFIG_DIR=~/.claude; beide Male hieß es „Not logged in“.

Deshalb liegt das Verzeichnis nicht temporär je Sitzung, sondern je Klon, und es wird mit dem Klon weggeworfen. Eingeloggt wird einmal je Klon mit dem neuen login. Nur dieser Lauf darf einen lokalen Port binden, weil der OAuth-Callback das braucht, und er startet ausschließlich claude auth login.

Nicht verifiziert ist ein vollständiger Login in der Sandbox. Er kommt bis zur Autorisierungs-URL; zum Abschließen braucht es einen Browser. Bitte vor dem Merge einmal login an einem Klon durchspielen. Dabei zeigt sich auch, ob das Token in der Keychain oder als Datei im Klon landet.

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.

@dieterbaier dieterbaier left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Erneut geprüft: Die beiden bisherigen Blocker sind in 022d1d8 aus meiner Sicht vollständig behoben. Der Claude-Zustand liegt jetzt im wegwerfbaren Klon, der reale Zustand ist weder les- noch schreibbar, und die Sandbox-Policy bleibt außerhalb des erlaubten Schreibbereichs. Die Abweichung „Zustand je Klon statt je Sitzung“ ist nachvollziehbar und für den Dry-Run-Zweck sogar praktisch.

Ich sehe keinen weiteren Code-Befund. CI ist grün; Implementierung, Dokumentation, Verhaltenstests und check sind konsistent.

Vor dem Merge bleibt genau der bereits genannte manuelle Abnahmepunkt: login einmal vollständig im Browser abschließen und danach einen authentifizierten start mit dem klonlokalen CLAUDE_CONFIG_DIR ausführen. Erst dieser Durchlauf bestätigt, dass Tokenablage, erneute Nutzung und Sandbox-Policy zusammen funktionieren. Wenn das erfolgreich ist, ist der PR aus meiner Sicht converged.

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.
@dieterbaier

Copy link
Copy Markdown
Member Author

Dry run on 15 September: RESULT: tight, and three findings

Run on macOS against a fresh clone of budget, with srt 1.0.0 and Claude Code 2.1.272 — newer than the 0.0.76 and 2.1.270 this pull request verified.

What holds.

  • check: RESULT: tight. All fourteen write and network probes blocked for the right reason, 0 refs in the probe repository, and the clone reported as logged in.
  • login: the token lands as .credentials.json in .git/dry-run-session/claude, mode 600. The macOS keychain entry was not modified by it.
  • A logged-in session — the first item under "Not covered". claude -p answers inside the sandbox with the clone's state. From inside a session, a write and a commit in the clone succeed (5c5b066), https://github.com/ fails with CONNECT tunnel failed, response 403, and ~/.ssh and ~/.claude.json are not even readable (Operation not permitted).
  • Afterwards the real state is untouched: budget-dry-run appears zero times in ~/.claude.json, there is no entry for it under ~/.claude/projects, and the top level of ~/.claude is unchanged.

Not covered by this run: a session in which Claude Code itself uses its tools. The agent session that would have driven them was refused by the permission layer of the driving session, not by this sandbox.

1. An interactive start cannot get past onboarding (blocker for the workshop)

OAuth error: Failed to start OAuth callback server: Failed to start server. Is port 0 in use?

The clone is logged in — its .claude.json carries oauthAccount and userID, and claude -p works. But login only runs claude auth login, so the clone's state never gets hasCompletedOnboarding. The first interactive start therefore runs onboarding, onboarding wants to log in, and start denies local binding by design (allowLocalBinding: process.env.DRY_RUN_MODE === "login").

So the non-interactive path works and the interactive one does not — which is the path a demonstration or a workshop uses.

Setting hasCompletedOnboarding: true (and lastOnboardingVersion) in the clone's .claude.json gets past it; the trust dialog that follows is interactive and needs no port. A fix belongs in login: seed those keys after a successful login, or state in the README that the first start after login has to be run once with local binding.

2. A missing directory reports "not a dry-run clone"

require_clone folds three cases into one message (adapters/shared/dry-run-session.sh:118):

if [ ! -d "$dst" ] || [ "$(git -C "$dst" config --get remote.origin.pushurl ...)" != ... ]

Running ./dry-run-session.sh login budget-dry-run from adapters/shared — where that relative path does not exist — reports budget-dry-run is not a dry-run clone; prepare one with: ... setup. The advice is wrong for the actual cause, and following it creates a second clone instead of correcting the path. "Does not exist" and "exists but was not prepared" want different messages.

3. claude auth login inside the clone silently logs in the real state

Standing in the clone and running claude auth login directly succeeds, and it re-authenticates the real Claude Code state in the keychain — the clone stays logged out, and nothing says so. It is the mistake a workshop participant makes, and the helper cannot prevent it, so the README should name it next to login: the directory you stand in does not matter, only CLAUDE_CONFIG_DIR does.

While verifying, the same section could say where the proof is: .credentials.json is a dotfile below .git/, so ls in the clone shows nothing.

@dieterbaier

Copy link
Copy Markdown
Member Author

4. The interactive session stalls on three paths outside allowWrite — and two of them must stay outside

Follow-up to finding 1. The terminal is not the cause: inside a session, tty reports /dev/ttys003 and test -t 0 succeeds, and srt spawns with stdio: 'inherit'. What the clone's own debug log shows instead (.git/dry-run-session/claude/debug/latest):

[ERROR] "execFileNoThrow spawn failed: EPERM ... posix_spawn 'ps'"
[ERROR] [uds-messaging] Failed to start: Error: Failed to listen at /tmp/cc-socks/53093.sock
[DEBUG] Failed to acquire lock for 2.1.273: EPERM: operation not permitted,
        open '~/.local/state/claude/locks/2.1.273.lock.tmp...'
[ERROR] NON-FATAL: Lock acquisition failed for ~/.local/share/claude/versions/2.1.273

allowWrite is [clone, ...temporary], and temporary covers only /tmp/claude*. Under claude -p each of these degrades and the run completes — which is why the verification in this pull request did not see them. The interactive start is where it stalls, and that is the path a demonstration uses.

The fix is not to add all three. Two of them would open a bridge out of the dry run:

  • ~/.local/share/claude/versions/<version> is the installed Claude Code. Writable from inside, a dry-run session could modify the binary used by later, unsandboxed sessions. That is the class of escape the second review found at ~/.claude, with executable code instead of settings.
  • ~/.local/state/claude/locks belongs to the same version machinery.
  • /tmp/cc-socks is different in kind: a socket directory under /tmp, next to the /tmp/claude* entries already allowed.

Suggested order: allow /tmp/cc-socks alone and measure the interactive start again; if the version machinery still blocks it, redirect it rather than open it (CLAUDE_CODE_DEBUG_LOGS_DIR exists; I found no equivalent for the locks); and keep srt 0.0.76 as a comparison, since this run used 1.0.0 while the version mismatch (2.1.273 seen in the clone against 2.1.272 installed) is what sets the machinery going.

Whatever is chosen, check should cover the interactive start — a RESULT: tight that only exercises the non-interactive path says nothing about the mode the workshop runs in.

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.
@dieterbaier

Copy link
Copy Markdown
Member Author

Follow-up on finding 4: /tmp/cc-socks was not the harmless one — fixed in 0f0f09e

Correction. The previous comment called /tmp/cc-socks "different in kind" and suggested allowing it first. That was wrong. It is the directory in which Claude Code listens for messages from other local agent sessions, and it holds the sockets of sessions outside the dry run. srt's allowUnixSockets opens binding and connecting for the whole path. Measured with srt 1.0.0: with the directory allowed, a connect to a socket in it goes through (connected); without, it fails with EPERM. Allowing it would have let a dry run message an unsandboxed session.

What 0f0f09e does instead.

  • Each session gets a short socket directory of its own (/tmp/dry-run-session.XXXXXX) via XDG_RUNTIME_DIR. Only that directory is writable and open for Unix sockets, and it is removed when the session ends. CLAUDE_CODE_MESSAGING_SOCKET and CLAUDE_CODE_MESSAGING_TOKEN are not passed on. Probed from inside: listening in the own directory works; connecting to the real sessions' sockets — directly, through a symlink, or via a hard link — is refused.
  • The next stall was the prompt: escape sequences, no input, because the terminal ioctl is denied. A start from a terminal now runs with allowPty; login, check and a start without a terminal do not.
  • start no longer execs the runtime, so the policy and socket directories are cleaned up.
  • check connects to a stand-in socket outside the session (must be refused) and reports whether the session can listen in its own directory.
  • Two new scenarios and tests; ./build.sh all passes locally.

Verified on macOS with srt 1.0.0 and Claude Code 2.1.273: check ends with RESULT: tight, and an interactive session in a logged-in clone takes input and answers with the clone's state. The version lock under ~/.local/state/claude stays closed and is logged as non-fatal.

What allowPty leaves open, measured and documented in the README. The runtime grants it for every /dev/ttys*. From an interactive session the user's other terminals can be opened for writing. TIOCSTI into another terminal is refused. And independent of this change, any session — with or without allowPty — can open other terminals for reading, and so compete for input typed there; that gap already existed. The README advises keeping no other terminal with a shell or an unsandboxed agent session open during a dry run.

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.
@dieterbaier

Copy link
Copy Markdown
Member Author

Finding 1 (interactive start stuck in onboarding after login) is fixed in 18e932d: after a successful claude auth login, login sets hasCompletedOnboarding and lastOnboardingVersion in the clone's .claude.json. It recurred today with a fresh clone of budget; with the flag set, the interactive start (without exec, from 0f0f09e) takes input and answers with the clone's state. New scenario and test; ./build.sh all passes locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a dry-run session helper that cannot publish

1 participant