From 89c03b2ebb8a21b09ae927ce4358141888976b8c Mon Sep 17 00:00:00 2001 From: Derek Pearson <32114370+dpearson2699@users.noreply.github.com> Date: Thu, 14 May 2026 11:39:30 -0400 Subject: [PATCH] fix: skip Claude augment hook when GitNexus server owns DB (#1493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(claude): skip augment hook when server owns db * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(hooks): cross-platform DB lock probe for MCP owner guard Extract hook-db-lock-probe.cjs with a single hasGitNexusDbLockedByGitNexusServer entry point used by both Claude hooks: - Linux: scan /proc//fd via dev+inode (no lsof required), optional lsof fallback; GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS caps scan time - macOS and other Unix: trusted lsof + ps (absolute paths / env overrides) - Windows: Restart Manager + Win32_Process via win-rm-list-json.ps1 and GITNEXUS_HOOK_POWERSHELL_PATH Update hooks.test.ts source coverage for the probe module. Co-authored-by: Cursor * Update gitnexus/hooks/claude/win-rm-list-json.ps1 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(gitnexus): repair package.json JSON after malformed engines edit Co-authored-by: Cursor * Update Node.js engine version requirement to 22.0.0 * Update Node.js engine version to >=22.0.0 * fix(hooks): address ce-code-review findings on PR #1493 P0: - Replace malformed `RM_UNIQUE_PROCESS` block in `gitnexus/hooks/claude/win-rm-list-json.ps1` (duplicate struct decl + duplicate `ProcessStartTime` + unbalanced braces) with a single well-formed `[StructLayout(LayoutKind.Sequential, Pack = 4)]` struct, so PowerShell `Add-Type` actually compiles and the Windows DB-lock probe stops fail-open on every machine. - `gitnexus/src/cli/setup.ts` now copies `hook-db-lock-probe.cjs` and `win-rm-list-json.ps1` into the user's `~/.claude/hooks/gitnexus/` alongside `hook-lock.cjs`, preventing the `MODULE_NOT_FOUND` thrown by `gitnexus-hook.cjs:18`'s top-level require on every fresh install. `gitnexus/test/unit/setup.test.ts` extended to assert both new copy destinations. - Four fail-open hook tests (`ENOENT lsof`, `npx parent line`, `non-GitNexus ps line`, `ps ENOENT`) now seed `createHookToolDir` with a valid `[GitNexus]` stderr line so `expect(parseHookOutput).not.toBeNull()` actually holds on CI. P1: - Plugin copy of `win-rm-list-json.ps1` gains `Pack = 4` so its CLR struct matches the 12-byte native `RM_UNIQUE_PROCESS` layout (multi-blocker `RmGetList` no longer reads mangled `dwProcessId`). - `GITNEXUS_HOOK_CLI_PATH = ''` now falls through to the resolution chain in `gitnexus-hook.cjs`, matching the plugin copy and removing the twin-file divergence on empty-string envs. - Lock-warning suppression test seeds `gitnexusMarkerPath` and asserts the augment subprocess actually ran, plus `GITNEXUS_DEBUG=1` preserves the full discarded prefix. - MCP-owner skip branch in both hook copies now emits `[GitNexus] augment skipped: MCP server owns DB` on stderr, so agents can distinguish intentional skip from silent failure. P2: - `ps` loop in `hook-db-lock-probe.cjs` fails-closed on `ETIMEDOUT` to mirror the `lsof` handling (symmetric subprocess-probe contract). - `RmStartSession` return value captured in both `.ps1` copies; exits early with `[]` on non-zero so subsequent RM API calls don't operate on an invalid handle. - Windows RM-list `.ps1` encoded cache distinguishes uninitialized (`undefined`) from load-failed (`null`) with a one-shot `GITNEXUS_DEBUG` warning instead of silently caching empty string. - `createHookToolDir` helper accepts `lsofOutputLines` and `psOutputByPid`; the multi-PID test uses them instead of duplicating the fake-binary construction inline. - All five skip-path tests now assert `result.status === 0` and the new skip-signal stderr line. - `AGENTS.md` documents the seven hook configuration env vars (`GITNEXUS_HOOK_CLI_PATH`, `_LSOF_PATH`, `_PS_PATH`, `_POWERSHELL_PATH`, `_LINUX_PROC_BUDGET_MS`, `_RM_TARGET`, `GITNEXUS_DEBUG`). - `GITNEXUS_DEBUG` path in `gitnexus-hook.cjs`/`.js` writes the full discarded stderr prefix instead of a 180-char preview. - Inline comment in `hook-db-lock-probe.cjs` explains the intentional Windows ETIMEDOUT fail-closed semantics. - Removed the unnecessary `as WriteFileOptions` cast and orphaned `import type { WriteFileOptions }` in `hooks.test.ts`. P3: - `isGitNexusServerCommand` unexported from `hook-db-lock-probe.cjs` (kept as private helper). - Env-path overrides (`GITNEXUS_HOOK_CLI_PATH`, `_POWERSHELL_PATH`, `_LSOF_PATH`, `_PS_PATH`) require `fs.existsSync` before being returned, so typos / stale config fall through to the standard resolution chain. Misc: - `gitnexus/package.json` engines.node back to `>=22.0.0` (matches origin/main and the original PR reviewer's earlier request). Twin-tree parity / CI sync mechanism tracked separately at abhigyanpatwari/GitNexus#1591. Test plan: vitest run test/unit/hooks.test.ts → 113 passed, 18 Unix-only skipped; setup.test.ts → 14 passed. * chore(autofix): apply prettier + eslint fixes via /autofix command * trigger --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar Co-authored-by: Cursor --- AGENTS.md | 14 + gitnexus-claude-plugin/hooks/gitnexus-hook.js | 42 +- .../hooks/hook-db-lock-probe.cjs | 238 +++++++++ .../hooks/win-rm-list-json.ps1 | 76 +++ gitnexus/hooks/claude/gitnexus-hook.cjs | 37 +- gitnexus/hooks/claude/hook-db-lock-probe.cjs | 238 +++++++++ gitnexus/hooks/claude/win-rm-list-json.ps1 | 76 +++ gitnexus/src/cli/setup.ts | 18 + gitnexus/test/unit/hooks.test.ts | 498 ++++++++++++++++++ gitnexus/test/unit/setup.test.ts | 15 + gitnexus/test/utils/hook-test-helpers.ts | 2 + 11 files changed, 1248 insertions(+), 6 deletions(-) create mode 100644 gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs create mode 100644 gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 create mode 100644 gitnexus/hooks/claude/hook-db-lock-probe.cjs create mode 100644 gitnexus/hooks/claude/win-rm-list-json.ps1 diff --git a/AGENTS.md b/AGENTS.md index 1346facc9..b9b9138b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no | Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | | CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +## Hook env knobs + +The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain. + +| Env var | Type | Default | Purpose | +|---------|------|---------|---------| +| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. | +| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. | +| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. | +| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. | +| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. | +| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. | +| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. | + ## Repo reference diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 245d34043..7ff03e430 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.js'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); /** * Read JSON input from stdin synchronously. @@ -103,6 +104,28 @@ function findGitNexusDir(startDir) { return null; } +function hasGitNexusServerOwner(gitNexusDir) { + return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — LadybugDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. The untruncated payload lets operators see exactly what was + // filtered out instead of a 180-char JSON-quoted preview. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -170,6 +193,15 @@ function extractPattern(toolName, toolInput) { */ function runGitNexusCli(args, cwd, timeout) { const isWin = process.platform === 'win32'; + const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH; + if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) { + return spawnSync(process.execPath, [String(hookCli), ...args], { + encoding: 'utf-8', + timeout, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } // Detect whether 'gitnexus' is on PATH (cheap check, no execution) let useDirectBinary = false; @@ -228,6 +260,10 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) { + process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); + return; + } const release = acquireHookSlot(gitNexusDir); if (!release) return; @@ -236,7 +272,7 @@ function handlePreToolUse(input) { try { const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ @@ -244,8 +280,8 @@ function handlePreToolUse(input) { release(); } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs new file mode 100644 index 000000000..783cd0804 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs @@ -0,0 +1,238 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof; + * optional lsof fallback when proc scan finds nothing. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or + * PowerShell ETIMEDOUT (Windows), matching the hook contract. + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +function readLinuxCmdline(pidStr) { + try { + return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } +} + +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200; + const start = Date.now(); + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + return false; + } + let procEntries; + try { + procEntries = fs.readdirSync('/proc', { withFileTypes: true }); + } catch { + return false; + } + for (const ent of procEntries) { + if (Date.now() - start > budget) return false; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + const fdDir = path.join('/proc', ent.name, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch { + continue; + } + let holds = false; + for (const fd of fds) { + if (Date.now() - start > budget) return false; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + holds = true; + break; + } + } catch { + /* ignore */ + } + } + if (!holds) continue; + if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true; + } + return false; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const lsofPath = resolveHookBinary('lsof'); + const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true; + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, +}; diff --git a/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 9541fcb50..e39fcf8e1 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.cjs'); +const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); /** * Read JSON input from stdin synchronously. @@ -103,6 +104,28 @@ function findGitNexusDir(startDir) { return null; } +function hasGitNexusServerOwner(gitNexusDir) { + return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid); +} + +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — KuzuDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. The untruncated payload lets operators see exactly what was + // filtered out instead of a 180-char JSON-quoted preview. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + /** * Extract search pattern from tool input. */ @@ -168,6 +191,10 @@ function extractPattern(toolName, toolInput) { * 3. Fall back to npx (returns empty string) */ function resolveCliPath() { + const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH; + if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js'); if (!fs.existsSync(cliPath)) { try { @@ -218,6 +245,10 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + if (hasGitNexusServerOwner(gitNexusDir)) { + process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n'); + return; + } const release = acquireHookSlot(gitNexusDir); if (!release) return; @@ -227,7 +258,7 @@ function handlePreToolUse(input) { try { const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); if (!child.error && child.status === 0) { - result = child.stderr || ''; + result = extractAugmentContext(child.stderr || ''); } } catch { /* graceful failure */ @@ -235,8 +266,8 @@ function handlePreToolUse(input) { release(); } - if (result && result.trim()) { - sendHookResponse('PreToolUse', result.trim()); + if (result) { + sendHookResponse('PreToolUse', result); } } diff --git a/gitnexus/hooks/claude/hook-db-lock-probe.cjs b/gitnexus/hooks/claude/hook-db-lock-probe.cjs new file mode 100644 index 000000000..783cd0804 --- /dev/null +++ b/gitnexus/hooks/claude/hook-db-lock-probe.cjs @@ -0,0 +1,238 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof; + * optional lsof fallback when proc scan finds nothing. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or + * PowerShell ETIMEDOUT (Windows), matching the hook contract. + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { + return String(fromEnv); + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +function readLinuxCmdline(pidStr) { + try { + return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } +} + +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200; + const start = Date.now(); + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + return false; + } + let procEntries; + try { + procEntries = fs.readdirSync('/proc', { withFileTypes: true }); + } catch { + return false; + } + for (const ent of procEntries) { + if (Date.now() - start > budget) return false; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + const fdDir = path.join('/proc', ent.name, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch { + continue; + } + let holds = false; + for (const fd of fds) { + if (Date.now() - start > budget) return false; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + holds = true; + break; + } + } catch { + /* ignore */ + } + } + if (!holds) continue; + if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true; + } + return false; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const lsofPath = resolveHookBinary('lsof'); + const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true; + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, +}; diff --git a/gitnexus/hooks/claude/win-rm-list-json.ps1 b/gitnexus/hooks/claude/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus/hooks/claude/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 8f52e0f2d..3e7cbad8f 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -373,6 +373,24 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // Helper not found in source — skip } + try { + await fs.copyFile( + path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'), + path.join(destHooksDir, 'hook-db-lock-probe.cjs'), + ); + } catch { + // Helper not found in source — skip + } + + try { + await fs.copyFile( + path.join(pluginHooksPath, 'win-rm-list-json.ps1'), + path.join(destHooksDir, 'win-rm-list-json.ps1'), + ); + } catch { + // Helper not found in source — skip + } + const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 346ee1ed6..a0ef2b8cb 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -12,6 +12,7 @@ * - shell injection: verifies no shell: true in spawnSync calls * - dispatch map: correct handler routing * - cross-platform: Windows .cmd extension handling + * - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM) * * Since the hooks are CJS scripts that call main() on load, we test them * by spawning them as child processes with controlled stdin JSON. @@ -45,6 +46,23 @@ const PLUGIN_HOOK_LOCK = path.resolve( 'hooks', 'hook-lock.js', ); +const CJS_HOOK_DB_PROBE = path.resolve( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'hook-db-lock-probe.cjs', +); +const PLUGIN_HOOK_DB_PROBE = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'hook-db-lock-probe.cjs', +); // ─── Test fixtures: temporary .gitnexus directory ─────────────────── @@ -109,6 +127,62 @@ function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 're } } +function writeExecutable(filePath: string, content: string) { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +function createHookToolDir(options: { + gitnexusStderr?: string; + gitnexusMarkerPath?: string; + lsofOutput?: string; + lsofOutputLines?: string[]; + psOutput?: string; + psOutputByPid?: Record; + lsofSleepMs?: number; +}) { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-bin-')); + const gitnexusStderr = JSON.stringify(options.gitnexusStderr ?? ''); + const markerPath = JSON.stringify(options.gitnexusMarkerPath ?? ''); + + const fakeGitNexus = `#!/usr/bin/env node\nconst fs = require('fs');\nconst marker = ${markerPath};\nif (marker) fs.writeFileSync(marker, 'called');\nprocess.stderr.write(${gitnexusStderr});\n`; + writeExecutable(path.join(binDir, 'gitnexus'), fakeGitNexus); + writeExecutable(path.join(binDir, 'gitnexus-cli.js'), fakeGitNexus); + + const lsofOutput = + options.lsofOutputLines != null + ? options.lsofOutputLines.join('\n') + (options.lsofOutputLines.length ? '\n' : '') + : (options.lsofOutput ?? ''); + const lsofBody = + options.lsofSleepMs != null + ? `#!/usr/bin/env node\nsetTimeout(() => {}, ${Number(options.lsofSleepMs)});\n` + : `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(lsofOutput)});\nprocess.exit(0);\n`; + writeExecutable(path.join(binDir, 'lsof'), lsofBody); + + const psBody = + options.psOutputByPid != null + ? `#!/usr/bin/env node +const byPid = ${JSON.stringify(options.psOutputByPid)}; +const args = process.argv; +const p = args[args.indexOf('-p') + 1]; +process.stdout.write(byPid[p] ?? ''); +process.exit(0); +` + : `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(options.psOutput ?? '')});\nprocess.exit(0);\n`; + writeExecutable(path.join(binDir, 'ps'), psBody); + + return binDir; +} + +function hookEnv(binDir: string) { + return { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`, + GITNEXUS_HOOK_CLI_PATH: path.join(binDir, 'gitnexus-cli.js'), + GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, 'lsof'), + GITNEXUS_HOOK_PS_PATH: path.join(binDir, 'ps'), + }; +} + // ─── Both hook files should exist ─────────────────────────────────── describe('Hook files exist', () => { @@ -594,6 +668,430 @@ describe('PreToolUse concurrency guard (integration)', () => { } }); +// ─── Source: cross-platform DB lock probe module (#1493) ───────────── + +describe('Cross-platform DB lock probe (source)', () => { + for (const [label, hookPath, probePath] of [ + ['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE], + ['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE], + ] as const) { + it(`${label} probe file exists`, () => { + expect(fs.existsSync(probePath)).toBe(true); + }); + + it(`${label} hook requires hook-db-lock-probe.cjs`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain("require('./hook-db-lock-probe.cjs')"); + }); + + it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => { + const p = fs.readFileSync(probePath, 'utf-8'); + expect(p).toContain('win-rm-list-json.ps1'); + expect(p).toContain('/proc/'); + expect(p).toContain('linuxProcScanFindGitNexusServer'); + expect(p).toContain('unixLsofPsFindGitNexusServer'); + expect(p).toContain('hasGitNexusServerOwnerWindows'); + expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH'); + expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH'); + expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS'); + }); + } +}); + +// ─── Integration: PreToolUse augmentation filtering (#1492) ───────── + +describe('PreToolUse augmentation filtering (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: emits valid GitNexus augmentation context`, () => { + const binDir = createHookToolDir({ + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.hookEventName).toBe('PreToolUse'); + expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found'); + } finally { + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => { + const markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: + 'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(fs.existsSync(markerPath)).toBe(true); + + // Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is + // recoverable on the hook's stderr (not silently dropped). + const debugResult = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } }, + ); + expect(debugResult.stderr).toContain('augment stderr discarded prefix'); + expect(debugResult.stderr).toContain('Could not set lock on file'); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it.skipIf(process.platform === 'win32')( + `${label}: skips augment when a GitNexus MCP process owns the repo DB`, + () => { + const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '12345\n', + psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }, + ); + } +}); + +describe.skipIf(process.platform === 'win32')( + 'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)', + () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '99901\n', + psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99902\n', + psOutput: 'npx -y gitnexus@latest mcp\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: skips augment for gitnexus serve child`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofOutput: '99903\n', + psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '', + psOutput: '', + }); + try { + const env = { + ...hookEnv(binDir), + GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'), + }; + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + lsofSleepMs: 5000, + psOutput: '', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: non-GitNexus ps line → augment runs`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99904\n', + psOutput: '/usr/bin/bash -l\n', + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutputLines: ['111', '222'], + psOutputByPid: { + '111': 'vim /tmp/x\n', + '222': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n', + }, + }); + try { + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env: hookEnv(binDir) }, + ); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + expect(result.stderr).toContain('[GitNexus] augment skipped'); + expect(fs.existsSync(markerPath)).toBe(false); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + + it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => { + const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`); + const lbugPath = path.join(gitNexusDir, 'lbug'); + fs.writeFileSync(lbugPath, ''); + fs.rmSync(markerPath, { force: true }); + const binDir = createHookToolDir({ + gitnexusMarkerPath: markerPath, + gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n', + lsofOutput: '99905\n', + psOutput: '', + }); + try { + const env = { + ...hookEnv(binDir), + GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'), + }; + const result = runHook( + hookPath, + { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }, + undefined, + { env }, + ); + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(fs.existsSync(markerPath)).toBe(true); + } finally { + fs.rmSync(markerPath, { force: true }); + fs.rmSync(binDir, { recursive: true, force: true }); + } + }); + } + }, +); + // ─── Integration: PostToolUse staleness detection ─────────────────── describe('PostToolUse staleness detection (integration)', () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 95ad261f0..bdf1cd1fd 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -267,6 +267,21 @@ describe('setupClaudeCode', () => { }); }); + it('copies hook-db-lock-probe.cjs and win-rm-list-json.ps1 to ~/.claude/hooks/gitnexus/', async () => { + setPlatform('linux'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus'); + await expect( + fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')), + ).resolves.toBeUndefined(); + await expect( + fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')), + ).resolves.toBeUndefined(); + }); + it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => { setPlatform('win32'); // Edge case: where returns only the POSIX script (no .cmd wrapper) diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index 6f5c5fbfd..3f519bc81 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -7,12 +7,14 @@ export function runHook( hookPath: string, input: Record, cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, ): { stdout: string; stderr: string; status: number | null } { const result = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(input), encoding: 'utf-8', timeout: 10000, cwd, + env: options.env, stdio: ['pipe', 'pipe', 'pipe'], }); return {