Skip to content

feat: read ancestor command lines on Windows for caller detection - #1295

Draft
codyde wants to merge 2 commits into
masterfrom
cody/windows-ancestor-cmdline
Draft

codyde wants to merge 2 commits into
masterfrom
cody/windows-ancestor-cmdline

Conversation

@codyde

@codyde codyde commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Why

On Windows the CLI's process snapshot carries only Toolhelp exe basenames (node.exe, python.exe). A node- or python-hosted agent (node.exe …\node_modules\@anthropic-ai\claude-code\cli.js) therefore matches nothing in caller_from_process_name, and the agent_unknown:<kind>:<residue> discovery segment never fires on Windows.

Warehouse data (fct_agentic_events, Oct 8–14, bot farm excluded):

  • 66% of the low-volume agent_unknown:node users are on Windows.
  • About 2.4k users/week sit in Windows agent_unknown:node / :python / :vscode:language_server_windows_x64 buckets that this can identify or catalog.

What changes

build_windows_snapshot now calls enrich_ancestor_command_lines, which reads the real command line for each ancestor of the current process. It rewrites those rows into the same lowercased argv0 args... shape ps gives on unix, so the identification, parent-kind and residue layers run unchanged:

  • Read: NtQueryInformationProcess(ProcessCommandLineInformation) (Windows 8.1+) on a PROCESS_QUERY_LIMITED_INFORMATION handle. The kernel copies the string into our buffer, so there's no PROCESS_VM_READ / PEB read and no WOW64 32/64-bit layout handling.
  • Parse: parse_windows_command_line follows the CommandLineToArgvW rules, so quoted paths with spaces stay whole.
  • Normalize: windows_command_from_argv
    • uses the Toolhelp exe basename as argv0;
    • turns \ into /, so script_residue's node_modules/ package extraction works;
    • drops the drive letter, so absolute paths read as absolute to the split-path guard;
    • rejoins with spaces unquoted. That mirrors unix ps, so an arg with a space splits again and the residue's split-path guard aborts instead of emitting part of a directory name.
  • parent_boot_time now shares the new process_creation_time helper.

Safety and performance bounds

  • Ancestor chain only, never the rest of the process table: at most 15 hops (same as the walks that consume it) and a 50 ms wall-clock budget. Each hop is one OpenProcess plus two queries.
  • The current process's own row stays a basename. Its argv is the user's railway … command, and flag values could match agent substrings.
  • Stale ppid after pid reuse: the walk stops at the first row created after its newest known descendant.
  • Kernel buffer: the UTF-16 range from the UNICODE_STRING header is bounds-checked against our own allocation before decoding (decode_command_line_buffer). Buffer sizes are capped at the 32,767-unit command-line maximum, with one resize retry.
  • Any failure keeps the Toolhelp basename, which is exactly the previous behavior. Failures include access denied (elevated or other-user parents), protected processes, STATUS_INVALID_INFO_CLASS on pre-8.1, and the budget running out.
  • No new crates. NtQueryInformationProcess is declared against ntdll directly; winapi 0.3.9 doesn't bind it.

Known shared hazard (not new to unix): with args visible, the substring arms of caller_from_process_name (cody, copilot, gemini, …) can match words in paths, such as a user directory. Unix has had this since it reads full argv. This PR brings Windows to parity rather than fixing it.

Testing

  • Unit tests run on every platform: argv parsing (quotes, the 2n/2n+1 backslash rules, ""), argv-to-command normalization including a Claude Code node_modules path, residue recovery of an unknown npm package, the spaces-in-path abort, and buffer bounds checks.
  • cargo fmt --check passes. cargo clippy --all-targets reports no new warnings.
  • cargo test: 1,842 passed. 2 cloud_agent Python bootstrap tests failed with Address already in use, because other local worktrees were running the suite at the same time. They don't touch telemetry.
  • The Windows-only code was copied into a scratch crate and type-checked with cargo clippy --target x86_64-pc-windows-gnu -- -D warnings. It passes.

Not verified (why this is a draft)

  • Not run on a real Windows machine. Not verified end to end: live NtQueryInformationProcess results, linking ntdll on MSVC and GNU (cargo check doesn't link), elevated and other-user parent behavior, and the observed caller from Claude Code / Codex / npm-installed agents running under node.exe.
  • The full crate wasn't built for a Windows target locally, because the bundled C dependencies need a mingw/MSVC C toolchain.
  • Suggested check on a Windows box: run railway whoami from Claude Code, from a Windows npm-installed agent, and from a plain PowerShell, and compare the caller sent in telemetry before and after.

🤖 Generated with Claude Code

The Windows process snapshot only carries Toolhelp exe basenames, so a
node- or python-hosted agent shows up as `node.exe` and neither the
process-name layer nor the ancestor residue can identify it. Read the real
command line for the ancestor chain only, via
NtQueryInformationProcess(ProcessCommandLineInformation), and rewrite those
rows into the unix snapshot's `argv0 args...` shape so the downstream
layers run unchanged.

- Ancestors only, at most 15 hops, 50ms budget; the current process's own
  argv is left out so its flag values can't match agent substrings.
- Stops at the first parent created after its descendant (stale Toolhelp
  ppid after pid reuse).
- Any failure (access denied, protected process, pre-8.1) keeps the
  Toolhelp basename, i.e. the previous behavior.
- Parsing (CommandLineToArgvW rules), argv normalization and the
  kernel-buffer bounds checks are pure and unit-tested on every platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codyde
codyde force-pushed the cody/windows-ancestor-cmdline branch from e882064 to 2101f6f Compare October 16, 2026 18:19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codyde codyde added the release/skip Author no release label Nov 10, 2026

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

release/skip Author no release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant