Skip to content

Commit 2da1687

Browse files
auditclaude
andcommitted
Drop CI/local-run noise from production Sentry in report_to_sentry()
The discovery CI suite runs the real script against a loopback mock gateway on clean GitHub-hosted runners, so every scan finds zero tools and the previously log-only Sentry paths self-report to the production project: no_tools_found / send-failure / timeout / signal events with zero customer impact (DISCOVERY-TOOL-SCRIPT-17 / -13 / -12 / -D; 346 events observed in 24h). Add a before_send-style guard at the top of report_to_sentry() that drops an event carrying a CI fingerprint, using two event-level signals measured 0-false-positive against those issues: - the report target (domain) is a loopback host -- normalized through the stdlib socket parsers so every spelling is caught (IPv4 dotted quad + shorthand like 127.1, ::1 and its expanded form, IPv4-mapped ::ffff:127.0.0.1), plus localhost / 0.0.0.0. A real install always points at the customer gateway URL, never loopback; an FQDN such as 127.example.com is not a valid IP literal so it is never matched. - the OS account (system_user) is a GitHub-hosted-runner account (runner / runneradmin). Keyed on the event context rather than a CI env var on purpose: report_to_sentry()'s own dedup/cap/breaker tests run under CI and must still exercise the transport, so an env-based unconditional drop would break them. Bare-context extract/detect emits are out of scope here; the fix for those is source-side (don't point the production DSN at CI runs). The guard never raises and defaults to keeping the event, so a malformed context can never suppress a genuine customer error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents f890439 + c6b102a commit 2da1687

28 files changed

Lines changed: 2396 additions & 427 deletions

scripts/coding_discovery_tools/ai_tools_discovery.py

Lines changed: 84 additions & 35 deletions
Large diffs are not rendered by default.

scripts/coding_discovery_tools/coding_tool_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ def create_all_tool_detectors(os_name: Optional[str] = None) -> list:
736736
ToolDetectorFactory.create_roo_detector(os_name),
737737
]
738738

739-
# Add Claude Cowork detector for macOS and Windows
739+
# Add Claude Cowork detector for macOS, Windows, and Linux
740740
claude_cowork_detector = ToolDetectorFactory.create_claude_cowork_detector(os_name)
741741
if claude_cowork_detector is not None:
742742
detectors.append(claude_cowork_detector)

scripts/coding_discovery_tools/linux/claude_cowork/claude_cowork.py

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
44
Cowork is the agentic feature of the Claude Desktop app. We treat it as a
55
distinct tool from Claude Code (which is the CLI). A device is considered
6-
to have Cowork installed if the on-disk session tree exists at:
6+
to have Cowork installed if BOTH:
77
8-
~/.config/Claude/local-agent-mode-sessions/
8+
- A Claude Desktop installation is discoverable on disk, AND
9+
- The on-disk session tree exists at
10+
~/.config/Claude/local-agent-mode-sessions/
911
1012
(Claude Desktop is an Electron app and follows the XDG convention of
1113
storing its data under ~/.config/Claude/ on Linux.)
1214
13-
If only the app is present (Cowork never enabled / never used), there is
14-
nothing on disk to report so we return None. When running as root we check
15-
every user's home (and /root) so MDM-style deployments are covered.
15+
If only the app is present (Cowork never enabled / never used) there is
16+
nothing on disk to report, and if only the sessions tree is present (app
17+
uninstalled, config residue left behind) it is not a real install — in both
18+
cases we return None. When running as root we check every user's home (and
19+
/root) so MDM-style deployments are covered.
1620
"""
1721

1822
import logging
@@ -52,17 +56,34 @@ def detect(self) -> Optional[Dict]:
5256
for user_home in get_linux_user_homes():
5357
sessions_dir = _sessions_dir_for_user(user_home)
5458
try:
55-
if sessions_dir.exists() and sessions_dir.is_dir():
56-
app_install = self._find_install_dir()
57-
install_path = str(app_install) if app_install else str(sessions_dir)
58-
return {
59-
"name": self.tool_name,
60-
"version": self.get_version(),
61-
"install_path": install_path,
62-
}
59+
if not (sessions_dir.exists() and sessions_dir.is_dir()):
60+
continue
6361
except OSError as e:
6462
logger.debug(f"Error checking Cowork sessions dir {sessions_dir}: {e}")
6563
continue
64+
65+
# Require the Claude Desktop install to be present. The per-user
66+
# ``~/.config/Claude`` tree (which holds the sessions dir) survives
67+
# an uninstall (anthropics/claude-code#25013), so reporting on the
68+
# sessions dir alone produced false positives. Gate on a real
69+
# install dir.
70+
app_install = self._find_install_dir()
71+
if app_install is None:
72+
logger.debug(
73+
"Cowork sessions present under %s but no Claude Desktop "
74+
"install found; treating as residue (not installed).",
75+
user_home,
76+
)
77+
continue
78+
79+
return {
80+
"name": self.tool_name,
81+
"version": self.get_version(),
82+
# Report the sessions dir (consistent with the macOS detector and
83+
# the central ``_detect_claude_cowork`` path). ``app_install`` is
84+
# the gate, not the reported path.
85+
"install_path": str(sessions_dir),
86+
}
6687
return None
6788

6889
def get_version(self) -> Optional[str]:
@@ -74,7 +95,9 @@ def get_version(self) -> Optional[str]:
7495
"""
7596
return None
7697

77-
def _find_install_dir(self) -> Optional[Path]:
98+
def _find_install_dir(self, user_home: Optional[Path] = None) -> Optional[Path]:
99+
# ``user_home`` is accepted for a uniform call signature with the central
100+
# path; Linux install dirs are machine-global so it is unused here.
78101
for candidate in _candidate_install_dirs():
79102
try:
80103
if candidate.exists() and candidate.is_dir():

scripts/coding_discovery_tools/linux/jetbrains/jetbrains.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class LinuxJetBrainsDetector(BaseToolDetector):
2525
IDE_NAME_MAPPING = {
2626
"IntelliJIdea": "IntelliJ IDEA",
2727
"IdeaIC": "IntelliJ IDEA Community",
28+
"IdeaIE": "IntelliJ IDEA Educational",
29+
"Aqua": "Aqua",
2830
"PyCharm": "PyCharm",
2931
"PyCharmCE": "PyCharm Community",
3032
"WebStorm": "WebStorm",
@@ -38,7 +40,10 @@ class LinuxJetBrainsDetector(BaseToolDetector):
3840
"DataSpell": "DataSpell",
3941
}
4042

41-
SKIP_FOLDERS = {"consentOptions", "PrivacyPolicy", "Toolbox"}
43+
SKIP_FOLDERS = {
44+
"consent", "DeviceId", "JetBrainsClient",
45+
"consentOptions", "PrivacyPolicy", "Toolbox",
46+
}
4247

4348
PLUGIN_NAME_OVERRIDES = {
4449
"ml-llm": "JetBrains AI Assistant",
@@ -149,7 +154,7 @@ def _parse_ide_name_and_version(self, folder_name: str) -> tuple:
149154

150155
@staticmethod
151156
def _detect_plan(folder_name: str) -> str:
152-
if "IdeaIC" in folder_name or "PyCharmCE" in folder_name:
157+
if "IdeaIC" in folder_name or "IdeaIE" in folder_name or "PyCharmCE" in folder_name:
153158
return "Free"
154159
return "Licensed"
155160

scripts/coding_discovery_tools/linux/junie/junie.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
from ...coding_tool_base import BaseToolDetector
1111
from ...linux_extraction_helpers import get_linux_user_homes
12+
from ...user_tool_detector import find_junie_binary_for_user
13+
from ..jetbrains.jetbrains import LinuxJetBrainsDetector
1214

1315
logger = logging.getLogger(__name__)
1416

@@ -48,22 +50,75 @@ def get_version(self) -> Optional[str]:
4850
return None
4951

5052
def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
51-
"""Detect Junie installation for a specific user."""
52-
junie_dir = user_home / self.JUNIE_DIR_NAME
53+
"""Detect Junie installation for a specific user.
5354
54-
if not junie_dir.exists() or not junie_dir.is_dir():
55+
Gates on a real install signal — the Junie CLI binary OR the Junie
56+
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
57+
user-authored guidelines residue that survives uninstall. ``~/.junie``
58+
is still used as the version source.
59+
"""
60+
junie_bin = find_junie_binary_for_user(user_home)
61+
install_path: Optional[str] = junie_bin
62+
63+
if not install_path:
64+
install_path = self._has_junie_jetbrains_plugin(user_home)
65+
66+
if not install_path:
5567
return None
5668

57-
logger.debug(f"Found Junie directory at: {junie_dir}")
69+
logger.debug(f"Detected Junie install signal at: {install_path}")
5870

59-
version = self._get_version_from_config(junie_dir)
71+
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)
6072

6173
return {
6274
"name": self.tool_name,
6375
"version": version or "Unknown",
64-
"install_path": str(junie_dir)
76+
"install_path": install_path
6577
}
6678

79+
def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
80+
"""Return an install_path if the Junie plugin is present in a JetBrains
81+
IDE belonging to ``user_home``, else None.
82+
83+
Scoping matters: ``LinuxJetBrainsDetector.detect()`` ignores ``user_home``
84+
and iterates every user home internally, so calling it would attribute
85+
another user's Junie plugin to whichever user is currently being scanned
86+
(cross-user false positive). Instead we drive the detector's per-user
87+
config-dir scan for ``user_home`` only, then enrich with plugins. The
88+
JetBrains detector itself is never modified — only its existing per-user
89+
methods are reused read-only.
90+
"""
91+
try:
92+
jetbrains_detector = LinuxJetBrainsDetector()
93+
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
94+
except (PermissionError, OSError) as e:
95+
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
96+
return None
97+
98+
for ide in scoped_ides:
99+
config_path = ide.get("config_path")
100+
if not self._path_under_user_home(config_path, user_home):
101+
continue
102+
try:
103+
plugins = jetbrains_detector._get_plugins(config_path)
104+
except (PermissionError, OSError) as e:
105+
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
106+
continue
107+
for plugin_name in plugins:
108+
if "junie" in plugin_name.lower():
109+
return config_path
110+
return None
111+
112+
@staticmethod
113+
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
114+
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
115+
if not config_path:
116+
return False
117+
try:
118+
return Path(config_path).resolve().is_relative_to(user_home.resolve())
119+
except (OSError, ValueError):
120+
return False
121+
67122
def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
68123
"""Try to extract Junie version from configuration files."""
69124
config_files = [
@@ -76,7 +131,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
76131
if config_file.exists():
77132
with open(config_file, 'r', encoding='utf-8') as f:
78133
data = json.load(f)
79-
if 'version' in data:
134+
if isinstance(data, dict) and isinstance(data.get('version'), str):
80135
return data['version']
81136
except (json.JSONDecodeError, OSError, PermissionError) as e:
82137
logger.debug(f"Could not read config file {config_file}: {e}")

scripts/coding_discovery_tools/macos/jetbrains/jetbrains.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class MacOSJetBrainsDetector(BaseToolDetector):
2727
IDE_NAME_MAPPING = {
2828
"IntelliJIdea": "IntelliJ IDEA",
2929
"IdeaIC": "IntelliJ IDEA Community",
30+
"IdeaIE": "IntelliJ IDEA Educational",
31+
"Aqua": "Aqua",
3032
"PyCharm": "PyCharm",
3133
"PyCharmCE": "PyCharm Community",
3234
"WebStorm": "WebStorm",
@@ -42,6 +44,7 @@ class MacOSJetBrainsDetector(BaseToolDetector):
4244

4345
# Folders to skip when scanning JetBrains directory
4446
SKIP_FOLDERS = {
47+
"consent", "DeviceId", "JetBrainsClient",
4548
"consentOptions", "PrivacyPolicy", "Toolbox",
4649
}
4750

@@ -193,8 +196,8 @@ def _parse_ide_name_and_version(self, folder_name: str) -> tuple:
193196

194197
@staticmethod
195198
def _detect_plan(folder_name: str) -> str:
196-
"""Return 'Free' for Community editions, 'Licensed' otherwise."""
197-
if "IdeaIC" in folder_name or "PyCharmCE" in folder_name:
199+
"""Return 'Free' for Community/Educational editions, 'Licensed' otherwise."""
200+
if "IdeaIC" in folder_name or "IdeaIE" in folder_name or "PyCharmCE" in folder_name:
198201
return "Free"
199202
return "Licensed"
200203

scripts/coding_discovery_tools/macos/junie/junie.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from typing import Optional, Dict, List
88

99
from ...coding_tool_base import BaseToolDetector
10+
from ...macos.jetbrains.jetbrains import MacOSJetBrainsDetector
1011
from ...macos_extraction_helpers import is_running_as_root
12+
from ...user_tool_detector import find_junie_binary_for_user
1113

1214
logger = logging.getLogger(__name__)
1315

@@ -56,22 +58,74 @@ def get_version(self) -> Optional[str]:
5658
def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
5759
"""
5860
Detect Junie installation for a specific user.
61+
62+
Gates on a real install signal — the Junie CLI binary OR the Junie
63+
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
64+
user-authored guidelines residue that survives uninstall. ``~/.junie``
65+
is still used as the version source.
5966
"""
60-
junie_dir = user_home / self.JUNIE_DIR_NAME
67+
junie_bin = find_junie_binary_for_user(user_home)
68+
install_path: Optional[str] = junie_bin
69+
70+
if not install_path:
71+
install_path = self._has_junie_jetbrains_plugin(user_home)
6172

62-
if not junie_dir.exists() or not junie_dir.is_dir():
73+
if not install_path:
6374
return None
6475

65-
logger.debug(f"Found Junie directory at: {junie_dir}")
76+
logger.debug(f"Detected Junie install signal at: {install_path}")
6677

67-
version = self._get_version_from_config(junie_dir)
78+
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)
6879

6980
return {
7081
"name": self.tool_name,
7182
"version": version or "Unknown",
72-
"install_path": str(junie_dir)
83+
"install_path": install_path
7384
}
7485

86+
def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
87+
"""Return an install_path if the Junie plugin is present in a JetBrains
88+
IDE belonging to ``user_home``, else None.
89+
90+
Scoping matters: ``MacOSJetBrainsDetector.detect()`` ignores ``user_home``
91+
and, under root, scans every user under ``/Users``. Calling it here would
92+
attribute another user's Junie plugin to whichever user is currently
93+
being scanned (cross-user false positive). Instead we drive the
94+
detector's per-user config-dir scan for ``user_home`` only, then enrich
95+
with plugins. The JetBrains detector itself is never modified — only its
96+
existing per-user methods are reused read-only.
97+
"""
98+
try:
99+
jetbrains_detector = MacOSJetBrainsDetector()
100+
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
101+
except (PermissionError, OSError) as e:
102+
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
103+
return None
104+
105+
for ide in scoped_ides:
106+
config_path = ide.get("config_path")
107+
if not self._path_under_user_home(config_path, user_home):
108+
continue
109+
try:
110+
plugins = jetbrains_detector._get_plugins(config_path)
111+
except (PermissionError, OSError) as e:
112+
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
113+
continue
114+
for plugin_name in plugins:
115+
if "junie" in plugin_name.lower():
116+
return config_path
117+
return None
118+
119+
@staticmethod
120+
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
121+
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
122+
if not config_path:
123+
return False
124+
try:
125+
return Path(config_path).resolve().is_relative_to(user_home.resolve())
126+
except (OSError, ValueError):
127+
return False
128+
75129
def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
76130
"""
77131
Try to extract Junie version from configuration files.
@@ -87,7 +141,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
87141
import json
88142
with open(config_file, 'r', encoding='utf-8') as f:
89143
data = json.load(f)
90-
if 'version' in data:
144+
if isinstance(data, dict) and isinstance(data.get('version'), str):
91145
return data['version']
92146
except (json.JSONDecodeError, OSError, PermissionError) as e:
93147
logger.debug(f"Could not read config file {config_file}: {e}")

0 commit comments

Comments
 (0)