This repo is a Claude Code plugin that sandboxes each Bash tool call through binpash/try so the user can review the accumulated filesystem diff after Claude finishes and choose to commit or discard.
- Per-Bash hook, not outer wrapper.
PreToolUse(Bash)rewrites eachBash(x)→Bash(try -D $overlay 'x')— passing the full command as a single arg (notbash -c '...') becausetryinternals doecho "$@"which collapses multi-args.-Dimplies-n. - Edit / Write / MultiEdit are NOT sandboxed. They write through Node directly; hooks can't intercept them. Users rely on git for that half.
- Plugin-only distribution. Installed via
/plugin marketplace add <git-url>. No curl-pipe-sh, no wrapper binary. - All-or-nothing commit in v1.
/trai:commitor/trai:discard --yes. Partial commits defer togit add -ppost-commit. - Linux-only. Doctor refuses to start on macOS/Windows.
- Shared overlay per session. One
-Ddir across all Bash calls so state accumulates.pre-bash.shpre-cleanstemproot/before each call because upstreamtry's validity check (sandbox_valid_or_empty) refuses sandboxes with non-directory entries intemproot/. git/sudo/docker/ read-only commands are passthrough (not wrapped).- No default network isolation (
-xnot passed). Claude needs the API. - Plugin-internal scripts self-exempt. Any command starting with
$CLAUDE_PLUGIN_ROOT/is passthrough. Required so/trai:doctorsees the real host,/trai:passthroughwrites its token to the real FS, etc. SessionStartis idempotent. Claude Code fires it on startup,/clear,/compact,/resume, and auto-compact.session-start.shreuses an existing overlay if one is live; otherwise creates a fresh one.
hooks/pre-bash.sh— the rewriter. Reads Claude'sPreToolUseJSON on stdin, emits{hookSpecificOutput.updatedInput.command}with thetry-wrapped command. If you break this, nothing is sandboxed.hooks/session-start.sh— creates the overlay dir, writes$XDG_STATE_HOME/trai/current-session, runsscripts/doctor.shin quiet mode. If doctor fails, writes emptycurrent-sessionso the pre-bash hook self-disables.hooks/session-end.sh— prints a reminder banner if the overlay is uncommitted.scripts/doctor.sh— kernel /unprivileged_userns_clone/try-installed /unshare/ FS-type preflight. Single source of truth for "is this environment compatible."scripts/is-passthrough.sh— matches a command against the passthrough patterns fromconfig/defaults.json. Called frompre-bash.sh.config/defaults.json— default passthrough + ignore patterns. Users override via$CLAUDE_PLUGIN_DATA/config.json..claude-plugin/plugin.json— plugin manifest..claude-plugin/marketplace.json— single-plugin marketplace so/plugin marketplace add <git-url>works.commands/*.md— seven slash commands (/trai:diff,/trai:commit,/trai:discard,/trai:explore,/trai:status,/trai:doctor,/trai:passthrough).
- Shell:
bash,#!/usr/bin/env bash,set -euo pipefail, prefer POSIX where practical. - Lint:
shellcheck-clean. Runshellcheck hooks/*.sh scripts/*.shbefore committing. - JSON: always through
jq. Never hand-roll string-matching on JSON. - No runtime deps outside POSIX +
jq+flock+tryitself. Explicitly no Node, no Python, no Go. - Paths: always absolute inside scripts; use
${CLAUDE_PLUGIN_ROOT},${CLAUDE_PLUGIN_DATA},${CLAUDE_PROJECT_DIR}. - Slash commands: terse markdown with frontmatter; body is a one-liner
!…shell invocation where possible, delegating to a helper script. - Tests:
test/smoke.shis the end-to-end. Run it in a scratch dir ($(mktemp -d)), NEVER in this repo's own cwd — the smoke test commits and discards overlays.
- Do NOT delete or rewrite an overlay dir without user confirmation. Programmatic
rm -rfon$XDG_STATE_HOME/trai/sessions/*without the--yesinteraction is a data-loss event. (Wipingoverlay/temproot/contents is the one exception — that'stry's chroot scratch space and never holds user data.) - Do NOT call
try commitprogrammatically from any script that isn't explicitly the/trai:commithandler. Commit is a user decision. - Do NOT add a runtime dependency on Node, Python, or any compiled binary other than
tryitself. The plugin must work on a minimal Debian container withbash,jq,flock,unshare, andtry. - Do NOT modify the user's
~/.claude/settings.json. Plugin state lives in${CLAUDE_PLUGIN_DATA}only. - Do NOT change the per-Bash-hook architecture without re-opening the planning conversation. If you think Edit/Write should be sandboxed, that's a different product; discuss before building.
- Do NOT default to network isolation (
-x). Opt-in only, because it severs the Anthropic API. - Do NOT remove the plugin-internal-script exemption at the top of
pre-bash.sh. Without it,/trai:doctorreports on the overlay,/trai:passthroughsilently fails, and/trai:commitwrites the session pointer into the overlay instead of the real FS. Discovered live; don't re-break. - Do NOT remove the pre-clean of
temproot/inpre-bash.sh. Upstreamtryfails with "sandbox invalid" orln: Permission deniedon the 2nd+ call into a shared-Doverlay without it. Also discovered live. - Do NOT make
session-start.shunconditionally create a new overlay.SessionStartfires on/compact//clear//resumetoo; a fresh-every-time implementation orphans in-progress work.
These all cost a debugging round in live sessions. They're written up fully in the docs; this is just the lookup table.
| Symptom | Cause | Fix location |
|---|---|---|
Plugin fails to load: Hook load failed: ... path ["hooks"] received undefined |
hooks/hooks.json event map must be wrapped under a top-level "hooks" key, like settings.json. |
hooks/hooks.json; docs/claude/research-claude-code.md §"Configuration shape" |
/trai:doctor reports on an overlay, not the real host; /trai:passthrough silently fails |
Plugin's own scripts getting sandboxed by the PreToolUse(Bash) hook |
Self-exemption at top of hooks/pre-bash.sh; docs/claude/design-notes.md §8 |
ln: failed to create symbolic link 'temproot//bin/bin': Permission denied on 2nd Bash call |
Leftover symlinks in overlay/temproot/ from try's incomplete cleanup |
Pre-clean in hooks/pre-bash.sh; docs/claude/design-notes.md §9 |
try: given sandbox 'X' is invalid on 3rd+ Bash call |
sandbox_valid_or_empty() refuses temproot with any non-directory entries |
Same pre-clean as above |
/trai:status reports "no active session" mid-session after successful Bash calls |
SessionStart re-fired on compact/resume and overwrote or cleared current-session |
Idempotent bail-out in hooks/session-start.sh; docs/claude/design-notes.md §10 |
/trai:status reports "no active session" even on a first-invocation Claude session where Bash calls just ran sandboxed |
Runtime state (current-session, bypass-next) was in $CLAUDE_PLUGIN_DATA which lands on NFS when $HOME is NFS and/or varies between hook and slash-command contexts |
Moved runtime state to $XDG_STATE_HOME/trai/; docs/claude/design-notes.md §11 |
cd: /home/user/...: No such file or directory inside every sandboxed Bash call |
User launched Claude from a cwd path that traverses NFS $HOME; /home couldn't be overlayed; the symlink doesn't resolve inside the chroot |
Launch Claude from real local-disk path, set XDG_STATE_HOME to local disk; docs/limitations.md §11 |
Overlay warnings about /home, /run, /snap, /sys |
Cosmetic — try tries to overlay every top-level mountpoint and warns when it can't. Execution continues. |
No fix needed; ignore |
- What is
try, how does it work, what breaks it? →docs/claude/research-try.md - How do Claude Code hooks / plugins / marketplaces work? →
docs/claude/research-claude-code.md - Why post-hoc review at all, what did we decide not to do? →
docs/claude/research-review-ux.md - Rationale for each locked-in decision →
docs/claude/design-notes.md - Known failure modes and workarounds →
docs/limitations.md
tryis vendored as a git submodule undervendor/try. Build it withmake -C vendor/try.scripts/doctor.shwill find it there as a fallback if no systemtryis on$PATH.- Host requirements: Linux kernel ≥ 5.11,
unprivileged_userns_clone=1,jq,flock,unshare. - Run
test/smoke.shfrom a scratch dir to verify end-to-end.