Skip to content
Merged
128 changes: 106 additions & 22 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 || {};

Expand All @@ -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,
Expand All @@ -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) ──
Expand Down Expand Up @@ -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
}
Expand All @@ -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];

Expand Down Expand Up @@ -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);
}
});

Expand Down
111 changes: 93 additions & 18 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -750,6 +756,7 @@ export async function authorizeHeadless(
};
}
} else {
if (creds?.apiKey) auditLocalAllow(toolName, args, 'ignoredTools', creds, meta);
return { approved: true };
}

Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1006,6 +1013,7 @@ export async function authorizeHeadless(
() => null
);
}

resolve(res);
}
};
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<void> {
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 */
}
}
Loading