@node9/proxy 1.1.6 → 1.2.0
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/README.md +1 -1
- package/dist/cli.js +4200 -3668
- package/dist/cli.mjs +3937 -3408
- package/dist/index.js +1689 -1752
- package/dist/index.mjs +1805 -1868
- package/package.json +9 -3
package/dist/index.mjs
CHANGED
|
@@ -1,846 +1,843 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import
|
|
3
|
-
import { confirm } from "@inquirer/prompts";
|
|
4
|
-
import fs3 from "fs";
|
|
5
|
-
import path5 from "path";
|
|
6
|
-
import os2 from "os";
|
|
7
|
-
import net from "net";
|
|
8
|
-
import { randomUUID } from "crypto";
|
|
9
|
-
import { spawnSync } from "child_process";
|
|
10
|
-
import pm from "picomatch";
|
|
11
|
-
import safeRegex from "safe-regex2";
|
|
12
|
-
import { parse } from "sh-syntax";
|
|
13
|
-
|
|
14
|
-
// src/ui/native.ts
|
|
15
|
-
import { spawn } from "child_process";
|
|
16
|
-
import path2 from "path";
|
|
17
|
-
import chalk from "chalk";
|
|
18
|
-
|
|
19
|
-
// src/context-sniper.ts
|
|
1
|
+
// src/audit/index.ts
|
|
2
|
+
import fs from "fs";
|
|
20
3
|
import path from "path";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
4
|
+
import os from "os";
|
|
5
|
+
var LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
|
|
6
|
+
var HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
|
|
7
|
+
function redactSecrets(text) {
|
|
8
|
+
if (!text) return text;
|
|
9
|
+
let redacted = text;
|
|
10
|
+
redacted = redacted.replace(
|
|
11
|
+
/(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
|
|
12
|
+
"$1********"
|
|
13
|
+
);
|
|
14
|
+
redacted = redacted.replace(
|
|
15
|
+
/(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
|
|
16
|
+
"$1$2********"
|
|
17
|
+
);
|
|
18
|
+
return redacted;
|
|
25
19
|
}
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
function appendToLog(logPath, entry) {
|
|
21
|
+
try {
|
|
22
|
+
const dir = path.dirname(logPath);
|
|
23
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
25
|
+
} catch {
|
|
30
26
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
}
|
|
28
|
+
function appendHookDebug(toolName, args, meta) {
|
|
29
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
30
|
+
appendToLog(HOOK_DEBUG_LOG, {
|
|
31
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
32
|
+
tool: toolName,
|
|
33
|
+
args: safeArgs,
|
|
34
|
+
agent: meta?.agent,
|
|
35
|
+
mcpServer: meta?.mcpServer,
|
|
36
|
+
hostname: os.hostname(),
|
|
37
|
+
cwd: process.cwd()
|
|
38
38
|
});
|
|
39
|
-
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
40
|
-
const start = Math.max(0, hitIndex - 3);
|
|
41
|
-
const end = Math.min(lines.length, hitIndex + 4);
|
|
42
|
-
const lineIndex = hitIndex - start;
|
|
43
|
-
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
44
|
-
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
45
|
-
` : "";
|
|
46
|
-
const tail = end < lines.length ? `
|
|
47
|
-
... [${lines.length - end} lines hidden] ...` : "";
|
|
48
|
-
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
49
39
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
40
|
+
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
41
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
42
|
+
appendToLog(LOCAL_AUDIT_LOG, {
|
|
43
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
44
|
+
tool: toolName,
|
|
45
|
+
args: safeArgs,
|
|
46
|
+
decision,
|
|
47
|
+
checkedBy,
|
|
48
|
+
agent: meta?.agent,
|
|
49
|
+
mcpServer: meta?.mcpServer,
|
|
50
|
+
hostname: os.hostname()
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/config/index.ts
|
|
55
|
+
import fs3 from "fs";
|
|
56
|
+
import path3 from "path";
|
|
57
|
+
import os3 from "os";
|
|
58
|
+
|
|
59
|
+
// src/config-schema.ts
|
|
60
|
+
import { z } from "zod";
|
|
61
|
+
var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
62
|
+
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
63
|
+
});
|
|
64
|
+
var SmartConditionSchema = z.object({
|
|
65
|
+
field: z.string().min(1, "Condition field must not be empty"),
|
|
66
|
+
op: z.enum(
|
|
67
|
+
[
|
|
68
|
+
"matches",
|
|
69
|
+
"notMatches",
|
|
70
|
+
"contains",
|
|
71
|
+
"notContains",
|
|
72
|
+
"exists",
|
|
73
|
+
"notExists",
|
|
74
|
+
"matchesGlob",
|
|
75
|
+
"notMatchesGlob"
|
|
76
|
+
],
|
|
77
|
+
{
|
|
78
|
+
errorMap: () => ({
|
|
79
|
+
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
|
|
80
|
+
})
|
|
80
81
|
}
|
|
82
|
+
),
|
|
83
|
+
value: z.string().optional(),
|
|
84
|
+
flags: z.string().optional()
|
|
85
|
+
}).refine(
|
|
86
|
+
(c) => {
|
|
87
|
+
if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
{ message: "matchesGlob and notMatchesGlob conditions require a value field" }
|
|
91
|
+
);
|
|
92
|
+
var SmartRuleSchema = z.object({
|
|
93
|
+
name: z.string().optional(),
|
|
94
|
+
tool: z.string().min(1, "Smart rule tool must not be empty"),
|
|
95
|
+
conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
96
|
+
conditionMode: z.enum(["all", "any"]).optional(),
|
|
97
|
+
verdict: z.enum(["allow", "review", "block"], {
|
|
98
|
+
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
99
|
+
}),
|
|
100
|
+
reason: z.string().optional()
|
|
101
|
+
});
|
|
102
|
+
var ConfigFileSchema = z.object({
|
|
103
|
+
version: z.string().optional(),
|
|
104
|
+
settings: z.object({
|
|
105
|
+
mode: z.enum(["standard", "strict", "audit"]).optional(),
|
|
106
|
+
autoStartDaemon: z.boolean().optional(),
|
|
107
|
+
enableUndo: z.boolean().optional(),
|
|
108
|
+
enableHookLogDebug: z.boolean().optional(),
|
|
109
|
+
approvalTimeoutMs: z.number().nonnegative().optional(),
|
|
110
|
+
approvalTimeoutSeconds: z.number().nonnegative().optional(),
|
|
111
|
+
flightRecorder: z.boolean().optional(),
|
|
112
|
+
approvers: z.object({
|
|
113
|
+
native: z.boolean().optional(),
|
|
114
|
+
browser: z.boolean().optional(),
|
|
115
|
+
cloud: z.boolean().optional(),
|
|
116
|
+
terminal: z.boolean().optional()
|
|
117
|
+
}).optional(),
|
|
118
|
+
environment: z.string().optional(),
|
|
119
|
+
slackEnabled: z.boolean().optional(),
|
|
120
|
+
enableTrustSessions: z.boolean().optional(),
|
|
121
|
+
allowGlobalPause: z.boolean().optional()
|
|
122
|
+
}).optional(),
|
|
123
|
+
policy: z.object({
|
|
124
|
+
sandboxPaths: z.array(z.string()).optional(),
|
|
125
|
+
dangerousWords: z.array(noNewlines).optional(),
|
|
126
|
+
ignoredTools: z.array(z.string()).optional(),
|
|
127
|
+
toolInspection: z.record(z.string()).optional(),
|
|
128
|
+
smartRules: z.array(SmartRuleSchema).optional(),
|
|
129
|
+
snapshot: z.object({
|
|
130
|
+
tools: z.array(z.string()).optional(),
|
|
131
|
+
onlyPaths: z.array(z.string()).optional(),
|
|
132
|
+
ignorePaths: z.array(z.string()).optional()
|
|
133
|
+
}).optional(),
|
|
134
|
+
dlp: z.object({
|
|
135
|
+
enabled: z.boolean().optional(),
|
|
136
|
+
scanIgnoredTools: z.boolean().optional()
|
|
137
|
+
}).optional()
|
|
138
|
+
}).optional(),
|
|
139
|
+
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
140
|
+
}).strict({ message: "Config contains unknown top-level keys" });
|
|
141
|
+
function sanitizeConfig(raw) {
|
|
142
|
+
const result = ConfigFileSchema.safeParse(raw);
|
|
143
|
+
if (result.success) {
|
|
144
|
+
return { sanitized: result.data, error: null };
|
|
81
145
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const result = extractContext(String(obj.new_string), matchedWord);
|
|
91
|
-
contextSnippet = result.snippet;
|
|
92
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
93
|
-
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
94
|
-
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
95
|
-
contextSnippet = result.snippet;
|
|
96
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
97
|
-
} else {
|
|
98
|
-
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
99
|
-
if (foundKey) {
|
|
100
|
-
const val = obj[foundKey];
|
|
101
|
-
contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
|
|
146
|
+
const invalidTopLevelKeys = new Set(
|
|
147
|
+
result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
|
|
148
|
+
);
|
|
149
|
+
const sanitized = {};
|
|
150
|
+
if (typeof raw === "object" && raw !== null) {
|
|
151
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
152
|
+
if (!invalidTopLevelKeys.has(key)) {
|
|
153
|
+
sanitized[key] = value;
|
|
102
154
|
}
|
|
103
155
|
}
|
|
104
|
-
} else if (typeof parsed === "string") {
|
|
105
|
-
contextSnippet = smartTruncate(parsed, 500);
|
|
106
156
|
}
|
|
157
|
+
const lines = result.error.issues.map((issue) => {
|
|
158
|
+
const path10 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
159
|
+
return ` \u2022 ${path10}: ${issue.message}`;
|
|
160
|
+
});
|
|
107
161
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
...matchedWord && { matchedWord },
|
|
112
|
-
...matchedField && { matchedField },
|
|
113
|
-
...contextSnippet !== void 0 && { contextSnippet },
|
|
114
|
-
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
115
|
-
...editFileName && { editFileName },
|
|
116
|
-
...editFilePath && { editFilePath },
|
|
117
|
-
...ruleName && { ruleName }
|
|
162
|
+
sanitized,
|
|
163
|
+
error: `Invalid config:
|
|
164
|
+
${lines.join("\n")}`
|
|
118
165
|
};
|
|
119
166
|
}
|
|
120
167
|
|
|
121
|
-
// src/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
168
|
+
// src/shields.ts
|
|
169
|
+
import fs2 from "fs";
|
|
170
|
+
import path2 from "path";
|
|
171
|
+
import os2 from "os";
|
|
172
|
+
var SHIELDS = {
|
|
173
|
+
postgres: {
|
|
174
|
+
name: "postgres",
|
|
175
|
+
description: "Protects PostgreSQL databases from destructive AI operations",
|
|
176
|
+
aliases: ["pg", "postgresql"],
|
|
177
|
+
smartRules: [
|
|
178
|
+
{
|
|
179
|
+
name: "shield:postgres:block-drop-table",
|
|
180
|
+
tool: "*",
|
|
181
|
+
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
182
|
+
verdict: "block",
|
|
183
|
+
reason: "DROP TABLE is irreversible \u2014 blocked by Postgres shield"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "shield:postgres:block-truncate",
|
|
187
|
+
tool: "*",
|
|
188
|
+
conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
|
|
189
|
+
verdict: "block",
|
|
190
|
+
reason: "TRUNCATE is irreversible \u2014 blocked by Postgres shield"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "shield:postgres:block-drop-column",
|
|
194
|
+
tool: "*",
|
|
195
|
+
conditions: [
|
|
196
|
+
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
197
|
+
],
|
|
198
|
+
verdict: "block",
|
|
199
|
+
reason: "DROP COLUMN is irreversible \u2014 blocked by Postgres shield"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "shield:postgres:review-grant-revoke",
|
|
203
|
+
tool: "*",
|
|
204
|
+
conditions: [{ field: "sql", op: "matches", value: "\\b(GRANT|REVOKE)\\b", flags: "i" }],
|
|
205
|
+
verdict: "review",
|
|
206
|
+
reason: "Permission changes require human approval (Postgres shield)"
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
dangerousWords: ["dropdb", "pg_dropcluster"]
|
|
210
|
+
},
|
|
211
|
+
github: {
|
|
212
|
+
name: "github",
|
|
213
|
+
description: "Protects GitHub repositories from destructive AI operations",
|
|
214
|
+
aliases: ["git"],
|
|
215
|
+
smartRules: [
|
|
216
|
+
{
|
|
217
|
+
// Note: git branch -d/-D is already caught by the built-in review-git-destructive rule.
|
|
218
|
+
// This rule adds coverage for `git push --delete` which the built-in does not match.
|
|
219
|
+
name: "shield:github:review-delete-branch-remote",
|
|
220
|
+
tool: "bash",
|
|
221
|
+
conditions: [
|
|
222
|
+
{
|
|
223
|
+
field: "command",
|
|
224
|
+
op: "matches",
|
|
225
|
+
value: "git\\s+push\\s+.*--delete",
|
|
226
|
+
flags: "i"
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
verdict: "review",
|
|
230
|
+
reason: "Remote branch deletion requires human approval (GitHub shield)"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "shield:github:block-delete-repo",
|
|
234
|
+
tool: "*",
|
|
235
|
+
conditions: [
|
|
236
|
+
{ field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
|
|
237
|
+
],
|
|
238
|
+
verdict: "block",
|
|
239
|
+
reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
|
|
240
|
+
}
|
|
241
|
+
],
|
|
242
|
+
dangerousWords: []
|
|
243
|
+
},
|
|
244
|
+
aws: {
|
|
245
|
+
name: "aws",
|
|
246
|
+
description: "Protects AWS infrastructure from destructive AI operations",
|
|
247
|
+
aliases: ["amazon"],
|
|
248
|
+
smartRules: [
|
|
249
|
+
{
|
|
250
|
+
name: "shield:aws:block-delete-s3-bucket",
|
|
251
|
+
tool: "*",
|
|
252
|
+
conditions: [
|
|
253
|
+
{
|
|
254
|
+
field: "command",
|
|
255
|
+
op: "matches",
|
|
256
|
+
value: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
|
|
257
|
+
flags: "i"
|
|
258
|
+
}
|
|
259
|
+
],
|
|
260
|
+
verdict: "block",
|
|
261
|
+
reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "shield:aws:review-iam-changes",
|
|
265
|
+
tool: "*",
|
|
266
|
+
conditions: [
|
|
267
|
+
{
|
|
268
|
+
field: "command",
|
|
269
|
+
op: "matches",
|
|
270
|
+
value: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
|
|
271
|
+
flags: "i"
|
|
272
|
+
}
|
|
273
|
+
],
|
|
274
|
+
verdict: "review",
|
|
275
|
+
reason: "IAM changes require human approval (AWS shield)"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "shield:aws:block-ec2-terminate",
|
|
279
|
+
tool: "*",
|
|
280
|
+
conditions: [
|
|
281
|
+
{
|
|
282
|
+
field: "command",
|
|
283
|
+
op: "matches",
|
|
284
|
+
value: "aws\\s+ec2\\s+terminate-instances",
|
|
285
|
+
flags: "i"
|
|
286
|
+
}
|
|
287
|
+
],
|
|
288
|
+
verdict: "block",
|
|
289
|
+
reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "shield:aws:review-rds-delete",
|
|
293
|
+
tool: "*",
|
|
294
|
+
conditions: [
|
|
295
|
+
{ field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
|
|
296
|
+
],
|
|
297
|
+
verdict: "review",
|
|
298
|
+
reason: "RDS deletion requires human approval (AWS shield)"
|
|
299
|
+
}
|
|
300
|
+
],
|
|
301
|
+
dangerousWords: []
|
|
302
|
+
},
|
|
303
|
+
filesystem: {
|
|
304
|
+
name: "filesystem",
|
|
305
|
+
description: "Protects the local filesystem from dangerous AI operations",
|
|
306
|
+
aliases: ["fs"],
|
|
307
|
+
smartRules: [
|
|
308
|
+
{
|
|
309
|
+
name: "shield:filesystem:review-chmod-777",
|
|
310
|
+
tool: "bash",
|
|
311
|
+
conditions: [
|
|
312
|
+
{ field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
|
|
313
|
+
],
|
|
314
|
+
verdict: "review",
|
|
315
|
+
reason: "chmod 777 requires human approval (filesystem shield)"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "shield:filesystem:review-write-etc",
|
|
319
|
+
tool: "bash",
|
|
320
|
+
conditions: [
|
|
321
|
+
{
|
|
322
|
+
field: "command",
|
|
323
|
+
// Narrow to write-indicative operations to avoid approval fatigue on reads.
|
|
324
|
+
// Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
|
|
325
|
+
op: "matches",
|
|
326
|
+
value: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
|
|
327
|
+
}
|
|
328
|
+
],
|
|
329
|
+
verdict: "review",
|
|
330
|
+
reason: "Writing to /etc requires human approval (filesystem shield)"
|
|
331
|
+
}
|
|
332
|
+
],
|
|
333
|
+
// dd removed: too common as a legitimate tool (disk imaging, file ops).
|
|
334
|
+
// mkfs removed: already in the built-in DANGEROUS_WORDS baseline.
|
|
335
|
+
// wipefs retained: rarely legitimate in an agent context and not in built-ins.
|
|
336
|
+
dangerousWords: ["wipefs"]
|
|
139
337
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
intent: "EDIT",
|
|
148
|
-
message: `\u{1F4DD} EDITING: ${file}
|
|
149
|
-
\u{1F4C2} PATH: ${obj.file_path}
|
|
150
|
-
|
|
151
|
-
--- REPLACING ---
|
|
152
|
-
${oldPreview}
|
|
153
|
-
|
|
154
|
-
+++ NEW CODE +++
|
|
155
|
-
${newPreview}`
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
if (matchedField && obj[matchedField] !== void 0) {
|
|
159
|
-
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
160
|
-
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(", ")}
|
|
161
|
-
|
|
162
|
-
` : "";
|
|
163
|
-
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
164
|
-
return {
|
|
165
|
-
intent: "EXEC",
|
|
166
|
-
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
167
|
-
${content}`
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
const codeKeys = [
|
|
171
|
-
"command",
|
|
172
|
-
"cmd",
|
|
173
|
-
"shell_command",
|
|
174
|
-
"bash_command",
|
|
175
|
-
"script",
|
|
176
|
-
"code",
|
|
177
|
-
"input",
|
|
178
|
-
"sql",
|
|
179
|
-
"query",
|
|
180
|
-
"arguments",
|
|
181
|
-
"args",
|
|
182
|
-
"param",
|
|
183
|
-
"params",
|
|
184
|
-
"text"
|
|
185
|
-
];
|
|
186
|
-
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
187
|
-
if (foundKey) {
|
|
188
|
-
const val = obj[foundKey];
|
|
189
|
-
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
190
|
-
return {
|
|
191
|
-
intent: "EXEC",
|
|
192
|
-
message: `[${foundKey.toUpperCase()}]:
|
|
193
|
-
${smartTruncate(str, 500)}`
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
const msg = Object.entries(obj).slice(0, 5).map(
|
|
197
|
-
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
198
|
-
).join("\n");
|
|
199
|
-
return { intent: "EXEC", message: msg };
|
|
338
|
+
};
|
|
339
|
+
function resolveShieldName(input) {
|
|
340
|
+
const lower = input.toLowerCase();
|
|
341
|
+
if (SHIELDS[lower]) return lower;
|
|
342
|
+
for (const [name, def] of Object.entries(SHIELDS)) {
|
|
343
|
+
if (def.aliases.includes(lower)) return name;
|
|
200
344
|
}
|
|
201
|
-
return
|
|
345
|
+
return null;
|
|
202
346
|
}
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (process.platform === "darwin") {
|
|
207
|
-
const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
|
|
208
|
-
spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
209
|
-
} else if (process.platform === "linux") {
|
|
210
|
-
spawn("notify-send", [title, body, "--icon=dialog-warning"], {
|
|
211
|
-
detached: true,
|
|
212
|
-
stdio: "ignore"
|
|
213
|
-
}).unref();
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
}
|
|
347
|
+
function getShield(name) {
|
|
348
|
+
const resolved = resolveShieldName(name);
|
|
349
|
+
return resolved ? SHIELDS[resolved] : null;
|
|
217
350
|
}
|
|
218
|
-
|
|
219
|
-
|
|
351
|
+
var SHIELDS_STATE_FILE = path2.join(os2.homedir(), ".node9", "shields.json");
|
|
352
|
+
function isShieldVerdict(v) {
|
|
353
|
+
return v === "allow" || v === "review" || v === "block";
|
|
220
354
|
}
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
235
|
-
const lines = [];
|
|
236
|
-
if (locked) {
|
|
237
|
-
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
238
|
-
lines.push("");
|
|
239
|
-
}
|
|
240
|
-
lines.push(
|
|
241
|
-
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
242
|
-
);
|
|
243
|
-
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
244
|
-
lines.push("");
|
|
245
|
-
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
246
|
-
if (!locked) {
|
|
247
|
-
lines.push("");
|
|
248
|
-
lines.push(
|
|
249
|
-
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
return lines.join("\n");
|
|
253
|
-
}
|
|
254
|
-
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
|
|
255
|
-
if (isTestEnv()) return "deny";
|
|
256
|
-
const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
|
|
257
|
-
const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
|
|
258
|
-
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
|
|
259
|
-
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
260
|
-
process.stderr.write(chalk.yellow(`
|
|
261
|
-
\u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
|
|
262
|
-
`));
|
|
263
|
-
return new Promise((resolve) => {
|
|
264
|
-
let childProcess = null;
|
|
265
|
-
const onAbort = () => {
|
|
266
|
-
if (childProcess && childProcess.pid) {
|
|
267
|
-
try {
|
|
268
|
-
process.kill(childProcess.pid, "SIGKILL");
|
|
269
|
-
} catch {
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
resolve("deny");
|
|
273
|
-
};
|
|
274
|
-
if (signal) {
|
|
275
|
-
if (signal.aborted) return resolve("deny");
|
|
276
|
-
signal.addEventListener("abort", onAbort);
|
|
277
|
-
}
|
|
278
|
-
try {
|
|
279
|
-
if (process.platform === "darwin") {
|
|
280
|
-
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "Always Allow", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
|
|
281
|
-
const script = `on run argv
|
|
282
|
-
tell application "System Events"
|
|
283
|
-
activate
|
|
284
|
-
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
285
|
-
end tell
|
|
286
|
-
end run`;
|
|
287
|
-
childProcess = spawn("osascript", ["-e", script, "--", message, title]);
|
|
288
|
-
} else if (process.platform === "linux") {
|
|
289
|
-
const pangoMessage = buildPangoMessage(
|
|
290
|
-
toolName,
|
|
291
|
-
formattedArgs,
|
|
292
|
-
agent,
|
|
293
|
-
explainableLabel,
|
|
294
|
-
locked
|
|
355
|
+
function validateOverrides(raw) {
|
|
356
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
357
|
+
const result = {};
|
|
358
|
+
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
359
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
360
|
+
const validRules = {};
|
|
361
|
+
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
362
|
+
if (isShieldVerdict(verdict)) {
|
|
363
|
+
validRules[ruleName] = verdict;
|
|
364
|
+
} else {
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
`[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
|
|
367
|
+
`
|
|
295
368
|
);
|
|
296
|
-
const argsList = [
|
|
297
|
-
locked ? "--info" : "--question",
|
|
298
|
-
"--modal",
|
|
299
|
-
"--width=480",
|
|
300
|
-
"--title",
|
|
301
|
-
title,
|
|
302
|
-
"--text",
|
|
303
|
-
pangoMessage,
|
|
304
|
-
"--ok-label",
|
|
305
|
-
locked ? "Waiting..." : "Allow \u21B5",
|
|
306
|
-
"--timeout",
|
|
307
|
-
"300"
|
|
308
|
-
];
|
|
309
|
-
if (!locked) {
|
|
310
|
-
argsList.push("--cancel-label", "Block \u238B");
|
|
311
|
-
argsList.push("--extra-button", "Always Allow");
|
|
312
|
-
}
|
|
313
|
-
childProcess = spawn("zenity", argsList);
|
|
314
|
-
} else if (process.platform === "win32") {
|
|
315
|
-
const b64Msg = Buffer.from(message).toString("base64");
|
|
316
|
-
const b64Title = Buffer.from(title).toString("base64");
|
|
317
|
-
const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
318
|
-
childProcess = spawn("powershell", ["-Command", ps]);
|
|
319
369
|
}
|
|
320
|
-
let output = "";
|
|
321
|
-
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
322
|
-
childProcess?.on("close", (code) => {
|
|
323
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
324
|
-
if (locked) return resolve("deny");
|
|
325
|
-
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
326
|
-
if (code === 0) return resolve("allow");
|
|
327
|
-
resolve("deny");
|
|
328
|
-
});
|
|
329
|
-
} catch {
|
|
330
|
-
resolve("deny");
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// src/config-schema.ts
|
|
336
|
-
import { z } from "zod";
|
|
337
|
-
var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
338
|
-
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
339
|
-
});
|
|
340
|
-
var SmartConditionSchema = z.object({
|
|
341
|
-
field: z.string().min(1, "Condition field must not be empty"),
|
|
342
|
-
op: z.enum(
|
|
343
|
-
[
|
|
344
|
-
"matches",
|
|
345
|
-
"notMatches",
|
|
346
|
-
"contains",
|
|
347
|
-
"notContains",
|
|
348
|
-
"exists",
|
|
349
|
-
"notExists",
|
|
350
|
-
"matchesGlob",
|
|
351
|
-
"notMatchesGlob"
|
|
352
|
-
],
|
|
353
|
-
{
|
|
354
|
-
errorMap: () => ({
|
|
355
|
-
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
|
|
356
|
-
})
|
|
357
370
|
}
|
|
358
|
-
|
|
359
|
-
value: z.string().optional(),
|
|
360
|
-
flags: z.string().optional()
|
|
361
|
-
}).refine(
|
|
362
|
-
(c) => {
|
|
363
|
-
if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
|
|
364
|
-
return true;
|
|
365
|
-
},
|
|
366
|
-
{ message: "matchesGlob and notMatchesGlob conditions require a value field" }
|
|
367
|
-
);
|
|
368
|
-
var SmartRuleSchema = z.object({
|
|
369
|
-
name: z.string().optional(),
|
|
370
|
-
tool: z.string().min(1, "Smart rule tool must not be empty"),
|
|
371
|
-
conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
372
|
-
conditionMode: z.enum(["all", "any"]).optional(),
|
|
373
|
-
verdict: z.enum(["allow", "review", "block"], {
|
|
374
|
-
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
375
|
-
}),
|
|
376
|
-
reason: z.string().optional()
|
|
377
|
-
});
|
|
378
|
-
var ConfigFileSchema = z.object({
|
|
379
|
-
version: z.string().optional(),
|
|
380
|
-
settings: z.object({
|
|
381
|
-
mode: z.enum(["standard", "strict", "audit"]).optional(),
|
|
382
|
-
autoStartDaemon: z.boolean().optional(),
|
|
383
|
-
enableUndo: z.boolean().optional(),
|
|
384
|
-
enableHookLogDebug: z.boolean().optional(),
|
|
385
|
-
approvalTimeoutMs: z.number().nonnegative().optional(),
|
|
386
|
-
flightRecorder: z.boolean().optional(),
|
|
387
|
-
approvers: z.object({
|
|
388
|
-
native: z.boolean().optional(),
|
|
389
|
-
browser: z.boolean().optional(),
|
|
390
|
-
cloud: z.boolean().optional(),
|
|
391
|
-
terminal: z.boolean().optional()
|
|
392
|
-
}).optional(),
|
|
393
|
-
environment: z.string().optional(),
|
|
394
|
-
slackEnabled: z.boolean().optional(),
|
|
395
|
-
enableTrustSessions: z.boolean().optional(),
|
|
396
|
-
allowGlobalPause: z.boolean().optional()
|
|
397
|
-
}).optional(),
|
|
398
|
-
policy: z.object({
|
|
399
|
-
sandboxPaths: z.array(z.string()).optional(),
|
|
400
|
-
dangerousWords: z.array(noNewlines).optional(),
|
|
401
|
-
ignoredTools: z.array(z.string()).optional(),
|
|
402
|
-
toolInspection: z.record(z.string()).optional(),
|
|
403
|
-
smartRules: z.array(SmartRuleSchema).optional(),
|
|
404
|
-
snapshot: z.object({
|
|
405
|
-
tools: z.array(z.string()).optional(),
|
|
406
|
-
onlyPaths: z.array(z.string()).optional(),
|
|
407
|
-
ignorePaths: z.array(z.string()).optional()
|
|
408
|
-
}).optional(),
|
|
409
|
-
dlp: z.object({
|
|
410
|
-
enabled: z.boolean().optional(),
|
|
411
|
-
scanIgnoredTools: z.boolean().optional()
|
|
412
|
-
}).optional()
|
|
413
|
-
}).optional(),
|
|
414
|
-
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
415
|
-
}).strict({ message: "Config contains unknown top-level keys" });
|
|
416
|
-
function sanitizeConfig(raw) {
|
|
417
|
-
const result = ConfigFileSchema.safeParse(raw);
|
|
418
|
-
if (result.success) {
|
|
419
|
-
return { sanitized: result.data, error: null };
|
|
371
|
+
if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
|
|
420
372
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
function readShieldsFile() {
|
|
376
|
+
try {
|
|
377
|
+
const raw = fs2.readFileSync(SHIELDS_STATE_FILE, "utf-8");
|
|
378
|
+
if (!raw.trim()) return { active: [] };
|
|
379
|
+
const parsed = JSON.parse(raw);
|
|
380
|
+
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
381
|
+
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
382
|
+
) : [];
|
|
383
|
+
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err.code !== "ENOENT") {
|
|
386
|
+
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
387
|
+
`);
|
|
430
388
|
}
|
|
389
|
+
return { active: [] };
|
|
431
390
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
error: `Invalid config:
|
|
439
|
-
${lines.join("\n")}`
|
|
440
|
-
};
|
|
391
|
+
}
|
|
392
|
+
function readActiveShields() {
|
|
393
|
+
return readShieldsFile().active;
|
|
394
|
+
}
|
|
395
|
+
function readShieldOverrides() {
|
|
396
|
+
return readShieldsFile().overrides ?? {};
|
|
441
397
|
}
|
|
442
398
|
|
|
443
|
-
// src/
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
399
|
+
// src/config/index.ts
|
|
400
|
+
var DANGEROUS_WORDS = [
|
|
401
|
+
"mkfs",
|
|
402
|
+
// formats/wipes a filesystem partition
|
|
403
|
+
"shred"
|
|
404
|
+
// permanently overwrites file contents (unrecoverable)
|
|
405
|
+
];
|
|
406
|
+
var DEFAULT_CONFIG = {
|
|
407
|
+
version: "1.0",
|
|
408
|
+
settings: {
|
|
409
|
+
mode: "audit",
|
|
410
|
+
autoStartDaemon: true,
|
|
411
|
+
enableUndo: true,
|
|
412
|
+
// 🔥 ALWAYS TRUE BY DEFAULT for the safety net
|
|
413
|
+
enableHookLogDebug: true,
|
|
414
|
+
approvalTimeoutMs: 12e4,
|
|
415
|
+
// 120-second auto-deny timeout
|
|
416
|
+
flightRecorder: true,
|
|
417
|
+
approvers: { native: true, browser: true, cloud: false, terminal: true }
|
|
418
|
+
},
|
|
419
|
+
policy: {
|
|
420
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
421
|
+
dangerousWords: DANGEROUS_WORDS,
|
|
422
|
+
ignoredTools: [
|
|
423
|
+
"list_*",
|
|
424
|
+
"get_*",
|
|
425
|
+
"read_*",
|
|
426
|
+
"describe_*",
|
|
427
|
+
"read",
|
|
428
|
+
"glob",
|
|
429
|
+
"grep",
|
|
430
|
+
"ls",
|
|
431
|
+
"notebookread",
|
|
432
|
+
"notebookedit",
|
|
433
|
+
"webfetch",
|
|
434
|
+
"websearch",
|
|
435
|
+
"exitplanmode",
|
|
436
|
+
"askuserquestion",
|
|
437
|
+
"agent",
|
|
438
|
+
"task*",
|
|
439
|
+
"toolsearch",
|
|
440
|
+
"mcp__ide__*",
|
|
441
|
+
"getDiagnostics"
|
|
442
|
+
],
|
|
443
|
+
toolInspection: {
|
|
444
|
+
bash: "command",
|
|
445
|
+
shell: "command",
|
|
446
|
+
run_shell_command: "command",
|
|
447
|
+
"terminal.execute": "command",
|
|
448
|
+
"postgres:query": "sql"
|
|
449
|
+
},
|
|
450
|
+
snapshot: {
|
|
451
|
+
tools: [
|
|
452
|
+
"str_replace_based_edit_tool",
|
|
453
|
+
"write_file",
|
|
454
|
+
"edit_file",
|
|
455
|
+
"create_file",
|
|
456
|
+
"edit",
|
|
457
|
+
"replace"
|
|
458
|
+
],
|
|
459
|
+
onlyPaths: [],
|
|
460
|
+
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
461
|
+
},
|
|
462
|
+
smartRules: [
|
|
463
|
+
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
467
464
|
{
|
|
468
|
-
name: "
|
|
469
|
-
tool: "
|
|
465
|
+
name: "block-rm-rf-home",
|
|
466
|
+
tool: "bash",
|
|
467
|
+
conditionMode: "all",
|
|
470
468
|
conditions: [
|
|
471
|
-
{
|
|
469
|
+
{
|
|
470
|
+
field: "command",
|
|
471
|
+
op: "matches",
|
|
472
|
+
value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
field: "command",
|
|
476
|
+
op: "matches",
|
|
477
|
+
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
478
|
+
}
|
|
472
479
|
],
|
|
473
480
|
verdict: "block",
|
|
474
|
-
reason: "
|
|
481
|
+
reason: "Recursive delete of home directory is irreversible"
|
|
475
482
|
},
|
|
483
|
+
// ── SQL safety ────────────────────────────────────────────────────────
|
|
476
484
|
{
|
|
477
|
-
name: "
|
|
485
|
+
name: "no-delete-without-where",
|
|
478
486
|
tool: "*",
|
|
479
|
-
conditions: [
|
|
487
|
+
conditions: [
|
|
488
|
+
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
489
|
+
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
490
|
+
],
|
|
491
|
+
conditionMode: "all",
|
|
480
492
|
verdict: "review",
|
|
481
|
-
reason: "
|
|
482
|
-
}
|
|
483
|
-
],
|
|
484
|
-
dangerousWords: ["dropdb", "pg_dropcluster"]
|
|
485
|
-
},
|
|
486
|
-
github: {
|
|
487
|
-
name: "github",
|
|
488
|
-
description: "Protects GitHub repositories from destructive AI operations",
|
|
489
|
-
aliases: ["git"],
|
|
490
|
-
smartRules: [
|
|
493
|
+
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
494
|
+
},
|
|
491
495
|
{
|
|
492
|
-
|
|
493
|
-
// This rule adds coverage for `git push --delete` which the built-in does not match.
|
|
494
|
-
name: "shield:github:review-delete-branch-remote",
|
|
496
|
+
name: "review-drop-truncate-shell",
|
|
495
497
|
tool: "bash",
|
|
496
498
|
conditions: [
|
|
497
499
|
{
|
|
498
500
|
field: "command",
|
|
499
501
|
op: "matches",
|
|
500
|
-
value: "
|
|
502
|
+
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
501
503
|
flags: "i"
|
|
502
504
|
}
|
|
503
505
|
],
|
|
506
|
+
conditionMode: "all",
|
|
504
507
|
verdict: "review",
|
|
505
|
-
reason: "
|
|
508
|
+
reason: "SQL DDL destructive statement inside a shell command"
|
|
506
509
|
},
|
|
510
|
+
// ── Git safety ────────────────────────────────────────────────────────
|
|
507
511
|
{
|
|
508
|
-
name: "
|
|
509
|
-
tool: "
|
|
510
|
-
conditions: [
|
|
511
|
-
{ field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
|
|
512
|
-
],
|
|
513
|
-
verdict: "block",
|
|
514
|
-
reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
|
|
515
|
-
}
|
|
516
|
-
],
|
|
517
|
-
dangerousWords: []
|
|
518
|
-
},
|
|
519
|
-
aws: {
|
|
520
|
-
name: "aws",
|
|
521
|
-
description: "Protects AWS infrastructure from destructive AI operations",
|
|
522
|
-
aliases: ["amazon"],
|
|
523
|
-
smartRules: [
|
|
524
|
-
{
|
|
525
|
-
name: "shield:aws:block-delete-s3-bucket",
|
|
526
|
-
tool: "*",
|
|
512
|
+
name: "block-force-push",
|
|
513
|
+
tool: "bash",
|
|
527
514
|
conditions: [
|
|
528
515
|
{
|
|
529
516
|
field: "command",
|
|
530
517
|
op: "matches",
|
|
531
|
-
value: "
|
|
518
|
+
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
532
519
|
flags: "i"
|
|
533
520
|
}
|
|
534
521
|
],
|
|
522
|
+
conditionMode: "all",
|
|
535
523
|
verdict: "block",
|
|
536
|
-
reason: "
|
|
524
|
+
reason: "Force push overwrites remote history and cannot be undone"
|
|
537
525
|
},
|
|
538
526
|
{
|
|
539
|
-
name: "
|
|
540
|
-
tool: "
|
|
527
|
+
name: "review-git-push",
|
|
528
|
+
tool: "bash",
|
|
541
529
|
conditions: [
|
|
542
530
|
{
|
|
543
531
|
field: "command",
|
|
544
532
|
op: "matches",
|
|
545
|
-
value: "
|
|
533
|
+
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
546
534
|
flags: "i"
|
|
547
535
|
}
|
|
548
536
|
],
|
|
537
|
+
conditionMode: "all",
|
|
549
538
|
verdict: "review",
|
|
550
|
-
reason: "
|
|
539
|
+
reason: "git push sends changes to a shared remote"
|
|
551
540
|
},
|
|
552
541
|
{
|
|
553
|
-
name: "
|
|
554
|
-
tool: "
|
|
542
|
+
name: "review-git-destructive",
|
|
543
|
+
tool: "bash",
|
|
555
544
|
conditions: [
|
|
556
545
|
{
|
|
557
546
|
field: "command",
|
|
558
547
|
op: "matches",
|
|
559
|
-
value: "
|
|
548
|
+
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
560
549
|
flags: "i"
|
|
561
550
|
}
|
|
562
551
|
],
|
|
563
|
-
|
|
564
|
-
reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
|
|
565
|
-
},
|
|
566
|
-
{
|
|
567
|
-
name: "shield:aws:review-rds-delete",
|
|
568
|
-
tool: "*",
|
|
569
|
-
conditions: [
|
|
570
|
-
{ field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
|
|
571
|
-
],
|
|
552
|
+
conditionMode: "all",
|
|
572
553
|
verdict: "review",
|
|
573
|
-
reason: "
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
dangerousWords: []
|
|
577
|
-
},
|
|
578
|
-
filesystem: {
|
|
579
|
-
name: "filesystem",
|
|
580
|
-
description: "Protects the local filesystem from dangerous AI operations",
|
|
581
|
-
aliases: ["fs"],
|
|
582
|
-
smartRules: [
|
|
554
|
+
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
555
|
+
},
|
|
556
|
+
// ── Shell safety ──────────────────────────────────────────────────────
|
|
583
557
|
{
|
|
584
|
-
name: "
|
|
558
|
+
name: "review-sudo",
|
|
585
559
|
tool: "bash",
|
|
586
|
-
conditions: [
|
|
587
|
-
|
|
588
|
-
],
|
|
560
|
+
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
561
|
+
conditionMode: "all",
|
|
589
562
|
verdict: "review",
|
|
590
|
-
reason: "
|
|
563
|
+
reason: "Command requires elevated privileges"
|
|
591
564
|
},
|
|
592
565
|
{
|
|
593
|
-
name: "
|
|
566
|
+
name: "review-curl-pipe-shell",
|
|
594
567
|
tool: "bash",
|
|
595
568
|
conditions: [
|
|
596
569
|
{
|
|
597
570
|
field: "command",
|
|
598
|
-
// Narrow to write-indicative operations to avoid approval fatigue on reads.
|
|
599
|
-
// Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
|
|
600
571
|
op: "matches",
|
|
601
|
-
value: "(
|
|
572
|
+
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
573
|
+
flags: "i"
|
|
602
574
|
}
|
|
603
575
|
],
|
|
604
|
-
|
|
605
|
-
|
|
576
|
+
conditionMode: "all",
|
|
577
|
+
verdict: "block",
|
|
578
|
+
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
606
579
|
}
|
|
607
580
|
],
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
dangerousWords: ["wipefs"]
|
|
612
|
-
}
|
|
581
|
+
dlp: { enabled: true, scanIgnoredTools: true }
|
|
582
|
+
},
|
|
583
|
+
environments: {}
|
|
613
584
|
};
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
function validateOverrides(raw) {
|
|
631
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
632
|
-
const result = {};
|
|
633
|
-
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
634
|
-
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
635
|
-
const validRules = {};
|
|
636
|
-
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
637
|
-
if (isShieldVerdict(verdict)) {
|
|
638
|
-
validRules[ruleName] = verdict;
|
|
639
|
-
} else {
|
|
640
|
-
process.stderr.write(
|
|
641
|
-
`[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
|
|
642
|
-
`
|
|
643
|
-
);
|
|
585
|
+
var ADVISORY_SMART_RULES = [
|
|
586
|
+
// ── rm safety ─────────────────────────────────────────────────────────────
|
|
587
|
+
// tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
|
|
588
|
+
// Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
|
|
589
|
+
// chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
|
|
590
|
+
{
|
|
591
|
+
name: "allow-rm-safe-paths",
|
|
592
|
+
tool: "*",
|
|
593
|
+
conditionMode: "all",
|
|
594
|
+
conditions: [
|
|
595
|
+
{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
|
|
596
|
+
{
|
|
597
|
+
field: "command",
|
|
598
|
+
op: "matches",
|
|
599
|
+
// Matches known-safe build artifact paths in the command.
|
|
600
|
+
value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
|
|
644
601
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
function readShieldsFile() {
|
|
651
|
-
try {
|
|
652
|
-
const raw = fs.readFileSync(SHIELDS_STATE_FILE, "utf-8");
|
|
653
|
-
if (!raw.trim()) return { active: [] };
|
|
654
|
-
const parsed = JSON.parse(raw);
|
|
655
|
-
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
656
|
-
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
657
|
-
) : [];
|
|
658
|
-
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
659
|
-
} catch (err) {
|
|
660
|
-
if (err.code !== "ENOENT") {
|
|
661
|
-
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
662
|
-
`);
|
|
663
|
-
}
|
|
664
|
-
return { active: [] };
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
function readActiveShields() {
|
|
668
|
-
return readShieldsFile().active;
|
|
669
|
-
}
|
|
670
|
-
function readShieldOverrides() {
|
|
671
|
-
return readShieldsFile().overrides ?? {};
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// src/dlp.ts
|
|
675
|
-
import fs2 from "fs";
|
|
676
|
-
import path4 from "path";
|
|
677
|
-
var DLP_PATTERNS = [
|
|
678
|
-
{ name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
|
|
679
|
-
{ name: "GitHub Token", regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/, severity: "block" },
|
|
680
|
-
// Slack bot tokens: xoxb- + variable segment. Real tokens are ~50–80 chars;
|
|
681
|
-
// lower bound 20 avoids false negatives on partial tokens, upper 100 caps scan cost.
|
|
682
|
-
{ name: "Slack Bot Token", regex: /\bxoxb-[0-9A-Za-z-]{20,100}\b/, severity: "block" },
|
|
683
|
-
{ name: "OpenAI API Key", regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/, severity: "block" },
|
|
684
|
-
{ name: "Stripe Secret Key", regex: /\bsk_(?:live|test)_[0-9a-zA-Z]{24}\b/, severity: "block" },
|
|
602
|
+
],
|
|
603
|
+
verdict: "allow",
|
|
604
|
+
reason: "Deleting a known-safe build artifact path"
|
|
605
|
+
},
|
|
685
606
|
{
|
|
686
|
-
name: "
|
|
687
|
-
|
|
688
|
-
|
|
607
|
+
name: "review-rm",
|
|
608
|
+
tool: "*",
|
|
609
|
+
conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
|
|
610
|
+
verdict: "review",
|
|
611
|
+
reason: "rm can permanently delete files \u2014 confirm the target path"
|
|
689
612
|
},
|
|
690
|
-
//
|
|
613
|
+
// ── SQL safety (Safe by Default) ──────────────────────────────────────────
|
|
614
|
+
// These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
|
|
615
|
+
// mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
|
|
616
|
+
// The postgres shield upgrades these from 'review' → 'block' for stricter teams;
|
|
617
|
+
// without a shield, users still get a human-approval gate on every destructive op.
|
|
691
618
|
{
|
|
692
|
-
name: "
|
|
693
|
-
|
|
694
|
-
|
|
619
|
+
name: "review-drop-table-sql",
|
|
620
|
+
tool: "*",
|
|
621
|
+
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
622
|
+
verdict: "review",
|
|
623
|
+
reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
|
|
695
624
|
},
|
|
696
|
-
// NPM auth token in .npmrc format
|
|
697
625
|
{
|
|
698
|
-
name: "
|
|
699
|
-
|
|
700
|
-
|
|
626
|
+
name: "review-truncate-sql",
|
|
627
|
+
tool: "*",
|
|
628
|
+
conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
|
|
629
|
+
verdict: "review",
|
|
630
|
+
reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
|
|
701
631
|
},
|
|
702
|
-
{
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
// .env, .env.local, .env.production — not .envoy
|
|
712
|
-
/[/\\]\.git-credentials$/i,
|
|
713
|
-
/[/\\]\.npmrc$/i,
|
|
714
|
-
/[/\\]\.docker[/\\]config\.json$/i,
|
|
715
|
-
/[/\\][^/\\]+\.pem$/i,
|
|
716
|
-
/[/\\][^/\\]+\.key$/i,
|
|
717
|
-
/[/\\][^/\\]+\.p12$/i,
|
|
718
|
-
/[/\\][^/\\]+\.pfx$/i,
|
|
719
|
-
/^(?:[a-zA-Z]:)?\/etc\/passwd$/,
|
|
720
|
-
/^(?:[a-zA-Z]:)?\/etc\/shadow$/,
|
|
721
|
-
/^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
|
|
722
|
-
/[/\\]credentials\.json$/i,
|
|
723
|
-
/[/\\]id_rsa$/i,
|
|
724
|
-
/[/\\]id_ed25519$/i,
|
|
725
|
-
/[/\\]id_ecdsa$/i
|
|
632
|
+
{
|
|
633
|
+
name: "review-drop-column-sql",
|
|
634
|
+
tool: "*",
|
|
635
|
+
conditions: [
|
|
636
|
+
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
637
|
+
],
|
|
638
|
+
verdict: "review",
|
|
639
|
+
reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
|
|
640
|
+
}
|
|
726
641
|
];
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
736
|
-
resolved = path4.resolve(cwd, filePath);
|
|
737
|
-
} else {
|
|
738
|
-
return {
|
|
739
|
-
patternName: "Sensitive File Path",
|
|
740
|
-
fieldPath: "file_path",
|
|
741
|
-
redactedSample: filePath,
|
|
742
|
-
severity: "block"
|
|
743
|
-
};
|
|
744
|
-
}
|
|
642
|
+
var cachedConfig = null;
|
|
643
|
+
function getCredentials() {
|
|
644
|
+
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
645
|
+
if (process.env.NODE9_API_KEY) {
|
|
646
|
+
return {
|
|
647
|
+
apiKey: process.env.NODE9_API_KEY,
|
|
648
|
+
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
649
|
+
};
|
|
745
650
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
651
|
+
try {
|
|
652
|
+
const credPath = path3.join(os3.homedir(), ".node9", "credentials.json");
|
|
653
|
+
if (fs3.existsSync(credPath)) {
|
|
654
|
+
const creds = JSON.parse(fs3.readFileSync(credPath, "utf-8"));
|
|
655
|
+
const profileName = process.env.NODE9_PROFILE || "default";
|
|
656
|
+
const profile = creds[profileName];
|
|
657
|
+
if (profile?.apiKey) {
|
|
658
|
+
return {
|
|
659
|
+
apiKey: profile.apiKey,
|
|
660
|
+
apiUrl: profile.apiUrl || DEFAULT_API_URL
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (creds.apiKey) {
|
|
664
|
+
return {
|
|
665
|
+
apiKey: creds.apiKey,
|
|
666
|
+
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
667
|
+
};
|
|
668
|
+
}
|
|
756
669
|
}
|
|
670
|
+
} catch {
|
|
757
671
|
}
|
|
758
672
|
return null;
|
|
759
673
|
}
|
|
760
|
-
function
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
const secret = match[0];
|
|
764
|
-
if (secret.length < 8) return "****";
|
|
765
|
-
const prefix = secret.slice(0, 4);
|
|
766
|
-
const suffix = secret.slice(-4);
|
|
767
|
-
const stars = "*".repeat(Math.min(secret.length - 8, 12));
|
|
768
|
-
return `${prefix}${stars}${suffix}`;
|
|
674
|
+
function getActiveEnvironment(config) {
|
|
675
|
+
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
676
|
+
return config.environments[env] ?? null;
|
|
769
677
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
678
|
+
function getConfig(cwd) {
|
|
679
|
+
if (!cwd && cachedConfig) return cachedConfig;
|
|
680
|
+
const globalPath = path3.join(os3.homedir(), ".node9", "config.json");
|
|
681
|
+
const projectPath = path3.join(cwd ?? process.cwd(), "node9.config.json");
|
|
682
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
683
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
684
|
+
const mergedSettings = {
|
|
685
|
+
...DEFAULT_CONFIG.settings,
|
|
686
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
687
|
+
};
|
|
688
|
+
const mergedPolicy = {
|
|
689
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
690
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
691
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
692
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
693
|
+
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
694
|
+
snapshot: {
|
|
695
|
+
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
696
|
+
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
697
|
+
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
698
|
+
},
|
|
699
|
+
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
700
|
+
};
|
|
701
|
+
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
702
|
+
const applyLayer = (source) => {
|
|
703
|
+
if (!source) return;
|
|
704
|
+
const s = source.settings || {};
|
|
705
|
+
const p = source.policy || {};
|
|
706
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
707
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
708
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
709
|
+
if (s.enableHookLogDebug !== void 0)
|
|
710
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
711
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
712
|
+
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
713
|
+
if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
|
|
714
|
+
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
715
|
+
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
716
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
717
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
718
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
719
|
+
if (p.toolInspection)
|
|
720
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
721
|
+
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
722
|
+
if (p.snapshot) {
|
|
723
|
+
const s2 = p.snapshot;
|
|
724
|
+
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
725
|
+
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
726
|
+
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
779
727
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
|
|
785
|
-
if (match) return match;
|
|
728
|
+
if (p.dlp) {
|
|
729
|
+
const d = p.dlp;
|
|
730
|
+
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
731
|
+
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
786
732
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
fieldPath,
|
|
796
|
-
redactedSample: maskSecret(text, pattern.regex),
|
|
797
|
-
severity: pattern.severity
|
|
733
|
+
const envs = source.environments || {};
|
|
734
|
+
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
735
|
+
if (envConfig && typeof envConfig === "object") {
|
|
736
|
+
const ec = envConfig;
|
|
737
|
+
mergedEnvironments[envName] = {
|
|
738
|
+
...mergedEnvironments[envName],
|
|
739
|
+
// Validate field types before merging — do not blindly spread user input
|
|
740
|
+
...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
|
|
798
741
|
};
|
|
799
742
|
}
|
|
800
743
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
744
|
+
};
|
|
745
|
+
applyLayer(globalConfig);
|
|
746
|
+
applyLayer(projectConfig);
|
|
747
|
+
const shieldOverrides = readShieldOverrides();
|
|
748
|
+
for (const shieldName of readActiveShields()) {
|
|
749
|
+
const shield = getShield(shieldName);
|
|
750
|
+
if (!shield) continue;
|
|
751
|
+
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
752
|
+
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
753
|
+
for (const rule of shield.smartRules) {
|
|
754
|
+
if (!existingRuleNames.has(rule.name)) {
|
|
755
|
+
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
756
|
+
mergedPolicy.smartRules.push(
|
|
757
|
+
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
758
|
+
);
|
|
810
759
|
}
|
|
811
760
|
}
|
|
761
|
+
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
762
|
+
for (const word of shield.dangerousWords) {
|
|
763
|
+
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
764
|
+
}
|
|
812
765
|
}
|
|
813
|
-
|
|
766
|
+
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
767
|
+
for (const rule of ADVISORY_SMART_RULES) {
|
|
768
|
+
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
769
|
+
}
|
|
770
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
771
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
772
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
773
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
774
|
+
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
775
|
+
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
776
|
+
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
777
|
+
const result = {
|
|
778
|
+
settings: mergedSettings,
|
|
779
|
+
policy: mergedPolicy,
|
|
780
|
+
environments: mergedEnvironments
|
|
781
|
+
};
|
|
782
|
+
if (!cwd) cachedConfig = result;
|
|
783
|
+
return result;
|
|
814
784
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
var TRUST_FILE = path5.join(os2.homedir(), ".node9", "trust.json");
|
|
819
|
-
var LOCAL_AUDIT_LOG = path5.join(os2.homedir(), ".node9", "audit.log");
|
|
820
|
-
var HOOK_DEBUG_LOG = path5.join(os2.homedir(), ".node9", "hook-debug.log");
|
|
821
|
-
function checkPause() {
|
|
785
|
+
function tryLoadConfig(filePath) {
|
|
786
|
+
if (!fs3.existsSync(filePath)) return null;
|
|
787
|
+
let raw;
|
|
822
788
|
try {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
789
|
+
raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
790
|
+
} catch (err) {
|
|
791
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
792
|
+
process.stderr.write(
|
|
793
|
+
`
|
|
794
|
+
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
795
|
+
${msg}
|
|
796
|
+
\u2192 Using default config
|
|
797
|
+
|
|
798
|
+
`
|
|
799
|
+
);
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
const SUPPORTED_VERSION = "1.0";
|
|
803
|
+
const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
|
|
804
|
+
const fileVersion = raw?.version;
|
|
805
|
+
if (fileVersion !== void 0) {
|
|
806
|
+
const vStr = String(fileVersion);
|
|
807
|
+
const fileMajor = vStr.split(".")[0];
|
|
808
|
+
if (fileMajor !== SUPPORTED_MAJOR) {
|
|
809
|
+
process.stderr.write(
|
|
810
|
+
`
|
|
811
|
+
\u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
|
|
812
|
+
|
|
813
|
+
`
|
|
814
|
+
);
|
|
815
|
+
return null;
|
|
816
|
+
} else if (vStr !== SUPPORTED_VERSION) {
|
|
817
|
+
process.stderr.write(
|
|
818
|
+
`
|
|
819
|
+
\u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
|
|
820
|
+
|
|
821
|
+
`
|
|
822
|
+
);
|
|
831
823
|
}
|
|
832
|
-
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
833
|
-
} catch {
|
|
834
|
-
return { paused: false };
|
|
835
824
|
}
|
|
825
|
+
const { sanitized, error } = sanitizeConfig(raw);
|
|
826
|
+
if (error) {
|
|
827
|
+
process.stderr.write(
|
|
828
|
+
`
|
|
829
|
+
\u26A0\uFE0F Node9: Invalid config at ${filePath}:
|
|
830
|
+
${error.replace("Invalid config:\n", "")}
|
|
831
|
+
\u2192 Invalid fields ignored, using defaults for those keys
|
|
832
|
+
|
|
833
|
+
`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return sanitized;
|
|
836
837
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const tmpPath = `${filePath}.${os2.hostname()}.${process.pid}.tmp`;
|
|
841
|
-
fs3.writeFileSync(tmpPath, data, options);
|
|
842
|
-
fs3.renameSync(tmpPath, filePath);
|
|
843
|
-
}
|
|
838
|
+
|
|
839
|
+
// src/utils/regex.ts
|
|
840
|
+
import safeRegex from "safe-regex2";
|
|
844
841
|
var MAX_REGEX_LENGTH = 100;
|
|
845
842
|
var REGEX_CACHE_MAX = 500;
|
|
846
843
|
var regexCache = /* @__PURE__ */ new Map();
|
|
@@ -887,88 +884,170 @@ function getCompiledRegex(pattern, flags = "") {
|
|
|
887
884
|
return null;
|
|
888
885
|
}
|
|
889
886
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
904
|
-
|
|
887
|
+
|
|
888
|
+
// src/policy/index.ts
|
|
889
|
+
import pm from "picomatch";
|
|
890
|
+
import { parse } from "sh-syntax";
|
|
891
|
+
|
|
892
|
+
// src/dlp.ts
|
|
893
|
+
import fs4 from "fs";
|
|
894
|
+
import path4 from "path";
|
|
895
|
+
var DLP_PATTERNS = [
|
|
896
|
+
{ name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
|
|
897
|
+
{ name: "GitHub Token", regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/, severity: "block" },
|
|
898
|
+
// Slack bot tokens: xoxb- + variable segment. Real tokens are ~50–80 chars;
|
|
899
|
+
// lower bound 20 avoids false negatives on partial tokens, upper 100 caps scan cost.
|
|
900
|
+
{ name: "Slack Bot Token", regex: /\bxoxb-[0-9A-Za-z-]{20,100}\b/, severity: "block" },
|
|
901
|
+
{ name: "OpenAI API Key", regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/, severity: "block" },
|
|
902
|
+
{ name: "Stripe Secret Key", regex: /\bsk_(?:live|test)_[0-9a-zA-Z]{24}\b/, severity: "block" },
|
|
903
|
+
{
|
|
904
|
+
name: "Private Key (PEM)",
|
|
905
|
+
regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
906
|
+
severity: "block"
|
|
907
|
+
},
|
|
908
|
+
// GCP service account JSON (detects the type field that uniquely identifies it)
|
|
909
|
+
{
|
|
910
|
+
name: "GCP Service Account",
|
|
911
|
+
regex: /"type"\s*:\s*"service_account"/,
|
|
912
|
+
severity: "block"
|
|
913
|
+
},
|
|
914
|
+
// NPM auth token in .npmrc format
|
|
915
|
+
{
|
|
916
|
+
name: "NPM Auth Token",
|
|
917
|
+
regex: /_authToken\s*=\s*[A-Za-z0-9_\-]{20,}/,
|
|
918
|
+
severity: "block"
|
|
919
|
+
},
|
|
920
|
+
{ name: "Bearer Token", regex: /Bearer\s+[a-zA-Z0-9\-._~+/]+=*/i, severity: "review" }
|
|
921
|
+
];
|
|
922
|
+
var SENSITIVE_PATH_PATTERNS = [
|
|
923
|
+
/[/\\]\.ssh[/\\]/i,
|
|
924
|
+
/[/\\]\.aws[/\\]/i,
|
|
925
|
+
/[/\\]\.config[/\\]gcloud[/\\]/i,
|
|
926
|
+
/[/\\]\.azure[/\\]/i,
|
|
927
|
+
/[/\\]\.kube[/\\]config$/i,
|
|
928
|
+
/[/\\]\.env($|\.)/i,
|
|
929
|
+
// .env, .env.local, .env.production — not .envoy
|
|
930
|
+
/[/\\]\.git-credentials$/i,
|
|
931
|
+
/[/\\]\.npmrc$/i,
|
|
932
|
+
/[/\\]\.docker[/\\]config\.json$/i,
|
|
933
|
+
/[/\\][^/\\]+\.pem$/i,
|
|
934
|
+
/[/\\][^/\\]+\.key$/i,
|
|
935
|
+
/[/\\][^/\\]+\.p12$/i,
|
|
936
|
+
/[/\\][^/\\]+\.pfx$/i,
|
|
937
|
+
/^(?:[a-zA-Z]:)?\/etc\/passwd$/,
|
|
938
|
+
/^(?:[a-zA-Z]:)?\/etc\/shadow$/,
|
|
939
|
+
/^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
|
|
940
|
+
/[/\\]credentials\.json$/i,
|
|
941
|
+
/[/\\]id_rsa$/i,
|
|
942
|
+
/[/\\]id_ed25519$/i,
|
|
943
|
+
/[/\\]id_ecdsa$/i
|
|
944
|
+
];
|
|
945
|
+
function scanFilePath(filePath, cwd = process.cwd()) {
|
|
946
|
+
if (!filePath) return null;
|
|
947
|
+
let resolved;
|
|
905
948
|
try {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
if (fs3.existsSync(TRUST_FILE)) {
|
|
909
|
-
trust = JSON.parse(fs3.readFileSync(TRUST_FILE, "utf-8"));
|
|
910
|
-
}
|
|
911
|
-
} catch {
|
|
912
|
-
}
|
|
913
|
-
const now = Date.now();
|
|
914
|
-
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
915
|
-
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
916
|
-
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
949
|
+
const absolute = path4.resolve(cwd, filePath);
|
|
950
|
+
resolved = fs4.realpathSync.native(absolute);
|
|
917
951
|
} catch (err) {
|
|
918
|
-
|
|
919
|
-
|
|
952
|
+
const code = err.code;
|
|
953
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
954
|
+
resolved = path4.resolve(cwd, filePath);
|
|
955
|
+
} else {
|
|
956
|
+
return {
|
|
957
|
+
patternName: "Sensitive File Path",
|
|
958
|
+
fieldPath: "file_path",
|
|
959
|
+
redactedSample: filePath,
|
|
960
|
+
severity: "block"
|
|
961
|
+
};
|
|
920
962
|
}
|
|
921
963
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
964
|
+
const normalised = resolved.replace(/\\/g, "/");
|
|
965
|
+
for (const pattern of SENSITIVE_PATH_PATTERNS) {
|
|
966
|
+
if (pattern.test(normalised)) {
|
|
967
|
+
return {
|
|
968
|
+
patternName: "Sensitive File Path",
|
|
969
|
+
fieldPath: "file_path",
|
|
970
|
+
redactedSample: filePath,
|
|
971
|
+
// show original path in alert, not resolved
|
|
972
|
+
severity: "block"
|
|
973
|
+
};
|
|
974
|
+
}
|
|
929
975
|
}
|
|
976
|
+
return null;
|
|
930
977
|
}
|
|
931
|
-
function
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
cwd: process.cwd()
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
|
-
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
944
|
-
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
945
|
-
appendToLog(LOCAL_AUDIT_LOG, {
|
|
946
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
947
|
-
tool: toolName,
|
|
948
|
-
args: safeArgs,
|
|
949
|
-
decision,
|
|
950
|
-
checkedBy,
|
|
951
|
-
agent: meta?.agent,
|
|
952
|
-
mcpServer: meta?.mcpServer,
|
|
953
|
-
hostname: os2.hostname()
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
function tokenize(toolName) {
|
|
957
|
-
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
978
|
+
function maskSecret(raw, pattern) {
|
|
979
|
+
const match = raw.match(pattern);
|
|
980
|
+
if (!match) return "****";
|
|
981
|
+
const secret = match[0];
|
|
982
|
+
if (secret.length < 8) return "****";
|
|
983
|
+
const prefix = secret.slice(0, 4);
|
|
984
|
+
const suffix = secret.slice(-4);
|
|
985
|
+
const stars = "*".repeat(Math.min(secret.length - 8, 12));
|
|
986
|
+
return `${prefix}${stars}${suffix}`;
|
|
958
987
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
988
|
+
var MAX_DEPTH = 5;
|
|
989
|
+
var MAX_STRING_BYTES = 1e5;
|
|
990
|
+
var MAX_JSON_PARSE_BYTES = 1e4;
|
|
991
|
+
function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
992
|
+
if (depth > MAX_DEPTH || args === null || args === void 0) return null;
|
|
993
|
+
if (Array.isArray(args)) {
|
|
994
|
+
for (let i = 0; i < args.length; i++) {
|
|
995
|
+
const match = scanArgs(args[i], depth + 1, `${fieldPath}[${i}]`);
|
|
996
|
+
if (match) return match;
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
if (typeof args === "object") {
|
|
1001
|
+
for (const [key, value] of Object.entries(args)) {
|
|
1002
|
+
const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
|
|
1003
|
+
if (match) return match;
|
|
1004
|
+
}
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
if (typeof args === "string") {
|
|
1008
|
+
const text = args.length > MAX_STRING_BYTES ? args.slice(0, MAX_STRING_BYTES) : args;
|
|
1009
|
+
for (const pattern of DLP_PATTERNS) {
|
|
1010
|
+
if (pattern.regex.test(text)) {
|
|
1011
|
+
return {
|
|
1012
|
+
patternName: pattern.name,
|
|
1013
|
+
fieldPath,
|
|
1014
|
+
redactedSample: maskSecret(text, pattern.regex),
|
|
1015
|
+
severity: pattern.severity
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
1020
|
+
const trimmed = text.trim();
|
|
1021
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1022
|
+
try {
|
|
1023
|
+
const parsed = JSON.parse(text);
|
|
1024
|
+
const inner = scanArgs(parsed, depth + 1, fieldPath);
|
|
1025
|
+
if (inner) return inner;
|
|
1026
|
+
} catch {
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// src/policy/index.ts
|
|
1035
|
+
function tokenize(toolName) {
|
|
1036
|
+
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
1037
|
+
}
|
|
1038
|
+
function matchesPattern(text, patterns) {
|
|
1039
|
+
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
1040
|
+
if (p.length === 0) return false;
|
|
1041
|
+
const isMatch = pm(p, { nocase: true, dot: true });
|
|
963
1042
|
const target = text.toLowerCase();
|
|
964
1043
|
const directMatch = isMatch(target);
|
|
965
1044
|
if (directMatch) return true;
|
|
966
1045
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
967
1046
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
968
1047
|
}
|
|
969
|
-
function getNestedValue(obj,
|
|
1048
|
+
function getNestedValue(obj, path10) {
|
|
970
1049
|
if (!obj || typeof obj !== "object") return null;
|
|
971
|
-
return
|
|
1050
|
+
return path10.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
972
1051
|
}
|
|
973
1052
|
function evaluateSmartConditions(args, rule) {
|
|
974
1053
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -1092,494 +1171,727 @@ async function analyzeShellCommand(command) {
|
|
|
1092
1171
|
}
|
|
1093
1172
|
return { actions, paths, allTokens };
|
|
1094
1173
|
}
|
|
1095
|
-
function
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1174
|
+
async function evaluatePolicy(toolName, args, agent) {
|
|
1175
|
+
const config = getConfig();
|
|
1176
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
|
|
1177
|
+
if (config.policy.smartRules.length > 0) {
|
|
1178
|
+
const matchedRule = config.policy.smartRules.find(
|
|
1179
|
+
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1180
|
+
);
|
|
1181
|
+
if (matchedRule) {
|
|
1182
|
+
if (matchedRule.verdict === "allow")
|
|
1183
|
+
return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
1184
|
+
return {
|
|
1185
|
+
decision: matchedRule.verdict,
|
|
1186
|
+
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
1187
|
+
reason: matchedRule.reason,
|
|
1188
|
+
tier: 2,
|
|
1189
|
+
ruleName: matchedRule.name ?? matchedRule.tool
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
let allTokens = [];
|
|
1194
|
+
let pathTokens = [];
|
|
1195
|
+
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
1196
|
+
if (shellCommand) {
|
|
1197
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
1198
|
+
allTokens = analyzed.allTokens;
|
|
1199
|
+
pathTokens = analyzed.paths;
|
|
1200
|
+
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
1201
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
1202
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
|
|
1203
|
+
}
|
|
1204
|
+
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
1205
|
+
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
1206
|
+
}
|
|
1207
|
+
} else {
|
|
1208
|
+
allTokens = tokenize(toolName);
|
|
1209
|
+
if (args && typeof args === "object") {
|
|
1210
|
+
const flattenedArgs = JSON.stringify(args).toLowerCase();
|
|
1211
|
+
const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
|
|
1212
|
+
allTokens.push(...extraTokens);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const isManual = agent === "Terminal";
|
|
1216
|
+
if (isManual) {
|
|
1217
|
+
const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
|
|
1218
|
+
const hasSystemDisaster = allTokens.some(
|
|
1219
|
+
(t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
|
|
1220
|
+
);
|
|
1221
|
+
const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
|
|
1222
|
+
if (hasSystemDisaster || isRootWipe) {
|
|
1223
|
+
return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
|
|
1224
|
+
}
|
|
1225
|
+
return { decision: "allow" };
|
|
1226
|
+
}
|
|
1227
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
1228
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
1229
|
+
if (allInSandbox) return { decision: "allow" };
|
|
1230
|
+
}
|
|
1231
|
+
let matchedDangerousWord;
|
|
1232
|
+
const isDangerous = allTokens.some(
|
|
1233
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
1234
|
+
const w = word.toLowerCase();
|
|
1235
|
+
const hit = token === w || (() => {
|
|
1236
|
+
try {
|
|
1237
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
1238
|
+
} catch {
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
})();
|
|
1242
|
+
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1243
|
+
return hit;
|
|
1244
|
+
})
|
|
1105
1245
|
);
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
enableHookLogDebug: true,
|
|
1122
|
-
approvalTimeoutMs: 3e4,
|
|
1123
|
-
// 30-second auto-deny timeout
|
|
1124
|
-
flightRecorder: true,
|
|
1125
|
-
approvers: { native: true, browser: true, cloud: false, terminal: true }
|
|
1126
|
-
},
|
|
1127
|
-
policy: {
|
|
1128
|
-
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
1129
|
-
dangerousWords: DANGEROUS_WORDS,
|
|
1130
|
-
ignoredTools: [
|
|
1131
|
-
"list_*",
|
|
1132
|
-
"get_*",
|
|
1133
|
-
"read_*",
|
|
1134
|
-
"describe_*",
|
|
1135
|
-
"read",
|
|
1136
|
-
"glob",
|
|
1137
|
-
"grep",
|
|
1138
|
-
"ls",
|
|
1139
|
-
"notebookread",
|
|
1140
|
-
"notebookedit",
|
|
1141
|
-
"webfetch",
|
|
1142
|
-
"websearch",
|
|
1143
|
-
"exitplanmode",
|
|
1144
|
-
"askuserquestion",
|
|
1145
|
-
"agent",
|
|
1146
|
-
"task*",
|
|
1147
|
-
"toolsearch",
|
|
1148
|
-
"mcp__ide__*",
|
|
1149
|
-
"getDiagnostics"
|
|
1150
|
-
],
|
|
1151
|
-
toolInspection: {
|
|
1152
|
-
bash: "command",
|
|
1153
|
-
shell: "command",
|
|
1154
|
-
run_shell_command: "command",
|
|
1155
|
-
"terminal.execute": "command",
|
|
1156
|
-
"postgres:query": "sql"
|
|
1157
|
-
},
|
|
1158
|
-
snapshot: {
|
|
1159
|
-
tools: [
|
|
1160
|
-
"str_replace_based_edit_tool",
|
|
1161
|
-
"write_file",
|
|
1162
|
-
"edit_file",
|
|
1163
|
-
"create_file",
|
|
1164
|
-
"edit",
|
|
1165
|
-
"replace"
|
|
1166
|
-
],
|
|
1167
|
-
onlyPaths: [],
|
|
1168
|
-
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
1169
|
-
},
|
|
1170
|
-
smartRules: [
|
|
1171
|
-
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
1172
|
-
{
|
|
1173
|
-
name: "block-rm-rf-home",
|
|
1174
|
-
tool: "bash",
|
|
1175
|
-
conditionMode: "all",
|
|
1176
|
-
conditions: [
|
|
1177
|
-
{
|
|
1178
|
-
field: "command",
|
|
1179
|
-
op: "matches",
|
|
1180
|
-
value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
|
|
1181
|
-
},
|
|
1182
|
-
{
|
|
1183
|
-
field: "command",
|
|
1184
|
-
op: "matches",
|
|
1185
|
-
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
1186
|
-
}
|
|
1187
|
-
],
|
|
1188
|
-
verdict: "block",
|
|
1189
|
-
reason: "Recursive delete of home directory is irreversible"
|
|
1190
|
-
},
|
|
1191
|
-
// ── SQL safety ────────────────────────────────────────────────────────
|
|
1192
|
-
{
|
|
1193
|
-
name: "no-delete-without-where",
|
|
1194
|
-
tool: "*",
|
|
1195
|
-
conditions: [
|
|
1196
|
-
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
1197
|
-
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
1198
|
-
],
|
|
1199
|
-
conditionMode: "all",
|
|
1200
|
-
verdict: "review",
|
|
1201
|
-
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
1202
|
-
},
|
|
1203
|
-
{
|
|
1204
|
-
name: "review-drop-truncate-shell",
|
|
1205
|
-
tool: "bash",
|
|
1206
|
-
conditions: [
|
|
1207
|
-
{
|
|
1208
|
-
field: "command",
|
|
1209
|
-
op: "matches",
|
|
1210
|
-
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
1211
|
-
flags: "i"
|
|
1246
|
+
if (isDangerous) {
|
|
1247
|
+
let matchedField;
|
|
1248
|
+
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1249
|
+
const obj = args;
|
|
1250
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1251
|
+
if (typeof value === "string") {
|
|
1252
|
+
try {
|
|
1253
|
+
if (new RegExp(
|
|
1254
|
+
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1255
|
+
"i"
|
|
1256
|
+
).test(value)) {
|
|
1257
|
+
matchedField = key;
|
|
1258
|
+
break;
|
|
1259
|
+
}
|
|
1260
|
+
} catch {
|
|
1212
1261
|
}
|
|
1213
|
-
|
|
1214
|
-
conditionMode: "all",
|
|
1215
|
-
verdict: "review",
|
|
1216
|
-
reason: "SQL DDL destructive statement inside a shell command"
|
|
1217
|
-
},
|
|
1218
|
-
// ── Git safety ────────────────────────────────────────────────────────
|
|
1219
|
-
{
|
|
1220
|
-
name: "block-force-push",
|
|
1221
|
-
tool: "bash",
|
|
1222
|
-
conditions: [
|
|
1223
|
-
{
|
|
1224
|
-
field: "command",
|
|
1225
|
-
op: "matches",
|
|
1226
|
-
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
1227
|
-
flags: "i"
|
|
1228
|
-
}
|
|
1229
|
-
],
|
|
1230
|
-
conditionMode: "all",
|
|
1231
|
-
verdict: "block",
|
|
1232
|
-
reason: "Force push overwrites remote history and cannot be undone"
|
|
1233
|
-
},
|
|
1234
|
-
{
|
|
1235
|
-
name: "review-git-push",
|
|
1236
|
-
tool: "bash",
|
|
1237
|
-
conditions: [
|
|
1238
|
-
{
|
|
1239
|
-
field: "command",
|
|
1240
|
-
op: "matches",
|
|
1241
|
-
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
1242
|
-
flags: "i"
|
|
1243
|
-
}
|
|
1244
|
-
],
|
|
1245
|
-
conditionMode: "all",
|
|
1246
|
-
verdict: "review",
|
|
1247
|
-
reason: "git push sends changes to a shared remote"
|
|
1248
|
-
},
|
|
1249
|
-
{
|
|
1250
|
-
name: "review-git-destructive",
|
|
1251
|
-
tool: "bash",
|
|
1252
|
-
conditions: [
|
|
1253
|
-
{
|
|
1254
|
-
field: "command",
|
|
1255
|
-
op: "matches",
|
|
1256
|
-
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
1257
|
-
flags: "i"
|
|
1258
|
-
}
|
|
1259
|
-
],
|
|
1260
|
-
conditionMode: "all",
|
|
1261
|
-
verdict: "review",
|
|
1262
|
-
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
1263
|
-
},
|
|
1264
|
-
// ── Shell safety ──────────────────────────────────────────────────────
|
|
1265
|
-
{
|
|
1266
|
-
name: "review-sudo",
|
|
1267
|
-
tool: "bash",
|
|
1268
|
-
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
1269
|
-
conditionMode: "all",
|
|
1270
|
-
verdict: "review",
|
|
1271
|
-
reason: "Command requires elevated privileges"
|
|
1272
|
-
},
|
|
1273
|
-
{
|
|
1274
|
-
name: "review-curl-pipe-shell",
|
|
1275
|
-
tool: "bash",
|
|
1276
|
-
conditions: [
|
|
1277
|
-
{
|
|
1278
|
-
field: "command",
|
|
1279
|
-
op: "matches",
|
|
1280
|
-
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
1281
|
-
flags: "i"
|
|
1282
|
-
}
|
|
1283
|
-
],
|
|
1284
|
-
conditionMode: "all",
|
|
1285
|
-
verdict: "block",
|
|
1286
|
-
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
1262
|
+
}
|
|
1287
1263
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
decision: "review",
|
|
1267
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1268
|
+
matchedWord: matchedDangerousWord,
|
|
1269
|
+
matchedField,
|
|
1270
|
+
tier: 6
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
if (config.settings.mode === "strict") {
|
|
1274
|
+
const envConfig = getActiveEnvironment(config);
|
|
1275
|
+
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
1276
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1277
|
+
}
|
|
1278
|
+
return { decision: "allow" };
|
|
1279
|
+
}
|
|
1280
|
+
function isIgnoredTool(toolName) {
|
|
1281
|
+
const config = getConfig();
|
|
1282
|
+
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/auth/state.ts
|
|
1286
|
+
import fs5 from "fs";
|
|
1287
|
+
import path5 from "path";
|
|
1288
|
+
import os4 from "os";
|
|
1289
|
+
var PAUSED_FILE = path5.join(os4.homedir(), ".node9", "PAUSED");
|
|
1290
|
+
var TRUST_FILE = path5.join(os4.homedir(), ".node9", "trust.json");
|
|
1291
|
+
function checkPause() {
|
|
1292
|
+
try {
|
|
1293
|
+
if (!fs5.existsSync(PAUSED_FILE)) return { paused: false };
|
|
1294
|
+
const state = JSON.parse(fs5.readFileSync(PAUSED_FILE, "utf-8"));
|
|
1295
|
+
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
1296
|
+
try {
|
|
1297
|
+
fs5.unlinkSync(PAUSED_FILE);
|
|
1298
|
+
} catch {
|
|
1309
1299
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1300
|
+
return { paused: false };
|
|
1301
|
+
}
|
|
1302
|
+
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
1303
|
+
} catch {
|
|
1304
|
+
return { paused: false };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
function atomicWriteSync(filePath, data, options) {
|
|
1308
|
+
const dir = path5.dirname(filePath);
|
|
1309
|
+
if (!fs5.existsSync(dir)) fs5.mkdirSync(dir, { recursive: true });
|
|
1310
|
+
const tmpPath = `${filePath}.${os4.hostname()}.${process.pid}.tmp`;
|
|
1311
|
+
fs5.writeFileSync(tmpPath, data, options);
|
|
1312
|
+
fs5.renameSync(tmpPath, filePath);
|
|
1313
|
+
}
|
|
1314
|
+
function getActiveTrustSession(toolName) {
|
|
1315
|
+
try {
|
|
1316
|
+
if (!fs5.existsSync(TRUST_FILE)) return false;
|
|
1317
|
+
const trust = JSON.parse(fs5.readFileSync(TRUST_FILE, "utf-8"));
|
|
1318
|
+
const now = Date.now();
|
|
1319
|
+
const active = trust.entries.filter((e) => e.expiry > now);
|
|
1320
|
+
if (active.length !== trust.entries.length) {
|
|
1321
|
+
fs5.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
1322
|
+
}
|
|
1323
|
+
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
1324
|
+
} catch {
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function writeTrustSession(toolName, durationMs) {
|
|
1329
|
+
try {
|
|
1330
|
+
let trust = { entries: [] };
|
|
1331
|
+
try {
|
|
1332
|
+
if (fs5.existsSync(TRUST_FILE)) {
|
|
1333
|
+
trust = JSON.parse(fs5.readFileSync(TRUST_FILE, "utf-8"));
|
|
1334
|
+
}
|
|
1335
|
+
} catch {
|
|
1336
|
+
}
|
|
1337
|
+
const now = Date.now();
|
|
1338
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
1339
|
+
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
1340
|
+
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
1343
|
+
console.error("[Node9 Trust Error]:", err);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
function getPersistentDecision(toolName) {
|
|
1348
|
+
try {
|
|
1349
|
+
const file = path5.join(os4.homedir(), ".node9", "decisions.json");
|
|
1350
|
+
if (!fs5.existsSync(file)) return null;
|
|
1351
|
+
const decisions = JSON.parse(fs5.readFileSync(file, "utf-8"));
|
|
1352
|
+
const d = decisions[toolName];
|
|
1353
|
+
if (d === "allow" || d === "deny") return d;
|
|
1354
|
+
} catch {
|
|
1355
|
+
}
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// src/auth/daemon.ts
|
|
1360
|
+
import fs6 from "fs";
|
|
1361
|
+
import path6 from "path";
|
|
1362
|
+
import os5 from "os";
|
|
1363
|
+
import { spawnSync } from "child_process";
|
|
1364
|
+
var DAEMON_PORT = 7391;
|
|
1365
|
+
var DAEMON_HOST = "127.0.0.1";
|
|
1366
|
+
function getInternalToken() {
|
|
1367
|
+
try {
|
|
1368
|
+
const pidFile = path6.join(os5.homedir(), ".node9", "daemon.pid");
|
|
1369
|
+
if (!fs6.existsSync(pidFile)) return null;
|
|
1370
|
+
const data = JSON.parse(fs6.readFileSync(pidFile, "utf-8"));
|
|
1371
|
+
process.kill(data.pid, 0);
|
|
1372
|
+
return data.internalToken ?? null;
|
|
1373
|
+
} catch {
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function isDaemonRunning() {
|
|
1378
|
+
const pidFile = path6.join(os5.homedir(), ".node9", "daemon.pid");
|
|
1379
|
+
if (fs6.existsSync(pidFile)) {
|
|
1380
|
+
try {
|
|
1381
|
+
const { pid, port } = JSON.parse(fs6.readFileSync(pidFile, "utf-8"));
|
|
1382
|
+
if (port !== DAEMON_PORT) return false;
|
|
1383
|
+
process.kill(pid, 0);
|
|
1384
|
+
return true;
|
|
1385
|
+
} catch {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const r = spawnSync("ss", ["-Htnp", `sport = :${DAEMON_PORT}`], {
|
|
1391
|
+
encoding: "utf8",
|
|
1392
|
+
timeout: 500
|
|
1393
|
+
});
|
|
1394
|
+
return r.status === 0 && (r.stdout ?? "").includes(`:${DAEMON_PORT}`);
|
|
1395
|
+
} catch {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
|
|
1400
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1401
|
+
const ctrl = new AbortController();
|
|
1402
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await fetch(`${base}/check`, {
|
|
1405
|
+
method: "POST",
|
|
1406
|
+
headers: { "Content-Type": "application/json" },
|
|
1407
|
+
body: JSON.stringify({
|
|
1408
|
+
toolName,
|
|
1409
|
+
args,
|
|
1410
|
+
agent: meta?.agent,
|
|
1411
|
+
mcpServer: meta?.mcpServer,
|
|
1412
|
+
fromCLI: true,
|
|
1413
|
+
// Pass the flight-recorder ID so the daemon uses the same UUID for
|
|
1414
|
+
// activity-result as the CLI used for the pending activity event.
|
|
1415
|
+
activityId,
|
|
1416
|
+
...riskMetadata && { riskMetadata },
|
|
1417
|
+
...cwd && { cwd }
|
|
1418
|
+
}),
|
|
1419
|
+
signal: ctrl.signal
|
|
1420
|
+
});
|
|
1421
|
+
if (!res.ok) throw new Error("Daemon fail");
|
|
1422
|
+
const { id } = await res.json();
|
|
1423
|
+
return id;
|
|
1424
|
+
} finally {
|
|
1425
|
+
clearTimeout(timer);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async function waitForDaemonDecision(id, signal) {
|
|
1429
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1430
|
+
const waitCtrl = new AbortController();
|
|
1431
|
+
const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
|
|
1432
|
+
const onAbort = () => waitCtrl.abort();
|
|
1433
|
+
if (signal) signal.addEventListener("abort", onAbort);
|
|
1434
|
+
try {
|
|
1435
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
1436
|
+
if (!waitRes.ok) return { decision: "deny" };
|
|
1437
|
+
const { decision, source } = await waitRes.json();
|
|
1438
|
+
if (decision === "allow") return { decision: "allow", source };
|
|
1439
|
+
if (decision === "abandoned") return { decision: "abandoned", source };
|
|
1440
|
+
return { decision: "deny", source };
|
|
1441
|
+
} finally {
|
|
1442
|
+
clearTimeout(waitTimer);
|
|
1443
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
|
|
1447
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1448
|
+
const res = await fetch(`${base}/check`, {
|
|
1449
|
+
method: "POST",
|
|
1450
|
+
headers: { "Content-Type": "application/json" },
|
|
1451
|
+
body: JSON.stringify({
|
|
1452
|
+
toolName,
|
|
1453
|
+
args,
|
|
1454
|
+
slackDelegated: true,
|
|
1455
|
+
agent: meta?.agent,
|
|
1456
|
+
mcpServer: meta?.mcpServer,
|
|
1457
|
+
...riskMetadata && { riskMetadata }
|
|
1458
|
+
}),
|
|
1459
|
+
signal: AbortSignal.timeout(3e3)
|
|
1460
|
+
});
|
|
1461
|
+
if (!res.ok) throw new Error("Daemon unreachable");
|
|
1462
|
+
const { id } = await res.json();
|
|
1463
|
+
return id;
|
|
1464
|
+
}
|
|
1465
|
+
async function resolveViaDaemon(id, decision, internalToken) {
|
|
1466
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1467
|
+
await fetch(`${base}/resolve/${id}`, {
|
|
1468
|
+
method: "POST",
|
|
1469
|
+
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
1470
|
+
body: JSON.stringify({ decision }),
|
|
1471
|
+
signal: AbortSignal.timeout(3e3)
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// src/auth/orchestrator.ts
|
|
1476
|
+
import net from "net";
|
|
1477
|
+
import path9 from "path";
|
|
1478
|
+
import os7 from "os";
|
|
1479
|
+
import { randomUUID } from "crypto";
|
|
1480
|
+
|
|
1481
|
+
// src/ui/native.ts
|
|
1482
|
+
import { spawn } from "child_process";
|
|
1483
|
+
import path8 from "path";
|
|
1484
|
+
|
|
1485
|
+
// src/context-sniper.ts
|
|
1486
|
+
import path7 from "path";
|
|
1487
|
+
function smartTruncate(str, maxLen = 500) {
|
|
1488
|
+
if (str.length <= maxLen) return str;
|
|
1489
|
+
const edge = Math.floor(maxLen / 2) - 3;
|
|
1490
|
+
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
|
|
1491
|
+
}
|
|
1492
|
+
function extractContext(text, matchedWord) {
|
|
1493
|
+
const lines = text.split("\n");
|
|
1494
|
+
if (lines.length <= 7 || !matchedWord) {
|
|
1495
|
+
return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
1348
1496
|
}
|
|
1497
|
+
const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1498
|
+
const pattern = new RegExp(`\\b${escaped}\\b`, "i");
|
|
1499
|
+
const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
|
|
1500
|
+
if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
1501
|
+
const nonComment = allHits.find(({ line }) => {
|
|
1502
|
+
const trimmed = line.trim();
|
|
1503
|
+
return !trimmed.startsWith("//") && !trimmed.startsWith("#");
|
|
1504
|
+
});
|
|
1505
|
+
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
1506
|
+
const start = Math.max(0, hitIndex - 3);
|
|
1507
|
+
const end = Math.min(lines.length, hitIndex + 4);
|
|
1508
|
+
const lineIndex = hitIndex - start;
|
|
1509
|
+
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
1510
|
+
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
1511
|
+
` : "";
|
|
1512
|
+
const tail = end < lines.length ? `
|
|
1513
|
+
... [${lines.length - end} lines hidden] ...` : "";
|
|
1514
|
+
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
1515
|
+
}
|
|
1516
|
+
var CODE_KEYS = [
|
|
1517
|
+
"command",
|
|
1518
|
+
"cmd",
|
|
1519
|
+
"shell_command",
|
|
1520
|
+
"bash_command",
|
|
1521
|
+
"script",
|
|
1522
|
+
"code",
|
|
1523
|
+
"input",
|
|
1524
|
+
"sql",
|
|
1525
|
+
"query",
|
|
1526
|
+
"arguments",
|
|
1527
|
+
"args",
|
|
1528
|
+
"param",
|
|
1529
|
+
"params",
|
|
1530
|
+
"text"
|
|
1349
1531
|
];
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1532
|
+
function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
|
|
1533
|
+
let intent = "EXEC";
|
|
1534
|
+
let contextSnippet;
|
|
1535
|
+
let contextLineIndex;
|
|
1536
|
+
let editFileName;
|
|
1537
|
+
let editFilePath;
|
|
1538
|
+
let parsed = args;
|
|
1539
|
+
if (typeof args === "string") {
|
|
1540
|
+
const trimmed = args.trim();
|
|
1541
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
1542
|
+
try {
|
|
1543
|
+
parsed = JSON.parse(trimmed);
|
|
1544
|
+
} catch {
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1549
|
+
const obj = parsed;
|
|
1550
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
1551
|
+
intent = "EDIT";
|
|
1552
|
+
if (obj.file_path) {
|
|
1553
|
+
editFilePath = String(obj.file_path);
|
|
1554
|
+
editFileName = path7.basename(editFilePath);
|
|
1555
|
+
}
|
|
1556
|
+
const result = extractContext(String(obj.new_string), matchedWord);
|
|
1557
|
+
contextSnippet = result.snippet;
|
|
1558
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
1559
|
+
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
1560
|
+
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
1561
|
+
contextSnippet = result.snippet;
|
|
1562
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
1563
|
+
} else {
|
|
1564
|
+
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
1565
|
+
if (foundKey) {
|
|
1566
|
+
const val = obj[foundKey];
|
|
1567
|
+
contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
} else if (typeof parsed === "string") {
|
|
1571
|
+
contextSnippet = smartTruncate(parsed, 500);
|
|
1360
1572
|
}
|
|
1573
|
+
return {
|
|
1574
|
+
intent,
|
|
1575
|
+
tier,
|
|
1576
|
+
blockedByLabel,
|
|
1577
|
+
...matchedWord && { matchedWord },
|
|
1578
|
+
...matchedField && { matchedField },
|
|
1579
|
+
...contextSnippet !== void 0 && { contextSnippet },
|
|
1580
|
+
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
1581
|
+
...editFileName && { editFileName },
|
|
1582
|
+
...editFilePath && { editFilePath },
|
|
1583
|
+
...ruleName && { ruleName }
|
|
1584
|
+
};
|
|
1361
1585
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1586
|
+
|
|
1587
|
+
// src/ui/native.ts
|
|
1588
|
+
var isTestEnv = () => {
|
|
1589
|
+
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";
|
|
1590
|
+
};
|
|
1591
|
+
function formatArgs(args, matchedField, matchedWord) {
|
|
1592
|
+
if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
|
|
1593
|
+
let parsed = args;
|
|
1594
|
+
if (typeof args === "string") {
|
|
1595
|
+
const trimmed = args.trim();
|
|
1596
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
1597
|
+
try {
|
|
1598
|
+
parsed = JSON.parse(trimmed);
|
|
1599
|
+
} catch {
|
|
1600
|
+
parsed = args;
|
|
1601
|
+
}
|
|
1602
|
+
} else {
|
|
1603
|
+
return { message: smartTruncate(args, 600), intent: "EXEC" };
|
|
1379
1604
|
}
|
|
1380
1605
|
}
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1606
|
+
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1607
|
+
const obj = parsed;
|
|
1608
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
1609
|
+
const file = obj.file_path ? path8.basename(String(obj.file_path)) : "file";
|
|
1610
|
+
const oldPreview = smartTruncate(String(obj.old_string), 120);
|
|
1611
|
+
const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
|
|
1612
|
+
return {
|
|
1613
|
+
intent: "EDIT",
|
|
1614
|
+
message: `\u{1F4DD} EDITING: ${file}
|
|
1615
|
+
\u{1F4C2} PATH: ${obj.file_path}
|
|
1616
|
+
|
|
1617
|
+
--- REPLACING ---
|
|
1618
|
+
${oldPreview}
|
|
1619
|
+
|
|
1620
|
+
+++ NEW CODE +++
|
|
1621
|
+
${newPreview}`
|
|
1622
|
+
};
|
|
1391
1623
|
}
|
|
1392
|
-
if (
|
|
1393
|
-
|
|
1624
|
+
if (matchedField && obj[matchedField] !== void 0) {
|
|
1625
|
+
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
1626
|
+
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(", ")}
|
|
1627
|
+
|
|
1628
|
+
` : "";
|
|
1629
|
+
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
1630
|
+
return {
|
|
1631
|
+
intent: "EXEC",
|
|
1632
|
+
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
1633
|
+
${content}`
|
|
1634
|
+
};
|
|
1394
1635
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1636
|
+
const codeKeys = [
|
|
1637
|
+
"command",
|
|
1638
|
+
"cmd",
|
|
1639
|
+
"shell_command",
|
|
1640
|
+
"bash_command",
|
|
1641
|
+
"script",
|
|
1642
|
+
"code",
|
|
1643
|
+
"input",
|
|
1644
|
+
"sql",
|
|
1645
|
+
"query",
|
|
1646
|
+
"arguments",
|
|
1647
|
+
"args",
|
|
1648
|
+
"param",
|
|
1649
|
+
"params",
|
|
1650
|
+
"text"
|
|
1651
|
+
];
|
|
1652
|
+
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
1653
|
+
if (foundKey) {
|
|
1654
|
+
const val = obj[foundKey];
|
|
1655
|
+
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
1656
|
+
return {
|
|
1657
|
+
intent: "EXEC",
|
|
1658
|
+
message: `[${foundKey.toUpperCase()}]:
|
|
1659
|
+
${smartTruncate(str, 500)}`
|
|
1660
|
+
};
|
|
1401
1661
|
}
|
|
1662
|
+
const msg = Object.entries(obj).slice(0, 5).map(
|
|
1663
|
+
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
1664
|
+
).join("\n");
|
|
1665
|
+
return { intent: "EXEC", message: msg };
|
|
1666
|
+
}
|
|
1667
|
+
return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
|
|
1668
|
+
}
|
|
1669
|
+
function escapePango(text) {
|
|
1670
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1671
|
+
}
|
|
1672
|
+
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
1673
|
+
const lines = [];
|
|
1674
|
+
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
1675
|
+
lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
|
|
1676
|
+
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
1677
|
+
lines.push("");
|
|
1678
|
+
lines.push(formattedArgs);
|
|
1679
|
+
if (!locked) {
|
|
1680
|
+
lines.push("");
|
|
1681
|
+
lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
|
|
1402
1682
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
);
|
|
1409
|
-
|
|
1410
|
-
if (hasSystemDisaster || isRootWipe) {
|
|
1411
|
-
return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
|
|
1412
|
-
}
|
|
1413
|
-
return { decision: "allow" };
|
|
1683
|
+
return lines.join("\n");
|
|
1684
|
+
}
|
|
1685
|
+
function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
1686
|
+
const lines = [];
|
|
1687
|
+
if (locked) {
|
|
1688
|
+
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
1689
|
+
lines.push("");
|
|
1414
1690
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1691
|
+
lines.push(
|
|
1692
|
+
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
1693
|
+
);
|
|
1694
|
+
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
1695
|
+
lines.push("");
|
|
1696
|
+
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
1697
|
+
if (!locked) {
|
|
1698
|
+
lines.push("");
|
|
1699
|
+
lines.push(
|
|
1700
|
+
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
1701
|
+
);
|
|
1418
1702
|
}
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1703
|
+
return lines.join("\n");
|
|
1704
|
+
}
|
|
1705
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
|
|
1706
|
+
if (isTestEnv()) return "deny";
|
|
1707
|
+
const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
|
|
1708
|
+
const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
|
|
1709
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
|
|
1710
|
+
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
1711
|
+
return new Promise((resolve) => {
|
|
1712
|
+
let childProcess = null;
|
|
1713
|
+
const onAbort = () => {
|
|
1714
|
+
if (childProcess && childProcess.pid) {
|
|
1424
1715
|
try {
|
|
1425
|
-
|
|
1716
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
1426
1717
|
} catch {
|
|
1427
|
-
return false;
|
|
1428
|
-
}
|
|
1429
|
-
})();
|
|
1430
|
-
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1431
|
-
return hit;
|
|
1432
|
-
})
|
|
1433
|
-
);
|
|
1434
|
-
if (isDangerous) {
|
|
1435
|
-
let matchedField;
|
|
1436
|
-
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1437
|
-
const obj = args;
|
|
1438
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
1439
|
-
if (typeof value === "string") {
|
|
1440
|
-
try {
|
|
1441
|
-
if (new RegExp(
|
|
1442
|
-
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1443
|
-
"i"
|
|
1444
|
-
).test(value)) {
|
|
1445
|
-
matchedField = key;
|
|
1446
|
-
break;
|
|
1447
|
-
}
|
|
1448
|
-
} catch {
|
|
1449
|
-
}
|
|
1450
1718
|
}
|
|
1451
1719
|
}
|
|
1452
|
-
|
|
1453
|
-
return {
|
|
1454
|
-
decision: "review",
|
|
1455
|
-
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1456
|
-
matchedWord: matchedDangerousWord,
|
|
1457
|
-
matchedField,
|
|
1458
|
-
tier: 6
|
|
1720
|
+
resolve("deny");
|
|
1459
1721
|
};
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1465
|
-
}
|
|
1466
|
-
return { decision: "allow" };
|
|
1467
|
-
}
|
|
1468
|
-
function isIgnoredTool(toolName) {
|
|
1469
|
-
const config = getConfig();
|
|
1470
|
-
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1471
|
-
}
|
|
1472
|
-
var DAEMON_PORT = 7391;
|
|
1473
|
-
var DAEMON_HOST = "127.0.0.1";
|
|
1474
|
-
function isDaemonRunning() {
|
|
1475
|
-
const pidFile = path5.join(os2.homedir(), ".node9", "daemon.pid");
|
|
1476
|
-
if (fs3.existsSync(pidFile)) {
|
|
1722
|
+
if (signal) {
|
|
1723
|
+
if (signal.aborted) return resolve("deny");
|
|
1724
|
+
signal.addEventListener("abort", onAbort);
|
|
1725
|
+
}
|
|
1477
1726
|
try {
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1727
|
+
if (process.platform === "darwin") {
|
|
1728
|
+
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "Always Allow", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
|
|
1729
|
+
const script = `on run argv
|
|
1730
|
+
tell application "System Events"
|
|
1731
|
+
activate
|
|
1732
|
+
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
1733
|
+
end tell
|
|
1734
|
+
end run`;
|
|
1735
|
+
childProcess = spawn("osascript", ["-e", script, "--", message, title]);
|
|
1736
|
+
} else if (process.platform === "linux") {
|
|
1737
|
+
const pangoMessage = buildPangoMessage(
|
|
1738
|
+
toolName,
|
|
1739
|
+
formattedArgs,
|
|
1740
|
+
agent,
|
|
1741
|
+
explainableLabel,
|
|
1742
|
+
locked
|
|
1743
|
+
);
|
|
1744
|
+
const argsList = [
|
|
1745
|
+
locked ? "--info" : "--question",
|
|
1746
|
+
"--modal",
|
|
1747
|
+
"--width=480",
|
|
1748
|
+
"--title",
|
|
1749
|
+
title,
|
|
1750
|
+
"--text",
|
|
1751
|
+
pangoMessage,
|
|
1752
|
+
"--ok-label",
|
|
1753
|
+
locked ? "Waiting..." : "Allow \u21B5",
|
|
1754
|
+
"--timeout",
|
|
1755
|
+
"300"
|
|
1756
|
+
];
|
|
1757
|
+
if (!locked) {
|
|
1758
|
+
argsList.push("--cancel-label", "Block \u238B");
|
|
1759
|
+
argsList.push("--extra-button", "Always Allow");
|
|
1760
|
+
}
|
|
1761
|
+
childProcess = spawn("zenity", argsList);
|
|
1762
|
+
} else if (process.platform === "win32") {
|
|
1763
|
+
const b64Msg = Buffer.from(message).toString("base64");
|
|
1764
|
+
const b64Title = Buffer.from(title).toString("base64");
|
|
1765
|
+
const ps = `Add-Type -AssemblyName PresentationFramework; $msg = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Msg}")); $title = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${b64Title}")); $res = [System.Windows.MessageBox]::Show($msg, $title, "${locked ? "OK" : "YesNo"}", "Warning", "Button2", "DefaultDesktopOnly"); if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
1766
|
+
childProcess = spawn("powershell", ["-Command", ps]);
|
|
1767
|
+
}
|
|
1768
|
+
let output = "";
|
|
1769
|
+
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
1770
|
+
childProcess?.on("close", (code) => {
|
|
1771
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1772
|
+
if (locked) return resolve("deny");
|
|
1773
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
1774
|
+
if (code === 0) return resolve("allow");
|
|
1775
|
+
resolve("deny");
|
|
1776
|
+
});
|
|
1482
1777
|
} catch {
|
|
1483
|
-
|
|
1778
|
+
resolve("deny");
|
|
1484
1779
|
}
|
|
1485
|
-
}
|
|
1486
|
-
try {
|
|
1487
|
-
const r = spawnSync("ss", ["-Htnp", `sport = :${DAEMON_PORT}`], {
|
|
1488
|
-
encoding: "utf8",
|
|
1489
|
-
timeout: 500
|
|
1490
|
-
});
|
|
1491
|
-
return r.status === 0 && (r.stdout ?? "").includes(`:${DAEMON_PORT}`);
|
|
1492
|
-
} catch {
|
|
1493
|
-
return false;
|
|
1494
|
-
}
|
|
1780
|
+
});
|
|
1495
1781
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1782
|
+
|
|
1783
|
+
// src/auth/cloud.ts
|
|
1784
|
+
import fs7 from "fs";
|
|
1785
|
+
import os6 from "os";
|
|
1786
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
1787
|
+
return fetch(`${creds.apiUrl}/audit`, {
|
|
1788
|
+
method: "POST",
|
|
1789
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1790
|
+
body: JSON.stringify({
|
|
1791
|
+
toolName,
|
|
1792
|
+
args,
|
|
1793
|
+
checkedBy,
|
|
1794
|
+
context: {
|
|
1795
|
+
agent: meta?.agent,
|
|
1796
|
+
mcpServer: meta?.mcpServer,
|
|
1797
|
+
hostname: os6.hostname(),
|
|
1798
|
+
cwd: process.cwd(),
|
|
1799
|
+
platform: os6.platform()
|
|
1800
|
+
}
|
|
1801
|
+
}),
|
|
1802
|
+
signal: AbortSignal.timeout(5e3)
|
|
1803
|
+
}).then(() => {
|
|
1804
|
+
}).catch(() => {
|
|
1805
|
+
});
|
|
1506
1806
|
}
|
|
1507
|
-
async function
|
|
1508
|
-
const
|
|
1509
|
-
const
|
|
1510
|
-
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
1511
|
-
const onAbort = () => checkCtrl.abort();
|
|
1512
|
-
if (signal) signal.addEventListener("abort", onAbort);
|
|
1807
|
+
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
1808
|
+
const controller = new AbortController();
|
|
1809
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1513
1810
|
try {
|
|
1514
|
-
const
|
|
1811
|
+
const response = await fetch(creds.apiUrl, {
|
|
1515
1812
|
method: "POST",
|
|
1516
|
-
headers: { "Content-Type": "application/json" },
|
|
1813
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1517
1814
|
body: JSON.stringify({
|
|
1518
1815
|
toolName,
|
|
1519
1816
|
args,
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
activityId,
|
|
1817
|
+
context: {
|
|
1818
|
+
agent: meta?.agent,
|
|
1819
|
+
mcpServer: meta?.mcpServer,
|
|
1820
|
+
hostname: os6.hostname(),
|
|
1821
|
+
cwd: process.cwd(),
|
|
1822
|
+
platform: os6.platform()
|
|
1823
|
+
},
|
|
1528
1824
|
...riskMetadata && { riskMetadata }
|
|
1529
1825
|
}),
|
|
1530
|
-
signal:
|
|
1826
|
+
signal: controller.signal
|
|
1827
|
+
});
|
|
1828
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1829
|
+
return await response.json();
|
|
1830
|
+
} finally {
|
|
1831
|
+
clearTimeout(timeout);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
async function pollNode9SaaS(requestId, creds, signal) {
|
|
1835
|
+
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
1836
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1837
|
+
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
1838
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
1839
|
+
if (signal.aborted) throw new Error("Aborted");
|
|
1840
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1841
|
+
try {
|
|
1842
|
+
const pollCtrl = new AbortController();
|
|
1843
|
+
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
1844
|
+
const statusRes = await fetch(statusUrl, {
|
|
1845
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
1846
|
+
signal: pollCtrl.signal
|
|
1847
|
+
});
|
|
1848
|
+
clearTimeout(pollTimer);
|
|
1849
|
+
if (!statusRes.ok) continue;
|
|
1850
|
+
const { status, reason } = await statusRes.json();
|
|
1851
|
+
if (status === "APPROVED") {
|
|
1852
|
+
return { approved: true, reason };
|
|
1853
|
+
}
|
|
1854
|
+
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1855
|
+
return { approved: false, reason };
|
|
1856
|
+
}
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
1861
|
+
}
|
|
1862
|
+
async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
1863
|
+
try {
|
|
1864
|
+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
1865
|
+
const ctrl = new AbortController();
|
|
1866
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1867
|
+
const res = await fetch(resolveUrl, {
|
|
1868
|
+
method: "PATCH",
|
|
1869
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1870
|
+
body: JSON.stringify({
|
|
1871
|
+
decision: approved ? "APPROVED" : "DENIED",
|
|
1872
|
+
...decidedBy && { decidedBy }
|
|
1873
|
+
}),
|
|
1874
|
+
signal: ctrl.signal
|
|
1531
1875
|
});
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
1540
|
-
if (!waitRes.ok) return "deny";
|
|
1541
|
-
const { decision } = await waitRes.json();
|
|
1542
|
-
if (decision === "allow") return "allow";
|
|
1543
|
-
if (decision === "abandoned") return "abandoned";
|
|
1544
|
-
return "deny";
|
|
1545
|
-
} finally {
|
|
1546
|
-
clearTimeout(waitTimer);
|
|
1547
|
-
if (signal) signal.removeEventListener("abort", onWaitAbort);
|
|
1876
|
+
clearTimeout(timer);
|
|
1877
|
+
if (!res.ok) {
|
|
1878
|
+
fs7.appendFileSync(
|
|
1879
|
+
HOOK_DEBUG_LOG,
|
|
1880
|
+
`[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
|
|
1881
|
+
`
|
|
1882
|
+
);
|
|
1548
1883
|
}
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
fs7.appendFileSync(
|
|
1886
|
+
HOOK_DEBUG_LOG,
|
|
1887
|
+
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
1888
|
+
`
|
|
1889
|
+
);
|
|
1552
1890
|
}
|
|
1553
1891
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
method: "POST",
|
|
1558
|
-
headers: { "Content-Type": "application/json" },
|
|
1559
|
-
body: JSON.stringify({
|
|
1560
|
-
toolName,
|
|
1561
|
-
args,
|
|
1562
|
-
slackDelegated: true,
|
|
1563
|
-
agent: meta?.agent,
|
|
1564
|
-
mcpServer: meta?.mcpServer,
|
|
1565
|
-
...riskMetadata && { riskMetadata }
|
|
1566
|
-
}),
|
|
1567
|
-
signal: AbortSignal.timeout(3e3)
|
|
1568
|
-
});
|
|
1569
|
-
if (!res.ok) throw new Error("Daemon unreachable");
|
|
1570
|
-
const { id } = await res.json();
|
|
1571
|
-
return id;
|
|
1572
|
-
}
|
|
1573
|
-
async function resolveViaDaemon(id, decision, internalToken) {
|
|
1574
|
-
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1575
|
-
await fetch(`${base}/resolve/${id}`, {
|
|
1576
|
-
method: "POST",
|
|
1577
|
-
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
1578
|
-
body: JSON.stringify({ decision }),
|
|
1579
|
-
signal: AbortSignal.timeout(3e3)
|
|
1580
|
-
});
|
|
1581
|
-
}
|
|
1582
|
-
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path5.join(os2.tmpdir(), "node9-activity.sock");
|
|
1892
|
+
|
|
1893
|
+
// src/auth/orchestrator.ts
|
|
1894
|
+
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path9.join(os7.tmpdir(), "node9-activity.sock");
|
|
1583
1895
|
function notifyActivity(data) {
|
|
1584
1896
|
return new Promise((resolve) => {
|
|
1585
1897
|
try {
|
|
@@ -1595,12 +1907,12 @@ function notifyActivity(data) {
|
|
|
1595
1907
|
}
|
|
1596
1908
|
});
|
|
1597
1909
|
}
|
|
1598
|
-
async function authorizeHeadless(toolName, args,
|
|
1910
|
+
async function authorizeHeadless(toolName, args, meta, options) {
|
|
1599
1911
|
if (!options?.calledFromDaemon) {
|
|
1600
1912
|
const actId = randomUUID();
|
|
1601
1913
|
const actTs = Date.now();
|
|
1602
1914
|
await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
|
|
1603
|
-
const result = await _authorizeHeadlessCore(toolName, args,
|
|
1915
|
+
const result = await _authorizeHeadlessCore(toolName, args, meta, {
|
|
1604
1916
|
...options,
|
|
1605
1917
|
activityId: actId
|
|
1606
1918
|
});
|
|
@@ -1615,14 +1927,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
1615
1927
|
}
|
|
1616
1928
|
return result;
|
|
1617
1929
|
}
|
|
1618
|
-
return _authorizeHeadlessCore(toolName, args,
|
|
1930
|
+
return _authorizeHeadlessCore(toolName, args, meta, options);
|
|
1619
1931
|
}
|
|
1620
|
-
async function _authorizeHeadlessCore(toolName, args,
|
|
1932
|
+
async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
1621
1933
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
1622
1934
|
const pauseState = checkPause();
|
|
1623
1935
|
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
1624
1936
|
const creds = getCredentials();
|
|
1625
|
-
const config = getConfig();
|
|
1937
|
+
const config = getConfig(options?.cwd);
|
|
1626
1938
|
const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
|
|
1627
1939
|
const approvers = {
|
|
1628
1940
|
...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
|
|
@@ -1659,651 +1971,276 @@ async function _authorizeHeadlessCore(toolName, args, allowTerminalFallback = fa
|
|
|
1659
1971
|
explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
|
|
1660
1972
|
}
|
|
1661
1973
|
}
|
|
1662
|
-
if (config.settings.mode === "audit") {
|
|
1663
|
-
if (!isIgnoredTool(toolName)) {
|
|
1664
|
-
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1665
|
-
if (policyResult.decision === "review") {
|
|
1666
|
-
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
1667
|
-
if (approvers.cloud && creds?.apiKey) {
|
|
1668
|
-
await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
|
|
1669
|
-
}
|
|
1670
|
-
sendDesktopNotification(
|
|
1671
|
-
"Node9 Audit Mode",
|
|
1672
|
-
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
1673
|
-
);
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
return { approved: true, checkedBy: "audit" };
|
|
1677
|
-
}
|
|
1678
|
-
if (!isIgnoredTool(toolName)) {
|
|
1679
|
-
if (getActiveTrustSession(toolName)) {
|
|
1680
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1681
|
-
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
1682
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
1683
|
-
return { approved: true, checkedBy: "trust" };
|
|
1684
|
-
}
|
|
1685
|
-
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1686
|
-
if (policyResult.decision === "allow") {
|
|
1687
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1688
|
-
auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
1689
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
1690
|
-
return { approved: true, checkedBy: "local-policy" };
|
|
1691
|
-
}
|
|
1692
|
-
if (policyResult.decision === "block") {
|
|
1693
|
-
if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
|
|
1694
|
-
return {
|
|
1695
|
-
approved: false,
|
|
1696
|
-
reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
|
|
1697
|
-
blockedBy: "local-config",
|
|
1698
|
-
blockedByLabel: policyResult.blockedByLabel
|
|
1699
|
-
};
|
|
1700
|
-
}
|
|
1701
|
-
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
1702
|
-
policyMatchedField = policyResult.matchedField;
|
|
1703
|
-
policyMatchedWord = policyResult.matchedWord;
|
|
1704
|
-
riskMetadata = computeRiskMetadata(
|
|
1705
|
-
args,
|
|
1706
|
-
policyResult.tier ?? 6,
|
|
1707
|
-
explainableLabel,
|
|
1708
|
-
policyMatchedField,
|
|
1709
|
-
policyMatchedWord,
|
|
1710
|
-
policyResult.ruleName
|
|
1711
|
-
);
|
|
1712
|
-
const persistent = getPersistentDecision(toolName);
|
|
1713
|
-
if (persistent === "allow") {
|
|
1714
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1715
|
-
await auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
1716
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
1717
|
-
return { approved: true, checkedBy: "persistent" };
|
|
1718
|
-
}
|
|
1719
|
-
if (persistent === "deny") {
|
|
1720
|
-
if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
|
|
1721
|
-
return {
|
|
1722
|
-
approved: false,
|
|
1723
|
-
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
1724
|
-
blockedBy: "persistent-deny",
|
|
1725
|
-
blockedByLabel: "Persistent User Rule"
|
|
1726
|
-
};
|
|
1727
|
-
}
|
|
1728
|
-
} else {
|
|
1729
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
1730
|
-
return { approved: true };
|
|
1731
|
-
}
|
|
1732
|
-
let cloudRequestId = null;
|
|
1733
|
-
let isRemoteLocked = false;
|
|
1734
|
-
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
1735
|
-
if (cloudEnforced) {
|
|
1736
|
-
try {
|
|
1737
|
-
const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
|
|
1738
|
-
if (!initResult.pending) {
|
|
1739
|
-
if (initResult.shadowMode) {
|
|
1740
|
-
console.error(
|
|
1741
|
-
chalk2.yellow(
|
|
1742
|
-
`
|
|
1743
|
-
\u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
|
|
1744
|
-
)
|
|
1745
|
-
);
|
|
1746
|
-
if (initResult.shadowReason) {
|
|
1747
|
-
console.error(chalk2.dim(` Reason: ${initResult.shadowReason}
|
|
1748
|
-
`));
|
|
1749
|
-
}
|
|
1750
|
-
return { approved: true, checkedBy: "cloud" };
|
|
1751
|
-
}
|
|
1752
|
-
return {
|
|
1753
|
-
approved: !!initResult.approved,
|
|
1754
|
-
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
1755
|
-
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
1756
|
-
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
1757
|
-
blockedByLabel: "Organization Policy (SaaS)"
|
|
1758
|
-
};
|
|
1759
|
-
}
|
|
1760
|
-
cloudRequestId = initResult.requestId || null;
|
|
1761
|
-
isRemoteLocked = !!initResult.remoteApprovalOnly;
|
|
1762
|
-
explainableLabel = "Organization Policy (SaaS)";
|
|
1763
|
-
} catch (err) {
|
|
1764
|
-
const error = err;
|
|
1765
|
-
const isAuthError = error.message.includes("401") || error.message.includes("403");
|
|
1766
|
-
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
1767
|
-
const reason = isAuthError ? "Invalid or missing API key. Run `node9 login` to generate a key (must start with n9_live_)." : isNetworkError ? "Could not reach the Node9 cloud. Check your network or API URL." : error.message;
|
|
1768
|
-
console.error(
|
|
1769
|
-
chalk2.yellow(`
|
|
1770
|
-
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk2.dim(`
|
|
1771
|
-
Falling back to local rules...
|
|
1772
|
-
`)
|
|
1773
|
-
);
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
if (!options?.calledFromDaemon) {
|
|
1777
|
-
if (cloudEnforced && cloudRequestId) {
|
|
1778
|
-
console.error(
|
|
1779
|
-
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
1780
|
-
);
|
|
1781
|
-
console.error(
|
|
1782
|
-
chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n")
|
|
1783
|
-
);
|
|
1784
|
-
} else if (!cloudEnforced) {
|
|
1785
|
-
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
1786
|
-
console.error(
|
|
1787
|
-
chalk2.dim(`
|
|
1788
|
-
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
1789
|
-
`)
|
|
1790
|
-
);
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
const abortController = new AbortController();
|
|
1794
|
-
const { signal } = abortController;
|
|
1795
|
-
const racePromises = [];
|
|
1796
|
-
const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
|
|
1797
|
-
if (approvalTimeoutMs > 0) {
|
|
1798
|
-
racePromises.push(
|
|
1799
|
-
new Promise((resolve, reject) => {
|
|
1800
|
-
const timer = setTimeout(() => {
|
|
1801
|
-
resolve({
|
|
1802
|
-
approved: false,
|
|
1803
|
-
reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
|
|
1804
|
-
blockedBy: "timeout",
|
|
1805
|
-
blockedByLabel: "Approval Timeout"
|
|
1806
|
-
});
|
|
1807
|
-
}, approvalTimeoutMs);
|
|
1808
|
-
signal.addEventListener("abort", () => {
|
|
1809
|
-
clearTimeout(timer);
|
|
1810
|
-
reject(new Error("Aborted"));
|
|
1811
|
-
});
|
|
1812
|
-
})
|
|
1813
|
-
);
|
|
1814
|
-
}
|
|
1815
|
-
let viewerId = null;
|
|
1816
|
-
const internalToken = getInternalToken();
|
|
1817
|
-
if (cloudEnforced && cloudRequestId) {
|
|
1818
|
-
racePromises.push(
|
|
1819
|
-
(async () => {
|
|
1820
|
-
try {
|
|
1821
|
-
if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
|
|
1822
|
-
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
|
|
1823
|
-
() => null
|
|
1824
|
-
);
|
|
1825
|
-
}
|
|
1826
|
-
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
1827
|
-
return {
|
|
1828
|
-
approved: cloudResult.approved,
|
|
1829
|
-
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
1830
|
-
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
1831
|
-
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
1832
|
-
blockedByLabel: "Organization Policy (SaaS)"
|
|
1833
|
-
};
|
|
1834
|
-
} catch (err) {
|
|
1835
|
-
const error = err;
|
|
1836
|
-
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
1837
|
-
throw err;
|
|
1838
|
-
}
|
|
1839
|
-
})()
|
|
1840
|
-
);
|
|
1841
|
-
}
|
|
1842
|
-
if (approvers.native && !isManual && !options?.calledFromDaemon) {
|
|
1843
|
-
racePromises.push(
|
|
1844
|
-
(async () => {
|
|
1845
|
-
const decision = await askNativePopup(
|
|
1846
|
-
toolName,
|
|
1847
|
-
args,
|
|
1848
|
-
meta?.agent,
|
|
1849
|
-
explainableLabel,
|
|
1850
|
-
isRemoteLocked,
|
|
1851
|
-
signal,
|
|
1852
|
-
policyMatchedField,
|
|
1853
|
-
policyMatchedWord
|
|
1854
|
-
);
|
|
1855
|
-
if (decision === "always_allow") {
|
|
1856
|
-
writeTrustSession(toolName, 36e5);
|
|
1857
|
-
return { approved: true, checkedBy: "trust" };
|
|
1858
|
-
}
|
|
1859
|
-
const isApproved = decision === "allow";
|
|
1860
|
-
return {
|
|
1861
|
-
approved: isApproved,
|
|
1862
|
-
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
1863
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1864
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1865
|
-
blockedByLabel: "User Decision (Native)"
|
|
1866
|
-
};
|
|
1867
|
-
})()
|
|
1868
|
-
);
|
|
1869
|
-
}
|
|
1870
|
-
if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
|
|
1871
|
-
racePromises.push(
|
|
1872
|
-
(async () => {
|
|
1873
|
-
try {
|
|
1874
|
-
if (!approvers.native && !cloudEnforced) {
|
|
1875
|
-
console.error(
|
|
1876
|
-
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
1877
|
-
);
|
|
1878
|
-
console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
1879
|
-
`));
|
|
1880
|
-
}
|
|
1881
|
-
const daemonDecision = await askDaemon(
|
|
1882
|
-
toolName,
|
|
1883
|
-
args,
|
|
1884
|
-
meta,
|
|
1885
|
-
signal,
|
|
1886
|
-
riskMetadata,
|
|
1887
|
-
options?.activityId
|
|
1888
|
-
);
|
|
1889
|
-
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
1890
|
-
const isApproved = daemonDecision === "allow";
|
|
1891
|
-
return {
|
|
1892
|
-
approved: isApproved,
|
|
1893
|
-
reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
|
|
1894
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1895
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1896
|
-
blockedByLabel: "User Decision (Browser)"
|
|
1897
|
-
};
|
|
1898
|
-
} catch (err) {
|
|
1899
|
-
throw err;
|
|
1900
|
-
}
|
|
1901
|
-
})()
|
|
1902
|
-
);
|
|
1903
|
-
}
|
|
1904
|
-
if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
|
|
1905
|
-
racePromises.push(
|
|
1906
|
-
(async () => {
|
|
1907
|
-
try {
|
|
1908
|
-
if (explainableLabel.includes("DLP")) {
|
|
1909
|
-
console.log(chalk2.bgRed.white.bold(` \u{1F6A8} NODE9 DLP ALERT \u2014 CREDENTIAL DETECTED `));
|
|
1910
|
-
console.log(
|
|
1911
|
-
chalk2.red.bold(` A sensitive secret was detected in the tool arguments!`)
|
|
1912
|
-
);
|
|
1913
|
-
} else {
|
|
1914
|
-
console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
1915
|
-
}
|
|
1916
|
-
console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
|
|
1917
|
-
console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
|
|
1918
|
-
if (isRemoteLocked) {
|
|
1919
|
-
console.log(chalk2.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
1920
|
-
`));
|
|
1921
|
-
await new Promise((_, reject) => {
|
|
1922
|
-
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
1923
|
-
});
|
|
1924
|
-
}
|
|
1925
|
-
const TIMEOUT_MS = 6e4;
|
|
1926
|
-
let timer;
|
|
1927
|
-
const result = await new Promise((resolve, reject) => {
|
|
1928
|
-
timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
|
|
1929
|
-
confirm(
|
|
1930
|
-
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
1931
|
-
{ signal }
|
|
1932
|
-
).then(resolve).catch(reject);
|
|
1933
|
-
});
|
|
1934
|
-
clearTimeout(timer);
|
|
1935
|
-
return {
|
|
1936
|
-
approved: result,
|
|
1937
|
-
reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
|
|
1938
|
-
checkedBy: result ? "terminal" : void 0,
|
|
1939
|
-
blockedBy: result ? void 0 : "local-decision",
|
|
1940
|
-
blockedByLabel: "User Decision (Terminal)"
|
|
1941
|
-
};
|
|
1942
|
-
} catch (err) {
|
|
1943
|
-
const error = err;
|
|
1944
|
-
if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
|
|
1945
|
-
throw err;
|
|
1946
|
-
if (error.message === "Terminal Timeout") {
|
|
1947
|
-
return {
|
|
1948
|
-
approved: false,
|
|
1949
|
-
reason: "The terminal prompt timed out without a human response.",
|
|
1950
|
-
blockedBy: "local-decision"
|
|
1951
|
-
};
|
|
1952
|
-
}
|
|
1953
|
-
throw err;
|
|
1954
|
-
}
|
|
1955
|
-
})()
|
|
1956
|
-
);
|
|
1957
|
-
}
|
|
1958
|
-
if (racePromises.length === 0) {
|
|
1959
|
-
return {
|
|
1960
|
-
approved: false,
|
|
1961
|
-
noApprovalMechanism: true,
|
|
1962
|
-
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
1963
|
-
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
1964
|
-
blockedBy: "no-approval-mechanism",
|
|
1965
|
-
blockedByLabel: explainableLabel
|
|
1966
|
-
};
|
|
1967
|
-
}
|
|
1968
|
-
const finalResult = await new Promise((resolve) => {
|
|
1969
|
-
let resolved = false;
|
|
1970
|
-
let failures = 0;
|
|
1971
|
-
const total = racePromises.length;
|
|
1972
|
-
const finish = (res) => {
|
|
1973
|
-
if (!resolved) {
|
|
1974
|
-
resolved = true;
|
|
1975
|
-
abortController.abort();
|
|
1976
|
-
if (viewerId && internalToken) {
|
|
1977
|
-
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
1978
|
-
() => null
|
|
1979
|
-
);
|
|
1974
|
+
if (config.settings.mode === "audit") {
|
|
1975
|
+
if (!isIgnoredTool(toolName)) {
|
|
1976
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1977
|
+
if (policyResult.decision === "review") {
|
|
1978
|
+
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
1979
|
+
if (approvers.cloud && creds?.apiKey) {
|
|
1980
|
+
await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
|
|
1980
1981
|
}
|
|
1981
|
-
resolve(res);
|
|
1982
1982
|
}
|
|
1983
|
-
};
|
|
1984
|
-
for (const p of racePromises) {
|
|
1985
|
-
p.then(finish).catch((err) => {
|
|
1986
|
-
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
1987
|
-
return;
|
|
1988
|
-
if (err.message === "Abandoned") {
|
|
1989
|
-
finish({
|
|
1990
|
-
approved: false,
|
|
1991
|
-
reason: "Browser dashboard closed without making a decision.",
|
|
1992
|
-
blockedBy: "local-decision",
|
|
1993
|
-
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
1994
|
-
});
|
|
1995
|
-
return;
|
|
1996
|
-
}
|
|
1997
|
-
failures++;
|
|
1998
|
-
if (failures === total && !resolved) {
|
|
1999
|
-
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
2000
|
-
}
|
|
2001
|
-
});
|
|
2002
1983
|
}
|
|
2003
|
-
|
|
2004
|
-
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
2005
|
-
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
1984
|
+
return { approved: true, checkedBy: "audit" };
|
|
2006
1985
|
}
|
|
2007
|
-
if (!
|
|
2008
|
-
|
|
2009
|
-
|
|
1986
|
+
if (!isIgnoredTool(toolName)) {
|
|
1987
|
+
if (getActiveTrustSession(toolName)) {
|
|
1988
|
+
if (approvers.cloud && creds?.apiKey)
|
|
1989
|
+
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
1990
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
1991
|
+
return { approved: true, checkedBy: "trust" };
|
|
1992
|
+
}
|
|
1993
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1994
|
+
if (policyResult.decision === "allow") {
|
|
1995
|
+
if (approvers.cloud && creds?.apiKey)
|
|
1996
|
+
auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
1997
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
1998
|
+
return { approved: true, checkedBy: "local-policy" };
|
|
1999
|
+
}
|
|
2000
|
+
if (policyResult.decision === "block") {
|
|
2001
|
+
if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
|
|
2002
|
+
return {
|
|
2003
|
+
approved: false,
|
|
2004
|
+
reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
|
|
2005
|
+
blockedBy: "local-config",
|
|
2006
|
+
blockedByLabel: policyResult.blockedByLabel
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
2010
|
+
policyMatchedField = policyResult.matchedField;
|
|
2011
|
+
policyMatchedWord = policyResult.matchedWord;
|
|
2012
|
+
riskMetadata = computeRiskMetadata(
|
|
2010
2013
|
args,
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
+
policyResult.tier ?? 6,
|
|
2015
|
+
explainableLabel,
|
|
2016
|
+
policyMatchedField,
|
|
2017
|
+
policyMatchedWord,
|
|
2018
|
+
policyResult.ruleName
|
|
2014
2019
|
);
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
const projectPath = path5.join(cwd ?? process.cwd(), "node9.config.json");
|
|
2022
|
-
const globalConfig = tryLoadConfig(globalPath);
|
|
2023
|
-
const projectConfig = tryLoadConfig(projectPath);
|
|
2024
|
-
const mergedSettings = {
|
|
2025
|
-
...DEFAULT_CONFIG.settings,
|
|
2026
|
-
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
2027
|
-
};
|
|
2028
|
-
const mergedPolicy = {
|
|
2029
|
-
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
2030
|
-
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
2031
|
-
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
2032
|
-
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
2033
|
-
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
2034
|
-
snapshot: {
|
|
2035
|
-
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
2036
|
-
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
2037
|
-
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
2038
|
-
},
|
|
2039
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
2040
|
-
};
|
|
2041
|
-
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
2042
|
-
const applyLayer = (source) => {
|
|
2043
|
-
if (!source) return;
|
|
2044
|
-
const s = source.settings || {};
|
|
2045
|
-
const p = source.policy || {};
|
|
2046
|
-
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
2047
|
-
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
2048
|
-
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
2049
|
-
if (s.enableHookLogDebug !== void 0)
|
|
2050
|
-
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
2051
|
-
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
2052
|
-
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
2053
|
-
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
2054
|
-
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
2055
|
-
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
2056
|
-
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
2057
|
-
if (p.toolInspection)
|
|
2058
|
-
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
2059
|
-
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
2060
|
-
if (p.snapshot) {
|
|
2061
|
-
const s2 = p.snapshot;
|
|
2062
|
-
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
2063
|
-
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
2064
|
-
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
2020
|
+
const persistent = getPersistentDecision(toolName);
|
|
2021
|
+
if (persistent === "allow") {
|
|
2022
|
+
if (approvers.cloud && creds?.apiKey)
|
|
2023
|
+
await auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
2024
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
2025
|
+
return { approved: true, checkedBy: "persistent" };
|
|
2065
2026
|
}
|
|
2066
|
-
if (
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2027
|
+
if (persistent === "deny") {
|
|
2028
|
+
if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
|
|
2029
|
+
return {
|
|
2030
|
+
approved: false,
|
|
2031
|
+
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
2032
|
+
blockedBy: "persistent-deny",
|
|
2033
|
+
blockedByLabel: "Persistent User Rule"
|
|
2034
|
+
};
|
|
2070
2035
|
}
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2036
|
+
} else {
|
|
2037
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
2038
|
+
return { approved: true };
|
|
2039
|
+
}
|
|
2040
|
+
let cloudRequestId = null;
|
|
2041
|
+
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
2042
|
+
if (cloudEnforced) {
|
|
2043
|
+
try {
|
|
2044
|
+
const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
|
|
2045
|
+
if (!initResult.pending) {
|
|
2046
|
+
if (initResult.shadowMode) {
|
|
2047
|
+
return { approved: true, checkedBy: "cloud" };
|
|
2048
|
+
}
|
|
2049
|
+
return {
|
|
2050
|
+
approved: !!initResult.approved,
|
|
2051
|
+
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
2052
|
+
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
2053
|
+
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
2054
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
2079
2055
|
};
|
|
2080
2056
|
}
|
|
2057
|
+
cloudRequestId = initResult.requestId || null;
|
|
2058
|
+
explainableLabel = "Organization Policy (SaaS)";
|
|
2059
|
+
} catch {
|
|
2081
2060
|
}
|
|
2082
|
-
};
|
|
2083
|
-
applyLayer(globalConfig);
|
|
2084
|
-
applyLayer(projectConfig);
|
|
2085
|
-
const shieldOverrides = readShieldOverrides();
|
|
2086
|
-
for (const shieldName of readActiveShields()) {
|
|
2087
|
-
const shield = getShield(shieldName);
|
|
2088
|
-
if (!shield) continue;
|
|
2089
|
-
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2090
|
-
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
2091
|
-
for (const rule of shield.smartRules) {
|
|
2092
|
-
if (!existingRuleNames.has(rule.name)) {
|
|
2093
|
-
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
2094
|
-
mergedPolicy.smartRules.push(
|
|
2095
|
-
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
2096
|
-
);
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
2100
|
-
for (const word of shield.dangerousWords) {
|
|
2101
|
-
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2105
|
-
for (const rule of ADVISORY_SMART_RULES) {
|
|
2106
|
-
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
2107
|
-
}
|
|
2108
|
-
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
2109
|
-
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
2110
|
-
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
2111
|
-
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
2112
|
-
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
2113
|
-
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
2114
|
-
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
2115
|
-
const result = {
|
|
2116
|
-
settings: mergedSettings,
|
|
2117
|
-
policy: mergedPolicy,
|
|
2118
|
-
environments: mergedEnvironments
|
|
2119
|
-
};
|
|
2120
|
-
if (!cwd) cachedConfig = result;
|
|
2121
|
-
return result;
|
|
2122
|
-
}
|
|
2123
|
-
function tryLoadConfig(filePath) {
|
|
2124
|
-
if (!fs3.existsSync(filePath)) return null;
|
|
2125
|
-
let raw;
|
|
2126
|
-
try {
|
|
2127
|
-
raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
2128
|
-
} catch (err) {
|
|
2129
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2130
|
-
process.stderr.write(
|
|
2131
|
-
`
|
|
2132
|
-
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
2133
|
-
${msg}
|
|
2134
|
-
\u2192 Using default config
|
|
2135
|
-
|
|
2136
|
-
`
|
|
2137
|
-
);
|
|
2138
|
-
return null;
|
|
2139
2061
|
}
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2142
|
-
const
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2062
|
+
const abortController = new AbortController();
|
|
2063
|
+
const { signal } = abortController;
|
|
2064
|
+
const racePromises = [];
|
|
2065
|
+
const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
|
|
2066
|
+
if (approvalTimeoutMs > 0) {
|
|
2067
|
+
racePromises.push(
|
|
2068
|
+
new Promise((resolve, reject) => {
|
|
2069
|
+
const timer = setTimeout(() => {
|
|
2070
|
+
resolve({
|
|
2071
|
+
approved: false,
|
|
2072
|
+
reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
|
|
2073
|
+
blockedBy: "timeout",
|
|
2074
|
+
blockedByLabel: "Approval Timeout"
|
|
2075
|
+
});
|
|
2076
|
+
}, approvalTimeoutMs);
|
|
2077
|
+
signal.addEventListener("abort", () => {
|
|
2078
|
+
clearTimeout(timer);
|
|
2079
|
+
reject(new Error("Aborted"));
|
|
2080
|
+
});
|
|
2081
|
+
})
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
let viewerId = null;
|
|
2085
|
+
const internalToken = getInternalToken();
|
|
2086
|
+
let daemonEntryId = null;
|
|
2087
|
+
if ((approvers.browser || approvers.terminal) && isDaemonRunning() && !options?.calledFromDaemon) {
|
|
2088
|
+
if (cloudEnforced && cloudRequestId) {
|
|
2089
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
|
|
2090
|
+
daemonEntryId = viewerId;
|
|
2091
|
+
} else {
|
|
2092
|
+
try {
|
|
2093
|
+
daemonEntryId = await registerDaemonEntry(
|
|
2094
|
+
toolName,
|
|
2095
|
+
args,
|
|
2096
|
+
meta,
|
|
2097
|
+
riskMetadata,
|
|
2098
|
+
options?.activityId,
|
|
2099
|
+
options?.cwd
|
|
2100
|
+
);
|
|
2101
|
+
} catch {
|
|
2102
|
+
}
|
|
2161
2103
|
}
|
|
2162
2104
|
}
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2105
|
+
if (cloudEnforced && cloudRequestId) {
|
|
2106
|
+
racePromises.push(
|
|
2107
|
+
(async () => {
|
|
2108
|
+
try {
|
|
2109
|
+
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
2110
|
+
return {
|
|
2111
|
+
approved: cloudResult.approved,
|
|
2112
|
+
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
2113
|
+
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
2114
|
+
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
2115
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
2116
|
+
};
|
|
2117
|
+
} catch (err) {
|
|
2118
|
+
const error = err;
|
|
2119
|
+
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
2120
|
+
throw err;
|
|
2121
|
+
}
|
|
2122
|
+
})()
|
|
2172
2123
|
);
|
|
2173
2124
|
}
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
const profileName = process.env.NODE9_PROFILE || "default";
|
|
2193
|
-
const profile = creds[profileName];
|
|
2194
|
-
if (profile?.apiKey) {
|
|
2125
|
+
if (approvers.native && !isManual && !options?.calledFromDaemon) {
|
|
2126
|
+
racePromises.push(
|
|
2127
|
+
(async () => {
|
|
2128
|
+
const decision = await askNativePopup(
|
|
2129
|
+
toolName,
|
|
2130
|
+
args,
|
|
2131
|
+
meta?.agent,
|
|
2132
|
+
explainableLabel,
|
|
2133
|
+
false,
|
|
2134
|
+
signal,
|
|
2135
|
+
policyMatchedField,
|
|
2136
|
+
policyMatchedWord
|
|
2137
|
+
);
|
|
2138
|
+
if (decision === "always_allow") {
|
|
2139
|
+
writeTrustSession(toolName, 36e5);
|
|
2140
|
+
return { approved: true, checkedBy: "trust" };
|
|
2141
|
+
}
|
|
2142
|
+
const isApproved = decision === "allow";
|
|
2195
2143
|
return {
|
|
2196
|
-
|
|
2197
|
-
|
|
2144
|
+
approved: isApproved,
|
|
2145
|
+
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
2146
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
2147
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
2148
|
+
blockedByLabel: "User Decision (Native)",
|
|
2149
|
+
decisionSource: "native"
|
|
2198
2150
|
};
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2151
|
+
})()
|
|
2152
|
+
);
|
|
2153
|
+
}
|
|
2154
|
+
if (daemonEntryId && (approvers.browser || approvers.terminal)) {
|
|
2155
|
+
racePromises.push(
|
|
2156
|
+
(async () => {
|
|
2157
|
+
const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
|
|
2158
|
+
daemonEntryId,
|
|
2159
|
+
signal
|
|
2160
|
+
);
|
|
2161
|
+
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
2162
|
+
const isApproved = daemonDecision === "allow";
|
|
2163
|
+
const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
|
|
2164
|
+
const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
|
|
2201
2165
|
return {
|
|
2202
|
-
|
|
2203
|
-
|
|
2166
|
+
approved: isApproved,
|
|
2167
|
+
reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
|
|
2168
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
2169
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
2170
|
+
blockedByLabel: `User Decision (${via})`,
|
|
2171
|
+
decisionSource: src
|
|
2204
2172
|
};
|
|
2173
|
+
})()
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
if (racePromises.length === 0) {
|
|
2177
|
+
return {
|
|
2178
|
+
approved: false,
|
|
2179
|
+
noApprovalMechanism: true,
|
|
2180
|
+
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
2181
|
+
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
2182
|
+
blockedBy: "no-approval-mechanism",
|
|
2183
|
+
blockedByLabel: explainableLabel
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
const finalResult = await new Promise((resolve) => {
|
|
2187
|
+
let resolved = false;
|
|
2188
|
+
let failures = 0;
|
|
2189
|
+
const total = racePromises.length;
|
|
2190
|
+
const finish = (res) => {
|
|
2191
|
+
if (!resolved) {
|
|
2192
|
+
resolved = true;
|
|
2193
|
+
abortController.abort();
|
|
2194
|
+
if (viewerId && internalToken) {
|
|
2195
|
+
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
2196
|
+
() => null
|
|
2197
|
+
);
|
|
2198
|
+
}
|
|
2199
|
+
resolve(res);
|
|
2205
2200
|
}
|
|
2201
|
+
};
|
|
2202
|
+
for (const p of racePromises) {
|
|
2203
|
+
p.then(finish).catch((err) => {
|
|
2204
|
+
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
2205
|
+
return;
|
|
2206
|
+
if (err.message === "Abandoned") {
|
|
2207
|
+
finish({
|
|
2208
|
+
approved: false,
|
|
2209
|
+
reason: "Browser dashboard closed without making a decision.",
|
|
2210
|
+
blockedBy: "local-decision",
|
|
2211
|
+
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
2212
|
+
});
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
failures++;
|
|
2216
|
+
if (failures === total && !resolved) {
|
|
2217
|
+
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2206
2220
|
}
|
|
2207
|
-
}
|
|
2221
|
+
});
|
|
2222
|
+
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
2223
|
+
await resolveNode9SaaS(
|
|
2224
|
+
cloudRequestId,
|
|
2225
|
+
creds,
|
|
2226
|
+
finalResult.approved,
|
|
2227
|
+
finalResult.decisionSource ?? finalResult.checkedBy ?? "local"
|
|
2228
|
+
);
|
|
2208
2229
|
}
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
async function authorizeAction(toolName, args) {
|
|
2212
|
-
const result = await authorizeHeadless(toolName, args, true);
|
|
2213
|
-
return result.approved;
|
|
2214
|
-
}
|
|
2215
|
-
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
2216
|
-
return fetch(`${creds.apiUrl}/audit`, {
|
|
2217
|
-
method: "POST",
|
|
2218
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2219
|
-
body: JSON.stringify({
|
|
2230
|
+
if (!isManual) {
|
|
2231
|
+
appendLocalAudit(
|
|
2220
2232
|
toolName,
|
|
2221
2233
|
args,
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
hostname: os2.hostname(),
|
|
2227
|
-
cwd: process.cwd(),
|
|
2228
|
-
platform: os2.platform()
|
|
2229
|
-
}
|
|
2230
|
-
}),
|
|
2231
|
-
signal: AbortSignal.timeout(5e3)
|
|
2232
|
-
}).then(() => {
|
|
2233
|
-
}).catch(() => {
|
|
2234
|
-
});
|
|
2235
|
-
}
|
|
2236
|
-
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
2237
|
-
const controller = new AbortController();
|
|
2238
|
-
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2239
|
-
try {
|
|
2240
|
-
const response = await fetch(creds.apiUrl, {
|
|
2241
|
-
method: "POST",
|
|
2242
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2243
|
-
body: JSON.stringify({
|
|
2244
|
-
toolName,
|
|
2245
|
-
args,
|
|
2246
|
-
context: {
|
|
2247
|
-
agent: meta?.agent,
|
|
2248
|
-
mcpServer: meta?.mcpServer,
|
|
2249
|
-
hostname: os2.hostname(),
|
|
2250
|
-
cwd: process.cwd(),
|
|
2251
|
-
platform: os2.platform()
|
|
2252
|
-
},
|
|
2253
|
-
...riskMetadata && { riskMetadata }
|
|
2254
|
-
}),
|
|
2255
|
-
signal: controller.signal
|
|
2256
|
-
});
|
|
2257
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2258
|
-
return await response.json();
|
|
2259
|
-
} finally {
|
|
2260
|
-
clearTimeout(timeout);
|
|
2261
|
-
}
|
|
2262
|
-
}
|
|
2263
|
-
async function pollNode9SaaS(requestId, creds, signal) {
|
|
2264
|
-
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
2265
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
2266
|
-
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
2267
|
-
while (Date.now() < POLL_DEADLINE) {
|
|
2268
|
-
if (signal.aborted) throw new Error("Aborted");
|
|
2269
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
2270
|
-
try {
|
|
2271
|
-
const pollCtrl = new AbortController();
|
|
2272
|
-
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
2273
|
-
const statusRes = await fetch(statusUrl, {
|
|
2274
|
-
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
2275
|
-
signal: pollCtrl.signal
|
|
2276
|
-
});
|
|
2277
|
-
clearTimeout(pollTimer);
|
|
2278
|
-
if (!statusRes.ok) continue;
|
|
2279
|
-
const { status, reason } = await statusRes.json();
|
|
2280
|
-
if (status === "APPROVED") {
|
|
2281
|
-
console.error(chalk2.green("\u2705 Approved via Cloud.\n"));
|
|
2282
|
-
return { approved: true, reason };
|
|
2283
|
-
}
|
|
2284
|
-
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
2285
|
-
console.error(chalk2.red("\u274C Denied via Cloud.\n"));
|
|
2286
|
-
return { approved: false, reason };
|
|
2287
|
-
}
|
|
2288
|
-
} catch {
|
|
2289
|
-
}
|
|
2234
|
+
finalResult.approved ? "allow" : "deny",
|
|
2235
|
+
finalResult.checkedBy || finalResult.blockedBy || "unknown",
|
|
2236
|
+
meta
|
|
2237
|
+
);
|
|
2290
2238
|
}
|
|
2291
|
-
return
|
|
2239
|
+
return finalResult;
|
|
2292
2240
|
}
|
|
2293
|
-
async function
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
const ctrl = new AbortController();
|
|
2297
|
-
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
2298
|
-
await fetch(resolveUrl, {
|
|
2299
|
-
method: "PATCH",
|
|
2300
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2301
|
-
body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
|
|
2302
|
-
signal: ctrl.signal
|
|
2303
|
-
});
|
|
2304
|
-
clearTimeout(timer);
|
|
2305
|
-
} catch {
|
|
2306
|
-
}
|
|
2241
|
+
async function authorizeAction(toolName, args) {
|
|
2242
|
+
const result = await authorizeHeadless(toolName, args);
|
|
2243
|
+
return result.approved;
|
|
2307
2244
|
}
|
|
2308
2245
|
|
|
2309
2246
|
// src/index.ts
|