Skip to content

Scope Windows Cursor/Windsurf search paths to the user being scanned - #253

Open
anonpran wants to merge 2 commits into
stagingfrom
nanda/windows-profile-sid-filter
Open

anonpran wants to merge 2 commits into
stagingfrom
nanda/windows-profile-sid-filter

Conversation

@anonpran

@anonpran anonpran commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Replaces this PR's original contents. It previously proposed a ProfileList/SID filter for Windows user enumeration; that turned out to be redundant for the observed rows once this fix lands, and carried rollout risk on container-backed profiles. Reasoning in the thread below.

The bug

detect_tool_for_user loops over every profile on the machine and tells each detector which user it is scanning:

detector.user_home = user_home        # user_tool_detector.py:51  ->  C:\Users\Bob

The Windows Cursor and Windsurf detectors discard that and build their candidate list from the scanner's own home:

user_home = Path.home()               # windows/cursor/cursor.py:72, windows/windsurf/windsurf.py:71
return [user_home / "AppData" / "Local" / "Programs" / "cursor", ...]

So every profile on the box is checked against one folder.

Effect

Scan context Path.home() resolves to Result
Logged-in user (Kapil) C:\Users\Kapil Kapil's Cursor recorded against every profile on the box
NT AUTHORITY\SYSTEM C:\Windows\system32\config\systemprofile Nothing found for anyone

Measured in prod: 28 of 42 Windows Cursor/Windsurf inventory rows are cross-user phantoms (20 Cursor, 8 Windsurf, 67%). All install_path values point at a real user's directory and none at systemprofile, so these came from the logged-in variant.

C:\Users\Kapil.Thakur\...\cursor  ->  home_user: Classic .NET AppPool
                                  ->  home_user: dev.xomepro.com
                                  ->  home_user: areagan
C:\Users\gg\...\cursor            ->  home_user: DefaultAppPool
                                  ->  home_user: DevToolsUser
C:\Users\KrishnaGK\...\cursor     ->  home_user: LokeshSM

Both IIS app-pool identities and unrelated real colleagues are credited with someone else's install.

Fix

Use the home the loop already resolved, falling back to current behaviour when it is unset:

- user_home = Path.home()
+ user_home = getattr(self, "user_home", None) or Path.home()

getattr rather than self.user_home is load-bearing, not defensive padding: BaseToolDetector (coding_tool_base.py:36) has no __init__ and never declares user_home, the factory constructs these detectors bare (coding_tool_factory.py:110,194), and three live callers reach detect() without setting it (ai_tools_discovery.py:470, :498, scripts/coding_discovery_tools/test.py:35). self.user_home would raise AttributeError on those paths.

It is also the dominant idiom in this repo: windows/cline/cline.py:70, windows/roo_code/roo_code.py:70, windows/kilocode/kilocode.py:71 and their macOS/Linux twins use the same getattr form, and windows/claude_cowork/claude_cowork.py:109 uses the identical or-chain.

Machine-global entries (C:\Program Files\Cursor) are untouched and still probed, by design.

What it brings

  • Removes the 28 phantom rows, both the service-account and the real-colleague ones.
  • Adds correct detection under SYSTEM/MDM scans, where the current code searches a Windows internal folder.
  • macOS and Linux untouched.

Rollout caveat: install.ps1 never invokes setup-scheduled-scan.ps1, so the existing Windows fleet does not re-scan on a schedule. Until that is fixed these rows will not actually be re-reported and pruned, so treat the inventory correction as pending fleet re-scan rather than automatic.

One narrow, accepted loss: for a scanning user whose profile is not under C:\Users (redirected profile), the pre-fix code found their Cursor via Path.home() and misattributed it to some other profile. Post-fix that row disappears rather than moving. Trading a wrong-owner row for a missing row is the right direction.

Verification

tests/test_windows_cursor_windsurf_user_scope.py, 8 tests across both detectors: candidate paths follow user_home, fall back to Path.home() when unset, machine-global paths stay unscoped, and a detect_tool_for_user cross-user case asserting user A's install is not reported for user B (the defect exactly as it appeared in prod).

Homes are synthetic rather than tempfile-rooted: on Windows %TEMP% lives under Path.home(), so a tempfile root plus a Path.home() negative assertion fails on any real Windows box and passes CI only because GitHub exposes TEMP as an 8.3 short name. Verified by re-running with TMPDIR forced under $HOME.

Mutation-checked: reverting the two production lines fails 4 of the 8. 66 tests pass across this file plus the Cursor/Windsurf residue suites, test_windows_cline_roo_antigravity_scope and test_user_filtering. All six CI legs green, including windows-latest on 3.9/3.11/3.12.

The affected rows are identifiable in advance, so the rollout can be asserted rather than eyeballed: snapshot the (device_id, tool_name, home_user) set where the install_path user segment differs from home_user, then confirm the post-rollout delta matches exactly.

On the enumeration finding

A bot review flagged that get_all_users_windows() (utils.py:380-405) still filters only against the five names in WINDOWS_SKIP_USER_DIRS (constants.py:146-148), so service profiles and localized junctions are still scanned. That is accurate and is a real defect, but it is separate and does not block this change.

Scoped precisely: for per-user (AppData/Squirrel) installs, a phantom row needs both weak enumeration and this Path.home() bug, so this PR alone removes that class — which is 28 of the 28 observed rows. For a machine-wide install, C:\Program Files\Cursor stays unscoped by design, so detect() still returns a hit for a junk profile and enumeration alone is sufficient to produce a phantom. That residual class is genuinely the enumeration ticket's, and none of the observed rows fall into it.

The enumeration fix was deliberately dropped from this PR: the natural implementation matches ProfileList by ProfileImagePath basename, which can reject real users when profiles are container-backed (FSLogix, Cloud PC), and that is the current leading hypothesis for the unexplained Cloud PC/RDS blindness. Filed separately rather than gambling there for telemetry hygiene.

Context

Found while investigating DISCOVERY-TOOL-SCRIPT-17 ("Discovery found no tools", 3050 events, ~47/day). Related defects, deliberately not in this PR:

  1. install.ps1 runs one scan at enrollment and never registers a recurring task; 55 of 64 zero-tool Windows devices reported exactly once (avg 0.5h lifespan). Largest cohort, ~26 devices whose users have active Copilot traffic.
  2. find_claude_binary_for_user has no machine-global candidates on Windows (no Program Files/ProgramData) and the which backstop is explicitly skipped, so a system-wide Claude Code install is invisible in every context. Confirmed by a live Windows Server 2022 SYSTEM test.
  3. windows/antigravity/antigravity.py:112 and windows/replit/replit.py:213 carry this same defect, in a worse form: both use Path.home(), neither references self.user_home, and under is_running_as_admin() they union every user's Programs dir into one candidate list while detect() returns the first match. Same prod query used above will size it.
  4. macOS Cursor/Windsurf/Antigravity/Replit gate only on /Applications with no ~/Applications probe, the same defect class the Cowork fix (WEB-5572: Detect Claude Cowork when Claude Desktop is installed in ~/Applications #246) addressed for one tool.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RYVuhiiJ8mW3T1P1soDFLR

Greptile Summary

The PR scopes Windows Cursor and Windsurf per-user installation probes to the profile currently being scanned while retaining current-home fallback behavior and machine-global probes.

  • Updates both Windows detectors to prefer the assigned user_home.
  • Adds coverage for user scoping, fallback behavior, machine-global paths, and cross-user attribution.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
scripts/coding_discovery_tools/windows/cursor/cursor.py Uses the target profile home for Cursor's per-user installation candidates while preserving global candidates and fallback behavior.
scripts/coding_discovery_tools/windows/windsurf/windsurf.py Uses the target profile home for Windsurf's per-user installation candidates while preserving global candidates and fallback behavior.
tests/test_windows_cursor_windsurf_user_scope.py Covers scoped paths, unset-home fallback, machine-global paths, and prevention of cross-user installation attribution for both detectors.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Resolve target Windows profile] --> B[Assign detector.user_home]
  B --> C[Probe target AppData install paths]
  C --> D[Probe machine-global Program Files paths]
  D --> E[Attribute detected tool to target profile]
Loading

Reviews (3): Last reviewed commit: "Make the Cursor/Windsurf scoping tests O..." | Re-trigger Greptile

Context used:

@anonpran
anonpran requested a review from a team August 31, 2026 12:57
Comment thread scripts/coding_discovery_tools/utils.py Outdated
return users


WINDOWS_USER_SID_RE = re.compile(r"^S-1-5-21-\d+-\d+-\d+-(\d+)$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Entra profiles fail SID classification

When a device has an Entra ID profile with an S-1-12-1 SID alongside a qualifying S-1-5-21 profile, this regex excludes the Entra profile from the nonempty allowlist, causing that user's tools, plugins, and rules to be omitted from discovery.

Knowledge Base Used: Platform filesystem extraction

Comment thread scripts/coding_discovery_tools/utils.py Outdated
and not user_dir.name.startswith('.')
and user_dir.name not in WINDOWS_SKIP_USER_DIRS):
and user_dir.name not in WINDOWS_SKIP_USER_DIRS
and (not human_profiles or user_dir.name.lower() in human_profiles)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty results disable profile filtering

When ProfileList is readable but contains no qualifying entries, or every qualifying entry raises an entry-level error, the helper returns the same empty set as an unreadable registry. This condition then admits every non-blacklisted directory under C:\Users, causing service profiles and localized junctions to be scanned and attributed as users.

Knowledge Base Used:

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Automated Security Review (consensus)

1 finding — 0 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

🟡 TRIAGE — Empty ProfileList allowlist disables SID filtering (fail-open regression)

scripts/coding_discovery_tools/utils.py:451

Impact: When ProfileList is readable but yields an empty allowlist (no qualifying S-1-5-21 entries, or every entry errors out), _get_windows_human_profile_dirs() returns frozenset() and get_all_users_windows() treats that the same as an unreadable registry (not human_profiles), re-admitting all non-blacklisted C:\Users directories—including IIS app pools, service profiles, and localized junctions—and restoring cross-user phantom attribution / over-collection.

Fix: Return a sentinel or tuple from _get_windows_human_profile_dirs() to distinguish “registry unreadable → fail open” from “registry read OK, zero matches → fail closed (or blacklist-only)”; only skip SID filtering on the former.

Flagged by: Cursor (lead), Greptile (inline on :451; not in automated reviewer set but noted in PR discussion)


Previously acknowledged (not re-flagged)

None — no maintainer replies marking items as accepted-by-design, false positive, or wontfix in the provided thread.


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 3359ecb9 · 2026-08-31T13:02Z

Both detectors built their candidate list from Path.home() instead of the
user_home that detect_tool_for_user assigns. Under a logged-in scan that
credited one user's install to every other profile on the box; under
SYSTEM it resolved to systemprofile and found nothing.

Falls back to Path.home() when user_home is unset, matching
windows/copilot_cli.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYVuhiiJ8mW3T1P1soDFLR
@anonpran
anonpran force-pushed the nanda/windows-profile-sid-filter branch from 3359ecb to 10405bb Compare August 31, 2026 13:29
@anonpran anonpran changed the title Filter Windows profiles by SID instead of a name blacklist Scope Windows Cursor/Windsurf search paths to the user being scanned Aug 31, 2026

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 10405bbe · 2026-08-31T13:31Z

The old assertions rooted the fake home in tempfile and asserted the
candidate paths did not start with Path.home(). On Windows %TEMP% lives
under the home directory, so that failed on any real Windows box; CI
passed only because GitHub exposes TEMP as an 8.3 short name.

Assert positively against a synthetic home instead, and add a
detect_tool_for_user cross-user case covering the defect as it appeared
in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYVuhiiJ8mW3T1P1soDFLR

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Automated Security Review (consensus)

0 findings — 0 high-confidence, 0 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)

Previously acknowledged (not re-flagged)

  • Weak Windows profile enumeration (WINDOWS_SKIP_USER_DIRS only) — Maintainer deferred to a separate ticket; out of scope for this PR, which fixes per-user path scoping only.
  • Empty/unreadable ProfileList admits all C:\Users dirs — Same enumeration ticket; acknowledged as a distinct defect, not introduced here.
  • Entra ID (S-1-12-1) SID profiles excluded from allowlist — Related enumeration/classification issue; deferred alongside the ProfileList work.

🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 78a80a82 · 2026-08-31T14:30Z

This branch has not been deployed

No deployments
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.

2 participants