Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Changes to dependency manifests (package.json, requirements.txt, etc.) trigger a
.claude/hooks/
├── snyk_secure_at_inception.py # Entry point, line tracking, vuln filtering
└── lib/
├── platform_utils.py # Cross-platform abstractions (Windows/Unix)
├── scan_runner.py # Scan lifecycle, SARIF parsing
└── scan_worker.py # Background subprocess
```
Expand All @@ -110,3 +111,69 @@ python3 -c "import hashlib,os,tempfile; h=hashlib.sha256(os.getcwd().encode()).h
**Hook not firing** -- Verify `.claude/settings.json` has the hook config, script is executable, and hooks are enabled in Claude Code's `/hooks` menu.

**Debug mode** -- `export CLAUDE_HOOK_DEBUG=1` before starting a session.

## Windows Installation / Compatibility

The hook scripts use a cross-platform `lib/platform_utils.py` module that handles OS differences automatically. The Python code works on Windows without modification. However, the **hook command** in `settings.json` and the **installation steps** need adjusting.

### Installation on Windows

**1. Copy files to your project:**

```powershell
mkdir -Force .claude\hooks\lib
copy path\to\async_cli_version\snyk_secure_at_inception.py .claude\hooks\
copy path\to\async_cli_version\lib\*.py .claude\hooks\lib\
```

Note: `chmod +x` is not needed on Windows -- executability is determined by file extension.

### Hook command in `settings.json`

The Unix hook command uses `python3` and `$HOME`, which may not work on Windows. Use one of these alternatives depending on your Python installation:

**Option A -- Using `py` launcher (recommended, ships with Python for Windows):**

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "py -3 \"%USERPROFILE%\\.claude\\hooks\\snyk_secure_at_inception.py\""
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "py -3 \"%USERPROFILE%\\.claude\\hooks\\snyk_secure_at_inception.py\""
}
]
}
]
}
}
```

**Option B -- Using `python` directly (if `python` points to Python 3 on your PATH):**

Replace `py -3` with `python` in the commands above.


### Snyk CLI on Windows

The Snyk CLI can be installed via any of these methods:

- **npm**: `npm install -g snyk` (installs as `snyk.cmd`)
- **Scoop**: `scoop install snyk`
- **Chocolatey**: `choco install snyk`
- **Standalone**: Download from [snyk.io/download](https://snyk.io/download)

After installing, authenticate with `snyk auth`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
Platform Utilities
==================

Centralizes all platform-specific logic so that scan_runner, scan_worker,
and snyk_secure_at_inception remain cross-platform without inline conditionals.

Windows vs Unix differences handled:
- Detached subprocess creation (start_new_session vs creationflags)
- Process liveness checking (os.kill vs kernel32.OpenProcess)
- Snyk binary search paths (nvm, Volta, Homebrew, Scoop, etc.)
- File locking (fcntl vs msvcrt)
- Path separator normalization
"""

import glob
import os
import subprocess
import sys
from contextlib import contextmanager
from typing import Dict, List

_IS_WINDOWS = sys.platform == "win32"


# =============================================================================
# DETACHED SUBPROCESS CREATION
# =============================================================================

def get_detached_popen_kwargs() -> Dict[str, object]:
"""Return Popen kwargs for launching a detached background process."""
if _IS_WINDOWS:
return {
"creationflags": (
subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
| subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
),
}
return {"start_new_session": True}


# =============================================================================
# PROCESS LIVENESS CHECK
# =============================================================================

def is_pid_alive(pid: int) -> bool:
"""Check whether a process with the given PID is still running."""
if _IS_WINDOWS:
return _is_pid_alive_windows(pid)
return _is_pid_alive_unix(pid)


def _is_pid_alive_windows(pid: int) -> bool:
import ctypes

kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
SYNCHRONIZE = 0x00100000
handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
if handle:
kernel32.CloseHandle(handle)
return True
return False


def _is_pid_alive_unix(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except OSError:
# Process exists but we lack permission to signal it.
return True


# =============================================================================
# SNYK BINARY SEARCH PATHS
# =============================================================================

def get_snyk_search_paths(env: Dict[str, str]) -> List[str]:
"""Return candidate directories where the Snyk CLI binary may reside."""
if _IS_WINDOWS:
return _get_snyk_search_paths_windows(env)
return _get_snyk_search_paths_unix(env)


def _get_snyk_search_paths_windows(env: Dict[str, str]) -> List[str]:
candidates: List[str] = []

# nvm-windows: %APPDATA%\nvm\v*
appdata = env.get("APPDATA", os.environ.get("APPDATA", ""))
if appdata:
nvm_root = os.path.join(appdata, "nvm")
candidates.extend(
sorted(glob.glob(os.path.join(nvm_root, "v*")), reverse=True)
)
# npm global bin
candidates.append(os.path.join(appdata, "npm"))

# Volta on Windows: %LOCALAPPDATA%\Volta\bin
local_appdata = env.get("LOCALAPPDATA", os.environ.get("LOCALAPPDATA", ""))
if local_appdata:
candidates.append(os.path.join(local_appdata, "Volta", "bin"))

# Scoop: %USERPROFILE%\scoop\shims
userprofile = env.get("USERPROFILE", os.environ.get("USERPROFILE", ""))
if userprofile:
candidates.append(os.path.join(userprofile, "scoop", "shims"))

# Chocolatey: %ChocolateyInstall%\bin
choco = env.get("ChocolateyInstall", os.environ.get("ChocolateyInstall", ""))
if choco:
candidates.append(os.path.join(choco, "bin"))

# Standalone Snyk installer: %ProgramFiles%\Snyk
program_files = env.get("ProgramFiles", os.environ.get("ProgramFiles", ""))
if program_files:
candidates.append(os.path.join(program_files, "Snyk"))

return candidates


def _get_snyk_search_paths_unix(env: Dict[str, str]) -> List[str]:
candidates: List[str] = []

# NVM
nvm_dir = env.get("NVM_DIR", os.path.expanduser("~/.nvm"))
nvm_node_bins = sorted(
glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")),
reverse=True,
)
candidates.extend(nvm_node_bins)

# Volta
candidates.append(os.path.expanduser("~/.volta/bin"))

# System paths
candidates.extend(["/usr/local/bin", "/opt/homebrew/bin"])

return candidates


# =============================================================================
# SNYK BINARY NAMES
# =============================================================================

def get_snyk_binary_names() -> List[str]:
"""Return the possible filenames for the Snyk CLI."""
return ["snyk.cmd", "snyk.exe", "snyk"]


# =============================================================================
# FILE LOCKING
# =============================================================================

@contextmanager
def file_lock(lock_path: str):
"""Cross-platform exclusive file lock.

Uses fcntl on Unix and msvcrt on Windows.
Falls back to a no-op if neither is available.
"""
if _IS_WINDOWS:
yield from _file_lock_windows(lock_path)
else:
yield from _file_lock_unix(lock_path)


def _file_lock_windows(lock_path: str):
import msvcrt

fd = open(lock_path, "w")
try:
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
yield
finally:
try:
msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
except OSError:
pass
fd.close()


def _file_lock_unix(lock_path: str):
try:
import fcntl
except ImportError:
# Platform has neither fcntl nor msvcrt — no-op.
yield
return

fd = open(lock_path, "w")
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
fd.close()


# =============================================================================
# PATH NORMALIZATION
# =============================================================================

def normalize_path(path: str) -> str:
"""Normalize a file path for cross-platform comparison.

Converts backslashes to forward slashes, strips leading ./ and /.
"""
path = path.replace("\\", "/")
while path.startswith("./"):
path = path[2:]
return path.lstrip("/")
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
then reads results (including parsed vulnerabilities) from scan.done.
"""

import glob
import hashlib
import json
import os
Expand All @@ -25,6 +24,13 @@
from pathlib import Path
from typing import Any, Dict, List, Optional

from platform_utils import (
get_detached_popen_kwargs,
get_snyk_binary_names,
get_snyk_search_paths,
is_pid_alive,
)


# =============================================================================
# CONFIGURATION
Expand Down Expand Up @@ -79,9 +85,11 @@ def is_scan_running(workspace: str) -> bool:
try:
with open(pid_file, "r") as f:
pid = int(f.read().strip())
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError, OSError):
if is_pid_alive(pid):
return True
_cleanup_pid_file(workspace)
return False
except (ValueError, OSError):
_cleanup_pid_file(workspace)
return False

Expand Down Expand Up @@ -167,24 +175,14 @@ def _augment_path_for_snyk(env: Dict[str, str]) -> None:
if shutil.which("snyk", path=env.get("PATH", "")):
return

candidates: List[str] = []

nvm_dir = env.get("NVM_DIR", os.path.expanduser("~/.nvm"))
nvm_node_bins = sorted(
glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")),
reverse=True,
)
candidates.extend(nvm_node_bins)

volta_bin = os.path.expanduser("~/.volta/bin")
candidates.append(volta_bin)

candidates.extend(["/usr/local/bin", "/opt/homebrew/bin"])
candidates = get_snyk_search_paths(env)
binary_names = get_snyk_binary_names()

for bin_dir in candidates:
if os.path.isfile(os.path.join(bin_dir, "snyk")):
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
return
for name in binary_names:
if os.path.isfile(os.path.join(bin_dir, name)):
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
return


# =============================================================================
Expand Down Expand Up @@ -245,9 +243,9 @@ def launch_background_scan(workspace: str) -> bool:
[sys.executable, worker_script],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
cwd=workspace,
env=env,
**get_detached_popen_kwargs(),
)
pid_file = get_scan_pid_file(workspace)
with open(pid_file, "w") as f:
Expand Down
Loading
Loading