Skip to content

Releases: karlkfi/claude-foreground-guard

v0.5.0

Choose a tag to compare

@karlkfi karlkfi released this 06 Aug 01:30

Main-thread time guard for Claude Code Bash commands: catches foreground polling and watching, and slow commands about to be killed by an inadequate timeout.

Highlights

A target-aware slow-command form: register "make": {"e2e*": 1800000} instead of writing a regex. The command word must equal the registered name (compared by basename, so /usr/bin/make counts) and the glob must match a whole argument word — no substring matching, so a quoted argument is one token and NOTE="a note mentioning e2e somewhere" can never fire. That closes the leak the regex form invites: an unbounded .* in a pattern like make .*\be2e reaches past the command word into quoted arguments, and the v0.4.0 command-position anchoring can't help because make genuinely is in command position (#21). Target maps merge additively per command across config files, same as the rest of the registry, and a broken entry still loses only itself.

The .* footgun is now documented where you'd hit it. The README's Class B section covers both registration forms, recommends the target form for command-plus-argument registrations, and warns that .* in the regex form puts quoted arguments in scope. The reduce-foreground-guard-prompts skill points regex-.* false positives at the target form.

cut-release.sh no longer reports failure on a successful release. The EXIT-trap bug the v0.4.0 notes flagged is fixed: with --notes-file, the cleanup handler's short-circuited && leaked a non-zero status into the script's exit code after every artifact had published correctly. Release tooling only — nothing in the installed plugin changes.

Upgrading

Install or update as usual — nothing is required, and no config file needs to change. Both slow-command forms coexist in one commands dict, and existing regex entries keep working unchanged.

Optional: a regex entry you prefixed with .* during the v0.4.0 migration so it could match an argument is a candidate for the target form, which cannot leak into quoted arguments and has no regex to get wrong.

Nothing is deprecated or removed in this release. No config key changed name, default, or meaning.

Configuration surface

No keys were added or removed — the table in Configuration still has the same keys as v0.3.0. slow.commands gained a second, additive value shape: alongside the regex form "<pattern>": <minimum timeout ms>, an entry may now be "<command name>": {"<argument glob>": <minimum timeout ms>}. The Class B reason for the new form reads matches the slow-command target `make e2e*` , and the friction report buckets both wordings under the same slow-timeout category.

Everything since v0.4.0

Two PRs.

  • feat(class-b): add a target-aware slow-command form by @karlkfi in #23 (fixes #21)
  • fix(release): stop the EXIT trap from failing a successful release by @karlkfi in #20

Validation

152 tests, green across Python 3.10, 3.11, 3.12, and 3.13 on the commit this release is cut from (run) — 135 at v0.4.0. The new coverage: 13 end-to-end tests over the target form (SlowTargetTests), including both reproduction commands from #21 asserted as defers; friction-report coverage of the new reason wording; and the first tests over scripts/cut-release.sh, which lift the EXIT-trap code verbatim out of the shipped script so the assertion tracks the source rather than a copy.

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 the suite does not assert
  • No live Claude Code session runs in CI. Hook behaviour is asserted against synthetic payloads, not a running CLI.
  • scripts/cut-release.sh still has no end-to-end test — its preflight runs the unit suite, which would recurse — so only the EXIT-trap regression from #20 is covered.
  • 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.

Security

No security advisory accompanies this release, and no dependency changed — the hook remains a single stdlib-only Python script with no telemetry; the friction report re-reads your local session transcripts and sends nothing anywhere. See PRIVACY.md.

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.

Installing this release

/plugin marketplace add karlkfi/claude-foreground-guard
/plugin install foreground-guard@foreground-guard

Note

An existing install does not pick this up on its own. Claude Code auto-updates official Anthropic marketplaces only; a third-party GitHub marketplace stays pinned at the version you installed. Either set autoUpdate for the marketplace in ~/.claude/settings.json once, or update by hand — both are in Keeping it updated.

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

Restart Claude Code (or /reload-plugins) to apply. Requires python3 on your PATH.

The plugin pairs with the sibling guards — workspace-guard, branch-guard, and prod-guard — which is why this one never emits allow.

Full changelog: v0.4.0...v0.5.0

v0.4.0

Choose a tag to compare

@karlkfi karlkfi released this 03 Aug 17:27

Main-thread time guard for Claude Code Bash commands: catches foreground polling and watching, and slow commands about to be killed by an inadequate timeout.

Important

Two changes make the guard prompt where it previously stayed silent, and one config form now needs a rewrite. Backgrounding a command no longer exempts it from poll detection, and a slow.commands pattern that matches an argument rather than a command now needs a .* prefix — it fails quiet, so nothing tells you it stopped matching. Read Upgrading below.

Highlights

Unattended permission modes deny instead of asking. In auto, dontAsk, and bypassPermissions, a would-be ask is now emitted as a deny. Nothing new is blocked — every affected call was already an ask, i.e. already blocking. Only the direction of failure changed: instead of parking on a prompt nobody is there to answer, the guard returns its reason to the agent, which retries with run_in_background: true, one snapshot, or a timeout bound. Previously only bypassPermissions escalated, and auto was the friction — the guard prompted, you rejected it, and the fix went nowhere but into your clipboard. Attended modes (default, acceptEdits, plan) still ask; someone is there, and an ask is the lighter interruption.

Backgrounding no longer switches off the poll detector. run_in_background: true returned before either analyzer ran, so it exempted Class A along with Class B. That is right for Class B — backgrounding is the fix that class teaches — and wrong for Class A: a detached gh run watch or sleep-loop moves the wait off the main thread without removing it, holds a task slot for the whole run, and hands back output the agent cannot date. Class A now analyzes backgrounded calls and words its reasons for a detached wait. Its own exemptions still hold when backgrounded: a trailing &, a timeout N wrap, below-floor sleeps, and poll.exempt_watch_patterns.

Class B patterns match at a command position. Registering a slow script used to flag every command that mentioned its path, not just one that ran it — grep -n foo scripts/gate.sh, wc -l over the same file, a git commit -m quoting it. The advice was unfollowable there: you cannot fix a grep by giving it an hour-long timeout. Class B now routes through the same segmentation Class A already used, requiring the pattern's leftmost match to begin inside a segment's command word. Invocation forms all still match — env prefixes, nohup/time/timeout wrappers, bash gate.sh, bash -c '...' bodies, absolute paths — and heredoc bodies naming the path no longer do.

The friction report stops counting sibling guards' overrides. Its override pattern matched override acknowledged without a guard name, and every co-installed guard phrases that prefix identically apart from the name. Under --plugin all it reported 49 uses of an override that had never been set once — a fabricated statistic arguing for loosening thresholds that were fine, with nothing in the output looking wrong. The pattern is now anchored to this guard's own name.

Upgrading

Install or update as usual — there is no migration for the plugin itself, and no config file needs to exist for the defaults to be correct.

1. If slow.commands has an argument-shaped pattern, prefix it with .*. A bare -race no longer matches go test -race, because patterns now have to hit a command word. Write .*-race\b to opt back into matching anywhere within a segment.

Caution

This one fails quiet. A pattern that stopped matching produces no prompt and no error — the guard simply defers, and a slow command runs under a short timeout again. Grep your .claude/foreground-guard.json for entries that do not begin with a command name. Patterns already anchored as a workaround for the old behaviour ((^|[;&|]\s*)scripts/gate\.sh) keep working; there is a regression test holding that.

2. Expect one new prompt on the deliberate "background a tail -f, read it later with BashOutput" pattern. It is an ask, never a deny, in attended modes. A repo that wants a specific form quiet in every mode lists it in poll.exempt_watch_patterns.

Nothing is deprecated or removed in this release. No config key changed name or default.

Configuration surface

No keys were added or removed — the table in Configuration is unchanged from v0.3.0. One key changed meaning: slow.commands regexes are matched at a command position rather than against the raw Bash string, which is step 1 of Upgrading above.

Two behaviours around the override escape hatch changed with the mode work. FOREGROUND_GUARD_OVERRIDE now downgrades any deny where a prompt can still be answered, mode-escalated as well as config-escalated. In dontAsk and bypassPermissions it is inert and now says so — previously it printed a deny claiming it had been "downgraded to a confirmation prompt", which was false.

Every deny now carries one tail naming the override, the friction report, and the issue tracker, so a wrong verdict gets reported upstream rather than worked around.

Everything since v0.3.0

Four PRs. Listed flat rather than folded — there is no volume here to hide.

  • feat(modes): deny instead of ask in unattended permission modes by @karlkfi in #18
  • fix(hook): stop run_in_background from exempting Class A by @karlkfi in #17 (fixes #15)
  • fix(class-b): match slow-command patterns at a command position by @karlkfi in #19 (closes #16)
  • fix(friction-report): count only this guard's override downgrades by @karlkfi in #14 (fixes #13)

README, CLAUDE.md, the friction-report command, and the reduce-foreground-guard-prompts skill were updated in lockstep with the behaviour changes — the category hints no longer advertise backgrounding as the thing that quiets a Class A prompt, because it isn't one now.

Validation

135 tests, green on the tag commit across Python 3.10, 3.11, 3.12, and 3.13 (run) — 117 at v0.3.0. Three layers: unit tests over heredoc stripping, tokenizing, segment splitting and sleep parsing; end-to-end tests running the hook as a subprocess against a fixture $HOME and fixture project config, so the developer's real ~/.claude can never leak into a verdict; and wiring tests over hooks.json, plugin.json, and marketplace.json, including the version lockstep that makes a one-sided bump fail the suite.

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 the suite does not assert
  • No live Claude Code session runs in CI. Permission-mode behaviour is asserted against synthetic hook payloads carrying each mode string. The mode set was read off the installed CLI (2.1.220) rather than assumed, but nothing here proves the CLI dispatches a hook deny the way this release expects it to in auto.
  • scripts/cut-release.sh is untested. It is not covered by the suite, and cutting this release surfaced a bug in it: with --notes-file, it returns exit 1 after a completely successful release, because its EXIT trap's cleanup short-circuits to a non-zero status. The v0.4.0 artifacts are unaffected and were verified against the remote by hand. A fix is queued.
  • 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.

For #19 the regression tests were confirmed to be regression tests rather than tests written to the new code: stashing the script change alone fails exactly the four behaviour-change assertions and leaves the nine invocation-form assertions green.

Security

This is a productivity guard, not a security boundary — that framing is deliberate and load-bearing. Parsing uncertainty defers, and infrastructure errors (bad JSON, unreadable config, an unexpected exception) fail open to a silent defer, because a guard that breaks the session is worse than a missed prompt. A missed poll costs you waiting, not an outage. Do not deploy it as a control that something must not run.

No dependencies, and no telemetry. The hook is a single stdlib-only Python script; the friction report re-reads the decisions Claude Code already wrote to your local session transcripts and sends nothing anywhere. See PRIVACY.md.

Installing this release

/plugin marketplace add karlkfi/claude-foreground-guard
/plugin install foreground-guard@foreground-guard

Note

An existing install does not pick this up on its own. Claude Code auto-updates official Anthropic marketplaces only; a third-party GitHub marketplace stays pinned at the version you installed. Either set autoUpdate for the marketplace in ~/.claude/settings.json once, or update by hand — both are in [Keeping it updated](https...

Read more

v0.3.0

Choose a tag to compare

@karlkfi karlkfi released this 24 Jul 17:06

Minor release.
Class B slow-command warnings can now escalate to hard denies via slow.action, and releases are cut with one command via scripts/cut-release.sh.

  • feat(config): add slow.action knob to escalate Class B to deny by @karlkfi in #12
  • feat(release): add cut-release.sh to automate the release process by @karlkfi in #11

Full Changelog: v0.2.0...v0.3.0

v0.2.0

Choose a tag to compare

@karlkfi karlkfi released this 18 Jul 23:41

Minor release: a Class A allowlist, a friction-report command, override-reason feedback, and a parser hardening fix.

  • feat(config): add exempt_watch_patterns allowlist for Class A by @karlkfi in #6
  • feat: add friction-report command + script by @karlkfi in #8
  • feat: echo FOREGROUND_GUARD_OVERRIDE reason in the downgrade message by @karlkfi in #4
  • fix(parser): port quote/arith-aware heredoc stripper by @karlkfi in #5

Also includes docs, brand-image, and test-hardening changes (#2, #3, #7, #9, #10).

Full Changelog: v0.1.0...v0.2.0

v0.1.0

Choose a tag to compare

@karlkfi karlkfi released this 18 Jul 19:01
b165980

Initial release: a PreToolUse hook for Bash that guards the session's main-thread time.

  • feat: initial foreground-guard plugin by @karlkfi in #1

Class A catches foreground polling/watching — gh pr checks --watch, gh run watch, kubectl logs -f / get -w, tail -f, journalctl -f, docker logs -f, watch, sleep-polling loops, repeat-with-sleep chains, and bare sleep at or above a configurable floor — and prompts ask (config may escalate to deny). Class B catches commands the repo has registered as slow when they are about to run with an inadequate Bash-call timeout, naming the exact fix; its registry ships empty and is populated per-repo in .claude/foreground-guard.json. Exemptions: run_in_background: true, a detaching &, and timeout N wraps. The hook never emits allow, so it composes with the sibling guards; FOREGROUND_GUARD_OVERRIDE=<reason> downgrades a config-escalated deny to a confirmation prompt.

Full Changelog: https://github.com/karlkfi/claude-foreground-guard/commits/v0.1.0