UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

1,159 lines (1,149 loc) • 48.7 kB
import * as fs from "fs"; import * as path from "path"; import { createHmac } from "crypto"; import { lookup } from "dns/promises"; import { execFileSync } from "child_process"; import { isIP } from "net"; import { fileURLToPath } from "url"; import { EXEC_TIMEOUT_QUICK_MS, PhrenError, debugLog, runtimeFile, homePath, installPreferencesFile, atomicWriteText } from "./shared.js"; import { errorMessage } from "./utils.js"; import { hookConfigPath } from "./provider-adapters.js"; import { PACKAGE_SPEC } from "./package-metadata.js"; import { logger } from "./logger.js"; import { withFileLock } from "./shared/governance.js"; import { resolveManagementCapabilities } from "./init/management-preset.js"; // Dependency-free leaf module — hooks.ts runs in a per-PostToolUse subprocess // and must not pull in content/dedup.ts's import graph. import { redactSecretsForLog } from "./content/secrets.js"; // Tool probes (`which`/`where.exe`) are deterministic for a given PATH but run // several times for the same command during one `phren init`. On POSIX a spawn // is cheap; on Windows every `where.exe` is a full process creation, and init // already spawns git and node. Cache results and key the cache on PATH so a // caller (or a test) that edits PATH still gets a fresh probe. // Entries also expire: the MCP server and the Hook are long-lived, and a tool // installed after a probe must show up without a restart. const COMMAND_EXISTS_TTL_MS = 60_000; let commandExistsPath; const commandExistsCache = new Map(); function commandExistsCacheForCurrentPath() { const path = process.env.PATH ?? ""; if (commandExistsPath !== path) { commandExistsCache.clear(); commandExistsPath = path; } return commandExistsCache; } export function commandExists(cmd) { const cache = commandExistsCacheForCurrentPath(); const cached = cache.get(cmd); if (cached && Date.now() - cached.at < COMMAND_EXISTS_TTL_MS) return cached.found; let found; try { const whichCmd = process.platform === "win32" ? "where.exe" : "which"; execFileSync(whichCmd, [cmd], { stdio: ["ignore", "ignore", "ignore"], timeout: EXEC_TIMEOUT_QUICK_MS }); found = true; } catch (err) { debugLog(`commandExists: ${cmd} not found: ${errorMessage(err)}`); found = false; } cache.set(cmd, { found, at: Date.now() }); return found; } /** Whether the GitHub Copilot CLI is present. Cheaper than * `detectInstalledTools()` for the one call site that only needs copilot. */ export function isCopilotInstalled() { return (commandExists("copilot") || commandExists("github-copilot-cli") || fs.existsSync(homePath(".local", "share", "gh", "extensions", "gh-copilot")) || fs.existsSync(homePath(".copilot", "config.json"))); } export function detectInstalledTools() { const tools = new Set(); if (isCopilotInstalled()) { tools.add("copilot"); } if (commandExists("cursor")) { tools.add("cursor"); } if (commandExists("codex") || fs.existsSync(homePath(".codex"))) { tools.add("codex"); } return tools; } function resolveToolBinary(tool) { // The probe above already established presence; skip the extra `which -a` / // `where` spawn (and its null result) when the tool is not installed. The // cache makes this check free at sites that probed the tool already. if (!commandExists(tool)) return null; try { const wrapperPath = path.resolve(homePath(".local", "bin", tool)); const whichCmd = process.platform === "win32" ? "where.exe" : "which"; const whichArgs = process.platform === "win32" ? [tool] : ["-a", tool]; const raw = execFileSync(whichCmd, whichArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: EXEC_TIMEOUT_QUICK_MS, }); const candidates = raw.split("\n").map((line) => line.trim()).filter(Boolean); for (const candidate of candidates) { const resolved = path.resolve(candidate); if (resolved !== wrapperPath) return candidate; } } catch (err) { debugLog(`resolveToolBinary: failed for ${tool}: ${errorMessage(err)}`); return null; } return null; } function resolveCliEntryScript() { const local = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js"); return fs.existsSync(local) ? local : null; } /** * True if a path lives inside an ephemeral npx download cache * (`~/.npm/_npx/<hash>/…`). Baking such a path into a hook command that has no * self-healing fallback is fragile: npx prunes that cache and the `<hash>` * segment changes between versions, silently breaking every phren hook. Hook * commands that resolve here must fall back to `npx -y <spec>` (which * re-resolves on every run) or the ~/.local/bin/phren wrapper instead. */ export function isEphemeralNpxPath(p) { return /(^|[/\\])_npx[/\\]/.test(p); } /** Split a hook command into tokens, honouring single and double quotes. */ function tokenizeCommand(command) { const tokens = []; let current = ""; let quote = null; let started = false; for (const ch of command) { if (quote) { if (ch === quote) quote = null; else current += ch; continue; } if (ch === '"' || ch === "'") { quote = ch; started = true; continue; } if (/\s/.test(ch)) { if (started || current) tokens.push(current); current = ""; started = false; continue; } current += ch; started = true; } if (started || current) tokens.push(current); return tokens; } function isPathLike(token) { return token.startsWith("/") || token.startsWith("~/") || token.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(token); } /** * The local file a hook command depends on — the `~/.local/bin/phren` wrapper, * or the script passed to `node`. * * Returns `null` for commands that re-resolve themselves on every run * (`npx -y @phren/cli …`), which cannot go stale, and for commands with no * recognisable path. * * Why this matters: `npm install -g @phren/cli` can move the package entry * between versions (0.1.40 moved it from `mcp/dist/index.js` to * `dist/index.js`). Hook commands in settings.json keep the old absolute path * and every hook then dies with MODULE_NOT_FOUND on every prompt and every * Stop — silently, because hook failures are not surfaced. */ function extractHookScriptPath(command) { if (!command) return null; // `npx` re-resolves the package each run, so it is never stale. if (/(^|\s)npx(\.cmd)?(\s|$)/.test(command)) return null; const tokens = tokenizeCommand(command); for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; if (!token) continue; // Skip shell scaffolding: `set`, `&&`, and `VAR=value` assignments. if (token === "&&" || token === "set" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue; if (/^node(\.exe)?$/i.test(token)) { const next = tokens[i + 1]; return next && isPathLike(next) ? next : null; } if (isPathLike(token)) return token; } return null; } /** * Hook commands whose entrypoint no longer exists on disk. Deduplicated, so an * upgrade that breaks all four lifecycle hooks reports one path, not four. */ export function findStaleHookEntrypoints(commands) { const stale = new Set(); for (const command of commands) { const scriptPath = extractHookScriptPath(command); if (!scriptPath) continue; const expanded = scriptPath.startsWith("~/") ? homePath(scriptPath.slice(2)) : scriptPath; if (!fs.existsSync(expanded)) stale.add(scriptPath); } return [...stale]; } export function phrenPackageSpec() { return PACKAGE_SPEC; } /** Shell-escape a value by wrapping in single quotes with proper escaping of embedded single quotes. */ export function shellEscape(s) { return "'" + s.replace(/'/g, "'\\''") + "'"; } function buildPackageLifecycleCommands() { const packageSpec = phrenPackageSpec(); return { sessionStart: `npx -y ${packageSpec} hook-session-start`, userPromptSubmit: `npx -y ${packageSpec} hook-prompt`, stop: `npx -y ${packageSpec} hook-stop`, hookTool: `npx -y ${packageSpec} hook-tool`, }; } export function buildLifecycleCommands(phrenPath, options = {}) { const rawEntry = resolveCliEntryScript(); // A bare `node <entry>` hook has no fallback if <entry> disappears. When the // package is running from the npx cache, that entry path is ephemeral, so we // drop it here and let resolution fall through to the ~/.local/bin/phren // wrapper (preferred) or the self-healing `npx -y <spec>` last resort. const entry = rawEntry && !isEphemeralNpxPath(rawEntry) ? rawEntry : null; const nativeWindows = process.platform === "win32" && !options.forcePosix; const escapedPhren = phrenPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const quotedPhren = shellEscape(phrenPath); // Prefer the stable wrapper at ~/.local/bin/phren — it survives nvm switches // and npx cache clears because it has a built-in npx fallback. const localBinDir = homePath(".local", "bin"); const wrapperBaseName = process.platform === "win32" ? "phren.cmd" : "phren"; const wrapperPath = path.join(localBinDir, wrapperBaseName); const wrapperExists = fs.existsSync(wrapperPath) && (() => { try { return fs.readFileSync(wrapperPath, "utf8").includes("PHREN_CLI_WRAPPER"); } catch { return false; } })(); if (wrapperExists) { if (nativeWindows) { return { sessionStart: `set "PHREN_PATH=${escapedPhren}" && "${wrapperPath}" hook-session-start`, userPromptSubmit: `set "PHREN_PATH=${escapedPhren}" && "${wrapperPath}" hook-prompt`, stop: `set "PHREN_PATH=${escapedPhren}" && "${wrapperPath}" hook-stop`, hookTool: `set "PHREN_PATH=${escapedPhren}" && "${wrapperPath}" hook-tool`, }; } const quotedWrapper = shellEscape(wrapperPath); return { sessionStart: `PHREN_PATH=${quotedPhren} ${quotedWrapper} hook-session-start`, userPromptSubmit: `PHREN_PATH=${quotedPhren} ${quotedWrapper} hook-prompt`, stop: `PHREN_PATH=${quotedPhren} ${quotedWrapper} hook-stop`, hookTool: `PHREN_PATH=${quotedPhren} ${quotedWrapper} hook-tool`, }; } // Direct node path — used when wrapper hasn't been installed yet if (entry) { const escapedEntry = entry.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const quotedEntry = shellEscape(entry); if (nativeWindows) { return { sessionStart: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-session-start`, userPromptSubmit: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-prompt`, stop: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-stop`, hookTool: `set "PHREN_PATH=${escapedPhren}" && node "${escapedEntry}" hook-tool`, }; } return { sessionStart: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-session-start`, userPromptSubmit: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-prompt`, stop: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-stop`, hookTool: `PHREN_PATH=${quotedPhren} node ${quotedEntry} hook-tool`, }; } // Last resort — npx const packageSpec = phrenPackageSpec(); if (nativeWindows) { return { sessionStart: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-session-start`, userPromptSubmit: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-prompt`, stop: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-stop`, hookTool: `set "PHREN_PATH=${escapedPhren}" && npx -y ${packageSpec} hook-tool`, }; } return { sessionStart: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-session-start`, userPromptSubmit: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-prompt`, stop: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-stop`, hookTool: `PHREN_PATH=${quotedPhren} npx -y ${packageSpec} hook-tool`, }; } export function buildSharedLifecycleCommands() { return buildPackageLifecycleCommands(); } function withHookToolEnv(command, tool, forcePosix = false) { if (process.platform === "win32" && !forcePosix) { return `set "PHREN_HOOK_TOOL=${tool}" && ${command}`; } return `PHREN_HOOK_TOOL=${shellEscape(tool)} ${command}`; } function withHookToolLifecycleCommands(lifecycle, tool, forcePosix = false) { return { sessionStart: withHookToolEnv(lifecycle.sessionStart, tool, forcePosix), userPromptSubmit: withHookToolEnv(lifecycle.userPromptSubmit, tool, forcePosix), stop: withHookToolEnv(lifecycle.stop, tool, forcePosix), hookTool: withHookToolEnv(lifecycle.hookTool, tool, forcePosix), }; } function installSessionWrapper(tool, phrenPath) { const isWindows = process.platform === "win32"; const realBinary = resolveToolBinary(tool); if (!realBinary) return false; const entry = resolveCliEntryScript(); const localBinDir = homePath(".local", "bin"); const wrapperPath = path.join(localBinDir, isWindows ? `${tool}.cmd` : tool); const packageSpec = phrenPackageSpec(); if (isWindows) { const sessionStartCmd = entry ? `node "${entry}" hook-session-start` : `npx -y ${packageSpec} hook-session-start`; const stopCmd = entry ? `node "${entry}" hook-stop` : `npx -y ${packageSpec} hook-stop`; const timeoutSec = Math.ceil(HOOK_TIMEOUT_MS / 1000); // Bound hook execution with PowerShell Start-Job + Wait-Job so a hung // hook can never stall the user's real command. The hook command is // passed via env var to avoid nested-quote escaping hell in cmd. const content = `@echo off\r setlocal\r set "REAL_BIN=${realBinary}"\r if not defined PHREN_PATH set "PHREN_PATH=${phrenPath}"\r set "PHREN_HOOK_TOOL=${tool}"\r if not defined PHREN_HOOK_TIMEOUT_S set "PHREN_HOOK_TIMEOUT_S=${timeoutSec}"\r \r if "%~1"=="-h" goto :passthrough\r if "%~1"=="--help" goto :passthrough\r if "%~1"=="help" goto :passthrough\r if "%~1"=="-V" goto :passthrough\r if "%~1"=="--version" goto :passthrough\r if "%~1"=="version" goto :passthrough\r if "%~1"=="completion" goto :passthrough\r \r set "PHREN_HOOK_CMD=${sessionStartCmd}"\r call :run_with_timeout\r "%REAL_BIN%" %*\r set "EXIT_STATUS=%ERRORLEVEL%"\r set "PHREN_HOOK_CMD=${stopCmd}"\r call :run_with_timeout\r exit /b %EXIT_STATUS%\r \r :passthrough\r "%REAL_BIN%" %*\r exit /b %ERRORLEVEL%\r \r :run_with_timeout\r powershell -NoProfile -NonInteractive -Command "$j = Start-Job -ScriptBlock { & cmd /c $env:PHREN_HOOK_CMD *> $null }; if (-not (Wait-Job $j -Timeout ([int]$env:PHREN_HOOK_TIMEOUT_S))) { Stop-Job $j -ErrorAction SilentlyContinue }; Remove-Job $j -Force -ErrorAction SilentlyContinue | Out-Null" >nul 2>&1\r exit /b 0\r `; try { fs.mkdirSync(localBinDir, { recursive: true }); atomicWriteText(wrapperPath, content); return true; } catch (err) { debugLog(`installSessionWrapper: failed for ${tool}: ${errorMessage(err)}`); return false; } } const sessionStartCmd = entry ? `env PHREN_PATH="$PHREN_PATH" node "$ENTRY_SCRIPT" hook-session-start` : `env PHREN_PATH="$PHREN_PATH" npx -y ${packageSpec} hook-session-start`; const stopCmd = entry ? `env PHREN_PATH="$PHREN_PATH" node "$ENTRY_SCRIPT" hook-stop` : `env PHREN_PATH="$PHREN_PATH" npx -y ${packageSpec} hook-stop`; const content = `#!/bin/sh set -u REAL_BIN=${shellEscape(realBinary)} DEFAULT_PHREN_PATH=${shellEscape(phrenPath)} PHREN_PATH="\${PHREN_PATH:-$DEFAULT_PHREN_PATH}" ENTRY_SCRIPT=${shellEscape(entry || "")} export PHREN_HOOK_TOOL="${tool}" if [ ! -x "$REAL_BIN" ]; then echo "phren wrapper error: real ${tool} binary not executable: $REAL_BIN" >&2 exit 127 fi case "\${1:-}" in -h|--help|help|-V|--version|version|completion) exec "$REAL_BIN" "$@" ;; esac run_with_timeout() { _timeout_val="$1" shift if command -v timeout >/dev/null 2>&1; then timeout "$_timeout_val" "$@" || true else "$@" || true fi } HOOK_TIMEOUT="\${PHREN_HOOK_TIMEOUT_S:-${Math.ceil(HOOK_TIMEOUT_MS / 1000)}}s" run_with_timeout "$HOOK_TIMEOUT" ${sessionStartCmd} >/dev/null 2>&1 "$REAL_BIN" "$@" status=$? run_with_timeout "$HOOK_TIMEOUT" ${stopCmd} >/dev/null 2>&1 exit $status `; try { fs.mkdirSync(localBinDir, { recursive: true }); atomicWriteText(wrapperPath, content); fs.chmodSync(wrapperPath, 0o755); return true; } catch (err) { debugLog(`installSessionWrapper: failed for ${tool}: ${errorMessage(err)}`); return false; } } /** * Windows-only: append `~/.local/bin` to the user's persistent PATH if it * isn't already there. On Linux/macOS `~/.local/bin` is on PATH by default * (XDG / `.profile`); on Windows nothing adds it, so the `phren.cmd` wrapper * is invisible to cmd/PowerShell/Git Bash until we fix PATH. * * Reads/writes the User PATH directly from the registry under * `HKCU\Environment` so we (a) keep the original value kind — `REG_EXPAND_SZ` * vs `REG_SZ` — intact, and (b) preserve any `%VAR%` references the user has * in their PATH. Going through `[Environment]::GetEnvironmentVariable` / * `SetEnvironmentVariable` would silently expand `%VAR%` on read and write * the result back as `REG_SZ`, baking expansions in permanently and * downgrading the registry type — corrupting unrelated PATH entries. * * After writing, broadcasts `WM_SETTINGCHANGE` so newly-launched processes * pick up the change without waiting for a logoff. * * Returns: * "added" — PATH was updated (user must open a new terminal). * "already" — dir was already on the user PATH. * "skipped" — not Windows (nothing to do). * "failed" — PowerShell call failed; caller should surface a manual hint. */ export function ensureLocalBinOnWindowsPath() { if (process.platform !== "win32") return "skipped"; const target = homePath(".local", "bin"); const escapedTarget = target.replace(/'/g, "''"); const script = ` $ErrorActionPreference = 'Stop' try { $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true) if ($null -eq $key) { Write-Output 'FAILED'; exit 1 } if (@($key.GetValueNames()) -contains 'Path') { $rawPath = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $kind = $key.GetValueKind('Path') } else { $rawPath = '' $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString } $target = '${escapedTarget}' $targetNorm = ([Environment]::ExpandEnvironmentVariables($target)).TrimEnd('\\').ToLowerInvariant() $present = $false if ($rawPath) { foreach ($entry in ($rawPath -split ';')) { if (-not $entry) { continue } $expanded = ([Environment]::ExpandEnvironmentVariables($entry)).TrimEnd('\\').ToLowerInvariant() if ($expanded -eq $targetNorm) { $present = $true; break } } } if ($present) { $key.Close(); Write-Output 'ALREADY'; exit 0 } $newRaw = if ($rawPath) { $rawPath.TrimEnd(';') + ';' + $target } else { $target } $key.SetValue('Path', $newRaw, $kind) $key.Close() Add-Type -Namespace PhrenInit -Name PathBroadcast -MemberDefinition '[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Auto)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' | Out-Null $r = [System.UIntPtr]::Zero [void][PhrenInit.PathBroadcast]::SendMessageTimeout([System.IntPtr]0xFFFF, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$r) Write-Output 'ADDED' } catch { Write-Output ('FAILED: ' + $_.Exception.Message) exit 1 } `; let out = ""; try { out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: EXEC_TIMEOUT_QUICK_MS, }).trim(); } catch (err) { debugLog(`ensureLocalBinOnWindowsPath: ${errorMessage(err)}`); return "failed"; } const lastLine = out.split(/\r?\n/).pop()?.trim() ?? ""; if (lastLine === "ADDED") return "added"; if (lastLine === "ALREADY") return "already"; debugLog(`ensureLocalBinOnWindowsPath: unexpected output: ${out}`); return "failed"; } /** * Install a lightweight `phren` CLI wrapper at ~/.local/bin/phren so the bare * `phren` command works without a global npm install. The wrapper simply execs * `node <entry_script> "$@"`. */ /** Whether a wrapper may be rewritten for `entry`: always, unless `entry` is * an ephemeral npx copy and the wrapper already runs a lasting entry that exists. */ export function keepsWrapperEntry(existing, entry, exists = fs.existsSync) { if (!isEphemeralNpxPath(entry)) return true; const current = [...existing.matchAll(/exec node '([^']+)'/g)].map(match => match[1]); return !current.some(file => !isEphemeralNpxPath(file) && exists(file)); } export function installPhrenCliWrapper(phrenPath) { const isWindows = process.platform === "win32"; const entry = resolveCliEntryScript(); if (!entry) return false; const localBinDir = homePath(".local", "bin"); const wrapperPath = path.join(localBinDir, isWindows ? "phren.cmd" : "phren"); // Don't overwrite a real global install — only our own wrapper if (fs.existsSync(wrapperPath)) { try { const existing = fs.readFileSync(wrapperPath, "utf8"); if (!existing.includes("PHREN_CLI_WRAPPER")) return false; // A one-off `npx @phren/cli` run must not repoint a wrapper that runs a // lasting install (a checkout's build, a global package): the npx copy // is older more often than not and its cache can vanish. if (!keepsWrapperEntry(existing, entry)) return false; } catch { // File exists but unreadable — don't overwrite, could be a real binary return false; } } const packageSpec = phrenPackageSpec(); const content = isWindows ? `@echo off\r\nrem PHREN_CLI_WRAPPER — managed by phren init; safe to delete\r\nif not defined PHREN_PATH set "PHREN_PATH=${phrenPath}"\r\nif exist "${entry}" (node "${entry}" %*) else (npx -y ${packageSpec} %*)\r\n` : `#!/bin/sh # PHREN_CLI_WRAPPER — managed by phren init; safe to delete set -u PHREN_PATH="\${PHREN_PATH:-${phrenPath}}" export PHREN_PATH if [ -f ${shellEscape(entry)} ]; then exec node ${shellEscape(entry)} "$@" else exec npx -y ${packageSpec} "$@" fi `; try { fs.mkdirSync(localBinDir, { recursive: true }); atomicWriteText(wrapperPath, content); if (!isWindows) fs.chmodSync(wrapperPath, 0o755); return true; } catch (err) { debugLog(`installPhrenCliWrapper: failed: ${errorMessage(err)}`); return false; } } function validateCopilotConfig(config) { return (typeof config.version === "number" && Array.isArray(config.hooks?.sessionStart) && Array.isArray(config.hooks?.userPromptSubmitted) && Array.isArray(config.hooks?.sessionEnd)); } function validateCursorConfig(config) { return (typeof config.version === "number" && typeof config.sessionStart?.command === "string" && typeof config.beforeSubmitPrompt?.command === "string" && typeof config.stop?.command === "string"); } function validateCodexConfig(config) { return (Array.isArray(config.hooks?.SessionStart) && Array.isArray(config.hooks?.UserPromptSubmit) && Array.isArray(config.hooks?.Stop)); } // ── mtime-based install-preferences cache (shared by readHookPreferences + readCustomHooks) ── const _installPrefsJsonCache = new Map(); export function clearHookPrefsCache() { _installPrefsJsonCache.clear(); } function cachedReadInstallPrefsJson(phrenPath) { const prefsPath = installPreferencesFile(phrenPath); let mtimeMs; try { mtimeMs = fs.statSync(prefsPath).mtimeMs; } catch { _installPrefsJsonCache.delete(prefsPath); return null; } const cached = _installPrefsJsonCache.get(prefsPath); if (cached && cached.mtimeMs === mtimeMs) { return cached.parsed; } let parsed; try { parsed = JSON.parse(fs.readFileSync(prefsPath, "utf8")); } catch { _installPrefsJsonCache.delete(prefsPath); return null; } _installPrefsJsonCache.set(prefsPath, { mtimeMs, parsed }); return parsed; } function readHookPreferences(phrenPath) { try { const prefs = cachedReadInstallPrefsJson(phrenPath); if (!prefs) return { enabled: true, toolPrefs: {} }; const enabled = prefs.hooksEnabled !== false; const toolPrefs = prefs.hookTools && typeof prefs.hookTools === "object" ? prefs.hookTools : {}; return { enabled, toolPrefs }; } catch (err) { debugLog(`readHookPreferences: ${errorMessage(err)}`); return { enabled: true, toolPrefs: {} }; } } export function isToolHookEnabled(phrenPath, tool) { const { enabled, toolPrefs } = readHookPreferences(phrenPath); if (!enabled) return false; const key = tool; if (key in toolPrefs) return toolPrefs[key] !== false; return true; } export const HOOK_EVENT_VALUES = [ "pre-save", "post-save", "post-search", "pre-finding", "post-finding", "pre-index", "post-index", "post-session-end", "post-consolidate", "pre-prompt", ]; const VALID_HOOK_EVENTS = new Set(HOOK_EVENT_VALUES); const MAX_HOOK_COMMAND_LENGTH = 1000; /** Return the target (URL or shell command) for display or matching. */ export function getHookTarget(h) { return "webhook" in h ? h.webhook : h.command; } export function validateCustomHookCommand(command) { const trimmed = command.trim(); if (!trimmed) return "Command cannot be empty."; if (trimmed.length > MAX_HOOK_COMMAND_LENGTH) return `Command too long (max ${MAX_HOOK_COMMAND_LENGTH} characters).`; if (/[`$(){}&|;<>\n\r#]/.test(trimmed)) { return "Command contains disallowed shell characters: ` $ ( ) { } & | ; < > # \\n \\r"; } if (/\b(eval|source)\b/.test(trimmed)) return "eval and source are not permitted in hook commands."; if (!/^[\w./~"'"]/.test(trimmed)) return "Command must begin with an executable name or path."; return null; } function normalizeWebhookHostname(hostname) { return hostname.toLowerCase().replace(/^\[|\]$/g, ""); } function isPrivateOrLoopbackIpv4(address) { const octets = address.split(".").map((part) => Number.parseInt(part, 10)); if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; if (octets[0] === 0 || octets[0] === 10 || octets[0] === 127) return true; if (octets[0] === 169 && octets[1] === 254) return true; if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) return true; if (octets[0] === 192 && octets[1] === 168) return true; return false; } function isPrivateOrLoopbackAddress(address) { const normalized = address.toLowerCase(); const ipVersion = isIP(normalized); if (ipVersion === 4) return isPrivateOrLoopbackIpv4(normalized); if (ipVersion !== 6) return false; if (normalized === "::" || normalized === "::1") return true; if (normalized.startsWith("::ffff:")) return true; if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; if (/^fe[89ab]/.test(normalized)) return true; const mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); if (mapped) return isPrivateOrLoopbackIpv4(mapped[1]); return false; } function blockedWebhookHostnameReason(hostname) { const normalized = normalizeWebhookHostname(hostname); if (normalized === "localhost" || normalized.endsWith(".local") || normalized.endsWith(".internal") || /^(0x[0-9a-f]+|0\d+)$/i.test(normalized) || /^\d{8,10}$/.test(normalized)) { return `webhook hostname "${hostname}" is a private or loopback address.`; } if (isPrivateOrLoopbackAddress(normalized)) { return `webhook hostname "${hostname}" is a private or loopback address.`; } return null; } export function validateCustomWebhookUrl(webhook) { let parsed; try { parsed = new URL(webhook.trim()); } catch { return "webhook is not a valid URL."; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return "webhook must be an http:// or https:// URL."; } return blockedWebhookHostnameReason(parsed.hostname); } /** * Validate a webhook URL at execution time and return the resolved IP address * to use for the fetch. Resolving once and re-using the IP prevents DNS * rebinding attacks where the hostname resolves to a safe IP during validation * but a private/loopback IP when fetch performs its own lookup. * * Returns { error } if blocked, or { resolvedUrl, host } if safe. */ async function validateAndResolveWebhook(webhook) { let parsed; try { parsed = new URL(webhook); } catch { return { error: "webhook is not a valid URL." }; } const literalBlock = blockedWebhookHostnameReason(parsed.hostname); if (literalBlock) return { error: literalBlock }; // If the hostname is already a literal IP, validate it directly if (isIP(parsed.hostname)) { if (isPrivateOrLoopbackAddress(parsed.hostname)) { return { error: `webhook hostname "${parsed.hostname}" is a private or loopback address.` }; } return { resolvedUrl: webhook, host: parsed.hostname }; } try { const records = await lookup(parsed.hostname, { all: true, verbatim: true }); if (records.length === 0) { return { error: `webhook hostname "${parsed.hostname}" did not resolve to any address.` }; } if (records.some((record) => isPrivateOrLoopbackAddress(record.address))) { return { error: `webhook hostname "${parsed.hostname}" resolved to a private or loopback address.` }; } // Use the first resolved IP to build a pinned URL, preventing DNS rebinding const resolvedIp = records[0].address; const pinnedUrl = new URL(webhook); pinnedUrl.hostname = records[0].family === 6 ? `[${resolvedIp}]` : resolvedIp; return { resolvedUrl: pinnedUrl.href, host: parsed.host }; } catch (err) { debugLog(`validateAndResolveWebhook lookup failed for ${parsed.hostname}: ${errorMessage(err)}`); return { error: `webhook hostname "${parsed.hostname}" could not be resolved: ${errorMessage(err)}` }; } } const DEFAULT_CUSTOM_HOOK_TIMEOUT = 5000; const HOOK_TIMEOUT_MS = parseInt(process.env.PHREN_HOOK_TIMEOUT_MS || '14000', 10); const HOOK_ERROR_LOG_MAX_LINES = 1000; export function readCustomHooks(phrenPath) { try { const prefs = cachedReadInstallPrefsJson(phrenPath); if (!prefs || !Array.isArray(prefs.customHooks)) return []; return prefs.customHooks.filter((h) => { if (!h || typeof h !== "object") return false; const rec = h; return (typeof rec.event === "string" && VALID_HOOK_EVENTS.has(rec.event) && ((typeof rec.command === "string" && rec.command.trim().length > 0) || (typeof rec.webhook === "string" && rec.webhook.trim().length > 0))); }); } catch (err) { debugLog(`readCustomHooks: ${errorMessage(err)}`); return []; } } /** * Every path that reports a custom-hook failure funnels through here and * through buildHookErrorMessage below, so redaction happens in one place. * * The composed message is the hook's own command plus the child's stderr. * Hook commands legitimately carry inline credentials — `curl -H * "Authorization: Bearer …"` contains none of the shell metacharacters * validateCustomHookCommand rejects — and a failing child prints whatever it * wants to stderr, which execFileSync appends to the thrown error's message. * That string was going verbatim into .runtime/hook-errors.log *and* back to * the MCP caller, i.e. into the agent's context and transcript. */ function buildHookErrorMessage(event, command, detail) { return redactSecretsForLog(`${event}: ${command}: ${detail}`); } function appendHookErrorLog(phrenPath, event, message) { const logPath = runtimeFile(phrenPath, "hook-errors.log"); const line = `[${new Date().toISOString()}] [${event}] ${redactSecretsForLog(message)}\n`; try { withFileLock(logPath, () => { fs.appendFileSync(logPath, line); try { const stat = fs.statSync(logPath); if (stat.size > 200_000) { const content = fs.readFileSync(logPath, "utf-8"); const lines = content.split("\n").filter(Boolean); atomicWriteText(logPath, lines.slice(-HOOK_ERROR_LOG_MAX_LINES).join("\n") + "\n"); } } catch (err) { logger.debug("appendHookErrorLog rotate", errorMessage(err)); } }); } catch (err) { logger.debug("appendHookErrorLog lock", errorMessage(err)); } } export function runCustomHooks(phrenPath, event, env = {}) { const hooks = readCustomHooks(phrenPath); const matching = hooks.filter((h) => h.event === event); const errors = []; const isWindows = process.platform === "win32"; const shellCmd = isWindows ? "cmd" : "sh"; for (const hook of matching) { if ("webhook" in hook) { // Webhook hook: fire-and-forget HTTP POST (async, does not block runCustomHooks) const payload = JSON.stringify({ event, env, timestamp: new Date().toISOString() }); const headers = { "Content-Type": "application/json" }; if (hook.secret) { headers["X-Phren-Signature"] = `sha256=${createHmac("sha256", hook.secret).update(payload).digest("hex")}`; } void validateAndResolveWebhook(hook.webhook) .then((result) => { if ("error" in result && result.error) { const message = `${event}: skipped webhook ${hook.webhook}: ${result.error}`; debugLog(`runCustomHooks webhook: ${message}`); appendHookErrorLog(phrenPath, event, message); return; } // Use the pinned resolved URL to prevent DNS rebinding; // set Host header to the original hostname for correct routing. const { resolvedUrl, host } = result; const fetchHeaders = { ...headers }; if (host) { fetchHeaders["Host"] = host; } return fetch(resolvedUrl, { method: "POST", headers: fetchHeaders, body: payload, redirect: "manual", signal: AbortSignal.timeout(hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT), }); }) .catch((err) => { const message = `${event}: ${hook.webhook}: ${errorMessage(err)}`; debugLog(`runCustomHooks webhook: ${message}`); try { appendHookErrorLog(phrenPath, event, message); } catch (logErr) { logger.debug("runCustomHooks webhookErrorLog", errorMessage(logErr)); } }); continue; } const cmdErr = validateCustomHookCommand(hook.command); if (cmdErr) { const message = `${event}: skipped hook (re-validation failed): ${cmdErr}`; debugLog(`runCustomHooks: ${message}`); errors.push({ code: PhrenError.VALIDATION_ERROR, message }); appendHookErrorLog(phrenPath, event, message); continue; } const shellArgs = isWindows ? ["/c", hook.command] : ["-c", hook.command]; // On Windows, cmd /c expands %VAR% in the command string. // Sanitize env values to prevent shell metacharacter injection. const mergedEnv = { ...process.env, PHREN_PATH: phrenPath, PHREN_HOOK_EVENT: event, ...env }; if (isWindows) { for (const [key, val] of Object.entries(mergedEnv)) { if (typeof val === "string") { mergedEnv[key] = val.replace(/[&|<>^%]/g, ""); } } } try { execFileSync(shellCmd, shellArgs, { cwd: phrenPath, encoding: "utf8", timeout: hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT, env: mergedEnv, stdio: ["ignore", "ignore", "pipe"], }); } catch (err) { const message = buildHookErrorMessage(event, hook.command, errorMessage(err)); debugLog(`runCustomHooks: ${message}`); errors.push({ code: PhrenError.VALIDATION_ERROR, message }); try { appendHookErrorLog(phrenPath, event, errorMessage(err)); } catch (logErr) { logger.debug("runCustomHooks hookErrorLog", errorMessage(logErr)); } } } return { ran: matching.length, errors }; } /** * Read the set of pre-prompt command strings already registered as sibling * UserPromptSubmit entries in Claude Code's settings.json. Used by * runPrePromptHooks to avoid double-running a hook that Claude Code is * already invoking directly (in parallel with phren's own hook). * * Returns an empty set on any error or if the settings file doesn't exist. */ export function getRegisteredPrePromptSiblingCommands() { const out = new Set(); try { const settingsPath = hookConfigPath("claude"); if (!fs.existsSync(settingsPath)) return out; const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")); if (!parsed || typeof parsed !== "object") return out; const hooksObj = parsed.hooks; if (!hooksObj || typeof hooksObj !== "object") return out; const ups = hooksObj.UserPromptSubmit; if (!Array.isArray(ups)) return out; for (const entry of ups) { if (!entry || typeof entry !== "object") continue; const inner = entry.hooks; if (!Array.isArray(inner)) continue; for (const h of inner) { if (h && typeof h === "object") { const cmd = h.command; if (typeof cmd === "string" && cmd.length > 0) out.add(cmd); } } } } catch (err) { debugLog(`getRegisteredPrePromptSiblingCommands: ${errorMessage(err)}`); } return out; } /** * Run pre-prompt custom hooks, piping stdinJson to each and capturing stdout. * Returns concatenated stdout from all matching hooks (empty string if none). * * Skips any hook whose command is already registered as a sibling * UserPromptSubmit entry in Claude Code's settings.json — those are dispatched * directly by Claude Code in parallel with phren's hook, so re-running them * here would duplicate their work and (worse) re-import their slow latency * into phren's response budget. */ export function runPrePromptHooks(phrenPath, stdinJson, siblingCommandsOverride) { const hooks = readCustomHooks(phrenPath); const siblingCommands = siblingCommandsOverride ?? getRegisteredPrePromptSiblingCommands(); const matching = hooks.filter((h) => h.event === "pre-prompt" && "command" in h && !siblingCommands.has(h.command)); if (matching.length === 0) return ""; const isWindows = process.platform === "win32"; const shellCmd = isWindows ? "cmd" : "sh"; const outputs = []; for (const hook of matching) { if (!("command" in hook)) continue; const cmdErr = validateCustomHookCommand(hook.command); if (cmdErr) { debugLog(`runPrePromptHooks: skipped (validation failed): ${cmdErr}`); appendHookErrorLog(phrenPath, "pre-prompt", cmdErr); continue; } const shellArgs = isWindows ? ["/c", hook.command] : ["-c", hook.command]; const mergedEnv = { ...process.env, PHREN_PATH: phrenPath, PHREN_HOOK_EVENT: "pre-prompt" }; if (isWindows) { for (const [key, val] of Object.entries(mergedEnv)) { if (typeof val === "string") { mergedEnv[key] = val.replace(/[&|<>^%]/g, ""); } } } try { const result = execFileSync(shellCmd, shellArgs, { cwd: phrenPath, encoding: "utf8", input: stdinJson, timeout: hook.timeout ?? DEFAULT_CUSTOM_HOOK_TIMEOUT, env: mergedEnv, stdio: ["pipe", "pipe", "pipe"], }); const trimmed = (result ?? "").trim(); if (trimmed) outputs.push(trimmed); } catch (err) { const message = buildHookErrorMessage("pre-prompt", hook.command, errorMessage(err)); debugLog(`runPrePromptHooks: ${message}`); try { appendHookErrorLog(phrenPath, "pre-prompt", errorMessage(err)); } catch (logErr) { logger.debug("runPrePromptHooks hookErrorLog", errorMessage(logErr)); } } } return outputs.join("\n"); } export function configureAllHooks(phrenPath, options = {}) { const configured = []; const detected = options.tools ? options.tools : options.allTools ? new Set(["copilot", "cursor", "codex"]) : detectInstalledTools(); const installWrappers = options.installWrappers ?? resolveManagementCapabilities(phrenPath).installWrappers; const lifecycle = buildLifecycleCommands(phrenPath); // ── GitHub Copilot CLI (user-level: ~/.github/hooks/phren.json) ────────── if (detected.has("copilot")) { // Copilot CLI invokes the `bash` key through Git Bash on Windows, so the // lifecycle commands must use POSIX shell syntax (not cmd's `set "VAR="`). const copilotLifecycleBase = process.platform === "win32" ? buildLifecycleCommands(phrenPath, { forcePosix: true }) : lifecycle; const copilotLifecycle = withHookToolLifecycleCommands(copilotLifecycleBase, "copilot", true); const copilotFile = hookConfigPath("copilot", phrenPath); const copilotHooksDir = path.dirname(copilotFile); try { fs.mkdirSync(copilotHooksDir, { recursive: true }); const config = { version: 1, hooks: { sessionStart: [{ type: "command", bash: copilotLifecycle.sessionStart }], userPromptSubmitted: [{ type: "command", bash: copilotLifecycle.userPromptSubmit }], sessionEnd: [{ type: "command", bash: copilotLifecycle.stop }], }, }; if (!validateCopilotConfig(config)) throw new Error("invalid copilot hook config shape"); atomicWriteText(copilotFile, JSON.stringify(config, null, 2)); configured.push("Copilot CLI"); } catch (err) { console.warn(`configureAllHooks: copilot hook config failed: ${errorMessage(err)}`); } if (installWrappers && isToolHookEnabled(phrenPath, "copilot")) installSessionWrapper("copilot", phrenPath); } // ── Cursor (user-level: ~/.cursor/hooks.json) ──────────────────────────── if (detected.has("cursor")) { const cursorLifecycle = withHookToolLifecycleCommands(lifecycle, "cursor"); const cursorFile = hookConfigPath("cursor", phrenPath); try { fs.mkdirSync(path.dirname(cursorFile), { recursive: true }); let existing = {}; try { existing = JSON.parse(fs.readFileSync(cursorFile, "utf8")); } catch (err) { logger.debug("configureAllHooks cursorRead", errorMessage(err)); } const config = { ...existing, version: 1, // Cursor parity: sessionStart is best-effort where supported; wrapper also enforces lifecycle. sessionStart: { command: cursorLifecycle.sessionStart }, beforeSubmitPrompt: { command: cursorLifecycle.userPromptSubmit }, stop: { command: cursorLifecycle.stop }, }; if (!validateCursorConfig(config)) throw new Error("invalid cursor hook config shape"); atomicWriteText(cursorFile, JSON.stringify(config, null, 2)); configured.push("Cursor"); } catch (err) { console.warn(`configureAllHooks: cursor hook config failed: ${errorMessage(err)}`); } if (installWrappers && isToolHookEnabled(phrenPath, "cursor")) installSessionWrapper("cursor", phrenPath); } // ── Codex (codex.json in phren path) ───────────────────────────────────── if (detected.has("codex")) { const codexFile = hookConfigPath("codex", phrenPath); try { const codexLifecycle = withHookToolLifecycleCommands(buildSharedLifecycleCommands(), "codex"); let existing = {}; try { existing = JSON.parse(fs.readFileSync(codexFile, "utf8")); } catch (err) { logger.debug("configureAllHooks codexRead", errorMessage(err)); } const config = { ...existing, hooks: { SessionStart: [{ type: "command", command: codexLifecycle.sessionStart }], UserPromptSubmit: [{ type: "command", command: codexLifecycle.userPromptSubmit }], Stop: [{ type: "command", command: codexLifecycle.stop }], }, }; if (!validateCodexConfig(config)) throw new Error("invalid codex hook config shape"); atomicWriteText(codexFile, JSON.stringify(config, null, 2)); configured.push("Codex"); } catch (err) { console.warn(`configureAllHooks: codex hook config failed: ${errorMessage(err)}`); } if (installWrappers && isToolHookEnabled(phrenPath, "codex")) installSessionWrapper("codex", phrenPath); } return configured; }