Skip to content

Releases: karlkfi/claude-bouncer

workspace-guard 1.13.0

Choose a tag to compare

@karlkfi karlkfi released this 09 Oct 05:00
816a09a

Minor release: the guard now reads through command wrappers such as env, sudo and xargs, judges the commands a find runs, and tracks a cd the way bash scopes it, so a guarded command behind any of them gets the decision it would get typed bare. Eight shell shapes that hid a command from the guard no longer do.

Highlights

A guarded command behind a wrapper is judged as if typed bare. The guard now peels env, nice, nohup, timeout, stdbuf, setsid, ionice, time, xargs, exec, command, builtin, sudo, doas, caffeinate, chrt, taskset and flock by their own option tables. env cat /outside/f and sudo cat /outside/f used to defer, and now ask. command cd /outside && cat f tracks the cd and asks where it was allowed. Files a wrapper opens itself are checked too: sudo -e writes its operands and flock creates its lock file, so flock /tmp/lock make now denies the way any host-wide temp write does. Put the lock under the project or the session scratchpad instead.

A wrapper the guard cannot read past escalates in the pre-approving modes. A wrapper flag the guard does not know, or an xargs that takes its operands on stdin, withholds allow. Where the command behind it is one the guard judges, that now asks in auto and acceptEdits and denies in bypassPermissions, the same as an unreadable sh -c body. git ls-files | xargs grep foo is one of those. ls | xargs echo is not, because echo is not a guarded command. WORKSPACE_GUARD_ESCALATE=off restores the silence.

find -exec is judged with {} standing for each start point. The command behind -exec, -execdir, -ok or -okdir gets the decision it would get run against the start points, so find /outside -name hosts -exec cat {} \; asks where it used to defer. find /outside -delete and -fprint /outside/f ask too.

A cd moves the tracked directory only where bash would move it. A cd inside ( … ), a bare $( … ), a function body, or a { … } group, loop or if that is piped or backgrounded no longer moves the directory for the commands after it. Each of these used to place a later relative path in the wrong directory. (cd sub) && cat ../secret was allowed, and it now asks. A call to a function that runs a cd denies a later relative path with the literal-cd rewrite, because the guard cannot see where the call left the shell. A case pattern's x) no longer closes the subshell around it. A substitution inside a here-string is checked from the directory its command runs in. On the PowerShell tool, a $(…) after a Set-Location is checked in the directory it moved to.

Eight shell shapes stop hiding a command from the guard. Each of these shapes used to make the guard misread where a command started or ended, and so defer or allow the command after it. They are: an array-element prefix such as FOO[0]=x, an ANSI-C string such as $'\'', a # inside backticks or ${...}, a # straight after a substitution's close, a nested backtick, and a quote inside a double-quoted substitution. A command inside a process substitution, true >(rm -rf ../x), is now judged like the same command written plainly. A file operand that holds a backtick substitution, cat `cat list.txt` , denies as the $(…) spelling already did.

Three prompts stop firing. Two were about files the command never opens. After (cd ..), cat in.txt reads the project's own file, because the subshell's cd never reached the outer shell. cd sub && cat <<<"$(cat ../in.txt)" reads inside the project too. The third was a false deny: BSD sed's in-place form, sed -i '' "s/old/$new/" in.txt, read the empty suffix as the script and denied the variable in it as an unexpandable path. It is allowed now.

A deny names the writes it discarded. A deny refuses the whole call, so every file write in it never happened. The reason now lists them: a > redirect, tee, sed -i, cp, mv, or a heredoc script fed to an interpreter.

Decision surface

Measured by running the v1.12.1 hook and this one as subprocesses over the same throwaway project, holding in.txt, list.txt and an empty sub/, with no WORKSPACE_GUARD_* variable set unless the row says so. macOS. defer means the guard expressed no opinion and your own permission settings decided.

command mode v1.12.1 v1.13.0
env cat /outside/f default defer ask
sudo cat /outside/f default defer ask
command cd /outside && cat f default allow ask
git ls-files | xargs grep foo auto defer ask
git ls-files | xargs grep foo bypassPermissions defer deny
flock /tmp/lock make default defer deny
find /outside -name hosts -exec cat {} \; default defer ask
(cd sub) && cat ../secret default allow ask
f() { cd sub; }; f; cat in.txt default allow deny
cat `cat list.txt` default allow deny
true >(rm -rf ../x) default defer ask
echo ${x:- #} ; cat /outside/f default defer ask
sudo cat in.txt default defer allow
(cd ..) && cat in.txt default ask allow
cd sub && cat <<<"$(cat ../in.txt)" default ask allow
sed -i '' "s/old/$new/" in.txt default deny allow
git ls-files | xargs grep foo (ESCALATE=off) auto defer defer
ls | xargs echo auto defer defer
cat /outside/f default ask ask
echo x > /var/tmp/f default deny deny

Twelve rows gain scrutiny and four lose it. Three of the four are the withdrawn prompts above, about files the command never opens or a path that was never unexpandable. The fourth, sudo cat in.txt, used to defer because the guard could not see past sudo; reading through it, the guard finds a read inside the project and allows it, the same as cat in.txt typed bare.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update workspace-guard@claude-bouncer

A restart is needed. The hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. No variable is new or renamed. WORKSPACE_GUARD_ESCALATE now also covers the wrapper and xargs suppressions, and =off turns them off with the rest.

Expect new prompts on commands behind a wrapper, and on relative paths after a scoped cd. Both are reads the guard used to miss. If you work in auto or acceptEdits, an xargs feeding a guarded command now asks. Where that is routine for you, WORKSPACE_GUARD_ESCALATE=off puts it back without turning the guard off. If a script takes a flock under /tmp, move the lock file into the project or the session scratchpad.

Everything since v1.12.1

Wrappers and find

  • fix(workspace-guard): read through command wrappers (Q219) by @karlkfi in #184
  • fix(workspace-guard): peel sudo, doas, caffeinate, chrt, taskset and flock (Q236) by @karlkfi in #259
  • fix(lib): read sudo -h as a host before a plain word (Q158) by @karlkfi in #261
  • fix(workspace-guard): judge the commands a find runs, with {} as its start points (Q237) by @karlkfi in #243

Where a cd applies

  • fix(workspace-guard): keep a subshell's cd inside the subshell (Q173) by @karlkfi in #217
  • fix(workspace-guard): scope a function body's cd to the body, and unknown at a call (Q265) by @karlkfi in #232
  • fix(workspace-guard): scope a cd in a piped or backgrounded compound command to it (Q272) by @karlkfi in #242
  • fix(workspace-guard): read a case pattern's paren as pattern syntax, not a subshell (Q273) by @karlkfi in #249
  • fix(workspace-guard): place a substitution in a here-string operand (Q177) by @karlkfi in #222
  • fix(workspace-guard): place no body by the start cwd once a cd has moved it (Q266) by @karlkfi in #248 — a relative path in a substitution, in a command that also holds a 0x1E byte and a cd or Set-Location, now denies as untracked where it was silently allowed or wrongly asked.
  • fix(workspace-guard): resolve a PowerShell subexpression where it sits (Q172) by @karlkfi in #208

Commands that hid from the guard

  • fix(lib): read NAME[sub]=value as an assignment, not a command name (Q214) by @karlkfi in #157
  • fix(lib): read a subscript whole wherever an assignment can stand (Q217) by @karlkfi in #172
  • fix(lib): read ANSI-C quoting the way bash does (Q225) by @karlkfi in #177
  • fix(lib): unescape a backtick body before returning it (Q226) by @karlkfi in #179
  • fix(lib): step over a comment inside a $( ) substitution (Q230) by @karlkfi in #207
  • fix(lib): keep a # after a substitution's close in the word (Q264) by @karlkfi in https://github.c...
Read more

prod-guard 2.7.0

Choose a tag to compare

@karlkfi karlkfi released this 09 Oct 05:00
816a09a

Minor release: wrappers, comments, quoting and shell keywords that hid a production command from the guard are now read through, and a kubeconfig the command names counts as a pinned target. Thirteen of the shapes measured below passed with no prompt at all on v2.6.0 and now deny or ask. Four move the other way: an override that now arms, a wrapper's --help that runs nothing, and two non-production targets the old hook could not read.

Highlights

More wrappers stop hiding the command behind them. nice, ionice, setsid, chrt and taskset are peeled like sudo and timeout. Wrapper options are now one table shared across the guards, so a value-taking flag is skipped with its value however it is spelled: bundled (sudo -nu root, env -iu FOO, timeout -vk 5 10), abbreviated (timeout --kill 5, sudo --us root), or newly listed (sudo -r/-t/-a/-c, GNU env -a). sudo FOO=1 kubectl …, an env operand that is not a shell identifier (env 'a b=c'), and a split string behind a bundled env -iS are read through too. Every one of these let a mutating command against a production target run unprompted.

Shell syntax stops hiding the command. A command inside { …; }, an if/then or loop body, behind !, or in a function body is judged as the bare command is. So is a command substitution inside double quotes (echo "$(kubectl … delete ns x)") or inside an unquoted heredoc body. A # comment ends at its own newline and no longer swallows the lines after it, including when the comment holds a $(, a backtick or an apostrophe. A # inside backticks or ${…}, an ANSI-C string such as $'\'', an escape-spelled name such as $'\x6bubectl', and a subscripted prefix such as FOO[0]=x no longer hide what follows them.

A kubeconfig the command names is a pinned target. kubectl, oc, helm and flux now read --kubeconfig <absolute path>, or a KUBECONFIG the command exports or prefixes, and take that file's current-context as the target. A non-production context stops drawing the pin---context deny. A production one is still denied, and the reason now names the context instead of asking you to pin one.

Variables resolve the way bash runs them. A variable an eval body sets carries to the commands after it, so eval 'C=<prod context>'; kubectl --context $C … denies instead of asking. A single-quoted bash -c body sees what env or sudo hands it, and loses what env -i or env -u takes away. A prefix append such as AWS_PROFILE=$P AWS_PROFILE+=x expands $P before joining, where v2.6.0 read a different variable named Px. An unquoted --context=$(…) now asks as an unresolved target instead of passing.

The override arms from an env or sudo operand. env PROD_GUARD_OVERRIDE=<reason> kubectl … and sudo PROD_GUARD_OVERRIDE=<reason> kubectl … downgrade a deny to a prompt, the same as the bare prefix, and so does PROD_GUARD_SESSION_OVERRIDE. An exported override still does nothing. Written as PROD_GUARD_OVERRIDE[0]=<reason> it does not arm, because bash never sets the variable.

A wrapper's own --help or --version runs nothing, so it is not judged. sudo --help kubectl delete ns foo, sudo -V … and env --version … print and exit, and now defer instead of denying. sudo -h HOST is read as a host, as sudo before 1.9.17p1 ran it, and the command behind it is judged; sudo -h -n … is help.

A deny names the writes it discarded. A deny refuses the whole call, so a > redirect, tee, sed -i, cp/mv or heredoc script in the same command never happened. The reason now ends by listing them.

The hook launches through run-python-hook.cmd. On Windows machines where python3 is only the Microsoft Store stub, the guard now finds a working Python and enforces. Before, it let every command through without a word. If no working Python exists, it says so on stderr.

Decision surface

Measured by running the v2.6.0 hook and this one over the same payloads and recording the emitted permissionDecision. Each hook was launched the way its own hooks.json launches it, python3 for v2.6.0 and run-python-hook.cmd for v2.7.0, with an empty $HOME, no PROD_GUARD_* variables and no config file. defer means the guard expressed no opinion and your own permission settings decided. acme-production matches a built-in production pattern and kind-ci a built-in non-production one. A kind-ci kubeconfig is a throwaway file, named by absolute path, whose current-context is kind-ci, and a production kubeconfig the same with acme-production.

command v2.6.0 v2.7.0
nice kubectl --context acme-production delete ns x defer deny
timeout --kill 5 10 kubectl --context acme-production delete ns x defer deny
sudo -nu root kubectl --context acme-production delete ns x defer deny
sudo FOO=1 kubectl --context acme-production delete ns x defer deny
env -iS 'kubectl --context acme-production delete ns x' defer deny
echo "$(kubectl --context acme-production delete ns x)" defer deny
# note ⏎ kubectl --context acme-production delete ns x defer deny
{ kubectl --context acme-production delete ns x; } defer deny
$'\x6bubectl' --context acme-production delete ns x defer deny
FOO[0]=x kubectl --context acme-production delete ns x defer deny
P=acme-; Pproduction=kind-ci; AWS_PROFILE=$P AWS_PROFILE+=production aws s3 rm s3://b/k defer deny
kubectl --context=$(cat ctx) delete ns x defer ask
sudo PROD_GUARD_OVERRIDE=why kubectl --context acme-production delete ns x defer ask
eval 'C=acme-production'; kubectl --context $C delete ns x ask deny
env C=acme-production bash -c 'kubectl --context "$C" delete ns x' ask deny
env PROD_GUARD_OVERRIDE=why kubectl --context acme-production delete ns x deny ask
env C=kind-ci bash -c 'kubectl --context "$C" delete ns x' ask defer
kubectl --kubeconfig <kind-ci kubeconfig> delete ns x deny defer
sudo --help kubectl --context acme-production delete ns x deny defer
kubectl --kubeconfig <production kubeconfig> delete ns x deny deny
kubectl --context acme-production delete ns x deny deny
kubectl --context kind-ci delete ns x defer defer

Fifteen rows gain scrutiny and four lose it. Thirteen of the fifteen were silent on v2.6.0, and twelve of those thirteen are a mutating command against a production target that ran with no prompt. One of the twelve is the sudo PROD_GUARD_OVERRIDE row, which now prompts the way the bare prefix does. Of the four that lose scrutiny, env PROD_GUARD_OVERRIDE is the break-glass arming as intended, and it still prompts. The other three run nothing, or name a non-production target the old hook could not read. The production-kubeconfig row keeps its deny, but the reason moves from "pin --context" to naming acme-production.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update prod-guard@claude-bouncer

A restart is needed. The hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed, and no configuration key changed name or default. PROD_GUARD_OVERRIDE, PROD_GUARD_SESSION_OVERRIDE, PROD_GUARD_PROD_PATTERNS, PROD_GUARD_NONPROD_PATTERNS and .claude/prod-guard.json behave as they did. The environment-variable surface is identical to v2.6.0's. Python 3 is still the only requirement, now under any of the names the launcher tries.

Expect denials where you were getting silence, and they are the release. If one is routine for you against a cluster the heuristic is calling production wrongly, add it to PROD_GUARD_NONPROD_PATTERNS or to .claude/prod-guard.json rather than reaching for the override each time.

A kubeconfig you name by absolute path is now how you pin. If you wrote --context only to satisfy the pin---context deny while also passing --kubeconfig, the context in that file is enough. KUBECONFIG=<path>; kubectl … with no export still denies, because bash never hands that value to kubectl.

Everything since v2.6.0

Wrappers no longer hide the command

  • fix(prod-guard): peel nice, ionice, setsid, chrt and taskset (Q229) by @karlkfi in #210
  • fix(prod-guard): decide a bundled wrapper flag's arity per character (Q200) by @karlkfi in #159
  • fix(prod-guard): read an abbreviated long wrapper option's value (Q228) by @karlkfi in #189
  • refactor(lib): share the wrapper option grammar and arity walk (Q233) by @karlkfi in #241
  • fix: peel env and sudo operands by the programs' own rule (Q218) by @karlkfi in #221
  • fix(prod-guard): peel sudo's NAME=value operands (Q240) by @karlkfi in #256
  • fix(prod-guard): read a bundled env -S, as in env -iS (Q287) by @karlkfi in #260
  • fix(lib): read sudo -h as a host before a plain word (Q158) by @karlkfi in #261

Shell syntax no longer hides the command

  • fix(prod-guard): a reserved word in front no lon...
Read more

foreground-guard 0.8.0

Choose a tag to compare

@karlkfi karlkfi released this 09 Oct 05:00
816a09a

Minor release: a blocking command hidden behind a wrapper flag, a prefix, a substitution or a comment now denies, and a command that bash backgrounds or never runs no longer does. One config key gained an object form, and the plain-string form still works.

Highlights

A wrapper or a prefix no longer hides the command behind it. The guard used to stop reading at a wrapper flag it did not expect, and the command it was there to catch ran unjudged. It now reads the flag that takes a separate value (stdbuf -o L, exec -a name, /usr/bin/time -o f), the bundled or abbreviated one (sudo -nu root, env -iu FOO, sudo --us root), the string env -S splits (env -S 'sleep 300'), an env or sudo operand that is not a shell identifier (env 'a b=c'), a sudo option written after an assignment (sudo A=1 -u root), and sudo -h HOST. A prefix that assigns an array element, FOO[0]=x, also no longer hides what follows it. Each of these denies a watch, a long sleep, or a registered slow command where it used to defer. bash's noexec option is read last-wins too, so bash -n +n scripts/gate.sh runs the script and denies.

A substitution or a comment no longer hides a command. A watch inside a double-quoted "$(...)" or a backtick substitution, or in an unquoted heredoc body, is now judged. So is a command on the line after a # comment, which used to be read as part of the comment, including a comment that holds a $(, a backtick or an apostrophe. Several quoting shapes the shared parser misread now reach the guard as well: an ANSI-C string like $'\'', a # straight after <(true), a # inside ${x:- #}, and a comment with an apostrophe inside a $( ). All of them hid the next command and passed it unjudged.

A trailing & exempts what bash backgrounds, and nothing else. sleep 300 && ls & make build backgrounds the whole and-or list, so it defers now where it used to deny. sleep 300; ls & backgrounds only ls, so it denies again. A & at the end of a line or inside (sleep 300 &) is read as backgrounding, a substitution in a backgrounded command (echo $(gh run watch 1) & ls) defers, and a registered slow command run as scripts/gate.sh & is no longer denied for its timeout.

A command that never runs is not denied. nohup NAME=v cmd makes nohup look for a program called NAME=v, so cmd never starts. The same holds after command, stdbuf, exec, builtin, unbuffer and /usr/bin/time. time NAME=v cmd with bash's time keyword does run cmd and still denies. sudo -l, -v, -e, -K, -U and -V run no command, sudo -h <word> takes the word as a host, and sudo -- A=1 cmd runs a program named A=1, so the guard no longer denies what is written after any of them.

An extra watch pattern can name itself and teach its own snapshot. A poll.extra_watch_patterns entry may now be an object, {"pattern": …, "label": …, "alternative": …}. The deny then names the tool by its label and offers your snapshot command as the first fix, the way a built-in watch does. A plain string still works and is labelled with the regex itself. Its generic first fix now reads "take one non-blocking snapshot".

On Windows the hook runs where python3 is only the Store stub. The hook used to be launched as python3. On a Windows machine where that name is the Microsoft Store alias, the launch failed, Claude Code treated the failure as non-blocking, and every command went through. It now starts through run-python-hook.cmd, which tries py -3, python and python3 by running each, and says so on stderr when none works.

A deny names the writes it discarded. A deny refuses the whole call, so a > out.txt redirect, tee, sed -i, cp/mv or a heredoc script in it never happened. The reason now ends by listing them, as every guard's does.

Decision surface

Measured by running the v0.7.0 hook and this one over the same payloads and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. The registered rows carry {"slow": {"commands": {"scripts/gate\\.sh": 3600000}}}, the object row carries {"poll": {"extra_watch_patterns": [{"pattern": "mytool\\s+follow", "label": "mytool follow", "alternative": "mytool status"}]}}, the string row carries the same pattern as a plain string, and the rest carry no config file.

command v0.7.0 v0.8.0
stdbuf -o L gh run watch 1 defer deny
sudo -nu root gh run watch 1 defer deny
env 'a b=c' sleep 300 defer deny
env -S 'sleep 300' defer deny
sudo A=1 -u root sleep 300 defer deny
FOO[0]=x sleep 300 defer deny
echo "$(gh run watch 1)" defer deny
sleep 300; ls & defer deny
bash -n +n scripts/gate.sh (registered) defer deny
mytool follow (object) defer deny
echo $(gh run watch 1) & ls deny defer
sleep 300 && ls & make build deny defer
(sleep 300 &) deny defer
nohup NAME=v sleep 300 deny defer
sudo -l sleep 300 deny defer
scripts/gate.sh & (registered) deny defer
mytool follow (string) deny deny
time NAME=v sleep 300 deny deny
scripts/gate.sh (registered) deny deny
sleep 300 deny deny
sleep 300 & defer defer
ls -la defer defer

Ten rows gain a denial and six lose one. A default install with no config file sees thirteen of those sixteen move: the bash -n +n and scripts/gate.sh & rows need a registered slow command, and the object row needs the object entry.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update foreground-guard@claude-bouncer

A restart is needed. The hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. The object form of poll.extra_watch_patterns is additive: a plain string entry denies exactly as it did. v0.7.0 skips an object entry without a word, though, so a project config that uses one guards nothing for a teammate still on v0.7.0. The environment-variable surface is identical to v0.7.0's.

The hook command in hooks/hooks.json changed from python3 … to the run-python-hook.cmd launcher. Off Windows it tries python3, then python, so any working Python 3 under either name is enough.

If you pasted the reduce-foreground-guard-prompts playbook into a CLAUDE.md, paste it again. It now says the guard denies by default rather than prompting.

This plugin is pre-1.0 and its surface is unstable by design. Nothing breaks in this release, but a break here would ride in a minor rather than waiting for a major, and it would be stated in these notes when it happens.

Everything since v0.7.0

Blocking commands that used to get through

  • fix(foreground-guard): skip a wrapper flag's separate value (Q160) by @karlkfi in #169
  • fix(foreground-guard): read noexec last-wins (Q163) by @karlkfi in #170
  • fix(foreground-guard): judge command substitutions inside double quotes (Q220) by @karlkfi in #175
  • fix(foreground-guard): take a bundled or abbreviated sudo/env flag's value (Q221) by @karlkfi in #190
  • fix: peel env and sudo operands by the programs' own rule (Q218) by @karlkfi in #221
  • fix(foreground-guard): end a comment at its newline (Q267) by @karlkfi in #230
  • refactor(lib): share the wrapper option grammar and arity walk (Q233) by @karlkfi in #241
  • fix(foreground-guard): judge the command inside an env -S string (Q246) by @karlkfi in #253
  • fix(lib): read sudo -h as a host before a plain word (Q158) by @karlkfi in #261
  • fix(exit-status-guard,foreground-guard): read a sudo option after an assignment operand (Q275) by @karlkfi in #267

Shared-parser fixes this guard now picks up

  • fix(lib): read NAME[sub]=value as an assignment, not a command name (Q214) by @karlkfi in #157
  • fix(lib): read a subscript whole wherever an assignment can stand (Q217) by @karlkfi in #172
  • fix(lib): read ANSI-C quoting the way bash does (Q225) by @karlkfi in #177
  • fix(lib): unescape a backtick body before returning it (Q226) by @karlkfi in #179
  • fix(lib): step over a comment inside a $( ) substitution (Q230) by @karlkfi in #207
  • fix(lib): keep a # after a substitution's close in the word (Q264) by @karlkfi in #214
  • fix(lib): read no comment inside backticks or ${...} (Q254) by @karlkfi in #235
  • fix(lib): close no double quote at a quote inside a substitution (Q257) by @karlkfi in #250
  • fix(lib): open no substitution or quote inside a top-level comment (Q258) by @karlkfi in https://github.com/karlkfi/cl...
Read more

exit-status-guard 2.2.0

Choose a tag to compare

@karlkfi karlkfi released this 09 Oct 05:00
816a09a

Minor release: a gate hidden behind a wrapper's options, a process substitution or a (…) subshell is now denied, set -o pipefail counts only where bash keeps it on for the pipe, and the override arms behind env or sudo.

Highlights

A gate behind a wrapper's options or operands is judged. The guard used to peel a wrapper's name and stop, so sudo -n make check | tail -5 and stdbuf -oL make check | tail -5 passed with no verdict. It now reads through the options the way the wrapper does, using one shared table of which options take a value: sudo -r/-t/-a/-c, GNU env -a, an abbreviated long option like sudo --us root, sudo -h HOST, a sudo option written after a NAME=value operand, and an env operand that is not a shell identifier (env 'a b=c', env 1=x). env -S 'make check' | tail -5 is split and judged too. A wrapper mode that runs nothing still says nothing: command -v make | head and sudo -l make check | tail -5 defer as before, and sudo -- A=1 make check runs a program named A=1, as sudo does.

set -o pipefail counts only where bash keeps it on for the pipe. Any set -o pipefail anywhere in the command used to lift the denial on a piped gate. It no longer does when the setting comes after the pipe, sits inside a subshell that has ended, is turned off again with set +o pipefail, or is written as set -e pipefail. A set that bash runs in a process of its own does not count either: a pipeline stage (set -o pipefail | true), a backgrounded list, a piped or backgrounded { …; }, if, case or loop, or a function nobody called. Two shapes go the other way and lose a denial: a pipe inside a later $(…), which inherits the option (set -o pipefail; out=$(make check | tail -5)), and a function that sets it and is then called (function f { set -o pipefail; }; f).

A gate inside a process substitution is denied. The shell reports a <(…) body's status nowhere, so tail <(make check) turned a failing check into a 0. That is a fourth rule, with its own README section. A gate or state change written in a (…) subshell around a ; is also seen now, so (make check); git push denies like the unwrapped form.

The override arms behind env or sudo. env EXIT_STATUS_GUARD_OVERRIDE=why make check | tail -5 was denied, because the guard only looked for the variable as a leading assignment. Both programs set the variables their operands name, so the override now counts there, as it already did in foreground-guard.

Quoting and comments no longer hide the command after them. Several shared-parser fixes close shapes where a command written after some quoting passed unjudged: a subscripted prefix (FOO[0]=x, FOO[a;b]=x), an ANSI-C string such as $'\'', a # inside backticks or ${...}, a # straight after a substitution's close, and a substitution in double quotes that holds a quote character (echo "$(printf '%s"' x)" ; make check | tail -5). Each of those now reaches the piped-gate rule.

git apply counts as a restore. The capture-and-recheck form the sequencing denial recommends now runs as written with git apply as the restore step.

Denials say more about what did not happen. When a ; sequence starts with set -e, the reason now explains that the Bash tool ignores errexit, because it runs the command inside an eval in an && list, so set -e does not stop the second command. Every denial also ends by naming the file writes in the refused command, such as a > redirect or a tee, because a deny discards the whole call and those writes never happened.

Decision surface

Measured by running the v2.1.0 hook and this one over the same payloads and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. All rows are foreground calls.

command v2.1.0 v2.2.0
sudo -n make check | tail -5 defer deny
sudo --us root make check | tail -5 defer deny
sudo -h host make check | tail -5 defer deny
sudo A=1 -u root make check | tail -5 defer deny
env 'a b=c' make check | tail -5 defer deny
env -S 'make check' | tail -5 defer deny
make check | tail -5; set -o pipefail defer deny
set -o pipefail | true; make check | tail -5 defer deny
tail <(make check) defer deny
(make check); git push defer deny
FOO[0]=x make check | tail -5 defer deny
echo ${x:- #} ; make check | tail -5 defer deny
set -o pipefail; out=$(make check | tail -5) deny defer
function f { set -o pipefail; }; f; make check | tail -5 deny defer
env EXIT_STATUS_GUARD_OVERRIDE=why make check | tail -5 deny defer
make check > log 2>&1; rc=$?; git apply p.diff; [ "$rc" -ne 0 ] || exit 1 deny defer
make check | tail -5 deny deny
make check > log 2>&1; rc=$?; git push; [ "$rc" -ne 0 ] || exit 1 deny deny
set -o pipefail; make check | tail -5 defer defer
sudo -l make check | tail -5 defer defer

Twelve rows gain a denial and four lose one. Every addition is a piped or sequenced gate that v2.1.0 failed to see, because a wrapper, a misplaced pipefail, a process substitution or a stretch of quoting hid it. The four withdrawals are commands that keep the status: pipefail really is on for the pipe, the override was armed, or the restore runs inside the capture-and-recheck form.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update exit-status-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. EXIT_STATUS_GUARD_OVERRIDE, EXIT_STATUS_GUARD_REGISTRY and .claude/exit-status-guard.json keep their names and their defaults, and the environment-variable surface is identical to v2.1.0's. The 1.x spellings — PIPE_GUARD_OVERRIDE=, .claude/pipe-guard.json, PIPE_GUARD_REGISTRY — still work and are not going away. Where both config files exist the current one wins outright and they are never merged, so a file left under the old name goes unread the moment anyone adds the new one. The bundled registry gains one entry, git apply under restores.

If a set -o pipefail was satisfying the guard from the wrong place, move it. Put it before the pipe, at the top level of the command, in a statement of its own. A setting after the pipe, inside an ended subshell, or in a pipeline stage never reached the pipe in bash, so the gate's failure was being lost all along.

Everything since v2.1.0

Gates behind a wrapper

  • fix(exit-status-guard): peel a wrapper's options, not only its name (Q222) by @karlkfi in #185
  • fix: peel env and sudo operands by the programs' own rule (Q218) by @karlkfi in #221
  • refactor(lib): share the wrapper option grammar and arity walk (Q233) by @karlkfi in #241
  • fix(exit-status-guard): judge the command env -S splits out (Q235) by @karlkfi in #255
  • fix(lib): read sudo -h as a host before a plain word (Q158) by @karlkfi in #261
  • fix(exit-status-guard,foreground-guard): read a sudo option after an assignment operand (Q275) by @karlkfi in #267

Where pipefail is on

  • fix(exit-status-guard): honour pipefail only where it is on for the pipe (Q148) by @karlkfi in #205
  • fix(exit-status-guard): carry pipefail into a substitution's body (Q259) by @karlkfi in #239
  • fix(exit-status-guard): count pipefail only where bash keeps it (Q263) by @karlkfi in #252

Status lost in a substitution or subshell

  • fix(exit-status-guard,lib): deny a gate lost in <(…) or a (…) subshell (Q277) by @karlkfi in #231

The override and the restore list

  • fix(exit-status-guard): arm the override behind env or sudo (Q270) by @karlkfi in #265
  • fix(exit-status-guard): count git apply as a restore by @karlkfi in #165

Reasons

  • fix(exit-status-guard): say why set -e does not guard a sequence by @karlkfi in #154
  • fix(lib): name the writes a deny discards, in every guard's reason (Q204) by @karlkfi in #162

Shared parser

  • fix(lib): read NAME[sub]=value as an assignment, not a command name (Q214) by @karlkfi in #157
  • fix(lib): read a subscript whole wherever an assignment can stand (Q217) by @karlkfi in #172
  • fix(lib): read ANSI-C quoting the way bash does (Q225) by @karlkfi in #177
  • fix(lib): unescape a backtick body before returning it (Q226) by @karlkfi in #179
  • fix(lib): step over a comment inside a $( ) substitution (Q230) by @karlkfi in https://github.com/karlkfi/...
Read more

branch-guard 1.12.0

Choose a tag to compare

@karlkfi karlkfi released this 09 Oct 05:00
816a09a

Minor release: cleanup after a rebase you already pushed stops being refused, a branch reset spelled switch -C stops being auto-approved, and the guard now reads a git command behind a wrapper, inside a group or loop, or after |&, where before it said nothing at all.

Highlights

A rebase you already pushed no longer blocks the cleanup. Rebasing a branch and pushing the result under another name gives every commit a new object name, so the old tip is reachable from nothing and git reset --hard off it was denied. The guard now matches each commit the move would orphan by patch-id against the newest 200 commits on your remotes, and a full match is auto-approved. git branch -D and the force overwrites (-f, -M, -C) take the same check. A match must also keep the commit's message, so a reword made locally after publishing, or during the rebase itself, still denies: the move would discard a message that exists nowhere else. A base that moved inside the lines around your change still denies too, and the denial now names git cherry <remote-branch> <branch> to show which commits have no match.

git switch -C main … was auto-approved, and now asks. git switch -C, git checkout -B and git worktree add -B reset an existing branch, and v1.11.0 read them as the harmless branch-creating forms. They are judged like git branch -f now: resetting a protected branch asks (denies under dontAsk), and resetting a private one asks when it would orphan commits nothing else reaches.

A git command behind a wrapper is judged. env, sudo, timeout, nice, nohup, command, stdbuf, setsid, time and exec used to hide the command from the guard entirely, so timeout 60 git push origin main ran with no prompt. It now gets every ask and deny the bare command would. A wrapped command is still never auto-approved, because the wrapper can change the user or the environment, and the guard reads neither. The break-glass does reach it: BRANCH_GUARD_OVERRIDE=<reason> written before the wrapper, or as an env or sudo operand (env BRANCH_GUARD_OVERRIDE=<reason> git reset --hard …), lifts a deny the way the bare prefix does. A wrapper that changes directory (env -C, sudo -D, sudo -i) keeps the deny, as git -C does. env -C <dir> and sudo -D <dir> are followed into a literal directory the way git -C <dir> is, so a commit into a checkout on a protected branch asks. The wrapper options that take a value come from a table shared with the other guards, which covers sudo -r, env -a, abbreviated long options such as sudo --us root, and sudo -h HOST.

Groups, conditionals, loops and |& no longer hide a command. A git command inside { … }, an if, while or for body, after !, or in a function body is judged as it would be typed bare, so { git push --force origin main; } 2>&1 asks. The same goes for a command after |&, and for one written straight after a ) and an operator, as in x=$(…); git commit. All of these used to return no decision. In the other direction, a read-only command piped with |& into a filter is auto-approved as | already was, and a cd inside ( … ), a pipeline stage or a backgrounded list no longer moves the branch later commands are judged against.

Shared-parser fixes stop five spellings from hiding a command. A prefix that assigns an array element (FOO[0]=x git …, FOO[a;b]=x git …), an ANSI-C quoted string earlier in the line (echo $'\'' ; git …), an escape-spelled command name ($'\x67it' push …), and a double-quoted substitution holding a quote (echo "$(printf '%s"' x)" ; git …) each made the guard defer, and each is now judged. Every deny also ends by naming the file writes in the refused command, since a deny discards the whole call and those writes never happened.

Decision surface

Measured by running the v1.11.0 hook and this one through their launchers over identical PreToolUse payloads and recording the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. The session checkout is on claude/x, and a linked worktree has the protected main. Rows marked republished use a repository where claude/dup holds two commits that were replayed onto a moved base and published as origin/claude/republished. Base moved in context means the base edited line 12 of the file claude/dup edits at line 10.

command v1.11.0 v1.12.0
git reset --hard origin/main (republished) deny allow
git branch -D claude/dup (republished) ask allow
git branch -f claude/dup origin/main (republished) ask allow
git log |& head (on main) defer allow
(cd <worktree on main>) && git commit -m x ask defer
git switch -C main origin/main allow ask
git switch -C scratch main (scratch holds unique work) allow ask
timeout 60 git push origin main defer ask
sudo --us root git push origin main defer ask
{ git push --force origin main; } 2>&1 defer ask
x=$(true); git commit -m x (on main) defer ask
true |& git commit -m x (on main) defer ask
env -C <worktree on main> git commit -m x defer ask
FOO[0]=x git commit -m x (on main) defer ask
timeout 60 git reset --hard origin/main (base moved in context) defer deny
BRANCH_GUARD_OVERRIDE=why timeout 60 git reset --hard origin/main (same) defer allow
env BRANCH_GUARD_OVERRIDE=why git reset --hard origin/main (same) defer allow
git reset --hard origin/main (republished, then reworded locally) deny deny
git reset --hard origin/main (base moved in context) deny deny
git commit -m x (on main) ask ask
timeout 60 git status defer defer
git status allow allow

Seven rows open and ten harden. Of the seven, three are the republish check, one is a read-only |& pipe, and two are the break-glass written before a wrapper or as an env operand, on a command v1.11.0 never saw. The seventh is a prompt about the wrong branch, which is now left to your permission settings. Of the ten, two were auto-approved resets of a branch, main included. The other eight were commands the guard never saw: seven now ask where nothing prompted, and the eighth is a wrapped reset --hard that denies exactly where the bare command does.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update branch-guard@claude-bouncer

A restart is needed. The hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. BRANCH_GUARD_OVERRIDE and the rest keep their names and their defaults.

Changes that need no action, but will be visible.

  • A wrapped git command can prompt or deny where it passed in silence. That is the point of the wrapper change. A destructive wrapped command such as timeout 60 git reset --hard … now denies exactly where the bare one does.
  • The break-glass works behind a wrapper, except one that changes directory. Behind env -C, sudo -D or sudo -i the deny stands, as it does for git -C. Drop that wrapper and write the override as a bare prefix if you mean to proceed.

Everything since v1.11.0

Republished rebases

  • fix(branch-guard): allow a reset off a rebase that is already published (Q145) by @karlkfi in #156
  • fix(branch-guard): let branch -D and the force overwrites take the republish check (Q216) by @karlkfi in #178
  • fix(branch-guard): require a republished match to keep the commit's message (Q227) by @karlkfi in #206

Commands the guard now judges

  • fix(branch-guard): peel command wrappers before the git lookup (Q223) by @karlkfi in #173
  • fix(branch-guard): split a glued separator run before segmenting (Q268) by @karlkfi in #219
  • fix(branch-guard): judge switch -C, checkout -B and worktree add -B as branch -f (Q252) by @karlkfi in #233
  • fix(branch-guard): split a segment on |& (Q278) by @karlkfi in #238
  • fix(branch-guard): follow a literal env -C / sudo -D directory (Q251) by @karlkfi in #258
  • fix(branch-guard): judge a git command inside a group, conditional or loop (Q302) by @karlkfi in #266
  • fix(branch-guard): lift the break-glass through a command wrapper by @karlkfi in #269

Shared parser

  • fix(lib): read NAME[sub]=value as an assignment, not a command name (Q214) by @karlkfi in #157
  • fix(lib): name the writes a deny discards, in every guard's reason (Q204) by @karlkfi in #162
  • fix(lib): read a subscript whole wherever an assignment can stand (Q217) by @karlkfi in #172
  • fix(lib): read ANSI-C quoting the way bash does (Q225) by @karlkfi in #177
  • refactor(lib): share the wrapper option grammar and arity wal...
Read more

workspace-guard 1.12.1

Choose a tag to compare

@karlkfi karlkfi released this 18 Sep 15:57
d2d54b8

Patch release: the auto-mode prompt about a shell -c body the hook "could not reach" stops firing on bodies it did in fact read.

What changed

A readable -c body no longer prompts. 1.12.0 set the suppression off a loose scan — some token is a shell, a later short-option cluster carries c — and never asked whether the body had actually been extracted. Where it had been, the hook was checking that body's own paths a few lines later, so the prompt's reason was false for the shape behind most of the prompts measured in local session transcripts (Q211): the watcher idiom timeout 240 bash -c 'until grep -q DONE tmp/w.log; do sleep 5; done' sharing a string with a tail or grep of the same log. A body behind a container runtime or a remote shell is still out of reach and still prompts, and the body's own paths are checked either way.

The allow is still withheld for a readable body, so default mode is unchanged — your own permission rules decide there, exactly as before.

Decision surface

Measured by running the v1.12.0 hook and this one as subprocesses over the same throwaway project, macOS, WORKSPACE_GUARD_ESCALATE unset. defer means the guard expressed no opinion and your own permission settings decided.

command mode v1.12.0 v1.12.1
cat f; timeout 5 bash -c 'grep -q DONE f' auto ask defer
cat f; timeout 5 bash -c 'grep -q DONE f' acceptEdits ask defer
cat f; timeout 5 bash -c 'grep -q DONE f' bypassPermissions deny defer
cat f && bash -c 'echo hi' auto ask defer
cat f; docker exec c sh -c 'cat /x' auto ask ask
cat f; ssh host sh -c 'cat /x' auto ask ask
cat f; bash -c 'cat /etc/passwd' auto ask ask
cat f; bash -c 'kill -9 -1' auto ask ask
cat f; bash -c 'kill -9 -1' bypassPermissions deny deny
cat f && bash -c 'echo hi' (ESCALATE=all) auto ask ask
cat f; timeout 5 bash -c 'grep -q DONE f' default defer defer
cat /etc/passwd default ask ask
cat ./f default allow allow

Four rows lose a prompt and none gains one. Every row that keeps its prompt keeps it for a reason the prompt states correctly: a body on another filesystem, a path outside the root, or an unscoped kill.

The kill rows are there because they nearly moved. Reading the body changes which label the group carries, and the label is what the escalation acts on — an unscoped kill written inside a readable body was masked by it for the length of one commit on main. It never reached a release; the rows are pinned by tests now.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update workspace-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. To keep prompting on these, WORKSPACE_GUARD_ESCALATE=all escalates the interpreter suppression, which covers every readable -c body — and inline interpreter code besides, so it is broader than what 1.12.0 did.

Everything since v1.12.0

Prompts withdrawn

  • fix(workspace-guard): escalate sh -c only on a body the hook cannot reach (Q211) by @karlkfi in #148

No change for an upgrader

  • fix(workspace-guard): prefer a nested body's signal over interpreter (Q212) by @karlkfi in #149 — restores the escalation on an unscoped kill written inside a readable body, which the fix above had masked for the length of one commit on main.

Validation

1567 tests green on the release tree, alongside the 207 shared-parser tests and the four sibling suites make check runs beside them. The count is from the release branch rather than quoted from an earlier run.

The decision surface above is the check a test suite cannot make: both hooks run as subprocesses over the same project, so a row records what a session would actually have seen.

What this does not assert. No live Claude Code session runs in the suite — hook behaviour is asserted against synthetic payloads. The table was measured on macOS. And the guard reads the command string Claude is about to run, so it governs Claude's own tools and nothing else.

Full Changelog: workspace-guard/v1.12.0...workspace-guard/v1.12.1

workspace-guard 1.12.0

Choose a tag to compare

@karlkfi karlkfi released this 12 Sep 23:25
18f1f34

Minor release: two opt-in postures — one that escalates a withheld allow where the unattended modes would otherwise run the command unasked, one that turns the four deny classes into prompts — plus five parser fixes, three of which close a shape that used to pass in silence.

Highlights

A withheld allow now escalates instead of evaporating. When a command mixes a guarded command with a shell -c body the hook cannot reach, or a kill it cannot scope, the guard withholds its allow and defers. Under default that is correct — your own permission settings still decide. Under auto, acceptEdits and bypassPermissions there is nothing behind the defer, so the command ran with nobody having looked at it. It now prompts in the first two and denies with the rewrite in the third. WORKSPACE_GUARD_ESCALATE=off restores the old behaviour; =all extends it to interpreter code.

WORKSPACE_GUARD_POSTURE=supervise turns the deny classes into prompts. Four classes deny outright — a host-wide temp write, a sibling-checkout write, a cross-session scratchpad write, and an unscoped kill. A deny is right when the model is the one being stopped and wrong when you are sitting there and meant it. supervise asks instead, and WORKSPACE_GUARD_SIBLING_ACTION, WORKSPACE_GUARD_KILL_ACTION, WORKSPACE_GUARD_CROSSSESSION_ACTION and the existing WORKSPACE_GUARD_TMP_ACTION override it per class. Defaults are unchanged, and bypassPermissions still denies whatever the posture says.

A prompt you approved this session can stop coming back. WORKSPACE_GUARD_SESSION_GRANTS=1 records an approved decision shape against the session id and stops re-asking for it. Off by default, session-scoped, and the first grant's timestamp never slides, so a grant cannot be extended by repeating the command.

Three shapes that used to pass in silence now reach you. python3 -m unittest behind a clean guarded read returned allow for the whole string, so the module's code ran without the prompt your settings would have raised — python3 -c had deferred for two releases and -m had not. An X+=1 command prefix made the guard stand aside entirely, because the append spelling was read as a command name rather than an assignment. And a quoted operator-only word — cat ';' /etc/passwd — split the command in the lexer but not in bash, so the guard judged a cat that had no outside path in it and allowed the read. A backtick or heredoc body after a cd out of the project root joins them: the substitution is now resolved where it was written rather than where the scan reached it.

Two prompts stop firing, for the same reason the bypasses closed. A quoted command prefix is a word bash runs as a program, not an assignment: 'X=1' cat /etc/passwd looks for a program named X=1 and never reaches cat, so there is no read to guard. The same goes for a quoted reserved word — 'time' cat … — which the guard no longer peels to reach the command behind it. Written unquoted, both behave exactly as before. This cuts the other way too: 'WORKSPACE_GUARD_OVERRIDE=why' … no longer arms the break-glass, because bash never set the variable.

The override works where the denial says to put it. WORKSPACE_GUARD_OVERRIDE=<reason> is now read off the command string as a prefix on a single Bash call, which is the form every cross-workspace denial has always named. Edit, Write and PowerShell denials have no command string to carry it, and they now name a route you can actually take. The sibling-checkout write denial names both of its ways out — the corrected in-session path, and the override — instead of leading with the path rewrite, which is the wrong repair for a worktree you created on purpose.

Decision surface

Measured by running the v1.11.0 hook and this one over the same throwaway project and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. Unmarked rows are permission_mode: default with no posture set.

command v1.11.0 v1.12.0
cat README.md && python3 -m unittest allow defer
cat ';' /etc/passwd allow ask
X+=1 cat /etc/passwd defer ask
cd /etc && echo `cat passwd` defer ask
cat README.md && bash -c 'echo hi' (auto) defer ask
cat README.md && kill -9 -1 (auto) defer ask
cat README.md && bash -c 'echo hi' (bypassPermissions) defer deny
cat README.md && python3 -c 'print(1)' (auto, ESCALATE=all) defer ask
echo x > /var/tmp/f (POSTURE=supervise) deny ask
'X=1' cat /etc/passwd ask defer
'time' cat /etc/passwd ask defer
cat README.md && bash -c 'echo hi' (default) defer defer
cat README.md && python3 -c 'print(1)' (auto) defer defer
echo x > /var/tmp/f (default posture) deny deny
cat /etc/passwd ask ask
cat ./docs/a.md allow allow
grep -r x . allow allow

Nine rows gain scrutiny and two lose a prompt. Both of the two are commands bash never runs the way the old lexer read them, so the prompt was about a read that was not going to happen.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update workspace-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. WORKSPACE_GUARD_OVERRIDE, WORKSPACE_GUARD_READ_ALLOW_PREFIXES, WORKSPACE_GUARD_TMP_* and WORKSPACE_GUARD_SCRATCH_DIR keep their names and their defaults. Six variables are new — WORKSPACE_GUARD_ESCALATE, WORKSPACE_GUARD_POSTURE, WORKSPACE_GUARD_SESSION_GRANTS, WORKSPACE_GUARD_SIBLING_ACTION, WORKSPACE_GUARD_KILL_ACTION and WORKSPACE_GUARD_CROSSSESSION_ACTION — and every one of them is off or neutral unless you set it.

If you work in auto or acceptEdits, expect prompts where you were getting silence. That is the release. A command that mixes a guarded command with a shell -c body was running unexamined; it now asks. If a shape is routine for you, WORKSPACE_GUARD_ESCALATE=off puts it back rather than turning the guard off.

Everything since v1.11.0

Silent bypasses closed

  • fix(workspace-guard): read python3 -m as unreadable code, not a path (Q143) by @karlkfi in #75
  • fix(lib): read NAME+=value as an assignment, not a command name by @karlkfi in #113
  • fix(lib): read quoting before treating a token as an operator by @karlkfi in #138
  • fix(workspace-guard): resolve a substitution body where it was written by @karlkfi in #109

New configuration

  • feat(workspace-guard): escalate a withheld allow where defer runs (Q74) by @karlkfi in #120
  • feat(workspace-guard): supervise the deny classes on an opt-in posture (Q84) by @karlkfi in #126
  • feat: session-scoped grants, and a worktree the session owns (Q139, Q144) by @karlkfi in #85

Prompts withdrawn, and denials that name a route

  • fix(lib): read a quoted command prefix as a command, not an assignment by @karlkfi in #110
  • fix(workspace-guard): read the override off the command string (Q156) by @karlkfi in #90
  • fix(workspace-guard): give the sibling deny's two ways out equal standing (Q155) by @karlkfi in #94

Repository

  • docs(readme): point every install surface at claude-bouncer, and gate it by @karlkfi in #67
  • docs(queue): re-measure Q144 as a write deny, and file Q155 by @karlkfi in #84

Validation

1559 tests green on the release tree, alongside the 207 shared-parser tests that cover lib/bouncer_parse.py directly and the four sibling suites make check runs beside them. The count is from the release branch rather than quoted from an earlier run.

The decision surface above is the check a test suite cannot make: both hooks run as subprocesses over the same project, so a row records what a session would actually have seen rather than what a fixture asserts.

What this does not assert. No live Claude Code session runs in the suite — hook behaviour is asserted against synthetic payloads. The table was measured on macOS; the PowerShell paths are asserted by fixtures on every platform rather than by a Windows session. And the guard reads the command string Claude is about to run, so it governs Claude's own tools and nothing else. Anything that must hold unconditionally belongs in the filesystem's permissions.

Full Changelog: workspace-guard/v1.11.0...workspace-guard/v1.12.0

prod-guard 2.6.0

Choose a tag to compare

@karlkfi karlkfi released this 12 Sep 23:25
18f1f34

Minor release: six command shapes that hid a production command from the guard are now read, and three that run nothing stop being denied. The boundary moved outward on every one of the six — each was a mutating command against a production target that previously ran with no prompt at all.

Highlights

Six wrappers stop hiding the command behind them. stdbuf and unbuffer are now peeled like any other wrapper. env -S / --split-string has its command string parsed rather than treated as one opaque argument. And a wrapper flag that takes a separate value — exec -a, /usr/bin/time -o, sudo -C/-D/-R/-T, BSD env -P, GNU time -f/--format/--output — no longer leaves the guard reading the value as the command. In every case a mutating command against a production target was deferring silently.

Two shell bodies now resolve variables the way bash does. A bash -c "…" body written in double quotes picks up what the parent expanded, so a production target reached through $VAR denies instead of merely prompting — and a non-production one stops prompting. An eval body does the same, under single and double quotes alike. A bash -c started inside an eval body still sees only exported variables, so nothing newly denies on a value that never reaches it.

A quoted operator word no longer splits the command. kubectl ';' --context acme-production delete ns x was split at the ';' by the lexer but not by bash, which hid the flags naming the target and left the guard with nothing to judge. Same class as the X+=1 prefix, which made the guard stand aside entirely and now arms as an assignment — including PROD_GUARD_OVERRIDE+=<reason>, which arms the break-glass.

Three modes that run nothing stop being denied. sudo -l, -v, -e, -K and -U run no command, so there was never a command to guard; the words after them were being classified anyway. command -v kubectl helm and command -V kubectl are lookups, not invocations, so a multi-binary lookup is no longer denied against the ambient context.

A quoted prefix is a program, not an assignment. 'X=1' kubectl … makes bash look for a program named X=1; it never reaches kubectl, so the guard stops judging a command that will not run. The same holds for 'PROD_GUARD_OVERRIDE=why' kubectl …, which never armed the break-glass in bash and no longer appears to here. Written unquoted, both behave exactly as before.

Decision surface

Measured by running the v2.5.2 hook and this one over the same payloads and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. acme-production matches a built-in production pattern and kind-ci a built-in non-production one; no config file is in play.

command v2.5.2 v2.6.0
stdbuf -oL kubectl --context acme-production delete ns x defer deny
unbuffer kubectl --context acme-production delete ns x defer deny
env -S 'kubectl --context acme-production delete ns x' defer deny
/usr/bin/time -o out.txt kubectl --context acme-production delete ns x defer deny
exec -a nm kubectl --context acme-production delete ns x defer deny
sudo -C 3 kubectl --context acme-production delete ns x defer deny
kubectl ';' --context acme-production delete ns x defer deny
X+=1 kubectl --context acme-production delete ns x defer deny
CTX=acme-production; bash -c "kubectl --context $CTX delete ns x" ask deny
CTX=acme-production; eval 'kubectl --context $CTX delete ns x' ask deny
PROD_GUARD_OVERRIDE+=why kubectl --context acme-production delete ns x defer ask
sudo -l kubectl --context acme-production delete ns x deny defer
sudo -v kubectl --context acme-production delete ns x deny defer
sudo -K kubectl --context acme-production delete ns x deny defer
command -v kubectl helm deny defer
'X=1' kubectl --context acme-production delete ns x deny defer
'PROD_GUARD_OVERRIDE=why' kubectl --context acme-production delete ns x ask defer
kubectl --context acme-production delete ns x deny deny
PROD_GUARD_OVERRIDE=why kubectl --context acme-production delete ns x ask ask
kubectl --context kind-ci delete ns x defer defer
command -V kubectl defer defer
ls -la defer defer

Eleven rows gain scrutiny and six lose it. Every one of the eleven is a mutating command against a production target, and eight of them were saying nothing at all. The six withdrawals are commands that run nothing, or that bash would never run the way the old lexer read them.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update prod-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed, nothing was added, and no configuration changed meaning. PROD_GUARD_OVERRIDE, PROD_GUARD_PROD_PATTERNS, PROD_GUARD_NONPROD_PATTERNS and .claude/prod-guard.json keep their names and their defaults. The environment-variable surface is identical to v2.5.2's.

Expect denials where you were getting silence, and they are the release. Eight of the shapes above used to pass unremarked. If one is routine for you against a cluster the heuristic is calling production wrongly, add it to PROD_GUARD_NONPROD_PATTERNS or to .claude/prod-guard.json rather than reaching for the override each time.

If you scripted 'PROD_GUARD_OVERRIDE=…' kubectl …, it never worked. Quoted, bash runs it as a program and the assignment never happens. Write the prefix unquoted; quoting the value — PROD_GUARD_OVERRIDE='incident 42' kubectl … — is unaffected, and the append spelling now arms too.

Everything since v2.5.2

Commands no longer hidden from the guard

  • fix(prod-guard): peel stdbuf and unbuffer like any other wrapper (Q161) by @karlkfi in #117
  • fix(prod-guard): read the command inside env -S (Q159) by @karlkfi in #123
  • fix(prod-guard): skip a wrapper flag's value with the flag (Q152) by @karlkfi in #92
  • fix(prod-guard): stop a quoted operator word from splitting a command (Q202) by @karlkfi in #139
  • fix(lib): read NAME+=value as an assignment, not a command name by @karlkfi in #113
  • fix(lib): read quoting before treating a token as an operator by @karlkfi in #138

Targets resolved through a variable

  • fix(prod-guard): expand a double-quoted -c body in the parent shell by @karlkfi in #77
  • fix(prod-guard): expand an eval body in the invoking shell (Q151) by @karlkfi in #98

Denials withdrawn

  • fix(prod-guard): stop classifying sudo's run-nothing modes as a command (Q157) by @karlkfi in #128
  • fix(prod-guard): stop reading command -v as an invocation by @karlkfi in #80
  • fix(lib): read a quoted command prefix as a command, not an assignment by @karlkfi in #110

Repository and shared parser

  • feat: session-scoped grants, and a worktree the session owns (Q139, Q144) by @karlkfi in #85
  • fix(workspace-guard): resolve a substitution body where it was written by @karlkfi in #109
  • docs(readme): point every install surface at claude-bouncer, and gate it by @karlkfi in #67

The grants change moved this plugin onto the shared lib/bouncer_grants.py store. It is a refactor here: prod-guard's grants keep their own meaning — an exact target string — and no verdict in the table above moved because of it.

Validation

493 tests green on the release tree, alongside the 207 shared-parser tests that cover lib/bouncer_parse.py directly and the four sibling suites make check runs beside them. The count is from the release branch rather than quoted from an earlier run.

The decision surface above is the check a test suite cannot make: both hooks run as subprocesses over the same payloads, so a row records what a session would actually have seen.

What this does not assert. No live Claude Code session runs in the suite — hook behaviour is asserted against synthetic payloads, and no fixture uses a real cluster, context name, or credentials path. Classification is a heuristic over target names, so whether your production cluster is recognised is a question for PROD_GUARD_PROD_PATTERNS and the config file, not for the suite. The guard reads the command string Claude is about to run, so it governs Claude's own tools and nothing else. Anything that must hold unconditionally belongs in RBAC.

Full Changelog: prod-guard/v2.5.2...prod-guard/v2.6.0

foreground-guard 0.7.0

Choose a tag to compare

@karlkfi karlkfi released this 12 Sep 23:25
18f1f34

Minor release: two shapes that let a blocking command past the guard now deny, and two false denials are withdrawn. No configuration changed, and a default install with no registry sees only the quoted-& fix.

Highlights

A quoted or escaped & is an argument, not a background operator. sleep 300 '&' and sleep 300 \& read as backgrounded, so the guard stood down — and a command that parks the main thread for five minutes went through unjudged. Bash passes both to sleep as an argument and runs it in the foreground, which is exactly the case the guard exists for. A real trailing & is unchanged.

An interpreter option that takes its own argument no longer hides the script. bash -o errexit gate.sh, --rcfile, --init-file, -O, and the +o/+O/+x forms each consume a separate word, and the Class B peel was reading that word as the script. A registered slow command invoked through any of them ran in the foreground unguarded. bash -o noexec gate.sh still defers, because it parses the script and runs nothing.

A parse-only run executes nothing, so it cannot block. bash -n <registered-script> and the other parse-only forms — sh -n, zsh -n, -en, -n -c — were denied as slow foreground runs. A -n written after the script name belongs to the script and is unaffected.

A sleep between two different commands is one wait, not a poll. The repeat-with-sleep rule fired on any cmd; sleep N; cmd shape regardless of whether the two commands matched, so pkill x; sleep 3; pgrep x and echo armed; sleep 3; pgrep x were denied as polling loops. They defer below the sleep floor now. The rule still fires when the same command sits on both sides.

Two command prefixes are read the way bash reads them. X+=1 scripts/gate.sh used to defer entirely — the append spelling was taken for a command name, so the guard never saw the script — and it now denies. In the other direction a quoted prefix is a program bash looks for and fails to find: 'X=1' scripts/gate.sh never reaches the script, so there is nothing to guard and the denial is gone.

Decision surface

Measured by running the v0.6.0 hook and this one over the same payloads and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. The registered rows carry {"slow": {"commands": {"scripts/gate\\.sh": 3600000}}}; the rest carry no config file.

command v0.6.0 v0.7.0
sleep 300 '&' defer deny
sleep 300 \& defer deny
bash -o errexit scripts/gate.sh (registered) defer deny
bash --rcfile /dev/null scripts/gate.sh (registered) defer deny
X+=1 scripts/gate.sh (registered) defer deny
bash -n scripts/gate.sh (registered) deny defer
pkill x; sleep 3; pgrep x deny defer
echo armed; sleep 3; pgrep x deny defer
'X=1' scripts/gate.sh (registered) deny defer
bash -o noexec scripts/gate.sh (registered) defer defer
sleep 300 & defer defer
sleep 300 deny deny
pgrep x; sleep 3; pgrep x deny deny
scripts/gate.sh (registered) deny deny
gh run watch deny deny
ls -la defer defer

Five rows gain a denial and four lose one. The two sleep 300 rows are the only ones a default install without a registry will see move, because everything under Class B needs a registered command to fire on at all.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update foreground-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. The config keys are the same keys with the same defaults, and the environment-variable surface is identical to v0.6.0's.

This plugin is pre-1.0 and its surface is unstable by design. Nothing breaks in this release, but a break here would ride in a minor rather than waiting for a major, and it would be stated in these notes when it happens.

Everything since v0.6.0

Blocking commands that used to get through

  • fix(foreground-guard): stop a quoted & from standing the guard down (Q203) by @karlkfi in #140
  • fix(foreground-guard): read interpreter option arity in the Class B peel (Q149) by @karlkfi in #89
  • fix(lib): read NAME+=value as an assignment, not a command name by @karlkfi in #113

Denials withdrawn

  • fix(foreground-guard): defer a parse-only bash -n run of a registered script by @karlkfi in #74
  • fix(foreground-guard): call a sleep a poll only when the same command repeats around it by @karlkfi in #102
  • fix(lib): read a quoted command prefix as a command, not an assignment by @karlkfi in #110

Repository and shared parser

  • fix(lib): read quoting before treating a token as an operator by @karlkfi in #138
  • fix(workspace-guard): resolve a substitution body where it was written by @karlkfi in #109
  • feat: session-scoped grants, and a worktree the session owns (Q139, Q144) by @karlkfi in #85
  • docs(readme): point every install surface at claude-bouncer, and gate it by @karlkfi in #67

The two shared-parser changes in the second group reach code this guard does not call — it keeps its own tokenizer and segmenter, because it has to know which segment was backgrounded and the shared operator-preserving lexer does not answer that. The & fix above is this guard's own segmenter, not lib/.

Validation

225 tests green on the release tree, alongside the 207 shared-parser tests and the four sibling suites make check runs beside them. The count is from the release branch rather than quoted from an earlier run.

The never-allow invariant is asserted on every end-to-end call: the hook returns ask, deny, or nothing. An allow would bypass your permission settings and override the sibling guards evaluating the same command.

What this does not assert. No live Claude Code session runs in the suite — hook behaviour is asserted against synthetic payloads, and the table above against real subprocess invocations rather than a real session. Class B's registry is per-repo by design, so no test can tell you whether your slow commands are registered, only that a registered one behaves; the friction report is the tool for that.

This is a productivity guard, not a security boundary. Parsing uncertainty defers, and infrastructure errors fail open to a silent defer, because a guard that breaks the session is worse than a missed prompt. Do not deploy it as a control that something must not run.

Full Changelog: foreground-guard/v0.6.0...foreground-guard/v0.7.0

exit-status-guard 2.1.0

Choose a tag to compare

@karlkfi karlkfi released this 12 Sep 23:25
18f1f34

Minor release: a pipeline whose status was being read through a zsh array no longer escapes the piped-gate rule, and three denials that were reading a correct command as a broken one are withdrawn.

Highlights

Reading $pipestatus no longer suppresses the denial. make check | tail -5; echo $pipestatus was treated as a command that recovers its own status, so the piped gate went unremarked. $pipestatus is zsh's array and the Bash tool runs bash, where it expands to empty — so the recovery was not happening and the gate's failure was still being lost. Redirect and read $? instead, or set EXIT_STATUS_GUARD_OVERRIDE=<reason> for a deliberate pipe.

A quoted operator word no longer hides the pipe. make check ';' | tail -5 was split at the ';' by the lexer but not by bash, so the pipeline the guard was meant to judge fell out of view. An X+=1 prefix did the same thing by another route, reading as a command name rather than an assignment.

A status test joined to the mutation with && is credited. make check > log 2>&1; rc=$?; [ "$rc" -eq 0 ] && git push captures the status, tests it, and publishes only on success — which is the shape the rule exists to produce. It was being denied under the sequencing rule anyway. The [, [[ and test spellings are all credited; the ;-joined and ||-joined forms still deny, because neither gates the publish on the result.

A backgrounded call may combine two statuses arithmetically. … ; exit $((rc|rc2)) is a status that reaches the completion notification, but $((…)) was being read as several words and the exit as bare. It is no longer denied as a lost status.

A newline is named as the separator it is. A gate sequenced before a state change by a newline — the shape a heredoc leaves behind — still denies, and the reason now says so, and says to put the && on the line carrying << rather than after the body.

A quoted prefix is a program, not an assignment. 'X=1' make check | tail -5 makes bash look for a program named X=1, so the gate never runs and there is no status to lose. The denial is gone. Written unquoted it behaves exactly as before, and 'EXIT_STATUS_GUARD_OVERRIDE=…' quoted no longer appears to arm a break-glass bash never set.

Decision surface

Measured by running the v2.0.1 hook and this one over the same payloads and diffing the emitted permissionDecision. defer means the guard expressed no opinion and your own permission settings decided. The background row carries run_in_background: true; the rest are foreground calls.

command v2.0.1 v2.1.0
make check | tail -5; echo $pipestatus defer deny
make check ';' | tail -5 defer deny
X+=1 make check | tail -5 defer deny
make check > log 2>&1; rc=$?; [ "$rc" -eq 0 ] && git push deny defer
… rc=$?; … rc2=$?; exit $((rc|rc2)) (background) deny defer
'X=1' make check | tail -5 deny defer
make check | tail -5 deny deny
make check > log 2>&1; rc=$?; [ "$rc" -eq 0 ] || git push deny deny
make check newline git push deny deny
make check > log 2>&1; echo EXIT=$? (background) deny deny
EXIT_STATUS_GUARD_OVERRIDE=why make check | tail -5 defer defer
make check && git push defer defer
ls -la defer defer

Three rows gain a denial and three lose one. The three additions are pipelines whose status was being lost while the guard said nothing; the three withdrawals are commands that either handle the status correctly or never run the gate at all.

Upgrading

claude plugin marketplace update claude-bouncer
claude plugin update exit-status-guard@claude-bouncer

A restart is needed — the hook is registered at startup, so a running session stays on the version it loaded.

Nothing was removed and no configuration changed meaning. EXIT_STATUS_GUARD_OVERRIDE, EXIT_STATUS_GUARD_REGISTRY and .claude/exit-status-guard.json keep their names and their defaults, and the environment-variable surface is identical to v2.0.1's. The 1.x spellings — PIPE_GUARD_OVERRIDE=, .claude/pipe-guard.json, PIPE_GUARD_REGISTRY — still work and are not going away. Where both config files exist the current one wins outright and they are never merged, so a file left under the old name goes unread the moment anyone adds the new one.

Every denial names .claude/exit-status-guard.json, so a project can exempt its own report targets once instead of overriding them on every call. This repository exempts make backlog and make backlog-next.

If you were relying on $pipestatus to satisfy the guard, it never satisfied bash. Redirect the gate to a log, capture $? in the next statement, and read the log as its own step.

Everything since v2.0.1

Status losses that were going unremarked

  • fix(exit-status-guard): stop $pipestatus silencing the pipe verdict (Q136) by @karlkfi in #88
  • fix(lib): read quoting before treating a token as an operator by @karlkfi in #138
  • fix(lib): read NAME+=value as an assignment, not a command name by @karlkfi in #113

Denials withdrawn

  • fix(exit-status-guard): credit a status test joined to the mutation with && by @karlkfi in #103
  • fix(exit-status-guard): read $((…)) as one word, and name a newline as the separator by @karlkfi in #105
  • fix(lib): read a quoted command prefix as a command, not an assignment by @karlkfi in #110

Reasons that name a route

  • fix(exit-status-guard): teach a project which of its targets are reports (Q154) by @karlkfi in #95
  • fix(exit-status-guard): stop naming the shell the Bash tool runs by @karlkfi in #66

Repository and shared parser

  • feat: session-scoped grants, and a worktree the session owns (Q139, Q144) by @karlkfi in #85
  • fix(workspace-guard): resolve a substitution body where it was written by @karlkfi in #109
  • docs(readme): point every install surface at claude-bouncer, and gate it by @karlkfi in #67

Validation

70 tests green on the release tree, alongside the 207 shared-parser tests that cover lib/bouncer_parse.py directly and the four sibling suites make check runs beside them. The count is from the release branch rather than quoted from an earlier run. Python 3.9 is this plugin's floor, and CI runs the shared parser against it.

The decision surface above is the check a test suite cannot make: both hooks run as subprocesses over the same payloads, so a row records what a session would actually have seen.

What this does not assert. No live Claude Code session runs in the suite — hook behaviour is asserted against synthetic payloads. The registry covers commands whose exit status is the answer, so whether your gates are registered is a question for .claude/exit-status-guard.json, not for the suite. Wanting a pipeline's output rather than its status is legitimate and indistinguishable from the bug, which is why the override exists rather than a narrower rule.

Full Changelog: exit-status-guard/v2.0.1...exit-status-guard/v2.1.0