issue_98: Add a dry-run session helper - #99
dieterbaier wants to merge 6 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Stimmt. Ich habe es vor der Änderung nachgestellt:
git push --no-verifyan ein lokales Repository ging durch;env -uholte 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
srtund 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
left a comment
There was a problem hiding this comment.
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.
| clone, | ||
| ...temporary, | ||
| "~/.claude", | ||
| "~/.claude.json", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
Dry run on 15 September:
|
4. The interactive session stalls on three paths outside
|
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.
Follow-up on finding 4:
|
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.
|
Finding 1 (interactive start stuck in onboarding after |
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 apre-pushhook installed through a clone-localcore.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 butclaude auth login. The token lands in the clone as.credentials.json.Only dry-run clones:
start,loginandcheckrefuse a directory thatsetupdid 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:srtand Claude Code;gh,glaband git credential files, and of~/.claudeand~/.claude.json;DRY_RUN_ALLOWED_DOMAINS), and denies GitHub and GitLab explicitly, so they stay unreachable even with*;.git/configand the deny rules unwritable.Claude Code runs with
CLAUDE_CONFIG_DIRset to.git/dry-run-session/claudeinside 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. Withoutsrt, 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.jsonas a soft layer. An existing file is reported and left untouched.Requirements:
srtand Node.js; on macOS alsoripgrep. Documented inadapters/shared/README.mdand referenced fromREADME.mdand 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-verifyto a local repository went through;env -ubrought back the keychain credential helper;checkreportedtightbecause it only tested the layers it knew about.89a7297moved 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.022d1d8redirectsCLAUDE_CONFIG_DIRand makes~/.claudeneither writable nor readable.One deviation from the suggestion: a separate
CLAUDE_CONFIG_DIRdoes not keep the keychain login. Claude Code reportsNot logged inwith any value of the variable, outside the sandbox and even withCLAUDE_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 completeloginsucceeded inside the sandbox, and the token landed as.credentials.jsonin the state directory. The run targeted an ordinary checkout, and the helper accepted it.24570b2refuses every directory thatsetupdid not prepare.Behaviour specification
features/dry-run-session.featureholds fifteen scenarios, bridged one-to-one by title totest/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
srtwith a stand-in that records the policy it receives and requires--before the command. So the tests specify the policy, not its enforcement; enforcement ischeck'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 cleanLive on macOS with
srt0.0.76 and Claude Code 2.1.270:checkagainst a fresh dry-run clone:RESULT: tight. Each of these was blocked for the right reason: a push toorigin; 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;ghwith its own configuration; reading~/.ssh; writing to and reading~/.claude. The probe repository received no refs, and~/.claudereceived no probe file.git login the clone and the agent's API worked.~/.claude.jsonand the top level of~/.claudewere the same before and after.logincompletes inside the sandbox, run interactively; the token lands as.credentials.jsonin the clone's state directory. Without local binding it fails at once withFailed to start OAuth callback server.start,loginandcheck, and receives no state.89a7297with the real configuration:claude -panswered. Its Bash tool wrote a file in the clone, andcurl https://github.com/from that tool failed withCONNECT tunnel failed, response 403.srtreads a wrapped command's options as its own (curl -sSbecame--settings S), hence the--./tmp/claude-<uid>and to/tmp/claude-*.CLAUDE_CONFIG_DIR.Architecture impact
None on the architecture documentation. The shared helpers are documented in
adapters/shared/README.md, not insrc/docs. No ADR, quality scenario or risk is affected.Not covered
/tmp/claude-<uid>stays writable.~/.claudedoes not apply inside a dry run.start.checkresult holds for the machine it ran on.implement-issue-workflow, the authoritative run belongs before integration.