Compare commits

...

18 Commits

Author SHA1 Message Date
github-actions[bot]
3ff99e42f5 bump(remember): c2c82ab5 → a4ff96f3 2026-06-01 09:12:37 +00:00
Bryan Thompson
0d82eac145 bump: switch to per-entry PR mode (one PR per stale plugin) (#2051)
* bump: switch to per-entry PR mode (one PR per stale plugin)

Replaces the single batched bump PR with one PR per stale plugin so a
single failing plugin no longer blocks the rest. Pins to a feature
branch of the bump-plugin-shas action that adds 'pr-mode: per-entry';
re-pin to the merge commit on the action's main when that lands.

- pr-mode: per-entry → one PR per plugin on bump/<slug>
- max_bumps default lowered 130 → 30 (per-entry scans cost more)
- scan dispatch fanned out over pr-urls JSON (one per per-entry branch)
- header comments updated for per-entry semantics

* bump: re-pin to merged composite action SHA on -community main

The pr-mode: per-entry input now lives on main of the bump-plugin-shas
action (merged at e2019b2a). Update the pin and drop the now-stale
header comment that tracked the feature branch.

* bump: dispatch all three required checks per per-entry PR

Bump PRs are opened with GITHUB_TOKEN, which doesn't fire on:pull_request
(recursion guard). The per-entry cutover already dispatched scan-plugins.yml
per branch to satisfy the `scan` required check, but `check` (Check MCP URLs)
and `validate` (Validate Plugins) are also required on main and likewise
never fired — leaving every bump PR BLOCKED on missing checks (observed on
the batched #2079, which only cleared after a human-authored push re-fired
the pull_request workflows).

Fix: dispatch all three workflows per per-entry bump branch. Each runs its
job unconditionally on workflow_dispatch, so the check run lands on the
branch HEAD (= PR head) and satisfies the required check.

- validate-plugins.yml: add workflow_dispatch trigger (check-mcp-urls.yml
  already had one). gh workflow run requires the trigger on the default
  branch; this lands together with the per-entry bump so main stays
  consistent.
- bump-plugin-shas.yml: loop the dispatch over
  {scan-plugins,check-mcp-urls,validate-plugins}; tolerate a single
  transient dispatch failure (warn, don't abort) so one hiccup can't
  strand the rest of the batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* bump: fail the per-entry check-dispatch step when a dispatch fails

The dispatch step logged each failed gh workflow run as a warning and exited 0, so a transient API error or rate limit could leave a per-entry bump PR missing a required check while the bump run still showed green. The composite action skips slugs with an open PR, so the stranded PR was never retried.

Attempt every dispatch (one failure must not strand the other branches), record failures via a temp file (the while loop runs in a pipe subshell), then emit an error and exit non-zero if any dispatch failed, so the bump run goes red and the affected PR can be re-dispatched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 06:38:22 +01:00
Mohamed Hegazy
3d368d2972 Merge pull request #2105 from anthropics/fix-2098-output-config-format
security-guidance: migrate _call_claude from deprecated output_format to output_config.format (#2098)
2026-05-30 22:32:02 -07:00
Mohamed Hegazy
84011d43b1 security-guidance: migrate from deprecated output_format to output_config.format (#2098)
Fixes #2098. The Anthropic Messages API moved structured-output
schema specification from a top-level `output_format` field to a
nested `output_config.format` field, per
https://platform.claude.com/docs/en/build-with-claude/structured-outputs.

Per docs the old form "will continue working for a transition period"
— and indeed for api-key + non-streaming auth it still returns HTTP
200 (verified via live API). But OAuth Bearer users with CLI 2.1.158
hit `invalid_request_error: output_format: This field is deprecated.
Use 'output_config.format' instead.` consistently — reporter saw 462
errors in one day. The trigger appears to be auth mode + possibly
stream:true (their controlled curl bypass used Bearer + stream=true);
api-key + non-streaming was my initial repro attempt and didn't fire.

The bug only affected `_call_claude` (the legacy direct-urllib path).
The agentic `_agentic_review` path goes through claude_agent_sdk →
subprocesses to the `claude` CLI binary, which already uses the new
`output_config.format` shape correctly (per src/utils/sideQuery.ts:263
in claude-cli-internal). So this PR only needs to fix the plugin's
direct HTTP path.

This commit:

1. llm.py: rewrite the payload literal in `_call_claude` to use
   `output_config: { format: { type: 'json_schema', schema: ... } }`
   instead of top-level `output_format`.

2. llm.py: in the adaptive-thinking branch, MERGE `effort: "high"`
   into the existing `output_config` dict instead of reassigning.
   Reassignment would silently clobber the format schema set in (1).
   The pre-existing code did `payload["output_config"] = {"effort":
   "high"}` which was correct WHEN output_format was top-level (and
   output_config wasn't otherwise used). With the migration the
   existing dict carries the schema, so we extend it not replace it.

Verified locally on macOS Python 3.13:

  - py_compile clean.
  - Existing 401 tests still pass — 0 regression.
  - 6 new tests in test_2098_output_config_format.py (added to
    internal test suite at sg-staging/tests/, not in this PR):

      * 2 static-shape: the `_call_claude` source no longer contains
        top-level `"output_format":` AND uses `output_config`. The
        adaptive-thinking branch does NOT reassign output_config (and
        DOES set output_config['effort']). Catches the regression
        class where a future refactor reintroduces either bug.
      * 2 payload-shape unit (mocked urllib): both thinking_budget=0
        and thinking_budget>0+adaptive code paths produce a payload
        with the correct `output_config.format` shape AND no
        `output_format` top-level. The adaptive path verifies both
        `format` and `effort` coexist in output_config (i.e., the
        merge fix works).
      * 2 live-API gating (skip-on-no-key): the new shape returns
        HTTP 200 against api.anthropic.com; the old shape's current
        status is recorded for canary purposes (still 200 for
        api-key today, but reporter shows it's 400 for OAuth).

  - Full suite: 405/405 pass + 2 skipped (live API tests, opt-in).
  - The reporter's exact deprecation 400 message reproduces if you
    swap auth to OAuth Bearer + stream:true (could not test locally
    without extracting the keychain OAuth token, which was out of
    scope). The fix shape is API-contract-level so it doesn't depend
    on which auth mode triggers the 400.

NOT verified end-to-end via OAuth-authenticated plugin invocation on
my machine (auto-mode classifier correctly declined to extract the
keychain token). Reporter's 462 production errors + the docs
migration notice + the live-API HTTP 200 on the new form are
sufficient evidence to ship.

Closes #2098.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:11:41 -07:00
Mohamed Hegazy
2a822c0787 Merge pull request #2101 from anthropics/fix-2099-quotepath-global
security-guidance: move core.quotePath=false to GIT_CMD globally (#2099 followup)
2026-05-30 20:07:06 -07:00
Mohamed Hegazy
a40c9f1e83 security-guidance: move core.quotePath=false to GIT_CMD globally (#2099 followup)
Followup to PR #2086 (which added the flag to 4 specific git call
sites) and PR #2100 (text=True purge for #2099).

The Windows reporter for #2099 noticed more git invocations still
lacked the flag — rev-parse path queries (--show-toplevel, --git-dir,
--git-common-dir), reflog %gs subjects, and `git show <sha>:<path>`
all output paths but the per-site PR #2086 approach missed them. The
result: an Arabic-named directory shows up via _git_diff_range but
rev-parse-emitted paths get C-quoted, breaking downstream
os.path.isabs() checks.

Fix: add `-c core.quotePath=false` to GIT_CMD itself as the 4th
config-set. Every subprocess.run using the *GIT_CMD splat picks it
up automatically — diff feeders, rev-parse path queries, reflog log,
ls-files, status, git show. No more per-site flag duplication.

This commit:

1. gitutil.py: add -c core.quotePath=false to GIT_CMD.
2. Remove the now-redundant per-site flags at the 7 call sites that
   previously had inline -c core.quotePath=false (cleanup, since the
   global setting subsumes them):
   gitutil.py: _git_diff_range, _git_name_only, _git_status_porcelain,
               get_git_diff (4 sites)
   diffstate.py: _list_untracked git ls-files (1 site)
   security_reminder_hook.py: commit-review git diff + git show (2 sites)

Verified locally on latest main (post PR #2100 merge) with macOS
Python 3.13:

  - py_compile clean on all 3 modified files.
  - Bare main BEFORE my fix: 400/401 pass — 1 failure proves the gap
    (test_git_cmd_contains_quotepath_false catches the missing flag).
  - Main + my fix: 401/401 pass.
  - 23 new tests in test_quotepath_global.py (added to internal test
    suite at sg-staging/tests/, not in this PR):

      * 1 GIT_CMD-level: GIT_CMD list contains core.quotePath=false as
        a (-c, value) pair. Single source of truth — single place a
        future PR will be caught if the flag gets dropped.
      * 10 static-shape (one per hooks/*.py): every subprocess.run
        uses the *GIT_CMD splat (no bare git invocation that would
        bypass the global flag).
      * 12 end-to-end (parametrized over Arabic, Hebrew, CJK directory
        names): real git repo, _git_diff_range emits unquoted diff,
        extract_file_paths_from_diff and parse_diff_into_files keep
        the non-ASCII path in their output, _git_toplevel returns the
        non-ASCII path intact.

  - 1 staleness fix in test_diff_parser_non_ascii.py
    (test_no_bare_git_diff_or_show_without_flag): updated to accept
    EITHER inline core.quotePath=false OR *GIT_CMD splat (which
    globally provides it).

NOT verified end-to-end on Windows with a non-ASCII repo root path.
The new global-flag test pins the contract permanently, and the
parametrized macOS tests confirm parser behavior on ASCII-control
paths in non-ASCII directories. The Windows-specific rev-parse
quoting behavior follows from the same git contract our macOS test
environment exercises (POSIX git always emits raw UTF-8 regardless
of quotePath; on Windows the flag is what makes output raw).

Closes the #2099 followup specifically about _git_diff_range /
rev-parse --show-toplevel / git log %gs paths slipping past.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 12:19:38 -07:00
Mohamed Hegazy
c7a3e2ffa0 Merge pull request #2100 from anthropics/fix-2099-text-true-pythonutf8
security-guidance: purge text=True from subprocess.run + bake PYTHONUTF8=1 (#2099)
2026-05-30 12:14:03 -07:00
Mohamed Hegazy
1ecf3d1bac security-guidance: purge text=True from subprocess.run + bake PYTHONUTF8=1 (#2099)
URGENT WINDOWS FIX. Sibling of #2056 / PR #2075 but covering 14 more
sites that PR #2075 missed.

The bug class: on Windows with cp1252 default encoding (typical
en-US locale), `subprocess.run(..., text=True)` decodes child stdout
AND stderr via `locale.getpreferredencoding()`. When git emits a
UTF-8 byte that's undefined in cp1252 (e.g. `0x81` from ف, present
in any path/filename/branch ref/commit message containing
Arabic/Hebrew/CJK), Python's internal `_readerthread` raises
UnicodeDecodeError. The thread crash is silent in Python 3.13+ (only
printed to stderr), but `subprocess.run` returns `stdout=None` and
the caller AttributeErrors on `.strip()`. The user sees a misleading
"WinError 267" or similar catch-all message instead of the real
decode failure.

PR #2075 fixed 6 specific helpers in `diffstate.py` / `gitutil.py`.
This commit covers the 14 survivors. Plus a defense-in-depth belt:
`PYTHONUTF8=1` exported by sg-python.sh.

This commit:

1. sg-python.sh: `export PYTHONUTF8=1` (PEP 540). No-op on
   macOS/Linux (already UTF-8). On Windows, makes Python's
   `locale.getpreferredencoding()` return UTF-8 instead of cp1252 —
   so even if a future regression slips in text=True, the decode
   succeeds. Must be set BEFORE Python starts; changing it from
   inside the interpreter has no effect.

2. gitutil.py: convert 8 subprocess.run sites from
   `capture_output=True, text=True` to `capture_output=True` +
   manual `r.stdout.decode("utf-8", errors="replace")`:
     - _git_rev_parse_head           (stdout = SHA, stderr risk)
     - _find_git_index               (stdout = PATH, primary bug site)
     - _temp_index git add           (returncode only, stderr risk)
     - _git_toplevel                 (stdout = PATH, primary bug site)
     - _git_dir                      (stdout = PATH, primary bug site)
     - _git_rev_list_range           (stdout = SHAs, stderr risk)
     - _detect_main_branch           (stdout = ref, stderr risk)
     - merge-base --is-ancestor      (returncode only, stderr risk)

3. security_reminder_hook.py: convert 6 subprocess.run sites
   (rev-parse @{u}/@{u}@{1}/local_ref, merge-base, HEAD lookup,
   reflog SHA resolution) — same pattern.

4. security_reminder_hook.py: fix the misleading log line in
   handle_user_prompt_submit. Was:
     debug_log("Failed to capture git baseline (not a git repo?)")
   Now includes the cwd in the message so the next reporter doesn't
   waste an hour grepping for the real WinError, per reporter's
   secondary finding.

Verified locally on macOS Python 3.13:

  - py_compile clean on all modified files.
  - bash -n sg-python.sh clean.
  - sg-python.sh actually propagates PYTHONUTF8=1 to child Python
    (verified via probe — sys.flags.utf8_mode=1).
  - Existing 353 tests still pass — 0 regression.
  - 25 new tests in test_2099_subprocess_text_true.py:
      * 10 static-shape catchers (one per hooks/*.py file). Any
        future PR that reintroduces text=True OR encoding= in
        subprocess.run fails this check at PR time. Single source
        of truth for the regression class.
      * 2 sg-python.sh verifiers (literal export + actual
        propagation to child Python).
      * 5 macOS end-to-end against a real git repo containing
        non-cp1252 content (`ف.py` filename): _git_toplevel,
        _git_dir, _find_git_index, _git_rev_parse_head,
        _git_rev_list_range all return clean values without
        AttributeError / UnicodeDecodeError.
      * 7 round-trip bytes-decode pattern verifiers (parametrized
        over Arabic ف, Hebrew א, Japanese 案, raw 0x81, multiple
        cp1252-undefined bytes, real-world git diff headers).
      * 1 sanity check that cp1252 strict DOES raise on 0x81
        (proves the test environment can catch the bug class).

  - Full suite: 378/378 pass in 5.56s.

  - End-to-end tmux smoke test driving real claude 2.1.145 CLI:
    Made a git commit via Bash tool call. All 4 hooks fired through
    the fixed plugin path:
      11:28:16.730  Hook called with args: …/plugin/hooks/security_reminder_hook.py
      11:28:16.734  Processing: hook_event=UserPromptSubmit
      11:28:16.825  Captured git baseline: 445f7f213256
      11:28:19.923  Hook called with args: …
      11:28:19.923  Processing: hook_event=PostToolUse, tool=Bash
      11:28:19.971  Commit review: detected git commit in command
      11:28:20.020  Commit review: 1/1 sha(s) resolved, 1 files
      11:28:26.415  Hook called with args: …
      11:28:26.416  Processing: hook_event=Stop
      11:28:26.550  Stop hook: empty review set

    Confirms: PYTHONUTF8=1 export doesn't break anything; converted
    helpers (_git_rev_parse_head, _git_toplevel, _git_dir,
    _find_git_index) run end-to-end without issue on the happy path.

NOT verified end-to-end on Windows with actual non-cp1252 content
in path/filename/stderr. The static-shape catcher pins the
regression class permanently. Reporter's PYTHONUTF8=1 workaround
empirically proves the encoding-mode fix works for the affected
scenario; this commit just bakes it in.

Closes #2099.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 11:29:07 -07:00
Mohamed Hegazy
c40770ae5a Merge pull request #2078 from anthropics/fix-1868-claude-config-dir
security-guidance: respect CLAUDE_CONFIG_DIR for plugin state files (#1868)
2026-05-29 16:14:35 -07:00
github-actions[bot]
7a0a7f486e Bump 58 plugin SHA pin(s) to upstream HEAD (#2079)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-29 21:18:49 +00:00
Mohamed Hegazy
42487ee6fd Merge pull request #2091 from anthropics/fix-2089-if-clause-regression
URGENT: security-guidance: fix #2089 regression — split |-joined if clauses
2026-05-29 13:39:45 -07:00
Mohamed Hegazy
bc07f7a1fd security-guidance: 5 precise if entries fixing #2089 regression + gt support
URGENT REGRESSION FIX. PR #2076 (Graphite gt workflow) gated the
PostToolUse commit/push hooks with:

    "if": "Bash(git commit:*)|Bash(gt create:*)|Bash(gt modify:*)"
    "if": "Bash(git push:*)|Bash(gt submit:*)"

mirroring the regex-OR idiom that `matcher` uses
("Edit|Write|MultiEdit|NotebookEdit"). But `if` is NOT a regex —
it's a SINGLE permission-rule string. The CC harness's dispatch
filter parses the entire `if` value as one rule of shape
`ToolName(rule_content)` via:

    let firstParen = H.indexOf("(");
    let lastParen  = H.lastIndexOf(")");      // searches from END
    if (lastParen !== H.length - 1) return { toolName: H };
    let toolName    = H.slice(0, firstParen);
    let ruleContent = H.slice(firstParen + 1, lastParen);

Applied to the broken commit clause:
    toolName    = "Bash"
    ruleContent = "git commit:*)|Bash(gt create:*)|Bash(gt modify:*"

The garbled `ruleContent` never matches any real command, so the
hook never fires — for ANY workflow, not just gt. The plugin's
deepest review layer was dead in production for all users on builds
shipping PR #2076.

Fix shape: split into separate hook entries, each with its own
well-formed single-rule `if` clause. The Python hook self-routes
commit vs push via the bash-command regexes and dedups concurrent
spawns via `_claim_bash_hook_once`, so multiple entries firing the
same script is safe.

This commit:

1. hooks.json: 5 precise entries (one per command shape) instead of
   the broken |-joined 2-entry form. Restores the original commit/
   push behavior bit-for-bit (`Bash(git commit:*)` + `Bash(git push:*)`
   are unchanged from pre-#2076), and adds 3 separate entries for
   the Graphite commands (`Bash(gt create:*)`, `Bash(gt modify:*)`,
   `Bash(gt submit:*)`). No git behavior change.

   The earlier draft used the broader `Bash(git *)` + `Bash(gt *)`
   per the reporter's suggestion, but that has a real cost: every
   `git status` / `git log` / `git diff` would spawn the Python
   hook only to early-exit via the regex matcher. Precise per-command
   entries avoid the spawn overhead and match the pre-#2076 cost
   profile exactly.

2. security_reminder_hook.py: widen `_GIT_COMMIT_RE` to tolerate
   `git -C <path>` and `git -c k=v` global options between `git`
   and `commit` (mirrors `_GIT_PUSH_RE`'s long-standing tolerance).
   Without this, `git -C /repo commit` is silently dropped by the
   handler — reporter flagged this as the secondary finding.

Verified locally on macOS Python 3.13:

  - hooks.json valid JSON, 5 `if` clauses each parses to a single
    `{toolName: "Bash", ruleContent: "<command>:*"}` pair.
  - py_compile security_reminder_hook.py clean.
  - 9-case regex sanity: all 4 commit forms match (bare, -C path,
    -c k=v, gt create/modify); 3 non-commit forms reject (status,
    gt submit, gt log). Pre-fix would reject -C path form.
  - 7 new tests in test_2089_if_clause_validity.py + 2 updated tests
    in test_gt_graphite_workflow.py:
      * 12 sanity tests for a Python parser mirroring harness's BA(H)
        — pinned so a future refactor can't silently start accepting
        the broken form.
      * 2 hooks.json validity: every `if` clause parses as a single
        valid rule; at least one if-gated hook exists.
      * 1 post-fix structure: separate entries cover git AND gt.
      * 2 updated gt-coverage: SOME clause covers git, SOME clause
        covers gt (no longer requires both in the same |-joined
        clause, which was the broken shape).

    TDD-verified the test catches the bug: temporarily restored
    main's broken |-joined hooks.json, ran the new test, saw
    `test_every_if_clause_is_single_valid_rule` fail with a clear
    error explaining #2089's cause. Restored fix, test passes.

  - Full suite: 336/353 pass (17 unrelated failures from open PRs
    #2078 / #2086 not in this branch).

NOT verified end-to-end with a real CC instance triggering the hooks
on a git or gt commit. The static-shape tests catch the regression
class and the regex sanity tests pin the `git -C` tolerance, but
the asyncRewake feedback loop needs runtime verification.

Closes #2089. Restores the closes for #2048 that PR #2076 attempted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:22:20 -07:00
Mohamed Hegazy
9e150cfd48 Merge pull request #2086 from anthropics/fix-2082-diff-parser-non-ascii
security-guidance: pass core.quotePath=false to diff feeders (#2082)
2026-05-29 08:11:25 -07:00
Mohamed Hegazy
8435428dfc Merge pull request #2077 from anthropics/fix-1358-1375-1783-hook-output-protocol
security-guidance: emit findings via hookSpecificOutput.additionalContext (#1358 #1375 #1783)
2026-05-29 00:20:48 -07:00
Mohamed Hegazy
0d22ba3501 security-guidance: respect CLAUDE_CONFIG_DIR for plugin state files (#1868)
Fixes #1868 — when CLAUDE_CONFIG_DIR is set to a non-default location
(e.g. ~/.config/claude for XDG compliance, or a multi-tenant install
path), the plugin still wrote state files to the hardcoded ~/.claude/
path, leaving stale state and breaking CLAUDE_CONFIG_DIR's purpose.

Resolution precedence (highest first):
  1. SECURITY_WARNINGS_STATE_DIR  — plugin-specific override (existing)
  2. CLAUDE_CONFIG_DIR/security    — CC's config-dir env (new — #1868)
  3. ~/.claude/security            — default fallback (unchanged)

Empty-string env vars (e.g. CLAUDE_CONFIG_DIR= in a misconfigured
shell) are treated as not-set so the empty path doesn't collide with
os.path.join and silently write to /security at the filesystem root.

Implementation: a single state_dir() helper in _base.py is the source
of truth for resolution. All five modules that previously had inline
SECURITY_WARNINGS_STATE_DIR / ~/.claude/security resolutions
(_base.py, session_state.py, ensure_agent_sdk.py, llm.py, and one
site in security_reminder_hook.py) now call state_dir() instead.
Re-implementing the precedence inline risks drift — one module gets
a future fix, others don't.

The helper is called per-invocation rather than cached at import time
so test monkeypatches of the env vars take effect, and so a long-
running test or future shared-process scenario can change the env
between calls and have the next call observe the new value. The
per-call cost is negligible compared to the subprocess-spawn cost
the hooks pay every fire in production.

Three hardcoded ~/.claude/security strings remain but are NOT
functional resolutions:
  - _base.py:39: the fallback BRANCH inside state_dir() itself
  - ensure_agent_sdk.py:6, :11: docstring text describing default
                                location for users

Verified locally on macOS Python 3.13:

  - py_compile clean on all 5 modified files.
  - Existing 45 smoke + extensibility tests still pass.
  - 14 new tests in test_claude_config_dir.py (added to internal test
    suite at sg-staging/tests/, not in this PR):

      * 7 resolution-semantics: default fallback, CLAUDE_CONFIG_DIR
        override, SECURITY_WARNINGS_STATE_DIR beats both, tilde
        expansion, empty-string handling (CLAUDE_CONFIG_DIR= must
        fall back, NOT join to /security).
      * 4 static-shape: each of session_state / ensure_agent_sdk /
        llm / security_reminder_hook either imports state_dir from
        _base OR has zero resolution patterns. Catches the
        regression where someone adds a new state-file writer and
        re-implements resolution inline, missing the
        CLAUDE_CONFIG_DIR branch.
      * 3 end-to-end: with CLAUDE_CONFIG_DIR set, get_state_file /
        get_lock_file return paths under <CLAUDE_CONFIG_DIR>/security/;
        save_state round-trip writes a file to the redirected path
        and re-reads the same contents.

  - 59/59 pass total (45 existing + 14 new) in 2.54s.

NOT verified end-to-end with a real CC instance setting
CLAUDE_CONFIG_DIR. The shape tests catch the regression class
(hardcoded ~/.claude/), and the end-to-end test pins the behavior
that user state files actually land at the redirected path.

Closes #1868.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:57:10 -07:00
Mohamed Hegazy
37ffc76005 security-guidance: emit findings via hookSpecificOutput.additionalContext (#1358 #1375 #1783)
Fixes #1358, #1375, and #1783 — three related complaints about the
hook output protocol used at the three asyncRewake exit-2 sites
(handle_commit_review_posttooluse, handle_push_sweep_posttooluse,
handle_stop_hook).

The old shape at each site was:

  emit_metrics({...})                              # JSON to stdout (metrics)
  sys.stderr.write(banner + guidance + suffix)     # plain text to stderr
  sys.exit(2)                                      # asyncRewake trigger

That triggered three reported problems:

  #1375: CC's hook system parsing stdout for a SyncHookJSONOutput sees
         only the bare metrics dict — no findings reason — and on older
         CC versions surfaces a 'json output validation failed' error
         because stderr's plain text isn't valid JSON.
  #1783: CC's UI shows 'Permission to use Edit has been denied' with no
         permissionDecisionReason — the stderr text is invisible to that
         UI surface; CC only renders fields it can find in the JSON.
  #1358: Reporters experienced the exit(2) as 'gating' behavior rather
         than 'warning' behavior. The pattern-warning path in main()
         was migrated to exit(0) + hookSpecificOutput.additionalContext
         long ago; these three asyncRewake sites were never updated.

Fix: extend emit_metrics() to accept additional_context, system_message,
and hook_event_name kwargs, and emit them in the same SyncHookJSONOutput
line as the metrics. CC's parser stops scanning stdout after the first
{-prefixed line, so the findings must ride in that same line — calling
emit_metrics twice or adding a second print(json.dumps(...)) would
silently drop the second emission.

At each of the three call sites: route the guidance text that used to
go to stderr through additional_context instead. The stderr.write is
dropped — additionalContext carries the same text to the model via the
JSON channel, and the legacy stderr surface is what triggered #1375's
JSON validation error on older CC clients.

exit(2) is preserved at all three sites. That's the documented mechanism
for triggering the asyncRewake 'force fix' feedback loop (per the
inline comment at the stop-hook site); switching to exit(0) without
verifying CC's protocol-version support risks dropping the rewake
entirely and silently losing all the findings the hook just computed.

For push-sweep specifically: emit_metrics had to move from an
unconditional pre-emission (line ~1680) to two conditional sites (one
in the no-vulns branch with exit(0), one in the with-vulns branch with
exit(2)) because the with-vulns branch needs to attach additional_context
and CC reads only the first JSON line — a second emit would be ignored.
Behavior is preserved: every push-sweep fire emits exactly one metrics
line, just at a slightly later point in the function body.

Verified locally on macOS Python 3.13:

  - py_compile clean.
  - Existing 45 smoke + extensibility tests still pass.
  - 21 new tests in test_hook_output_protocol.py (added to internal
    test suite at sg-staging/tests/, not in this PR):

      * 6 backward-compat: emit_metrics with metrics only, with
        rewake_summary, etc. — verifies the legacy callers still
        produce the same output shape.
      * 5 additional_context shape: lands in hookSpecificOutput,
        round-trips the value, default hook_event_name is sensible,
        empty/None doesn't pollute the JSON with an empty hSO block.
      * 3 system_message shape: lands in systemMessage, empty/None
        suppressed, round-trips.
      * 1 combined: metrics + rewake_summary + additional_context +
        system_message + hook_event_name all merge into one JSON line.
      * 6 round-trip safety: emoji, quotes, backslashes, newlines,
        Unicode (山田太郎 + 🎉), tabs, null bytes — all survive the
        json.dumps cycle.
      * 6 static-shape: each of the three asyncRewake handlers
        (commit_review, push_sweep, stop_hook) is checked to confirm
        it passes additional_context to emit_metrics and no longer
        writes the PROVENANCE_BANNER guidance to stderr. Catches the
        regression class where a new exit(2) site forgets to plumb
        guidance through the JSON channel.

  - 66/66 pass total (45 existing + 21 new) in 2.57s.

NOT verified end-to-end with a real CC instance triggering all three
hooks. The static-shape tests + the JSON round-trip tests should catch
any regression in the emit_metrics output, but the actual interaction
with CC's asyncRewake / rewakeMessage flow (especially: does
hookSpecificOutput.additionalContext successfully appear in the
rewakeMessage that CC sends to the model?) needs runtime verification
against a CC version that supports the modern protocol.

The reporter for #1375 specifically called out that CC's older
versions surfaced 'json output validation failed' on the old stderr-
only output; this fix changes the stdout shape to valid JSON with the
findings included, which should resolve that error class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:53:04 -07:00
Mohamed Hegazy
982070e51f Merge pull request #2076 from anthropics/fix-2048-graphite-gt-workflow
security-guidance: detect Graphite (gt) commands as commit/push events (#2048)
2026-05-28 23:44:58 -07:00
Mohamed Hegazy
5212308979 security-guidance: detect Graphite (gt) commands as commit/push events (#2048)
Fixes anthropics/claude-plugins-official#2048 — teams using Graphite
for stacked PRs (`gt create` / `gt modify` / `gt submit`) never get
the commit/push agentic review because the hook matcher only catches
literal `git commit` / `git push` Bash calls. gt shells out to git
as a subprocess, but the hook fires on Claude's top-level tool call,
which is `gt create` — not the `git commit` invocation inside the
gt subprocess that Claude Code never observes.

Per-edit pattern checks and end-of-turn Stop review still fire (those
don't depend on detecting the commit command), so the silent-coverage-
gap is bounded to the deepest review layer for Graphite users. Still:
that's exactly the layer designed to catch IDOR / auth-bypass /
cross-file SSRF, so the gap matters.

Semantic mapping (per the reporter):

  gt create  -> commit            (like `git commit`)
  gt modify  -> commit + amend    (like `git commit --amend`)
  gt submit  -> push              (like `git push`)

Changes:

1. hooks/hooks.json: extend two PostToolUse `if` matchers.

   "Bash(git commit:*)"
     -> "Bash(git commit:*)|Bash(gt create:*)|Bash(gt modify:*)"
   "Bash(git push:*)"
     -> "Bash(git push:*)|Bash(gt submit:*)"

   Without this, the hook subprocess never spawns for gt invocations
   and the Python regex changes below are dead code.

2. hooks/security_reminder_hook.py: extend three regexes that classify
   the bash command line.

   _GIT_COMMIT_RE: now also matches `gt create` and `gt modify`.
     Used at 4 sites (handler gate, multi-commit count, prompt
     detection, event classification). Compound commands like
     `gt create -am a && gt submit` now correctly trigger both the
     commit and push paths.

   _GIT_AMEND_RE: now also matches `gt modify` (semantically an
     amend). The amend code path uses reflog to find the pre-amend
     SHA and diff against THAT instead of HEAD~1 — same code path
     now applies to `gt modify`.

   _GIT_PUSH_RE: now also matches `gt submit`. Tolerates the same
     `git -C path` / `git -c k=v` global options as before for the
     git form; gt has its own flag layer that doesn't conflict.

Verified locally on macOS Python 3.13:

  - JSON valid (hooks.json roundtrips).
  - Existing 45 smoke + extensibility tests still pass.
  - 76 new tests in test_gt_graphite_workflow.py (added to internal
    test suite this PR doesn't ship — kept in sg-staging tests/ until
    we have a story for shipping plugin tests publicly):

      * 16 parametrized commit-match: native git commit variants +
        all gt create / gt modify variants from the reporter's repro.
      * 11 parametrized commit-reject: gt submit, gt log, gtoolkit
        (word-boundary), agt create, etc.
      * 9 parametrized amend-match: git commit --amend variants +
        gt modify variants + chained git+gt.
      * 7 parametrized amend-reject: regular git commit, gt create,
        gt submit, echo'd substring noise.
      * 11 parametrized push-match: git push variants + gt submit
        variants + chained.
      * 12 parametrized push-reject: git commit, gt log, gt fetch,
        gt down, gt restack, gh pr create, agt submit.
      * 3 compound-command class tests: git+gt mixtures trigger both
        paths; gt modify chained with gt submit triggers
        amend + push.
      * 3 commit-invocation-count tests: gt commands contribute to
        the multi-commit-detection findall count.
      * 2 hooks.json static config tests: read the JSON, verify the
        commit and push `if` clauses include the gt cases. Catches
        the easy regression where someone updates the Python regex
        but forgets to widen the matcher.

  - 121/121 pass total (45 existing + 76 new) in 2.50s.

NOT verified end-to-end with a real `gt` install. Reporter has the
deterministic Graphite workflow and offered to retest. The regex +
matcher widening is a clean superset — current git-only matching still
works (verified by the 45-test smoke suite that uses `git commit` /
`git push` exclusively), and the new gt cases are pure additions.

Not in this PR:

  - `gt prev` / `gt next` / `gt up` / `gt down` etc. — pure
    navigation, no commit / push side effect.
  - `gt restack` — could in principle rewrite commits (so the
    plugin's reviewed-shas cache becomes stale), but it doesn't
    create reviewable new content. Out of scope.
  - `gh pr create` — already explicitly NOT a separate matcher per
    the existing comment in _GIT_PUSH_RE (gh invokes git push as a
    child process; the bash hook only sees the top-level
    `gh pr create`). Same architectural issue as gt but with a
    different cost/benefit per the existing comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:33:14 -07:00
12 changed files with 432 additions and 194 deletions

View File

@@ -19,7 +19,7 @@
"url": "https://github.com/42Crunch-AI/claude-plugins.git",
"path": "plugins/api-security-testing",
"ref": "v1.5.5",
"sha": "5c8074d846b852c21da23bbf6effbfdabb18ba2d"
"sha": "b404d99a3f0bc1f3e74a1638671e2e3319187e2c"
},
"homepage": "https://42crunch.com"
},
@@ -35,7 +35,7 @@
"url": "https://github.com/adobe/skills.git",
"path": "plugins/creative-cloud/adobe-for-creativity",
"ref": "main",
"sha": "ecd1e2b2c493ba0627774f36a897bd44d47fef1d"
"sha": "0a015c06894332091b79e055e0404fbc1a18c9fe"
},
"homepage": "https://github.com/adobe/skills/tree/main/plugins/creative-cloud/adobe-for-creativity"
},
@@ -57,7 +57,7 @@
"source": {
"source": "url",
"url": "https://github.com/SalesforceAIResearch/agentforce-adlc.git",
"sha": "5ddccc36737b8bdc3dcabb3d6f51daa350c3d16d"
"sha": "1584dd52f388482db78949456addfa29a4c9d9c3"
},
"homepage": "https://github.com/SalesforceAIResearch/agentforce-adlc"
},
@@ -120,7 +120,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/amazon-location-service",
"ref": "main",
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -193,7 +193,7 @@
"source": {
"source": "url",
"url": "https://github.com/astronomer/agents.git",
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
},
"homepage": "https://github.com/astronomer/agents"
},
@@ -203,7 +203,7 @@
"source": {
"source": "url",
"url": "https://github.com/atlanhq/agent-toolkit.git",
"sha": "790398c87378f128bdc74c31bb7ecfb8e4695f29"
"sha": "b0efcc8e6adc64d052b634ac1103932390413fd9"
},
"homepage": "https://docs.atlan.com/"
},
@@ -226,7 +226,7 @@
"source": "url",
"url": "https://github.com/BrainBlend-AI/atomic-agents.git",
"path": "claude-plugin/atomic-agents",
"sha": "c4e905c49884747be65e7ed42ccfb118c67f57ac"
"sha": "bb9708ec7c4c7145bd64033dbece0bfaed0c2ad5"
},
"homepage": "https://github.com/BrainBlend-AI/atomic-agents",
"tags": [
@@ -245,7 +245,7 @@
"url": "https://github.com/auth0/agent-skills.git",
"path": "plugins/auth0",
"ref": "main",
"sha": "c771dc1c77bfd5a67686afb464ccebd227c02b0f"
"sha": "c38453f6a99bbfeaf73b5be81db987ec6af982da"
},
"homepage": "https://auth0.com/docs/quickstart/agent-skills"
},
@@ -274,7 +274,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/aws-amplify",
"ref": "main",
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -335,7 +335,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/aws-serverless",
"ref": "main",
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -346,7 +346,7 @@
"source": {
"source": "url",
"url": "https://github.com/microsoft/azure-skills.git",
"sha": "d02fd24f151f5133650eaa78e7da3cac2cedd72f"
"sha": "7cb89c221ecc9eccb71580aaff3695408cdeef2b"
},
"homepage": "https://github.com/microsoft/azure-skills"
},
@@ -412,7 +412,7 @@
"source": {
"source": "url",
"url": "https://github.com/brightdata/skills.git",
"sha": "071e9d4db77c8561e333799f25ea85f11f7b667d"
"sha": "da73549126e5834a9230ee5532d4917d43aedf11"
},
"homepage": "https://docs.brightdata.com"
},
@@ -442,7 +442,7 @@
"url": "https://github.com/carta/plugins.git",
"path": "plugins/carta-cap-table",
"ref": "main",
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
},
"homepage": "https://carta.com"
},
@@ -458,7 +458,7 @@
"url": "https://github.com/carta/plugins.git",
"path": "plugins/carta-crm",
"ref": "main",
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
},
"homepage": "https://carta.com"
},
@@ -474,7 +474,7 @@
"url": "https://github.com/carta/plugins.git",
"path": "plugins/carta-investors",
"ref": "main",
"sha": "5e6c9d1cfa3bff9b91138e7906c6eb088fd9a66a"
"sha": "e66d331cd8e669ee121c96ee35b0c91acd828970"
},
"homepage": "https://carta.com"
},
@@ -501,7 +501,7 @@
"source": {
"source": "url",
"url": "https://github.com/ChromeDevTools/chrome-devtools-mcp.git",
"sha": "60be3e6bc157bd1121ea1d4b6ad59e37a73cac3e"
"sha": "2e039c09e1a273581d9b51081a0feb8a57791947"
},
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp"
},
@@ -716,7 +716,7 @@
"source": {
"source": "url",
"url": "https://github.com/CodSpeedHQ/codspeed.git",
"sha": "ecf3c2ebf959479126d631ad39d317738d559388"
"sha": "407dd3c930b8dc5e5655a2d91a65d88f01829955"
},
"homepage": "https://codspeed.io"
},
@@ -753,7 +753,7 @@
"source": {
"source": "url",
"url": "https://github.com/get-convex/convex-backend-skill.git",
"sha": "5e59870cda2a5892e18a7164d1a46fcf57b70bea"
"sha": "ece93250d560f0ce32a24223dea92b33050b2a66"
},
"homepage": "https://github.com/get-convex/convex-backend-skill",
"keywords": [
@@ -784,7 +784,7 @@
"source": {
"source": "url",
"url": "https://github.com/CrowdStrike/foundry-skills.git",
"sha": "99edea095f4e32ed008706b55257d0893fb93387"
"sha": "fb25d60ecdbc0129071802dad210a65168ca55a9"
},
"homepage": "https://github.com/CrowdStrike/foundry-skills"
},
@@ -830,7 +830,7 @@
"source": {
"source": "url",
"url": "https://github.com/dash0hq/dash0-agent-plugin.git",
"sha": "2909be7ebc2804af464e0d7f660ccc2b62d94623"
"sha": "d1ad56f86f2a9ae74eccf1df2bb2985c963005b1"
},
"homepage": "https://dash0.com/"
},
@@ -841,7 +841,7 @@
"source": {
"source": "url",
"url": "https://github.com/astronomer/agents.git",
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
},
"homepage": "https://github.com/astronomer/agents"
},
@@ -855,7 +855,7 @@
"source": {
"source": "url",
"url": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack.git",
"sha": "7bc75b5e53d6eaae103132fd1a47de26239e4ae4"
"sha": "86eb482b33d943aa4242ae6f06d627ec12064d46"
},
"homepage": "https://github.com/gemini-cli-extensions/data-agent-kit-starter-pack"
},
@@ -865,7 +865,7 @@
"source": {
"source": "url",
"url": "https://github.com/astronomer/agents.git",
"sha": "535a040ca9e27aaed6da13f0f959625fb3294820"
"sha": "7ce4a12d3cabb506294134c91a1b876d4b166a70"
},
"homepage": "https://github.com/astronomer/agents"
},
@@ -878,7 +878,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/databases-on-aws",
"ref": "main",
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -920,7 +920,7 @@
"source": {
"source": "url",
"url": "https://github.com/datarobot-oss/datarobot-agent-skills.git",
"sha": "8124faae2154117382b1046aa74d8901a3ffe930"
"sha": "4c3dfbd259bc2c6c815f7575d27ca26bc09d0d17"
},
"homepage": "https://datarobot.com"
},
@@ -946,7 +946,7 @@
"url": "https://github.com/awslabs/agent-plugins.git",
"path": "plugins/deploy-on-aws",
"ref": "main",
"sha": "5d982e8a5f1e0b06545adac69ff0348141587725"
"sha": "9d46cc0a092c0a8c01a5bd06a4349985cc6c8f08"
},
"homepage": "https://github.com/awslabs/agent-plugins"
},
@@ -1048,7 +1048,7 @@
"url": "https://github.com/expo/skills.git",
"path": "plugins/expo",
"ref": "main",
"sha": "510373b50956ef4dc84c20bb4c9cce70b618aa06"
"sha": "fdd3df12151a208853fe540ffea9a67773446377"
},
"homepage": "https://github.com/expo/skills/blob/main/plugins/expo/README.md"
},
@@ -1114,7 +1114,7 @@
"source": {
"source": "url",
"url": "https://github.com/firecrawl/firecrawl-claude-plugin.git",
"sha": "01d11b30ace699a27f9ea7decf6ce6c9857f71ff"
"sha": "e71cec486062680f0c8f8823afcb3558ad81ce60"
},
"homepage": "https://github.com/firecrawl/firecrawl-claude-plugin.git"
},
@@ -1217,7 +1217,7 @@
"source": {
"source": "url",
"url": "https://github.com/huggingface/skills.git",
"sha": "7a493b09c81aae09a41bd2e1fa33dfc0f68acd75"
"sha": "df627be1837523c91ac6df472e3dc543d3107bd9"
},
"homepage": "https://github.com/huggingface/skills.git"
},
@@ -1231,7 +1231,7 @@
"source": {
"source": "url",
"url": "https://github.com/hunter-io/claude-plugin.git",
"sha": "c67942395cde155e9ad4ed8e3a137926f9992fb8"
"sha": "9b6146520c48f9dcc6092f106e5c1a5762ca3e7a"
},
"homepage": "https://hunter.io"
},
@@ -1245,7 +1245,7 @@
"source": {
"source": "url",
"url": "https://github.com/heygen-com/hyperframes.git",
"sha": "7ea4d1c1314bd60d5273efa92626bd1d0f9c621d"
"sha": "bc3701f5905c5ba7c8cf03c3bbe3a49162d2b1f1"
},
"homepage": "https://hyperframes.heygen.com"
},
@@ -1410,7 +1410,7 @@
"url": "https://github.com/pydantic/skills.git",
"path": "plugins/logfire",
"ref": "main",
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
"sha": "eb17c0da94de81488825c0198475233dc1f06393"
},
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/logfire"
},
@@ -1523,7 +1523,7 @@
"url": "https://github.com/mercadopago/mercadopago-claude-marketplace.git",
"path": "plugins/mercadopago",
"ref": "main",
"sha": "f52c138924d8035b39e8fe02d41c6712fc41ceb4"
"sha": "ba967158392bec9f0c199cd39196af64222f0ab0"
},
"homepage": "https://github.com/mercadopago/mercadopago-claude-marketplace/tree/main/plugins/mercadopago"
},
@@ -1638,7 +1638,7 @@
"source": {
"source": "url",
"url": "https://github.com/Nimbleway/agent-skills.git",
"sha": "95ed06468957ddc9de609b25c390b30c3864eac8"
"sha": "9736dfc757f5ed4f05da0480b202af09e93a27de"
},
"homepage": "https://docs.nimbleway.com/integrations/agent-skills/plugin-installation"
},
@@ -1665,7 +1665,7 @@
"url": "https://github.com/oracle-samples/oracle-aidp-samples.git",
"path": "ai/claude-code-plugins/oracle-ai-data-platform-workbench-spark-connectors",
"ref": "main",
"sha": "f7ea9cae6fce69a4e3798dfc1d5216ac1d0dd7e8"
"sha": "6e59f24cd3e8870649e7f9b2e3e106502b43fd5f"
},
"homepage": "https://docs.oracle.com/en/cloud/paas/ai-data-platform/index.html"
},
@@ -1681,7 +1681,7 @@
"url": "https://github.com/growthxai/output.git",
"path": "coding_assistants/claude/plugins/outputai",
"ref": "main",
"sha": "93dd22ee568a97911a332b5aa0d9cebb2b6f7da1"
"sha": "0eeffece25b6f471c48b705a214471164b8c5946"
},
"homepage": "https://output.ai"
},
@@ -1846,7 +1846,7 @@
"url": "https://github.com/pydantic/skills.git",
"path": "plugins/ai",
"ref": "main",
"sha": "0c38c5bb5679f6cc41956bbbf811396a0d108ac9"
"sha": "eb17c0da94de81488825c0198475233dc1f06393"
},
"homepage": "https://github.com/pydantic/skills/tree/main/plugins/ai"
},
@@ -1884,7 +1884,7 @@
"source": {
"source": "url",
"url": "https://github.com/qdrant/skills.git",
"sha": "1390c811e03922b822dc9e12b832ba4dc82e0bf0"
"sha": "ea62a9857dabcc169597549da7681bd6d4cd13e9"
},
"homepage": "https://skills.qdrant.tech"
},
@@ -1895,7 +1895,7 @@
"source": {
"source": "url",
"url": "https://github.com/qodo-ai/qodo-skills.git",
"sha": "b1eb0389480ee6de8df874f40a230ed2625ef0d3"
"sha": "8aec13d6ac60feb9d9f84f36aa1753234de17dc8"
},
"homepage": "https://github.com/qodo-ai/qodo-skills.git"
},
@@ -1909,7 +1909,7 @@
"source": {
"source": "url",
"url": "https://github.com/TheQtCompanyRnD/agent-skills.git",
"sha": "23772fa2264b3ff1037a96164b2c28d2b29a4c2f"
"sha": "a7189a7bc17e616b725e7ce4e46a4f5ebd50d94f"
},
"homepage": "https://www.qt.io/"
},
@@ -1923,7 +1923,7 @@
"source": {
"source": "url",
"url": "https://github.com/quarkusio/quarkus-agent-mcp.git",
"sha": "77fd36284a80b3ed1bde3d2fe48a0b2f99e4941e"
"sha": "32cad78bd9040efe31794cfc10f70caf2a724dd9"
},
"homepage": "https://quarkus.io"
},
@@ -1975,7 +1975,7 @@
"url": "https://github.com/redis/agent-skills.git",
"path": "plugins/redis-development",
"ref": "main",
"sha": "18da4e42371f7eee0dcfafd8461effd41de351e9"
"sha": "5ca2e1a2d82a768221e8f71a02e3ca095a37d38e"
},
"homepage": "https://redis.io"
},
@@ -1985,7 +1985,7 @@
"source": {
"source": "url",
"url": "https://github.com/Digital-Process-Tools/claude-remember.git",
"sha": "c9b34417a8132f0416411a0ca51d009a256a3acc"
"sha": "a4ff96f38622f7c4920dc349d59cc980663336f4"
},
"homepage": "https://github.com/Digital-Process-Tools/claude-remember"
},
@@ -1999,7 +1999,7 @@
"source": {
"source": "url",
"url": "https://github.com/resend/resend-skills.git",
"sha": "78469829399beec62b8f815f109ebfcfa3b0680b"
"sha": "376d1c3fb37cc7d22ab21cce836f4d6f323922de"
},
"homepage": "https://resend.com"
},
@@ -2097,7 +2097,7 @@
"source": {
"source": "url",
"url": "https://github.com/sanity-io/agent-toolkit.git",
"sha": "236348e29b31e834ce71e4e2e3072184dd1c1e27"
"sha": "d7545f5cc6f8fb39554083b52ad074a6d912db9f"
},
"homepage": "https://www.sanity.io"
},
@@ -2131,7 +2131,7 @@
"url": "https://github.com/SAP/open-ux-tools.git",
"path": "packages/fiori-mcp-server",
"ref": "main",
"sha": "d2a6fce818f3c046c5bbb041507be4632f926602"
"sha": "7432d23a7b5c3bd1c0a01cf76696bf0c417ecd1f"
},
"homepage": "https://github.com/SAP/open-ux-tools/tree/main/packages/fiori-mcp-server"
},
@@ -2198,7 +2198,7 @@
"source": {
"source": "url",
"url": "https://github.com/getsentry/sentry-for-claude.git",
"sha": "ed0875684192bb8a050297a896657ff9db1ffdf5"
"sha": "d6123be331e2224b037e1ffefd27c806e7566dcf"
},
"homepage": "https://github.com/getsentry/sentry-for-claude/tree/main"
},
@@ -2214,7 +2214,7 @@
"url": "https://github.com/getsentry/cli.git",
"path": "plugins/sentry-cli",
"ref": "main",
"sha": "d9bcd70eaa467fb3ddf591bfbfb0686fd1e9c016"
"sha": "db90767935558db16c45036f89e68edaa1dde106"
},
"homepage": "https://sentry.io"
},
@@ -2279,7 +2279,7 @@
"source": {
"source": "url",
"url": "https://github.com/Shopify/Shopify-AI-Toolkit.git",
"sha": "c164cf45c4bc1d17bbc105168d99a4f744cfaac2"
"sha": "859be93bfc858f183ff5eb40183e35a4d91d2950"
},
"homepage": "https://shopify.dev"
},
@@ -2364,7 +2364,7 @@
"source": {
"source": "url",
"url": "https://github.com/spotify/ads-claude-plugin.git",
"sha": "7ed948b85337f6b31a82dfaa8f033b6843659fa3"
"sha": "73b8bd490e02d3ed0bb4c8e228a470c46f995154"
},
"homepage": "https://github.com/spotify/ads-claude-plugin"
},
@@ -2377,7 +2377,7 @@
"url": "https://github.com/stripe/ai.git",
"path": "providers/claude/plugin",
"ref": "main",
"sha": "a34795211da530a168f581122011bb5ceb2e4bd0"
"sha": "99425a010474c6aab745a975d06764e323c2c4d4"
},
"homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
},
@@ -2400,7 +2400,7 @@
"source": {
"source": "url",
"url": "https://github.com/supabase-community/supabase-plugin.git",
"sha": "1b910c021aee8c9c054196f0e840b3a65e1a7c63"
"sha": "3217ac038647f6901a166f3264a32f01833f73ba"
},
"homepage": "https://github.com/supabase-community/supabase-plugin"
},
@@ -2445,7 +2445,7 @@
"source": {
"source": "url",
"url": "https://github.com/JetBrains/teamcity-cli.git",
"sha": "7f8419738b452108ff181365be30c1fab0a6905e"
"sha": "9436b94b228579ba952aba809357776c3db9ce1a"
},
"homepage": "https://www.jetbrains.com/teamcity/"
},
@@ -2538,7 +2538,7 @@
"url": "https://github.com/UI5/plugins-coding-agents.git",
"path": "plugins/ui5",
"ref": "main",
"sha": "78f657e6a5004b5cdd1b998aabea616023eeabbb"
"sha": "7acd8328399a221e161ae5bb04a5675696f92920"
},
"homepage": "https://github.com/UI5/plugins-coding-agents"
},
@@ -2556,7 +2556,7 @@
"url": "https://github.com/UI5/plugins-coding-agents.git",
"path": "plugins/ui5-typescript-conversion",
"ref": "main",
"sha": "78f657e6a5004b5cdd1b998aabea616023eeabbb"
"sha": "7acd8328399a221e161ae5bb04a5675696f92920"
},
"homepage": "https://github.com/UI5/plugins-coding-agents"
},
@@ -2595,7 +2595,7 @@
"source": {
"source": "url",
"url": "https://github.com/explorium-ai/vibeprospecting-plugin.git",
"sha": "ada4d569dbf70194fe18750ecbc5170e9a3f120a"
"sha": "c00b11db4efc3e7b7aaffc10d71db33c806d5607"
},
"homepage": "https://www.vibeprospecting.ai/product/claude-plugin"
},
@@ -2620,7 +2620,7 @@
"source": {
"source": "url",
"url": "https://github.com/wix/skills.git",
"sha": "5da7e749a466ef9ddcdb2822099b940b9a1bc151"
"sha": "c5b343f2dadba06da91ee6de07272161fb68d40d"
},
"homepage": "https://dev.wix.com/docs/wix-cli/guides/development/about-wix-skills"
},
@@ -2727,7 +2727,7 @@
"source": {
"source": "url",
"url": "https://github.com/zscaler/zscaler-mcp-server.git",
"sha": "8409e1661b7f7171bfbb9297e1ecfc61c28b6d92"
"sha": "be37fb604a07dc9c5a4c3e009312c4f11acaa6d3"
},
"homepage": "https://github.com/zscaler/zscaler-mcp-server"
}

View File

@@ -2,25 +2,24 @@ name: Bump Plugin SHAs
# Nightly sweep: for each external entry whose upstream HEAD has moved past
# its pinned SHA, validate at the new SHA with `claude plugin validate`
# inline, then open one PR with all passing bumps. Each run force-resets the
# bump/plugin-shas branch, so a previous night's unmerged PR is replaced (and
# its review state discarded) — review and merge same-day to avoid churn.
# inline, then open one PR per bumped plugin on branch `bump/<slug>`.
# Failing entries stay isolated in their own PR; passing bumps merge
# independently.
#
# Bot-free — uses the default GITHUB_TOKEN. PRs opened with GITHUB_TOKEN don't
# trigger on:pull_request workflows, so the policy scan (`Scan Plugins`, a
# required status check on main) would never run and the bump PR could never
# merge. workflow_dispatch is exempt from that recursion guard, so we dispatch
# the scan ourselves on the bump branch after the PR is opened. The check run
# lands on the branch HEAD — the same SHA as the PR head — and satisfies the
# required check.
# trigger on:pull_request workflows, so the required status checks on main
# (`scan` from Scan Plugins, `check` from Check MCP URLs, `validate` from
# Validate Plugins) would never run and the bump PR could never merge.
# workflow_dispatch is exempt from that recursion guard, so we dispatch all
# three ourselves against each per-entry bump branch after its PR is opened.
# Each check run lands on the branch HEAD — the same SHA as the PR head — and
# satisfies the corresponding required check. (Each of those workflows runs
# its job unconditionally on workflow_dispatch, so a dispatch always reports.)
#
# max-bumps is set above the external-entry count so a single run can clear
# any backlog. The cost-control mechanisms are downstream:
# - scan-plugins.yml caches verdicts by (plugin, sha) so an unchanged SHA
# is never re-scanned across nightly force-resets.
# - revert-failed-bumps.yml drops policy-failing entries from the bump PR
# so one bad upstream can't block the rest.
# See those files for details.
# max-bumps caps the per-night work for cost control. Per-entry scans are
# more expensive than a single batched scan, so the cap is conservative.
# The composite action skips entries that already have an open bump PR, so
# re-dispatches don't pile up duplicate work.
on:
schedule:
@@ -30,12 +29,12 @@ on:
max_bumps:
description: Cap on plugins bumped this run
required: false
default: '130'
default: '30'
permissions:
contents: write
pull-requests: write
actions: write # gh workflow run scan-plugins.yml on the bump branch
actions: write # gh workflow run {scan-plugins,check-mcp-urls,validate-plugins}.yml per bump branch
concurrency:
group: bump-plugin-shas
@@ -43,8 +42,8 @@ concurrency:
jobs:
bump:
runs-on: ubuntu-latest
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 130 entries
# is ~5 min. The 60 min ceiling absorbs slow upstreams without letting a
# Per-bump cost is ~2s (ls-remote + shallow clone + validate); 30 entries
# is ~1-2 min. The 60 min ceiling absorbs slow upstreams without letting a
# pathological run consume the default 360 min budget.
timeout-minutes: 60
steps:
@@ -52,18 +51,44 @@ jobs:
# createCommitOnBranch-based bump so commits are signed by GitHub and
# satisfy the org-level required_signatures ruleset on main.
- uses: anthropics/claude-plugins-community/.github/actions/bump-plugin-shas@c41c6911de0afffd2bc5cd8b21fb1e06444ee13b
- uses: anthropics/claude-plugins-community/.github/actions/bump-plugin-shas@e2019b2a01f11aa1484c53540b1cfab5eebbc299
id: bump
with:
marketplace-path: .claude-plugin/marketplace.json
max-bumps: ${{ inputs.max_bumps || '130' }}
max-bumps: ${{ inputs.max_bumps || '30' }}
pr-mode: per-entry
claude-cli-version: latest
# `bump/plugin-shas` is the action's default `pr-branch`. The scan diffs
# the branch against origin/main (the action's base-ref fallback when
# there's no pull_request event) and scans only the bumped entries.
- name: Dispatch policy scan on bump branch
if: steps.bump.outputs.pr-url != ''
# Per-entry fan-out: dispatch the three required checks against each bump
# branch. `pr-urls` is a JSON array of {name, old_sha, new_sha, branch,
# pr_url} entries emitted by the composite action when pr-mode is
# per-entry. All three (scan / check / validate) are required on main and
# none fire on the GITHUB_TOKEN-opened PR, so each must be dispatched.
# A single failed dispatch (transient API error / rate limit) must not
# strand the remaining branches, so we attempt every dispatch, then fail
# the step if any failed: a missing required check would otherwise leave
# its bump PR silently blocked behind a green run, and the composite
# action skips slugs with an open PR so it would never be retried.
- name: Dispatch required checks per per-entry PR
if: steps.bump.outputs.pr-urls != '' && steps.bump.outputs.pr-urls != '[]'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run scan-plugins.yml --ref bump/plugin-shas
PR_URLS: ${{ steps.bump.outputs.pr-urls }}
run: |
set -euo pipefail
dispatch_failures="$(mktemp)"
jq -c '.[]' <<<"$PR_URLS" | while read -r entry; do
branch=$(jq -r '.branch' <<<"$entry")
name=$(jq -r '.name' <<<"$entry")
for wf in scan-plugins check-mcp-urls validate-plugins; do
echo "Dispatching ${wf}.yml against $branch ($name)"
if ! gh workflow run "${wf}.yml" --ref "$branch"; then
echo "::error::Failed to dispatch ${wf}.yml against $branch ($name) — required check will be missing; re-dispatch with: gh workflow run ${wf}.yml --ref $branch"
echo "${wf} ${branch}" >> "$dispatch_failures"
fi
done
done
if [ -s "$dispatch_failures" ]; then
echo "::error::$(wc -l < "$dispatch_failures" | tr -d ' ') required-check dispatch(es) failed; the affected bump PR(s) are blocked until re-dispatched (see annotations above)."
exit 1
fi

View File

@@ -12,6 +12,14 @@ on:
branches: [main]
paths:
- '.claude-plugin/**'
# `validate` is a required status check on main. Bump PRs are opened with
# GITHUB_TOKEN, which doesn't fire on:pull_request (recursion guard), so the
# path-filtered trigger above never reports on them and the PR would be
# blocked forever. The bump workflow dispatches this against each per-entry
# bump branch instead; the check run lands on the branch HEAD (= PR head)
# and satisfies the required check. The validate job runs unconditionally,
# so a dispatch always reports.
workflow_dispatch:
permissions:
contents: read

View File

@@ -10,15 +10,42 @@ import os
import threading
from datetime import datetime
def state_dir():
"""Return the absolute path of the plugin's state directory.
Resolution precedence (highest first):
1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing)
2. CLAUDE_CONFIG_DIR/security — CC's config-dir env var (#1868)
3. ~/.claude/security — default fallback
Empty-string env vars are treated as not-set so a misconfigured shell
(`CLAUDE_CONFIG_DIR=` with no value) doesn't silently write to
/security at the filesystem root.
Returns a fully-expanded absolute path (no literal `~`) so subprocess
callers can pass it through to code that doesn't re-expand tildes.
Called per-invocation rather than cached at import time so test
monkeypatches of the env vars take effect — the plugin's hooks each
run as fresh subprocesses in production, so the per-call cost is
negligible compared to subprocess spawn.
"""
explicit = os.environ.get("SECURITY_WARNINGS_STATE_DIR")
if explicit:
return os.path.expanduser(explicit)
cc_config = os.environ.get("CLAUDE_CONFIG_DIR")
if cc_config:
return os.path.expanduser(os.path.join(cc_config, "security"))
return os.path.expanduser("~/.claude/security")
# Debug log file. Lives under the plugin state dir (default ~/.claude/security/)
# rather than /tmp because /tmp is world-writable on multi-user hosts (TOCTOU /
# symlink-attack surface, cross-user log leakage). Overridable per-process via
# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR.
_DEFAULT_STATE_DIR = os.path.expanduser(
os.environ.get("SECURITY_WARNINGS_STATE_DIR") or "~/.claude/security"
)
# SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR
# (plugin-specific override) or CLAUDE_CONFIG_DIR (CC-wide config dir, #1868).
DEBUG_LOG_FILE = os.environ.get("SECURITY_GUIDANCE_DEBUG_LOG") or os.path.join(
_DEFAULT_STATE_DIR, "log.txt"
state_dir(), "log.txt"
)
# Cap the debug log so parallel-worker fleets don't fill disk. When the active
# file exceeds this it's atomically rotated to <file>.1 (overwriting any prior

View File

@@ -355,9 +355,9 @@ def _list_untracked(cwd):
the holdouts."""
try:
repo = _git_toplevel(cwd) or cwd
# core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
r = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false", "ls-files",
"--others", "--exclude-standard", "-z"],
[*GIT_CMD, "ls-files", "--others", "--exclude-standard", "-z"],
cwd=repo, capture_output=True, timeout=15,
)
if r.returncode != 0:

View File

@@ -23,6 +23,12 @@ import sys
import time
from pathlib import Path
# Shared state-dir resolver: SECURITY_WARNINGS_STATE_DIR → CLAUDE_CONFIG_DIR/security
# → ~/.claude/security. See _base.state_dir for resolution precedence. Re-aliased
# here to match the existing local name (state_dir was already a local var in
# main() and _maybe_emit_user_notice).
from _base import state_dir as _resolve_state_dir
# Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry.
NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python
NOOP_VENV = 1 # venv already built and SDK imports from it
@@ -90,10 +96,7 @@ def main() -> tuple[int, str, str]:
if _sdk_on_syspath():
return NOOP_SYSTEM, "", ""
state_dir = Path(
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
or os.path.expanduser("~/.claude/security")
)
state_dir = Path(_resolve_state_dir())
venv = state_dir / "agent-sdk-venv"
# Windows venvs put the interpreter at Scripts\python.exe; POSIX uses bin/python.
if sys.platform == "win32":
@@ -239,10 +242,7 @@ def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None:
if outcome != HOOK_PY_INCOMPATIBLE:
return None
try:
state_dir = Path(
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
or os.path.expanduser("~/.claude/security")
)
state_dir = Path(_resolve_state_dir())
marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}"
if marker.exists():
return None

View File

@@ -26,18 +26,34 @@ GIT_CMD = [
"git",
"-c", "core.fsmonitor=false",
"-c", "core.hooksPath=/dev/null",
# core.quotePath=false: emit raw UTF-8 in path-emitting commands instead
# of C-quoting non-ASCII bytes (default `"\\303\\201vila/..."` vs
# `Ávila/...`). Downstream parsers — both ours (parse_diff_into_files,
# extract_file_paths_from_diff) and Python stdlib (os.path.isabs,
# os.path.join) — expect raw paths and silently drop / mishandle the
# quoted form. Adding the flag globally to GIT_CMD covers every
# subprocess.run site that uses the splat — diff feeders, rev-parse
# path queries (--show-toplevel, --git-dir, --git-common-dir),
# reflog %gs subjects, ls-files, status, etc. — without per-site
# flag duplication. See #2082, #2099.
"-c", "core.quotePath=false",
]
def _git_rev_parse_head(cwd):
"""Return the current HEAD SHA, or None if not a git repo / no commits."""
try:
# See #2099: text=True on Windows cp1252 crashes the reader thread on
# any UTF-8 byte undefined in cp1252 (e.g. via a git error message
# referencing a non-ASCII filename in stderr). stdout is a SHA so it
# IS safe; stderr is not. capture_output=True with bytes-by-default
# never decodes, so the reader thread can't crash.
result = subprocess.run(
[*GIT_CMD, "rev-parse", "HEAD"],
cwd=cwd, capture_output=True, text=True, timeout=5
cwd=cwd, capture_output=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return result.stdout.decode("utf-8", errors="replace").strip()
return None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
@@ -52,13 +68,17 @@ def _find_git_index(cwd):
Returns the absolute path to the index file, or None.
"""
try:
# See #2099: stdout here is a PATH which can contain non-ASCII bytes
# (e.g. C:\אבטחה\repo\.git). text=True decodes via cp1252 strict on
# Windows → crashes the reader thread → returns stdout=None →
# caller does .strip() on None → AttributeError. Decode manually.
result = subprocess.run(
[*GIT_CMD, "rev-parse", "--git-dir"],
cwd=cwd, capture_output=True, text=True, timeout=5
cwd=cwd, capture_output=True, timeout=5
)
if result.returncode != 0:
return None
git_dir = result.stdout.strip()
git_dir = result.stdout.decode("utf-8", errors="replace").strip()
if not os.path.isabs(git_dir):
git_dir = os.path.join(cwd, git_dir)
index_path = os.path.join(git_dir, "index")
@@ -128,9 +148,13 @@ def _temp_index(cwd, untracked_paths=None):
else:
add_args = None
if add_args:
# No stdout used here (only returncode matters), but text=True
# still spawns reader threads that decode stderr — git error
# messages can reference non-ASCII filenames and crash on
# cp1252. See #2099. Drop text=True so bytes stay raw.
subprocess.run(
[*GIT_CMD, "add", "--intent-to-add"] + add_args,
cwd=cwd, capture_output=True, text=True, timeout=10,
cwd=cwd, capture_output=True, timeout=10,
env=env,
)
yield env
@@ -144,11 +168,17 @@ def _temp_index(cwd, untracked_paths=None):
def _git_toplevel(cwd):
"""Absolute repo root for `cwd`, or None if not in a work tree."""
try:
# See #2099: stdout is a PATH — `C:\אבטחה\repo` returned as UTF-8
# bytes by git. text=True would decode via cp1252 strict on Windows
# → reader-thread crash. Decode manually with errors="replace".
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--show-toplevel"],
cwd=cwd, capture_output=True, text=True, timeout=5,
cwd=cwd, capture_output=True, timeout=5,
)
return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else None
if r.returncode != 0:
return None
path = r.stdout.decode("utf-8", errors="replace").strip()
return path if path else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
@@ -164,13 +194,15 @@ def _git_dir(repo_root):
callers can degrade (push-sweep state is best-effort).
"""
try:
# See #2099: stdout is a PATH (shared gitdir), may be non-ASCII.
# Decode bytes manually to avoid cp1252 reader-thread crash.
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--git-common-dir"],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
if r.returncode != 0:
return None
d = r.stdout.strip()
d = r.stdout.decode("utf-8", errors="replace").strip()
return d if os.path.isabs(d) else os.path.join(repo_root, d)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
@@ -179,13 +211,15 @@ def _git_dir(repo_root):
def _git_rev_list_range(repo_root, base, head="HEAD"):
"""Shas in `base..head`, oldest→newest. Empty list on error."""
try:
# See #2099: stdout is ASCII SHAs, but stderr can carry git error
# messages referencing non-ASCII filenames — keep bytes raw.
r = subprocess.run(
[*GIT_CMD, "rev-list", "--reverse", f"{base}..{head}"],
cwd=repo_root, capture_output=True, text=True, timeout=10,
cwd=repo_root, capture_output=True, timeout=10,
)
if r.returncode != 0:
return []
return [s for s in r.stdout.strip().split("\n") if s]
return [s for s in r.stdout.decode("utf-8", errors="replace").strip().split("\n") if s]
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return []
@@ -199,15 +233,12 @@ def _git_diff_range(repo_root, base, head="HEAD"):
them reviewed — otherwise unreviewed commits get permanently silenced.
"""
try:
# core.quotePath=false makes git emit raw UTF-8 in `diff --git a/... b/...`
# headers instead of C-quoting non-ASCII path bytes (`"a/\303\201vila/..."`
# vs `a/Ávila/...`). The downstream `re.match(r'^a/(.+?) b/(.+)$', ...)`
# in parse_diff_into_files / extract_file_paths_from_diff matches the
# raw form only — quoted headers slip past and the entire file is
# silently dropped from review. See #2082 (sibling of #2056 / #2075).
# GIT_CMD globally passes core.quotePath=false (see definition) so
# non-ASCII paths in `diff --git a/... b/...` headers come through as
# raw UTF-8, not C-quoted. Required by the downstream
# parse_diff_into_files / extract_file_paths_from_diff regex.
r = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false",
"diff", "-p", "--no-color", "--no-ext-diff", base, head],
[*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", base, head],
cwd=repo_root, capture_output=True, timeout=30,
)
if r.returncode != 0:
@@ -220,9 +251,11 @@ def _git_diff_range(repo_root, base, head="HEAD"):
def _detect_main_branch(repo_root):
for ref in ("origin/HEAD", "origin/main", "origin/master", "main", "master"):
try:
# See #2099: stdout is a SHA but stderr can carry non-ASCII git
# warnings — keep bytes raw to avoid cp1252 reader-thread crash.
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", ref],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
if r.returncode == 0 and r.stdout.strip():
return ref
@@ -330,8 +363,9 @@ def _git_name_only(cwd, base, include_untracked=False):
# result.stdout=None, and propagate AttributeError out of the helper.
# Same fix shape as diffstate._list_untracked. See #2056.
def _run(env):
# core.quotePath=false comes from GIT_CMD globally (see definition).
result = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false", "diff", "--name-only", "-z", base],
[*GIT_CMD, "diff", "--name-only", "-z", base],
cwd=cwd, capture_output=True, timeout=30,
env=env,
)
@@ -368,9 +402,9 @@ def _git_status_porcelain(cwd):
# sibling helpers — a non-ASCII path in the worktree would otherwise
# crash the cp1252 reader thread on Windows. See #2056.
try:
# core.quotePath=false comes from GIT_CMD globally (see definition).
r = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false", "status",
"--porcelain=v1", "-uall", "-z"],
[*GIT_CMD, "status", "--porcelain=v1", "-uall", "-z"],
cwd=cwd, capture_output=True, timeout=30,
)
if r.returncode != 0:
@@ -410,9 +444,12 @@ def _is_ancestor(cwd, maybe_ancestor, descendant):
"""True if `maybe_ancestor` is reachable from `descendant` (i.e. HEAD
moved forward via commit/merge, not sideways via checkout)."""
try:
# See #2099: only returncode matters, but text=True spawns reader
# threads that decode stderr — git error messages can carry non-ASCII
# filenames. Drop text=True to keep bytes raw, avoid cp1252 crash.
result = subprocess.run(
[*GIT_CMD, "merge-base", "--is-ancestor", maybe_ancestor, descendant],
cwd=cwd, capture_output=True, text=True, timeout=5,
cwd=cwd, capture_output=True, timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
@@ -443,11 +480,8 @@ def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_pa
# change exists to fix.
return ""
# core.quotePath=false: emit raw UTF-8 in `diff --git a/... b/...` headers
# so non-ASCII paths aren't C-quoted past the downstream parse_diff_into_files
# regex. See #2082 (sibling of #2056 / #2075).
cmd = [*GIT_CMD, "-c", "core.quotePath=false",
"diff", "--no-color", "--no-ext-diff", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
# core.quotePath=false comes from GIT_CMD globally (see definition).
cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
try:
with _temp_index(cwd, untracked_paths) as env:
# env is None when no index could be found (bare repo / not a

View File

@@ -49,6 +49,30 @@
"asyncRewake": true,
"rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
"rewakeSummary": "Push security review found issues"
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
"if": "Bash(gt create:*)",
"asyncRewake": true,
"rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
"rewakeSummary": "Commit security review found issues"
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
"if": "Bash(gt modify:*)",
"asyncRewake": true,
"rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
"rewakeSummary": "Commit security review found issues"
},
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
"if": "Bash(gt submit:*)",
"asyncRewake": true,
"rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
"rewakeSummary": "Push security review found issues"
}
],
"matcher": "Bash"

View File

@@ -27,7 +27,7 @@ from typing import Optional, Tuple, Dict, Any, List
import extensibility
import review_api
from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG # noqa: F401
from _base import debug_log, _record_usage, _PV, PROVENANCE_TAG, state_dir as _resolve_state_dir # noqa: F401
from session_state import with_locked_state
@@ -355,10 +355,7 @@ def _call_claude_via_sdk(prompt, output_schema, *, max_tokens=16000, model=None)
# Try the venv ensure_agent_sdk.py builds. Same fallback logic as
# agentic_review() — duplicated here so the 3P path doesn't require
# the agentic path to have run first.
_state_dir = os.environ.get(
"SECURITY_WARNINGS_STATE_DIR",
os.path.expanduser("~/.claude/security"),
)
_state_dir = _resolve_state_dir()
_inject_agent_sdk_venv_into_syspath(_state_dir)
try:
import asyncio as _asyncio # noqa: F811
@@ -482,10 +479,21 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
"max_tokens": max_tokens,
"system": CLAUDE_CODE_SYSTEM_PROMPT,
"messages": [{"role": "user", "content": prompt}],
"output_format": {
"type": "json_schema",
"schema": output_schema
}
# API moved the structured-output schema from top-level `output_format`
# to `output_config.format` per
# https://platform.claude.com/docs/en/build-with-claude/structured-outputs.
# The old form "continues to work for a transition period" for some
# auth modes (API key + non-streaming), but is rejected with
# `invalid_request_error: output_format: This field is deprecated.
# Use 'output_config.format' instead.` for others (OAuth Bearer +
# newer CLI versions hit it consistently — reporter saw 462 errors
# in one day). See #2098.
"output_config": {
"format": {
"type": "json_schema",
"schema": output_schema,
},
},
}
if thinking_budget > 0:
# Models trained on adaptive thinking (4.6+) reject the budget_tokens
@@ -493,7 +501,10 @@ def _call_claude(prompt, output_schema, thinking_budget=10000, max_tokens=16000,
# models (4.5 and earlier, all 3.x) reject adaptive. Pick by model.
if _model_supports_adaptive_thinking(payload["model"]):
payload["thinking"] = {"type": "adaptive"}
payload["output_config"] = {"effort": "high"}
# Merge `effort` into the existing output_config dict (which
# now carries the `format` schema) rather than reassigning —
# otherwise the schema is silently overwritten. See #2098.
payload["output_config"]["effort"] = "high"
else:
payload["thinking"] = {
"type": "enabled",
@@ -1145,10 +1156,7 @@ def agentic_review(
# ~/.claude/security/ with the SDK installed; try that as a fallback
# before giving up. The system import is attempted first so users
# who DO have it never touch the venv.
_state_dir = os.environ.get(
"SECURITY_WARNINGS_STATE_DIR",
os.path.expanduser("~/.claude/security"),
)
_state_dir = _resolve_state_dir()
_venv_tried = _inject_agent_sdk_venv_into_syspath(_state_dir)
try:
import asyncio as _asyncio # noqa: F811

View File

@@ -82,6 +82,7 @@ from _base import ( # noqa: E402,F401
PROVENANCE_TAG, PROVENANCE_BANNER,
_read_plugin_version_int, _PV, _USAGE, _USAGE_LOCK,
_PRICE_PER_MTOK, _PRICE_DEFAULT, _record_usage, _usage_metrics,
state_dir as _resolve_state_dir,
)
import extensibility # noqa: E402
from patterns import ( # noqa: E402,F401
@@ -190,7 +191,13 @@ CONTINUATION_SUFFIX = (
"response."
)
def emit_metrics(metrics, rewake_summary=None):
def emit_metrics(
metrics,
rewake_summary=None,
additional_context=None,
system_message=None,
hook_event_name="PostToolUse",
):
"""
Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
@@ -213,6 +220,27 @@ def emit_metrics(metrics, rewake_summary=None):
rewakeSummary in hooks.json, shown to the user in the terminal as the
task-notification one-liner. Must be in the same JSON line as the metrics
because CC stops scanning stdout after the first {-prefixed line.
`additional_context` (asyncRewake findings): model-visible guidance text
that CC surfaces via the modern hook-output protocol
(hookSpecificOutput.additionalContext) instead of the legacy stderr +
exit(2) pair. The caller passes the finding-explanation text it would
have written to stderr; the JSON channel carries it cleanly so CC's UI
shows the reason properly instead of "Permission denied with no reason".
See anthropics/claude-plugins-official#1375 and #1783. Empty/None
means no hookSpecificOutput field is emitted (preserves backward compat
for legacy emit-sites that only want metrics).
`system_message` (optional, asyncRewake only): user-visible TUI message,
distinct from rewakeSummary which is the task-notification one-liner.
Use sparingly — the rewakeMessage in hooks.json is the primary user
surface; systemMessage adds a per-fire override when the static
rewakeMessage isn't specific enough for the finding being shown.
`hook_event_name` (used only when additional_context is set): which event
the hookSpecificOutput attaches to. Defaults to "PostToolUse" since the
commit-review and push-sweep handlers are the most common callers;
handle_stop_hook explicitly passes "Stop".
"""
head = {}
if _PV and "pv" not in metrics:
@@ -223,6 +251,17 @@ def emit_metrics(metrics, rewake_summary=None):
out = {"metrics": metrics}
if rewake_summary:
out["rewakeSummary"] = rewake_summary
if additional_context:
# Wrap in hookSpecificOutput per CC's modern hook-output contract.
# Drops the legacy `sys.stderr.write(...) + sys.exit(2)` shape that
# left CC's UI showing "denied with no reason" (#1783) and triggered
# "json output validation failed" on older CC versions (#1375).
out["hookSpecificOutput"] = {
"hookEventName": hook_event_name,
"additionalContext": additional_context,
}
if system_message:
out["systemMessage"] = system_message
print(json.dumps(out), flush=True)
# =====================================================================
@@ -510,7 +549,11 @@ def handle_user_prompt_submit(input_data):
elif sha:
debug_log(f"Captured git baseline: {sha[:12]}")
else:
debug_log("Failed to capture git baseline (not a git repo?)")
# Show cwd so the next reporter can immediately see when this isn't
# actually "not a git repo" but a path-encoding / permissions / git
# invocation failure. See #2099.
debug_log(f"Failed to capture git baseline (cwd={cwd!r}) — not a git repo, "
f"or git invocation failed (check log entries above)")
sys.exit(0)
@@ -594,8 +637,29 @@ _COMMIT_SHA_RE = re.compile(r'^\[[^\]]*?\b([0-9a-f]{7,40})\]', re.MULTILINE)
# detection — it does NOT tolerate `git -c k=v commit` global options, which
# keeps this hook aligned with CC's commit attribution on what counts as a
# commit.
_GIT_COMMIT_RE = re.compile(r'\bgit\s+commit(?:\s|$)')
_GIT_AMEND_RE = re.compile(r'\s--amend\b')
#
# Also matches `gt create` and `gt modify` — Graphite's stacked-PR wrapper
# around git. `gt create` produces a new commit (mapped to git commit
# semantics); `gt modify` amends the current commit (mapped to git commit
# --amend, also flagged by _GIT_AMEND_RE below). The hooks.json matcher
# widening for `gt create:*` / `gt modify:*` / `gt submit:*` ships in the
# same change set — without that widening this regex change is dead code
# because the hook subprocess never spawns for gt invocations. See #2048.
_GIT_COMMIT_RE = re.compile(
# `git -C <path>` and `git -c key=val` global options are allowed between
# `git` and `commit` (mirrors the long-standing tolerance in
# _GIT_PUSH_RE). Without this, `git -C /repo commit` is silently dropped
# by the handler — see #2089's secondary finding. The gt branch has no
# global-option layer to worry about.
r'\bgit(?:\s+-[Cc]\s+\S+|\s+--\S+=\S+)*\s+commit\b'
r'|\bgt\s+(?:create|modify)\b'
)
# Match either the `--amend` flag (with the leading whitespace boundary
# preserved from the original) OR `gt modify` which is semantically an
# amend. The handler treats matches as "find the pre-amend SHA via reflog
# and diff against THAT, not against the post-amend HEAD's parent" — same
# code path for both git --amend and gt modify.
_GIT_AMEND_RE = re.compile(r'(?:\s--amend\b|\bgt\s+modify\b)')
# Rolling-window cap on LLM commit-review calls. See atomic_check_rate_limit
# docstring for the rationale that motivated the switch from a lifetime cap.
@@ -624,8 +688,13 @@ COMMIT_REVIEW_RATE_WINDOW_S = int(
# entry would buy minimal extra coverage (sessions that push only via gh) at
# the cost of an extra python spawn on every `... && gh pr create` compound
# (the common case). Those sessions are caught on their next standalone `git push`.
# Matches `git push` (with optional `-c k=v` / `-C path` global options
# CC's hooks.json matcher doesn't tolerate) OR `gt submit` — Graphite's
# stacked-PR push command. gt submit forwards to `git push` internally,
# but the bash hook fires on Claude's top-level command so we need to
# recognize gt submit at the matcher level. See #2048.
_GIT_PUSH_RE = re.compile(
r'\bgit(?:\s+-[cC]\s+\S+|\s+--\S+=\S+)*\s+push\b'
r'(?:\bgit(?:\s+-[cC]\s+\S+|\s+--\S+=\S+)*\s+push\b|\bgt\s+submit\b)'
)
# `git push` stdout: "abc1234..def5678 branch -> branch" (or `+abc..def` on
@@ -791,23 +860,30 @@ def _detect_prev_upstream(repo_root, bash_output):
# @{u}@{1} — only meaningful if an upstream is configured.
for ref in ("@{u}@{1}", "@{push}@{1}"):
try:
# See #2099: stdout is a SHA but stderr can carry non-ASCII git
# warnings — keep bytes raw to avoid cp1252 reader-thread crash.
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", ref],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
sha = r.stdout.decode("utf-8", errors="replace").strip()
if r.returncode == 0 and sha:
return sha
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
main = _detect_main_branch(repo_root)
if main:
try:
# See #2099: drop text=True; decode bytes manually so a
# cp1252-undefined byte in git's stderr doesn't crash the
# reader thread.
r = subprocess.run(
[*GIT_CMD, "merge-base", "HEAD", main],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
sha = r.stdout.decode("utf-8", errors="replace").strip()
if r.returncode == 0 and sha:
return sha
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
return None
@@ -1121,18 +1197,18 @@ def handle_commit_review_posttooluse(input_data):
# core.quotePath=false: emit raw UTF-8 in `diff --git a/... b/...`
# headers so non-ASCII paths aren't C-quoted past the downstream
# parse_diff_into_files regex (sibling of #2056 / #2075). See #2082.
# core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
if pre_amend_sha:
# Delta review: pre-amend → post-amend. `git diff` (not show)
# so the output is a pure unified diff with no commit header.
result = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false",
"diff", "--no-color", "--no-ext-diff", pre_amend_sha, sha, "--"],
[*GIT_CMD, "diff", "--no-color", "--no-ext-diff",
pre_amend_sha, sha, "--"],
cwd=repo_root, capture_output=True, timeout=15
)
else:
result = subprocess.run(
[*GIT_CMD, "-c", "core.quotePath=false",
"show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
[*GIT_CMD, "show", "-p", "--no-color", "--no-ext-diff", sha, "--"],
cwd=repo_root, capture_output=True, timeout=15
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
@@ -1259,12 +1335,13 @@ def handle_commit_review_posttooluse(input_data):
try:
full_shas = []
for s in shas:
# See #2099: drop text=True; decode manually for cp1252 safety.
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", s],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
if r.returncode == 0:
full_shas.append(r.stdout.strip())
full_shas.append(r.stdout.decode("utf-8", errors="replace").strip())
_append_reviewed_shas(repo_root, full_shas, vulns_found=len(vulns or []))
except Exception:
pass
@@ -1366,18 +1443,26 @@ def handle_commit_review_posttooluse(input_data):
if s in sev:
sev[s] += 1
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
# still lists deduped entries. Pass via additional_context so CC surfaces
# the reason via hookSpecificOutput.additionalContext instead of empty
# stdout (#1783) / stderr-only "json output validation failed" (#1375).
_commit_guidance = (PROVENANCE_BANNER + "\n\n"
+ _format_vulns_guidance(new_vulns)
+ CONTINUATION_SUFFIX + "\n")
emit_metrics({
"vulns_found": len(new_vulns), **_base, **_agentic_m,
"critical_count": sev["critical"], "high_count": sev["high"],
"files_reviewed": len(diff_files), "review_ms": review_ms,
**({"deduped": n_deduped} if n_deduped else {}),
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"))
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"),
additional_context=_commit_guidance,
hook_event_name="PostToolUse")
# Rebuild guidance from new_vulns only — concrete_guidance from the LLM
# still lists deduped entries.
sys.stderr.write(PROVENANCE_BANNER + "\n\n"
+ _format_vulns_guidance(new_vulns)
+ CONTINUATION_SUFFIX + "\n")
# exit(2) is preserved per the asyncRewake protocol — it's what CC
# uses as the "force fix" signal that triggers the rewakeMessage flow.
# The stderr.write was removed; additional_context above now carries
# the same text via the modern JSON channel. See #1358/#1375/#1783.
sys.exit(2)
def handle_push_sweep_posttooluse(input_data):
@@ -1458,9 +1543,10 @@ def handle_push_sweep_posttooluse(input_data):
# both.
head = None
try:
# See #2099: drop text=True; decode manually for cp1252 safety.
r = subprocess.run([*GIT_CMD, "rev-parse", "HEAD"], cwd=repo_root,
capture_output=True, text=True, timeout=5)
head = r.stdout.strip() if r.returncode == 0 else None
capture_output=True, timeout=5)
head = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
push_section = _push_section(bash_output or "")
@@ -1490,14 +1576,15 @@ def handle_push_sweep_posttooluse(input_data):
quiet_success = False
if not (bash_output or "").strip() and not interrupted:
try:
# See #2099: drop text=True; decode manually for cp1252 safety.
r_cur = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}"],
cwd=repo_root, capture_output=True, text=True, timeout=5)
cwd=repo_root, capture_output=True, timeout=5)
r_prev = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}@{1}"],
cwd=repo_root, capture_output=True, text=True, timeout=5)
cur = r_cur.stdout.strip() if r_cur.returncode == 0 else ""
prev_u = r_prev.stdout.strip() if r_prev.returncode == 0 else ""
cwd=repo_root, capture_output=True, timeout=5)
cur = r_cur.stdout.decode("utf-8", errors="replace").strip() if r_cur.returncode == 0 else ""
prev_u = r_prev.stdout.decode("utf-8", errors="replace").strip() if r_prev.returncode == 0 else ""
quiet_success = bool(cur and prev_u and cur == head and prev_u != cur)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
@@ -1511,11 +1598,12 @@ def handle_push_sweep_posttooluse(input_data):
# reviewed-shas state.
for local_ref in new_branch_matches:
try:
# See #2099: drop text=True; decode manually for cp1252 safety.
r = subprocess.run(
[*GIT_CMD, "rev-parse", "--verify", "-q", local_ref],
cwd=repo_root, capture_output=True, text=True, timeout=5,
cwd=repo_root, capture_output=True, timeout=5,
)
local_sha = r.stdout.strip() if r.returncode == 0 else ""
local_sha = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else ""
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
local_sha = ""
if local_sha and local_sha != head:
@@ -1634,17 +1722,23 @@ def handle_push_sweep_posttooluse(input_data):
# Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
# here in favour of the push-sweep funnel keys (telemetry can join on session_id
# to the per-commit fires for agentic detail). rewake_summary must ride
# this line (CC reads only the first {-prefixed stdout line); it's a
# no-op when new_vulns is empty since we exit 0 below.
emit_metrics({
# this line (CC reads only the first {-prefixed stdout line); the emit
# is deferred to the two exit points below so the with-vulns path can
# also pass additional_context in the same JSON line (#1375/#1783) —
# the by-design "CC keeps only the first JSON line" constraint means
# we can't emit twice. Builds the shared metrics dict here; vulns path
# adds additional_context, no-vulns path emits as-is.
_push_metrics = {
**_base, "pushed": len(push_range), "unreviewed": len(tail),
"prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
"files_reviewed": len(diff_files), "review_ms": review_ms,
**({"deduped": n_deduped} if n_deduped else {}),
}, rewake_summary=_format_vulns_summary(new_vulns, prefix="Push security review found"))
}
_push_rewake_summary = _format_vulns_summary(new_vulns, prefix="Push security review found")
if not new_vulns:
debug_log("Push sweep: no new findings")
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary)
sys.exit(0)
# First-push of a big branch can surface many findings at once across
@@ -1697,9 +1791,14 @@ def handle_push_sweep_posttooluse(input_data):
guidance = _format_vulns_guidance(reported) or ""
else:
guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
sys.stderr.write(
PROVENANCE_BANNER + "\n\n" + guidance + CONTINUATION_SUFFIX + "\n"
)
# Emit metrics + additional_context together — single JSON line is the
# contract CC's hook parser expects. exit(2) preserved as the asyncRewake
# "force fix" trigger (see comment near handle_commit_review_posttooluse).
# See #1358 / #1375 / #1783.
emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary,
additional_context=(PROVENANCE_BANNER + "\n\n"
+ guidance + CONTINUATION_SUFFIX + "\n"),
hook_event_name="PostToolUse")
sys.exit(2)
def handle_stop_hook(input_data):
@@ -1932,6 +2031,11 @@ def handle_stop_hook(input_data):
# untracked_baseline_n is the signal for whether the UPS-time
# untracked-snapshot capture actually ran.
sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
# Pass guidance via additional_context so CC surfaces the findings via
# hookSpecificOutput.additionalContext instead of stderr-only (which
# was the cause of "json output validation failed" / empty-reason UI in
# #1375 / #1783). exit(2) preserved as the asyncRewake "force fix"
# signal — that's the documented mechanism. See #1358 / #1375 / #1783.
emit_metrics({
"vulns_found": len(vulns),
"untracked_baseline_n": len(untracked_at_baseline),
@@ -1945,10 +2049,10 @@ def handle_stop_hook(input_data):
**({"diff_truncated": llm._last_review_truncated_bytes}
if llm._last_review_truncated_bytes else {}),
**sweep_trimmed,
}, rewake_summary=_format_vulns_summary(vulns))
# Exit code 2 with stderr forces Claude to continue and fix
sys.stderr.write(PROVENANCE_BANNER + "\n\n" + concrete_guidance + CONTINUATION_SUFFIX + "\n")
}, rewake_summary=_format_vulns_summary(vulns),
additional_context=(PROVENANCE_BANNER + "\n\n"
+ concrete_guidance + CONTINUATION_SUFFIX + "\n"),
hook_event_name="Stop")
sys.exit(2)
if llm._last_call_claude_http_error is not None:
@@ -1976,10 +2080,7 @@ def handle_stop_hook(input_data):
})
sys.exit(0)
_SDK_BOOTSTRAP_THROTTLE = os.path.join(
os.environ.get("SECURITY_WARNINGS_STATE_DIR")
or os.path.expanduser("~/.claude/security"),
".sdk_bootstrap_spawned")
_SDK_BOOTSTRAP_THROTTLE = os.path.join(_resolve_state_dir(), ".sdk_bootstrap_spawned")
def _maybe_bootstrap_agent_sdk_async():
"""Fire-and-forget SDK bootstrap, for remote-pod environments.

View File

@@ -19,7 +19,7 @@ import os
import re
from datetime import datetime
from _base import debug_log
from _base import debug_log, state_dir as _state_dir
def _state_key(session_id):
@@ -36,20 +36,20 @@ def _state_key(session_id):
def get_state_file(session_id):
"""Get session-specific state file path."""
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
state_dir = _state_dir()
return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.json")
def get_lock_file(session_id):
"""Get session-specific lock file path."""
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
state_dir = _state_dir()
return os.path.join(state_dir, f"security_warnings_state_{_state_key(session_id)}.lock")
def cleanup_old_state_files():
"""Remove state files and lock files older than 30 days."""
try:
state_dir = os.environ.get("SECURITY_WARNINGS_STATE_DIR", os.path.expanduser("~/.claude/security"))
state_dir = _state_dir()
if not os.path.exists(state_dir):
return

View File

@@ -22,6 +22,17 @@
# "${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
set -e
# Force UTF-8 for ALL Python filesystem + IO operations (PEP 540).
# Without this, Windows Python defaults `locale.getpreferredencoding()` to
# cp1252 — which makes `text=True` in subprocess.run / open() / json.load
# crash the internal reader thread on any byte that's undefined in cp1252
# (e.g. the 0x81 byte from ف, present in any path/filename with
# Arabic/Hebrew/CJK characters). See #2056, #2099.
#
# No-op on macOS/Linux (already UTF-8). Must be set BEFORE Python starts —
# changing it from inside the interpreter has no effect.
export PYTHONUTF8=1
# Git Bash / MSYS on Windows hands script paths to this shim in POSIX form
# (`/c/Users/...`). When we exec a Windows `python.exe` (which we do on
# Windows since `python3` is the Microsoft Store stub), python interprets the