Skip to content

Commit 4fe3c45

Browse files
node9ainawi-25claude
authored
Release/v0.2.0 ready (#3)
* fix: resolve eslint require() errors and format code * 0.2.1 * Rewrote the Proxy/MCP runner to intercept the Agent's (requests) rather than just monitoring the Server's (responses). Dangerous actions are now caught _before_ they reach the target server. * fix: resolve browser approval race, undo engine correctness, and UI init crash - Race condition: autoStartDaemonAndWait now verifies HTTP readiness via GET /settings before returning true, preventing stale-PID false positives - Race condition: openBrowserLocal() called immediately after daemon is HTTP-ready so browser starts loading before POST /check fires, ensuring the SSE 'add' event is delivered to an already-connected client - Race condition: daemon skips openBrowser() when autoStarted=true to avoid duplicate tabs (CLI already opened the browser) - Race condition: 'Abandoned' browser racer result now resolves the race as denied instead of being silently swallowed (caused CLI to hang) - Race condition: SSE reconnect abandon timer raised 2s→10s so a page reload doesn't abandon pending requests before the browser reconnects - Bug fix: cloudBadge null check in SSE 'init' handler — missing DOM element crashed the handler before addCard() ran, causing approval requests to never appear when browser was cold-started - Undo engine: moved snapshot trigger from PostToolUse (log) to PreToolUse (check) so snapshot captures state before AI change, not after (previous timing made undo a no-op) - Undo engine: applyUndo now deletes files created after the snapshot that git restore alone does not remove - Undo engine: expanded STATE_CHANGING_TOOLS list to include str_replace_based_edit_tool and create_file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: fix prettier formatting in undo.ts * fix: applyUndo now deletes untracked files absent from snapshot Previously only tracked files (git ls-files) were checked for deletion, so files created after the snapshot but never committed (e.g. test.txt) survived the undo. Now also queries git ls-files --others --exclude-standard to catch untracked non-ignored files — the same set git add -A captures when building the snapshot tree. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: report local approval/deny back to SaaS to clear PENDING status When a local channel (native popup, browser dashboard, terminal) wins the approval race while cloud is also enforced, the pending SaaS request was never resolved — leaving Mission Control stuck on PENDING forever. Now finish() calls resolveNode9SaaS() (PATCH /intercept/requests/:id) whenever checkedBy !== 'cloud' and a cloudRequestId exists, closing the request immediately with the correct APPROVED/DENIED status. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: cloud audit, config merge fixes, and agentVersion client tracking core.ts: - Fire-and-forget POST /intercept/audit for all local fast-path allows (ignoredTools, sandboxPaths, local-policy, trust) — gives org admins full visibility of calls that never reached the cloud - Fixed config merge: sandboxPaths and ignoredTools now concatenate across layers (global → project → local); dangerousWords replaces (higher wins) - agentVersion context now sent as context.agent so backend can store AI client type (Claude Code, Gemini CLI, Terminal) separately from machine identity cli.ts: - Updated context payload to include agent type metadata for accurate per-client breakdown in Mission Control Agents tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: nadav <isr.nadav@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2e4e92d commit 4fe3c45

File tree

3 files changed

+214
-44
lines changed

3 files changed

+214
-44
lines changed

src/cli.ts

Lines changed: 106 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
checkPause,
1010
pauseNode9,
1111
resumeNode9,
12-
getConfig, // Ensure this is exported from core.ts!
12+
getConfig,
13+
_resetConfigCache,
1314
} from './core';
1415
import { setupClaude, setupGemini, setupCursor } from './setup';
1516
import { startDaemon, stopDaemon, daemonStatus, DAEMON_PORT, DAEMON_HOST } from './daemon/index';
@@ -125,15 +126,27 @@ async function runProxy(targetCommand: string) {
125126
const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
126127

127128
agentIn.on('line', async (line) => {
129+
let message;
130+
131+
// 1. Safely attempt to parse JSON first
128132
try {
129-
const message = JSON.parse(line);
133+
message = JSON.parse(line);
134+
} catch {
135+
// If it's not JSON (raw shell usage), just forward it immediately
136+
child.stdin.write(line + '\n');
137+
return;
138+
}
130139

131-
// If the Agent is trying to call a tool
132-
if (
133-
message.method === 'call_tool' ||
134-
message.method === 'tools/call' ||
135-
message.method === 'use_tool'
136-
) {
140+
// 2. Check if it's an MCP tool call
141+
if (
142+
message.method === 'call_tool' ||
143+
message.method === 'tools/call' ||
144+
message.method === 'use_tool'
145+
) {
146+
// PAUSE the stream so we don't process the next request while waiting for the human
147+
agentIn.pause();
148+
149+
try {
137150
const name = message.params?.name || message.params?.tool_name || 'unknown';
138151
const toolArgs = message.params?.arguments || message.params?.tool_input || {};
139152

@@ -143,7 +156,7 @@ async function runProxy(targetCommand: string) {
143156
});
144157

145158
if (!result.approved) {
146-
// If denied, send the error back to the Agent and DO NOT forward to the server
159+
// If denied, send the MCP error back to the Agent and DO NOT forward to the server
147160
const errorResponse = {
148161
jsonrpc: '2.0',
149162
id: message.id,
@@ -153,15 +166,28 @@ async function runProxy(targetCommand: string) {
153166
},
154167
};
155168
process.stdout.write(JSON.stringify(errorResponse) + '\n');
156-
return; // Stop the command here!
169+
return; // Stop here! (The 'finally' block will handle the resume)
157170
}
171+
} catch {
172+
// FAIL CLOSED SECURITY: If the auth engine crashes, deny the action!
173+
const errorResponse = {
174+
jsonrpc: '2.0',
175+
id: message.id,
176+
error: {
177+
code: -32000,
178+
message: `Node9: Security engine encountered an error. Action blocked for safety.`,
179+
},
180+
};
181+
process.stdout.write(JSON.stringify(errorResponse) + '\n');
182+
return;
183+
} finally {
184+
// 3. GUARANTEE RESUME: Whether approved, denied, or errored, always wake up the stream
185+
agentIn.resume();
158186
}
159-
// If approved or not a tool call, forward it to the server's STDIN
160-
child.stdin.write(line + '\n');
161-
} catch {
162-
// If it's not JSON (raw shell usage), just forward it
163-
child.stdin.write(line + '\n');
164187
}
188+
189+
// If approved or not a tool call, forward it to the real server's STDIN
190+
child.stdin.write(line + '\n');
165191
});
166192

167193
// ── FORWARD OUTPUT (Server -> Agent) ──
@@ -465,19 +491,45 @@ program
465491
try {
466492
if (!raw || raw.trim() === '') process.exit(0);
467493

468-
const payload = JSON.parse(raw) as {
494+
let payload = JSON.parse(raw) as {
469495
tool_name?: string;
470496
tool_input?: unknown;
471497
name?: string;
472498
args?: unknown;
473499
cwd?: string;
500+
session_id?: string;
501+
hook_event_name?: string; // Claude: "PreToolUse" | Gemini: "BeforeTool"
502+
tool_use_id?: string; // Claude-only
503+
permission_mode?: string; // Claude-only
504+
timestamp?: string; // Gemini-only
474505
};
475506

507+
try {
508+
payload = JSON.parse(raw);
509+
} catch (err) {
510+
// If JSON is broken (e.g. half-sent due to timeout), log it and fail open.
511+
// We load config temporarily just to check if debug logging is on.
512+
const tempConfig = getConfig();
513+
if (process.env.NODE9_DEBUG === '1' || tempConfig.settings.enableHookLogDebug) {
514+
const logPath = path.join(os.homedir(), '.node9', 'hook-debug.log');
515+
const errMsg = err instanceof Error ? err.message : String(err);
516+
fs.appendFileSync(
517+
logPath,
518+
`[${new Date().toISOString()}] JSON_PARSE_ERROR: ${errMsg}\nRAW: ${raw}\n`
519+
);
520+
}
521+
process.exit(0);
522+
return;
523+
}
524+
476525
// Change to the project cwd from the hook payload BEFORE loading config,
477526
// so getConfig() finds the correct node9.config.json for that project.
478527
if (payload.cwd) {
479528
try {
480529
process.chdir(payload.cwd);
530+
// Crucial: Reset the config cache so we look for node9.config.json
531+
// in the project folder we just moved into.
532+
_resetConfigCache();
481533
} catch {
482534
// ignore if cwd doesn't exist
483535
}
@@ -495,12 +547,22 @@ program
495547
const toolName = sanitize(payload.tool_name ?? payload.name ?? '');
496548
const toolInput = payload.tool_input ?? payload.args ?? {};
497549

550+
// Both Claude and Gemini send session_id + hook_event_name, but with different values:
551+
// Claude: hook_event_name = "PreToolUse" | "PostToolUse", also sends tool_use_id
552+
// Gemini: hook_event_name = "BeforeTool" | "AfterTool", also sends timestamp
498553
const agent =
499-
payload.tool_name !== undefined
554+
payload.hook_event_name === 'PreToolUse' ||
555+
payload.hook_event_name === 'PostToolUse' ||
556+
payload.tool_use_id !== undefined ||
557+
payload.permission_mode !== undefined
500558
? 'Claude Code'
501-
: payload.name !== undefined
559+
: payload.hook_event_name === 'BeforeTool' ||
560+
payload.hook_event_name === 'AfterTool' ||
561+
payload.timestamp !== undefined
502562
? 'Gemini CLI'
503-
: 'Terminal';
563+
: payload.tool_name !== undefined || payload.name !== undefined
564+
? 'Unknown Agent'
565+
: 'Terminal';
504566
const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
505567
const mcpServer = mcpMatch?.[1];
506568

@@ -646,18 +708,40 @@ program
646708
if (data) {
647709
await processPayload(data);
648710
} else {
711+
// ── THIS IS THE SECTION YOU ARE REPLACING ──
649712
let raw = '';
650713
let processed = false;
714+
let inactivityTimer: NodeJS.Timeout | null = null;
715+
651716
const done = async () => {
717+
// Atomic check: prevents double-processing if 'end' and 'timeout' fire together
652718
if (processed) return;
653719
processed = true;
720+
721+
// Kill the timer so it doesn't fire while we are waiting for human approval
722+
if (inactivityTimer) clearTimeout(inactivityTimer);
723+
654724
if (!raw.trim()) return process.exit(0);
725+
655726
await processPayload(raw);
656727
};
728+
657729
process.stdin.setEncoding('utf-8');
658-
process.stdin.on('data', (chunk) => (raw += chunk));
659-
process.stdin.on('end', () => void done());
660-
setTimeout(() => void done(), 5000);
730+
731+
process.stdin.on('data', (chunk) => {
732+
raw += chunk;
733+
734+
// Sliding window: reset timer every time data arrives
735+
if (inactivityTimer) clearTimeout(inactivityTimer);
736+
inactivityTimer = setTimeout(() => void done(), 2000);
737+
});
738+
739+
process.stdin.on('end', () => {
740+
void done();
741+
});
742+
743+
// Initial safety: if no data arrives at all within 5s, exit.
744+
inactivityTimer = setTimeout(() => void done(), 5000);
661745
}
662746
});
663747

src/core.ts

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -696,23 +696,20 @@ export async function authorizeHeadless(
696696
process.env.NODE9_TESTING === '1'
697697
);
698698

699-
// Get the actual config from file/defaults
700-
const approvers = isTestEnv
701-
? {
702-
native: false,
703-
browser: false,
704-
cloud: config.settings.approvers?.cloud ?? true,
705-
terminal: false,
706-
}
707-
: config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true };
699+
// 2. Clone the config object!
700+
// This prevents us from accidentally mutating the global config cache.
701+
const approvers = {
702+
...(config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }),
703+
};
708704

709-
// 2. THE TEST SILENCER: If we are in a test environment, hard-disable all physical UIs.
710-
// We leave 'cloud' alone so your SaaS/Cloud tests can still manage it via mock configs!
711-
if (process.env.VITEST || process.env.NODE_ENV === 'test' || process.env.NODE9_TESTING === '1') {
705+
// 3. THE TEST SILENCER: Hard-disable all physical UIs in test/CI environments.
706+
// We leave 'cloud' untouched so your SaaS/Cloud tests can still manage it via mock configs.
707+
if (isTestEnv) {
712708
approvers.native = false;
713709
approvers.browser = false;
714710
approvers.terminal = false;
715711
}
712+
716713
const isManual = meta?.agent === 'Terminal';
717714

718715
let explainableLabel = 'Local Config';
@@ -733,14 +730,23 @@ export async function authorizeHeadless(
733730

734731
// Fast Paths (Ignore, Trust, Policy Allow)
735732
if (!isIgnoredTool(toolName)) {
736-
if (getActiveTrustSession(toolName)) return { approved: true, checkedBy: 'trust' };
733+
if (getActiveTrustSession(toolName)) {
734+
if (creds?.apiKey) auditLocalAllow(toolName, args, 'trust', creds, meta);
735+
return { approved: true, checkedBy: 'trust' };
736+
}
737737
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
738-
if (policyResult.decision === 'allow') return { approved: true, checkedBy: 'local-policy' };
738+
if (policyResult.decision === 'allow') {
739+
if (creds?.apiKey) auditLocalAllow(toolName, args, 'local-policy', creds, meta);
740+
return { approved: true, checkedBy: 'local-policy' };
741+
}
739742

740743
explainableLabel = policyResult.blockedByLabel || 'Local Config';
741744

742745
const persistent = getPersistentDecision(toolName);
743-
if (persistent === 'allow') return { approved: true, checkedBy: 'persistent' };
746+
if (persistent === 'allow') {
747+
if (creds?.apiKey) auditLocalAllow(toolName, args, 'persistent', creds, meta);
748+
return { approved: true, checkedBy: 'persistent' };
749+
}
744750
if (persistent === 'deny') {
745751
return {
746752
approved: false,
@@ -750,6 +756,7 @@ export async function authorizeHeadless(
750756
};
751757
}
752758
} else {
759+
if (creds?.apiKey) auditLocalAllow(toolName, args, 'ignoredTools', creds, meta);
753760
return { approved: true };
754761
}
755762

@@ -807,7 +814,7 @@ export async function authorizeHeadless(
807814
console.error(
808815
chalk.yellow('\n🛡️ Node9: Action suspended — waiting for Organization approval.')
809816
);
810-
console.error(chalk.cyan(' Dashboard → ') + chalk.bold('Mission Control > Flows\n'));
817+
console.error(chalk.cyan(' Dashboard → ') + chalk.bold('Mission Control > Activity Feed\n'));
811818
} else if (!cloudEnforced) {
812819
const cloudOffReason = !creds?.apiKey
813820
? 'no API key — run `node9 login` to connect'
@@ -1006,6 +1013,7 @@ export async function authorizeHeadless(
10061013
() => null
10071014
);
10081015
}
1016+
10091017
resolve(res);
10101018
}
10111019
};
@@ -1038,6 +1046,15 @@ export async function authorizeHeadless(
10381046
}
10391047
});
10401048

1049+
// If a LOCAL channel (native/browser/terminal) won while the cloud had a
1050+
// pending request open, report the decision back to the SaaS so Mission
1051+
// Control doesn't stay stuck on PENDING forever.
1052+
// We await this (not fire-and-forget) because the CLI process may exit
1053+
// immediately after this function returns, killing any in-flight fetch.
1054+
if (cloudRequestId && creds && finalResult.checkedBy !== 'cloud') {
1055+
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
1056+
}
1057+
10411058
return finalResult;
10421059
}
10431060

@@ -1088,9 +1105,9 @@ export function getConfig(): Config {
10881105
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
10891106
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
10901107

1091-
if (p.sandboxPaths) mergedPolicy.sandboxPaths = [...p.sandboxPaths];
1108+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
10921109
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
1093-
if (p.ignoredTools) mergedPolicy.ignoredTools = [...p.ignoredTools];
1110+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
10941111

10951112
if (p.toolInspection)
10961113
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
@@ -1172,6 +1189,39 @@ export interface CloudApprovalResult {
11721189
remoteApprovalOnly?: boolean;
11731190
}
11741191

1192+
/**
1193+
* Fire-and-forget: send an audit record to the backend for a locally fast-pathed call.
1194+
* Never blocks the agent — failures are silently ignored.
1195+
*/
1196+
function auditLocalAllow(
1197+
toolName: string,
1198+
args: unknown,
1199+
checkedBy: string,
1200+
creds: { apiKey: string; apiUrl: string },
1201+
meta?: { agent?: string; mcpServer?: string }
1202+
): void {
1203+
const controller = new AbortController();
1204+
setTimeout(() => controller.abort(), 5000);
1205+
1206+
fetch(`${creds.apiUrl}/audit`, {
1207+
method: 'POST',
1208+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${creds.apiKey}` },
1209+
body: JSON.stringify({
1210+
toolName,
1211+
args,
1212+
checkedBy,
1213+
context: {
1214+
agent: meta?.agent,
1215+
mcpServer: meta?.mcpServer,
1216+
hostname: os.hostname(),
1217+
cwd: process.cwd(),
1218+
platform: os.platform(),
1219+
},
1220+
}),
1221+
signal: controller.signal,
1222+
}).catch(() => {});
1223+
}
1224+
11751225
/**
11761226
* STEP 1: The Handshake. Runs BEFORE the local UI is spawned to check for locks.
11771227
*/
@@ -1269,3 +1319,28 @@ async function pollNode9SaaS(
12691319
}
12701320
return { approved: false, reason: 'Cloud approval timed out after 10 minutes.' };
12711321
}
1322+
1323+
/**
1324+
* Reports a locally-made decision (native/browser/terminal) back to the SaaS
1325+
* so the pending request doesn't stay stuck in Mission Control.
1326+
*/
1327+
async function resolveNode9SaaS(
1328+
requestId: string,
1329+
creds: { apiKey: string; apiUrl: string },
1330+
approved: boolean
1331+
): Promise<void> {
1332+
try {
1333+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
1334+
const ctrl = new AbortController();
1335+
const timer = setTimeout(() => ctrl.abort(), 5000);
1336+
await fetch(resolveUrl, {
1337+
method: 'PATCH',
1338+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${creds.apiKey}` },
1339+
body: JSON.stringify({ decision: approved ? 'APPROVED' : 'DENIED' }),
1340+
signal: ctrl.signal,
1341+
});
1342+
clearTimeout(timer);
1343+
} catch {
1344+
/* fire-and-forget — don't block the proxy on a network error */
1345+
}
1346+
}

0 commit comments

Comments
 (0)