Recipes

Windows executable-search hardening across omc CLI, hooks, MCP servers and HUD

Discovered from README cross-links · today

Security, Science & HardwareSoftware & DevOps

Block repo-planted git.exe from hijacking omc spawns on Windows.

What they did

The author closed a Windows PATH-hijack hole in oh-my-claudecode (omc). An earlier fix (#4726) set NoDefaultCurrentDirectoryInExePath=1 for team workers only, but the hook runner, plugin hooks, standalone hooks in ~/.claude/hooks, the omc CLI, MCP servers and the HUD still spawned git by bare name with the project as cwd — so a git.exe committed to a repo would run instead, in every Claude Code session. They added src/utils/windows-search.ts (plus .mjs/.cjs twins under scripts/lib) exposing hardenWindowsExeSearch(), which drops all case variants of the variable from process.env and sets it to 1, and childEnv(env), which returns a hardened copy. Both are no-ops off win32. They wired hardenWindowsExeSearch() into 25 entry points right after imports: bundle sources (cli/index.ts, team.ts, claude-md-coordinator.ts, mcp/standalone-server.ts, mcp/team-server.ts, team/runtime-cli.ts, team/bridge-entry.ts), the HUD, scripts/run.cjs (which every hook runs through), skill/advisor scripts, and the 10 standalone hooks in templates/hooks/*.mjs via a shipped lib copy. childEnv() wraps spawns whose env is built without process.env: the graph command (shell: true) and agent nodes, the SessionEnd action runner and worker, the rate-limit-wait daemon and the reply listener. The native-Windows worker start line now sets the variable first, dropping any caller-supplied value. Cwd was deliberately left unchanged. They added a 7-test windows-search suite with red/green planted zzprobe.exe and zzprobe.cmd probes, made the #4746 supervisor test libuv-conditional, and wrote a cross-platform lint test that collects entry points from build scripts, hooks.json, skills/commands references and templates, asserting each imports and calls the helper and that the template lib copy is byte-identical.

How it went

15/15 targeted tests pass on Node 25.1.0 and Node 20.20.2. End-to-end with a git init'd folder holding a planted git.exe, scripts/run.cjs scripts/session-start.mjs went from 5 planted runs on dev to 0 on the branch, and resolve-paths.mjs from 1 to 0. Across 43 hook-template suites plus tmux-session compared with upstream/dev, there were 0 new failures; tsc --noEmit is clean and the inventory verify passes. No dist/ or bridge/ changes.

Worth knowing

On Node 20 (libuv 1.46) libuv ignores the variable, so the no-shell spawn route stays open there (#4746); only the cmd.exe route is closed on every Node version. Behaviour change: under cmd.exe a repo-local command run by bare name (gradlew, build.bat) now needs .\ (PowerShell and Git Bash unaffected). Tests must strip NoDefaultCurrentDirectoryInExePath first, since Claude Code's shell exports it and they would pass vacuously. Pre-existing Windows-local failures (symlink, flock, timing) remain on dev.

Wiring

Create src/utils/windows-search.ts with hardenWindowsExeSearch() and childEnv(env), plus byte-identical twins at scripts/lib/windows-search.mjs/.cjs and templates/hooks/lib/windows-search.mjs (the installer already ships templates/hooks/lib). Import and call hardenWindowsExeSearch() immediately after imports in each entry point: src/cli/index.ts, src/cli/team.ts, src/cli/claude-md-coordinator.ts, src/mcp/standalone-server.ts, src/mcp/team-server.ts, src/team/runtime-cli.ts, src/team/bridge-entry.ts, src/hud/index.ts, scripts/run.cjs, scripts/cleanup-orphans.mjs, scripts/repair-plugin-cache.mjs, scripts/shipyard-audit.mjs, scripts/run-provider-advisor.js, skills/self-improve/scripts/resolve-paths.mjs, and each templates/hooks/*.mjs. Scripts started by path from src inherit the variable because their spawns pass process.env or a spread of it. Wrap env-built spawns in childEnv(): graph command (shell: true) and agent nodes, SessionEnd action runner and worker, rate-limit-wait daemon, reply listener. In buildWorkerStartCommand, prepend set "NoDefaultCurrentDirectoryInExePath=1" and drop any caller value of any case. Add src/utils/__tests__/windows-search.test.ts to the Windows path-suite CI job, regenerate the inventory baseline, and index the new helper in skills/omc-doctor/node-hook-fingerprints.json.

Try it yourself

Audit my repo for Windows bare-name executable hijacking. Find every place we spawn a command by bare name (git, node, npm, etc.) with the project directory as cwd — CLI entry points, hook runners, MCP servers, background daemons, shell: true spawns. Add a src/utils/windows-search.ts helper exporting hardenWindowsExeSearch() (drops all case variants of NoDefaultCurrentDirectoryInExePath from process.env and sets it to 1) and childEnv(env) (returns a hardened copy), both no-ops off win32. Call hardenWindowsExeSearch() right after imports in every entry point, and wrap spawns whose env is built without process.env in childEnv(). Add tests that plant a zzprobe.exe (copy of hostname.exe) and a zzprobe.cmd in a temp cwd and assert they are blocked, plus a lint test that fails if any entry point stops calling the helper. Keep cwd unchanged.

Source

Tools in this stack