diff --git a/src/cli.ts b/src/cli.ts index eadf851..27e0a16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,7 +9,8 @@ import { checkPause, pauseNode9, resumeNode9, - getConfig, // Ensure this is exported from core.ts! + getConfig, + _resetConfigCache, } from './core'; import { setupClaude, setupGemini, setupCursor } from './setup'; import { startDaemon, stopDaemon, daemonStatus, DAEMON_PORT, DAEMON_HOST } from './daemon/index'; @@ -125,15 +126,27 @@ async function runProxy(targetCommand: string) { const agentIn = readline.createInterface({ input: process.stdin, terminal: false }); agentIn.on('line', async (line) => { + let message; + + // 1. Safely attempt to parse JSON first try { - const message = JSON.parse(line); + message = JSON.parse(line); + } catch { + // If it's not JSON (raw shell usage), just forward it immediately + child.stdin.write(line + '\n'); + return; + } - // If the Agent is trying to call a tool - if ( - message.method === 'call_tool' || - message.method === 'tools/call' || - message.method === 'use_tool' - ) { + // 2. Check if it's an MCP tool call + if ( + message.method === 'call_tool' || + message.method === 'tools/call' || + message.method === 'use_tool' + ) { + // PAUSE the stream so we don't process the next request while waiting for the human + agentIn.pause(); + + try { const name = message.params?.name || message.params?.tool_name || 'unknown'; const toolArgs = message.params?.arguments || message.params?.tool_input || {}; @@ -143,7 +156,7 @@ async function runProxy(targetCommand: string) { }); if (!result.approved) { - // If denied, send the error back to the Agent and DO NOT forward to the server + // If denied, send the MCP error back to the Agent and DO NOT forward to the server const errorResponse = { jsonrpc: '2.0', id: message.id, @@ -153,15 +166,28 @@ async function runProxy(targetCommand: string) { }, }; process.stdout.write(JSON.stringify(errorResponse) + '\n'); - return; // Stop the command here! + return; // Stop here! (The 'finally' block will handle the resume) } + } catch { + // FAIL CLOSED SECURITY: If the auth engine crashes, deny the action! + const errorResponse = { + jsonrpc: '2.0', + id: message.id, + error: { + code: -32000, + message: `Node9: Security engine encountered an error. Action blocked for safety.`, + }, + }; + process.stdout.write(JSON.stringify(errorResponse) + '\n'); + return; + } finally { + // 3. GUARANTEE RESUME: Whether approved, denied, or errored, always wake up the stream + agentIn.resume(); } - // If approved or not a tool call, forward it to the server's STDIN - child.stdin.write(line + '\n'); - } catch { - // If it's not JSON (raw shell usage), just forward it - child.stdin.write(line + '\n'); } + + // If approved or not a tool call, forward it to the real server's STDIN + child.stdin.write(line + '\n'); }); // ── FORWARD OUTPUT (Server -> Agent) ── @@ -465,19 +491,45 @@ program try { if (!raw || raw.trim() === '') process.exit(0); - const payload = JSON.parse(raw) as { + let payload = JSON.parse(raw) as { tool_name?: string; tool_input?: unknown; name?: string; args?: unknown; cwd?: string; + session_id?: string; + hook_event_name?: string; // Claude: "PreToolUse" | Gemini: "BeforeTool" + tool_use_id?: string; // Claude-only + permission_mode?: string; // Claude-only + timestamp?: string; // Gemini-only }; + try { + payload = JSON.parse(raw); + } catch (err) { + // If JSON is broken (e.g. half-sent due to timeout), log it and fail open. + // We load config temporarily just to check if debug logging is on. + const tempConfig = getConfig(); + if (process.env.NODE9_DEBUG === '1' || tempConfig.settings.enableHookLogDebug) { + const logPath = path.join(os.homedir(), '.node9', 'hook-debug.log'); + const errMsg = err instanceof Error ? err.message : String(err); + fs.appendFileSync( + logPath, + `[${new Date().toISOString()}] JSON_PARSE_ERROR: ${errMsg}\nRAW: ${raw}\n` + ); + } + process.exit(0); + return; + } + // Change to the project cwd from the hook payload BEFORE loading config, // so getConfig() finds the correct node9.config.json for that project. if (payload.cwd) { try { process.chdir(payload.cwd); + // Crucial: Reset the config cache so we look for node9.config.json + // in the project folder we just moved into. + _resetConfigCache(); } catch { // ignore if cwd doesn't exist } @@ -495,12 +547,22 @@ program const toolName = sanitize(payload.tool_name ?? payload.name ?? ''); const toolInput = payload.tool_input ?? payload.args ?? {}; + // Both Claude and Gemini send session_id + hook_event_name, but with different values: + // Claude: hook_event_name = "PreToolUse" | "PostToolUse", also sends tool_use_id + // Gemini: hook_event_name = "BeforeTool" | "AfterTool", also sends timestamp const agent = - payload.tool_name !== undefined + payload.hook_event_name === 'PreToolUse' || + payload.hook_event_name === 'PostToolUse' || + payload.tool_use_id !== undefined || + payload.permission_mode !== undefined ? 'Claude Code' - : payload.name !== undefined + : payload.hook_event_name === 'BeforeTool' || + payload.hook_event_name === 'AfterTool' || + payload.timestamp !== undefined ? 'Gemini CLI' - : 'Terminal'; + : payload.tool_name !== undefined || payload.name !== undefined + ? 'Unknown Agent' + : 'Terminal'; const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i); const mcpServer = mcpMatch?.[1]; @@ -646,18 +708,40 @@ program if (data) { await processPayload(data); } else { + // ── THIS IS THE SECTION YOU ARE REPLACING ── let raw = ''; let processed = false; + let inactivityTimer: NodeJS.Timeout | null = null; + const done = async () => { + // Atomic check: prevents double-processing if 'end' and 'timeout' fire together if (processed) return; processed = true; + + // Kill the timer so it doesn't fire while we are waiting for human approval + if (inactivityTimer) clearTimeout(inactivityTimer); + if (!raw.trim()) return process.exit(0); + await processPayload(raw); }; + process.stdin.setEncoding('utf-8'); - process.stdin.on('data', (chunk) => (raw += chunk)); - process.stdin.on('end', () => void done()); - setTimeout(() => void done(), 5000); + + process.stdin.on('data', (chunk) => { + raw += chunk; + + // Sliding window: reset timer every time data arrives + if (inactivityTimer) clearTimeout(inactivityTimer); + inactivityTimer = setTimeout(() => void done(), 2000); + }); + + process.stdin.on('end', () => { + void done(); + }); + + // Initial safety: if no data arrives at all within 5s, exit. + inactivityTimer = setTimeout(() => void done(), 5000); } }); diff --git a/src/core.ts b/src/core.ts index 532b776..c052007 100644 --- a/src/core.ts +++ b/src/core.ts @@ -696,23 +696,20 @@ export async function authorizeHeadless( process.env.NODE9_TESTING === '1' ); - // Get the actual config from file/defaults - const approvers = isTestEnv - ? { - native: false, - browser: false, - cloud: config.settings.approvers?.cloud ?? true, - terminal: false, - } - : config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }; + // 2. Clone the config object! + // This prevents us from accidentally mutating the global config cache. + const approvers = { + ...(config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }), + }; - // 2. THE TEST SILENCER: If we are in a test environment, hard-disable all physical UIs. - // We leave 'cloud' alone so your SaaS/Cloud tests can still manage it via mock configs! - if (process.env.VITEST || process.env.NODE_ENV === 'test' || process.env.NODE9_TESTING === '1') { + // 3. THE TEST SILENCER: Hard-disable all physical UIs in test/CI environments. + // We leave 'cloud' untouched so your SaaS/Cloud tests can still manage it via mock configs. + if (isTestEnv) { approvers.native = false; approvers.browser = false; approvers.terminal = false; } + const isManual = meta?.agent === 'Terminal'; let explainableLabel = 'Local Config'; @@ -733,14 +730,23 @@ export async function authorizeHeadless( // Fast Paths (Ignore, Trust, Policy Allow) if (!isIgnoredTool(toolName)) { - if (getActiveTrustSession(toolName)) return { approved: true, checkedBy: 'trust' }; + if (getActiveTrustSession(toolName)) { + if (creds?.apiKey) auditLocalAllow(toolName, args, 'trust', creds, meta); + return { approved: true, checkedBy: 'trust' }; + } const policyResult = await evaluatePolicy(toolName, args, meta?.agent); - if (policyResult.decision === 'allow') return { approved: true, checkedBy: 'local-policy' }; + if (policyResult.decision === 'allow') { + if (creds?.apiKey) auditLocalAllow(toolName, args, 'local-policy', creds, meta); + return { approved: true, checkedBy: 'local-policy' }; + } explainableLabel = policyResult.blockedByLabel || 'Local Config'; const persistent = getPersistentDecision(toolName); - if (persistent === 'allow') return { approved: true, checkedBy: 'persistent' }; + if (persistent === 'allow') { + if (creds?.apiKey) auditLocalAllow(toolName, args, 'persistent', creds, meta); + return { approved: true, checkedBy: 'persistent' }; + } if (persistent === 'deny') { return { approved: false, @@ -750,6 +756,7 @@ export async function authorizeHeadless( }; } } else { + if (creds?.apiKey) auditLocalAllow(toolName, args, 'ignoredTools', creds, meta); return { approved: true }; } @@ -807,7 +814,7 @@ export async function authorizeHeadless( console.error( chalk.yellow('\n🛡️ Node9: Action suspended — waiting for Organization approval.') ); - console.error(chalk.cyan(' Dashboard → ') + chalk.bold('Mission Control > Flows\n')); + console.error(chalk.cyan(' Dashboard → ') + chalk.bold('Mission Control > Activity Feed\n')); } else if (!cloudEnforced) { const cloudOffReason = !creds?.apiKey ? 'no API key — run `node9 login` to connect' @@ -1006,6 +1013,7 @@ export async function authorizeHeadless( () => null ); } + resolve(res); } }; @@ -1038,6 +1046,15 @@ export async function authorizeHeadless( } }); + // If a LOCAL channel (native/browser/terminal) won while the cloud had a + // pending request open, report the decision back to the SaaS so Mission + // Control doesn't stay stuck on PENDING forever. + // We await this (not fire-and-forget) because the CLI process may exit + // immediately after this function returns, killing any in-flight fetch. + if (cloudRequestId && creds && finalResult.checkedBy !== 'cloud') { + await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved); + } + return finalResult; } @@ -1088,9 +1105,9 @@ export function getConfig(): Config { mergedSettings.enableHookLogDebug = s.enableHookLogDebug; if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers }; - if (p.sandboxPaths) mergedPolicy.sandboxPaths = [...p.sandboxPaths]; + if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths); if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords]; - if (p.ignoredTools) mergedPolicy.ignoredTools = [...p.ignoredTools]; + if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools); if (p.toolInspection) mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection }; @@ -1172,6 +1189,39 @@ export interface CloudApprovalResult { remoteApprovalOnly?: boolean; } +/** + * Fire-and-forget: send an audit record to the backend for a locally fast-pathed call. + * Never blocks the agent — failures are silently ignored. + */ +function auditLocalAllow( + toolName: string, + args: unknown, + checkedBy: string, + creds: { apiKey: string; apiUrl: string }, + meta?: { agent?: string; mcpServer?: string } +): void { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 5000); + + fetch(`${creds.apiUrl}/audit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${creds.apiKey}` }, + body: JSON.stringify({ + toolName, + args, + checkedBy, + context: { + agent: meta?.agent, + mcpServer: meta?.mcpServer, + hostname: os.hostname(), + cwd: process.cwd(), + platform: os.platform(), + }, + }), + signal: controller.signal, + }).catch(() => {}); +} + /** * STEP 1: The Handshake. Runs BEFORE the local UI is spawned to check for locks. */ @@ -1269,3 +1319,28 @@ async function pollNode9SaaS( } return { approved: false, reason: 'Cloud approval timed out after 10 minutes.' }; } + +/** + * Reports a locally-made decision (native/browser/terminal) back to the SaaS + * so the pending request doesn't stay stuck in Mission Control. + */ +async function resolveNode9SaaS( + requestId: string, + creds: { apiKey: string; apiUrl: string }, + approved: boolean +): Promise { + try { + const resolveUrl = `${creds.apiUrl}/requests/${requestId}`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 5000); + await fetch(resolveUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${creds.apiKey}` }, + body: JSON.stringify({ decision: approved ? 'APPROVED' : 'DENIED' }), + signal: ctrl.signal, + }); + clearTimeout(timer); + } catch { + /* fire-and-forget — don't block the proxy on a network error */ + } +} diff --git a/src/undo.ts b/src/undo.ts index e233a13..e362046 100644 --- a/src/undo.ts +++ b/src/undo.ts @@ -70,10 +70,21 @@ export function applyUndo(hash: string): boolean { const lsTree = spawnSync('git', ['ls-tree', '-r', '--name-only', hash]); const snapshotFiles = new Set(lsTree.stdout.toString().trim().split('\n').filter(Boolean)); - // 3. Find currently tracked files that weren't in the snapshot → delete them - const lsCurrent = spawnSync('git', ['ls-files']); - const currentFiles = lsCurrent.stdout.toString().trim().split('\n').filter(Boolean); - for (const file of currentFiles) { + // 3. Delete files that weren't in the snapshot. + // Must cover both tracked files (git ls-files) AND untracked non-ignored + // files (git ls-files --others --exclude-standard), since `git add -A` + // captures both and `git restore` doesn't remove either category. + const tracked = spawnSync('git', ['ls-files']) + .stdout.toString() + .trim() + .split('\n') + .filter(Boolean); + const untracked = spawnSync('git', ['ls-files', '--others', '--exclude-standard']) + .stdout.toString() + .trim() + .split('\n') + .filter(Boolean); + for (const file of [...tracked, ...untracked]) { if (!snapshotFiles.has(file) && fs.existsSync(file)) { fs.unlinkSync(file); }