Compare commits

..

11 Commits

Author SHA1 Message Date
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
9 changed files with 324 additions and 139 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": "c2c82ab5fd2f4f5c0cddc9c7d8a749655dec4cb9"
},
"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

@@ -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

@@ -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

@@ -32,12 +32,17 @@ GIT_CMD = [
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 +57,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 +137,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 +157,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 +183,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 +200,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 []
@@ -220,9 +243,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
@@ -410,9 +435,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):

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
@@ -1145,10 +1142,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
@@ -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