A Claude Code plugin for the times Claude changes files through Bash (sed -i, heredocs, python -c, mv, rm) instead of the Edit/Write tools. When that happens, bash-bridge:
- runs your
Write|Edithooks (formatters, linters, guards, loggers) on the files the Bash command changed, - loads the path-scoped rules and nested
CLAUDE.mdfiles that Claude Code would have loaded if Edit/Write had been used, - keeps an undo log, so
/bash-undocan roll back Bash changes./rewinddoes not cover them.
Python 3 standard library only. Runs on macOS and Linux.
In Auto Mode and bypass mode, Claude Code's system prompt tells the model to do file work through Bash. Several features only notice the dedicated file tools, so with Bash edits they stop working, and nothing tells you:
| What breaks | Issue |
|---|---|
PostToolUse / PreToolUse hooks with matcher Write|Edit never fire. Formatters, lint gates, change logs and write guards are skipped. |
#87971, #89251 (a guard denying edits to one file, routed around by sed -i), #87575 comment (a CI battery "exits green" because it saw zero files) |
Path-scoped rules (.claude/rules/*.md with paths:) and nested CLAUDE.md files don't load for files touched through Bash. 2.1.288 and 2.1.293 fixed Write/Edit and plain single-file cat/head. A 2.1.296 measurement shows pipes, cd … &&, redirects and every Bash write still load nothing. |
#90450, #88565 |
/rewind says it succeeded, but files Bash changed stay changed. A sed range delete with a mistyped end pattern cuts a file down to its first lines, and there's nothing to restore from. |
#87575, #87971 |
The undocumented CLAUDE_CODE_THRIFTY_SONIC=0 (or false) in settings.json env removes the Bash-first instruction (found by users, see the issues). It doesn't help when a model picks Bash anyway (one commenter in #87971 reports Opus 5.5 still uses Bash a lot with it set): bulk renames, sed one-liners, or a prompt that asks for Bash. bash-bridge works either way.
PreToolUse(Bash) -> skip commands that are clearly read-only; otherwise snapshot the work tree
the command runs
PostToolUse(Bash) -> snapshot again, diff: created / modified / deleted
-> PreToolUse Write|Edit hooks, after the fact: a deny rolls that file back
-> PostToolUse Write|Edit hooks with a synthesized Edit/Write event
-> rules + nested CLAUDE.md for the changed files, once per session
-> undo log entry
-
Snapshots go into a private shadow git repository per project,
~/.claude-bash-bridge/projects/<hash>/shadow.git. Everything under~/.claude-bash-bridgeis created0700/0600. bash-bridge refuses to use a state folder that is a symlink or belongs to another user. Its work tree is your project. bash-bridge never writes to your repository's.git, not even the index. For git projects the shadow repo borrows your object store (alternates). When it's safe, it also starts from a copy of your index. It skips the copy in any of these cases:- the index is locked, or is not a plain SHA-1 v2/v3 index with a valid checksum;
- it has an extension other than cache-tree, resolve-undo or the offset tables;
- entries carry extended flags (skip-worktree, intent-to-add) or are assume-unchanged;
- sparse checkout, fsmonitor or the untracked cache is on;
- attributes rewrite content;
- the index bytes change while it is copied.
Then a snapshot costs about as much as
git status:ls-files -m -d -olists what changed, and only those files are hashed..gitignoreis respected. Without git, the defaults leave outnode_modules/,.venv/,__pycache__/and similar folders. -
Synthesized events. A modified text file becomes
Editwith the smallest changed line range asold_string/new_string. A new file becomesWritewithcontent(text up to 256 KB). The top-level fields,tool_inputandtool_responsehave the same keys as real Write/Edit events captured from Claude Code 2.1.303 (test/fixtures/native_posttooluse.json), includingoriginalFileand astructuredPatchbuilt with difflib.session_id,transcript_path,cwd,permission_mode,prompt_id,effortandduration_mscome from the real Bash event. One extra field,bash_bridge: {"synthetic": true, "change": …}, marks the event as synthesized; a hook that rejects unknown fields would have to allow it. The Bash command itself is deliberately not in the event: a guard that text-matches its stdin would otherwise fire for every file of a command that merely mentions a protected name. Hooks that need it read$BASH_BRIDGE_COMMAND, cut to 64 KiB (UTF-8); when it was cut,BASH_BRIDGE_COMMAND_TRUNCATED=1is set. Files are bridged in parallel (hook_workers, default 4; set it to 1 if your hooks can't run at the same time, for example ones that append to a shared log without locking). Files overmax_file_bytesare reported as "too large" (aWriteevent without content, no undo). New files without read permission are reported as "unreadable". Deleted files have no Edit/Write equivalent, so they don't fire hooks, but undo still covers them. -
Which hooks run. Command hooks from managed settings,
~/.claude/settings.json, enabled plugins (hooks/hooks.jsonandplugin.jsonhooks), and, in trusted projects only (see Security),.claude/settings.jsonand.claude/settings.local.json. They run when the matcher matchesWriteorEditbut notBash. Hooks matching*,""orBashalready ran for the real Bash call. Shell-form commands get${CLAUDE_PLUGIN_ROOT},${CLAUDE_PLUGIN_DATA}and${CLAUDE_PROJECT_DIR}as environment variables, never pasted into the command text. Exec-formargsget them as plain strings.timeoutandasyncare honoured.ifis matched approximately:Edit(glob)/Write(glob)against the file path; any other form makes the hook skip, with a debug note.disableAllHooksandallowManagedHooksOnlyare honoured at every level they appear in. bash-bridge never runs its own hooks: nested calls seeBASH_BRIDGE_ACTIVE=1and exit at once. -
What Claude is told. An exit-2 /
decision: "block"result from a bridged hook goes back to Claude as this Bash call'sdecision: "block"+reason.additionalContextandsystemMessageare passed through as well. Rules arrive asadditionalContextwith a short header, kept under Claude Code's 10,000-character cap. Files Claude Code already loaded itself, found asnested_memoryattachments in the transcript, are not sent again. A compaction resets this record. -
Read-only commands (
cat … | head,cd src && cat x,grep,git log…) get no snapshot.git diff/log/show/status/blame/grep …count as read-only only when the repository's config (any level) has nothing that makes them start a program (diff.external,diff.*.command/textconv,filter.*, ancore.fsmonitorhook,log.showSignature), noGIT_*override is set, and the command uses no-c,--git-dir,--ext-diff,--textconv,--show-signature,-Oand the like.lessandmorealways get a snapshot (LESSOPENcan run a program). bash-bridge still sends the rules for files they name, which Claude Code skips for those forms. Plain single-filecat/headis left to Claude Code, which handles it since 2.1.293.
test/run.sh simulates Claude Code calling the hooks around real shell commands. The suite has 179 tests. All pass on Python 3.14 and on macOS's /usr/bin/python3 3.9. test/test_livetest.py reproduces the bugs a live stress test found (34 real claude -p sessions):
- a call denied by a permission rule (its PreToolUse ran, its PostToolUse never came) is cleared through PermissionDenied, PostToolBatch and the transcript, so it can't switch the guard off;
- the classifier treats
sed -Ei/-ni/-ri,git stash,tree -o,xxd in outandrg --preas writers; - the Bash command is no longer in synthesized events;
- a pre hook that can't get the lock is reported, and the other call's entry is flagged
[overlapped]; undoing such an entry needs--force; - more than
max_untracked(5000) untracked files only degrade the call: tracked files are still covered, Claude and you are told (since review 3; it used to switch bash-bridge off for 7 days); - files that are too large or unreadable are reported as such;
- restored folders and files follow your umask;
- a file replaced by a folder is undone in one go;
listshows expiry by checking the stored blobs, and escapes control characters in names;- undo reminds you that git staging isn't undone;
- hooks for many files run in parallel.
It covers:
-
Detection: create, modify, delete, rename,
sed -i, heredoc,pythonwrites, names with spaces and Unicode, binary files,chmod, commits inside the command,git checkout, failing commands, ignored files, folders without git, and the cap for huge repos (bails out in under 3 s with a one-time note). -
Hooks: the synthesized Edit/Write payload, every settings source and plugins, exit-2 and JSON block relayed,
additionalContext, matcher rules,if, timeouts, the recursion guard, formatter output included in undo, and the #89251 guard (revert and report modes). -
Rules: globs (
**, braces, brackets, root-only*.md), frontmatter forms, nestedCLAUDE.md, nested.claude/rules, user rules,claudeMdExcludes, HTML comment stripping, per-session dedupe, skipping what the transcript shows as already loaded, re-injection after a compaction, piped andcd &&reads, and size caps. -
Undo: restore, recreate and remove, dry run, the last N calls in order, conflicts that need
--force, pre-images of dirty and untracked files, binary files, file modes, and folders without git. -
Review fixes (
test/test_review.py):- a guard rollback that would overwrite a later change is refused
- interleaved parallel calls
- a file changed between undo planning and writing is kept
- a restore replaces a symlink rather than writing through it
- untrusted projects and repos that try to opt themselves in
disableAllHooksand managed-only settings- http/prompt hooks next to command hooks
- undo still works after
git gc --prune=nowin the project and in the shadow repo - expired entries are reported as such
- age-based expiry takes pre-images along
0700/0600modes, and the user's hooks keep their own umask- symlinked state folders are refused
- index seeding refused for
index.lock, skip-worktree, assume-unchanged, untracked cache, sparse checkout and fsmonitor - linked worktrees and nested repositories
- a plugin path containing
$(), quotes and backticks ifforms- key-for-key comparison with captured native events
- rule symlinks that leave the rules folder, and symlink loops
Three of these tests were checked by mutation: undoing the fix makes them fail.
-
Second review (
test/test_review2.py):BASH_BRIDGE_PROJECT_HOOKS,CLAUDE_CONFIG_DIRandBASH_BRIDGE_STATE_DIRset through a project's settingsenvare ignoredBASH_BRIDGE_PYTHONis ignored in hook mode- the per-call cap is checked from object sizes before anything is read
- the project-wide store cap evicts the oldest entries
- a project config can only lower limits
- fd-relative restore and delete refuse symlinked parents and
.. - loose modes on existing state files are fixed, and symlinked state files refused
- index parsing: unknown extension, bad checksum, v4, intent-to-add
- overlapping Bash calls are flagged and not rolled back
The F13 and F16 tests were also checked by mutation.
-
Third review (
test/test_review3.py):- a call that ran and printed a denial message keeps the overlap (no rollback over its changes); only Claude Code's
toolDenialKindrecord counts,interrupteddoesn't - undo leaves a file alone, and everything below it, when the file replaced a folder and changed again
- a project config can't switch the guard, hooks, rules or undo off, can't set limits to 0 or below a floor, and turning bash-bridge off is announced
- one hook deadline for all files; the call stays registered while its hooks run
- registrations of exited processes, from the future, too old or too many are dropped; overlap marks don't extend their life
PostToolBatchdrops only its owntool_calls; malformed events change nothing- transcript parsing: other JSON spacing, a cut first record, the record cap, multi-result records
- git readers with
diff.external, textconv, an fsmonitor hook,-c,GIT_EXTERNAL_DIFF=,--ext-diff…, andless, get a snapshot - invalid config values fall back to defaults
- a
0600file stays0600after undo and guard rollback BASH_BRIDGE_COMMAND_TRUNCATED
Findings 1, 2 and 3 were reproduced first: the tests failed on the 0.2.0 code.
- a call that ran and printed a denial message keeps the overlap (no rollback over its changes); only Claude Code's
-
Safety: garbage on stdin, corrupt settings, no
gitonPATH, an unwritable state dir, a corrupt shadow repo, and no Python. In every case: exit 0 and nothing on stdout.
The project's own .git/index is checked byte for byte: it stays untouched.
Hook overhead in a small warm repo (0.2.0 measurement): about 400–500 ms for pre and post together with Homebrew Python 3.14, about 515–545 ms with macOS's /usr/bin/python3, start-up included. Clearly read-only commands cost about 0.1 s (no snapshot). Inside real sessions the pre hook took 38–48 ms of its own work, plus about 370 ms the first time while it creates the shadow repo.
test/e2e.py builds a fresh git project for every run. Each project has a .claude/settings.json with:
- a
PostToolUseWrite|Editlogging hook, - a
PreToolUseWrite|Edit|NotebookEditguard that deniesprotected.txt, - an
InstructionsLoadedlogger, which records what Claude Code loaded by itself.
The project also contains .claude/rules/api.md (paths: ["src/api/**"], which requires // RULE-MARKER-7731) and src/api/CLAUDE.md (which requires // NESTED-MARKER-4412). Each run is claude -p --setting-sources project,local --permission-mode auto, with and without --plugin-dir. All numbers come from files on disk and hook logs, not from the model's own report.
| Scenario (model, runs) | Without bash-bridge | With bash-bridge |
|---|---|---|
Edit with one sed -i, no reading first (haiku, 3 + 5) |
Write|Edit hook fired 0/3; rule marker 0/3; nested marker 0/3; Claude said "I hadn't been given any project instruction for that file" | hook fired 5/5 (2 synthesized events each); rule marker 5/5; nested marker 5/5; undo restored the file 5/5 |
| Edit asked to go through Bash, exploring allowed (haiku, 3 + 4) | hook 0/3; rule marker 0/3; nested marker 3/3, because the model ran find … -exec cat CLAUDE.md itself |
hook 4/4; rule marker 4/4; nested 4/4; undo 4/4 |
| Bulk rename, natural prompt (sonnet chose Bash for every step on its own, 1 + 1) | hook 0; Claude Code loaded nested CLAUDE.md no; nested marker missing |
hook fired for 4 synthesized events; nested marker present; undo restored the files |
#89251 guard: echo 'hacked' >> protected.txt (haiku, 3 + 4) |
guard called 0/3; protected.txt modified 3/3 | guard called 4/4; protected.txt restored 4/4; Claude replied "a project guard hook denies changes to protected.txt, and bash-bridge restored the file" |
#87575 sed truncation: sed -i '' '/BEGIN generated/,/END generatd/d' doc.md on an uncommitted file (haiku, 3 + 4) |
doc.md cut from 43 to 10 lines, 3/3; nothing to restore from (not committed, and /rewind doesn't cover Bash) |
truncated 3/4 (in one run the model refused to run the command); bash-bridge undo restored all 43 lines, 3/3 |
Opus 5.5, auto mode, natural multi-file prompt ("Rename the identifier users to userCount everywhere under src/api"; 1 + 1) |
Opus edited with the Write tool (2 Writes), so hooks fired natively (2) and Claude Code loaded the rule and nested CLAUDE.md itself |
Opus edited both files with Bash heredocs (cat > src/api/users.ts <<'EOF', 0 Edit/Write calls); 2 synthesized Write|Edit hook events and 2 guard checks; bash-bridge undo restored both files. Opus had read src/api/CLAUDE.md with cat before writing, so here the hooks and undo made the difference, not the rule injection |
/bash-bridge:bash-undo --dry-run, then /bash-bridge:bash-undo inside claude -p (haiku) |
– | the dry run listed the file without changing it; the undo restored README.md (2 of 2 runs after allowed-tools was added; one earlier run without it had the inline command blocked) |
When the model used Edit/Write on its own, both sides behaved the same and bash-bridge stayed quiet. That was haiku on the natural and bulk prompts, and sonnet on the natural prompt: hooks fired natively and Claude Code loaded the rules itself. With these models in claude -p --permission-mode auto, the Bash-first instruction didn't seem to be active. Haiku used Bash only when the prompt asked for it, while sonnet chose Bash by itself for the bulk rename. Opus 5.5 chose Bash heredocs for its edits in 1 of 2 runs of the same natural prompt, and used Write in the other.
After the review fixes, the sed -i (rule + nested + hooks), #89251 guard and #87575 undo arms were run again with haiku, 2 times each, with the same results: hooks 2/2, both markers 2/2, protected.txt restored 2/2, truncated doc.md restored 2/2.
A haiku smoke run after the second review fixes (sed -i + rules + hooks + undo) passed again: 2 synthesized hook events, both markers present, undo restored both Bash calls.
After the third review fixes (0.3.0), a haiku smoke run passed: sed -i arm with both markers, 2 synthesized hook events and undo; the #89251 guard restored protected.txt; the denied-guard arm (touch denied by --disallowedTools in the same batch as the echo, run with --persist so a transcript exists) cleared the denied call from the transcript's toolDenialKind record and rolled protected.txt back. Without a transcript (--no-session-persistence, e2e's default) the same arm is reported as overlapping and not rolled back, which is the intended conservative result. Cost: about $0.02.
Cost of all end-to-end runs: about $0.68 (52 harness runs, including 2 with Opus at $0.12–0.15 each, plus 13 short /bash-undo runs; haiku about $0.002–0.005 per run, sonnet $0.05–0.07).
claude plugin marketplace add JasonGDG/claude-bash-bridge
claude plugin install bash-bridge@claude-bash-bridgeRestart Claude Code. To try it without installing: claude --plugin-dir /path/to/claude-bash-bridge.
Nothing to do: it runs on every Bash call. To undo:
/bash-bridge:bash-undo --dry-run # what the last Bash call would undo
/bash-bridge:bash-undo # undo the last Bash call
/bash-bridge:bash-undo 3 # undo the last three, newest first
/bash-bridge:bash-undo list
The same from a terminal, run inside the project. The plugin's bin/ is on Claude's PATH. In your own shell, use the full path, for example ~/.claude/plugins/cache/claude-bash-bridge/bash-bridge/<version>/bin/bash-bridge:
bash-bridge list # recorded Bash calls and the files they changed
bash-bridge undo 2 --dry-run
bash-bridge undo --id 17 # one specific call
bash-bridge undo --force # also overwrite files that changed again since
bash-bridge statusUndo restores modified files with their content and mode, recreates deleted files and removes created ones. If a file changed again after that Bash call, through Edit, you or another command, undo skips it unless you pass --force.
~/.claude/bash-bridge.json, or <project>/.claude/bash-bridge.json, which takes precedence where it may. A cloned repository can't widen what bash-bridge runs, reads or stores, and can't switch its protection off piece by piece:
- A project file can't set
hooks,guard,rules,undo,project_hooks,sources,plugin_dirs,include_match_all,follow_rule_symlinks,excludes,disable_hours,max_untracked,max_files,max_file_bytes,max_bridged_files, the snapshot budgets (first_snapshot_ms,snapshot_ms,scan_ms), the hook deadlines (hook_timeout_s,hook_budget_s) or undo retention (keep_entries,undo_days,undo_max_bytes,store_max_bytes). Lower values there would make the project look "too big", skip files, let guards time out or drop undo data early. - It can only lower
hook_workers,context_charsandrule_chars(concurrency and output size), not below a floor. - A key it may not set is ignored, and the first Bash call of each session tells you and Claude which keys were ignored.
- It can turn bash-bridge off completely with
"enabled": false. Then the first Bash call of each session tells you and Claude: "bash-bridge is disabled by …/.claude/bash-bridge.json". The same goes forBASH_BRIDGE_DISABLEset in the project's settingsenv. - Every value is checked for type and range (numbers as numbers or numeric strings). An invalid value is replaced by the default, with a line in the debug log.
{
"enabled": true,
"hooks": true,
"guard": "revert",
"rules": true,
"rules_on_read": "auto",
"undo": true,
"include_match_all": false,
"max_files": 100000,
"max_untracked": 5000,
"max_file_bytes": 20971520,
"hook_timeout_s": 60,
"hook_budget_s": 180,
"hook_workers": 4,
"max_bridged_files": 50,
"disable_hours": 0,
"undo_days": 30,
"keep_entries": 300,
"store_max_bytes": 1073741824,
"project_hooks": "trusted",
"follow_rule_symlinks": false
}hook_budget_sis one deadline for all bridged hooks of a Bash call. It covers the hooks only: snapshots, lock waits, rollback and the undo log have their own timeouts, which together fit in the remaining 100 s (at most 200 of the post hook's 300 s). No hook starts after it, and running ones are stopped at it; you get a note naming the skipped hooks.hook_workers(1 to 16): files whose hooks run at the same time.disable_hours: a project that is too big or too slow (see Limits) is skipped for 10 minutes and then measured again. A value above 0 skips it for that many hours instead.guard:"revert"rolls back a change your PreToolUse Write/Edit hook denies;"report"only tells Claude;"off"skips it.rules_on_read:"auto","all"or"off".project_hooks:"trusted"(the default),trueorfalse.follow_rule_symlinks:truealso reads.claude/rulessymlinks that point outside the rules folder. Claude Code itself asks before loading those.
Environment variables override the files, and you can set them in settings.json under env:
BASH_BRIDGE_DISABLE=1turns everything off (silently when it comes from your environment; with a note when the project's settings set it).BASH_BRIDGE_HOOKS,BASH_BRIDGE_RULESandBASH_BRIDGE_UNDOtake0/1.BASH_BRIDGE_GUARDtakesrevert,reportoroff.BASH_BRIDGE_SOURCES=user,project,local,plugins,managedpicks the settings sources hooks are collected from.BASH_BRIDGE_PROJECT_HOOKS=1|0|trusteddecides whether project hooks run. Use1forclaude -pruns in folders that were never trusted interactively; it only counts when it comes from your environment, see Security.BASH_BRIDGE_PLUGIN_DIRSadds plugins you load with--plugin-dir.BASH_BRIDGE_MAX_FILESsets the file cap.BASH_BRIDGE_STATE_DIRdefaults to~/.claude-bash-bridge.BASH_BRIDGE_DEBUG=/path/logwrites a debug log; that's the only place errors go.BASH_BRIDGE_PYTHONpicks the interpreter for the CLI. Hooks ignore it and usepython3fromPATH.
Every BASH_BRIDGE_* variable, plus CLAUDE_CONFIG_DIR, is ignored when the project's own .claude/settings.json or settings.local.json sets it in its env block, because Claude Code exports those values to hooks. The exception is BASH_BRIDGE_DISABLE: a repository may turn bash-bridge off. CLAUDE_CONFIG_DIR and BASH_BRIDGE_STATE_DIR are also ignored when they point into the project.
-
Hooks run after the fact. A guard can't stop a Bash command before it runs, because nobody knows the target files in advance. bash-bridge rolls the file back right after the command. Side effects outside the files remain, and so does anything the command did with the content in between.
-
Only the project's work tree is watched. Files outside it are not snapshotted:
~/.zshrc,/tmp, other repositories. Neither are git-ignored files, files in submodules or nested repositories, files overmax_file_bytes(20 MB, tracked or not: they are reported as "too large" and can't be undone), or files thatassume-unchanged/ skip-worktree hides. A symlink is tracked as a link, so writes through it to a target outside the project are not seen. -
Big projects. Over
max_files(100,000 tracked files), or when a snapshot takes longer than its budget (8 s for the first, 3 s later), bash-bridge pauses for that project for 10 minutes (disable_hoursfor longer) and tells you and Claude, at most every 10 minutes per session. The shadow repo is kept. A folder without git gets a 400 ms file-count scan first; because every file there is untracked, it pauses when the folder has more thanmax_untrackedfiles. A home directory, for example, pauses right away. -
Many untracked files. With more than
max_untracked(5000) files that are neither tracked nor ignored (an unignorednode_modules, say), only the files in git's index are snapshotted for that call: new or changed untracked files get no hooks, rules or undo. You and Claude are told, at most every 10 minutes per session. Nothing is switched off; once the count drops, untracked files are covered again. -
Attribution. Changes from background processes or other programs during a command can land in that command's entry.
run_in_backgroundcommands are skipped. When two Bash calls in one work tree are between their pre and post hooks at the same time, both are flagged[overlapped]inlist. That holds across sessions too, on purpose: two sessions in the same work tree write the same files. A guard deny is then reported to Claude but not rolled back, because bash-bridge can't tell whose write it was. A call whose pre hook couldn't get the lock counts as well (it runs, unsnapshotted). A call stays registered from its pre hook until its post hook is completely done. A call that never gets a post hook (denied by a rule, a hook, the user or auto mode) is cleared by:PostToolBatch, for exactly the calls listed in itstool_calls;PermissionDenied, which Claude Code fires only for auto mode denials;- the transcript: each JSONL record of the last 4 MB (at most 20,000 records, the cut first record dropped) is parsed, and a call counts as denied only if its
tool_resultrecord carries Claude Code's owntoolDenialKindmarker (permission-rule,user-rejected,automode-blocked,automode-unavailable) withis_error. Text a command prints can't fake that. A result older than that window, or inside a single line over 4 MB, is not seen; the call then keeps counting as in flight, which only means no rollback.
A
tool_use_idwith more than one result record counts as unknown, never as denied. Otherwise a registered call stops counting after 120 s, or earlier when the Claude Code process that started it has exited: the entry stores that process's pid and start time (ps -o lstart=), and a pid that is gone or now has another start time means exited. When the identity is unknown (the hook's parent is a shell, orpsfailed), the entry simply counts for the 120 s. Registrations older than an hour, dated in the future (clock jump) or beyond 200 are deleted on every pre and post hook. -
Command hooks only.
http,mcp_tool,promptandagenthooks, hooks from skill or agent frontmatter, and PowerShell hooks are not bridged. Plugins loaded with--plugin-dirare invisible unless you list them inBASH_BRIDGE_PLUGIN_DIRS. Runtime choices are invisible to a hook:--setting-sources,--settings '{"disableAllHooks": true}'. So bash-bridge can run a hook that Claude Code disabled for that one process; mirror such flags withBASH_BRIDGE_SOURCES/BASH_BRIDGE_DISABLE. This is a known limit until Claude Code tells hooks which sources are active. -
Rules arrive one step late: after the Bash write, not before. Claude then fixes the file, which is what the end-to-end runs show. The glob matcher mirrors Claude Code's documented syntax but isn't its code. For nested
.claude/rules, globs are relative to the folder that contains the.claudefolder; that's an assumption. -
Synthesized
Editpayloads carry the changed line range, not Claude's exactold_string. Hooks that rebuild a patch from it see a line-level diff. -
File modes. git stores only the executable bit. A modified file keeps its permission bits on undo and on guard rollback (the bits it had after the command, which a file edited in place keeps). A deleted file comes back with
0666/0777minus your umask. -
Case-only renames (
mv Readme.txt README.txt) on a case-insensitive file system (the macOS default) are not seen: git reports no change. -
Undo covers Bash changes only. Edit/Write changes are still for
/rewind. Undo does not depend on git objects. The previous content of every changed file is copied intoblobs/(zlib,0600, at most 200 MB per call) and deleted together with its entry. An entry is dropped after 30 days or when the log grows past 300 calls. Entries whose content is no longer stored show as[expired]inlistand asEXPIREDinundo, and nothing is touched for them. Old snapshot trees in the shadow repo are pruned by a backgroundgit gc(14 days, at most every 3 days); detection only needs the current trees. -
Guard rollback is conservative. A denied file is rolled back only if it still holds exactly what the Bash command wrote and no other call overlapped. Undo checks each file again, under the lock, right before writing it. Both work relative to opened directory descriptors (
O_NOFOLLOWper path component, then temp file plusrenameinside that directory). A symlink swapped into the path can't redirect them out of the project. Programs outside bash-bridge don't take its lock, though: a write that lands in the microseconds between the last check and therenameis overwritten. -
Hook concurrency is
hook_workersfiles at a time, and each file's matching hooks run at once, so up tohook_workersx (hooks per file) processes. -
Pre-image storage is bounded. Sizes are checked before content is read, copying streams in 1 MB chunks, at most 200 MB is kept per call, and at most 1 GB (
store_max_bytes) per project, with the oldest entries expiring first. Files above a cap are listed as[expired]rather than half-saved.
bash-bridge executes hook commands. That makes it a security boundary, so it is stricter than "whatever is in the files".
Threat model. bash-bridge protects against mistakes (a wrong sed, a Bash route around your Write/Edit guards) and against untrusted repository content (settings, config, rules, git config in a clone). It does not protect against a deliberately adversarial model: a Bash command can already change any file the model can write, including the session transcript bash-bridge reads to see denied calls, its state folder, or your settings. Anything that can write the transcript file is out of scope.
-
Hooks from
.claude/settings.jsonand.claude/settings.local.jsonare code from the repository. They run only if one of these holds:- Claude Code has recorded the workspace trust dialog as accepted for the project or a parent folder (
hasTrustDialogAccepted). The file read is the account's real~/.claude.json, from the password database rather than$HOME, and$CLAUDE_CONFIG_DIR/.claude.jsononly when the project didn't set that variable and it doesn't point into the project. As in Claude Code, a trusted parent folder covers everything below it: trusting$HOMEtrusts every repository you clone under it. - You opted in:
project_hooks: truein your own~/.claude/bash-bridge.json, orBASH_BRIDGE_PROJECT_HOOKS=1in your environment.
claude -pskips the trust dialog, so throwaway folders used with-pneed that opt-in. A repository can't opt itself in, through itsbash-bridge.jsonor through its settingsenv. Plugins enabled only in an untrusted project's settings are ignored too. - Claude Code has recorded the workspace trust dialog as accepted for the project or a parent folder (
-
disableAllHooks(any level; managed = everything) andallowManagedHooksOnlyare honoured. Runtime flags are not visible to hooks, see Limits. -
Bridged hooks inherit the full Claude Code environment, as native hooks do, plus
BASH_BRIDGE_ACTIVE=1,CLAUDE_PROJECT_DIRand the plugin variables. There is no allowlist. A hook that Claude Code would have skipped because of a runtime flag (see Limits) therefore runs with your credentials in its environment. -
Environment variables a repository exports through direnv (
.envrc, once youdirenv allowit) look exactly like your own environment, so they can setBASH_BRIDGE_GUARD=offorBASH_BRIDGE_PROJECT_HOOKS=1. Don'tdirenv allowrepositories you don't trust. -
The undo store holds old file contents, which can include secrets you deleted. It is private (
0700/0600), owner-checked and never a symlink. Deleting~/.claude-bash-bridgeclears it. -
Rule files reached through symlinks that leave
.claude/rulesare not injected unless you allow it.
claude plugin uninstall bash-bridge@claude-bash-bridge
claude plugin marketplace remove claude-bash-bridge
rm -rf ~/.claude-bash-bridge # snapshots and undo logstest/run.sh # unit + integration tests
PATH=/usr/bin:$PATH PYTHON=/usr/bin/python3 test/run.sh # with macOS's Python 3.9
python3 test/e2e.py /tmp/bb-e2e --model haiku # real claude -p runs, costs a few cents
python3 test/e2e.py /tmp/bb-e2e --only denied-guard-bridge --persist # keeps transcripts
claude plugin validate --strict .MIT licensed.