Approach B: own device_id + is_container flag + stable container id#170
Merged
Conversation
* feat: add cross-platform --set-cron support (macOS launchd, Linux systemd/crontab, Windows Task Scheduler) (#126)
* feat: add cross-platform --set-cron support for user-level onboard and discover
Extends setup-scheduled-scan.sh to support Linux (systemd/crontab) in
addition to macOS launchd, and adds --command discover|onboard with
--discovery-key flag so the scheduled job can re-run the full onboard
flow — not just discovery.
Adds setup-scheduled-scan.ps1 (Windows) using Task Scheduler with
StartWhenAvailable so missed runs catch up on next boot. Credentials
stored via cmdkey (Windows Credential Manager). Verified empirically
on Azure VM via deallocate/start cycle.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: escape JSON values and scope umask in Linux credential storage
- Escape backslash and double-quote in api_key/discovery_key/domain
before writing to scheduled-creds.json. A literal " in any value
previously corrupted the file and broke the regex parser at run time.
- Run the credential write inside a subshell so umask 077 is scoped to
that operation and does not leak into the rest of the install.
Caught by greptile review on the original PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: systemd PATH for onboard wrapper + EDR-safe Windows install.ps1 path
- setup-scheduled-scan.sh: add Environment=PATH= to the systemd --user
service unit. The default --user PATH is minimal and excludes
~/.local/bin, /usr/local/bin, and homebrew paths, so the onboard
wrapper failed to locate the 'unbound' CLI at run time.
- setup-scheduled-scan.ps1: cache install.ps1 under %LOCALAPPDATA%\Unbound
instead of downloading to TEMP and deleting on each run. The
download-execute-delete pattern is flagged as suspicious by EDR tools;
a stable script path under the app data dir is recognised as the
install location. Also adds a catch around the discover wrapper so
network failures are logged rather than left silent.
Both caught by greptile review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: prepend user-binary dirs to PATH in scheduled wrapper
cron and systemd --user invoke the wrapper with a minimal PATH that
excludes ~/.local/bin, ~/.npm-global/bin, /usr/local/bin, and homebrew
paths — exactly where 'unbound' lives after 'npm install -g'. The
crontab fallback path therefore failed the onboard branch on every
scheduled run with 'unbound: command not found', visible only in the
log file.
Setting PATH in the wrapper itself covers both crontab and systemd
fallback paths, complementing the Environment=PATH= already set on the
systemd unit.
Caught by greptile review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: discovery_key validation in wrappers, accurate print statements, $args shadow
setup-scheduled-scan.sh:
- Validate DISCOVERY_KEY is present in the onboard branch of the wrapper
before invoking unbound, so a missing credential fails with a clear log
message rather than a confusing empty-arg error downstream.
- Fix success message: "runs once on install" → "runs on install + at
each login via RunAtLoad" (RunAtLoad fires on every bootstrap, not once).
- Fix Linux success message to distinguish systemd catch-up vs crontab
no-catch-up behaviour.
- Replace "$0 --uninstall" in both macOS and Linux success messages with
"unbound discover unschedule" — $0 resolves to 'bash' when the script
is piped via curl | bash, making the hint non-actionable.
setup-scheduled-scan.ps1:
- Validate $DiscoveryKey before invoking unbound in the onboard wrapper
branch to surface a clear log error instead of a silent empty-arg run.
- Rename $args → $cmdArgs in the onboard wrapper branch to avoid
shadowing the PowerShell built-in automatic variable $args.
- Fix header comment: "schtasks" → "Register-ScheduledTask" to match
the actual cmdlet used in the script.
- Fix catch-up comment: remove stray "launchd" cross-OS reference.
- Replace raw PS1 script path in uninstall hint with "unbound discover
unschedule" — users invoke uninstall via the CLI, not the raw script.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: domain guard in shell wrapper + tighten ~/.unbound dir + validate downloaded install.ps1
setup-scheduled-scan.sh:
- Add $DOMAIN presence check at the top of the wrapper's discover branch
so a missing Keychain/creds-file entry fails with a clear log line
instead of silently invoking install.sh with --domain "" on every run.
Mirrors the existing guard in the Windows wrapper.
- Move ~/.unbound mkdir inside the umask 077 subshell so the credential
directory itself is 0700 (defense-in-depth — the file is already 0600).
setup-scheduled-scan.ps1:
- Validate the downloaded install.ps1 before executing it (length > 100
bytes, first line looks like PowerShell). Mirrors the shell wrapper's
size + shebang check, so an HTML error body returned with HTTP 200 is
not blindly handed to powershell -ExecutionPolicy Bypass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: pin install.sh/install.ps1 to an immutable commit SHA at cron-setup time
At the moment the cron is installed, resolve the current HEAD SHA of
coding-discovery-tool via the GitHub API and bake that SHA into the
generated wrapper script's install URL. Raw GitHub content served at a
specific commit SHA is immutable — future pushes to main cannot change
what the daily job executes.
Falls back to 'main' with a visible warning when the API is unreachable
(e.g. no network at install time). The pin is refreshed automatically
every time the user re-runs --set-cron or discover schedule.
setup-scheduled-scan.sh: resolve_install_ref() resolves the SHA via
curl + python3 (both already required by install.sh); updates
SCAN_SCRIPT_URL before create_wrapper_script() writes it into the
wrapper.
setup-scheduled-scan.ps1: resolves the SHA via Invoke-RestMethod
(built into PowerShell 3+); passes it as -InstallRef to
Create-WrapperScript, which replaces the UNBOUND_INSTALL_REF
placeholder in the single-quoted here-string before writing to disk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Revert "fix: pin install.sh/install.ps1 to an immutable commit SHA at cron-setup time"
This reverts commit 48790286b90e87c7ce37ec820eeebe49d730b25d.
* fix: propagate exit code from wrapper script to Task Scheduler
Without exit $ec the wrapper always exits 0, so Task Scheduler records
LastTaskResult = 0x0 even when install.ps1 or unbound onboard fails.
Adds exit $ec before the closing heredoc so failed runs are visible
in the task history and retry-on-failure policies can trigger.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: capture wrapper exit code under set -e and migrate legacy LaunchAgent
Two issues addressed:
1. The generated wrapper runs under set -euo pipefail, so a non-zero exit
from install.sh or unbound onboard terminated the script before the
"Scheduled run failed" log line and before the trailing exit propagated
the status. Every failure appeared as a successful run.
Fix: capture EXIT_CODE inline via "|| EXIT_CODE=$?" (the canonical
set -e bypass) and add an explicit "exit $EXIT_CODE" so launchd /
systemd / cron observe the real status.
2. install_macos only unloaded the new "ai.getunbound.scheduled" label.
Users upgrading from a previous version still had the old
"ai.getunbound.discovery" agent loaded — both fired daily at 09:00
after the upgrade.
Fix: add a migration step at the top of install_macos that boots out
the legacy label and removes its plist before registering the new one.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: harden Linux systemd→crontab fallback and PowerShell CLM handling
setup-scheduled-scan.sh
- install_linux previously gated on [ -d /run/systemd/system ] and ran
`systemctl --user daemon-reload` directly. On containers, WSL2, CI
runners and headless servers, the system systemd dir exists but no
user instance is running — daemon-reload exited non-zero, set -e
killed the script after credentials and the wrapper had already
been written, and the crontab fallback was never attempted.
- Introduces systemd_user_available() that adds a `systemctl --user
status` probe (the only check that catches the "no user bus" case).
- install_linux_systemd now returns non-zero instead of aborting when
daemon-reload or enable fails. install_linux tears down the unit
files and falls through to crontab so the user is never left with
credentials on disk and no scheduler.
setup-scheduled-scan.ps1
- Wraps `Add-Type -Language CSharp` in try/catch. Constrained Language
Mode (enforced by AppLocker / WDAC on locked-down enterprise fleets
— exactly the environments most likely to deploy a discovery tool)
blocks Add-Type and `-ExecutionPolicy Bypass` does NOT bypass CLM.
Without the try/catch the wrapper logged only "=== Starting ===" on
every run with no indication of what failed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: bake unbound path at install time and detect auth errors in wrapper
Resolves two known limitations:
1. PATH resolution — bakes the unbound binary path resolved at install
time into the wrapper (falls back to PATH search at runtime) so the
scheduled job survives shell profile changes, nvm switches, and
custom npm-prefix installs.
2. API key rotation — after a non-zero exit, tail the log and emit an
actionable HINT when a 401/Invalid-key/Unauthorized pattern is found,
telling the operator which command to re-run with new credentials.
Applies to both the sh (macOS/Linux) and ps1 (Windows) wrappers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: clarify LogonType=Interactive is intentional for personal-device use
Adds a comment explaining the design rationale so reviewers don't flag
it as an oversight: Interactive avoids storing a plaintext password and
is correct for developer laptops. Notes the path forward for headless
deployments if ever required.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: guard grep -vF exit code in Linux crontab uninstall
grep -vF exits 1 when it matches no lines (i.e. the Unbound entry was
the only crontab line). Under set -euo pipefail, pipefail propagates
that exit code through the pipeline and set -e aborts the script before
crontab - executes — leaving the cron entry alive after credentials and
the install dir are already gone.
Wraps the grep in a subshell with || true, matching the identical guard
already present in install_linux_crontab at line 512.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: use || true on full pipeline in crontab uninstall path
Previous fix wrapped grep in a subshell which is correct but non-idiomatic.
The cleaner form is || true on the whole pipeline — with pipefail, all pipeline
stages still execute (grep writes nothing, crontab - clears the crontab), and
|| true suppresses the non-zero exit code that grep produces when it selects
no lines.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: use systemctl --user show to probe user bus availability
systemctl --user status (no unit arg) exits 1 when the user manager is
in degraded state — a failed xdg-portal, pipewire, or gnome-keyring unit
is enough. This is the normal condition on most desktop sessions, so the
probe was silently falling back to crontab for the majority of Linux
users even when the user bus was fully operational.
systemctl --user show queries the manager's own properties and exits 0
as long as the user bus is reachable, regardless of individual unit
health. It is the correct reachability probe.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: pass credentials via env vars to avoid Win32_Process.CommandLine exposure
Win32_Process.CommandLine is readable by any authenticated user without
elevation, and Windows Event Log 4688 (process-creation auditing, common
in enterprise SIEMs) captures full command lines. Passing -ApiKey <value>
as a CLI flag would have put raw API keys into security logs.
discover case: set UNBOUND_API_KEY / UNBOUND_DOMAIN before spawning, pass
-Command with backtick-escaped $env: references so the child process
command line contains the variable name, not the value. finally block
clears the env vars in all exit paths.
onboard case: drop --api-key / --discovery-key from cmdArgs entirely, set
UNBOUND_API_KEY / UNBOUND_DISCOVERY_KEY before spawning unbound. Requires
unbound-cli to read these env vars (see unbound-cli PR change).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: pass credentials via env vars in shell wrapper to avoid ps/cmdline exposure
Mirrors the same fix already applied to the PS1 wrapper. On Linux,
/proc/pid/cmdline is readable by any local user without elevation; on
macOS, ps output is world-readable. Passing --api-key / --discovery-key
as CLI arguments therefore exposes raw keys to every process on the
machine for the lifetime of the subprocess.
setup-scheduled-scan.sh (generated wrapper):
- discover: UNBOUND_API_KEY="$API_KEY" invoke, --api-key dropped from args
- onboard: UNBOUND_API_KEY + UNBOUND_DISCOVERY_KEY in env, keys dropped from args
- chmod 700 on wrapper (not +x): outer umask is typically 022 so +x
would yield 755 (world-executable); 700 restricts to owner only
install.sh:
- main() prepends --api-key from $UNBOUND_API_KEY when the flag is
absent from explicit args, so the scheduled wrapper can invoke it
without putting the key value on the command line
- $# -eq 0 guard now also passes when UNBOUND_API_KEY is set
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: escape apostrophes in paths embedded in single-quoted PS expressions
On machines where the Windows username contains an apostrophe (e.g.
O'Brien), $env:LOCALAPPDATA and the npm global bin path both contain a
literal apostrophe. Embedding these paths directly inside a single-quoted
PowerShell expression ('$installScript', '$resolvedUnbound') produces an
unbalanced quote that breaks the expression and silently fails every
scheduled discover or onboard run.
Fix: apply -replace "'", "''" before interpolating into single-quoted
contexts. In PowerShell single-quoted strings, '' is the escape sequence
for a literal apostrophe, so O'Brien becomes O''Brien and the expression
remains syntactically valid.
Two sites fixed:
- discover wrapper: $safeInstallScript used in -Command string
- baked-path replacement: $escapedUnbound used in Test-Path / string literals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: clear all credentials before storing new ones in Install-ScheduledTask
If a user reinstalls with a different command (e.g. switching from onboard
to discover), credentials from the previous install that are not present in
the new invocation (e.g. discovery_key) were left in Windows Credential
Manager. Calling Remove-AllCredentials before storing new values makes
Install-ScheduledTask idempotent — same behaviour as the macOS path which
unconditionally deletes all four credentials before writing new ones.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: guard npm config get prefix against set -e abort when npm absent
`npm config get prefix` exits 127 when npm is not installed; under
`set -euo pipefail` that propagated a fatal error mid-install, leaving
credentials written to disk with no diagnostic message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: log onboard spawn failure on Windows; remove stale macOS Keychain creds on migration
- PS1 wrapper onboard branch: add catch block so a spawn failure
(e.g. access denied, corrupted binary) is logged instead of silently
falling through with an uninitialised exit code. Mirrors the existing
catch block in the discover branch. $ec pre-set to 1 so the failure
is recorded even if the catch block itself throws.
- SH install_macos: after removing the legacy ai.getunbound.discovery
plist/LaunchAgent, also delete the four Keychain entries stored under
that old service name. Without this, --uninstall (which only cleans
the current name) leaves stale credentials on upgraded systems.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): replace cmdkey /pass: with CredWrite P/Invoke for credential storage
cmdkey /pass:<secret> exposes API keys in the Windows process command
line, which Event Log 4688 (process-creation auditing) captures
persistently — exactly the enterprise environments this tool targets.
Replace Store-Credential with a CredWrite P/Invoke call (mirroring the
CredRead already in the scheduled wrapper) so secrets never appear in
any command line. cmdkey /delete: is retained for removal since
deletion exposes no secret.
Add-Type CLM guard: if PowerShell Constrained Language Mode blocks
Add-Type, exit with a descriptive error rather than silently falling
back to the insecure cmdkey /pass: form.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address anonpran review — credential exposure, json_escape scope, uninstall cleanup
P0-1: Windows discover wrapper used -Command to invoke install.ps1,
causing UNBOUND_API_KEY to be bound as a param and appear in child
process command lines. Switch to -File so the only thing in the
child's command line is the script path. Update install.ps1 to
resolve ApiKey/Domain from UNBOUND_API_KEY/UNBOUND_DOMAIN env vars
(matching the pattern already in install.sh) when not passed as params.
P1-2: json_escape in store_credentials_linux only escapes \ and ".
Add a comment documenting the intentionally limited scope — API keys
and domain URLs never contain control characters so the simpler form
is sufficient.
P3-1: remove_credentials_linux left ~/.unbound/ behind after removing
the creds file. Add rmdir on the parent directory; rmdir is a no-op
when the directory is non-empty, so it is safe when other components
share the directory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs: note pre-existing Python CLI arg exposure in install.sh and install.ps1
The Python subprocess receives --api-key as a CLI argument, making the
key visible in /proc/pid/cmdline, ps, and Event Log 4688 for the
duration of the Python process. This is a pre-existing limitation of
the ai_tools_discovery.py entry point; both install scripts already
avoid exposing the key at the shell/PS level via UNBOUND_API_KEY env var.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: sentinel file to prevent RunAtLoad+StartCalendarInterval double-run
macOS launchd fires RunAtLoad (every boot/login) and StartCalendarInterval
(daily 09:00) as two independent triggers. When the machine boots before
09:00, both fire in the same morning. The wrapper now writes a timestamp to
$LOG_DIR/.last-run-ts after each execution and skips if the last run was
within 8 hours.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: Junie support for Windows (#137)
* feat: Junie support for Windows
Junie (JetBrains' AI coding agent) ships on Windows too — as a CLI and as a
JetBrains IDE plugin — but coding-discovery only detected it on macOS. This
adds Windows parity (config lives in %USERPROFILE%\.junie, same layout as
macOS).
- WindowsJunieDetector — detects ~\.junie; multi-user via is_running_as_admin
scanning C:\Users.
- WindowsJunieRulesExtractor — global ~\.junie\*.md + project-level .junie\*.md;
project walk uses get_windows_system_directories and the is_user_level_tool_dir
guard so user-scope rules aren't reported as project-scope.
- WindowsJunieMCPConfigExtractor — reads ~\.junie\mcp\mcp.json per user.
- Wired Windows branches into create_junie_detector and the Junie MCP/rules
factories.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address Greptile review on Windows Junie
- Global Junie rules were tagged scope="project": _detect_rule_scope did
not recognize .junie. Added .junie to its user-config dir set, and pass
explicit scope="user" when extracting global rules so user-level rules
are classified correctly regardless of path resolution.
- Admin scan in the rules extractor skipped the system/default account
exclusion (Public, Default, etc.) that the detector and MCP extractor
already applied.
- All three Windows Junie files reinvented the admin/non-admin loop. They
now use the shared scan_windows_user_directories helper, which centralises
the branching, excludes system accounts, and handles PermissionError —
fixing the exclusion bug and removing the duplicated logic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: Junie MCP parent_levels 2 -> 3 so path resolves to home
~\.junie\mcp\mcp.json needs 3 parent levels to resolve to ~ (home),
matching how every other global MCP config keys its `path`. With 2 it
returned ~\.junie, inconsistently keying Junie's entry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: add 45 unit tests for macOS and Windows Junie extraction
Covers detector, rules extractor, and MCP config extractor for both
platforms - detection with/without .junie dir, version parsing from
config.json/settings.json, global vs project-level rule extraction,
walker skipping of user-level dirs, and multi-user root scanning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: add new flags to rm overdump of logs
* added payload flag
* refactored and added test cases
* Add ~/.unbound/discovery-cache.json local cache + lock + heartbeat
New module that backs per-tool payload-hash dedup and prevents concurrent scans from piling up. Cache file is JSON; lock is an mtime-heartbeat file under ~/.unbound/discovery.lock.
* Integrate per-tool hash dedup into discovery main
Acquire the discovery lock, start the heartbeat, compute payload_hash per (tool, home_user), skip upload when the local cache shows the same hash, and update the cache on a successful send.
* Stabilize payload hash by stripping volatile fields and sorting lists
Expand _strip_ephemeral so cosmetic re-scans dedup correctly: drop plugins[*].installed_at, mcpServers[*].scan.scanned_at/error, mcpServers[*].oauth.{clientId,callbackPort}, and canonicalise filesystem-walk-order-dependent lists (projects, plugins, rules, skills, mcpServers, scan.tools, permissions.{allow,deny,ask}_rules).
* Skip other tools' ~/.<dir>/ from project-scope walkers
Project-scope MCP/rules/skills walkers (filesystem from /) were adopting .mcp.json / .agents / .cursor entries inside ~/.codex, ~/.cursor extensions, etc. as projects of the wrong tool. New is_home_dotdir_descendant predicate wired into walk_for_tool_directories, walk_for_mcp_configs_generic, walk_for_claude_project_mcp_configs, and the Cursor skills _walk_for_skills uniformly — covers any tool that uses the standard ~/.toolname/ convention without per-tool maintenance.
* Accept api_key via UNBOUND_API_KEY env (keeps argv clean)
Hook-triggered invocations can now pass the api_key through the subprocess env so it never appears in argv / /proc/<pid>/cmdline. CLI --api-key remains the default for MDM and direct-script usage.
* Address PR review: tighten dotdir helper, drop dead funcs, lift import
- is_home_dotdir_descendant now matches only the canonical /Users/<u>/.foo and /home/<u>/.foo positions; non-standard mounts like /srv/home/<u>/.config or /data/Users/shared/.cache no longer false-match.
- Remove unused stamp_last_run, is_debounced, _parse_iso, and DEBOUNCE_SECONDS from cache.py — the hook owns the debounce + last_run_at stamp directly, the cache module was never the call site for these.
- Move heartbeat_start() into the try block in ai_tools_discovery main so a heartbeat thread-spawn failure can't leak the discovery lock (release_lock now always runs).
- Lift is_home_dotdir_descendant import in walk_for_tool_directories from function body to module level — no circular import (mcp_extraction_helpers only does lazy imports from macos_extraction_helpers).
* added guardrail to print summary if not --payload, --summary
* fix(macos): skip RunAtLoad when caller already ran the scan (#141)
* chore: mark --no-run-at-load as internal-only in arg parser comment (#148)
The flag is passed automatically by the unbound CLI when a scan already
ran immediately before cron setup; it is not a user-facing option and
is intentionally absent from usage().
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: implement KiloCode version extraction (macOS + Windows) (#149)
* fix: implement KiloCode version extraction (macOS + Windows)
Kilo Code ships standard VS Code extension metadata in
~/.vscode/extensions/kilocode.Kilo-Code-X.Y.Z/package.json (and
~/.cursor/extensions/ for Cursor), but both KiloCode detectors hardcoded
"version": "Unknown" and returned None from get_version(). A stale
docstring claimed "version extraction removed per requirements" — the
sibling Roo Code and Cline detectors had the same stub in early 2025
and were both rewritten to read package.json in commit a31ecfa
(Feb 2026). KiloCode was missed.
Apply the same package.json read pattern KiloCode's siblings use, with
a fall-back to the folder-name version suffix when package.json is
unreadable, and surface the resolved version through both detect() and
get_version() (the latter walks every admin user when running elevated).
Linux equivalent will land via the feat/linux-full-support branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode): delegate get_version() to detect() (Greptile review)
Reviewer flagged that get_version() walked extension dirs directly while
detect() also checked that the extension settings dir + IDE app were
present. A partially-uninstalled KiloCode (leftover ~/.vscode/extensions
folder but no globalStorage and no IDE) would let get_version() return
a stale version while detect() returned None — they could disagree.
Make get_version() delegate to detect() and read 'version' off the
result so the install-gating logic is the single source of truth.
Matches the Roo Code / Cline pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address anonpran review + add Replit version detection
KiloCode (PR #149 review by anonpran):
- Scope _get_extension_version_for_user to the IDE that triggered detection.
Previously looped all SUPPORTED_IDES and returned the first match in any
extensions dir (.vscode first), so a Cursor install with a leftover
~/.vscode/extensions/kilocode.Kilo-Code-* would return the VS Code
version against the Cursor install_path. Now takes ide_name explicitly,
same as Roo Code / Cline siblings.
- Drop dead `except IndexError` around rsplit("-", 1)[1] — the `if "-"`
guard already ensures the split returns 2 elements.
- Add docstring to _get_extension_version_for_user.
- Add tests/test_kilocode_version_extraction.py covering package.json
reads, folder-suffix fallback, and IDE scoping (must NOT cross-leak
VS Code version to a Cursor install).
Replit version extraction (macOS + Windows):
- macOS: Read CFBundleShortVersionString from /Applications/Replit.app/
Contents/Info.plist (same source Cursor/Windsurf use); fall back to
Contents/Resources/app/package.json.
- Windows: Walk %LOCALAPPDATA%\Programs and Program Files for the
install dir, read resources\app\package.json; fall back to the .exe
FileVersion via PowerShell. The earlier "doesn't expose version
information in a standard way" comment was wrong — Replit Desktop
is a standard Electron app.
Linux versions for Antigravity + Replit will land via the
feat/linux-full-support branch alongside the matching KiloCode fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode, replit): address the two edge-case flags
1. KiloCode folder-name fallback preserved pre-release suffix.
rsplit("-", 1)[1] truncated pre-release semver — kilocode.Kilo-Code-
1.2.3-pre.5 collapsed to "pre.5" instead of "1.2.3-pre.5". Replace
with a regex that captures full semver including pre-release/build
metadata from the end of the folder name. Add tests covering -pre.5
and -beta.1 forms.
2. Replit Windows PowerShell path quoting.
repr(str(path)) produced 'C:\\Users\\…' (literal double-backslash
inside PowerShell's single quotes), which doesn't reliably resolve.
Switch to a hand-built single-quoted string with single-quote
doubling, plus -LiteralPath so PowerShell doesn't try to glob the
path. Inside single quotes, backslashes are already literal — no
double-backslash dance needed.
Both bugs only fire on the fallback branch (after package.json fails
to parse), so they couldn't produce worse output than the pre-PR
"Unknown" — but the fix is cheap and removes the footgun.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address greptile + anonpran follow-ups on PR #149
Test coverage:
- Drop @unittest.skipUnless from TestMacOSKiloCodeVersion and
TestWindowsKiloCodeVersion. _get_extension_version_for_user is pure
pathlib + JSON parsing, so the IDE-scoping regression guard (which
proves a Cursor install doesn't inherit a leftover VS Code version)
must execute on every CI runner — previously it ran only on a dev's
macOS/Windows box and was silently skipped on the Linux runner that
produced the "553 passed" claim.
Import hygiene / cross-OS consistency:
- Windows Replit imported COMMAND_TIMEOUT while macOS imported
VERSION_TIMEOUT for the same lookup. Same value (30s), but mixing
the import names across sibling files is exactly the inconsistency
Greptile and anonpran flagged. Switch Windows to VERSION_TIMEOUT.
- Replit detect() now wraps get_version() in `or "Unknown"` on both
macOS and Windows. Without it, a data-dir-only install emitted
`"version": null` while a KiloCode equivalent emitted `"Unknown"`
for the same case.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: bring Windows KiloCode test coverage to macOS parity
Greptile flagged the Windows test class as thin (2 cases vs 7 on macOS).
Add the 3 missing parity tests: folder-suffix fallback, pre-release
suffix preservation, Cursor extensions-dir routing. The Linux runner
will now execute all 12 cases instead of the previous 9.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode/macos): require single-IDE pairing for globalStorage + .app
The fallback loop in _check_user_for_kilocode (macOS only) updated
ide_installed but never updated ide_with_extension. So when
globalStorage was found in IDE-A but only IDE-B was actually installed,
the version lookup ran against IDE-A's extensions dir — either
returning a stale leftover version or falling through to "Unknown"
even when IDE-B genuinely had Kilo Code installed with a readable
package.json.
The fallback was always wrong-headed: globalStorage in an uninstalled
IDE doesn't mean Kilo Code is currently usable from a different IDE.
Replace it with a single pass that requires BOTH globalStorage AND
matching .app for the same IDE.
Add three regression tests:
- test_rejects_globalstorage_when_matching_ide_not_installed: trap config
(Code globalStorage + Cursor.app, no Cursor globalStorage) now returns
None instead of an incoherent dict.
- test_prefers_ide_with_both_globalstorage_and_app: when both IDEs have
globalStorage but only Cursor.app is installed, version comes from
Cursor's extensions dir — NOT from a stale Code leftover.
- test_returns_result_when_first_ide_has_both: happy path.
Windows and Linux detectors never had this fallback loop — they
correctly return None when the first IDE check fails — so this fix is
macOS-only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(replit/macos): avoid redundant _check_application_installation()
When detect() finds an app install, it then calls get_version() which
in turn calls _check_application_installation() again as a safety guard
— two filesystem syscalls where one suffices. Greptile flagged it as
a minor inefficiency.
Add an optional app_path parameter to get_version() so detect() can
pass the already-resolved Path. Falls back to the internal check when
called directly (keeping standalone-call safety). Matches the macOS
Antigravity detector's existing signature.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode/windows): require live IDE install + globalStorage pairing
Mirror the macOS fix (f87c864) to Windows. AppData survives an IDE
uninstall on Windows, so the previous detector — which trusted
globalStorage as proof of an IDE install — would report stale state.
Concrete scenario flagged in review:
- User installed VS Code, added KiloCode (creates
%AppData%\Code\User\globalStorage\kilocode.Kilo-Code\ AND
%USERPROFILE%\.vscode\extensions\kilocode.Kilo-Code-X.Y.Z\)
- User uninstalled VS Code (both AppData and the extensions folder
persist on disk)
- User installed Cursor + KiloCode in Cursor
The old detector found Code globalStorage first, marked ide_with_extension
= 'Code', then surfaced the *VS Code* leftover version against a 'Code'
install_path even though only Cursor is actually installed. Before this
PR opened, that path returned 'Unknown'; after the version extraction
landed, it would start returning a stale-but-plausible version.
Add a Windows-shaped _check_ide_installation that requires the IDE's
.exe under one of the conventional install roots:
- %LOCALAPPDATA%\Programs\
- %ProgramFiles%, %ProgramFiles(x86)% (with C:\Program Files fallbacks
when the env vars are unset, e.g. service-account scans)
Three regression tests mirror the macOS suite: trap config (Code
globalStorage + uninstalled Code + live Cursor without globalStorage),
preference for Cursor when both have globalStorage but only Cursor is
installed, and the happy path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(replit/macos) + tests: use app_path, add Replit version coverage
macOS Replit get_version() still referenced self.APPLICATION_PATH for
both the Info.plist and package.json reads even after the redundancy
fix added an app_path parameter — so passing app_path was effectively a
no-op and the function always read from /Applications/Replit.app
regardless of caller. Greptile caught it. Replace both references
with the passed-through app_path so the parameter has real effect.
Add tests/test_replit_version_extraction.py — 10 cases, no platform
skips so the Linux/macOS/Windows CI runners all execute them:
macOS (4 cases):
- returns None when no install
- reads Info.plist first via the explicit app_path AND asserts the
call doesn't reference /Applications/Replit.app — the regression
guard for this commit
- falls back to package.json when defaults read returns nothing
- returns None when both sources fail
Windows (6 cases):
- returns None when no install
- reads version from resources/app/package.json
- skips missing candidate dirs and tries the next one
- falls back to PowerShell FileVersion when package.json is broken
- escapes single quotes in paths (asserts user' -> user'' in the PS
command — the regression guard for the earlier quoting fix)
- candidate_install_paths includes both LOCALAPPDATA\Programs and
Program Files family roots
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(replit): trim version-extraction tests to regression-guard set
Drop 5 trivial/sanity tests from tests/test_replit_version_extraction.py
that didn't lock in a real regression:
- macOS test_returns_none_when_no_install (trivial)
- macOS test_falls_through_to_none_when_both_sources_missing (edge case)
- Windows test_returns_none_when_no_install_found (trivial)
- Windows test_skips_candidate_when_directory_missing (sanity)
- Windows test_candidate_install_paths_includes_user_and_system_dirs
(implementation detail)
Keep the 5 that earn their slot:
- macOS test_reads_plist_first_when_passed_explicit_app_path — locks
in the app_path-vs-self.APPLICATION_PATH fix Greptile flagged.
- macOS test_falls_back_to_package_json_when_plist_unreadable —
exercises the secondary read path.
- Windows test_reads_version_from_package_json — primary success path.
- Windows test_falls_back_to_powershell_when_package_json_unreadable —
asserts -LiteralPath is in the command, locks in the quoting fix.
- Windows test_escapes_single_quotes_in_path — asserts ' -> '' so a
path containing a quote can't escape the PS single-quoted literal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style(replit/windows): symmetrise _candidate_install_paths with KiloCode
Greptile flagged that Windows Replit's _candidate_install_paths drops
each root entirely when its env var is unset, while the KiloCode
sibling falls back to Path.home()-derived / C:\Program Files defaults.
Real-world impact is small (env vars are always set in normal user
sessions) but matters for restricted service accounts where AppData
or Program Files env vars get stripped — and the symmetry just reads
cleaner.
Mirror the KiloCode helper's fallback structure here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: full Linux support for unbound discover and onboard (#128)
* feat: full Linux support for unbound discover and onboard
Adds Linux implementations for every AI coding tool detector and
extractor, bringing Linux to full parity with macOS and Windows
(detectors, rules, MCP config, settings, skills), plus cross-platform
--set-cron support for user-level onboard/discover.
Highlights:
- linux/ package mirroring macos/windows for all 17 tools, wired through
coding_tool_factory and ai_tools_discovery (multi-user /home + /root,
Docker/CI root-only containers, /etc/machine-id device id).
- linux_extraction_helpers with Linux-aware system-path skipping and a
walk_for_tool_directories that does not blocklist /home.
- Claude Cowork (~/.config/Claude) and Junie (~/.junie) Linux parity.
- Plugin provenance (plugin_lookup) threaded through Linux Claude/Cursor
skills and Claude MCP extractors.
- setup-scheduled-scan cron support hardened across Linux/macOS/Windows.
- tests/test_cowork_skills_extraction_linux.py.
Rebased cleanly onto staging (which already contains plugin provenance
#129); main-only plan-detection commits are intentionally excluded — they
reach staging via the normal main->staging path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: Linux Junie MCP parent_levels 2 -> 3 so path resolves to home
~/.junie/mcp/mcp.json needs 3 parent levels to resolve its reported path
to ~ (home), matching every other global MCP config. Same fix applied to
Windows Junie in PR #137.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: scheduler setup no longer aborts on npm-absent Linux hosts
create_wrapper_script ran NPM_BIN=$(npm config get prefix 2>/dev/null)/bin
with no failure guard, so under 'set -euo pipefail' a host without npm
aborted the entire scheduler setup before the wrapper or cron entry was
written. Guard the lookup with '|| true' and only append the bin dir when
a prefix resolves. NPM_BIN now carries its own trailing colon when set and
is empty otherwise, so an npm-absent host no longer leaves a leading ':'
in the wrapper PATH (which would have put the CWD on PATH).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: Linux multi-user gaps in plugin scan, cursor DB, managed MCP, user-home enum
Audited every `Darwin`/`Windows` branch across the codebase and found five
spots that had no Linux counterpart. Each one silently dropped real data
on a Linux MDM/agent runner:
1. ai_tools_discovery._extract_claude_code_plugins multi-user scan only
covered Darwin and Windows. On Linux, running as root only checked
/root/.claude/plugins — plugins installed under /home/<user> were
invisible, so discovery reported "No plugins found" even when a
plugin was installed.
2. Same gap in the Cursor plugin multi-user scan.
3. ai_tools_discovery user-home enumeration (fallback when user_rules
provide no project_path) lacked a Linux branch — used the current
process's home instead of iterating /home/<user> + /root.
4. mcp_extraction_helpers._get_managed_mcp_path returned None on Linux.
Added /etc/ClaudeCode/managed-mcp.json (system-wide convention).
5. utils._get_cursor_db_path returned None on Linux, breaking Cursor plan
detection. Added the XDG path ~/.config/Cursor/User/globalStorage/
state.vscdb.
All five Linux branches use get_all_users_linux() + the established
"/root" vs "/home/<user>" special-case so they match the rest of the
PR's multi-user handling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: skip macOS-only cowork extractor test on non-Darwin
TestMacOSClaudeCoworkSkillsExtractor exercises MacOSClaudeCoworkSkillsExtractor
specifically, and that extractor intentionally skips its final-step dedup
when running as root. The explicit-sessions-root tempdir path is therefore
ordering-dependent on ext4 (Linux), causing the dedup assertion to flake
under root — pre-existing on main, not introduced by this PR.
Skip the class on non-Darwin so Linux CI/runners don't see a failure that's
not actually about the Linux work. The Linux equivalent
(tests/test_cowork_skills_extraction_linux.py + LinuxClaudeCoworkSkillsExtractor)
dedups unconditionally and already covers the same behaviour on Linux.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: Linux unit-test parity with macOS for skills/cline/cursor extractors
Adds direct unit-test coverage for the Linux skills extractors mirroring
the macOS pattern, and updates cursor plan-detection to cover the Linux
SQLite DB path now that _get_cursor_db_path resolves it on Linux.
New files (20 new tests total):
- tests/test_claude_skills_extraction_linux.py — TestLinuxExtractorEndToEnd
(4 methods) + TestLinuxExtractorAgents (3) mirror the macOS classes for
LinuxClaudeSkillsExtractor (~/.claude/{skills,commands,agents}/).
- tests/test_cursor_skills_extraction_linux.py — TestLinuxCursorSkillsExtractor
(4) + TestLinuxCursorSkillsExtractorWithCommands (5) for the Cursor flow
including .cursor/ and .agents/ parent dirs.
- tests/test_cline_skills_extraction_linux.py — TestLinuxClineSkillsExtractor
(5 methods) for the Cline flow.
Pattern: Linux extractors iterate get_linux_user_homes() instead of using
Path.home() + is_running_as_root, so the new tests patch
linux.<tool>.skills_extractor.get_linux_user_homes to return [fake_home]
in place of the three-decorator macOS pattern.
tests/test_cursor_plan_detection.py:
- The Linux branch used to assert "returns None (unsupported)". Now that
_get_cursor_db_path resolves ~/.config/Cursor/User/globalStorage/state.vscdb
on Linux, replaced with three correctly-shaped tests:
* test_get_cursor_db_path_linux — creates the file, expects the path
* test_get_cursor_db_path_linux_file_not_exists — mirrors the macOS
file-not-exists assertion
* test_get_cursor_db_path_unsupported_platform — moved to FreeBSD so
it still validates the unsupported-platform fallthrough path.
Local macOS: 549 passed (was 527).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: escape heredoc runtime vars in wrapper dedup check
LAST_RUN_FILE, _last, _now, and the arithmetic expansions were bare
$/$(...) inside the unquoted <<WRAPPER_EOF heredoc, causing them to be
expanded by the setup shell at install time instead of at wrapper
runtime. The 8-hour dedup check consequently never fired.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: add 10 Linux Junie unit tests (detector, rules, MCP config)
Mirrors the macOS/Windows Junie test coverage for the Linux
implementation - detection, version parsing, global rule extraction,
MCP config reading, and multi-user scanning via get_linux_user_homes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: address principal engineer review — B1-B4 + H1/H2/H5
B1 (mcp_extraction_helpers): add _iter_admin_user_homes() helper that
delegates to get_linux_user_homes() on Linux, which correctly includes
/root alongside /home/*. Replace all 5 Linux admin-scan loops to use it
instead of iterating Path("/home") directly.
B2 (utils): hardcode "root" in Docker/CI fallback instead of
Path.home().name which could return "app" when HOME is overridden.
B3 (detect_openclaw): add timeout=10 to subprocess.run(["ps","aux"])
to prevent hangs on loaded systems.
B4 (windsurf): get_version() now checks _USER_RELATIVE_PATHS via
get_linux_user_homes() so user-local installs return a real version.
H1 (linux_extraction_helpers + ai_tools_discovery): extract
linux_home_for_user(username) helper; replace 7 inline ternaries.
H2 (mcp_extraction_helpers): _iter_admin_user_homes() eliminates the
5 copy-pasted Linux admin-detection blocks.
H5 (gemini_cli, opencode): add _USER_RELATIVE_PATHS fallback scan
(~/.local/bin, ~/.npm-global/bin) when which finds nothing, so
installs are detected when user profiles are not sourced (cron/systemd).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: Linux Claude Cowork detection in per-user iteration
user_tool_detector._detect_claude_cowork() had only Darwin and Windows
branches — Linux fell through the else into the Windows AppData path,
which never exists on Linux, so Cowork was silently skipped on Linux
even when sessions dir was present.
Add the Linux branch using ~/.config/Claude/local-agent-mode-sessions
(matches LinuxClaudeCoworkDetector._sessions_dir_for_user).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: implement KiloCode version extraction (Linux)
Mirror the macOS/Windows fix from fix/kilocode-version-extraction
(PR #149): Linux KiloCode detector hardcoded 'version': 'Unknown' even
though Kilo Code ships standard VS Code extension metadata in
~/.vscode/extensions/kilocode.Kilo-Code-X.Y.Z/package.json (and the
~/.cursor/extensions/ equivalent for Cursor).
Apply the same package.json read pattern its Linux Roo Code and Cline
siblings already use, with a fall-back to the folder-name version
suffix when package.json is unreadable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode): delegate Linux get_version() to detect() (Greptile review)
Mirror the PR #149 review fix: get_version() must not surface a stale
version when detect() would return None (e.g. leftover
~/.vscode/extensions folder but no kilocode globalStorage settings dir).
Delegate to detect() so install-gating logic stays the single source
of truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(linux): version extraction for Antigravity, Replit, and scoped KiloCode
KiloCode (mirrors PR #149 anonpran review fix):
- Scope _get_extension_version_for_user to the IDE that triggered detection.
Previously looped all SUPPORTED_IDES and returned the first hit, so a
Cursor install with a leftover ~/.vscode/extensions/kilocode.Kilo-Code-*
would have returned the VS Code version against the Cursor install_path.
- Drop dead `except IndexError` around rsplit("-", 1)[1] — guarded.
Antigravity (Linux):
- Read version from resources/app/product.json (preferred) and
resources/app/package.json (fallback) across the common install paths
(/opt/Antigravity, /usr/lib/antigravity, etc. + ~/.local/share/...).
Antigravity is a VS Code fork, so the resource layout matches what
Cursor/Windsurf already use elsewhere in this codebase.
Replit (Linux):
- Read version from resources/app/package.json across system + per-user
install paths. Final fallback: `replit --version`. The earlier "doesn't
expose version information in a standard way" comment was wrong — Replit
Desktop is a standard Electron app.
Tests:
- tests/test_linux_version_extraction.py covers all three detectors:
package.json reads, fallbacks, and KiloCode IDE-scoping (must NOT
cross-leak VS Code version onto a Cursor install).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(kilocode): preserve pre-release suffix in folder-name fallback (Linux)
rsplit("-", 1)[1] truncated semver pre-release suffixes — e.g.
kilocode.Kilo-Code-1.2.3-pre.5 returned just "pre.5". Switch to a
regex that captures the full version (incl. pre-release/build metadata)
from the end of the folder name.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: drop dead imports flagged in principal-engineering review
anonpran's PR #128 review (https://github.com/websentry-ai/coding-
discovery-tool/pull/128#issuecomment-4583525626) approved the PR with
three trivial dead-import nits. Removing them so Pylint stays clean:
- linux/codex/mcp_config_extractor.py: is_running_as_root never used
in this module (root-vs-user logic lives in get_linux_user_homes()
which is already imported and used).
- linux/cursor/mcp_config_extractor.py: extract_cursor_mcp_from_dir
was imported but only walk_for_cursor_mcp_configs + read_global_
mcp_config are actually called.
- linux/windsurf/mcp_config_extractor.py: same shape as cursor —
extract_windsurf_mcp_from_dir imported but never referenced.
No behaviour change. 579 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: drop dead Linux branch from extract_global_mcp_config_with_root_support
Greptile flagged the Linux branch in this helper as a latent
early-return bug (would silently drop non-first-user configs if any
future Linux extractor invoked it). The branch was preparation-for-
future-use that no current Linux extractor reaches.
Removing it instead. Linux MCP extractors already follow a better
pattern — per-user accumulation across get_linux_user_homes() — so
the helper is macOS/Windows-only by intent. Replace the branch with
a comment that points future authors at the correct Linux template.
Zero behaviour change today: macOS callers hit the Darwin branch,
Windows callers hit the Windows branch, no Linux callers exist. 579
tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: fix over-indented try block in extract_global_mcp_config_with_root_support
Greptile cosmetic note — the try block under the admin-homes loop was
indented 16 spaces instead of 12 (a pre-existing artifact, syntactically
valid but inconsistent). Re-align to the 4-space loop body. No behaviour
change; 579 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
* fix: allow Linux in main() OS guard so discovery actually runs (#152)
PR #128 added full Linux support (70 modules, 52 factory branches,
linux_extraction_helpers, and 3 Linux branches in main()), but the
squash-merge into staging retained the exit-3 OS guard at the top of
main():
if current_platform not in ("Darwin", "Windows"):
... "AI tool discovery is not supported on {platform}"
sys.exit(3)
This guard fires BEFORE detector init, so on Linux `unbound discover`
exits 3 immediately and skips the scan — making every downstream Linux
branch unreachable dead code. Reproduced end-to-end on an Ubuntu VM via
the real install.sh entry path against staging: clones staging, runs
ai_tools_discovery.py, exits 3.
Narrow the guard to include Linux. Genuinely unsupported platforms
(*BSD, AIX, etc.) still exit 3 cleanly before detector init so they
can't crash and page Sentry on every run.
Update TestUnsupportedPlatformGuard accordingly:
- test_linux_proceeds_past_os_guard: Linux now passes the guard (exits 0
at the single-flight lock check, not 3 at the guard).
- test_unsupported_platform_exits_code_3_before_detector_init: FreeBSD
still exits 3, detector never constructed.
621 tests pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix: remove double-send regression, gate dedup transport logs
Drop the unconditional send block that fired before the dedup check; it
bypassed the local hash cache (uploading on every match) and dropped
retryable failures (no failed_reports append / cache update). The
dedup-gated send is now the sole upload path.
Gate the dedup-block "skipping upload", "Sending", and "✓ sent" lines
behind not --summary and not --payload so those modes suppress all
transport output. Errors stay ungated.
Add TestSendDedupCount: exactly one send on a changed payload, zero on a
hash match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Merge main into staging: sync staging up to main (#156)
* Exit cleanly on unsupported platforms instead of crashing (WEB-4370) (#131)
* Exit cleanly on unsupported platforms instead of crashing
On Linux (and any non-macOS/Windows platform), discovery crashed with a
ValueError deep in detector init and reported to Sentry on every run. Add an
early guard in main() that prints a friendly message to stderr and exits with
code 3 before any detector is constructed, so there's no traceback and no
Sentry noise.
WEB-4370
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Cache platform.system() result in the unsupported-platform guard
Call platform.system() once and reuse it for the check and the message,
per review feedback.
WEB-4370
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Fix Claude Code plan detection by using shell-based binary resolution (#134)
Previously, plan detection was skipped entirely when find_claude_binary_for_user
couldn't locate the binary in hardcoded paths (Homebrew, .local/bin, .bun, nvm).
Users who installed via volta, pnpm, fnm, asdf, mise, etc. had empty plan badges.
Instead of hardcoding more paths, this change lets the user's login shell resolve
the binary via PATH. When claude_binary is None, the command is passed through
`shell -lc "claude auth status --json"` — the -l flag sources the user's profile
(.zshrc, .bashrc) which sets up PATH for any package manager.
This works in all execution contexts:
- Terminal: login shell resolves via user's PATH
- MDM/root: launchctl asuser <uid> /bin/zsh -lc "claude auth status --json"
- su fallback: su - <user> -c "claude auth status --json"
When the binary IS found via hardcoded paths, behavior is unchanged (uses full path).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add Sentry diagnostics for Claude Code plan detection failures (#135)
* Add Sentry diagnostic reporting for Claude Code plan detection failures
When plan detection returns None, send a Sentry message event with
breadcrumbs showing which stages were attempted and why each failed
(keychain, launchctl, su, direct execution). This gives visibility
into cases like kim.le where plan detection silently fails.
- Extract _send_sentry_payload helper from report_to_sentry
- Add report_message_to_sentry for non-exception Sentry events
- Add optional diagnostics parameter to get_claude_subscription_type
- Wire up at call site in ai_tools_discovery.py
- Add tests for never-crash contract and diagnostics population
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Simplify: use existing report_to_sentry instead of new message function
Remove over-engineered _send_sentry_payload and report_message_to_sentry.
Use report_to_sentry with a synthetic RuntimeError and diagnostics in
the context dict instead. Same Sentry visibility, much less code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove unnecessary test additions
Diagnostics list population is trivial — existing plan detection
tests in test_claude_auth_status.py already cover the core behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Gate Sentry alert on active usage to reduce noise
Only send plan detection failure to Sentry when the user has projects
(i.e. actively uses Claude Code). Users without projects having no
plan is expected — not worth alerting on.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Detect API key auth as plan type in Claude Code plan detection (#136)
* Detect API key auth as plan type in Claude Code plan detection
When users authenticate via ANTHROPIC_API_KEY instead of OAuth,
`claude auth status --json` returns `authMethod: "api_key"` without
a `subscriptionType`. Previously this was treated as a detection
failure (plan=None), triggering false-positive Sentry warnings.
Now `_run_auth_status` checks for `authMethod: "api_key"` when
`subscriptionType` is absent and returns "api_key" as the plan.
OAuth users are unaffected — subscriptionType always takes priority.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Make authMethod comparison case-insensitive
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Include authMethod in diagnostic breadcrumbs for Sentry
When plan detection returns ok=True but plan=None, we now include
the authMethod field from the CLI response in Sentry diagnostics.
This enables debugging future unknown auth scenarios without
logging the full CLI response.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Gate Sentry alerts on CLI success — suppress noise from unauthenticated users
Only fire plan detection Sentry events when at least one CLI stage
returned ok=True (user authenticated but plan missing). When all
stages return ok=False, the user never authenticated Claude Code —
not an actionable alert.
Eliminates noise from test users (dev-diana, dev-alice, ec2-user)
who have Claude binary installed but never logged in.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix Codex TOML sub-table sections leaking as spurious MCP servers
Nested sections like [mcp_servers.foo.http_headers] and
[mcp_servers.foo.tools.bar] were being parsed as top-level server
entries, leaking secrets (e.g. API keys in http_headers) into the
report payload. Use tomllib on Python 3.11+ and skip dotted-name
sections in the regex fallback for older runtimes.
* Address greptile review: fix or-falsy-dict and list-of-dicts secret leak
- Use key-presence check instead of `or` to avoid treating an empty
mcp_servers dict as absent and falling through to mcpServers
- Also filter out list-of-dict values so arrays of inline tables
can't bypass the secret filter
* [WEB-4391] Remove verbose JSON payload logging and add per-tool timing metrics (#132)
* Remove verbose JSON payload logging and add per-tool timing metrics
The JSON payload logging serialized every report to stderr line-by-line,
producing ~39K-156K log lines per run. This wasted ~1-2 min of wall-clock
time and caused pipe buffer issues under Rippling MDM execution.
Per-tool timing replaces the aggregated `process_single_tool` and
`send_report_per_tool_user` step names with `process_tool.<slug>` and
`send_tool.<slug>`, enabling identification of which specific tool
causes the 2-4 minute processing gaps observed via Sentry timestamps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Restore JSON payload logging with rules/skills content stripped
Instead of removing the entire JSON dump, keep it for debugging but
replace rules and skills `content` fields with `<N chars>` placeholders.
This preserves structural visibility while cutting the bulk of the
stderr output (~39K-156K lines per run → ~2-3K lines).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove over-engineered test file for _metric_slug helper
The _metric_slug helper is a simple 4-line internal function that
doesn't warrant its own dedicated test file with 12 parametrized cases.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert per-tool metric names, keep only content stripping
Remove _metric_slug helper and per-tool Sentry metric names
(process_tool.{slug}, send_tool.{slug}) to avoid inflating
Sentry metric volume. Restore original generic metric names.
The only change in this PR is now: strip rules/skills content
from the JSON payload log to reduce stderr volume.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* [WEB-4391] Strip MCP scan bulk from stderr log to fix Rippling MDM pipe buffer exhaustion (#139)
* Strip MCP scan bulk from JSON payload log to reduce stderr volume
Remove inputSchema, outputSchema from MCP scan tool entries and
truncate description to 80 chars in the log-only deepcopy. This
reduces stderr by ~150-200KB per run on devices with many MCP
servers, fixing Rippling MDM pipe buffer exhaustion that causes
"Transaction was interrupted during processing" errors.
The actual payload sent to the backend is untouched (deepcopy
isolation). Rule/skill content stripping was already merged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Trigger CI
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Improve plan detection diagnostics and broaden api_key matching (#140)
Two changes to improve debuggability of plan detection Sentry alerts:
1. Use `"api_key" in` instead of `== "api_key"` for auth method matching.
Claude Code has both "api_key" and "api_key_helper" auth methods — the
strict equality missed api_key_helper users, leaving their plan as None
and triggering false-positive Sentry warnings.
2. Add `key_source` (from apiKeySource) to diagnostic breadcrumbs.
This distinguishes org-managed logins ("/login managed key") from
personal accounts, which is critical for understanding why
subscriptionType is null for team/enterprise org users.
Fixes DISCOVERY-TOOL-SCRIPT-V
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add ~/.unbound/discovery-cache.json local cache + lock + heartbeat
New module that backs per-tool payload-hash dedup and prevents concurrent scans from piling up. Cache file is JSON; lock is an mtime-heartbeat file under ~/.unbound/discovery.lock.
* Integrate per-tool hash dedup into discovery main
Acquire the discovery lock, start the heartbeat, compute payload_hash per (tool, home_user), skip upload when the local cache shows the same hash, and update the cache on a successful send.
* Stabilize payload hash by stripping volatile fields and sorting lists
Expand _strip_ephemeral so cosmetic re-scans dedup correctly: drop plugins[*].installed_at, mcpServers[*].scan.scanned_at/error, mcpServers[*].oauth.{clientId,callbackPort}, and canonicalise filesystem-walk-order-dependent lists (projects, plugins, rules, skills, mcpServers, scan.tools, permissions.{allow,deny,ask}_rules).
* Skip other tools' ~/.<dir>/ from project-scope walkers
Project-scope MCP/rules/skills walkers (filesystem from /) were adopting .mcp.json / .agents / .cursor entries inside ~/.codex, ~/.cursor extensions, etc. as projects of the wrong tool. New is_home_dotdir_descendant predicate wired into walk_for_tool_directories, walk_for_mcp_configs_generic, walk_for_claude_project_mcp_configs, and the Cursor skills _walk_for_skills uniformly — covers any tool that uses the standard ~/.toolname/ convention without per-tool maintenance.
* Accept api_key via UNBOUND_API_KEY env (keeps argv clean)
Hook-triggered invocations can now pass the api_key through the subprocess env so it never appears in argv / /proc/<pid>/cmdline. CLI --api-key remains the default for MDM and direct-script usage.
* Address PR review: tighten dotdir helper, drop dead funcs, lift import
- is_home_dotdir_descendant now matches only the canonical /Users/<u>/.foo and /home/<u>/.foo positions; non-standard mounts like /srv/home/<u>/.config or /data/Users/shared/.cache no longer false-match.
- Remove unused stamp_last_run, is_debounced, _parse_iso, and DEBOUNCE_SECONDS from cache.py — the hook owns the debounce + last_run_at stamp directly, the cache module was never the call site for these.
- Move heartbeat_start() into the try block in ai_tools_discovery main so a heartbeat thread-spawn failure can't leak the discovery lock (release_lock now always runs).
- Lift is_home_dotdir_descendant import in walk_for_tool_directories from function body to module level — no circular import (mcp_extraction_helpers only does lazy imports from macos_extraction_helpers).
* Release: staging → main (2026-05-31) (#153)
* feat: add cross-platform --set-cron support (macOS launchd, Linux systemd/crontab, Windows Task Scheduler) (#126)
* feat: add cross-platform --set-cron support for user-level onboard and discover
Extends setup-scheduled-scan.sh to support Linux (systemd/crontab) in
addition to macOS launchd, and adds --command discover|onboard with
--discovery-key flag so the scheduled job can re-run the full onboard
flow — not just discovery.
Adds setup-scheduled-scan.ps1 (Windows) using Task Scheduler with
StartWhenAvailable so missed runs catch up…
…t Sentry (#159/#160) (#161) * Report discovery preflight/lock setup failures to Sentry (#159) * Release Staging to Main (#154) * feat: add cross-platform --set-cron support (macOS launchd, Linux systemd/crontab, Windows Task Scheduler) (#126) * feat: add cross-platform --set-cron support for user-level onboard and discover Extends setup-scheduled-scan.sh to support Linux (systemd/crontab) in addition to macOS launchd, and adds --command discover|onboard with --discovery-key flag so the scheduled job can re-run the full onboard flow — not just discovery. Adds setup-scheduled-scan.ps1 (Windows) using Task Scheduler with StartWhenAvailable so missed runs catch up on next boot. Credentials stored via cmdkey (Windows Credential Manager). Verified empirically on Azure VM via deallocate/start cycle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: escape JSON values and scope umask in Linux credential storage - Escape backslash and double-quote in api_key/discovery_key/domain before writing to scheduled-creds.json. A literal " in any value previously corrupted the file and broke the regex parser at run time. - Run the credential write inside a subshell so umask 077 is scoped to that operation and does not leak into the rest of the install. Caught by greptile review on the original PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: systemd PATH for onboard wrapper + EDR-safe Windows install.ps1 path - setup-scheduled-scan.sh: add Environment=PATH= to the systemd --user service unit. The default --user PATH is minimal and excludes ~/.local/bin, /usr/local/bin, and homebrew paths, so the onboard wrapper failed to locate the 'unbound' CLI at run time. - setup-scheduled-scan.ps1: cache install.ps1 under %LOCALAPPDATA%\Unbound instead of downloading to TEMP and deleting on each run. The download-execute-delete pattern is flagged as suspicious by EDR tools; a stable script path under the app data dir is recognised as the install location. Also adds a catch around the discover wrapper so network failures are logged rather than left silent. Both caught by greptile review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: prepend user-binary dirs to PATH in scheduled wrapper cron and systemd --user invoke the wrapper with a minimal PATH that excludes ~/.local/bin, ~/.npm-global/bin, /usr/local/bin, and homebrew paths — exactly where 'unbound' lives after 'npm install -g'. The crontab fallback path therefore failed the onboard branch on every scheduled run with 'unbound: command not found', visible only in the log file. Setting PATH in the wrapper itself covers both crontab and systemd fallback paths, complementing the Environment=PATH= already set on the systemd unit. Caught by greptile review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: discovery_key validation in wrappers, accurate print statements, $args shadow setup-scheduled-scan.sh: - Validate DISCOVERY_KEY is present in the onboard branch of the wrapper before invoking unbound, so a missing credential fails with a clear log message rather than a confusing empty-arg error downstream. - Fix success message: "runs once on install" → "runs on install + at each login via RunAtLoad" (RunAtLoad fires on every bootstrap, not once). - Fix Linux success message to distinguish systemd catch-up vs crontab no-catch-up behaviour. - Replace "$0 --uninstall" in both macOS and Linux success messages with "unbound discover unschedule" — $0 resolves to 'bash' when the script is piped via curl | bash, making the hint non-actionable. setup-scheduled-scan.ps1: - Validate $DiscoveryKey before invoking unbound in the onboard wrapper branch to surface a clear log error instead of a silent empty-arg run. - Rename $args → $cmdArgs in the onboard wrapper branch to avoid shadowing the PowerShell built-in automatic variable $args. - Fix header comment: "schtasks" → "Register-ScheduledTask" to match the actual cmdlet used in the script. - Fix catch-up comment: remove stray "launchd" cross-OS reference. - Replace raw PS1 script path in uninstall hint with "unbound discover unschedule" — users invoke uninstall via the CLI, not the raw script. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: domain guard in shell wrapper + tighten ~/.unbound dir + validate downloaded install.ps1 setup-scheduled-scan.sh: - Add $DOMAIN presence check at the top of the wrapper's discover branch so a missing Keychain/creds-file entry fails with a clear log line instead of silently invoking install.sh with --domain "" on every run. Mirrors the existing guard in the Windows wrapper. - Move ~/.unbound mkdir inside the umask 077 subshell so the credential directory itself is 0700 (defense-in-depth — the file is already 0600). setup-scheduled-scan.ps1: - Validate the downloaded install.ps1 before executing it (length > 100 bytes, first line looks like PowerShell). Mirrors the shell wrapper's size + shebang check, so an HTML error body returned with HTTP 200 is not blindly handed to powershell -ExecutionPolicy Bypass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: pin install.sh/install.ps1 to an immutable commit SHA at cron-setup time At the moment the cron is installed, resolve the current HEAD SHA of coding-discovery-tool via the GitHub API and bake that SHA into the generated wrapper script's install URL. Raw GitHub content served at a specific commit SHA is immutable — future pushes to main cannot change what the daily job executes. Falls back to 'main' with a visible warning when the API is unreachable (e.g. no network at install time). The pin is refreshed automatically every time the user re-runs --set-cron or discover schedule. setup-scheduled-scan.sh: resolve_install_ref() resolves the SHA via curl + python3 (both already required by install.sh); updates SCAN_SCRIPT_URL before create_wrapper_script() writes it into the wrapper. setup-scheduled-scan.ps1: resolves the SHA via Invoke-RestMethod (built into PowerShell 3+); passes it as -InstallRef to Create-WrapperScript, which replaces the UNBOUND_INSTALL_REF placeholder in the single-quoted here-string before writing to disk. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "fix: pin install.sh/install.ps1 to an immutable commit SHA at cron-setup time" This reverts commit 48790286b90e87c7ce37ec820eeebe49d730b25d. * fix: propagate exit code from wrapper script to Task Scheduler Without exit $ec the wrapper always exits 0, so Task Scheduler records LastTaskResult = 0x0 even when install.ps1 or unbound onboard fails. Adds exit $ec before the closing heredoc so failed runs are visible in the task history and retry-on-failure policies can trigger. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: capture wrapper exit code under set -e and migrate legacy LaunchAgent Two issues addressed: 1. The generated wrapper runs under set -euo pipefail, so a non-zero exit from install.sh or unbound onboard terminated the script before the "Scheduled run failed" log line and before the trailing exit propagated the status. Every failure appeared as a successful run. Fix: capture EXIT_CODE inline via "|| EXIT_CODE=$?" (the canonical set -e bypass) and add an explicit "exit $EXIT_CODE" so launchd / systemd / cron observe the real status. 2. install_macos only unloaded the new "ai.getunbound.scheduled" label. Users upgrading from a previous version still had the old "ai.getunbound.discovery" agent loaded — both fired daily at 09:00 after the upgrade. Fix: add a migration step at the top of install_macos that boots out the legacy label and removes its plist before registering the new one. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: harden Linux systemd→crontab fallback and PowerShell CLM handling setup-scheduled-scan.sh - install_linux previously gated on [ -d /run/systemd/system ] and ran `systemctl --user daemon-reload` directly. On containers, WSL2, CI runners and headless servers, the system systemd dir exists but no user instance is running — daemon-reload exited non-zero, set -e killed the script after credentials and the wrapper had already been written, and the crontab fallback was never attempted. - Introduces systemd_user_available() that adds a `systemctl --user status` probe (the only check that catches the "no user bus" case). - install_linux_systemd now returns non-zero instead of aborting when daemon-reload or enable fails. install_linux tears down the unit files and falls through to crontab so the user is never left with credentials on disk and no scheduler. setup-scheduled-scan.ps1 - Wraps `Add-Type -Language CSharp` in try/catch. Constrained Language Mode (enforced by AppLocker / WDAC on locked-down enterprise fleets — exactly the environments most likely to deploy a discovery tool) blocks Add-Type and `-ExecutionPolicy Bypass` does NOT bypass CLM. Without the try/catch the wrapper logged only "=== Starting ===" on every run with no indication of what failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: bake unbound path at install time and detect auth errors in wrapper Resolves two known limitations: 1. PATH resolution — bakes the unbound binary path resolved at install time into the wrapper (falls back to PATH search at runtime) so the scheduled job survives shell profile changes, nvm switches, and custom npm-prefix installs. 2. API key rotation — after a non-zero exit, tail the log and emit an actionable HINT when a 401/Invalid-key/Unauthorized pattern is found, telling the operator which command to re-run with new credentials. Applies to both the sh (macOS/Linux) and ps1 (Windows) wrappers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: clarify LogonType=Interactive is intentional for personal-device use Adds a comment explaining the design rationale so reviewers don't flag it as an oversight: Interactive avoids storing a plaintext password and is correct for developer laptops. Notes the path forward for headless deployments if ever required. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard grep -vF exit code in Linux crontab uninstall grep -vF exits 1 when it matches no lines (i.e. the Unbound entry was the only crontab line). Under set -euo pipefail, pipefail propagates that exit code through the pipeline and set -e aborts the script before crontab - executes — leaving the cron entry alive after credentials and the install dir are already gone. Wraps the grep in a subshell with || true, matching the identical guard already present in install_linux_crontab at line 512. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use || true on full pipeline in crontab uninstall path Previous fix wrapped grep in a subshell which is correct but non-idiomatic. The cleaner form is || true on the whole pipeline — with pipefail, all pipeline stages still execute (grep writes nothing, crontab - clears the crontab), and || true suppresses the non-zero exit code that grep produces when it selects no lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use systemctl --user show to probe user bus availability systemctl --user status (no unit arg) exits 1 when the user manager is in degraded state — a failed xdg-portal, pipewire, or gnome-keyring unit is enough. This is the normal condition on most desktop sessions, so the probe was silently falling back to crontab for the majority of Linux users even when the user bus was fully operational. systemctl --user show queries the manager's own properties and exits 0 as long as the user bus is reachable, regardless of individual unit health. It is the correct reachability probe. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: pass credentials via env vars to avoid Win32_Process.CommandLine exposure Win32_Process.CommandLine is readable by any authenticated user without elevation, and Windows Event Log 4688 (process-creation auditing, common in enterprise SIEMs) captures full command lines. Passing -ApiKey <value> as a CLI flag would have put raw API keys into security logs. discover case: set UNBOUND_API_KEY / UNBOUND_DOMAIN before spawning, pass -Command with backtick-escaped $env: references so the child process command line contains the variable name, not the value. finally block clears the env vars in all exit paths. onboard case: drop --api-key / --discovery-key from cmdArgs entirely, set UNBOUND_API_KEY / UNBOUND_DISCOVERY_KEY before spawning unbound. Requires unbound-cli to read these env vars (see unbound-cli PR change). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: pass credentials via env vars in shell wrapper to avoid ps/cmdline exposure Mirrors the same fix already applied to the PS1 wrapper. On Linux, /proc/pid/cmdline is readable by any local user without elevation; on macOS, ps output is world-readable. Passing --api-key / --discovery-key as CLI arguments therefore exposes raw keys to every process on the machine for the lifetime of the subprocess. setup-scheduled-scan.sh (generated wrapper): - discover: UNBOUND_API_KEY="$API_KEY" invoke, --api-key dropped from args - onboard: UNBOUND_API_KEY + UNBOUND_DISCOVERY_KEY in env, keys dropped from args - chmod 700 on wrapper (not +x): outer umask is typically 022 so +x would yield 755 (world-executable); 700 restricts to owner only install.sh: - main() prepends --api-key from $UNBOUND_API_KEY when the flag is absent from explicit args, so the scheduled wrapper can invoke it without putting the key value on the command line - $# -eq 0 guard now also passes when UNBOUND_API_KEY is set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: escape apostrophes in paths embedded in single-quoted PS expressions On machines where the Windows username contains an apostrophe (e.g. O'Brien), $env:LOCALAPPDATA and the npm global bin path both contain a literal apostrophe. Embedding these paths directly inside a single-quoted PowerShell expression ('$installScript', '$resolvedUnbound') produces an unbalanced quote that breaks the expression and silently fails every scheduled discover or onboard run. Fix: apply -replace "'", "''" before interpolating into single-quoted contexts. In PowerShell single-quoted strings, '' is the escape sequence for a literal apostrophe, so O'Brien becomes O''Brien and the expression remains syntactically valid. Two sites fixed: - discover wrapper: $safeInstallScript used in -Command string - baked-path replacement: $escapedUnbound used in Test-Path / string literals Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: clear all credentials before storing new ones in Install-ScheduledTask If a user reinstalls with a different command (e.g. switching from onboard to discover), credentials from the previous install that are not present in the new invocation (e.g. discovery_key) were left in Windows Credential Manager. Calling Remove-AllCredentials before storing new values makes Install-ScheduledTask idempotent — same behaviour as the macOS path which unconditionally deletes all four credentials before writing new ones. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard npm config get prefix against set -e abort when npm absent `npm config get prefix` exits 127 when npm is not installed; under `set -euo pipefail` that propagated a fatal error mid-install, leaving credentials written to disk with no diagnostic message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: log onboard spawn failure on Windows; remove stale macOS Keychain creds on migration - PS1 wrapper onboard branch: add catch block so a spawn failure (e.g. access denied, corrupted binary) is logged instead of silently falling through with an uninitialised exit code. Mirrors the existing catch block in the discover branch. $ec pre-set to 1 so the failure is recorded even if the catch block itself throws. - SH install_macos: after removing the legacy ai.getunbound.discovery plist/LaunchAgent, also delete the four Keychain entries stored under that old service name. Without this, --uninstall (which only cleans the current name) leaves stale credentials on upgraded systems. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): replace cmdkey /pass: with CredWrite P/Invoke for credential storage cmdkey /pass:<secret> exposes API keys in the Windows process command line, which Event Log 4688 (process-creation auditing) captures persistently — exactly the enterprise environments this tool targets. Replace Store-Credential with a CredWrite P/Invoke call (mirroring the CredRead already in the scheduled wrapper) so secrets never appear in any command line. cmdkey /delete: is retained for removal since deletion exposes no secret. Add-Type CLM guard: if PowerShell Constrained Language Mode blocks Add-Type, exit with a descriptive error rather than silently falling back to the insecure cmdkey /pass: form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address anonpran review — credential exposure, json_escape scope, uninstall cleanup P0-1: Windows discover wrapper used -Command to invoke install.ps1, causing UNBOUND_API_KEY to be bound as a param and appear in child process command lines. Switch to -File so the only thing in the child's command line is the script path. Update install.ps1 to resolve ApiKey/Domain from UNBOUND_API_KEY/UNBOUND_DOMAIN env vars (matching the pattern already in install.sh) when not passed as params. P1-2: json_escape in store_credentials_linux only escapes \ and ". Add a comment documenting the intentionally limited scope — API keys and domain URLs never contain control characters so the simpler form is sufficient. P3-1: remove_credentials_linux left ~/.unbound/ behind after removing the creds file. Add rmdir on the parent directory; rmdir is a no-op when the directory is non-empty, so it is safe when other components share the directory. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: note pre-existing Python CLI arg exposure in install.sh and install.ps1 The Python subprocess receives --api-key as a CLI argument, making the key visible in /proc/pid/cmdline, ps, and Event Log 4688 for the duration of the Python process. This is a pre-existing limitation of the ai_tools_discovery.py entry point; both install scripts already avoid exposing the key at the shell/PS level via UNBOUND_API_KEY env var. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: sentinel file to prevent RunAtLoad+StartCalendarInterval double-run macOS launchd fires RunAtLoad (every boot/login) and StartCalendarInterval (daily 09:00) as two independent triggers. When the machine boots before 09:00, both fire in the same morning. The wrapper now writes a timestamp to $LOG_DIR/.last-run-ts after each execution and skips if the last run was within 8 hours. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Junie support for Windows (#137) * feat: Junie support for Windows Junie (JetBrains' AI coding agent) ships on Windows too — as a CLI and as a JetBrains IDE plugin — but coding-discovery only detected it on macOS. This adds Windows parity (config lives in %USERPROFILE%\.junie, same layout as macOS). - WindowsJunieDetector — detects ~\.junie; multi-user via is_running_as_admin scanning C:\Users. - WindowsJunieRulesExtractor — global ~\.junie\*.md + project-level .junie\*.md; project walk uses get_windows_system_directories and the is_user_level_tool_dir guard so user-scope rules aren't reported as project-scope. - WindowsJunieMCPConfigExtractor — reads ~\.junie\mcp\mcp.json per user. - Wired Windows branches into create_junie_detector and the Junie MCP/rules factories. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Greptile review on Windows Junie - Global Junie rules were tagged scope="project": _detect_rule_scope did not recognize .junie. Added .junie to its user-config dir set, and pass explicit scope="user" when extracting global rules so user-level rules are classified correctly regardless of path resolution. - Admin scan in the rules extractor skipped the system/default account exclusion (Public, Default, etc.) that the detector and MCP extractor already applied. - All three Windows Junie files reinvented the admin/non-admin loop. They now use the shared scan_windows_user_directories helper, which centralises the branching, excludes system accounts, and handles PermissionError — fixing the exclusion bug and removing the duplicated logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Junie MCP parent_levels 2 -> 3 so path resolves to home ~\.junie\mcp\mcp.json needs 3 parent levels to resolve to ~ (home), matching how every other global MCP config keys its `path`. With 2 it returned ~\.junie, inconsistently keying Junie's entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add 45 unit tests for macOS and Windows Junie extraction Covers detector, rules extractor, and MCP config extractor for both platforms - detection with/without .junie dir, version parsing from config.json/settings.json, global vs project-level rule extraction, walker skipping of user-level dirs, and multi-user root scanning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add new flags to rm overdump of logs * added payload flag * refactored and added test cases * Add ~/.unbound/discovery-cache.json local cache + lock + heartbeat New module that backs per-tool payload-hash dedup and prevents concurrent scans from piling up. Cache file is JSON; lock is an mtime-heartbeat file under ~/.unbound/discovery.lock. * Integrate per-tool hash dedup into discovery main Acquire the discovery lock, start the heartbeat, compute payload_hash per (tool, home_user), skip upload when the local cache shows the same hash, and update the cache on a successful send. * Stabilize payload hash by stripping volatile fields and sorting lists Expand _strip_ephemeral so cosmetic re-scans dedup correctly: drop plugins[*].installed_at, mcpServers[*].scan.scanned_at/error, mcpServers[*].oauth.{clientId,callbackPort}, and canonicalise filesystem-walk-order-dependent lists (projects, plugins, rules, skills, mcpServers, scan.tools, permissions.{allow,deny,ask}_rules). * Skip other tools' ~/.<dir>/ from project-scope walkers Project-scope MCP/rules/skills walkers (filesystem from /) were adopting .mcp.json / .agents / .cursor entries inside ~/.codex, ~/.cursor extensions, etc. as projects of the wrong tool. New is_home_dotdir_descendant predicate wired into walk_for_tool_directories, walk_for_mcp_configs_generic, walk_for_claude_project_mcp_configs, and the Cursor skills _walk_for_skills uniformly — covers any tool that uses the standard ~/.toolname/ convention without per-tool maintenance. * Accept api_key via UNBOUND_API_KEY env (keeps argv clean) Hook-triggered invocations can now pass the api_key through the subprocess env so it never appears in argv / /proc/<pid>/cmdline. CLI --api-key remains the default for MDM and direct-script usage. * Address PR review: tighten dotdir helper, drop dead funcs, lift import - is_home_dotdir_descendant now matches only the canonical /Users/<u>/.foo and /home/<u>/.foo positions; non-standard mounts like /srv/home/<u>/.config or /data/Users/shared/.cache no longer false-match. - Remove unused stamp_last_run, is_debounced, _parse_iso, and DEBOUNCE_SECONDS from cache.py — the hook owns the debounce + last_run_at stamp directly, the cache module was never the call site for these. - Move heartbeat_start() into the try block in ai_tools_discovery main so a heartbeat thread-spawn failure can't leak the discovery lock (release_lock now always runs). - Lift is_home_dotdir_descendant import in walk_for_tool_directories from function body to module level — no circular import (mcp_extraction_helpers only does lazy imports from macos_extraction_helpers). * added guardrail to print summary if not --payload, --summary * fix(macos): skip RunAtLoad when caller already ran the scan (#141) * chore: mark --no-run-at-load as internal-only in arg parser comment (#148) The flag is passed automatically by the unbound CLI when a scan already ran immediately before cron setup; it is not a user-facing option and is intentionally absent from usage(). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: implement KiloCode version extraction (macOS + Windows) (#149) * fix: implement KiloCode version extraction (macOS + Windows) Kilo Code ships standard VS Code extension metadata in ~/.vscode/extensions/kilocode.Kilo-Code-X.Y.Z/package.json (and ~/.cursor/extensions/ for Cursor), but both KiloCode detectors hardcoded "version": "Unknown" and returned None from get_version(). A stale docstring claimed "version extraction removed per requirements" — the sibling Roo Code and Cline detectors had the same stub in early 2025 and were both rewritten to read package.json in commit a31ecfa (Feb 2026). KiloCode was missed. Apply the same package.json read pattern KiloCode's siblings use, with a fall-back to the folder-name version suffix when package.json is unreadable, and surface the resolved version through both detect() and get_version() (the latter walks every admin user when running elevated). Linux equivalent will land via the feat/linux-full-support branch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode): delegate get_version() to detect() (Greptile review) Reviewer flagged that get_version() walked extension dirs directly while detect() also checked that the extension settings dir + IDE app were present. A partially-uninstalled KiloCode (leftover ~/.vscode/extensions folder but no globalStorage and no IDE) would let get_version() return a stale version while detect() returned None — they could disagree. Make get_version() delegate to detect() and read 'version' off the result so the install-gating logic is the single source of truth. Matches the Roo Code / Cline pattern. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address anonpran review + add Replit version detection KiloCode (PR #149 review by anonpran): - Scope _get_extension_version_for_user to the IDE that triggered detection. Previously looped all SUPPORTED_IDES and returned the first match in any extensions dir (.vscode first), so a Cursor install with a leftover ~/.vscode/extensions/kilocode.Kilo-Code-* would return the VS Code version against the Cursor install_path. Now takes ide_name explicitly, same as Roo Code / Cline siblings. - Drop dead `except IndexError` around rsplit("-", 1)[1] — the `if "-"` guard already ensures the split returns 2 elements. - Add docstring to _get_extension_version_for_user. - Add tests/test_kilocode_version_extraction.py covering package.json reads, folder-suffix fallback, and IDE scoping (must NOT cross-leak VS Code version to a Cursor install). Replit version extraction (macOS + Windows): - macOS: Read CFBundleShortVersionString from /Applications/Replit.app/ Contents/Info.plist (same source Cursor/Windsurf use); fall back to Contents/Resources/app/package.json. - Windows: Walk %LOCALAPPDATA%\Programs and Program Files for the install dir, read resources\app\package.json; fall back to the .exe FileVersion via PowerShell. The earlier "doesn't expose version information in a standard way" comment was wrong — Replit Desktop is a standard Electron app. Linux versions for Antigravity + Replit will land via the feat/linux-full-support branch alongside the matching KiloCode fix. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode, replit): address the two edge-case flags 1. KiloCode folder-name fallback preserved pre-release suffix. rsplit("-", 1)[1] truncated pre-release semver — kilocode.Kilo-Code- 1.2.3-pre.5 collapsed to "pre.5" instead of "1.2.3-pre.5". Replace with a regex that captures full semver including pre-release/build metadata from the end of the folder name. Add tests covering -pre.5 and -beta.1 forms. 2. Replit Windows PowerShell path quoting. repr(str(path)) produced 'C:\\Users\\…' (literal double-backslash inside PowerShell's single quotes), which doesn't reliably resolve. Switch to a hand-built single-quoted string with single-quote doubling, plus -LiteralPath so PowerShell doesn't try to glob the path. Inside single quotes, backslashes are already literal — no double-backslash dance needed. Both bugs only fire on the fallback branch (after package.json fails to parse), so they couldn't produce worse output than the pre-PR "Unknown" — but the fix is cheap and removes the footgun. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: address greptile + anonpran follow-ups on PR #149 Test coverage: - Drop @unittest.skipUnless from TestMacOSKiloCodeVersion and TestWindowsKiloCodeVersion. _get_extension_version_for_user is pure pathlib + JSON parsing, so the IDE-scoping regression guard (which proves a Cursor install doesn't inherit a leftover VS Code version) must execute on every CI runner — previously it ran only on a dev's macOS/Windows box and was silently skipped on the Linux runner that produced the "553 passed" claim. Import hygiene / cross-OS consistency: - Windows Replit imported COMMAND_TIMEOUT while macOS imported VERSION_TIMEOUT for the same lookup. Same value (30s), but mixing the import names across sibling files is exactly the inconsistency Greptile and anonpran flagged. Switch Windows to VERSION_TIMEOUT. - Replit detect() now wraps get_version() in `or "Unknown"` on both macOS and Windows. Without it, a data-dir-only install emitted `"version": null` while a KiloCode equivalent emitted `"Unknown"` for the same case. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: bring Windows KiloCode test coverage to macOS parity Greptile flagged the Windows test class as thin (2 cases vs 7 on macOS). Add the 3 missing parity tests: folder-suffix fallback, pre-release suffix preservation, Cursor extensions-dir routing. The Linux runner will now execute all 12 cases instead of the previous 9. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode/macos): require single-IDE pairing for globalStorage + .app The fallback loop in _check_user_for_kilocode (macOS only) updated ide_installed but never updated ide_with_extension. So when globalStorage was found in IDE-A but only IDE-B was actually installed, the version lookup ran against IDE-A's extensions dir — either returning a stale leftover version or falling through to "Unknown" even when IDE-B genuinely had Kilo Code installed with a readable package.json. The fallback was always wrong-headed: globalStorage in an uninstalled IDE doesn't mean Kilo Code is currently usable from a different IDE. Replace it with a single pass that requires BOTH globalStorage AND matching .app for the same IDE. Add three regression tests: - test_rejects_globalstorage_when_matching_ide_not_installed: trap config (Code globalStorage + Cursor.app, no Cursor globalStorage) now returns None instead of an incoherent dict. - test_prefers_ide_with_both_globalstorage_and_app: when both IDEs have globalStorage but only Cursor.app is installed, version comes from Cursor's extensions dir — NOT from a stale Code leftover. - test_returns_result_when_first_ide_has_both: happy path. Windows and Linux detectors never had this fallback loop — they correctly return None when the first IDE check fails — so this fix is macOS-only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(replit/macos): avoid redundant _check_application_installation() When detect() finds an app install, it then calls get_version() which in turn calls _check_application_installation() again as a safety guard — two filesystem syscalls where one suffices. Greptile flagged it as a minor inefficiency. Add an optional app_path parameter to get_version() so detect() can pass the already-resolved Path. Falls back to the internal check when called directly (keeping standalone-call safety). Matches the macOS Antigravity detector's existing signature. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode/windows): require live IDE install + globalStorage pairing Mirror the macOS fix (f87c864) to Windows. AppData survives an IDE uninstall on Windows, so the previous detector — which trusted globalStorage as proof of an IDE install — would report stale state. Concrete scenario flagged in review: - User installed VS Code, added KiloCode (creates %AppData%\Code\User\globalStorage\kilocode.Kilo-Code\ AND %USERPROFILE%\.vscode\extensions\kilocode.Kilo-Code-X.Y.Z\) - User uninstalled VS Code (both AppData and the extensions folder persist on disk) - User installed Cursor + KiloCode in Cursor The old detector found Code globalStorage first, marked ide_with_extension = 'Code', then surfaced the *VS Code* leftover version against a 'Code' install_path even though only Cursor is actually installed. Before this PR opened, that path returned 'Unknown'; after the version extraction landed, it would start returning a stale-but-plausible version. Add a Windows-shaped _check_ide_installation that requires the IDE's .exe under one of the conventional install roots: - %LOCALAPPDATA%\Programs\ - %ProgramFiles%, %ProgramFiles(x86)% (with C:\Program Files fallbacks when the env vars are unset, e.g. service-account scans) Three regression tests mirror the macOS suite: trap config (Code globalStorage + uninstalled Code + live Cursor without globalStorage), preference for Cursor when both have globalStorage but only Cursor is installed, and the happy path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(replit/macos) + tests: use app_path, add Replit version coverage macOS Replit get_version() still referenced self.APPLICATION_PATH for both the Info.plist and package.json reads even after the redundancy fix added an app_path parameter — so passing app_path was effectively a no-op and the function always read from /Applications/Replit.app regardless of caller. Greptile caught it. Replace both references with the passed-through app_path so the parameter has real effect. Add tests/test_replit_version_extraction.py — 10 cases, no platform skips so the Linux/macOS/Windows CI runners all execute them: macOS (4 cases): - returns None when no install - reads Info.plist first via the explicit app_path AND asserts the call doesn't reference /Applications/Replit.app — the regression guard for this commit - falls back to package.json when defaults read returns nothing - returns None when both sources fail Windows (6 cases): - returns None when no install - reads version from resources/app/package.json - skips missing candidate dirs and tries the next one - falls back to PowerShell FileVersion when package.json is broken - escapes single quotes in paths (asserts user' -> user'' in the PS command — the regression guard for the earlier quoting fix) - candidate_install_paths includes both LOCALAPPDATA\Programs and Program Files family roots Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(replit): trim version-extraction tests to regression-guard set Drop 5 trivial/sanity tests from tests/test_replit_version_extraction.py that didn't lock in a real regression: - macOS test_returns_none_when_no_install (trivial) - macOS test_falls_through_to_none_when_both_sources_missing (edge case) - Windows test_returns_none_when_no_install_found (trivial) - Windows test_skips_candidate_when_directory_missing (sanity) - Windows test_candidate_install_paths_includes_user_and_system_dirs (implementation detail) Keep the 5 that earn their slot: - macOS test_reads_plist_first_when_passed_explicit_app_path — locks in the app_path-vs-self.APPLICATION_PATH fix Greptile flagged. - macOS test_falls_back_to_package_json_when_plist_unreadable — exercises the secondary read path. - Windows test_reads_version_from_package_json — primary success path. - Windows test_falls_back_to_powershell_when_package_json_unreadable — asserts -LiteralPath is in the command, locks in the quoting fix. - Windows test_escapes_single_quotes_in_path — asserts ' -> '' so a path containing a quote can't escape the PS single-quoted literal. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(replit/windows): symmetrise _candidate_install_paths with KiloCode Greptile flagged that Windows Replit's _candidate_install_paths drops each root entirely when its env var is unset, while the KiloCode sibling falls back to Path.home()-derived / C:\Program Files defaults. Real-world impact is small (env vars are always set in normal user sessions) but matters for restricted service accounts where AppData or Program Files env vars get stripped — and the symmetry just reads cleaner. Mirror the KiloCode helper's fallback structure here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat: full Linux support for unbound discover and onboard (#128) * feat: full Linux support for unbound discover and onboard Adds Linux implementations for every AI coding tool detector and extractor, bringing Linux to full parity with macOS and Windows (detectors, rules, MCP config, settings, skills), plus cross-platform --set-cron support for user-level onboard/discover. Highlights: - linux/ package mirroring macos/windows for all 17 tools, wired through coding_tool_factory and ai_tools_discovery (multi-user /home + /root, Docker/CI root-only containers, /etc/machine-id device id). - linux_extraction_helpers with Linux-aware system-path skipping and a walk_for_tool_directories that does not blocklist /home. - Claude Cowork (~/.config/Claude) and Junie (~/.junie) Linux parity. - Plugin provenance (plugin_lookup) threaded through Linux Claude/Cursor skills and Claude MCP extractors. - setup-scheduled-scan cron support hardened across Linux/macOS/Windows. - tests/test_cowork_skills_extraction_linux.py. Rebased cleanly onto staging (which already contains plugin provenance #129); main-only plan-detection commits are intentionally excluded — they reach staging via the normal main->staging path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Linux Junie MCP parent_levels 2 -> 3 so path resolves to home ~/.junie/mcp/mcp.json needs 3 parent levels to resolve its reported path to ~ (home), matching every other global MCP config. Same fix applied to Windows Junie in PR #137. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: scheduler setup no longer aborts on npm-absent Linux hosts create_wrapper_script ran NPM_BIN=$(npm config get prefix 2>/dev/null)/bin with no failure guard, so under 'set -euo pipefail' a host without npm aborted the entire scheduler setup before the wrapper or cron entry was written. Guard the lookup with '|| true' and only append the bin dir when a prefix resolves. NPM_BIN now carries its own trailing colon when set and is empty otherwise, so an npm-absent host no longer leaves a leading ':' in the wrapper PATH (which would have put the CWD on PATH). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Linux multi-user gaps in plugin scan, cursor DB, managed MCP, user-home enum Audited every `Darwin`/`Windows` branch across the codebase and found five spots that had no Linux counterpart. Each one silently dropped real data on a Linux MDM/agent runner: 1. ai_tools_discovery._extract_claude_code_plugins multi-user scan only covered Darwin and Windows. On Linux, running as root only checked /root/.claude/plugins — plugins installed under /home/<user> were invisible, so discovery reported "No plugins found" even when a plugin was installed. 2. Same gap in the Cursor plugin multi-user scan. 3. ai_tools_discovery user-home enumeration (fallback when user_rules provide no project_path) lacked a Linux branch — used the current process's home instead of iterating /home/<user> + /root. 4. mcp_extraction_helpers._get_managed_mcp_path returned None on Linux. Added /etc/ClaudeCode/managed-mcp.json (system-wide convention). 5. utils._get_cursor_db_path returned None on Linux, breaking Cursor plan detection. Added the XDG path ~/.config/Cursor/User/globalStorage/ state.vscdb. All five Linux branches use get_all_users_linux() + the established "/root" vs "/home/<user>" special-case so they match the rest of the PR's multi-user handling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: skip macOS-only cowork extractor test on non-Darwin TestMacOSClaudeCoworkSkillsExtractor exercises MacOSClaudeCoworkSkillsExtractor specifically, and that extractor intentionally skips its final-step dedup when running as root. The explicit-sessions-root tempdir path is therefore ordering-dependent on ext4 (Linux), causing the dedup assertion to flake under root — pre-existing on main, not introduced by this PR. Skip the class on non-Darwin so Linux CI/runners don't see a failure that's not actually about the Linux work. The Linux equivalent (tests/test_cowork_skills_extraction_linux.py + LinuxClaudeCoworkSkillsExtractor) dedups unconditionally and already covers the same behaviour on Linux. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: Linux unit-test parity with macOS for skills/cline/cursor extractors Adds direct unit-test coverage for the Linux skills extractors mirroring the macOS pattern, and updates cursor plan-detection to cover the Linux SQLite DB path now that _get_cursor_db_path resolves it on Linux. New files (20 new tests total): - tests/test_claude_skills_extraction_linux.py — TestLinuxExtractorEndToEnd (4 methods) + TestLinuxExtractorAgents (3) mirror the macOS classes for LinuxClaudeSkillsExtractor (~/.claude/{skills,commands,agents}/). - tests/test_cursor_skills_extraction_linux.py — TestLinuxCursorSkillsExtractor (4) + TestLinuxCursorSkillsExtractorWithCommands (5) for the Cursor flow including .cursor/ and .agents/ parent dirs. - tests/test_cline_skills_extraction_linux.py — TestLinuxClineSkillsExtractor (5 methods) for the Cline flow. Pattern: Linux extractors iterate get_linux_user_homes() instead of using Path.home() + is_running_as_root, so the new tests patch linux.<tool>.skills_extractor.get_linux_user_homes to return [fake_home] in place of the three-decorator macOS pattern. tests/test_cursor_plan_detection.py: - The Linux branch used to assert "returns None (unsupported)". Now that _get_cursor_db_path resolves ~/.config/Cursor/User/globalStorage/state.vscdb on Linux, replaced with three correctly-shaped tests: * test_get_cursor_db_path_linux — creates the file, expects the path * test_get_cursor_db_path_linux_file_not_exists — mirrors the macOS file-not-exists assertion * test_get_cursor_db_path_unsupported_platform — moved to FreeBSD so it still validates the unsupported-platform fallthrough path. Local macOS: 549 passed (was 527). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: escape heredoc runtime vars in wrapper dedup check LAST_RUN_FILE, _last, _now, and the arithmetic expansions were bare $/$(...) inside the unquoted <<WRAPPER_EOF heredoc, causing them to be expanded by the setup shell at install time instead of at wrapper runtime. The 8-hour dedup check consequently never fired. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add 10 Linux Junie unit tests (detector, rules, MCP config) Mirrors the macOS/Windows Junie test coverage for the Linux implementation - detection, version parsing, global rule extraction, MCP config reading, and multi-user scanning via get_linux_user_homes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address principal engineer review — B1-B4 + H1/H2/H5 B1 (mcp_extraction_helpers): add _iter_admin_user_homes() helper that delegates to get_linux_user_homes() on Linux, which correctly includes /root alongside /home/*. Replace all 5 Linux admin-scan loops to use it instead of iterating Path("/home") directly. B2 (utils): hardcode "root" in Docker/CI fallback instead of Path.home().name which could return "app" when HOME is overridden. B3 (detect_openclaw): add timeout=10 to subprocess.run(["ps","aux"]) to prevent hangs on loaded systems. B4 (windsurf): get_version() now checks _USER_RELATIVE_PATHS via get_linux_user_homes() so user-local installs return a real version. H1 (linux_extraction_helpers + ai_tools_discovery): extract linux_home_for_user(username) helper; replace 7 inline ternaries. H2 (mcp_extraction_helpers): _iter_admin_user_homes() eliminates the 5 copy-pasted Linux admin-detection blocks. H5 (gemini_cli, opencode): add _USER_RELATIVE_PATHS fallback scan (~/.local/bin, ~/.npm-global/bin) when which finds nothing, so installs are detected when user profiles are not sourced (cron/systemd). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Linux Claude Cowork detection in per-user iteration user_tool_detector._detect_claude_cowork() had only Darwin and Windows branches — Linux fell through the else into the Windows AppData path, which never exists on Linux, so Cowork was silently skipped on Linux even when sessions dir was present. Add the Linux branch using ~/.config/Claude/local-agent-mode-sessions (matches LinuxClaudeCoworkDetector._sessions_dir_for_user). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: implement KiloCode version extraction (Linux) Mirror the macOS/Windows fix from fix/kilocode-version-extraction (PR #149): Linux KiloCode detector hardcoded 'version': 'Unknown' even though Kilo Code ships standard VS Code extension metadata in ~/.vscode/extensions/kilocode.Kilo-Code-X.Y.Z/package.json (and the ~/.cursor/extensions/ equivalent for Cursor). Apply the same package.json read pattern its Linux Roo Code and Cline siblings already use, with a fall-back to the folder-name version suffix when package.json is unreadable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode): delegate Linux get_version() to detect() (Greptile review) Mirror the PR #149 review fix: get_version() must not surface a stale version when detect() would return None (e.g. leftover ~/.vscode/extensions folder but no kilocode globalStorage settings dir). Delegate to detect() so install-gating logic stays the single source of truth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(linux): version extraction for Antigravity, Replit, and scoped KiloCode KiloCode (mirrors PR #149 anonpran review fix): - Scope _get_extension_version_for_user to the IDE that triggered detection. Previously looped all SUPPORTED_IDES and returned the first hit, so a Cursor install with a leftover ~/.vscode/extensions/kilocode.Kilo-Code-* would have returned the VS Code version against the Cursor install_path. - Drop dead `except IndexError` around rsplit("-", 1)[1] — guarded. Antigravity (Linux): - Read version from resources/app/product.json (preferred) and resources/app/package.json (fallback) across the common install paths (/opt/Antigravity, /usr/lib/antigravity, etc. + ~/.local/share/...). Antigravity is a VS Code fork, so the resource layout matches what Cursor/Windsurf already use elsewhere in this codebase. Replit (Linux): - Read version from resources/app/package.json across system + per-user install paths. Final fallback: `replit --version`. The earlier "doesn't expose version information in a standard way" comment was wrong — Replit Desktop is a standard Electron app. Tests: - tests/test_linux_version_extraction.py covers all three detectors: package.json reads, fallbacks, and KiloCode IDE-scoping (must NOT cross-leak VS Code version onto a Cursor install). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(kilocode): preserve pre-release suffix in folder-name fallback (Linux) rsplit("-", 1)[1] truncated semver pre-release suffixes — e.g. kilocode.Kilo-Code-1.2.3-pre.5 returned just "pre.5". Switch to a regex that captures the full version (incl. pre-release/build metadata) from the end of the folder name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: drop dead imports flagged in principal-engineering review anonpran's PR #128 review (https://github.com/websentry-ai/coding- discovery-tool/pull/128#issuecomment-4583525626) approved the PR with three trivial dead-import nits. Removing them so Pylint stays clean: - linux/codex/mcp_config_extractor.py: is_running_as_root never used in this module (root-vs-user logic lives in get_linux_user_homes() which is already imported and used). - linux/cursor/mcp_config_extractor.py: extract_cursor_mcp_from_dir was imported but only walk_for_cursor_mcp_configs + read_global_ mcp_config are actually called. - linux/windsurf/mcp_config_extractor.py: same shape as cursor — extract_windsurf_mcp_from_dir imported but never referenced. No behaviour change. 579 tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: drop dead Linux branch from extract_global_mcp_config_with_root_support Greptile flagged the Linux branch in this helper as a latent early-return bug (would silently drop non-first-user configs if any future Linux extractor invoked it). The branch was preparation-for- future-use that no current Linux extractor reaches. Removing it instead. Linux MCP extractors already follow a better pattern — per-user accumulation across get_linux_user_homes() — so the helper is macOS/Windows-only by intent. Replace the branch with a comment that points future authors at the correct Linux template. Zero behaviour change today: macOS callers hit the Darwin branch, Windows callers hit the Windows branch, no Linux callers exist. 579 tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style: fix over-indented try block in extract_global_mcp_config_with_root_support Greptile cosmetic note — the try block under the admin-homes loop was indented 16 spaces instead of 12 (a pre-existing artifact, syntactically valid but inconsistent). Re-align to the 4-space loop body. No behaviour change; 579 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * fix: allow Linux in main() OS guard so discovery actually runs (#152) PR #128 added full Linux support (70 modules, 52 factory branches, linux_extraction_helpers, and 3 Linux branches in main()), but the squash-merge into staging retained the exit-3 OS guard at the top of main(): if current_platform not in ("Darwin", "Windows"): ... "AI tool discovery is not supported on {platform}" sys.exit(3) This guard fires BEFORE detector init, so on Linux `unbound discover` exits 3 immediately and skips the scan — making every downstream Linux branch unreachable dead code. Reproduced end-to-end on an Ubuntu VM via the real install.sh entry path against staging: clones staging, runs ai_tools_discovery.py, exits 3. Narrow the guard to include Linux. Genuinely unsupported platforms (*BSD, AIX, etc.) still exit 3 cleanly before detector init so they can't crash and page Sentry on every run. Update TestUnsupportedPlatformGuard accordingly: - test_linux_proceeds_past_os_guard: Linux now passes the guard (exits 0 at the single-flight lock check, not 3 at the guard). - test_unsupported_platform_exits_code_3_before_detector_init: FreeBSD still exits 3, detector never constructed. 621 tests pass. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix: remove double-send regression, gate dedup transport logs Drop the unconditional send block that fired before the dedup check; it bypassed the local hash cache (uploading on every match) and dropped retryable failures (no failed_reports append / cache update). The dedup-gated send is now the sole upload path. Gate the dedup-block "skipping upload", "Sending", and "✓ sent" lines behind not --summary and not --payload so those modes suppress all transport output. Errors stay ungated. Add TestSendDedupCount: exactly one send on a changed payload, zero on a hash match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Merge main into staging: sync staging up to main (#156) * Exit cleanly on unsupported platforms instead of crashing (WEB-4370) (#131) * Exit cleanly on unsupported platforms instead of crashing On Linux (and any non-macOS/Windows platform), discovery crashed with a ValueError deep in detector init and reported to Sentry on every run. Add an early guard in main() that prints a friendly message to stderr and exits with code 3 before any detector is constructed, so there's no traceback and no Sentry noise. WEB-4370 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Cache platform.system() result in the unsupported-platform guard Call platform.system() once and reuse it for the check and the message, per review feedback. WEB-4370 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix Claude Code plan detection by using shell-based binary resolution (#134) Previously, plan detection was skipped entirely when find_claude_binary_for_user couldn't locate the binary in hardcoded paths (Homebrew, .local/bin, .bun, nvm). Users who installed via volta, pnpm, fnm, asdf, mise, etc. had empty plan badges. Instead of hardcoding more paths, this change lets the user's login shell resolve the binary via PATH. When claude_binary is None, the command is passed through `shell -lc "claude auth status --json"` — the -l flag sources the user's profile (.zshrc, .bashrc) which sets up PATH for any package manager. This works in all execution contexts: - Terminal: login shell resolves via user's PATH - MDM/root: launchctl asuser <uid> /bin/zsh -lc "claude auth status --json" - su fallback: su - <user> -c "claude auth status --json" When the binary IS found via hardcoded paths, behavior is unchanged (uses full path). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add Sentry diagnostics for Claude Code plan detection failures (#135) * Add Sentry diagnostic reporting for Claude Code plan detection failures When plan detection returns None, send a Sentry message event with breadcrumbs showing which stages were attempted and why each failed (keychain, launchctl, su, direct execution). This gives visibility into cases like kim.le where plan detection silently fails. - Extract _send_sentry_payload helper from report_to_sentry - Add report_message_to_sentry for non-exception Sentry events - Add optional diagnostics parameter to get_claude_subscription_type - Wire up at call site in ai_tools_discovery.py - Add tests for never-crash contract and diagnostics population Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify: use existing report_to_sentry instead of new message function Remove over-engineered _send_sentry_payload and report_message_to_sentry. Use report_to_sentry with a synthetic RuntimeError and diagnostics in the context dict instead. Same Sentry visibility, much less code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove unnecessary test additions Diagnostics list population is trivial — existing plan detection tests in test_claude_auth_status.py already cover the core behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Gate Sentry alert on active usage to reduce noise Only send plan detection failure to Sentry when the user has projects (i.e. actively uses Claude Code). Users without projects having no plan is expected — not worth alerting on. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Detect API key auth as plan type in Claude Code plan detection (#136) * Detect API key auth as plan type in Claude Code plan detection When users authenticate via ANTHROPIC_API_KEY instead of OAuth, `claude auth status --json` returns `authMethod: "api_key"` without a `subscriptionType`. Previously this was treated as a detection failure (plan=None), triggering false-positive Sentry warnings. Now `_run_auth_status` checks for `authMethod: "api_key"` when `subscriptionType` is absent and returns "api_key" as the plan. OAuth users are unaffected — subscriptionType always takes priority. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Make authMethod comparison case-insensitive Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Include authMethod in diagnostic breadcrumbs for Sentry When plan detection returns ok=True but plan=None, we now include the authMethod field from the CLI response in Sentry diagnostics. This enables debugging future unknown auth scenarios without logging the full CLI response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Gate Sentry alerts on CLI success — suppress noise from unauthenticated users Only fire plan detection Sentry events when at least one CLI stage returned ok=True (user authenticated but plan missing). When all stages return ok=False, the user never authenticated Claude Code — not an actionable alert. Eliminates noise from test users (dev-diana, dev-alice, ec2-user) who have Claude binary installed but never logged in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Fix Codex TOML sub-table sections leaking as spurious MCP servers Nested sections like [mcp_servers.foo.http_headers] and [mcp_servers.foo.tools.bar] were being parsed as top-level server entries, leaking secrets (e.g. API keys in http_headers) into the report payload. Use tomllib on Python 3.11+ and skip dotted-name sections in the regex fallback for older runtimes. * Address greptile review: fix or-falsy-dict and list-of-dicts secret leak - Use key-presence check instead of `or` to avoid treating an empty mcp_servers dict as absent and falling through to mcpServers - Also filter out list-of-dict values so arrays of inline tables can't bypass the secret filter * [WEB-4391] Remove verbose JSON payload logging and add per-tool timing metrics (#132) * Remove verbose JSON payload logging and add per-tool timing metrics The JSON payload logging serialized every report to stderr line-by-line, producing ~39K-156K log lines per run. This wasted ~1-2 min of wall-clock time and caused pipe buffer issues under Rippling MDM execution. Per-tool timing replaces the aggregated `process_single_tool` and `send_report_per_tool_user` step names with `process_tool.<slug>` and `send_tool.<slug>`, enabling identification of which specific tool causes the 2-4 minute processing gaps observed via Sentry timestamps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore JSON payload logging with rules/skills content stripped Instead of removing the entire JSON dump, keep it for debugging but replace rules and skills `content` fields with `<N chars>` placeholders. This preserves structural visibility while cutting the bulk of the stderr output (~39K-156K lines per run → ~2-3K lines). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove over-engineered test file for _metric_slug helper The _metric_slug helper is a simple 4-line internal function that doesn't warrant its own dedicated test file with 12 parametrized cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Revert per-tool metric names, keep only content stripping Remove _metric_slug helper and per-tool Sentry metric names (process_tool.{slug}, send_tool.{slug}) to avoid inflating Sentry metric volume. Restore original generic metric names. The only change in this PR is now: strip rules/skills content from the JSON payload log to reduce stderr volume. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * [WEB-4391] Strip MCP scan bulk from stderr log to fix Rippling MDM pipe buffer exhaustion (#139) * Strip MCP scan bulk from JSON payload log to reduce stderr volume Remove inputSchema, outputSchema from MCP scan tool entries and truncate description to 80 chars in the log-only deepcopy. This reduces stderr by ~150-200KB per run on devices with many MCP servers, fixing Rippling MDM pipe buffer exhaustion that causes "Transaction was interrupted during processing" errors. The actual payload sent to the backend is untouched (deepcopy isolation). Rule/skill content stripping was already merged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Trigger CI --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Improve plan detection diagnostics and broaden api_key matching (#140) Two changes to improve debuggability of plan detection Sentry alerts: 1. Use `"api_key" in` instead of `== "api_key"` for auth method matching. Claude Code has both "api_key" and "api_key_helper" auth methods — the strict equality missed api_key_helper users, leaving their plan as None and triggering false-positive Sentry warnings. 2. Add `key_source` (from apiKeySource) to diagnostic breadcrumbs. This distinguishes org-managed logins ("/login managed key") from personal accounts, which is critical for understanding why subscriptionType is null for team/enterprise org users. Fixes DISCOVERY-TOOL-SCRIPT-V Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add ~/.unbound/discovery-cache.json local cache + lock + heartbeat New module that backs per-tool payload-hash dedup and prevents concurrent scans from piling up. Cache file is JSON; lock is an mtime-heartbeat file under ~/.unbound/discovery.lock. * Integrate per-tool hash dedup into discovery main Acquire the discovery lock, start the heartbeat, compute payload_hash per (tool, home_user), skip upload when the local cache shows the same hash, and update the cache on a successful send. * Stabilize payload hash by stripping volatile fields and sorting lists Expand _strip_ephemeral so cosmetic re-scans dedup correctly: drop plugins[*].installed_at, mcpServers[*].scan.scanned_at/error, mcpServers[*].oauth.{clientId,callbackPort}, and canonicalise filesystem-walk-order-dependent lists (projects, plugins, rules, skills, mcpServers, scan.tools, permissions.{allow,deny,ask}_rules). * Skip other tools' ~/.<dir>/ from project-scope walkers Project-scope MCP/rules/skills walkers (filesystem from /) were adopting .mcp.json / .agents / .cursor entries inside ~/.codex, ~/.cursor extensions, etc. as projects of the wrong tool. New is_home_dotdir_descendant predicate wired into walk_for_tool_directories, walk_for_mcp_configs_generic, walk_for_claude_project_mcp_configs, and the Cursor skills _walk_for_skills uniformly — covers any tool that uses the standard ~/.toolname/ convention without per-tool maintenance. * Accept api_key via UNBOUND_API_KEY env (keeps argv clean) Hook-triggered invocations can now pass the api_key through the subprocess env so it never appears in argv / /proc/<pid>/cmdline. CLI --api-key remains the default for MDM and direct-script usage. * Address PR review: tighten dotdir helper, drop dead funcs, lift import - is_home_dotdir_descendant now matches only the canonical /Users/<u>/.foo and /home/<u>/.foo positions; non-standard mounts like /srv/home/<u>/.config or /data/Users/shared/.cache no longer false-match. - Remove unused stamp_last_run, is_debounced, _parse_iso, and DEBOUNCE_SECONDS from cache.py — the hook owns the debounce + last_run_at stamp directly, the cache module was never the call site for these. - Move heartbeat_start() into the try block in ai_tools_discovery main so a heartbeat thread-spawn failure can't leak the discovery lock (release_lock now always runs). - Lift is_home_dotdir_descendant import in walk_for_tool_directories from function body to module level — no circular import (mcp_extraction_helpers only does lazy imports from macos_extraction_helpers). * Release: staging → main (2026-05-31) (#153) * feat: add cross-platform --set-cron support (macOS launchd, Linux systemd/crontab, Windows Task Scheduler) (#126) * feat: add cross-platform --set-cron support for user-level onboard and discover Extends setup-scheduled-scan.sh to support Linux (systemd/crontab) in addition to macOS launchd, and adds --command discover|onboard with --discovery-key flag so the scheduled job…
… (shared skills/ no longer triggers false positive) (#164) * fix(copilot-cli): only detect CLI on CLI-exclusive ~/.copilot markers The Copilot CLI detector declared a "GitHub Copilot CLI" install whenever ~/.copilot/ contained ANY marker, including skills/. But ~/.copilot/skills/ is a SHARED location the VS Code/JetBrains Copilot agent also reads (per official VS Code Agent Skills docs), so IDE-only users with no actual CLI got phantom CLI rows (confirmed in prod: device MY4W6QQGCQ / user karthick). Split markers into STRONG (CLI-exclusive: config.json, mcp-config.json, settings.json, lsp-config.json, permissions-config.json, session-store.db + dirs logs/session-state/command-history-state/installed-plugins/ plugin-data/hooks/ide/pkg) vs SHARED (copilot-instructions.md, skills/, agents/, instructions/). Detection now fires only on a STRONG marker; a dir holding only shared markers logs an INFO line and is suppressed. Skills/rules extraction is unchanged (still enriches a detected install). Per-user over-attribution is a separate fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(copilot-cli): nest COPILOT_HOME test dir under managed tmp_dir (no temp leak) Addresses greptile P2: reuse the class-managed self.tmp_dir (cleaned in tearDown) instead of an unmanaged tempfile.mkdtemp() that leaked a /tmp dir per run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(copilot-cli): treat ~/.copilot/ide as SHARED (the IDE extension writes it) ~/.copilot/ide/<uuid>.lock is written by the VS Code/JetBrains Copilot EXTENSION (microsoft/vscode-copilot-chat#3583) as a discovery lock so the CLI can connect — an IDE-only user with no standalone CLI has it. It was mis-classified as a STRONG (CLI-exclusive) marker, so an IDE-only Copilot user would STILL be flagged as a phantom "GitHub Copilot CLI" install — the exact false positive this PR exists to remove. Demote `ide` from STRONG to SHARED so it can never alone trigger a CLI record (it still enriches a real install). Add a regression test for an ide/-only ~/.copilot. Confirmed on a real machine: the lock file contains "ideName": "Visual Studio Code". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r flag Implements step ③ of Approach B (user-centric device attribution). Each device reports its OWN device_id and a plain home_user; supersedes PR #165's device_id injection + home_user namespacing (not carried forward). - Salvage the in_container() helper into utils.py (checks /.dockerenv, /run/.containerenv, overlay root mount, cgroup v1 fallback; lru_cached). Only this helper is brought over from feat/device-id-host-override — not the injection override or namespacing. - Add is_container (bool) as a top-level field in the report payload built by generate_single_tool_report, set from in_container(). - Stable container device_id: when /etc/machine-id is empty/absent, fall back to a UUID persisted in the home-user's ~/.unbound/ (read if present, else generate uuid4 + write) instead of the ephemeral hostname, so a restarted container keeps one device row. Reuses cache._ensure_state_dir() for robust state-dir resolution (writable fallback) rather than a bare Path.home(). Machine-id-first behavior unchanged; never raises. - Tests: is_container present/top-level in report; persisted-UUID fallback (generate+write, read-existing, unwritable dir, write-failure); in_container detection with lru_cache clearing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1d9b5b2 to
5c2d6ce
Compare
Addresses Greptile review on PR #170: - Atomic write (P1): persist the device-id UUID via tempfile.mkstemp + os.replace in the same dir, mirroring cache.atomic_write_cache(). A kill mid-write can no longer leave a partial UUID on disk. - UUID validation on read (P1): validate the persisted value with uuid.UUID() and treat a corrupt/truncated/non-UUID value as absent, regenerating a fresh UUID. Pairs with the atomic write. - Log levels (P2): failures that cost the container its stable identity (no state dir, unreadable/corrupt file, failed write, outer catch) now log at warning; the benign absent-machine-id read stays at debug. Extends tests/test_container_attribution.py to use a real temp dir so the atomic write path is exercised for real, and adds corrupt-partial and garbage-non-UUID regeneration cases plus a write-failure case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pugazhendhi-m
approved these changes
Jun 5, 2026
… fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pugazhendhi-m
added a commit
that referenced
this pull request
Jun 10, 2026
* feat(discovery): Approach B tool change — own device_id + is_container flag
Implements step ③ of Approach B (user-centric device attribution). Each
device reports its OWN device_id and a plain home_user; supersedes PR #165's
device_id injection + home_user namespacing (not carried forward).
- Salvage the in_container() helper into utils.py (checks /.dockerenv,
/run/.containerenv, overlay root mount, cgroup v1 fallback; lru_cached).
Only this helper is brought over from feat/device-id-host-override — not
the injection override or namespacing.
- Add is_container (bool) as a top-level field in the report payload built
by generate_single_tool_report, set from in_container().
- Stable container device_id: when /etc/machine-id is empty/absent, fall
back to a UUID persisted in the home-user's ~/.unbound/ (read if present,
else generate uuid4 + write) instead of the ephemeral hostname, so a
restarted container keeps one device row. Reuses cache._ensure_state_dir()
for robust state-dir resolution (writable fallback) rather than a bare
Path.home(). Machine-id-first behavior unchanged; never raises.
- Tests: is_container present/top-level in report; persisted-UUID fallback
(generate+write, read-existing, unwritable dir, write-failure); in_container
detection with lru_cache clearing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Harden persisted device-id: atomic write, UUID validation, warning logs
Addresses Greptile review on PR #170:
- Atomic write (P1): persist the device-id UUID via tempfile.mkstemp +
os.replace in the same dir, mirroring cache.atomic_write_cache(). A
kill mid-write can no longer leave a partial UUID on disk.
- UUID validation on read (P1): validate the persisted value with
uuid.UUID() and treat a corrupt/truncated/non-UUID value as absent,
regenerating a fresh UUID. Pairs with the atomic write.
- Log levels (P2): failures that cost the container its stable identity
(no state dir, unreadable/corrupt file, failed write, outer catch) now
log at warning; the benign absent-machine-id read stays at debug.
Extends tests/test_container_attribution.py to use a real temp dir so the
atomic write path is exercised for real, and adds corrupt-partial and
garbage-non-UUID regeneration cases plus a write-failure case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (#183)
* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (fixes #1, #3)
Fix #1: the GitHub Copilot (VS Code) MCP extractor's _read_mcp_config called
json.loads() directly despite a docstring claiming it stripped comments, so any
hand-edited mcp.json with // or /* */ comments or a trailing comma threw
JSONDecodeError and silently yielded 0 servers. Now strips comments + trailing
commas before parsing, reusing the existing string-aware helpers.
Fix #3: removed the dead globalStorage/ms-vscode.vscode-github-copilot/mcp.json
fallback. That publisher/extension id does not exist (real ids are
GitHub.copilot / GitHub.copilot-chat) and VS Code never stores MCP config in
extension globalStorage, so the branch could never match a real install.
DRY: relocated _strip_jsonc_comments / _strip_trailing_commas into the shared
mcp_extraction_helpers.py as the single source of truth; macos/copilot_cli
re-exports them for back-compat. Applied across all three OS variants.
Fix #2 (profiles / Insiders / legacy settings.json MCP locations) is
intentionally deferred to a follow-up PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Trim verbose comments on JSONC strippers
Condense the explanatory block comments in mcp_extraction_helpers.py to
concise one-liners; no code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Drop ticket/task-specific references from test comments
Remove WEB-4703 / "fix #1" / "fix #3" labels from the JSONC test module
docstrings and section comments; keep the behavioral descriptions. No test
change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (#185)
* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (fix #2, scoped A+B)
The GitHub Copilot (VS Code) MCP extractor read only the default-profile
Code/User/mcp.json. Since VS Code 1.102 MCP is per-profile, so any user on a
named profile (Code/User/profiles/<id>/mcp.json) had their servers missed; VS
Code Insiders users were also skipped even though detection already counts them
(detect_copilot._VSCODE_USER_DATA_DIRS).
Adds a shared, bounded, crash-safe enumerator enumerate_vscode_mcp_files() that
yields the default mcp.json plus sorted profiles/*/mcp.json for one Code/User
base. Each OS extractor now iterates [Code/User, Code - Insiders/User] through
it and attributes each config to its own dir (str(mcp_file.parent)), so distinct
profiles/variants surface as distinct sources.
Customer-agnostic and additive: a machine with only the default-profile mcp.json
produces byte-identical output. Reuses the JSONC strippers from #183.
Scoped to A+B. Fix C (legacy settings.json mcp key) is intentionally deferred:
1.102 auto-migrates settings.json -> mcp.json, so the remaining population is
small and shrinking, and it would add dedup/precedence complexity that profiles
+ Insiders (disjoint locations) do not need.
Predecessor: #183 (fixes #1 + #3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Log at debug when enumerate_vscode_mcp_files skips on FS error
Addresses Greptile P2: the two except blocks swallowed PermissionError/
OSError silently; log at debug to match the rest of the extraction layer
(still never raises). No behavior change to returned files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable (#186)
* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable
On a shared-HOME macOS host where discovery runs under two uids -- classically a
root MDM agent and the login user on the same machine (the failing host is an EC2
Mac) -- discovery-cache.json is written 0600 by whichever uid runs first. The other
non-root uid then gets PermissionError [Errno 13] reading it. Surfaced as Sentry
DISCOVERY-TOOL-SCRIPT-11.
_ensure_state_dir() already falls back to a per-uid /var/tmp/unbound-{uid} dir, but
_try_state_dir() only probed the *directory's* writability, never whether an existing
discovery-cache.json was *readable*. A home whose dir is writable but whose cache file
is a foreign-owned 0600 was accepted as healthy, then read_cache() raised EACCES on it.
Probe the cache file's readability too: a candidate holding a cache file this uid
cannot read is rejected, so the resolver falls through to the per-uid temp dir -- the
same uid-namespacing utils._get_queue_file_path() already uses for the queue file.
os.access() uses the real uid, so root (which can read any file) keeps using its own
home cache unchanged, leaving root/all-users scans intact.
Scope: this fixes the cross-uid collision and the file-readability probe gap.
Downgrading the *expected* permission warning that read_cache/atomic_write_cache emit
to Sentry is tracked separately and intentionally not included here.
Fixes DISCOVERY-TOOL-SCRIPT-11
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4702: surface fallback reason in state-dir warning; condense probe comment
Address PR review: _ensure_state_dir's fallback warning now includes
last_lock_error, so a fall-back to the per-uid temp dir logs *why* the home dir
was rejected (unreadable cache vs. unwritable dir vs. OSError) before it is
cleared. Also condense the readability-probe comment to a single line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4702: trim verbose comments in the cache-fallback test
Condense the readability-fallback test's comments to match the source cleanup.
No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4707: layer Cursor permissions.json as a per-field override on the state.vscdb read (#191)
* WEB-4707: layer Cursor permissions.json as per-field override on SQLite read
Cursor added a file-based permissions.json (~/.cursor + <workspace>/.cursor)
that overrides the in-app/SQLite allowlists. Layer it on top of the existing
state.vscdb (composerState) read as a per-field override, collapsing into one
effective backend record.
- Per-field override: a file-defined field replaces the SQLite-derived value;
fields are independent; SQLite stays the sole source for useYoloMode / run
mode / modes4 / enabledMcpServers (no permissions.json equivalent).
- User + workspace arrays concatenate within a field (autoRun nested arrays
merged independently).
- JSONC-tolerant parse; any read/parse failure falls back to SQLite-only.
- File-absent path is byte-identical (_parse_composer_state untouched; override
is a post-processing layer in _apply_permissions_json_override).
mcpAllowlist -> mcp_tool_allowlist; terminalAllowlist -> Bash(cmd*) prefix rules
(distinct from the SQLite Bash(cmd *) command rules); autoRun -> raw_settings
verbatim for the permissions classifier. No gateway-data / unbound-fe changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4707: address elite-PR-review findings
- C1 (critical): scope macOS workspace walk to user_home (was rooting at /),
matching the Linux/Windows siblings. Prevents cross-user workspace
permission leakage under root/MDM and avoids a full-filesystem walk.
- W3: preserve unknown autoRun sub-keys (shallow-merge across files,
last-file-wins) while still concatenating the documented
allow_instructions/block_instructions arrays.
- I4: skip symlinked intermediate dirs in the Windows walk (junction-loop
hardening; a .cursor dir that is itself a symlink still matches).
- Tests: M5/T5 pin the intentional empty-array override-to-empty; A7 pins
autoRun unknown-subkey preservation. _parse_composer_state still untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4707: close test-coverage gaps from 2nd-pass review
Test-only additions (no production change):
- D2: _dedupe_preserve_order unhashable (dict) fallback branch
- T6: terminalAllowlist drops non-string/empty entries (no Bash(*) , no raise)
- W3/W4: Windows _walk_for_permissions real-walk coverage (workspace file
applied; global ~/.cursor not double-counted) — the one new path not
backed by a pre-tested shared helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4707: log info when permissions.json override is applied
Addresses greptile P2 (success-path diagnosability): emit an info log naming
which fields (mcp/terminal/autorun) the override replaced. Placed after the
no-file early-return, so the byte-identical file-absent path stays silent and
unchanged. Stdlib logging only (no new deps).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4718: use raw docstrings to silence invalid-escape SyntaxWarnings (#190)
* fix(discovery): detect home-rooted project-scope .mcp.json files (#166)
* fix(discovery): detect home-rooted project-scope .mcp.json files
The Claude Code project-scope MCP walk skipped a `.mcp.json` located
directly in a user's home directory (project root == home, e.g.
C:\Users\<u>\.mcp.json, /Users/<u>/.mcp.json, /home/<u>/.mcp.json).
`is_home_dotdir_descendant` — intended to skip the *contents of* hidden
home tool dirs (~/.cursor/, ~/.codex/) — also matched a home-rooted leaf
`.mcp.json`, so its servers were never reported. On a real device a
user-scope server in ~/.claude.json was detected while project-scope
servers in ~/.mcp.json (e.g. policycenter, playwright) were missed.
Fix: the walk's file branch now tests the file's parent directory
(`is_home_dotdir_descendant(entry.parent)`). A home-rooted `.mcp.json` is
read, while a `.mcp.json` inside a hidden home tool dir (~/.cursor/.mcp.json)
stays skipped. The directory-recursion branch and the sibling
directory-based walks (generic + skills) are intentionally unchanged.
Purely additive: for any path with >=5 segments old and new are identical;
only the 4-segment home-rooted leaf flips skip->read. No path flips
read->skip, so no previously-detected config can disappear.
Add tests/test_mcp_home_rooted_config.py: predicate regression tests
(POSIX + Windows-drive shapes), a walk-level test asserting the file branch
consults entry.parent (not the file), and a smoke test for normal projects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(discovery): union MCP servers on path collision + cover Copilot CLI
Addresses principal-engineer review of the home-rooted .mcp.json fix.
H1: un-skipping a home-rooted ~/.mcp.json makes it emit a project entry
whose path == the home dir, which can collide with the ~/.claude.json
projects[<home>] (local-scope) entry. The merge functions overwrote
mcpServers by path (last-writer-wins), silently dropping one source's
servers. Add AIToolsDetector._union_mcp_servers (dedupe by name,
first/higher-precedence wins) and apply it at all three merge sites:
_merge_claude_mcp_configs_into_projects, _merge_mcp_configs_into_projects,
and the Copilot CLI inline merge. Also closes the pre-existing sub-folder
collision and stops additionalMcpData from clobbering an existing entry.
H2: Copilot CLI's Workspace .mcp.json uses the same project-scope walk, so
the home-rooted fix applies to it too; its inline merge now unions as well.
Tests: union helper, two-source home collision through the real Claude
merge, default-merge union, single-source-unchanged, Copilot shared-walk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: detect built-in VS Code Copilot (all OS) + macOS Codex rules crash — direct to main (#169)
* fix(macos/github-copilot): detect built-in VS Code Copilot so its MCPs surface
VS Code now ships GitHub Copilot / Copilot Chat as BUILT-IN extensions in the
app bundle, which never appear in the per-user ~/.vscode/extensions/extensions.json
the detector reads. So users on built-in Copilot were never detected, and their
VS Code MCP servers (Code/User/mcp.json) were silently skipped.
_detect_vscode_for_user now falls back to scanning the VS Code app bundle's
built-in `copilot`/`copilot-chat` extension when no marketplace extension is
present, reading the version from package.json. Gated on the user actually
having a Code/User data dir so a machine-wide install isn't attributed to
unrelated users in a root scan. Detected "GitHub Copilot (VS Code)" then routes
through the existing MCP extractor, surfacing Code/User/mcp.json servers.
Verified on a real macOS box: built-in Copilot 0.51.0 detected -> 3 user MCP
servers (github-mcp-server, context7, playwright-mcp) extracted.
Also adds a regression test for the Codex should_process_directory('/' branch)
arg-count crash (the code fix already landed on staging; this guards it).
Full suite 835 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* review: log built-in Copilot detection outcomes + document single-result return
Addresses PR #169 review (4/5):
- _detect_vscode_builtin_copilot now logs all three outcomes (no VS Code data
dir, built-in found w/ version+path, VS Code-but-no-built-in) at debug, so an
admin scan resolving from a non-obvious app bundle is reconstructable.
- Documents the intentional at-most-one-result contract: one detection suffices
to trigger downstream rules/MCP extraction; built-in Copilot bundles chat in
the same `copilot` extension, so a second `copilot-chat` row would only
double-process and duplicate MCP servers (unlike the marketplace path where
the two are separate installs).
No behavior change. Detection tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(win/linux github-copilot): detect built-in VS Code Copilot (parity with macOS)
Extends the macOS built-in Copilot detection (this PR) to Windows and Linux, so
users on built-in VS Code Copilot — and their VS Code MCP servers
(%APPDATA%\Code\User\mcp.json / ~/.config/Code/User/mcp.json) — are no longer
missed on those OSes either.
- linux/github_copilot: _detect_vscode_for_user falls back to the VS Code
install tree (deb/rpm, /opt, snap; stable + Insiders) when no marketplace
extension is present, gated on a ~/.config/Code/User data dir.
- windows/github_copilot: same fallback over per-user (LocalAppData\Programs)
and system (Program Files) installs, gated on %APPDATA%\Code\User. Also fixes
an early-return that skipped detection entirely when .vscode\extensions was
absent (the exact built-in-only case).
- Both log detection outcomes at debug and return at most one entry (a 2nd
copilot-chat row would only duplicate the same MCP servers).
Adds Linux + Windows detection tests. Full suite 839 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* harden: guard non-dict package.json in built-in Copilot version read (audit P2)
A bundled copilot/package.json that parses as valid JSON but isn't an object
(array/string/number) made data.get("version") raise AttributeError, which
escapes the per-user loop and aborts detect_copilot() mid-iteration — silently
dropping ALL Copilot results (marketplace + built-in, every user) for the run.
Add an isinstance(data, dict) guard in _read_extension_version (macOS/Linux) and
the Windows built-in version read. Regression test with a non-dict package.json.
Full suite 840 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(macos/codex): pass root_path to should_process_directory (silent 0-rules crash)
main lacks the staging-only fix (#163): should_process_directory(dir_path) was
called with one arg but the helper requires (directory, root_path), raising a
TypeError on every '/' scan that was swallowed into 0 Codex project rules.
Included here so this main-targeted release carries both the fix and its
regression test (test_codex_rules_extraction.py).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(codex): exercise the real should_process_directory (not a stub)
Audit note: the regression test stubbed should_process_directory and only
asserted call arity, so it never exercised the real TypeError. Call THROUGH to
the real helper instead — a future signature/arity regression now raises the
actual production TypeError in the test (verified: fails with "missing 1
required positional argument: 'root_path'" when reverted to the one-arg call),
while still pinning the (directory, root_path) contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(github-copilot vscode): prove built-in fallback is purely additive
Add per-OS regression tests asserting that when a MARKETPLACE Copilot extension
is present, _detect_vscode_for_user returns it and the new built-in fallback is
never invoked (spy.assert_not_called) — locking in that existing detection
behavior is unchanged and the built-in path only runs when nothing was found.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(codex): make '/'-branch test cross-platform (fix Windows CI)
The codex regression test drove the macOS extractor's root_path==Path('/')
branch and asserted AGENTS.md was found. On Windows CI a C:\ temp path can't be
made relative to '/' (the walk's relative_to raises ValueError, skipping items),
so the file-finding assertion failed there — a test-only POSIX assumption, not a
product bug (the macOS extractor's '/' scan is POSIX-only).
- Keep the cross-platform contract check (should_process_directory called with
(directory, root_path)); guard the AGENTS.md assertion to non-Windows.
- Add a cross-platform test exercising the REAL helper: one-arg call raises
TypeError (the bug), two-arg returns bool — no '/' walk involved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)" (#172)
* fix(github-copilot vscode): label built-in as "GitHub Copilot Chat (VS Code)"
Follow-up to the built-in detection (#169). VS Code consolidates Copilot into the
`copilot` extension folder, whose manifest is name="copilot-chat",
displayName="GitHub Copilot Chat" ("AI chat features powered by Copilot") — it's
the Copilot Chat extension, and the one that consumes mcp.json. The built-in
detection was hardcoding "GitHub Copilot (VS Code)", mislabeling Chat as the
inline-completions product.
Derive the name from the bundle's package.json: name contains "copilot-chat"
(or displayName contains "chat") -> "GitHub Copilot Chat (VS Code)" (matching the
marketplace github.copilot-chat mapping); a plain "copilot" stays
"GitHub Copilot (VS Code)". Marketplace paths unchanged. Per-OS tests updated +
a plain-copilot generic case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* review: tighten chat heuristic + add Linux/Windows plain-copilot tests
Addresses Greptile (4/5) on #172:
- Narrow the displayName check from "chat" to "copilot chat" in all 3 detectors,
so a hypothetical future variant like "GitHub Copilot (chat enabled)" isn't
mislabeled as Chat. Still matches the real displayName "GitHub Copilot Chat".
- Add the plain-copilot generic-label test to the Linux and Windows classes
(was macOS-only), so all 3 platforms cover both the chat and plain branches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface (#173)
* feat(github-copilot): attach shared ~/.copilot skills to the VS Code Copilot surface
An IDE-only GitHub Copilot user's shared ~/.copilot/skills were read by
nobody: the CLI skills extractor owns ~/.copilot but the CLI isn't detected
(skills/ is a SHARED marker excluded from CLI detection by #164), and the IDE
Copilot branch read nothing from ~/.copilot + has no skills extractor. This
enriches the detected VS Code Copilot row with the shared skills it actually
consumes (VS Code Agent Skills docs) — EXTRACTION-ONLY; detection and the #164
markers are untouched, so it cannot re-introduce the CLI false positive.
- S6: memoized _get_copilot_cli_skills() — one filesystem walk per scan,
shared by the CLI + VS Code branches; CLI output byte-identical.
- S2: _set_canonical_vscode_copilot() picks ONE VS Code row (prefer Chat),
computed from the full detected list; only that row carries skills.
- S5: user-scope skills keyed by each skill's own owner-home (from file_path)
so multi-user/MDM scans don't leak skills across users.
- JetBrains excluded; Linux a graceful no-op (no CLI skills extractor).
- copilot-instructions.md + mcp-config.json stay CLI-only; ~/.copilot
instructions deferred to a follow-up.
Scope: skills only. detect_copilot.py / copilot_cli.py / backend untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot): make skill owner-home derivation OS-independent
_copilot_skill_owner_home ran the skill file_path through pathlib and
returned str(parent.parent). On a Windows interpreter, pathlib re-emits a
POSIX-style path with backslashes (str(WindowsPath('/Users/a')) ->
'\Users\a'), so the projects_dict key no longer matched the raw
forward-slash keys used everywhere else — failing the per-user keying
assertions on Windows CI (test_github_copilot_vscode_skills, 3 cases).
Derive the owner home by string-slicing at the .copilot/.agents marker
instead, preserving the input's separator style on any interpreter.
macOS and native-Windows output are byte-identical to before; only the
cross-OS (POSIX-path-on-Windows) case is corrected. Detection, the CLI
branch, copilot_cli.py and detect_copilot.py are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot): address PR review — surface swallowed skills failure + dedup user skills
Two greptile review findings on the VS Code Copilot skills enrichment:
- P2: _get_copilot_cli_skills swallowed extraction failures at DEBUG, but the
accessor is memoized so the CLI branch's existing WARNING never fired for a
walk failure — invisible at the prod INFO floor. Upgraded to WARNING, matching
the sibling skills-extraction error log.
- P1: user-scope skills were appended without dedup while project-scope skills
10 lines above call _deduplicate_project_items. Dedup the owner-home bucket
to match (by file_path). IDE branch only; CLI branch output unchanged.
Adds test_duplicate_user_skills_deduped_on_owner_home. The third finding
(per-sub-flow observability metric) was declined on the PR — inconsistent with
the sibling rules/MCP/permissions sub-steps, out of scope.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot-cli): demote hooks/ to SHARED — Unbound's own MDM hook caused fleet-wide phantom CLI (#174)
* fix(copilot-cli): demote hooks/ to a SHARED marker (Unbound's own MDM hook creates it)
#164 split ~/.copilot markers into STRONG (CLI-exclusive) vs SHARED, but kept
`hooks/` as STRONG. It is NOT CLI-exclusive: Unbound's OWN MDM onboarding
(websentry-ai/setup copilot/hooks/mdm/setup.py) runs for EVERY onboarded device
and does `(~/.copilot/hooks).mkdir(parents=True)` then writes unbound.json +
unbound.py — creating ~/.copilot/hooks/ from scratch on machines that never had
the CLI. So `hooks/` alone triggered a phantom "GitHub Copilot CLI" install
fleet-wide.
Confirmed in prod: device D2FJV74J5Q / user gowshik — ~/.copilot held only
hooks/unbound.json, `copilot --version` = not installed, no config/permissions.
Fix mirrors #164's ide/ demotion: move `hooks` STRONG->SHARED so it can never
alone declare a CLI install (it still enriches a real install). False-negative-
safe: a genuine CLI always also has a strong marker (config.json /
session-store.db / logs/). Detection of real installs is unchanged.
Adds regression tests: hooks/-only ~/.copilot is suppressed (gowshik repro), and
a real install with hooks/ + a strong marker is still detected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* address greptile review: complete the SHARED-marker docstring + Windows hooks guard
Two non-blocking gaps flagged on the PR:
1. `_copilot_dir_has_shared_artifact` docstring enumerated a stale shared-marker
list that omitted both `ide/` (since #164) and the newly demoted `hooks/`, and
wrongly attributed all shared markers to the IDE. Rewrote it to list all six
(skills/agents/instructions/copilot-instructions.md/ide/hooks) with their true
origins: IDE-read, IDE-written (ide/), and Unbound-MDM-written (hooks/).
2. The Windows detection test class had no hooks-specific assertion. Added
`test_hooks_dir_alone_not_detected` to the existing TestWindowsCopilotCliDetection
so the SHARED demotion is guarded on Windows too — catches a future regression
if the MacOSCopilotCliDetector inheritance is ever broken.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot-cli): stop per-user over-attribution + extractor over-collection (#175)
Two independent extraction/attribution bugs found alongside the Copilot CLI
phantom-detection work (#164/#174). Detection and marker sets are untouched.
FIX #2 — per-user over-attribution. The CLI's install_path is a per-user
~/.copilot owned by exactly one user, but main()'s per-user loop re-emits every
detected tool for every OS user. filter_tool_projects_by_user scopes a tool's
projects/permissions to the user but never rewrites install_path, so a second
user (e.g. gowshik_2) who never had ~/.copilot still got a phantom "GitHub
Copilot CLI" row pointing at gowshik's home with 0 projects. Add an ownership
gate at the per-user emit site (CLI only): emit iff the user owns the detected
install OR the filter produced data for them. Extract the path-normaliser to a
module-level _normalise_path shared with filter_tool_projects_by_user (DRY).
Scoped to the CLI — IDE tools legitimately share a machine-wide install_path.
FIX #3 — extractor over-collection. The rules/skills project walks descended
into OTHER tools' per-user config dirs and their installed-extension packages
(e.g. ~/.antigravity/extensions/<pkg>/.github/instructions/*), mis-attributing
those bundled files to Copilot CLI. Add traverses_other_tool_config_dir() and
skip those dirs in both the rules walk (macOS + Windows _should_skip) and the
skills walk — while still allowing the shared .claude/.agents skill dirs a real
repo root legitimately carries (Agent Skills convention). Also dedupe repo-root
rule files by realpath + content so an AGENTS.md symlinked to / copied as
CLAUDE.md emits once. NOTE: CLAUDE.md/GEMINI.md remain collected — the official
GitHub Copilot CLI custom-instructions reference confirms the CLI reads them at
the repo root; only the symlink/copy double-count is removed.
Tests: tests/test_copilot_cli_per_user_attribution.py (12) and
tests/test_copilot_cli_overcollection.py (10). Full suite 902 green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs (H2/H4/H5) (#176)
* fix(copilot): per-user version probe + documented VS Code instructions/prompts dirs
Three doc-verified GitHub Copilot discovery gaps (H2, H4, H5). H3 from the same
audit was REFUTED against official docs and is intentionally excluded — see PR
body. Detection/markers and MCP extraction are untouched.
H2 (CLI) — version="unknown" on root/MDM scans. get_version() probed a bare
`copilot` on the scanner's PATH; root's PATH lacks the per-user install, so the
version always read "unknown" on MDM all-users scans (an existing TODO flagged
this). Resolve the per-user binary from self.user_home first — ~/.local/bin,
~/.bun/bin, ~/.nvm/versions/node/*/bin (macOS, X_OK-checked like
find_claude_binary_for_user); AppData/Roaming/npm/copilot.cmd, .local/bin and
.bun/bin .exe (Windows, shell=True for the .cmd shim) — then fall back to the
bare-PATH probe (zero regression for the running-user case). Best-effort; still
degrades to "unknown".
H4 (VS Code) — wrong instructions dir. Read the documented
.github/instructions/**/*.instructions.md (recursive, depth-gated) instead of
the undocumented, over-broad .github/copilot/*.md. Removing the legacy path is a
deliberate collection-scope reduction (consistent with #175's anti-over-collection
direction); pinned by a negative test.
H5 (VS Code) — prompt files never collected. Collect *.prompt.md from project
.github/prompts/ and the user Code/User/prompts/ dir (already opened but globbed
only *.instructions.md). Prompt files are emitted as ordinary project/user-scoped
rule dicts with NO extra fields, because the backend silently discards any rule
carrying a non-allowlisted key — locked down by test_prompt_rule_has_only_allowed_fields.
find_github_copilot_project_root generalized to walk to the nearest .github
ancestor (serves nested instructions + prompts) without regressing the
prompts/intellij/AGENTS.md branches. Windows mirrors macOS.
Tests: new tests/test_github_copilot_instructions_prompts.py + extended
test_copilot_cli_discovery.py and test_scanning_enhancements.py. Full suite 916
pass; the 1 failing test (test_main_cli_with_queue_drain) is a pre-existing
environmental flake that also fails on clean main.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(copilot): gate POSIX-only version stub tests to fix Windows CI
test_version_resolved_from_local_bin_stub / _from_nvm_stub create a #!/bin/sh
executable stub and assert the macOS detector probes it. Windows can't exec a
shebang script, so get_version() returned None there (Windows CI red). The
exec path is inherently POSIX; the Windows per-user-binary path is already
covered portably by test_version_probed_from_per_user_npm_shim (which mocks
subprocess.run). Gate the two stub-exec tests with skipUnless(os.name=="posix").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot): resolve newest nvm Node version for CLI binary (greptile P2)
_resolve_copilot_binary iterated nvm version dirs in arbitrary glob order, so a
user with multiple nvm-managed Node versions (each with a copilot install) could
resolve a stale one. Sort by NUMERIC (major,minor,patch) parsed from the dir
name, newest first — note a plain string sort (greptile's suggestion) orders
"v9" after "v10"; the numeric key fixes that. Also makes a re-scan deterministic.
Adds a POSIX-gated test (macOS resolver) asserting v18 wins over v10/v9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Add Sync Staging with Main workflow (#179)
Keeps staging in sync with main after each release to main.
Triggers on push to main (and workflow_dispatch).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot-vscode): read .claude/rules + user ~/.copilot|.claude instructions; scope MCP per IDE surface (#178)
* fix(copilot-vscode): read .claude/rules + ~/.copilot|.claude user instructions; scope MCP per IDE surface
Two related GitHub Copilot VS Code Chat discovery fixes (macOS + Windows;
Linux tracked separately). Verified against the official VS Code Copilot
custom-instructions docs.
(1) Rules completeness — the VS Code rules extractor now reads the three
documented "Default file location" custom-instruction sources it was missing:
- workspace .claude/rules/**/*.md (Claude format)
- user ~/.copilot/instructions/**/*.instructions.md
- user ~/.claude/rules/**/*.md
find_github_copilot_project_root now resolves the nearest .github/.claude/.copilot
ancestor (keys these to the right repo root / user home) without regressing the
prompts/intellij/AGENTS.md branches. New _extract_claude_rules helper +
add_user_rules refactor. Guards: skip the user-home ~/.claude (collected as user
scope, no double-count since add_rule_to_project doesn't dedupe) and skip
other-tool config dirs / installed extension packages (traverses_other_tool_config_dir).
(2) MCP identity scoping — extract_mcp_config was identity-blind: it unioned
VS Code global + JetBrains global + workspace .vscode/mcp.json and returned that
to EVERY Copilot row, so on a machine with both IDE Copilots each row showed the
other's servers. Now gated by tool_name: a VS Code row gets VS Code global +
workspace; a JetBrains row gets JetBrains global only; tool_name=None keeps the
legacy union (back-compat). Pure narrowing — single-IDE users unaffected. Call
site passes the detected row name. Mirrors the already identity-aware rules
extractor.
Tests: all regression coverage lives in the one focused file
tests/test_github_copilot_instructions_prompts.py (.claude/rules project +
allowlist guard + extension-dir-skipped + user-home-not-double-collected; user
~/.copilot/instructions + ~/.claude/rules; MCP identity scoping). Full suite
green; the lone failure (test_main_cli_with_queue_drain) is a pre-existing
environmental flake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(copilot-vscode): Linux MCP tool_name param (regression) + user-rules depth guard
Follow-up to the review of this PR.
- CRITICAL: the call site passes extract_mcp_config(tool_name=...) for all OSes,
but the Linux GitHub Copilot MCP extractor's signature was still
extract_mcp_config(self) -> TypeError (swallowed) -> Linux Copilot MCP servers
returned empty (a regression vs main). Linux now accepts tool_name and applies
the same VS Code / JetBrains surface gating as macOS/Windows.
- add_user_rules now depth-gates its globs (~/.copilot/instructions/**,
~/.claude/rules/**) with MAX_SEARCH_DEPTH, matching _extract_claude_rules
(macOS + Windows).
- Windows user-rules debug log relabeled "Found VS Code Copilot user rule" (it
now also covers ~/.copilot/instructions and ~/.claude/rules).
Note: the MCP JetBrains gate stays the simple `not is_vscode` (correct for every
github_copilot surface the detector emits today); a speculative positive
predicate was considered and dropped as over-engineering per principal review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (#183) (#184)
* WEB-4703: VS Code Copilot MCP — JSONC-tolerant parse + remove dead globalStorage fallback (fixes #1, #3)
Fix #1: the GitHub Copilot (VS Code) MCP extractor's _read_mcp_config called
json.loads() directly despite a docstring claiming it stripped comments, so any
hand-edited mcp.json with // or /* */ comments or a trailing comma threw
JSONDecodeError and silently yielded 0 servers. Now strips comments + trailing
commas before parsing, reusing the existing string-aware helpers.
Fix #3: removed the dead globalStorage/ms-vscode.vscode-github-copilot/mcp.json
fallback. That publisher/extension id does not exist (real ids are
GitHub.copilot / GitHub.copilot-chat) and VS Code never stores MCP config in
extension globalStorage, so the branch could never match a real install.
DRY: relocated _strip_jsonc_comments / _strip_trailing_commas into the shared
mcp_extraction_helpers.py as the single source of truth; macos/copilot_cli
re-exports them for back-compat. Applied across all three OS variants.
Fix #2 (profiles / Insiders / legacy settings.json MCP locations) is
intentionally deferred to a follow-up PR.
* Trim verbose comments on JSONC strippers
Condense the explanatory block comments in mcp_extraction_helpers.py to
concise one-liners; no code change.
* Drop ticket/task-specific references from test comments
Remove WEB-4703 / "fix #1" / "fix #3" labels from the JSONC test module
docstrings and section comments; keep the behavioral descriptions. No test
change.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (#185) (#188)
* WEB-4703: VS Code Copilot MCP — read named profiles + Insiders (fix #2, scoped A+B)
The GitHub Copilot (VS Code) MCP extractor read only the default-profile
Code/User/mcp.json. Since VS Code 1.102 MCP is per-profile, so any user on a
named profile (Code/User/profiles/<id>/mcp.json) had their servers missed; VS
Code Insiders users were also skipped even though detection already counts them
(detect_copilot._VSCODE_USER_DATA_DIRS).
Adds a shared, bounded, crash-safe enumerator enumerate_vscode_mcp_files() that
yields the default mcp.json plus sorted profiles/*/mcp.json for one Code/User
base. Each OS extractor now iterates [Code/User, Code - Insiders/User] through
it and attributes each config to its own dir (str(mcp_file.parent)), so distinct
profiles/variants surface as distinct sources.
Customer-agnostic and additive: a machine with only the default-profile mcp.json
produces byte-identical output. Reuses the JSONC strippers from #183.
Scoped to A+B. Fix C (legacy settings.json mcp key) is intentionally deferred:
1.102 auto-migrates settings.json -> mcp.json, so the remaining population is
small and shrinking, and it would add dedup/precedence complexity that profiles
+ Insiders (disjoint locations) do not need.
Predecessor: #183 (fixes #1 + #3).
* Log at debug when enumerate_vscode_mcp_files skips on FS error
Addresses Greptile P2: the two except blocks swallowed PermissionError/
OSError silently; log at debug to match the rest of the extraction layer
(still never raises). No behavior change to returned files.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable (#186) (#189)
* WEB-4702: fall back to per-uid state dir when home discovery-cache.json is unreadable
On a shared-HOME macOS host where discovery runs under two uids -- classically a
root MDM agent and the login user on the same machine (the failing host is an EC2
Mac) -- discovery-cache.json is written 0600 by whichever uid runs first. The other
non-root uid then gets PermissionError [Errno 13] reading it. Surfaced as Sentry
DISCOVERY-TOOL-SCRIPT-11.
_ensure_state_dir() already falls back to a per-uid /var/tmp/unbound-{uid} dir, but
_try_state_dir() only probed the *directory's* writability, never whether an existing
discovery-cache.json was *readable*. A home whose dir is writable but whose cache file
is a foreign-owned 0600 was accepted as healthy, then read_cache() raised EACCES on it.
Probe the cache file's readability too: a candidate holding a cache file this uid
cannot read is rejected, so the resolver falls through to the per-uid temp dir -- the
same uid-namespacing utils._get_queue_file_path() already uses for the queue file.
os.access() uses the real uid, so root (which can read any file) keeps using its own
home cache unchanged, leaving root/all-users scans intact.
Scope: this fixes the cross-uid collision and the file-readability probe gap.
Downgrading the *expected* permission warning that read_cache/atomic_write_cache emit
to Sentry is tracked separately and intentionally not included here.
Fixes DISCOVERY-TOOL-SCRIPT-11
* WEB-4702: surface fallback reason in state-dir warning; condense probe comment
Address PR review: _ensure_state_dir's fallback warning now includes
last_lock_error, so a fall-back to the per-uid temp dir logs *why* the home dir
was rejected (unreadable cache vs. unwritable dir vs. OSError) before it is
cleared. Also condense the readability-probe comment to a single line.
* WEB-4702: trim verbose comments in the cache-fallback test
Condense the readability-fallback test's comments to match the source cleanup.
No behavior change.
---------
(cherry picked from commit 87114e80bc9ffae25c6644ebb654eda8e9c2f9a8)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* WEB-4718: use raw docstrings to silence invalid-escape SyntaxWarnings
Windows-path docstrings (AppData\Roaming, %APPDATA%\JetBrains,
%USERPROFILE%\.claude, etc.) used non-raw string literals, so \R \J \.
\) parsed as invalid escape sequences. Python 3.12+ surfaces these as
visible SyntaxWarnings at compile time; because install.sh git-clones a
fresh copy per run (no cached .pyc) they print on every discovery run.
The factory imports all platform modules unconditionally, so they fire
on macOS/Linux too, not just Windows.
Prefix the 11 affected docstrings with r"""; __doc__ content is
byte-identical (these paths held no intended escape sequences), so the
change is cosmetic with no behavioral effect.
Verify: python3.12 -W error::SyntaxWarning -m compileall scripts/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com>
Co-authored-by: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com>
Co-authored-by: Pugazhendhi <pugazhendhi@unboundsecurity.ai>
Co-authored-by: MohamedAklamaash <aklamaash78@gmail.com>
Co-authored-by: vishnu <vishnuvinod072@gmail.com>
Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com>
Co-authored-by: Mohamed Aklamaash M.R <111295679+MohamedAklamaash@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com>
* [WEB-4725] Isolate the test retry-queue + CLI home so QUEUED-DEVICE can't leak to prod (#192)
* Isolate the test retry-queue + CLI home so QUEUED-DEVICE can't leak to prod
A phantom device "QUEUED-DEVICE" reached the production discovery dashboard.
Root cause: integration tests wrote report fixtures to the REAL per-UID retry
queue (/var/tmp/ai-discovery-queue-<uid>.json) via the import-time-cached
utils.QUEUE_FILE constant. An interrupted test left the fixture on disk, and a
later real agent run on the same machine drained it and POSTed it to prod. The
string exists only in the test suite.
Fix (harness-level test isolation; no behavior change for real runs):
- utils._get_queue_file_path() honors an AI_DISCOVERY_QUEUE_FILE env override
(strip + expanduser/expandvars, matching the sibling _resolve_copilot_dir
convention). Remove the import-time QUEUE_FILE constant so save/load/cleanup
resolve the path per-call, including the value-imported cleanup in
ai_tools_discovery.py.
- tests/__init__.py points AI_DISCOVERY_QUEUE_FILE at a throwaway temp file for
the whole session. It lives in __init__.py, not conftest.py, so it arms under
BOTH pytest and the `unittest discover` runner used in CI.
- test_discovery_flow.py: the CLI subprocess gets an isolated HOME so its
~/.unbound state + single-flight lock go to a throwaway dir (stops tests
touching real home state and the lock-contention flakiness). Migrate the two
queue tests off the old QUEUE_FILE monkeypatch; add a regression test that the
env override is honored and the real /var/tmp path is never used.
- Remove the now-redundant tests/conftest.py.
Deferred as separate follow-ups (intentionally out of scope): backend device_id
sentinel denylist, DeviceDiscovery pruning, and the one-off delete of the
existing QUEUED-DEVICE row (this change is preventative, not retroactive).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Trim verbose test comments to concise necessary ones
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address PR review: clean up test temp dir + mkdir queue parent before write
- tests/__init__.py: register atexit cleanup for the session temp queue dir so
it doesn't accumulate in /tmp on long-lived CI machines (greptile r3380308482).
- utils._write_file_secure: mkdir(parents=True, exist_ok=True) before write so a
queue-path override with a missing parent can't silently drop failed reports
(greptile r3380308569).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Accumulate all users' global MCP configs under root (fix multi-user first-match drop) (#195)
* Accumulate all users' global MCP configs under root (fix first-match drop)
On a multi-user macOS/Windows machine scanned as root, the global-MCP config
helpers returned only the FIRST user's config and silently dropped every other
user's. Three helper copies had this `if config: return config`-inside-the-loop
bug:
- extract_global_mcp_config_with_root_support (Cursor, Cursor CLI, Gemini CLI,
Windsurf, Antigravity)
- extract_codex_global_mcp_config_with_admin_support (Codex; TOML)
- opencode's own copy (macOS + Windows)
Claude Code / Cline / Roo Code already accumulated correctly and are untouched.
Each helper now accumulates every user's config into a list, de-duped by the
config's "path" (guards the Windows admin-own-home double-count), and keeps the
admin's own home as a FALLBACK ONLY (`if not configs:`) to preserve today's
single-user-root semantics exactly. The 14 thin callers change
`projects.append(global_config)` -> `projects.extend(self._extract_global_config())`;
`extract_mcp_config` still returns {"projects": [...]}.
Strictly additive: single-user and non-admin machines are byte-for-byte
unaffected (a 1-element list, identical content/ordering); output only grows for
multi-user-root, recovering the previously-dropped users. Nothing reported today
is dropped.
Adds tests/test_multiuser_global_mcp.py (19 cases) covering Cursor (JSON), Codex
(TOML), and opencode (macOS + the divergent Windows variant): both-users-recovered
regression, single-user-unchanged, dedup, and fallback-only/fallback-suppressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Hoist the accumulate+dedup+fallback loop into one helper (no behavior change)
The first-match-drop fix landed the same accumulate + de-dup-by-path +
fallback-only inner loop in four places: the JSON helper
(extract_global_mcp_config_with_root_support), the Codex/TOML helper
(extract_codex_global_mcp_config_with_admin_support), and OpenCode's own
copy on macOS and Windows. Four copies of the same subtle loop is exactly
how the bug got duplicated in the first place.
Extract that loop into a single private helper,
_accumulate_per_user_with_fallback(user_homes, global_config_path,
reader_fn, tool_name, parent_levels), and have all four call sites defer to
it. The helper has exactly two seams: the already-resolved user_homes list
(empty when not admin) and the tool's reader_fn.
Detection deliberately stays at each call site, NOT unified:
- the JSON helper keeps its platform/is_admin block + _iter_admin_user_homes
- Codex reproduces its own user-dir filter verbatim
([d for d in users_dir.iterdir() if d.is_dir() and not d.name.startswith('.')])
- each OpenCode extractor keeps its own admin check
The helper's docstring and a call-site comment record WHY it must never grow
a Linux branch: Linux uses a separate per-user extractor, and an
accumulated-or-first-match walk here would silently drop users there.
Behavior-preserving: the extracted loop is byte-for-byte the prior inline
logic, so single-user and non-admin machines are unaffected and the
multi-user-root recovery is identical. Dead
extract_global_mcp_config_with_root_support imports are dropped from both
OpenCode extractors.
The three OpenCode test methods that whole-symbol-rebind a module-local
Path shim now also patch helpers.Path, since relative_to(Path.home())
resolution moved into the shared helper's module (the Codex/Cursor tests
mutate pathlib.Path.home directly, so they need no change). No test
behavior or assertions change; no new tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Keep per-user exists() inside the try (restore byte-identical skip-one-user)
The loop hoist in the previous commit narrowed the per-user try to cover only
relative_to(), leaving Path.exists() outside the guard. All four prior inline
loops wrapped exists() inside the try, so a single user's filesystem error
(ValueError, or an OSError such as EACCES from Path.exists() on a Python 3.9
locked / NFS-mounted home -- exists() re-raises anything not ENOENT/ENOTDIR/
EBADF/ELOOP) skipped only that user. Outside the guard the error instead
propagates out of the helper; the orchestrator catches it but drops the WHOLE
tool's MCP config, losing every other user on the machine -- a regression in
exactly the multi-user-root case this PR hardens.
Move exists() back inside the per-user try so the helper is byte-identical to
all four originals: a per-user path/permission error skips only that user.
Adds a deterministic lock-in test that drives the helper with one user whose
exists() raises PermissionError (iterated first) and asserts the other user
still survives -- it errors against the pre-fix exists()-outside-try shape.
Found by principal-engineer review of the refactor commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Merge pull request #196 from websentry-ai/WEB-4755
WEB-4755: Discovery self-timeout, signal cleanup, and dead-PID lock recovery
---------
Co-authored-by: Sumit Badsara <sumit@unboundsecurity.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Sumit Badsara <sumitbadsara.dev@gmail.com>
Co-authored-by: NandaPranesh <106886030+anonpran@users.noreply.github.com>
Co-authored-by: Aakash Velusamy <aakashvpsgtech@gmail.com>
Co-authored-by: MohamedAklamaash <aklamaash78@gmail.com>
Co-authored-by: vishnu <vishnuvinod072@gmail.com>
Co-authored-by: Vishnu <79318686+zeus-12@users.noreply.github.com>
Co-authored-by: Mohamed Aklamaash M.R <111295679+MohamedAklamaash@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Vignesh Subbiah <51325334+vigneshsubbiah16@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements step ③ of Approach B (user-centric device attribution) for the coding-discovery-tool. Under Approach B, every host and every container reports its own
device_idand a plainhome_user, attributed to the authenticated API-key owner on the backend — instead of containers masquerading as the host device.This supersedes PR #165 (
feat/device-id-host-override), which diddevice_idinjection +home_usernamespacing. Those are not carried forward. Only thein_container()helper is salvaged from that branch. (PR #165 is left open per operator decision.)Changes
in_container()intoutils.py— best-effort container detection (/.dockerenv,/run/.containerenv, overlay root mount, cgroup-v1 fallback),lru_cached. Theget_device_idinjection override andhome_usernamespacing from Container-aware device identity: inject host device_id + namespace home_user per container #165 are deliberately omitted.is_containertop-level report field — added to the dict built bygenerate_single_tool_report(ai_tools_discovery.py), set fromin_container(). Lives in the report dict only (not inside the tool payload, so it does not affect the tool-level dedup hash). Harmless/ignored on backends that don't yet read it; stored once backend steps ①+② land.device_id(linux/device_id.py) — when/etc/machine-idis empty/absent, fall back to a UUID persisted in the home-user's~/.unbound/(read if present, else generateuuid4+ write) instead of the ephemeral hostname, so a restarted container that mounts a persistent~/.unboundkeeps a single device row instead of exploding into one row per launch. Reusescache._ensure_state_dir()(the repo's canonical state-dir resolver with a writable fallback) rather than a barePath.home()— co-locating the device id next to the API key written byunbound login. Machine-id-first behavior is unchanged; the fallback never raises (returns an unpersisted uuid if the dir is unwritable).Tests
New
tests/test_container_attribution.py(12 tests, all green):is_containerpresent, top-level (not in tool payload), correct value, andhome_usernot namespaced.in_container()detection (dockerenv / containerenv / no-markers), withlru_cacheclearing between toggles.python3 -m pytest tests/ -q→ 842 passed, 1 skipped, 1 pre-existing failure (test_main_cli_with_queue_drain— verified to fail identically on cleanorigin/main, unrelated to this change).🤖 Generated with Claude Code
Greptile Summary
This PR implements Approach B container attribution: every host and container independently reports its own
device_idand plainhome_userrather than containers masquerading as the host. It salvages thein_container()detector from the now-superseded PR #165, adds anis_containertop-level flag to the report payload, and replaces the ephemeral hostname fallback with a UUID persisted under~/.unbound/device-idwhen/etc/machine-idis absent.in_container()helper added toutils.pywith three detection signals (.dockerenv/.containerenv, overlay root mount, cgroup v1 markers),lru_cached for process lifetime.is_containerfield injected at the top level of thegenerate_single_tool_reportoutput (not inside the tool payload, preserving dedup-hash stability) — backward-compatible with backends that don't yet read it.device_idinlinux/device_id.py: atomic write (mkstemp+os.replace), UUID format validation on read with regeneration on corrupt values, andwarning-level logging on all failure branches — addressing all three previous review threads on this file.Confidence Score: 5/5
Safe to merge — all three previously flagged concerns (write atomicity, UUID validation, log levels) are fully addressed and covered by new tests.
The changes are well-scoped: a new top-level report field computed from a cached helper, and a hardened device-id fallback path. The atomic write pattern mirrors the existing cache.atomic_write_cache() exactly, UUID validation correctly rejects corrupt values and regenerates, and all failure branches log at warning level. The 12 new tests exercise the full state machine including corruption, write failures, and cache-clearing between state toggles. No regressions to existing behavior — machine-id still takes precedence, home_user is not namespaced.
No files require special attention.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[extract_device_id - Linux] --> B{machine-id readable\nand non-empty?} B -- Yes --> C[Return machine-id] B -- No --> D[_persisted_device_id] D --> E{cache._ensure_state_dir\nreturns True?} E -- No --> F[warning: no usable state dir\nReturn ephemeral uuid4] E -- Yes --> G{device-id file\nexists and non-empty?} G -- Yes --> H{uuid.UUID validate} H -- Valid --> I[Return persisted UUID] H -- ValueError --> J[warning: corrupt value\nfall through to generate] G -- No --> K[Generate new uuid4] J --> K K --> L{atomic write:\nmkstemp + os.replace} L -- Success --> M[Return new UUID\npersisted on disk] L -- OSError --> N[warning: write failed\nReturn new UUID unpersisted]Reviews (3): Last reviewed commit: "Merge origin/staging: WEB-4662 lock-less..." | Re-trigger Greptile