Skip to content

feat(nhi,ai-agents): Add support for Cursor and Claude secrets hooks#1183

Merged
paulpetit-gg-ext merged 1 commit intomainfrom
ppetit/NHI-1403/cursor_claude_hooks
Mar 17, 2026
Merged

feat(nhi,ai-agents): Add support for Cursor and Claude secrets hooks#1183
paulpetit-gg-ext merged 1 commit intomainfrom
ppetit/NHI-1403/cursor_claude_hooks

Conversation

@xblanchot-gg
Copy link
Contributor

@xblanchot-gg xblanchot-gg commented Mar 3, 2026

This PR adds support for Cursor and Claude secret detection hooks.
It's from Paul Petit, waiting for his account to be re-activated, the PR description will be detailed today.

@xblanchot-gg xblanchot-gg requested a review from a team March 3, 2026 14:37
@xblanchot-gg xblanchot-gg requested a review from a team as a code owner March 3, 2026 14:37
@linear
Copy link

linear bot commented Mar 3, 2026

@codecov
Copy link

codecov bot commented Mar 3, 2026

Codecov Report

❌ Patch coverage is 95.76271% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.76%. Comparing base (47bfac2) to head (331ab74).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
ggshield/cmd/secret/scan/ai_hook.py 60.00% 10 Missing ⚠️
ggshield/verticals/secret/ai_hook/scanner.py 96.89% 4 Missing ⚠️
ggshield/cmd/install.py 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1183      +/-   ##
==========================================
+ Coverage   92.62%   92.76%   +0.13%     
==========================================
  Files         160      168       +8     
  Lines        7756     8109     +353     
==========================================
+ Hits         7184     7522     +338     
- Misses        572      587      +15     
Flag Coverage Δ
unittests 92.76% <95.76%> (+0.13%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

@amascia-gg amascia-gg left a comment

Choose a reason for hiding this comment

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

Code Review Summary

Overall: Well-structured feature with clean architecture (Flavor strategy pattern, good cross-tool quirk handling). A few issues to address before merge.


Critical

1. break skips remaining template keys in _fill_dictggshield/verticals/secret/ai_hook/installation.py:132

The break exits the for key, value in template.items() loop when a list match is found. For the Claude Code template (which has PreToolUse, PostToolUse, UserPromptSubmit at the same level), only the first hook type gets updated during reinstallation — the others are silently skipped.

                _fill_dict(existing_value, value[0], command, overwrite, stats, locator)
-                break

2. File handle resource leakggshield/verticals/secret/ai_hook/scanner.py:95

content = open(identifier, "r").read()  # never closed

Should use a context manager. Combined fix with items below:

3. Arbitrary file read without path validationscanner.py:91-95

file_path from untrusted JSON is used directly to read any file on disk. Content is then sent to the GitGuardian API. Should validate path is within workspace_roots/cwd and use is_file() instead of exists().

Suggested consolidated fix for findings 2, 3, plus missing encoding and error handling:

elif tool == Tool.READ:
    identifier = lookup(tool_input, ["file_path", "filePath"], "")
    content = ""
    try:
        file_path = Path(identifier)
        if file_path.is_file() and file_path.stat().st_size <= MAX_FILE_READ_SIZE:
            with open(identifier, "r", encoding="utf-8") as f:
                content = f.read()
    except (OSError, UnicodeDecodeError):
        content = ""

High

4. No exception handling around notify-pyscanner.py:238-246

Will crash the hook on headless/CI environments (no display server, DBus errors). Since this runs in the POST_TOOL_USE path, the scan result would be lost and the AI tool might hang waiting for JSON output.

try:
    notification = Notify()
    # ...
    notification.send()
except Exception:
    pass  # Best-effort notification

5. No size limit on stdin readcmd/secret/scan/ai_hook.py:43

stdin_content = sys.stdin.read()  # unbounded

Consider sys.stdin.read(MAX_STDIN_SIZE) (e.g. 10 MB).

6. Notification icon uses relative source repo pathscanner.py:245

notification.icon = "scripts/chocolatey/icon.png"

This path won't exist when installed via pip/pipx. Use a package resource or remove the icon setting.

7. Inconsistent case handling in flavor detectionscanner.py:121-123

.lower() is applied for Copilot detection but not for Claude:

elif "github.copilot-chat" in data.get("transcript_path", "").lower():
    flavor = Copilot()
elif "claude" in data.get("transcript_path", ""):  # <-- not lowered
    flavor = Claude()

Medium

8. --append flag silently ignored for AI hookscmd/install.py

When hook_type in AI_FLAVORS, append is never used. Consider raising a UsageError if --append is passed with an AI hook type.

9. Stale docstringsscanner.py:182,229

  • _message_from_secrets docstring references param message (should be payload)
  • _send_secret_notification docstring references secrets/source (should be nbr_secrets/tool/agent_name)

10. tool_response type handling is fragilescanner.py:104

content = data.get("tool_output", "") or data.get("tool_response", {})
if isinstance(content, dict):
    content = json.dumps(content)

If tool_response is a list, it passes through as-is (not a dict). Consider:

raw_output = data.get("tool_output") or data.get("tool_response") or ""
content = json.dumps(raw_output) if isinstance(raw_output, (dict, list)) else str(raw_output)

Test Coverage

Estimated effective branch coverage: ~55-60%. Key gaps:

  • scan() public method is untested — the POST_TOOL_USE notification path is completely uncovered
  • _send_secret_notification() untested
  • install_hooks() public function untested (only _fill_dict helper is tested)
  • No test uses the actual Claude Code template — would have caught the break bug (#1)
  • Missing edge cases: EventType.OTHER, fallback Flavor(), Tool.OTHER, directory paths, binary files

@paulpetit-gg-ext
Copy link
Contributor

Here is a more detailed description of what this PR does:

Add secret scanning for AI coding tool hooks

Summary

  • Add ggshield secret scan ai-hook command that scans AI coding assistant interactions (prompts, shell commands, file reads, tool I/O) for secrets in real-time, blocking or notifying as appropriate
  • Extend ggshield install -t <hook-type> to support cursor, claude-code, and copilot, automatically writing the correct hook configuration into each tool's settings file
  • Support auto-detection of which AI tool is calling (Cursor, Claude Code, Copilot) based on payload fields, since tools may cross-call each other's hook formats

Details

New command: ggshield secret scan ai-hook

Reads a hook event from stdin as JSON, scans the relevant content for secrets using the existing SecretScanner, and outputs a JSON response in the calling tool's expected format.

Supported hook event types:

  • UserPromptSubmit / beforeSubmitPrompt — scans the user's prompt before submission; blocks if secrets are found
  • PreToolUse / preToolUse — scans bash commands before execution and reads files before the AI tool does; blocks if secrets are found
  • PostToolUse / postToolUse — scans tool output after execution; since the action already happened, sends a desktop notification instead of blocking

Turns out this three hooks are both universal among coding assistants (naming aside) and are called in all cases that interest us. Other hooks like Cursor's beforeReadFile or equivalent are called in addition to them.

Hook installation

ggshield install -t cursor|claude-code|copilot writes or merges ggshield hooks into the tool's configuration file (.cursor/hooks.json, .claude/settings.json, .github/hooks/hooks.json). The installation is idempotent: it detects existing ggshield hooks and avoids duplicates, and supports --force to overwrite.

Architecture

  • Flavor base class with Cursor, Claude, and Copilot subclasses handle tool-specific output formatting, settings paths, and configuration templates
  • AIHookScanner is a single scanner class that auto-detects the calling tool from payload fields (e.g., cursor_version, transcript_path) rather than relying on the hook configuration
  • Desktop notifications via notifypy for post-execution secret detection

New dependencies

  • notifypy — cross-platform desktop notifications

@paulpetit-gg-ext
Copy link
Contributor

paulpetit-gg-ext commented Mar 5, 2026

To comment on the automated review:

  1. The comment is wrong. hooks are keys in an object, not elements in an array. This is also why we assert that any list that appear in our templates only contain one element (that will contain where the hook command is defined). This is indeed an error.
  2. Actshually this is not a leak given how CPython handles files and memory, but I changed it nonetheless to be more Pythonic. :P
  3. Fair point. I put a 10MB limit, I'm still not exactly sure if this is something we want... But I also added a graceful management of reading binary data, which I forgot, and would have been a far better comment for a review 👼
  4. The package's documentation doesn't really explain how this could raise an exception... And I don't think the CI/CD scenario mentioned is a plausible use case for code assistant hooks.
  5. The remark is relevant, but I think I'm ok with that. All code assistants have their own payload limit (counted in tokens, but that still means a manageable payload size in any case).
  6. Probably true. Also, the doc explains that custom icon will probably not work in MacOS, so I removed the icon entirely.
  7. This was on purpose, but should have been explained. I also improved the test made.
  8. Yes... I don't really know what to do between -a and -f. I would like someone's opinion on this.
  9. Nice catch. Fixed.
  10. I never encountered this case but it doesn't hurt to support it. Fixed.

To summarizes, I would like other's opinion on these points:

  • What behavior do we want for the -a and -f flags of the ggshield install command, when used to install AI hooks? For now only the -f is taken into account and -a is considered to always be what we want (we never want to replace hooks installed by other tools).
  • Do we want to put a limit on the size of files read? On the payload received via stdin?

Copy link
Contributor Author

@xblanchot-gg xblanchot-gg left a comment

Choose a reason for hiding this comment

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

A few comments, but nothing really problematic. I still need to check the tests

Copy link
Collaborator

@agateau-gg agateau-gg left a comment

Choose a reason for hiding this comment

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

Looks mostly good, but I wouldn't mind if next PRs are less than 2k lines.

Comment on lines +62 to +67
if result.block:
click.echo(result.message, err=True)
return 2
else:
click.echo("No secrets found. Good to go.")
return 0
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since every class inheriting from Flavor override this method, I think it should be abstract.

Copy link
Contributor

Choose a reason for hiding this comment

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

Currently if we are not able to identify which code assistant triggered the hook, we fallback to a base instance with this default implementation (returning 2 is a "universal" convention among coding tools, but prevents from having a deeper integration, like surfacing the error to the user, etc.)

So we can't make the class abstract with the current logic.

@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from 72d425d to f722e44 Compare March 10, 2026 17:09
@paulpetit-gg-ext
Copy link
Contributor

Thanks for all your reviews! I applied your comments and rebased to main. I took the liberty to close suggestions I implemented right away. I left open those that may still need some feedback.

I added a commit with your fixes, but once this PR is approved I think i will squash them because the two fix commits don't make a lot of sense by themselves.

@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from f722e44 to 7a4d69e Compare March 10, 2026 17:14
Copy link
Contributor Author

@xblanchot-gg xblanchot-gg left a comment

Choose a reason for hiding this comment

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

Looks good to me thanks a lot Paul ! I cannot approve as I created the PR for you but you have my validation ;)

@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from 7a4d69e to 814b308 Compare March 12, 2026 14:33
@paulpetit-gg-ext paulpetit-gg-ext self-requested a review March 12, 2026 14:34
Copy link
Contributor

@paulpetit-gg-ext paulpetit-gg-ext left a comment

Choose a reason for hiding this comment

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

(approved in the name of xavier)

@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from 814b308 to 116a88a Compare March 12, 2026 16:43
@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from 116a88a to 946a8c3 Compare March 17, 2026 09:54
@paulpetit-gg-ext paulpetit-gg-ext force-pushed the ppetit/NHI-1403/cursor_claude_hooks branch from 946a8c3 to 331ab74 Compare March 17, 2026 09:55
@paulpetit-gg-ext paulpetit-gg-ext merged commit 09e184a into main Mar 17, 2026
25 of 26 checks passed
@paulpetit-gg-ext paulpetit-gg-ext deleted the ppetit/NHI-1403/cursor_claude_hooks branch March 17, 2026 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants