Skip to content

fix(context-guard): stop an uninstalled plugin's tee and the compounding sh -c wrap - #1844

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/1787-context-guard-shim-uninstall-and-shc-wrap
Jul 31, 2026
Merged

fix(context-guard): stop an uninstalled plugin's tee and the compounding sh -c wrap#1844
kyle-sexton merged 2 commits into
mainfrom
fix/1787-context-guard-shim-uninstall-and-shc-wrap

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #1787

Summary

Two findings that landed on #1252 after it merged, so the thread-resolution gate never saw them.
Both were re-verified present on origin/main before this branch started, and both are fixed here
with regression coverage.

  1. P1 — an uninstalled plugin's tee kept executing through the shim. resolve_tee() picked the
    newest scripts/statusline-tee.sh by mtime, skipping only temp_* marketplace clones. It never
    asked whether the plugin was still installed, so an uninstalled plugin kept teeing and kept
    writing snapshots, with no signal to the operator.
  2. P2 — setup added one sh -c layer per run. "Unwrap before you compose" stripped guard-shim
    prefixes but not the sh -c '<escaped …>' adapter the skill's own shell-syntax guard prints.

Fix

The orphan marker, not installed_plugins.json

claude plugin uninstall does not delete the version directory. The plugins reference, under
Plugin caching and file resolution (fetched
2026-07-30), states: "When you update or uninstall a plugin, the previous version directory is
marked as orphaned and removed automatically 14 days later. The grace period lets concurrent Claude
Code sessions that already loaded the old version keep running without errors."
Uninstall is named
explicitly, so scripts/statusline-tee.sh stays on disk — and stays executable by an mtime-only
shim — for that whole window.

resolve_tee() now skips a candidate whose version directory carries the orphan marker.

The issue asked for a supported signal, not the internal file the shim header deliberately
rejected. That rejection stands, on both of its original grounds: installed_plugins.json is
undocumented and reading it would put a jq spawn on every statusline refresh. The orphan marker
beats it on both — the behavior it reports is documented, and the test is a shell builtin, so the
file's pure-builtins invariant is preserved ([[ -e … ]], no subprocess).

What is documented vs. what is measured, recorded at the site rather than assumed:

  • The marking is documented (quoted above).
  • The marker's on-disk spelling is not. Measured on Claude Code 2.1.220 against a relocated
    CLAUDE_CONFIG_DIR: an uninstall writes <version-dir>/.orphaned_at holding an epoch-ms stamp
    and leaves scripts/statusline-tee.sh in place. Reproducible on any live cache — every superseded
    version directory carries the marker and the currently installed one does not.
  • A directory can also be marker-less while merely staged (a newer version fetched for a pending
    update), so absence of the marker is not a claim of installation. mtime still picks the winner
    among unmarked candidates, exactly as before.
  • Fallback if upstream renames or drops the marker: the test simply finds nothing and resolution
    degrades to today's mtime-only behavior — a stale tee, never a broken statusline.

Unwrapping is now a fixpoint, not one pass

"Unwrap before you compose" became two rules applied until a pass strips nothing:

  1. guard-shim prefixes (unchanged);
  2. a generated sh -c '<single-quoted string>' adapter with nothing after the closing quote and
    whose carried string itself contains shell syntax
    — unescaped back by dropping sh -c and the
    outer quotes and replacing '\'' with '.

Iterating matters: an operator may already carry several layers from earlier reruns, and a single
peel over three layers leaves three. The shell-syntax-guard step now also states that it applies
only to the unwrapped renderer.

The second condition on rule 2 is provenance, and it is load-bearing. The first push of this
branch peeled any bare sh -c '<string>', and disclosed that as a deliberate, behaviorally
equivalent narrowing. Review showed that claim was wrong, so it is now fixed rather than documented:
an operator whose genuine renderer is sh -c 'ulimit -n' would have had the layer dropped, and
since ulimit -n carries no shell syntax the guard would not restore it — leaving the shim
exec-ing a shell builtin with no shell, so the statusline exits 127 instead of rendering.

Requiring shell syntax in the carried string is a provenance test, not a heuristic: this skill emits
the adapter only for a renderer that carries shell syntax, so an sh -c over a string carrying
none cannot have come from it and is the operator's own. Both target cases still hold — every
generated layer wraps something with shell syntax, so nested generated layers collapse until what
remains is the operator's own, while the genuine-builtin case is preserved verbatim. A trailing word
(sh -c '…' extra) marks a real command and is left alone.

Stated precisely, because the distinction matters: the fixpoint does not always terminate at a bare
renderer. Over a hand-written sh -c 'sh -c '\''my-statusline --flag'\''' the outer layer peels (its
carried string contains quoting) and the inner one does not (my-statusline --flag carries no shell
syntax), leaving exactly one layer. That is the correct outcome — the surviving layer is the
operator's own — and it is still idempotent and non-compounding, which is what the issue asked for.

context-guard 0.4.3 → 0.4.4 with a matching ## [0.4.4] CHANGELOG entry. (The branch originally
bumped 0.4.2 → 0.4.3; the shared hook-utils.sh sync claimed 0.4.3 on main while this branch was
open, so it was renumbered after fast-forwarding rather than co-owning a released version.)

Verification

Regression coverage per the acceptance criteria — and the shim cases are proven non-vacuous,
not merely green:

Evidence Result
bash plugins/context-guard/scripts/statusline-shim.test.sh (post-fix) 36 passed, 0 failed
Same suite against the pre-fix shim 3 failedwant [] got [TEE:uninstalled], [TEE:orphaned] does not contain [TEE:installed], [TEE:orphaned] unexpectedly contains [TEE:orphaned]
Live-cache measurement of the marker 5 superseded context-guard version dirs all carry .orphaned_at; the installed one does not
Doc claim re-fetched this session uninstall named explicitly in the quoted sentence

Two new shim cases: an uninstalled plugin's orphaned tee is not executed and the statusline still
renders with the wrapped exit code preserved
; and an orphaned directory loses to an installed
sibling even when it is newer by mtime, so only the marker can decide the second case.

The P2 fix is a prose-instruction change, so its regressions live where instruction behavior is
graded — two evals, one per direction:

  • id 8, rerun-does-not-compound-the-sh-c-wrap — pins the exact rerun in the issue and asserts
    one sh -c layer, never a nested one. Traced by hand:
    …shim.sh sh -c 'THEME=dark my-statusline --flag' → rule 1 strips the shim → the carried string
    has an inline env assignment, so rule 2 recovers THEME=dark my-statusline --flag → second pass
    strips nothing → re-wrap reproduces the input byte-for-byte.
  • id 9, genuine-sh-c-renderer-is-not-peeled — the opposite direction, added in response to
    review. sh -c 'ulimit -n' carries no shell syntax, so rule 2 does not fire and the adapter
    survives into the printed wiring.

Review findings addressed on this branch

Finding Disposition
Codex P2 — "Preserve genuine sh -c renderers" (sh -c 'ulimit -n' peeled to a broken exec) Fixed, not just documented. Rule 2 now requires shell syntax in the carried string as a provenance test; eval 9 pins it.
Claude review — garbled fallback sentence in the 0.4.4 changelog entry Fixed. Rewritten to "should upstream rename or drop the marker, resolution degrades to exactly what it does today — a stale tee, never a broken statusline."

Gates, all run from the worktree root against origin/main:

Gate Result
shellcheck (shim + test) clean
scripts/check-shell-portability.sh origin/main PASS — 2 files, no unexcused GNU-only constructs
scripts/check-changed-skills.sh origin/main PASS — 0 errors, 2 warnings (both pre-existing: soft line target, no Gotchas surface)
scripts/check-changelog-parity.sh --check PASS
scripts/check-changelog-parity.sh --check-bump origin/main PASS
scripts/check-changelog-parity.sh --check-order PASS — 71 changelogs, newest-first, no duplicate versions
markdownlint-cli2 "plugins/context-guard/**/*.md" PASS — 0 issues, 4 files
scripts/check-cross-plugin-source-drift.sh --check PASS
scripts/check-silent-skips.sh PASS
scripts/validate-plugins.sh PASS — manifests + catalog

Gate scoping notes, stated rather than assumed: check-silent-skips.sh reads only
plugins/*/hooks/*.sh, so the new [[ -e … ]] && continue in scripts/statusline-shim.sh is
outside its corpus by construction — it is not an unreported silent skip but a resolution filter in
a non-hook script, documented inline at the site. check-cross-plugin-source-drift.sh passes
because the two plugins' shims are already not byte-identical and are correctly unregistered.

Related

…ing sh -c wrap

Two defects stranded on merged #1252, both re-verified present on main.

The shim's resolve_tee() picked the newest tee by mtime, skipping only
temp_* marketplace clones. `claude plugin uninstall` does not delete the
version directory — the plugins reference documents that updating or
uninstalling marks it orphaned and removes it automatically 14 days later
— so scripts/statusline-tee.sh stayed on disk and kept being executed for
that whole window, still writing snapshots, with no signal to the
operator. A candidate whose version directory carries the orphan marker
is now skipped.

The marking is documented; the marker's on-disk spelling is not, so it is
measured and recorded at the site (Claude Code 2.1.220), together with
the fallback: a marker upstream renames or drops leaves resolution
exactly as it is today — a stale tee, never a broken statusline. The
undocumented installed_plugins.json the header previously rejected stays
rejected, on both of its original grounds.

setup's "Unwrap before you compose" stripped guard-shim prefixes but not
the `sh -c '<escaped …>'` adapter the skill's own shell-syntax guard
prints, so a rerun read that adapter as the renderer, found shell syntax
in it, and wrapped it again — one layer per run. Unwrapping is now two
rules applied until a pass strips nothing, so several layers from earlier
reruns collapse rather than only the outermost.

Regression coverage: two shim test cases (an uninstalled orphaned tee is
not executed; an orphaned directory loses to an installed sibling even
when it is newer by mtime) and eval 8 for the rerun-idempotence case.
Both shim cases fail against the pre-fix shim and pass after.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 34s —— View job


Security review complete

  • Gather context (diff + changed files)
  • Review statusline-shim.sh changes (orphan-marker check, sh -c unwrap fixpoint)
  • Review statusline-shim.test.sh changes
  • Review skills/setup/SKILL.md changes
  • Review evals.json changes
  • Review CHANGELOG.md / plugin.json changes
  • Post final security review

No security issues found. This PR is scoped to plugins/context-guard/ and touches no workflow files, secrets, credentials, or network/remote code paths. Findings below are the result of a targeted review for logic flaws, injection surfaces, and trust-boundary issues (per the review lane's scope; supply-chain/permissions/trigger patterns are deferred to zizmor).

What changed, and why it doesn't open new surface:

  1. statusline-shim.sh resolve_tee() adds [[ -e "${cand%/scripts/statusline-tee.sh}/.orphaned_at" ]] && continue. cand is derived purely from a fixed glob ("$cache"/*/"$PLUGIN_NAME"/*/scripts/statusline-tee.sh) rooted under ${CLAUDE_CONFIG_DIR:-$HOME/.claude} — no external/attacker-controlled input reaches the path, no subprocess is spawned (pure builtin -e test, consistent with the file's existing pure-builtins invariant), and no new privilege boundary is crossed. A local actor who could already plant .orphaned_at files in that tree would need write access to the user's own Claude config directory, at which point they already control what code the tee/shim executes — this check doesn't create that access, and at most lets such an actor suppress a legitimate tee (an availability nit on an already-fully-compromised path), not gain anything.
  2. statusline-shim.test.sh additions (cases 15–16, orphan_tee() helper) are test-only, use mktemp -d correctly for isolation, and don't affect the runtime shim's behavior in production.
  3. skills/setup/SKILL.md and evals.json changes are prose instructions/eval fixtures that guide the agent's own construction of a settings.json statusLine.command string (unwrap-to-fixpoint, then re-wrap in sh -c only when needed). This is advisory documentation consumed by the assistant performing setup, not executable code shipped by the plugin — it doesn't introduce a new code-execution or injection path beyond what already existed (an operator's statusLine command already runs arbitrary shell by design; that trust boundary is unchanged by this PR).
  4. CHANGELOG.md / plugin.json are metadata-only (version bump 0.4.3 → 0.4.4, changelog entry).

No command/path/template injection, no secret/token handling, no authorization changes, and no GitHub Actions workflow modifications in this diff.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 50s —— View job


Code review complete

  • Gather context (diff + changed files)
  • Review statusline-shim.sh changes (orphan-marker check, sh -c unwrap fixpoint)
  • Review statusline-shim.test.sh changes
  • Review skills/setup/SKILL.md changes
  • Review evals.json changes
  • Review CHANGELOG.md / plugin.json changes
  • Post final review

This is a well-scoped, well-documented fix. The resolve_tee() orphan check is a clean addition (pure builtin -e test, no new subprocess, preserves the file's pure-builtins invariant), the doc citation for the orphan-marking behavior is fetched fresh and matches docs/OFFICIAL-DOCS.md's indexed page, and the header transparently separates what's documented (the marking) from what's measured (the marker's on-disk spelling), with an explicit fallback if upstream changes it. The sh -c fixpoint-unwrap logic in SKILL.md is sound and the new eval (id 8) exercises exactly the compounding-wrap scenario from the issue.

Test coverage: genuinely non-vacuous — case 16 in particular (plugins/context-guard/scripts/statusline-shim.test.sh) is the right test, since it's the only one where an orphaned-but-newer candidate can't win by accident of ordering, only by the marker being honored.

One nit and one open question:

  1. Garbled sentence in the changelog. CHANGELOG.md#L20-L21:

    "...along with the fallback: a marker upstream renames or drops leaves resolution exactly as it is today, never a broken statusline."

    This doesn't parse — reads like a rewrite left words out of order. Likely meant something like "if upstream renames or drops the marker, resolution falls back to exactly what it is today, never a broken statusline." Fix this →

  2. Edge case not covered by the tests or the header's stated fallback: reinstalling the exact same version after an uninstall. statusline-shim.sh#L117 skips a candidate purely on .orphaned_at presence. The header documents the fallback for when upstream removes or renames the marker mechanism entirely ("the test finds nothing … a stale tee, never a broken statusline") — but doesn't address whether Claude Code clears a pre-existing .orphaned_at when a version directory is reused by a fresh install at the same version number. If it doesn't, a legitimately-reinstalled plugin at an already-orphaned version would have its tee wrongly skipped — the inverse failure mode from the one this PR fixes (a working tee going silent, not a stale one lingering). This is speculative (I couldn't verify against a live cache), and pinning the exact same version across an uninstall/reinstall cycle is a narrow scenario, but it's worth a one-line confirmation from the author or a follow-up issue if untested, since it sits right next to the invariant this PR is establishing.

Everything else — the plugin.json/CHANGELOG.md version bump pairing, the deliberate rate-limit-guard follow-up scoping, the shim-revision bump, and the evals.json addition — is consistent and correctly scoped to context-guard per this repo's plugin-isolation rules. I was unable to execute the test suite or shellcheck directly in this sandbox (write/execute operations require interactive approval not available here) — reviewed statically instead; the PR body's reported gate results are consistent with what the diff implies.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be492aa445

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/context-guard/skills/setup/SKILL.md Outdated
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude has reviewed this PR 2 times. The lane skips further automatic reviews after 5; deleting this comment resets the count.

Review found a real defect in the unwrap rule, not just the narrowing the
PR body disclosed. Peeling any bare `sh -c '<string>'` breaks an operator
whose genuine renderer needs a shell but carries no shell syntax:
`sh -c 'ulimit -n'` peels to `ulimit -n`, the shell-syntax guard has
nothing to re-wrap, and the shim ends up exec'ing a shell builtin with no
shell — the statusline exits 127 instead of rendering.

Rule 2 now peels only when the carried string ITSELF contains shell
syntax. That is the provenance test rather than a heuristic: the skill
emits the adapter only for a renderer carrying shell syntax, so an `sh -c`
over a string carrying none cannot have been generated by it and is the
operator's own.

Both target cases still hold. The compounding case peels — every
generated layer wraps something with shell syntax, so nested adapters
still collapse to the innermost renderer. The genuine-builtin case does
not, and is preserved verbatim. Eval 9 pins the preservation case
alongside eval 8's rerun-idempotence case.

Also fixes a garbled sentence in the 0.4.4 changelog entry describing the
marker fallback.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 15s —— View job


Security review complete

  • Gather context (diff + changed files, origin/main...HEAD)
  • Review statusline-shim.sh changes (orphan-marker check, sh -c unwrap fixpoint)
  • Review statusline-shim.test.sh changes
  • Review skills/setup/SKILL.md changes
  • Review evals.json changes
  • Review CHANGELOG.md / plugin.json changes
  • Post final security review

No security issues found. This PR is scoped entirely to plugins/context-guard/ and touches no GitHub Actions workflow files, secrets, credentials, or network/remote-execution paths. This review covers the full current diff at 86c9416 (origin/main...HEAD), including the newest commit made since the prior automated review at be492aa.

Analysis by file:

  1. statusline-shim.sh resolve_tee() — the new orphan check [[ -e "${cand%/scripts/statusline-tee.sh}/.orphaned_at" ]] && continue is a pure builtin parameter-expansion + existence test. cand comes only from a fixed glob rooted at ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/cache, so there's no attacker-controlled input, no subprocess spawn, and no new trust boundary — a local actor who could already plant a .orphaned_at file in that tree already controls the user's Claude config dir and thus already controls what the tee/shim executes. Worst case here is suppressing a legitimate tee (availability nit on an already-compromised path), not a privilege gain. Severity: N/A (no exploitable issue) — Confidence: high.
  2. statusline-shim.test.sh — test-only additions (cases 15–16, orphan_tee() helper), correctly isolated via mktemp -d, no production code path affected.
  3. skills/setup/SKILL.md and evals.json — prose instructions/eval fixtures guiding the assistant's construction of a settings.json statusLine.command string (fixpoint-unwrap, then conditionally re-wrap in sh -c). This is advisory documentation, not executable code shipped by the plugin, and doesn't create a new code-execution or injection path: an operator's statusLine command already runs arbitrary shell by design, and that trust boundary is unchanged. The latest commit (86c9416) specifically tightens rule 2 to require the peeled string itself contain shell syntax before treating an sh -c '...' as a generated adapter — this closes the one correctness gap a reviewer (Codex) flagged (a genuine sh -c 'ulimit -n' renderer previously being misidentified as generated wrapping and stripped down to a bare builtin with no shell to run in). That's a correctness/logic fix for the setup skill's guidance, not a security vulnerability — there's no code execution surface in this repo affected either way, since the skill only prints text for a human/agent to apply.
  4. CHANGELOG.md / plugin.json — metadata only (version bump 0.4.3 → 0.4.4, changelog entries, including a since-fixed wording issue from the prior review).

No command/path/template injection, no secrets or token handling, no authorization changes, and no workflow modifications anywhere in this diff.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 17s —— View job


Code review complete

  • Gather context (diff + changed files)
  • Review statusline-shim.sh changes (orphan-marker check, sh -c unwrap fixpoint)
  • Review statusline-shim.test.sh changes
  • Review skills/setup/SKILL.md changes
  • Review evals.json changes
  • Review CHANGELOG.md / plugin.json changes
  • Post final review

This synchronize pushed a second commit (86c9416) since the last review pass that fixes both open items from that pass on its own:

  1. The Codex nit is already resolved. SKILL.md#L140-L150 now requires the carried string to itself contain shell syntax before rule 2 peels it — sh -c 'ulimit -n' is correctly left intact instead of being unwrapped into a bare ulimit -n that would exit 127 with no shell. Eval 9 (genuine-sh-c-renderer-is-not-peeled) pins this case, alongside eval 8's rerun-idempotence case. Traced by hand against statusline-shim.sh: the "shell syntax" test in rule 2 (SKILL.md#L140-L150) and the shell-syntax guard it cross-references (SKILL.md#L186-L189) use identical criteria, so the provenance test is self-consistent rather than two independently-drifting heuristics.
  2. The garbled changelog sentence is fixed. CHANGELOG.md#L20-L21 now reads cleanly: "...along with the fallback: should upstream rename or drop the marker, resolution degrades to exactly what it does today — a stale tee, never a broken statusline."

Remaining open item from the prior pass — still unaddressed, not a blocker: the reinstall-same-version edge case (statusline-shim.sh#L117) wasn't touched by the follow-up commit. The header documents the fallback for when upstream removes or renames the marker mechanism, but not whether Claude Code clears a pre-existing .orphaned_at when a version directory is reused by a fresh install at the same version. If it doesn't, a legitimately reinstalled plugin at an already-orphaned version number would have its tee silently and permanently skipped — the inverse failure mode from the one this PR fixes. Narrow scenario, and the PR body scopes it out implicitly by not claiming to have tested it; worth a one-line confirmation or a filed follow-up rather than blocking here.

Everything else checks out on static review (execute permissions aren't available in this sandbox, consistent with the prior pass, so I read rather than ran the suite):

  • resolve_tee()'s new orphan check (statusline-shim.sh#L117) is a pure builtin -e test consistent with the file's stated pure-builtins invariant, and the path derivation (${cand%/scripts/statusline-tee.sh}/.orphaned_at) is correct against the glob shape used elsewhere in the function.
  • Test coverage in statusline-shim.test.sh is genuinely non-vacuous — case 16 in particular (orphaned-but-newer-by-mtime vs. an installed sibling) is the one case that can only pass if the marker is actually honored, not by accident of iteration order.
  • evals.json ids 8 and 9 are sequential, non-duplicated, and each targets a distinct defect (compounding wrap vs. genuine-renderer preservation) rather than overlapping.
  • plugin.json/CHANGELOG.md version bump (0.4.3 → 0.4.4) is paired correctly, and the entry format matches sibling entries in the same file.
  • Repo-agnostic per CLAUDE.md: config-dir resolution goes through ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, no hardcoded paths or project-specific values introduced.

@kyle-sexton
kyle-sexton merged commit 3cbfaa8 into main Jul 31, 2026
36 checks passed
@kyle-sexton
kyle-sexton deleted the fix/1787-context-guard-shim-uninstall-and-shc-wrap branch July 31, 2026 04:15
@kyle-sexton
kyle-sexton restored the fix/1787-context-guard-shim-uninstall-and-shc-wrap branch August 1, 2026 01:39
kyle-sexton added a commit that referenced this pull request Aug 9, 2026
…usline renderer (#1999)

## Summary

`/context-guard:setup check` printed an operator's own `sh -c
'<command>'` renderer back to them
wrapped in a second one — `sh -c 'sh -c '\''ulimit -n'\'''` — adding one
more shell on every
statusline refresh.

PR #1844 fixed the mechanism the finding named: unwrap rule 2
(`skills/setup/SKILL.md:143-153`)
correctly declines to peel an `sh -c` the operator wrote themselves,
because the string it carries
holds no shell syntax. But the finding's complaint kept reproducing by a
second path. The
*preserved* renderer then reached the shell-syntax guard, which listed
bare **quoting** among the
triggers needing an adapter — so it matched on its own quote characters
and was wrapped anyway. The
escape hatch that was supposed to stop that had a true condition and a
**false** rationale: it
claimed such a command meant "rule 2 above having been skipped", when
rule 2 had in fact run and
declined.

A finding is discharged when its complaint stops reproducing, not when
the line it pointed at
changes. Decisive proof it had not: a faithful implementation of the
prose failed this skill's own
**eval 9** (`skills/setup/evals/evals.json:107-118`), whose fourth
expectation is "prints exactly
one shim invocation and exactly one `sh -c` layer".

## What changed

**Quoting is no longer a trigger, and the trigger test is scoped to
top-level syntax.** The
`statusLine` `command` field "runs in a shell"
([docs](https://code.claude.com/docs/en/statusline), fetched
2026-08-07), so that shell splits the
line into words and consumes the quotes before `statusline-shim.sh` sees
ARGV, and the shim `exec`s
those words unchanged. A quoted argument therefore already survives the
plain wrapped form intact —
including an operator's `sh -c '<string>'`, where `sh` is the executable
and `-c` and the carried
string are two ordinary ARGV words.

The guard now fires only on syntax no ARGV word can express — an inline
env assignment, a pipe,
`&&`, `||`, `;`, a trailing `&`, a redirection — **and only where it
stands unquoted at the top
level**. That second clause matters on its own: without it, "carries
shell syntax" reads as a
substring test, and an implementer meeting `sh -c 'a | b'` at the guard
sees a pipe and wraps it,
reproducing the double wrap by a third path.

**Rule 2's provenance test is now three explicit branches, because
scoping the guard silently
rescoped it too.** Rule 2 cites the guard to define "carries shell
syntax", so narrowing the guard
narrowed rule 2 with it — and a wrap whose syntax sits inside inner
quotes then looked
operator-written. Two of the three branches are therefore keyed to the
**shape** of the carried
string, not the syntax in it, and are explicitly exempt from the
top-level scoping:

- **A — the carried string is itself an `sh -c '<string>'`.** Always a
generated layer: an
operator's renderer is at most one `sh -c` deep. Without this the peel
stops one layer early and
  hands back the two-layer wrap it exists to collapse.
- **B — the carried string begins with a guard-shim prefix.** This skill
never puts a shim inside
an adapter. Sealed there it is invisible to rule 1, which strips only
*leading* prefixes, so the
composed wiring named the sibling shim a **second** time and ran its tee
twice per refresh — the
`context → rate → rate → renderer` duplication this skill exists to
prevent, reachable for anyone
  who wired `rate-limit-guard` first and ran the pre-0.4.8 guard.
- **C — the carried string is a command the guard would wrap.** The only
shape this skill's own
  adapter ever carries.

Absent all three it is the operator's and is preserved. One shape stays
ambiguous **by design** — a
single `sh -c` over a merely-quoted command, which the buggy guard also
emitted and which carries no
evidence either way. It is preserved: one spurious shell per refresh is
cheaper than a statusline
broken by peeling on a guess. The prose says so rather than implying a
re-run cleans it up.

**The hatch's condition and rationale now agree**, because the hatch is
gone and the guard's own
condition does the work. The idempotency claim is scoped to what is
actually true: for an input that
is itself `sh -c '<string>'`, the two tests leave exactly one layer.
That does **not** generalize to
a layer count — a plain renderer takes none, and `sh -c 'ulimit -n' &&
echo ok` correctly takes
**two**, since `&&` cannot be an ARGV word and peeling the inner `sh -c`
would strand the builtin.
What is invariant is that peel and wrap are inverses, which is what
makes a re-run byte-identical at
whatever count the renderer needs.

**Three evals now pin this.** Eval 10
(`multiple-generated-layers-collapse-in-one-run`) was added
earlier on this branch and deleted by `81c03090e7` with no rationale in
the commit message or the
CHANGELOG; it covers the nested-layer case exactly, and the scoping
change broke it. It is restored
with a fifth expectation pinning branch A. Eval 11
(`adapter-hiding-a-sibling-shim-is-peeled`) is
new and pins branch B.

**`check`'s shim-drift report stopped claiming a differing installed
copy is harmless.** It said an
older or hand-edited copy "still resolves the newest tee", which stopped
being true when
`# shim-revision: 3` added the orphan skip: a copy predating it picks by
mtime alone, so it also
resolves a tee left behind by an uninstalled plugin and keeps teeing for
the whole ~14-day grace
window. It now states which of the two behaviors the installed copy has.

`0.4.7` → `0.4.8` with the matching CHANGELOG entry.

## Verification

- Traced end to end for eval 9's input `sh -c 'ulimit -n'`: rule 1 finds
no shim prefix; rule 2
declines on both branches (carried string is not an `sh -c` shape, and
the guard would leave it
alone); the guard finds no unquoted top-level trigger; the plain wrapped
form is printed — one
  shim invocation, one `sh -c` layer. All four expectations met.
- Eval 10's input `bash <shim> sh -c 'sh -c '\''THEME=dark
my-statusline'\'''`: rule 1 strips the
shim, rule 2's nested branch peels the outer layer, rule 2's guard
branch peels the inner one, a
third pass strips nothing, the guard fires once on `THEME=dark
my-statusline`. One layer.
- Eval 11's input (a `rate-limit-guard` shim sealed inside a preserved
adapter): branch B peels the
adapter, exposing the shim to rule 1, which strips it. The combined
wiring names each shim exactly
  once.
- Eval 8 is unaffected: rule 2 peels, the env assignment lands unquoted
at top level, the guard
fires once, output is byte-identical to the wiring already in
settings.json.
- No eval was loosened; one was restored and strengthened, one added.
Evals 1-7 carry no renderer
  whose behavior the trigger-list change moves.
- **A fresh-context verifier CHALLENGED this fix twice, and was right
both times.** Round one caught
that the guard scoping had silently regressed the nested-layer case;
round two caught a false
idempotency invariant and the sealed-sibling-shim duplication. Branches
A and B, both restored and
  new evals, and the scoped invariant all come from those challenges.
- Gates green from the worktree root: changelog parity (`--check`,
`--check-order`, `--check-bump`
against `origin/main`), `check-changed-skills.sh` (context-guard/setup
PASS, 0 errors),
orphaned-fixture gate, shell-portability, skill-portability,
markdownlint, and the context-guard
  suite (shim 36, tee 47, zone 73 — 0 failures).
- SKILL.md is 391/500 lines.

## Related

- Reopens review finding `PRRT_kwDOTCGFQM6Tzj7l` on #1252, which a prior
pass closed as
  already-fixed and a fresh-context verifier correctly challenged.
- Builds on `3cbfaa89ae` (#1844), which fixed unwrap rule 2 and the
shim's orphan skip. That
  behavior is untouched here.
- Out of scope, filed for follow-up:
`plugins/rate-limit-guard/skills/setup/SKILL.md` still lists
bare quoting as a trigger and has no adapter-peel rule at all, so the
sibling skill reproduces
  this same finding on its own surface. Not touched here.

No linked issue

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 9, 2026
…1866)

Closes #1849

## Summary

Port the context-guard orphan-aware `resolve_tee()` fix into
`rate-limit-guard` so an uninstalled plugin stops teeing through
superseded version directories during the ~14-day orphan window.

## Fix

`plugins/rate-limit-guard/scripts/statusline-shim.sh` now skips cache
candidates whose version directory carries `.orphaned_at`, matching the
supported uninstall/update marking documented for Claude Code (same
shape as #1787 / PR #1844). Shim revision bumped to 3. Manifest `0.4.0`
→ `0.4.1` with a matching CHANGELOG entry. Two regression cases added:
an orphaned-only install is not executed (statusline still renders), and
an orphaned newer-mtime sibling loses to an installed one. Shims stay
deliberately unregistered as a byte-identical cluster.

## Verification

| Evidence | Result |
| --- | --- |
| `bash plugins/rate-limit-guard/scripts/statusline-shim.test.sh` | **36
passed, 0 failed** (includes new orphan cases) |
| `shellcheck` (shim + test) | clean |
| `scripts/check-shell-portability.sh origin/main` | PASS — 2 files |
| `scripts/check-changelog-parity.sh --check-bump origin/main` | PASS |
| `scripts/check-cross-plugin-source-drift.sh --check` | PASS (shims
remain unregistered; deliberate) |

## Related

- Refs #1787 — original context-guard defect this ports
- Refs #1844
— sibling fix for context-guard

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kyle-sexton
kyle-sexton deleted the fix/1787-context-guard-shim-uninstall-and-shc-wrap branch August 14, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

context-guard: an uninstalled plugin's tee keeps executing through the shim, plus a compounding sh -c wrap

1 participant