Skip to content

Commit 430db8d

Browse files
nawi-25claude
andcommitted
feat: Context Sniper UI parity — native popup, browser daemon, cloud
- context-sniper.ts (new): shared RiskMetadata type, smartTruncate, extractContext (returns {snippet, lineIndex}), computeRiskMetadata - native.ts: import from context-sniper, use .snippet on extractContext calls - core.ts: add tier to evaluatePolicy returns; compute riskMetadata once in authorizeHeadless; pass it to initNode9SaaS, askDaemon, notifyDaemonViewer - daemon/index.ts: store and broadcast riskMetadata in PendingEntry - daemon/ui.html: renderPayload() uses riskMetadata for intent badge, tier, file path, annotated snippet, matched-word highlight; falls back to raw args Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e4d2cdc commit 430db8d

File tree

5 files changed

+269
-68
lines changed

5 files changed

+269
-68
lines changed

src/context-sniper.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// src/context-sniper.ts
2+
// Shared Context Sniper module.
3+
// Pre-computes the code snippet and intent ONCE in authorizeHeadless (core.ts),
4+
// then the resulting RiskMetadata bundle flows to every approval channel:
5+
// native popup, browser daemon, cloud/SaaS backend, Slack, and Mission Control.
6+
7+
import path from 'path';
8+
9+
export interface RiskMetadata {
10+
intent: 'EDIT' | 'EXEC';
11+
tier: 1 | 2 | 3 | 4 | 5 | 6 | 7;
12+
blockedByLabel: string;
13+
matchedWord?: string;
14+
matchedField?: string;
15+
contextSnippet?: string; // Pre-computed 7-line window with 🛑 marker
16+
contextLineIndex?: number; // Index of the 🛑 line within the snippet (0-based)
17+
editFileName?: string; // basename of file_path (EDIT intent only)
18+
editFilePath?: string; // full file_path (EDIT intent only)
19+
ruleName?: string; // Tier 2 (Smart Rules) only
20+
}
21+
22+
/** Keeps the start and end of a long string, truncating the middle. */
23+
export function smartTruncate(str: string, maxLen = 500): string {
24+
if (str.length <= maxLen) return str;
25+
const edge = Math.floor(maxLen / 2) - 3;
26+
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
27+
}
28+
29+
/**
30+
* Returns the 7-line context window centred on matchedWord, plus the
31+
* 0-based index of the hit line within the returned snippet.
32+
* If the text is short or the word isn't found, returns the full text and lineIndex -1.
33+
*/
34+
export function extractContext(
35+
text: string,
36+
matchedWord?: string,
37+
): { snippet: string; lineIndex: number } {
38+
const lines = text.split('\n');
39+
if (lines.length <= 7 || !matchedWord) {
40+
return { snippet: smartTruncate(text, 500), lineIndex: -1 };
41+
}
42+
43+
const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44+
const pattern = new RegExp(`\\b${escaped}\\b`, 'i');
45+
46+
const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
47+
if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
48+
49+
// Prefer non-comment lines so we highlight actual code, not documentation
50+
const nonComment = allHits.find(({ line }) => {
51+
const trimmed = line.trim();
52+
return !trimmed.startsWith('//') && !trimmed.startsWith('#');
53+
});
54+
const hitIndex = (nonComment ?? allHits[0]).i;
55+
56+
const start = Math.max(0, hitIndex - 3);
57+
const end = Math.min(lines.length, hitIndex + 4);
58+
const lineIndex = hitIndex - start;
59+
60+
const snippet = lines
61+
.slice(start, end)
62+
.map((line, i) => `${start + i === hitIndex ? '🛑 ' : ' '}${line}`)
63+
.join('\n');
64+
65+
const head = start > 0 ? `... [${start} lines hidden] ...\n` : '';
66+
const tail = end < lines.length ? `\n... [${lines.length - end} lines hidden] ...` : '';
67+
68+
return { snippet: `${head}${snippet}${tail}`, lineIndex };
69+
}
70+
71+
const CODE_KEYS = [
72+
'command', 'cmd', 'shell_command', 'bash_command', 'script',
73+
'code', 'input', 'sql', 'query', 'arguments', 'args', 'param', 'params', 'text',
74+
];
75+
76+
/**
77+
* Computes the RiskMetadata bundle from args + policy result fields.
78+
* Called once in authorizeHeadless; the result is forwarded unchanged to all channels.
79+
*/
80+
export function computeRiskMetadata(
81+
args: unknown,
82+
tier: RiskMetadata['tier'],
83+
blockedByLabel: string,
84+
matchedField?: string,
85+
matchedWord?: string,
86+
ruleName?: string,
87+
): RiskMetadata {
88+
let intent: 'EDIT' | 'EXEC' = 'EXEC';
89+
let contextSnippet: string | undefined;
90+
let contextLineIndex: number | undefined;
91+
let editFileName: string | undefined;
92+
let editFilePath: string | undefined;
93+
94+
// Handle Gemini-style stringified JSON
95+
let parsed = args;
96+
if (typeof args === 'string') {
97+
const trimmed = args.trim();
98+
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
99+
try { parsed = JSON.parse(trimmed); } catch { /* keep as string */ }
100+
}
101+
}
102+
103+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
104+
const obj = parsed as Record<string, unknown>;
105+
106+
if (obj.old_string !== undefined && obj.new_string !== undefined) {
107+
// EDIT intent — extract context from the incoming new_string
108+
intent = 'EDIT';
109+
if (obj.file_path) {
110+
editFilePath = String(obj.file_path);
111+
editFileName = path.basename(editFilePath);
112+
}
113+
const result = extractContext(String(obj.new_string), matchedWord);
114+
contextSnippet = result.snippet;
115+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
116+
117+
} else if (matchedField && obj[matchedField] !== undefined) {
118+
// EXEC — we know which field triggered, extract context from it
119+
const result = extractContext(String(obj[matchedField]), matchedWord);
120+
contextSnippet = result.snippet;
121+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
122+
123+
} else {
124+
// EXEC fallback — pick the first recognisable code-like key
125+
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
126+
if (foundKey) {
127+
const val = obj[foundKey];
128+
contextSnippet = smartTruncate(typeof val === 'string' ? val : JSON.stringify(val), 500);
129+
}
130+
}
131+
} else if (typeof parsed === 'string') {
132+
contextSnippet = smartTruncate(parsed, 500);
133+
}
134+
135+
return {
136+
intent,
137+
tier,
138+
blockedByLabel,
139+
...(matchedWord && { matchedWord }),
140+
...(matchedField && { matchedField }),
141+
...(contextSnippet !== undefined && { contextSnippet }),
142+
...(contextLineIndex !== undefined && { contextLineIndex }),
143+
...(editFileName && { editFileName }),
144+
...(editFilePath && { editFilePath }),
145+
...(ruleName && { ruleName }),
146+
};
147+
}

src/core.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import os from 'os';
77
import pm from 'picomatch';
88
import { parse } from 'sh-syntax';
99
import { askNativePopup, sendDesktopNotification } from './ui/native';
10+
import { computeRiskMetadata, RiskMetadata } from './context-sniper';
1011

1112
// ── Feature file paths ────────────────────────────────────────────────────────
1213
const PAUSED_FILE = path.join(os.homedir(), '.node9', 'PAUSED');
@@ -639,6 +640,8 @@ export async function evaluatePolicy(
639640
reason?: string;
640641
matchedField?: string;
641642
matchedWord?: string;
643+
tier?: 1 | 2 | 3 | 4 | 5 | 6 | 7;
644+
ruleName?: string;
642645
}> {
643646
const config = getConfig();
644647

@@ -656,6 +659,8 @@ export async function evaluatePolicy(
656659
decision: matchedRule.verdict,
657660
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
658661
reason: matchedRule.reason,
662+
tier: 2,
663+
ruleName: matchedRule.name ?? matchedRule.tool,
659664
};
660665
}
661666
}
@@ -675,7 +680,7 @@ export async function evaluatePolicy(
675680
// Inline arbitrary code execution is always a review
676681
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
677682
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
678-
return { decision: 'review', blockedByLabel: 'Node9 Standard (Inline Execution)' };
683+
return { decision: 'review', blockedByLabel: 'Node9 Standard (Inline Execution)', tier: 3 };
679684
}
680685

681686
// Strip DML keywords from tokens so user dangerousWords like "delete"/"update"
@@ -714,7 +719,7 @@ export async function evaluatePolicy(
714719
if (hasSystemDisaster || isRootWipe) {
715720
// If it IS a system disaster, return review so the dev gets a
716721
// "Manual Nuclear Protection" popup as a final safety check.
717-
return { decision: 'review', blockedByLabel: 'Manual Nuclear Protection' };
722+
return { decision: 'review', blockedByLabel: 'Manual Nuclear Protection', tier: 3 };
718723
}
719724

720725
// For everything else (docker, psql, rmdir, delete, rm),
@@ -740,13 +745,15 @@ export async function evaluatePolicy(
740745
return {
741746
decision: 'review',
742747
blockedByLabel: `Project/Global Config — rule "${rule.action}" (path blocked)`,
748+
tier: 5,
743749
};
744750
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
745751
if (allAllowed) return { decision: 'allow' };
746752
}
747753
return {
748754
decision: 'review',
749755
blockedByLabel: `Project/Global Config — rule "${rule.action}" (default block)`,
756+
tier: 5,
750757
};
751758
}
752759
}
@@ -798,14 +805,15 @@ export async function evaluatePolicy(
798805
blockedByLabel: `Project/Global Config — dangerous word: "${matchedDangerousWord}"`,
799806
matchedWord: matchedDangerousWord,
800807
matchedField,
808+
tier: 6,
801809
};
802810
}
803811

804812
// ── 7. Strict Mode Fallback ─────────────────────────────────────────────
805813
if (config.settings.mode === 'strict') {
806814
const envConfig = getActiveEnvironment(config);
807815
if (envConfig?.requireApproval === false) return { decision: 'allow' };
808-
return { decision: 'review', blockedByLabel: 'Global Config (Strict Mode Active)' };
816+
return { decision: 'review', blockedByLabel: 'Global Config (Strict Mode Active)', tier: 7 };
809817
}
810818

811819
return { decision: 'allow' };
@@ -1215,7 +1223,8 @@ async function askDaemon(
12151223
toolName: string,
12161224
args: unknown,
12171225
meta?: { agent?: string; mcpServer?: string },
1218-
signal?: AbortSignal // NEW: Added signal
1226+
signal?: AbortSignal,
1227+
riskMetadata?: RiskMetadata
12191228
): Promise<'allow' | 'deny' | 'abandoned'> {
12201229
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
12211230

@@ -1229,7 +1238,7 @@ async function askDaemon(
12291238
const checkRes = await fetch(`${base}/check`, {
12301239
method: 'POST',
12311240
headers: { 'Content-Type': 'application/json' },
1232-
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
1241+
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer, ...(riskMetadata && { riskMetadata }) }),
12331242
signal: checkCtrl.signal,
12341243
});
12351244
if (!checkRes.ok) throw new Error('Daemon fail');
@@ -1261,7 +1270,8 @@ async function askDaemon(
12611270
async function notifyDaemonViewer(
12621271
toolName: string,
12631272
args: unknown,
1264-
meta?: { agent?: string; mcpServer?: string }
1273+
meta?: { agent?: string; mcpServer?: string },
1274+
riskMetadata?: RiskMetadata
12651275
): Promise<string> {
12661276
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
12671277
const res = await fetch(`${base}/check`, {
@@ -1273,6 +1283,7 @@ async function notifyDaemonViewer(
12731283
slackDelegated: true,
12741284
agent: meta?.agent,
12751285
mcpServer: meta?.mcpServer,
1286+
...(riskMetadata && { riskMetadata }),
12761287
}),
12771288
signal: AbortSignal.timeout(3000),
12781289
});
@@ -1381,6 +1392,7 @@ export async function authorizeHeadless(
13811392
let explainableLabel = 'Local Config';
13821393
let policyMatchedField: string | undefined;
13831394
let policyMatchedWord: string | undefined;
1395+
let riskMetadata: RiskMetadata | undefined;
13841396

13851397
if (config.settings.mode === 'audit') {
13861398
if (!isIgnoredTool(toolName)) {
@@ -1424,6 +1436,14 @@ export async function authorizeHeadless(
14241436
explainableLabel = policyResult.blockedByLabel || 'Local Config';
14251437
policyMatchedField = policyResult.matchedField;
14261438
policyMatchedWord = policyResult.matchedWord;
1439+
riskMetadata = computeRiskMetadata(
1440+
args,
1441+
policyResult.tier ?? 6,
1442+
explainableLabel,
1443+
policyMatchedField,
1444+
policyMatchedWord,
1445+
policyResult.ruleName,
1446+
);
14271447

14281448
const persistent = getPersistentDecision(toolName);
14291449
if (persistent === 'allow') {
@@ -1453,7 +1473,7 @@ export async function authorizeHeadless(
14531473

14541474
if (cloudEnforced) {
14551475
try {
1456-
const initResult = await initNode9SaaS(toolName, args, creds!, meta);
1476+
const initResult = await initNode9SaaS(toolName, args, creds!, meta, riskMetadata);
14571477

14581478
if (!initResult.pending) {
14591479
return {
@@ -1544,7 +1564,7 @@ export async function authorizeHeadless(
15441564
(async () => {
15451565
try {
15461566
if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
1547-
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
1567+
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
15481568
}
15491569
const cloudResult = await pollNode9SaaS(cloudRequestId, creds!, signal);
15501570

@@ -1613,7 +1633,7 @@ export async function authorizeHeadless(
16131633
console.error(chalk.cyan(` URL → http://${DAEMON_HOST}:${DAEMON_PORT}/\n`));
16141634
}
16151635

1616-
const daemonDecision = await askDaemon(toolName, args, meta, signal);
1636+
const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
16171637
if (daemonDecision === 'abandoned') throw new Error('Abandoned');
16181638

16191639
const isApproved = daemonDecision === 'allow';
@@ -1966,7 +1986,8 @@ async function initNode9SaaS(
19661986
toolName: string,
19671987
args: unknown,
19681988
creds: { apiKey: string; apiUrl: string },
1969-
meta?: { agent?: string; mcpServer?: string }
1989+
meta?: { agent?: string; mcpServer?: string },
1990+
riskMetadata?: RiskMetadata
19701991
): Promise<{
19711992
pending: boolean;
19721993
requestId?: string;
@@ -1991,6 +2012,7 @@ async function initNode9SaaS(
19912012
cwd: process.cwd(),
19922013
platform: os.platform(),
19932014
},
2015+
...(riskMetadata && { riskMetadata }),
19942016
}),
19952017
signal: controller.signal,
19962018
});

src/daemon/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// src/daemon/index.ts — Node9 localhost approval server
22
import { UI_HTML_TEMPLATE } from './ui';
3+
import { RiskMetadata } from '../context-sniper';
34
import http from 'http';
45
import fs from 'fs';
56
import path from 'path';
@@ -136,6 +137,7 @@ interface PendingEntry {
136137
id: string;
137138
toolName: string;
138139
args: unknown;
140+
riskMetadata?: RiskMetadata;
139141
agent?: string;
140142
mcpServer?: string;
141143
timestamp: number;
@@ -274,6 +276,7 @@ export function startDaemon(): void {
274276
id: e.id,
275277
toolName: e.toolName,
276278
args: e.args,
279+
riskMetadata: e.riskMetadata,
277280
slackDelegated: e.slackDelegated,
278281
timestamp: e.timestamp,
279282
agent: e.agent,
@@ -303,12 +306,13 @@ export function startDaemon(): void {
303306

304307
const body = await readBody(req);
305308
if (body.length > 65_536) return res.writeHead(413).end();
306-
const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
309+
const { toolName, args, slackDelegated = false, agent, mcpServer, riskMetadata } = JSON.parse(body);
307310
const id = randomUUID();
308311
const entry: PendingEntry = {
309312
id,
310313
toolName,
311314
args,
315+
riskMetadata: riskMetadata ?? undefined,
312316
agent: typeof agent === 'string' ? agent : undefined,
313317
mcpServer: typeof mcpServer === 'string' ? mcpServer : undefined,
314318
slackDelegated: !!slackDelegated,
@@ -340,6 +344,7 @@ export function startDaemon(): void {
340344
id,
341345
toolName,
342346
args,
347+
riskMetadata: entry.riskMetadata,
343348
slackDelegated: entry.slackDelegated,
344349
agent: entry.agent,
345350
mcpServer: entry.mcpServer,

0 commit comments

Comments
 (0)