Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
78961b4
fix(discovery): Tier-B detector accuracy — binary-gate Cursor/Copilot…
anonpran Jun 11, 2026
12d7b2d
fix(windows/copilot-cli): dedup MCP servers on admin all-users scans …
AakashVelusamy Jun 12, 2026
1acfd84
fix(discovery): Tier-C residue cleanup — junie, github_copilot (Windo…
anonpran Jun 12, 2026
8d460d5
feat: show concise summary logs
MohamedAklamaash Jun 15, 2026
f71bba8
fix: rm --summary
MohamedAklamaash Jun 15, 2026
9950059
fix: remove dead --summary flag; correct --dump help
MohamedAklamaash Jun 15, 2026
0fcd34b
Merge pull request #203 from websentry-ai/ak/concise-logs-v1
MohamedAklamaash Jun 15, 2026
b356036
fix(discovery): skip mcp-remote scan when it has no cached token
zeus-12 Jun 15, 2026
dfce85e
Merge pull request #204 from websentry-ai/vv/mcp-scan-skip-unauthed-r…
zeus-12 Jun 15, 2026
169dba0
[WEB-4770] Increase discovery self-timeout from 90 to 150 minutes (#208)
anonpran Jun 23, 2026
c9c8198
WEB-4855: send real-or-None system_user with scan lifecycle events (a…
anonpran Jun 23, 2026
64417fc
forward script_content from server config to scan object
zeus-12 Jun 23, 2026
d96da20
JetBrains detector: name IdeaIE/Aqua, skip non-IDE client folders (#211)
anonpran Jun 24, 2026
1b65aca
scan: log script_content forwarding for local-script servers
zeus-12 Jun 24, 2026
246b684
fix(mcp-scan): report single-server scans even with zero tools
zeus-12 Jun 24, 2026
c5f4b60
Merge pull request #210 from websentry-ai/vv/mcp-fingerprint-coverage
zeus-12 Jun 24, 2026
2f07203
WEB-4891: make the discovery key optional for the per-user onboard cr…
AakashVelusamy Jun 25, 2026
955b8b8
Merge branch 'main' into staging
pugazhendhi-m Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 54 additions & 35 deletions scripts/coding_discovery_tools/ai_tools_discovery.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/coding_discovery_tools/coding_tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ def create_all_tool_detectors(os_name: Optional[str] = None) -> list:
ToolDetectorFactory.create_roo_detector(os_name),
]

# Add Claude Cowork detector for macOS and Windows
# Add Claude Cowork detector for macOS, Windows, and Linux
claude_cowork_detector = ToolDetectorFactory.create_claude_cowork_detector(os_name)
if claude_cowork_detector is not None:
detectors.append(claude_cowork_detector)
Expand Down
51 changes: 37 additions & 14 deletions scripts/coding_discovery_tools/linux/claude_cowork/claude_cowork.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@

Cowork is the agentic feature of the Claude Desktop app. We treat it as a
distinct tool from Claude Code (which is the CLI). A device is considered
to have Cowork installed if the on-disk session tree exists at:
to have Cowork installed if BOTH:

~/.config/Claude/local-agent-mode-sessions/
- A Claude Desktop installation is discoverable on disk, AND
- The on-disk session tree exists at
~/.config/Claude/local-agent-mode-sessions/

(Claude Desktop is an Electron app and follows the XDG convention of
storing its data under ~/.config/Claude/ on Linux.)

If only the app is present (Cowork never enabled / never used), there is
nothing on disk to report so we return None. When running as root we check
every user's home (and /root) so MDM-style deployments are covered.
If only the app is present (Cowork never enabled / never used) there is
nothing on disk to report, and if only the sessions tree is present (app
uninstalled, config residue left behind) it is not a real install — in both
cases we return None. When running as root we check every user's home (and
/root) so MDM-style deployments are covered.
"""

import logging
Expand Down Expand Up @@ -52,17 +56,34 @@ def detect(self) -> Optional[Dict]:
for user_home in get_linux_user_homes():
sessions_dir = _sessions_dir_for_user(user_home)
try:
if sessions_dir.exists() and sessions_dir.is_dir():
app_install = self._find_install_dir()
install_path = str(app_install) if app_install else str(sessions_dir)
return {
"name": self.tool_name,
"version": self.get_version(),
"install_path": install_path,
}
if not (sessions_dir.exists() and sessions_dir.is_dir()):
continue
except OSError as e:
logger.debug(f"Error checking Cowork sessions dir {sessions_dir}: {e}")
continue

# Require the Claude Desktop install to be present. The per-user
# ``~/.config/Claude`` tree (which holds the sessions dir) survives
# an uninstall (anthropics/claude-code#25013), so reporting on the
# sessions dir alone produced false positives. Gate on a real
# install dir.
app_install = self._find_install_dir()
if app_install is None:
logger.debug(
"Cowork sessions present under %s but no Claude Desktop "
"install found; treating as residue (not installed).",
user_home,
)
continue

return {
"name": self.tool_name,
"version": self.get_version(),
# Report the sessions dir (consistent with the macOS detector and
# the central ``_detect_claude_cowork`` path). ``app_install`` is
# the gate, not the reported path.
"install_path": str(sessions_dir),
}
return None

def get_version(self) -> Optional[str]:
Expand All @@ -74,7 +95,9 @@ def get_version(self) -> Optional[str]:
"""
return None

def _find_install_dir(self) -> Optional[Path]:
def _find_install_dir(self, user_home: Optional[Path] = None) -> Optional[Path]:
# ``user_home`` is accepted for a uniform call signature with the central
# path; Linux install dirs are machine-global so it is unused here.
for candidate in _candidate_install_dirs():
try:
if candidate.exists() and candidate.is_dir():
Expand Down
69 changes: 62 additions & 7 deletions scripts/coding_discovery_tools/linux/junie/junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from ...coding_tool_base import BaseToolDetector
from ...linux_extraction_helpers import get_linux_user_homes
from ...user_tool_detector import find_junie_binary_for_user
from ..jetbrains.jetbrains import LinuxJetBrainsDetector

logger = logging.getLogger(__name__)

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

def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
"""Detect Junie installation for a specific user."""
junie_dir = user_home / self.JUNIE_DIR_NAME
"""Detect Junie installation for a specific user.

if not junie_dir.exists() or not junie_dir.is_dir():
Gates on a real install signal — the Junie CLI binary OR the Junie
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
user-authored guidelines residue that survives uninstall. ``~/.junie``
is still used as the version source.
"""
junie_bin = find_junie_binary_for_user(user_home)
install_path: Optional[str] = junie_bin

if not install_path:
install_path = self._has_junie_jetbrains_plugin(user_home)

if not install_path:
return None

logger.debug(f"Found Junie directory at: {junie_dir}")
logger.debug(f"Detected Junie install signal at: {install_path}")

version = self._get_version_from_config(junie_dir)
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)

return {
"name": self.tool_name,
"version": version or "Unknown",
"install_path": str(junie_dir)
"install_path": install_path
}

def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
"""Return an install_path if the Junie plugin is present in a JetBrains
IDE belonging to ``user_home``, else None.

Scoping matters: ``LinuxJetBrainsDetector.detect()`` ignores ``user_home``
and iterates every user home internally, so calling it would attribute
another user's Junie plugin to whichever user is currently being scanned
(cross-user false positive). Instead we drive the detector's per-user
config-dir scan for ``user_home`` only, then enrich with plugins. The
JetBrains detector itself is never modified — only its existing per-user
methods are reused read-only.
"""
try:
jetbrains_detector = LinuxJetBrainsDetector()
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
except (PermissionError, OSError) as e:
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
return None

for ide in scoped_ides:
config_path = ide.get("config_path")
if not self._path_under_user_home(config_path, user_home):
continue
try:
plugins = jetbrains_detector._get_plugins(config_path)
except (PermissionError, OSError) as e:
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
continue
for plugin_name in plugins:
if "junie" in plugin_name.lower():
return config_path
return None

@staticmethod
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
if not config_path:
return False
try:
return Path(config_path).resolve().is_relative_to(user_home.resolve())
except (OSError, ValueError):
return False

def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
"""Try to extract Junie version from configuration files."""
config_files = [
Expand All @@ -76,7 +131,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'version' in data:
if isinstance(data, dict) and isinstance(data.get('version'), str):
return data['version']
except (json.JSONDecodeError, OSError, PermissionError) as e:
logger.debug(f"Could not read config file {config_file}: {e}")
Expand Down
66 changes: 60 additions & 6 deletions scripts/coding_discovery_tools/macos/junie/junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
from typing import Optional, Dict, List

from ...coding_tool_base import BaseToolDetector
from ...macos.jetbrains.jetbrains import MacOSJetBrainsDetector
from ...macos_extraction_helpers import is_running_as_root
from ...user_tool_detector import find_junie_binary_for_user

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,22 +58,74 @@ def get_version(self) -> Optional[str]:
def _detect_junie_for_user(self, user_home: Path) -> Optional[Dict]:
"""
Detect Junie installation for a specific user.

Gates on a real install signal — the Junie CLI binary OR the Junie
plugin in a JetBrains IDE — not on the ``~/.junie`` directory, which is
user-authored guidelines residue that survives uninstall. ``~/.junie``
is still used as the version source.
"""
junie_dir = user_home / self.JUNIE_DIR_NAME
junie_bin = find_junie_binary_for_user(user_home)
install_path: Optional[str] = junie_bin

if not install_path:
install_path = self._has_junie_jetbrains_plugin(user_home)

if not junie_dir.exists() or not junie_dir.is_dir():
if not install_path:
return None

logger.debug(f"Found Junie directory at: {junie_dir}")
logger.debug(f"Detected Junie install signal at: {install_path}")

version = self._get_version_from_config(junie_dir)
version = self._get_version_from_config(user_home / self.JUNIE_DIR_NAME)

return {
"name": self.tool_name,
"version": version or "Unknown",
"install_path": str(junie_dir)
"install_path": install_path
}

def _has_junie_jetbrains_plugin(self, user_home: Path) -> Optional[str]:
"""Return an install_path if the Junie plugin is present in a JetBrains
IDE belonging to ``user_home``, else None.

Scoping matters: ``MacOSJetBrainsDetector.detect()`` ignores ``user_home``
and, under root, scans every user under ``/Users``. Calling it here would
attribute another user's Junie plugin to whichever user is currently
being scanned (cross-user false positive). Instead we drive the
detector's per-user config-dir scan for ``user_home`` only, then enrich
with plugins. The JetBrains detector itself is never modified — only its
existing per-user methods are reused read-only.
"""
try:
jetbrains_detector = MacOSJetBrainsDetector()
scoped_ides = jetbrains_detector._scan_jetbrains_config_dir(user_home)
except (PermissionError, OSError) as e:
logger.debug(f"JetBrains scan for Junie failed under {user_home}: {e}")
return None

for ide in scoped_ides:
config_path = ide.get("config_path")
if not self._path_under_user_home(config_path, user_home):
continue
try:
plugins = jetbrains_detector._get_plugins(config_path)
except (PermissionError, OSError) as e:
logger.debug(f"Plugin scan for Junie failed under {config_path}: {e}")
continue
for plugin_name in plugins:
if "junie" in plugin_name.lower():
return config_path
return None

@staticmethod
def _path_under_user_home(config_path: Optional[str], user_home: Path) -> bool:
"""True if ``config_path`` is inside ``user_home`` (strict scoping guard)."""
if not config_path:
return False
try:
return Path(config_path).resolve().is_relative_to(user_home.resolve())
except (OSError, ValueError):
return False

def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
"""
Try to extract Junie version from configuration files.
Expand All @@ -87,7 +141,7 @@ def _get_version_from_config(self, junie_dir: Path) -> Optional[str]:
import json
with open(config_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'version' in data:
if isinstance(data, dict) and isinstance(data.get('version'), str):
return data['version']
except (json.JSONDecodeError, OSError, PermissionError) as e:
logger.debug(f"Could not read config file {config_file}: {e}")
Expand Down
Loading
Loading