@node9/proxy 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +183 -21
- package/README.md +1 -106
- package/dist/cli.js +750 -182
- package/dist/cli.mjs +750 -182
- package/dist/index.js +510 -79
- package/dist/index.mjs +510 -79
- package/package.json +6 -3
package/dist/index.mjs
CHANGED
|
@@ -2,24 +2,124 @@
|
|
|
2
2
|
import chalk2 from "chalk";
|
|
3
3
|
import { confirm } from "@inquirer/prompts";
|
|
4
4
|
import fs from "fs";
|
|
5
|
-
import
|
|
5
|
+
import path3 from "path";
|
|
6
6
|
import os from "os";
|
|
7
7
|
import pm from "picomatch";
|
|
8
8
|
import { parse } from "sh-syntax";
|
|
9
9
|
|
|
10
10
|
// src/ui/native.ts
|
|
11
11
|
import { spawn } from "child_process";
|
|
12
|
+
import path2 from "path";
|
|
12
13
|
import chalk from "chalk";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
|
|
15
|
+
// src/context-sniper.ts
|
|
16
|
+
import path from "path";
|
|
16
17
|
function smartTruncate(str, maxLen = 500) {
|
|
17
18
|
if (str.length <= maxLen) return str;
|
|
18
19
|
const edge = Math.floor(maxLen / 2) - 3;
|
|
19
20
|
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
|
|
20
21
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
22
|
+
function extractContext(text, matchedWord) {
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
if (lines.length <= 7 || !matchedWord) {
|
|
25
|
+
return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
26
|
+
}
|
|
27
|
+
const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28
|
+
const pattern = new RegExp(`\\b${escaped}\\b`, "i");
|
|
29
|
+
const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
|
|
30
|
+
if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
31
|
+
const nonComment = allHits.find(({ line }) => {
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
return !trimmed.startsWith("//") && !trimmed.startsWith("#");
|
|
34
|
+
});
|
|
35
|
+
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
36
|
+
const start = Math.max(0, hitIndex - 3);
|
|
37
|
+
const end = Math.min(lines.length, hitIndex + 4);
|
|
38
|
+
const lineIndex = hitIndex - start;
|
|
39
|
+
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
40
|
+
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
41
|
+
` : "";
|
|
42
|
+
const tail = end < lines.length ? `
|
|
43
|
+
... [${lines.length - end} lines hidden] ...` : "";
|
|
44
|
+
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
45
|
+
}
|
|
46
|
+
var CODE_KEYS = [
|
|
47
|
+
"command",
|
|
48
|
+
"cmd",
|
|
49
|
+
"shell_command",
|
|
50
|
+
"bash_command",
|
|
51
|
+
"script",
|
|
52
|
+
"code",
|
|
53
|
+
"input",
|
|
54
|
+
"sql",
|
|
55
|
+
"query",
|
|
56
|
+
"arguments",
|
|
57
|
+
"args",
|
|
58
|
+
"param",
|
|
59
|
+
"params",
|
|
60
|
+
"text"
|
|
61
|
+
];
|
|
62
|
+
function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
|
|
63
|
+
let intent = "EXEC";
|
|
64
|
+
let contextSnippet;
|
|
65
|
+
let contextLineIndex;
|
|
66
|
+
let editFileName;
|
|
67
|
+
let editFilePath;
|
|
68
|
+
let parsed = args;
|
|
69
|
+
if (typeof args === "string") {
|
|
70
|
+
const trimmed = args.trim();
|
|
71
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(trimmed);
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
79
|
+
const obj = parsed;
|
|
80
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
81
|
+
intent = "EDIT";
|
|
82
|
+
if (obj.file_path) {
|
|
83
|
+
editFilePath = String(obj.file_path);
|
|
84
|
+
editFileName = path.basename(editFilePath);
|
|
85
|
+
}
|
|
86
|
+
const result = extractContext(String(obj.new_string), matchedWord);
|
|
87
|
+
contextSnippet = result.snippet;
|
|
88
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
89
|
+
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
90
|
+
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
91
|
+
contextSnippet = result.snippet;
|
|
92
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
93
|
+
} else {
|
|
94
|
+
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
95
|
+
if (foundKey) {
|
|
96
|
+
const val = obj[foundKey];
|
|
97
|
+
contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (typeof parsed === "string") {
|
|
101
|
+
contextSnippet = smartTruncate(parsed, 500);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
intent,
|
|
105
|
+
tier,
|
|
106
|
+
blockedByLabel,
|
|
107
|
+
...matchedWord && { matchedWord },
|
|
108
|
+
...matchedField && { matchedField },
|
|
109
|
+
...contextSnippet !== void 0 && { contextSnippet },
|
|
110
|
+
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
111
|
+
...editFileName && { editFileName },
|
|
112
|
+
...editFilePath && { editFilePath },
|
|
113
|
+
...ruleName && { ruleName }
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/ui/native.ts
|
|
118
|
+
var isTestEnv = () => {
|
|
119
|
+
return process.env.NODE_ENV === "test" || process.env.VITEST === "true" || !!process.env.VITEST || process.env.CI === "true" || !!process.env.CI || process.env.NODE9_TESTING === "1";
|
|
120
|
+
};
|
|
121
|
+
function formatArgs(args, matchedField, matchedWord) {
|
|
122
|
+
if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
|
|
23
123
|
let parsed = args;
|
|
24
124
|
if (typeof args === "string") {
|
|
25
125
|
const trimmed = args.trim();
|
|
@@ -30,11 +130,39 @@ function formatArgs(args) {
|
|
|
30
130
|
parsed = args;
|
|
31
131
|
}
|
|
32
132
|
} else {
|
|
33
|
-
return smartTruncate(args, 600);
|
|
133
|
+
return { message: smartTruncate(args, 600), intent: "EXEC" };
|
|
34
134
|
}
|
|
35
135
|
}
|
|
36
136
|
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
37
137
|
const obj = parsed;
|
|
138
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
139
|
+
const file = obj.file_path ? path2.basename(String(obj.file_path)) : "file";
|
|
140
|
+
const oldPreview = smartTruncate(String(obj.old_string), 120);
|
|
141
|
+
const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
|
|
142
|
+
return {
|
|
143
|
+
intent: "EDIT",
|
|
144
|
+
message: `\u{1F4DD} EDITING: ${file}
|
|
145
|
+
\u{1F4C2} PATH: ${obj.file_path}
|
|
146
|
+
|
|
147
|
+
--- REPLACING ---
|
|
148
|
+
${oldPreview}
|
|
149
|
+
|
|
150
|
+
+++ NEW CODE +++
|
|
151
|
+
${newPreview}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (matchedField && obj[matchedField] !== void 0) {
|
|
155
|
+
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
156
|
+
const context = otherKeys.length > 0 ? `\u2699\uFE0F Context: ${otherKeys.map((k) => `${k}=${smartTruncate(typeof obj[k] === "object" ? JSON.stringify(obj[k]) : String(obj[k]), 30)}`).join(", ")}
|
|
157
|
+
|
|
158
|
+
` : "";
|
|
159
|
+
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
160
|
+
return {
|
|
161
|
+
intent: "EXEC",
|
|
162
|
+
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
163
|
+
${content}`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
38
166
|
const codeKeys = [
|
|
39
167
|
"command",
|
|
40
168
|
"cmd",
|
|
@@ -55,14 +183,18 @@ function formatArgs(args) {
|
|
|
55
183
|
if (foundKey) {
|
|
56
184
|
const val = obj[foundKey];
|
|
57
185
|
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
58
|
-
return
|
|
59
|
-
|
|
186
|
+
return {
|
|
187
|
+
intent: "EXEC",
|
|
188
|
+
message: `[${foundKey.toUpperCase()}]:
|
|
189
|
+
${smartTruncate(str, 500)}`
|
|
190
|
+
};
|
|
60
191
|
}
|
|
61
|
-
|
|
192
|
+
const msg = Object.entries(obj).slice(0, 5).map(
|
|
62
193
|
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
63
194
|
).join("\n");
|
|
195
|
+
return { intent: "EXEC", message: msg };
|
|
64
196
|
}
|
|
65
|
-
return smartTruncate(JSON.stringify(parsed), 200);
|
|
197
|
+
return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
|
|
66
198
|
}
|
|
67
199
|
function sendDesktopNotification(title, body) {
|
|
68
200
|
if (isTestEnv()) return;
|
|
@@ -115,10 +247,11 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
|
|
|
115
247
|
}
|
|
116
248
|
return lines.join("\n");
|
|
117
249
|
}
|
|
118
|
-
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
|
|
250
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
|
|
119
251
|
if (isTestEnv()) return "deny";
|
|
120
|
-
const formattedArgs = formatArgs(args);
|
|
121
|
-
const
|
|
252
|
+
const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
|
|
253
|
+
const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
|
|
254
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
|
|
122
255
|
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
123
256
|
process.stderr.write(chalk.yellow(`
|
|
124
257
|
\u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
|
|
@@ -195,11 +328,113 @@ end run`;
|
|
|
195
328
|
});
|
|
196
329
|
}
|
|
197
330
|
|
|
331
|
+
// src/config-schema.ts
|
|
332
|
+
import { z } from "zod";
|
|
333
|
+
var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
334
|
+
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
335
|
+
});
|
|
336
|
+
var validRegex = noNewlines.refine(
|
|
337
|
+
(s) => {
|
|
338
|
+
try {
|
|
339
|
+
new RegExp(s);
|
|
340
|
+
return true;
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
{ message: "Value must be a valid regular expression" }
|
|
346
|
+
);
|
|
347
|
+
var SmartConditionSchema = z.object({
|
|
348
|
+
field: z.string().min(1, "Condition field must not be empty"),
|
|
349
|
+
op: z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
|
|
350
|
+
errorMap: () => ({
|
|
351
|
+
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
|
|
352
|
+
})
|
|
353
|
+
}),
|
|
354
|
+
value: validRegex.optional(),
|
|
355
|
+
flags: z.string().optional()
|
|
356
|
+
});
|
|
357
|
+
var SmartRuleSchema = z.object({
|
|
358
|
+
name: z.string().optional(),
|
|
359
|
+
tool: z.string().min(1, "Smart rule tool must not be empty"),
|
|
360
|
+
conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
361
|
+
conditionMode: z.enum(["all", "any"]).optional(),
|
|
362
|
+
verdict: z.enum(["allow", "review", "block"], {
|
|
363
|
+
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
364
|
+
}),
|
|
365
|
+
reason: z.string().optional()
|
|
366
|
+
});
|
|
367
|
+
var PolicyRuleSchema = z.object({
|
|
368
|
+
action: z.string().min(1),
|
|
369
|
+
allowPaths: z.array(z.string()).optional(),
|
|
370
|
+
blockPaths: z.array(z.string()).optional()
|
|
371
|
+
});
|
|
372
|
+
var ConfigFileSchema = z.object({
|
|
373
|
+
version: z.string().optional(),
|
|
374
|
+
settings: z.object({
|
|
375
|
+
mode: z.enum(["standard", "strict", "audit"]).optional(),
|
|
376
|
+
autoStartDaemon: z.boolean().optional(),
|
|
377
|
+
enableUndo: z.boolean().optional(),
|
|
378
|
+
enableHookLogDebug: z.boolean().optional(),
|
|
379
|
+
approvalTimeoutMs: z.number().nonnegative().optional(),
|
|
380
|
+
approvers: z.object({
|
|
381
|
+
native: z.boolean().optional(),
|
|
382
|
+
browser: z.boolean().optional(),
|
|
383
|
+
cloud: z.boolean().optional(),
|
|
384
|
+
terminal: z.boolean().optional()
|
|
385
|
+
}).optional(),
|
|
386
|
+
environment: z.string().optional(),
|
|
387
|
+
slackEnabled: z.boolean().optional(),
|
|
388
|
+
enableTrustSessions: z.boolean().optional(),
|
|
389
|
+
allowGlobalPause: z.boolean().optional()
|
|
390
|
+
}).optional(),
|
|
391
|
+
policy: z.object({
|
|
392
|
+
sandboxPaths: z.array(z.string()).optional(),
|
|
393
|
+
dangerousWords: z.array(noNewlines).optional(),
|
|
394
|
+
ignoredTools: z.array(z.string()).optional(),
|
|
395
|
+
toolInspection: z.record(z.string()).optional(),
|
|
396
|
+
rules: z.array(PolicyRuleSchema).optional(),
|
|
397
|
+
smartRules: z.array(SmartRuleSchema).optional(),
|
|
398
|
+
snapshot: z.object({
|
|
399
|
+
tools: z.array(z.string()).optional(),
|
|
400
|
+
onlyPaths: z.array(z.string()).optional(),
|
|
401
|
+
ignorePaths: z.array(z.string()).optional()
|
|
402
|
+
}).optional()
|
|
403
|
+
}).optional(),
|
|
404
|
+
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
405
|
+
}).strict({ message: "Config contains unknown top-level keys" });
|
|
406
|
+
function sanitizeConfig(raw) {
|
|
407
|
+
const result = ConfigFileSchema.safeParse(raw);
|
|
408
|
+
if (result.success) {
|
|
409
|
+
return { sanitized: result.data, error: null };
|
|
410
|
+
}
|
|
411
|
+
const invalidTopLevelKeys = new Set(
|
|
412
|
+
result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
|
|
413
|
+
);
|
|
414
|
+
const sanitized = {};
|
|
415
|
+
if (typeof raw === "object" && raw !== null) {
|
|
416
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
417
|
+
if (!invalidTopLevelKeys.has(key)) {
|
|
418
|
+
sanitized[key] = value;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const lines = result.error.issues.map((issue) => {
|
|
423
|
+
const path4 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
424
|
+
return ` \u2022 ${path4}: ${issue.message}`;
|
|
425
|
+
});
|
|
426
|
+
return {
|
|
427
|
+
sanitized,
|
|
428
|
+
error: `Invalid config:
|
|
429
|
+
${lines.join("\n")}`
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
198
433
|
// src/core.ts
|
|
199
|
-
var PAUSED_FILE =
|
|
200
|
-
var TRUST_FILE =
|
|
201
|
-
var LOCAL_AUDIT_LOG =
|
|
202
|
-
var HOOK_DEBUG_LOG =
|
|
434
|
+
var PAUSED_FILE = path3.join(os.homedir(), ".node9", "PAUSED");
|
|
435
|
+
var TRUST_FILE = path3.join(os.homedir(), ".node9", "trust.json");
|
|
436
|
+
var LOCAL_AUDIT_LOG = path3.join(os.homedir(), ".node9", "audit.log");
|
|
437
|
+
var HOOK_DEBUG_LOG = path3.join(os.homedir(), ".node9", "hook-debug.log");
|
|
203
438
|
function checkPause() {
|
|
204
439
|
try {
|
|
205
440
|
if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
|
|
@@ -217,7 +452,7 @@ function checkPause() {
|
|
|
217
452
|
}
|
|
218
453
|
}
|
|
219
454
|
function atomicWriteSync(filePath, data, options) {
|
|
220
|
-
const dir =
|
|
455
|
+
const dir = path3.dirname(filePath);
|
|
221
456
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
222
457
|
const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
|
|
223
458
|
fs.writeFileSync(tmpPath, data, options);
|
|
@@ -258,7 +493,7 @@ function writeTrustSession(toolName, durationMs) {
|
|
|
258
493
|
}
|
|
259
494
|
function appendToLog(logPath, entry) {
|
|
260
495
|
try {
|
|
261
|
-
const dir =
|
|
496
|
+
const dir = path3.dirname(logPath);
|
|
262
497
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
263
498
|
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
264
499
|
} catch {
|
|
@@ -302,9 +537,9 @@ function matchesPattern(text, patterns) {
|
|
|
302
537
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
303
538
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
304
539
|
}
|
|
305
|
-
function getNestedValue(obj,
|
|
540
|
+
function getNestedValue(obj, path4) {
|
|
306
541
|
if (!obj || typeof obj !== "object") return null;
|
|
307
|
-
return
|
|
542
|
+
return path4.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
308
543
|
}
|
|
309
544
|
function evaluateSmartConditions(args, rule) {
|
|
310
545
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -441,15 +676,10 @@ function redactSecrets(text) {
|
|
|
441
676
|
return redacted;
|
|
442
677
|
}
|
|
443
678
|
var DANGEROUS_WORDS = [
|
|
444
|
-
"
|
|
445
|
-
|
|
446
|
-
"
|
|
447
|
-
|
|
448
|
-
"destroy",
|
|
449
|
-
"terminate",
|
|
450
|
-
"revoke",
|
|
451
|
-
"docker",
|
|
452
|
-
"psql"
|
|
679
|
+
"mkfs",
|
|
680
|
+
// formats/wipes a filesystem partition
|
|
681
|
+
"shred"
|
|
682
|
+
// permanently overwrites file contents (unrecoverable)
|
|
453
683
|
];
|
|
454
684
|
var DEFAULT_CONFIG = {
|
|
455
685
|
settings: {
|
|
@@ -493,7 +723,21 @@ var DEFAULT_CONFIG = {
|
|
|
493
723
|
"terminal.execute": "command",
|
|
494
724
|
"postgres:query": "sql"
|
|
495
725
|
},
|
|
726
|
+
snapshot: {
|
|
727
|
+
tools: [
|
|
728
|
+
"str_replace_based_edit_tool",
|
|
729
|
+
"write_file",
|
|
730
|
+
"edit_file",
|
|
731
|
+
"create_file",
|
|
732
|
+
"edit",
|
|
733
|
+
"replace"
|
|
734
|
+
],
|
|
735
|
+
onlyPaths: [],
|
|
736
|
+
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
737
|
+
},
|
|
496
738
|
rules: [
|
|
739
|
+
// Only use the legacy rules format for simple path-based rm control.
|
|
740
|
+
// All other command-level enforcement lives in smartRules below.
|
|
497
741
|
{
|
|
498
742
|
action: "rm",
|
|
499
743
|
allowPaths: [
|
|
@@ -510,6 +754,7 @@ var DEFAULT_CONFIG = {
|
|
|
510
754
|
}
|
|
511
755
|
],
|
|
512
756
|
smartRules: [
|
|
757
|
+
// ── SQL safety ────────────────────────────────────────────────────────
|
|
513
758
|
{
|
|
514
759
|
name: "no-delete-without-where",
|
|
515
760
|
tool: "*",
|
|
@@ -520,6 +765,84 @@ var DEFAULT_CONFIG = {
|
|
|
520
765
|
conditionMode: "all",
|
|
521
766
|
verdict: "review",
|
|
522
767
|
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
name: "review-drop-truncate-shell",
|
|
771
|
+
tool: "bash",
|
|
772
|
+
conditions: [
|
|
773
|
+
{
|
|
774
|
+
field: "command",
|
|
775
|
+
op: "matches",
|
|
776
|
+
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
777
|
+
flags: "i"
|
|
778
|
+
}
|
|
779
|
+
],
|
|
780
|
+
conditionMode: "all",
|
|
781
|
+
verdict: "review",
|
|
782
|
+
reason: "SQL DDL destructive statement inside a shell command"
|
|
783
|
+
},
|
|
784
|
+
// ── Git safety ────────────────────────────────────────────────────────
|
|
785
|
+
{
|
|
786
|
+
name: "block-force-push",
|
|
787
|
+
tool: "bash",
|
|
788
|
+
conditions: [
|
|
789
|
+
{
|
|
790
|
+
field: "command",
|
|
791
|
+
op: "matches",
|
|
792
|
+
value: "git push.*(--force|--force-with-lease|-f\\b)",
|
|
793
|
+
flags: "i"
|
|
794
|
+
}
|
|
795
|
+
],
|
|
796
|
+
conditionMode: "all",
|
|
797
|
+
verdict: "block",
|
|
798
|
+
reason: "Force push overwrites remote history and cannot be undone"
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
name: "review-git-push",
|
|
802
|
+
tool: "bash",
|
|
803
|
+
conditions: [{ field: "command", op: "matches", value: "^\\s*git push\\b", flags: "i" }],
|
|
804
|
+
conditionMode: "all",
|
|
805
|
+
verdict: "review",
|
|
806
|
+
reason: "git push sends changes to a shared remote"
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: "review-git-destructive",
|
|
810
|
+
tool: "bash",
|
|
811
|
+
conditions: [
|
|
812
|
+
{
|
|
813
|
+
field: "command",
|
|
814
|
+
op: "matches",
|
|
815
|
+
value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
|
|
816
|
+
flags: "i"
|
|
817
|
+
}
|
|
818
|
+
],
|
|
819
|
+
conditionMode: "all",
|
|
820
|
+
verdict: "review",
|
|
821
|
+
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
822
|
+
},
|
|
823
|
+
// ── Shell safety ──────────────────────────────────────────────────────
|
|
824
|
+
{
|
|
825
|
+
name: "review-sudo",
|
|
826
|
+
tool: "bash",
|
|
827
|
+
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
828
|
+
conditionMode: "all",
|
|
829
|
+
verdict: "review",
|
|
830
|
+
reason: "Command requires elevated privileges"
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "review-curl-pipe-shell",
|
|
834
|
+
tool: "bash",
|
|
835
|
+
conditions: [
|
|
836
|
+
{
|
|
837
|
+
field: "command",
|
|
838
|
+
op: "matches",
|
|
839
|
+
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
840
|
+
flags: "i"
|
|
841
|
+
}
|
|
842
|
+
],
|
|
843
|
+
conditionMode: "all",
|
|
844
|
+
verdict: "block",
|
|
845
|
+
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
523
846
|
}
|
|
524
847
|
]
|
|
525
848
|
},
|
|
@@ -528,7 +851,7 @@ var DEFAULT_CONFIG = {
|
|
|
528
851
|
var cachedConfig = null;
|
|
529
852
|
function getInternalToken() {
|
|
530
853
|
try {
|
|
531
|
-
const pidFile =
|
|
854
|
+
const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
|
|
532
855
|
if (!fs.existsSync(pidFile)) return null;
|
|
533
856
|
const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
|
|
534
857
|
process.kill(data.pid, 0);
|
|
@@ -549,7 +872,9 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
549
872
|
return {
|
|
550
873
|
decision: matchedRule.verdict,
|
|
551
874
|
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
552
|
-
reason: matchedRule.reason
|
|
875
|
+
reason: matchedRule.reason,
|
|
876
|
+
tier: 2,
|
|
877
|
+
ruleName: matchedRule.name ?? matchedRule.tool
|
|
553
878
|
};
|
|
554
879
|
}
|
|
555
880
|
}
|
|
@@ -564,7 +889,7 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
564
889
|
pathTokens = analyzed.paths;
|
|
565
890
|
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
566
891
|
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
567
|
-
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
|
|
892
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
|
|
568
893
|
}
|
|
569
894
|
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
570
895
|
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
@@ -587,7 +912,7 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
587
912
|
);
|
|
588
913
|
const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
|
|
589
914
|
if (hasSystemDisaster || isRootWipe) {
|
|
590
|
-
return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
|
|
915
|
+
return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
|
|
591
916
|
}
|
|
592
917
|
return { decision: "allow" };
|
|
593
918
|
}
|
|
@@ -605,14 +930,16 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
605
930
|
if (anyBlocked)
|
|
606
931
|
return {
|
|
607
932
|
decision: "review",
|
|
608
|
-
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)
|
|
933
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
|
|
934
|
+
tier: 5
|
|
609
935
|
};
|
|
610
936
|
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
611
937
|
if (allAllowed) return { decision: "allow" };
|
|
612
938
|
}
|
|
613
939
|
return {
|
|
614
940
|
decision: "review",
|
|
615
|
-
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)
|
|
941
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
|
|
942
|
+
tier: 5
|
|
616
943
|
};
|
|
617
944
|
}
|
|
618
945
|
}
|
|
@@ -632,15 +959,36 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
632
959
|
})
|
|
633
960
|
);
|
|
634
961
|
if (isDangerous) {
|
|
962
|
+
let matchedField;
|
|
963
|
+
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
964
|
+
const obj = args;
|
|
965
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
966
|
+
if (typeof value === "string") {
|
|
967
|
+
try {
|
|
968
|
+
if (new RegExp(
|
|
969
|
+
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
970
|
+
"i"
|
|
971
|
+
).test(value)) {
|
|
972
|
+
matchedField = key;
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
} catch {
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
635
980
|
return {
|
|
636
981
|
decision: "review",
|
|
637
|
-
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"
|
|
982
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
983
|
+
matchedWord: matchedDangerousWord,
|
|
984
|
+
matchedField,
|
|
985
|
+
tier: 6
|
|
638
986
|
};
|
|
639
987
|
}
|
|
640
988
|
if (config.settings.mode === "strict") {
|
|
641
989
|
const envConfig = getActiveEnvironment(config);
|
|
642
990
|
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
643
|
-
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
|
|
991
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
644
992
|
}
|
|
645
993
|
return { decision: "allow" };
|
|
646
994
|
}
|
|
@@ -652,7 +1000,7 @@ var DAEMON_PORT = 7391;
|
|
|
652
1000
|
var DAEMON_HOST = "127.0.0.1";
|
|
653
1001
|
function isDaemonRunning() {
|
|
654
1002
|
try {
|
|
655
|
-
const pidFile =
|
|
1003
|
+
const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
|
|
656
1004
|
if (!fs.existsSync(pidFile)) return false;
|
|
657
1005
|
const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
|
|
658
1006
|
if (port !== DAEMON_PORT) return false;
|
|
@@ -664,7 +1012,7 @@ function isDaemonRunning() {
|
|
|
664
1012
|
}
|
|
665
1013
|
function getPersistentDecision(toolName) {
|
|
666
1014
|
try {
|
|
667
|
-
const file =
|
|
1015
|
+
const file = path3.join(os.homedir(), ".node9", "decisions.json");
|
|
668
1016
|
if (!fs.existsSync(file)) return null;
|
|
669
1017
|
const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
670
1018
|
const d = decisions[toolName];
|
|
@@ -673,7 +1021,7 @@ function getPersistentDecision(toolName) {
|
|
|
673
1021
|
}
|
|
674
1022
|
return null;
|
|
675
1023
|
}
|
|
676
|
-
async function askDaemon(toolName, args, meta, signal) {
|
|
1024
|
+
async function askDaemon(toolName, args, meta, signal, riskMetadata) {
|
|
677
1025
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
678
1026
|
const checkCtrl = new AbortController();
|
|
679
1027
|
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
@@ -683,7 +1031,13 @@ async function askDaemon(toolName, args, meta, signal) {
|
|
|
683
1031
|
const checkRes = await fetch(`${base}/check`, {
|
|
684
1032
|
method: "POST",
|
|
685
1033
|
headers: { "Content-Type": "application/json" },
|
|
686
|
-
body: JSON.stringify({
|
|
1034
|
+
body: JSON.stringify({
|
|
1035
|
+
toolName,
|
|
1036
|
+
args,
|
|
1037
|
+
agent: meta?.agent,
|
|
1038
|
+
mcpServer: meta?.mcpServer,
|
|
1039
|
+
...riskMetadata && { riskMetadata }
|
|
1040
|
+
}),
|
|
687
1041
|
signal: checkCtrl.signal
|
|
688
1042
|
});
|
|
689
1043
|
if (!checkRes.ok) throw new Error("Daemon fail");
|
|
@@ -708,7 +1062,7 @@ async function askDaemon(toolName, args, meta, signal) {
|
|
|
708
1062
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
709
1063
|
}
|
|
710
1064
|
}
|
|
711
|
-
async function notifyDaemonViewer(toolName, args, meta) {
|
|
1065
|
+
async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
|
|
712
1066
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
713
1067
|
const res = await fetch(`${base}/check`, {
|
|
714
1068
|
method: "POST",
|
|
@@ -718,7 +1072,8 @@ async function notifyDaemonViewer(toolName, args, meta) {
|
|
|
718
1072
|
args,
|
|
719
1073
|
slackDelegated: true,
|
|
720
1074
|
agent: meta?.agent,
|
|
721
|
-
mcpServer: meta?.mcpServer
|
|
1075
|
+
mcpServer: meta?.mcpServer,
|
|
1076
|
+
...riskMetadata && { riskMetadata }
|
|
722
1077
|
}),
|
|
723
1078
|
signal: AbortSignal.timeout(3e3)
|
|
724
1079
|
});
|
|
@@ -735,7 +1090,7 @@ async function resolveViaDaemon(id, decision, internalToken) {
|
|
|
735
1090
|
signal: AbortSignal.timeout(3e3)
|
|
736
1091
|
});
|
|
737
1092
|
}
|
|
738
|
-
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
|
|
1093
|
+
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
|
|
739
1094
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
740
1095
|
const pauseState = checkPause();
|
|
741
1096
|
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
@@ -755,11 +1110,17 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
755
1110
|
}
|
|
756
1111
|
const isManual = meta?.agent === "Terminal";
|
|
757
1112
|
let explainableLabel = "Local Config";
|
|
1113
|
+
let policyMatchedField;
|
|
1114
|
+
let policyMatchedWord;
|
|
1115
|
+
let riskMetadata;
|
|
758
1116
|
if (config.settings.mode === "audit") {
|
|
759
1117
|
if (!isIgnoredTool(toolName)) {
|
|
760
1118
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
761
1119
|
if (policyResult.decision === "review") {
|
|
762
1120
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
1121
|
+
if (approvers.cloud && creds?.apiKey) {
|
|
1122
|
+
await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
|
|
1123
|
+
}
|
|
763
1124
|
sendDesktopNotification(
|
|
764
1125
|
"Node9 Audit Mode",
|
|
765
1126
|
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
@@ -770,13 +1131,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
770
1131
|
}
|
|
771
1132
|
if (!isIgnoredTool(toolName)) {
|
|
772
1133
|
if (getActiveTrustSession(toolName)) {
|
|
773
|
-
if (creds?.apiKey)
|
|
1134
|
+
if (approvers.cloud && creds?.apiKey)
|
|
1135
|
+
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
774
1136
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
775
1137
|
return { approved: true, checkedBy: "trust" };
|
|
776
1138
|
}
|
|
777
1139
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
778
1140
|
if (policyResult.decision === "allow") {
|
|
779
|
-
if (creds?.apiKey)
|
|
1141
|
+
if (approvers.cloud && creds?.apiKey)
|
|
1142
|
+
auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
780
1143
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
781
1144
|
return { approved: true, checkedBy: "local-policy" };
|
|
782
1145
|
}
|
|
@@ -790,9 +1153,20 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
790
1153
|
};
|
|
791
1154
|
}
|
|
792
1155
|
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
1156
|
+
policyMatchedField = policyResult.matchedField;
|
|
1157
|
+
policyMatchedWord = policyResult.matchedWord;
|
|
1158
|
+
riskMetadata = computeRiskMetadata(
|
|
1159
|
+
args,
|
|
1160
|
+
policyResult.tier ?? 6,
|
|
1161
|
+
explainableLabel,
|
|
1162
|
+
policyMatchedField,
|
|
1163
|
+
policyMatchedWord,
|
|
1164
|
+
policyResult.ruleName
|
|
1165
|
+
);
|
|
793
1166
|
const persistent = getPersistentDecision(toolName);
|
|
794
1167
|
if (persistent === "allow") {
|
|
795
|
-
if (creds?.apiKey)
|
|
1168
|
+
if (approvers.cloud && creds?.apiKey)
|
|
1169
|
+
await auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
796
1170
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
797
1171
|
return { approved: true, checkedBy: "persistent" };
|
|
798
1172
|
}
|
|
@@ -806,7 +1180,6 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
806
1180
|
};
|
|
807
1181
|
}
|
|
808
1182
|
} else {
|
|
809
|
-
if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
|
|
810
1183
|
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
811
1184
|
return { approved: true };
|
|
812
1185
|
}
|
|
@@ -815,8 +1188,21 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
815
1188
|
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
816
1189
|
if (cloudEnforced) {
|
|
817
1190
|
try {
|
|
818
|
-
const initResult = await initNode9SaaS(toolName, args, creds, meta);
|
|
1191
|
+
const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
|
|
819
1192
|
if (!initResult.pending) {
|
|
1193
|
+
if (initResult.shadowMode) {
|
|
1194
|
+
console.error(
|
|
1195
|
+
chalk2.yellow(
|
|
1196
|
+
`
|
|
1197
|
+
\u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
|
|
1198
|
+
)
|
|
1199
|
+
);
|
|
1200
|
+
if (initResult.shadowReason) {
|
|
1201
|
+
console.error(chalk2.dim(` Reason: ${initResult.shadowReason}
|
|
1202
|
+
`));
|
|
1203
|
+
}
|
|
1204
|
+
return { approved: true, checkedBy: "cloud" };
|
|
1205
|
+
}
|
|
820
1206
|
return {
|
|
821
1207
|
approved: !!initResult.approved,
|
|
822
1208
|
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
@@ -841,18 +1227,22 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
841
1227
|
);
|
|
842
1228
|
}
|
|
843
1229
|
}
|
|
844
|
-
if (
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1230
|
+
if (!options?.calledFromDaemon) {
|
|
1231
|
+
if (cloudEnforced && cloudRequestId) {
|
|
1232
|
+
console.error(
|
|
1233
|
+
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
1234
|
+
);
|
|
1235
|
+
console.error(
|
|
1236
|
+
chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n")
|
|
1237
|
+
);
|
|
1238
|
+
} else if (!cloudEnforced) {
|
|
1239
|
+
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
1240
|
+
console.error(
|
|
1241
|
+
chalk2.dim(`
|
|
853
1242
|
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
854
1243
|
`)
|
|
855
|
-
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
856
1246
|
}
|
|
857
1247
|
const abortController = new AbortController();
|
|
858
1248
|
const { signal } = abortController;
|
|
@@ -882,8 +1272,10 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
882
1272
|
racePromises.push(
|
|
883
1273
|
(async () => {
|
|
884
1274
|
try {
|
|
885
|
-
if (isDaemonRunning() && internalToken) {
|
|
886
|
-
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(
|
|
1275
|
+
if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
|
|
1276
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
|
|
1277
|
+
() => null
|
|
1278
|
+
);
|
|
887
1279
|
}
|
|
888
1280
|
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
889
1281
|
return {
|
|
@@ -901,7 +1293,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
901
1293
|
})()
|
|
902
1294
|
);
|
|
903
1295
|
}
|
|
904
|
-
if (approvers.native && !isManual) {
|
|
1296
|
+
if (approvers.native && !isManual && !options?.calledFromDaemon) {
|
|
905
1297
|
racePromises.push(
|
|
906
1298
|
(async () => {
|
|
907
1299
|
const decision = await askNativePopup(
|
|
@@ -910,7 +1302,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
910
1302
|
meta?.agent,
|
|
911
1303
|
explainableLabel,
|
|
912
1304
|
isRemoteLocked,
|
|
913
|
-
signal
|
|
1305
|
+
signal,
|
|
1306
|
+
policyMatchedField,
|
|
1307
|
+
policyMatchedWord
|
|
914
1308
|
);
|
|
915
1309
|
if (decision === "always_allow") {
|
|
916
1310
|
writeTrustSession(toolName, 36e5);
|
|
@@ -927,7 +1321,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
927
1321
|
})()
|
|
928
1322
|
);
|
|
929
1323
|
}
|
|
930
|
-
if (approvers.browser && isDaemonRunning()) {
|
|
1324
|
+
if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
|
|
931
1325
|
racePromises.push(
|
|
932
1326
|
(async () => {
|
|
933
1327
|
try {
|
|
@@ -938,7 +1332,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
938
1332
|
console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
939
1333
|
`));
|
|
940
1334
|
}
|
|
941
|
-
const daemonDecision = await askDaemon(toolName, args, meta, signal);
|
|
1335
|
+
const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
|
|
942
1336
|
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
943
1337
|
const isApproved = daemonDecision === "allow";
|
|
944
1338
|
return {
|
|
@@ -1063,8 +1457,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
|
|
|
1063
1457
|
}
|
|
1064
1458
|
function getConfig() {
|
|
1065
1459
|
if (cachedConfig) return cachedConfig;
|
|
1066
|
-
const globalPath =
|
|
1067
|
-
const projectPath =
|
|
1460
|
+
const globalPath = path3.join(os.homedir(), ".node9", "config.json");
|
|
1461
|
+
const projectPath = path3.join(process.cwd(), "node9.config.json");
|
|
1068
1462
|
const globalConfig = tryLoadConfig(globalPath);
|
|
1069
1463
|
const projectConfig = tryLoadConfig(projectPath);
|
|
1070
1464
|
const mergedSettings = {
|
|
@@ -1077,7 +1471,12 @@ function getConfig() {
|
|
|
1077
1471
|
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
1078
1472
|
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
1079
1473
|
rules: [...DEFAULT_CONFIG.policy.rules],
|
|
1080
|
-
smartRules: [...DEFAULT_CONFIG.policy.smartRules]
|
|
1474
|
+
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
1475
|
+
snapshot: {
|
|
1476
|
+
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
1477
|
+
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
1478
|
+
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
1479
|
+
}
|
|
1081
1480
|
};
|
|
1082
1481
|
const applyLayer = (source) => {
|
|
1083
1482
|
if (!source) return;
|
|
@@ -1089,6 +1488,7 @@ function getConfig() {
|
|
|
1089
1488
|
if (s.enableHookLogDebug !== void 0)
|
|
1090
1489
|
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
1091
1490
|
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
1491
|
+
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
1092
1492
|
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
1093
1493
|
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
1094
1494
|
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
@@ -1097,6 +1497,12 @@ function getConfig() {
|
|
|
1097
1497
|
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
1098
1498
|
if (p.rules) mergedPolicy.rules.push(...p.rules);
|
|
1099
1499
|
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
1500
|
+
if (p.snapshot) {
|
|
1501
|
+
const s2 = p.snapshot;
|
|
1502
|
+
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
1503
|
+
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
1504
|
+
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
1505
|
+
}
|
|
1100
1506
|
};
|
|
1101
1507
|
applyLayer(globalConfig);
|
|
1102
1508
|
applyLayer(projectConfig);
|
|
@@ -1104,6 +1510,9 @@ function getConfig() {
|
|
|
1104
1510
|
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
1105
1511
|
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
1106
1512
|
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
1513
|
+
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
1514
|
+
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
1515
|
+
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
1107
1516
|
cachedConfig = {
|
|
1108
1517
|
settings: mergedSettings,
|
|
1109
1518
|
policy: mergedPolicy,
|
|
@@ -1113,11 +1522,33 @@ function getConfig() {
|
|
|
1113
1522
|
}
|
|
1114
1523
|
function tryLoadConfig(filePath) {
|
|
1115
1524
|
if (!fs.existsSync(filePath)) return null;
|
|
1525
|
+
let raw;
|
|
1116
1526
|
try {
|
|
1117
|
-
|
|
1118
|
-
} catch {
|
|
1527
|
+
raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1530
|
+
process.stderr.write(
|
|
1531
|
+
`
|
|
1532
|
+
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
1533
|
+
${msg}
|
|
1534
|
+
\u2192 Using default config
|
|
1535
|
+
|
|
1536
|
+
`
|
|
1537
|
+
);
|
|
1119
1538
|
return null;
|
|
1120
1539
|
}
|
|
1540
|
+
const { sanitized, error } = sanitizeConfig(raw);
|
|
1541
|
+
if (error) {
|
|
1542
|
+
process.stderr.write(
|
|
1543
|
+
`
|
|
1544
|
+
\u26A0\uFE0F Node9: Invalid config at ${filePath}:
|
|
1545
|
+
${error.replace("Invalid config:\n", "")}
|
|
1546
|
+
\u2192 Invalid fields ignored, using defaults for those keys
|
|
1547
|
+
|
|
1548
|
+
`
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
return sanitized;
|
|
1121
1552
|
}
|
|
1122
1553
|
function getActiveEnvironment(config) {
|
|
1123
1554
|
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
@@ -1132,7 +1563,7 @@ function getCredentials() {
|
|
|
1132
1563
|
};
|
|
1133
1564
|
}
|
|
1134
1565
|
try {
|
|
1135
|
-
const credPath =
|
|
1566
|
+
const credPath = path3.join(os.homedir(), ".node9", "credentials.json");
|
|
1136
1567
|
if (fs.existsSync(credPath)) {
|
|
1137
1568
|
const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
|
|
1138
1569
|
const profileName = process.env.NODE9_PROFILE || "default";
|
|
@@ -1159,9 +1590,7 @@ async function authorizeAction(toolName, args) {
|
|
|
1159
1590
|
return result.approved;
|
|
1160
1591
|
}
|
|
1161
1592
|
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
1162
|
-
|
|
1163
|
-
setTimeout(() => controller.abort(), 5e3);
|
|
1164
|
-
fetch(`${creds.apiUrl}/audit`, {
|
|
1593
|
+
return fetch(`${creds.apiUrl}/audit`, {
|
|
1165
1594
|
method: "POST",
|
|
1166
1595
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1167
1596
|
body: JSON.stringify({
|
|
@@ -1176,11 +1605,12 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
|
1176
1605
|
platform: os.platform()
|
|
1177
1606
|
}
|
|
1178
1607
|
}),
|
|
1179
|
-
signal:
|
|
1608
|
+
signal: AbortSignal.timeout(5e3)
|
|
1609
|
+
}).then(() => {
|
|
1180
1610
|
}).catch(() => {
|
|
1181
1611
|
});
|
|
1182
1612
|
}
|
|
1183
|
-
async function initNode9SaaS(toolName, args, creds, meta) {
|
|
1613
|
+
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
1184
1614
|
const controller = new AbortController();
|
|
1185
1615
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1186
1616
|
try {
|
|
@@ -1196,7 +1626,8 @@ async function initNode9SaaS(toolName, args, creds, meta) {
|
|
|
1196
1626
|
hostname: os.hostname(),
|
|
1197
1627
|
cwd: process.cwd(),
|
|
1198
1628
|
platform: os.platform()
|
|
1199
|
-
}
|
|
1629
|
+
},
|
|
1630
|
+
...riskMetadata && { riskMetadata }
|
|
1200
1631
|
}),
|
|
1201
1632
|
signal: controller.signal
|
|
1202
1633
|
});
|