Skip to content

Approach B: own device_id + is_container flag + stable container id#170

Merged
sumit-badsara merged 6 commits into
stagingfrom
approach-b/is-container-flag
Jun 5, 2026
Merged

Approach B: own device_id + is_container flag + stable container id#170
sumit-badsara merged 6 commits into
stagingfrom
approach-b/is-container-flag

Conversation

@sumit-badsara

@sumit-badsara sumit-badsara commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

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_id and a plain home_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 did device_id injection + home_user namespacing. Those are not carried forward. Only the in_container() helper is salvaged from that branch. (PR #165 is left open per operator decision.)

Changes

  • Salvage in_container() into utils.py — best-effort container detection (/.dockerenv, /run/.containerenv, overlay root mount, cgroup-v1 fallback), lru_cached. The get_device_id injection override and home_user namespacing from Container-aware device identity: inject host device_id + namespace home_user per container #165 are deliberately omitted.
  • is_container top-level report field — added to the dict built by generate_single_tool_report (ai_tools_discovery.py), set from in_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.
  • Stable container device_id (linux/device_id.py) — 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 that mounts a persistent ~/.unbound keeps a single device row instead of exploding into one row per launch. Reuses cache._ensure_state_dir() (the repo's canonical state-dir resolver with a writable fallback) rather than a bare Path.home() — co-locating the device id next to the API key written by unbound 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_container present, top-level (not in tool payload), correct value, and home_user not namespaced.
  • in_container() detection (dockerenv / containerenv / no-markers), with lru_cache clearing between toggles.
  • Persisted-UUID device_id fallback: machine-id precedence preserved; generate+write on first call; read-existing on restart (no regeneration); unwritable state dir returns unpersisted uuid; write-failure still returns the uuid.

python3 -m pytest tests/ -q842 passed, 1 skipped, 1 pre-existing failure (test_main_cli_with_queue_drain — verified to fail identically on clean origin/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_id and plain home_user rather than containers masquerading as the host. It salvages the in_container() detector from the now-superseded PR #165, adds an is_container top-level flag to the report payload, and replaces the ephemeral hostname fallback with a UUID persisted under ~/.unbound/device-id when /etc/machine-id is absent.

  • in_container() helper added to utils.py with three detection signals (.dockerenv/.containerenv, overlay root mount, cgroup v1 markers), lru_cached for process lifetime.
  • is_container field injected at the top level of the generate_single_tool_report output (not inside the tool payload, preserving dedup-hash stability) — backward-compatible with backends that don't yet read it.
  • Stable container device_id in linux/device_id.py: atomic write (mkstemp + os.replace), UUID format validation on read with regeneration on corrupt values, and warning-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

Filename Overview
scripts/coding_discovery_tools/linux/device_id.py Replaces ephemeral hostname fallback with atomic persisted-UUID mechanism; atomic write, UUID validation, and warning-level logging all correctly implemented. Previous review concerns fully addressed.
scripts/coding_discovery_tools/utils.py Adds lru_cached in_container() with three complementary detection signals; each signal is independently guarded with OSError; returns False on non-Linux hosts as expected.
scripts/coding_discovery_tools/ai_tools_discovery.py Minimal change: imports in_container and sets is_container at the report top-level (not in tool payload). Both import paths (relative and absolute fallback) are correctly updated.
tests/test_container_attribution.py New test file covering is_container placement, in_container() detection (including lru_cache clearing), machine-id precedence, generate+write, read-existing, corrupt-regeneration, unwritable-dir, and write-failure paths.
tests/test_copilot_cli_discovery.py Trivial blank-line removal between two test methods; no logic change.

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]
Loading

Reviews (3): Last reviewed commit: "Merge origin/staging: WEB-4662 lock-less..." | Re-trigger Greptile

zeus-12 and others added 4 commits June 2, 2026 22:00
* 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>
@sumit-badsara sumit-badsara force-pushed the approach-b/is-container-flag branch from 1d9b5b2 to 5c2d6ce Compare June 4, 2026 21:25
Comment thread scripts/coding_discovery_tools/linux/device_id.py Outdated
Comment thread scripts/coding_discovery_tools/linux/device_id.py Outdated
Comment thread scripts/coding_discovery_tools/linux/device_id.py
Comment thread scripts/coding_discovery_tools/linux/device_id.py
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>
@sumit-badsara sumit-badsara changed the base branch from main to staging June 5, 2026 15:10
@sumit-badsara sumit-badsara requested a review from a team June 5, 2026 15:10
… fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sumit-badsara sumit-badsara merged commit 5f51cbd into staging Jun 5, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants