Skip to content

Commit 66bc455

Browse files
feat(ai_hook): refactor into new "ai" vertical
1 parent adc734d commit 66bc455

File tree

16 files changed

+613
-649
lines changed

16 files changed

+613
-649
lines changed

.importlinter

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ ignore_imports =
4646
ggshield.cmd.hmsl.** -> ggshield.verticals.hmsl.**
4747
ggshield.cmd.honeytoken.** -> ggshield.verticals.honeytoken
4848
ggshield.cmd.honeytoken.** -> ggshield.verticals.honeytoken.**
49-
ggshield.cmd.install -> ggshield.verticals.secret.ai_hook
5049
ggshield.cmd.install.** -> ggshield.verticals.install
5150
ggshield.cmd.install.** -> ggshield.verticals.install.**
5251
ggshield.cmd.plugin.** -> ggshield.core.plugin

ggshield/cmd/install.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from ggshield.core.dirs import get_data_dir
1111
from ggshield.core.errors import UnexpectedError
1212
from ggshield.utils.git_shell import check_git_dir, git
13-
from ggshield.verticals.secret.ai_hook import AI_FLAVORS, install_hooks
13+
from ggshield.verticals.ai.installation import AGENTS, install_hooks
1414

1515

1616
# This snippet is used by the global hook to call the hook defined in the
@@ -39,7 +39,7 @@
3939
@click.option(
4040
"--hook-type",
4141
"-t",
42-
type=click.Choice(["pre-commit", "pre-push"] + list(AI_FLAVORS.keys())),
42+
type=click.Choice(["pre-commit", "pre-push"] + list(AGENTS.keys())),
4343
help="Type of hook to install.",
4444
default="pre-commit",
4545
)
@@ -61,7 +61,7 @@ def install_cmd(
6161
It can also install ggshield as a Cursor IDE or Claude Code agent hook.
6262
"""
6363

64-
if hook_type in AI_FLAVORS:
64+
if hook_type in AGENTS:
6565
return install_hooks(name=hook_type, mode=mode, force=force)
6666

6767
return_code = (

ggshield/cmd/secret/scan/ai_hook.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from ggshield.core.scan import ScanContext, ScanMode
1313
from ggshield.verticals.secret import SecretScanner
1414
from ggshield.verticals.secret.ai_hook import AIHookScanner
15-
from ggshield.verticals.secret.ai_hook.models import MAX_READ_SIZE
15+
16+
17+
MAX_READ_SIZE = 1024 * 1024 * 10 # We restrict stdin read to 10MB
1618

1719

1820
@click.command()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from .claude_code import Claude
2+
from .copilot import Copilot
3+
from .cursor import Cursor
4+
5+
6+
AGENTS = {agent.name: agent for agent in [Cursor(), Claude(), Copilot()]}
7+
8+
9+
__all__ = ["AGENTS", "Claude", "Copilot", "Cursor"]

ggshield/verticals/secret/ai_hook/claude_code.py renamed to ggshield/verticals/ai/agents/claude_code.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@
44

55
import click
66

7-
from .models import EventType, Flavor, Result
7+
from ..models import Agent, EventType, HookResult
88

99

10-
class Claude(Flavor):
10+
class Claude(Agent):
1111
"""Behavior specific to Claude Code."""
1212

13-
name = "Claude Code"
13+
@property
14+
def name(self) -> str:
15+
return "claude-code"
16+
17+
@property
18+
def display_name(self) -> str:
19+
return "Claude Code"
1420

15-
def output_result(self, result: Result) -> int:
21+
def output_result(self, result: HookResult) -> int:
1622
response = {}
1723
if result.block:
1824
if result.payload.event_type in [

ggshield/verticals/secret/ai_hook/copilot.py renamed to ggshield/verticals/ai/agents/copilot.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import click
55

6+
from ..models import EventType, HookResult
67
from .claude_code import Claude
7-
from .models import EventType, Result
88

99

1010
class Copilot(Claude):
@@ -13,9 +13,15 @@ class Copilot(Claude):
1313
Inherits most of its behavior from Claude Code.
1414
"""
1515

16-
name = "Copilot"
16+
@property
17+
def name(self) -> str:
18+
return "copilot"
19+
20+
@property
21+
def display_name(self) -> str:
22+
return "Copilot Chat"
1723

18-
def output_result(self, result: Result) -> int:
24+
def output_result(self, result: HookResult) -> int:
1925
response = {}
2026
if result.block:
2127
if result.payload.event_type == EventType.PRE_TOOL_USE:

ggshield/verticals/secret/ai_hook/cursor.py renamed to ggshield/verticals/ai/agents/cursor.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,21 @@
44

55
import click
66

7-
from .models import EventType, Flavor, Result
7+
from ..models import Agent, EventType, HookResult
88

99

10-
class Cursor(Flavor):
10+
class Cursor(Agent):
1111
"""Behavior specific to Cursor."""
1212

13-
name = "Cursor"
13+
@property
14+
def name(self) -> str:
15+
return "cursor"
16+
17+
@property
18+
def display_name(self) -> str:
19+
return "Cursor"
1420

15-
def output_result(self, result: Result) -> int:
21+
def output_result(self, result: HookResult) -> int:
1622
response = {}
1723
if result.payload.event_type == EventType.USER_PROMPT:
1824
response["continue"] = not result.block

ggshield/verticals/ai/hooks.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import hashlib
2+
import json
3+
import re
4+
from typing import Any, Dict, List, Sequence, Set
5+
6+
from ggshield.verticals.ai.agents import Claude, Copilot, Cursor
7+
8+
from .models import Agent, EventType, HookPayload, Tool
9+
10+
11+
HOOK_NAME_TO_EVENT_TYPE = {
12+
"userpromptsubmit": EventType.USER_PROMPT,
13+
"beforesubmitprompt": EventType.USER_PROMPT,
14+
"pretooluse": EventType.PRE_TOOL_USE,
15+
"posttooluse": EventType.POST_TOOL_USE,
16+
}
17+
18+
TOOL_NAME_TO_TOOL = {
19+
"shell": Tool.BASH, # Cursor
20+
"bash": Tool.BASH, # Claude Code
21+
"run_in_terminal": Tool.BASH, # Copilot
22+
"read": Tool.READ, # Claude/Cursor
23+
"read_file": Tool.READ, # Copilot
24+
}
25+
26+
27+
def lookup(data: Dict[str, Any], keys: Sequence[str], default: Any = None) -> Any:
28+
"""Returns the value of the first key found in a dictionary."""
29+
for key in keys:
30+
if key in data:
31+
return data[key]
32+
return default
33+
34+
35+
# Regex (and method) to look for any @file_path in the prompt.
36+
# A list of test cases can be found in test_hooks.py.
37+
_FILE_PATH_REGEX = re.compile(
38+
r'@"((?:[^"\\]|\\.)*)"' # quoted: @"..."
39+
r"|"
40+
r"(?:\W|^)@([\w/\\.-]+)", # unquoted: @path
41+
re.MULTILINE,
42+
)
43+
44+
45+
def find_filepaths(prompt: str) -> Set[str]:
46+
"""Find all file paths in the prompt."""
47+
paths = set()
48+
for m in _FILE_PATH_REGEX.finditer(prompt):
49+
path = m.group(1) or m.group(2) or ""
50+
path = path.strip()
51+
# Don't include trailing dots in the path
52+
if path.endswith("."):
53+
path = path[:-1]
54+
if path:
55+
paths.add(path)
56+
return paths
57+
58+
59+
def parse_hook_input(raw_content: str) -> list[HookPayload]:
60+
"""Parse the input content. Raises a ValueError if the input is not valid.
61+
62+
Returns:
63+
A list of payloads. Most of the time the list will contain only one payload,
64+
but in some cases files mentioned in the prompt will be read but the
65+
PreToolUse event will not be called. So we need to handle this case ourselves.
66+
"""
67+
# Parse the content as JSON
68+
if not raw_content.strip():
69+
raise ValueError("Error: No input received on stdin")
70+
try:
71+
data = json.loads(raw_content)
72+
except json.JSONDecodeError as e:
73+
raise ValueError(f"Error: Failed to parse JSON from stdin: {e}") from e
74+
75+
payloads = []
76+
77+
# Try to guess which AI coding assistant is calling us
78+
agent = _detect_agent(data)
79+
80+
# Infer the event type
81+
event_name = lookup(data, ["hook_event_name", "hookEventName"], None)
82+
if event_name is None:
83+
raise ValueError("Error: couldn't find event type")
84+
event_type = HOOK_NAME_TO_EVENT_TYPE.get(event_name.lower(), EventType.OTHER)
85+
86+
identifier = ""
87+
content = ""
88+
tool = None
89+
90+
# Extract the identifier and content based on the event type
91+
if event_type == EventType.USER_PROMPT:
92+
content = data.get("prompt", "")
93+
# Look for files mentioned in the prompt that could be read
94+
# without triggering a PRE_TOOL_USE event.
95+
payloads.extend(_parse_user_prompt(content, event_type, agent))
96+
97+
elif event_type == EventType.PRE_TOOL_USE:
98+
tool_name = data.get("tool_name", "").lower()
99+
tool = TOOL_NAME_TO_TOOL.get(tool_name, Tool.OTHER)
100+
tool_input = data.get("tool_input", {})
101+
# Select the content based on the tool
102+
if tool == Tool.BASH:
103+
content = tool_input.get("command", "")
104+
identifier = content
105+
elif tool == Tool.READ:
106+
# We only need to deal with the identifier, the content will be read by the Scannable
107+
identifier = lookup(tool_input, ["file_path", "filePath"], "")
108+
109+
elif event_type == EventType.POST_TOOL_USE:
110+
tool_name = data.get("tool_name", "").lower()
111+
tool = TOOL_NAME_TO_TOOL.get(tool_name, Tool.OTHER)
112+
content = data.get("tool_output", "") or data.get("tool_response", {})
113+
# Claude Code returns a dict for the tool output
114+
if isinstance(content, (dict, list)):
115+
content = json.dumps(content)
116+
117+
# If identifier was not set, hash the content
118+
if not identifier:
119+
identifier = hashlib.sha256((content or "").encode()).hexdigest()
120+
121+
payloads.append(
122+
HookPayload(
123+
event_type=event_type,
124+
tool=tool,
125+
content=content,
126+
identifier=identifier,
127+
agent=agent,
128+
)
129+
)
130+
return payloads
131+
132+
133+
def _detect_agent(data: Dict[str, Any]) -> Agent:
134+
"""Detect the AI code assistant."""
135+
if "cursor_version" in data:
136+
return Cursor()
137+
elif "github.copilot-chat" in data.get("transcript_path", "").lower():
138+
return Copilot()
139+
# no .lower() here to reduce the risk of false positives (this is also why this check is last)
140+
elif "session_id" in data and "claude" in data.get("transcript_path", ""):
141+
return Claude()
142+
# No other agent is supported yet
143+
raise ValueError("Unsupported agent")
144+
145+
146+
def _parse_user_prompt(
147+
content: str, event_type: EventType, agent: Agent
148+
) -> List[HookPayload]:
149+
"""Parse the user prompt for additional payloads that we may miss."""
150+
payloads = []
151+
# Scenario 1 (the only one we know about so far):
152+
# Code assistants don't always trigger a PRE_TOOL_USE event when
153+
# a file is mentioned in the prompt, especially with an "@" prefix.
154+
matches = find_filepaths(content)
155+
for match in matches:
156+
payloads.append(
157+
HookPayload(
158+
event_type=event_type,
159+
tool=Tool.READ,
160+
content="",
161+
identifier=match,
162+
agent=agent,
163+
)
164+
)
165+
return payloads

ggshield/verticals/secret/ai_hook/installation.py renamed to ggshield/verticals/ai/installation.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,7 @@
99
from ggshield.core.dirs import get_user_home_dir
1010
from ggshield.core.errors import UnexpectedError
1111

12-
from .claude_code import Claude
13-
from .copilot import Copilot
14-
from .cursor import Cursor
15-
16-
17-
AI_FLAVORS = {
18-
"cursor": Cursor,
19-
"claude-code": Claude,
20-
"copilot": Copilot,
21-
}
12+
from .agents import AGENTS
2213

2314

2415
@dataclass
@@ -41,12 +32,12 @@ def install_hooks(
4132
"""
4233

4334
try:
44-
flavor = AI_FLAVORS[name]()
35+
agent = AGENTS[name]
4536
except KeyError:
46-
raise ValueError(f"Unsupported tool name: {name}")
37+
raise ValueError(f"Unsupported agent: {name}")
4738

4839
base_dir = get_user_home_dir() if mode == "global" else Path(".")
49-
settings_path = base_dir / flavor.settings_path
40+
settings_path = base_dir / agent.settings_path
5041

5142
command = "ggshield secret scan ai-hook"
5243

@@ -71,11 +62,11 @@ def install_hooks(
7162

7263
stats = _fill_dict(
7364
config=existing_config,
74-
template=flavor.settings_template,
65+
template=agent.settings_template,
7566
command=command,
7667
overwrite=force,
7768
stats=stats,
78-
locator=flavor.settings_locate,
69+
locator=agent.settings_locate,
7970
)
8071

8172
# Ensure parent directory exists
@@ -89,11 +80,11 @@ def install_hooks(
8980
# Report what happened
9081
styled_path = click.style(settings_path, fg="yellow", bold=True)
9182
if stats.added == 0 and stats.already_present > 0:
92-
click.echo(f"{flavor.name} hooks already installed in {styled_path}")
83+
click.echo(f"{agent.name} hooks already installed in {styled_path}")
9384
elif stats.added > 0 and stats.already_present > 0:
94-
click.echo(f"{flavor.name} hooks updated in {styled_path}")
85+
click.echo(f"{agent.name} hooks updated in {styled_path}")
9586
else:
96-
click.echo(f"{flavor.name} hooks successfully added in {styled_path}")
87+
click.echo(f"{agent.name} hooks successfully added in {styled_path}")
9788

9889
return 0
9990

0 commit comments

Comments
 (0)