@node9/proxy 1.1.7 → 1.3.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 +125 -18
- package/dist/cli.js +4138 -3652
- package/dist/cli.mjs +4435 -3952
- package/dist/index.js +1651 -1620
- package/dist/index.mjs +1649 -1618
- package/package.json +8 -3
package/dist/index.mjs
CHANGED
|
@@ -1,826 +1,843 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import
|
|
3
|
-
import path5 from "path";
|
|
4
|
-
import os2 from "os";
|
|
5
|
-
import net from "net";
|
|
6
|
-
import { randomUUID } from "crypto";
|
|
7
|
-
import { spawnSync } from "child_process";
|
|
8
|
-
import pm from "picomatch";
|
|
9
|
-
import safeRegex from "safe-regex2";
|
|
10
|
-
import { parse } from "sh-syntax";
|
|
11
|
-
|
|
12
|
-
// src/ui/native.ts
|
|
13
|
-
import { spawn } from "child_process";
|
|
14
|
-
import path2 from "path";
|
|
15
|
-
|
|
16
|
-
// src/context-sniper.ts
|
|
1
|
+
// src/audit/index.ts
|
|
2
|
+
import fs from "fs";
|
|
17
3
|
import path from "path";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
22
19
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 {
|
|
27
26
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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()
|
|
35
38
|
});
|
|
36
|
-
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
37
|
-
const start = Math.max(0, hitIndex - 3);
|
|
38
|
-
const end = Math.min(lines.length, hitIndex + 4);
|
|
39
|
-
const lineIndex = hitIndex - start;
|
|
40
|
-
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
41
|
-
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
42
|
-
` : "";
|
|
43
|
-
const tail = end < lines.length ? `
|
|
44
|
-
... [${lines.length - end} lines hidden] ...` : "";
|
|
45
|
-
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
46
39
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
+
})
|
|
77
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 };
|
|
78
145
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const result = extractContext(String(obj.new_string), matchedWord);
|
|
88
|
-
contextSnippet = result.snippet;
|
|
89
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
90
|
-
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
91
|
-
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
92
|
-
contextSnippet = result.snippet;
|
|
93
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
94
|
-
} else {
|
|
95
|
-
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
96
|
-
if (foundKey) {
|
|
97
|
-
const val = obj[foundKey];
|
|
98
|
-
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;
|
|
99
154
|
}
|
|
100
155
|
}
|
|
101
|
-
} else if (typeof parsed === "string") {
|
|
102
|
-
contextSnippet = smartTruncate(parsed, 500);
|
|
103
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
|
+
});
|
|
104
161
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
...matchedWord && { matchedWord },
|
|
109
|
-
...matchedField && { matchedField },
|
|
110
|
-
...contextSnippet !== void 0 && { contextSnippet },
|
|
111
|
-
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
112
|
-
...editFileName && { editFileName },
|
|
113
|
-
...editFilePath && { editFilePath },
|
|
114
|
-
...ruleName && { ruleName }
|
|
162
|
+
sanitized,
|
|
163
|
+
error: `Invalid config:
|
|
164
|
+
${lines.join("\n")}`
|
|
115
165
|
};
|
|
116
166
|
}
|
|
117
167
|
|
|
118
|
-
// src/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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)"
|
|
132
207
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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"]
|
|
136
337
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
intent: "EDIT",
|
|
145
|
-
message: `\u{1F4DD} EDITING: ${file}
|
|
146
|
-
\u{1F4C2} PATH: ${obj.file_path}
|
|
147
|
-
|
|
148
|
-
--- REPLACING ---
|
|
149
|
-
${oldPreview}
|
|
150
|
-
|
|
151
|
-
+++ NEW CODE +++
|
|
152
|
-
${newPreview}`
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
if (matchedField && obj[matchedField] !== void 0) {
|
|
156
|
-
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
157
|
-
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(", ")}
|
|
158
|
-
|
|
159
|
-
` : "";
|
|
160
|
-
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
161
|
-
return {
|
|
162
|
-
intent: "EXEC",
|
|
163
|
-
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
164
|
-
${content}`
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
const codeKeys = [
|
|
168
|
-
"command",
|
|
169
|
-
"cmd",
|
|
170
|
-
"shell_command",
|
|
171
|
-
"bash_command",
|
|
172
|
-
"script",
|
|
173
|
-
"code",
|
|
174
|
-
"input",
|
|
175
|
-
"sql",
|
|
176
|
-
"query",
|
|
177
|
-
"arguments",
|
|
178
|
-
"args",
|
|
179
|
-
"param",
|
|
180
|
-
"params",
|
|
181
|
-
"text"
|
|
182
|
-
];
|
|
183
|
-
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
184
|
-
if (foundKey) {
|
|
185
|
-
const val = obj[foundKey];
|
|
186
|
-
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
187
|
-
return {
|
|
188
|
-
intent: "EXEC",
|
|
189
|
-
message: `[${foundKey.toUpperCase()}]:
|
|
190
|
-
${smartTruncate(str, 500)}`
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
const msg = Object.entries(obj).slice(0, 5).map(
|
|
194
|
-
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
195
|
-
).join("\n");
|
|
196
|
-
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;
|
|
197
344
|
}
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
function escapePango(text) {
|
|
201
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
345
|
+
return null;
|
|
202
346
|
}
|
|
203
|
-
function
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
|
|
207
|
-
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
208
|
-
lines.push("");
|
|
209
|
-
lines.push(formattedArgs);
|
|
210
|
-
if (!locked) {
|
|
211
|
-
lines.push("");
|
|
212
|
-
lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
|
|
213
|
-
}
|
|
214
|
-
return lines.join("\n");
|
|
347
|
+
function getShield(name) {
|
|
348
|
+
const resolved = resolveShieldName(name);
|
|
349
|
+
return resolved ? SHIELDS[resolved] : null;
|
|
215
350
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
220
|
-
lines.push("");
|
|
221
|
-
}
|
|
222
|
-
lines.push(
|
|
223
|
-
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
224
|
-
);
|
|
225
|
-
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
226
|
-
lines.push("");
|
|
227
|
-
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
228
|
-
if (!locked) {
|
|
229
|
-
lines.push("");
|
|
230
|
-
lines.push(
|
|
231
|
-
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
return lines.join("\n");
|
|
351
|
+
var SHIELDS_STATE_FILE = path2.join(os2.homedir(), ".node9", "shields.json");
|
|
352
|
+
function isShieldVerdict(v) {
|
|
353
|
+
return v === "allow" || v === "review" || v === "block";
|
|
235
354
|
}
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
resolve("deny");
|
|
252
|
-
};
|
|
253
|
-
if (signal) {
|
|
254
|
-
if (signal.aborted) return resolve("deny");
|
|
255
|
-
signal.addEventListener("abort", onAbort);
|
|
256
|
-
}
|
|
257
|
-
try {
|
|
258
|
-
if (process.platform === "darwin") {
|
|
259
|
-
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"`;
|
|
260
|
-
const script = `on run argv
|
|
261
|
-
tell application "System Events"
|
|
262
|
-
activate
|
|
263
|
-
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
264
|
-
end tell
|
|
265
|
-
end run`;
|
|
266
|
-
childProcess = spawn("osascript", ["-e", script, "--", message, title]);
|
|
267
|
-
} else if (process.platform === "linux") {
|
|
268
|
-
const pangoMessage = buildPangoMessage(
|
|
269
|
-
toolName,
|
|
270
|
-
formattedArgs,
|
|
271
|
-
agent,
|
|
272
|
-
explainableLabel,
|
|
273
|
-
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
|
+
`
|
|
274
368
|
);
|
|
275
|
-
const argsList = [
|
|
276
|
-
locked ? "--info" : "--question",
|
|
277
|
-
"--modal",
|
|
278
|
-
"--width=480",
|
|
279
|
-
"--title",
|
|
280
|
-
title,
|
|
281
|
-
"--text",
|
|
282
|
-
pangoMessage,
|
|
283
|
-
"--ok-label",
|
|
284
|
-
locked ? "Waiting..." : "Allow \u21B5",
|
|
285
|
-
"--timeout",
|
|
286
|
-
"300"
|
|
287
|
-
];
|
|
288
|
-
if (!locked) {
|
|
289
|
-
argsList.push("--cancel-label", "Block \u238B");
|
|
290
|
-
argsList.push("--extra-button", "Always Allow");
|
|
291
|
-
}
|
|
292
|
-
childProcess = spawn("zenity", argsList);
|
|
293
|
-
} else if (process.platform === "win32") {
|
|
294
|
-
const b64Msg = Buffer.from(message).toString("base64");
|
|
295
|
-
const b64Title = Buffer.from(title).toString("base64");
|
|
296
|
-
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 }`;
|
|
297
|
-
childProcess = spawn("powershell", ["-Command", ps]);
|
|
298
369
|
}
|
|
299
|
-
let output = "";
|
|
300
|
-
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
301
|
-
childProcess?.on("close", (code) => {
|
|
302
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
303
|
-
if (locked) return resolve("deny");
|
|
304
|
-
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
305
|
-
if (code === 0) return resolve("allow");
|
|
306
|
-
resolve("deny");
|
|
307
|
-
});
|
|
308
|
-
} catch {
|
|
309
|
-
resolve("deny");
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// src/config-schema.ts
|
|
315
|
-
import { z } from "zod";
|
|
316
|
-
var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
317
|
-
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
318
|
-
});
|
|
319
|
-
var SmartConditionSchema = z.object({
|
|
320
|
-
field: z.string().min(1, "Condition field must not be empty"),
|
|
321
|
-
op: z.enum(
|
|
322
|
-
[
|
|
323
|
-
"matches",
|
|
324
|
-
"notMatches",
|
|
325
|
-
"contains",
|
|
326
|
-
"notContains",
|
|
327
|
-
"exists",
|
|
328
|
-
"notExists",
|
|
329
|
-
"matchesGlob",
|
|
330
|
-
"notMatchesGlob"
|
|
331
|
-
],
|
|
332
|
-
{
|
|
333
|
-
errorMap: () => ({
|
|
334
|
-
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
|
|
335
|
-
})
|
|
336
370
|
}
|
|
337
|
-
|
|
338
|
-
value: z.string().optional(),
|
|
339
|
-
flags: z.string().optional()
|
|
340
|
-
}).refine(
|
|
341
|
-
(c) => {
|
|
342
|
-
if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
|
|
343
|
-
return true;
|
|
344
|
-
},
|
|
345
|
-
{ message: "matchesGlob and notMatchesGlob conditions require a value field" }
|
|
346
|
-
);
|
|
347
|
-
var SmartRuleSchema = z.object({
|
|
348
|
-
name: z.string().optional(),
|
|
349
|
-
tool: z.string().min(1, "Smart rule tool must not be empty"),
|
|
350
|
-
conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
351
|
-
conditionMode: z.enum(["all", "any"]).optional(),
|
|
352
|
-
verdict: z.enum(["allow", "review", "block"], {
|
|
353
|
-
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
354
|
-
}),
|
|
355
|
-
reason: z.string().optional()
|
|
356
|
-
});
|
|
357
|
-
var ConfigFileSchema = z.object({
|
|
358
|
-
version: z.string().optional(),
|
|
359
|
-
settings: z.object({
|
|
360
|
-
mode: z.enum(["standard", "strict", "audit"]).optional(),
|
|
361
|
-
autoStartDaemon: z.boolean().optional(),
|
|
362
|
-
enableUndo: z.boolean().optional(),
|
|
363
|
-
enableHookLogDebug: z.boolean().optional(),
|
|
364
|
-
approvalTimeoutMs: z.number().nonnegative().optional(),
|
|
365
|
-
approvalTimeoutSeconds: z.number().nonnegative().optional(),
|
|
366
|
-
flightRecorder: z.boolean().optional(),
|
|
367
|
-
approvers: z.object({
|
|
368
|
-
native: z.boolean().optional(),
|
|
369
|
-
browser: z.boolean().optional(),
|
|
370
|
-
cloud: z.boolean().optional(),
|
|
371
|
-
terminal: z.boolean().optional()
|
|
372
|
-
}).optional(),
|
|
373
|
-
environment: z.string().optional(),
|
|
374
|
-
slackEnabled: z.boolean().optional(),
|
|
375
|
-
enableTrustSessions: z.boolean().optional(),
|
|
376
|
-
allowGlobalPause: z.boolean().optional()
|
|
377
|
-
}).optional(),
|
|
378
|
-
policy: z.object({
|
|
379
|
-
sandboxPaths: z.array(z.string()).optional(),
|
|
380
|
-
dangerousWords: z.array(noNewlines).optional(),
|
|
381
|
-
ignoredTools: z.array(z.string()).optional(),
|
|
382
|
-
toolInspection: z.record(z.string()).optional(),
|
|
383
|
-
smartRules: z.array(SmartRuleSchema).optional(),
|
|
384
|
-
snapshot: z.object({
|
|
385
|
-
tools: z.array(z.string()).optional(),
|
|
386
|
-
onlyPaths: z.array(z.string()).optional(),
|
|
387
|
-
ignorePaths: z.array(z.string()).optional()
|
|
388
|
-
}).optional(),
|
|
389
|
-
dlp: z.object({
|
|
390
|
-
enabled: z.boolean().optional(),
|
|
391
|
-
scanIgnoredTools: z.boolean().optional()
|
|
392
|
-
}).optional()
|
|
393
|
-
}).optional(),
|
|
394
|
-
environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
|
|
395
|
-
}).strict({ message: "Config contains unknown top-level keys" });
|
|
396
|
-
function sanitizeConfig(raw) {
|
|
397
|
-
const result = ConfigFileSchema.safeParse(raw);
|
|
398
|
-
if (result.success) {
|
|
399
|
-
return { sanitized: result.data, error: null };
|
|
371
|
+
if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
|
|
400
372
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
`);
|
|
410
388
|
}
|
|
389
|
+
return { active: [] };
|
|
411
390
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
error: `Invalid config:
|
|
419
|
-
${lines.join("\n")}`
|
|
420
|
-
};
|
|
391
|
+
}
|
|
392
|
+
function readActiveShields() {
|
|
393
|
+
return readShieldsFile().active;
|
|
394
|
+
}
|
|
395
|
+
function readShieldOverrides() {
|
|
396
|
+
return readShieldsFile().overrides ?? {};
|
|
421
397
|
}
|
|
422
398
|
|
|
423
|
-
// src/
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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) ──────────────────────
|
|
447
464
|
{
|
|
448
|
-
name: "
|
|
449
|
-
tool: "
|
|
465
|
+
name: "block-rm-rf-home",
|
|
466
|
+
tool: "bash",
|
|
467
|
+
conditionMode: "all",
|
|
450
468
|
conditions: [
|
|
451
|
-
{
|
|
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
|
+
}
|
|
452
479
|
],
|
|
453
480
|
verdict: "block",
|
|
454
|
-
reason: "
|
|
481
|
+
reason: "Recursive delete of home directory is irreversible"
|
|
455
482
|
},
|
|
483
|
+
// ── SQL safety ────────────────────────────────────────────────────────
|
|
456
484
|
{
|
|
457
|
-
name: "
|
|
485
|
+
name: "no-delete-without-where",
|
|
458
486
|
tool: "*",
|
|
459
|
-
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",
|
|
460
492
|
verdict: "review",
|
|
461
|
-
reason: "
|
|
462
|
-
}
|
|
463
|
-
],
|
|
464
|
-
dangerousWords: ["dropdb", "pg_dropcluster"]
|
|
465
|
-
},
|
|
466
|
-
github: {
|
|
467
|
-
name: "github",
|
|
468
|
-
description: "Protects GitHub repositories from destructive AI operations",
|
|
469
|
-
aliases: ["git"],
|
|
470
|
-
smartRules: [
|
|
493
|
+
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
494
|
+
},
|
|
471
495
|
{
|
|
472
|
-
|
|
473
|
-
// This rule adds coverage for `git push --delete` which the built-in does not match.
|
|
474
|
-
name: "shield:github:review-delete-branch-remote",
|
|
496
|
+
name: "review-drop-truncate-shell",
|
|
475
497
|
tool: "bash",
|
|
476
498
|
conditions: [
|
|
477
499
|
{
|
|
478
500
|
field: "command",
|
|
479
501
|
op: "matches",
|
|
480
|
-
value: "
|
|
502
|
+
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
481
503
|
flags: "i"
|
|
482
504
|
}
|
|
483
505
|
],
|
|
506
|
+
conditionMode: "all",
|
|
484
507
|
verdict: "review",
|
|
485
|
-
reason: "
|
|
508
|
+
reason: "SQL DDL destructive statement inside a shell command"
|
|
486
509
|
},
|
|
510
|
+
// ── Git safety ────────────────────────────────────────────────────────
|
|
487
511
|
{
|
|
488
|
-
name: "
|
|
489
|
-
tool: "
|
|
490
|
-
conditions: [
|
|
491
|
-
{ field: "command", op: "matches", value: "gh\\s+repo\\s+delete", flags: "i" }
|
|
492
|
-
],
|
|
493
|
-
verdict: "block",
|
|
494
|
-
reason: "Repository deletion is irreversible \u2014 blocked by GitHub shield"
|
|
495
|
-
}
|
|
496
|
-
],
|
|
497
|
-
dangerousWords: []
|
|
498
|
-
},
|
|
499
|
-
aws: {
|
|
500
|
-
name: "aws",
|
|
501
|
-
description: "Protects AWS infrastructure from destructive AI operations",
|
|
502
|
-
aliases: ["amazon"],
|
|
503
|
-
smartRules: [
|
|
504
|
-
{
|
|
505
|
-
name: "shield:aws:block-delete-s3-bucket",
|
|
506
|
-
tool: "*",
|
|
512
|
+
name: "block-force-push",
|
|
513
|
+
tool: "bash",
|
|
507
514
|
conditions: [
|
|
508
515
|
{
|
|
509
516
|
field: "command",
|
|
510
517
|
op: "matches",
|
|
511
|
-
value: "
|
|
518
|
+
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
512
519
|
flags: "i"
|
|
513
520
|
}
|
|
514
521
|
],
|
|
522
|
+
conditionMode: "all",
|
|
515
523
|
verdict: "block",
|
|
516
|
-
reason: "
|
|
524
|
+
reason: "Force push overwrites remote history and cannot be undone"
|
|
517
525
|
},
|
|
518
526
|
{
|
|
519
|
-
name: "
|
|
520
|
-
tool: "
|
|
527
|
+
name: "review-git-push",
|
|
528
|
+
tool: "bash",
|
|
521
529
|
conditions: [
|
|
522
530
|
{
|
|
523
531
|
field: "command",
|
|
524
532
|
op: "matches",
|
|
525
|
-
value: "
|
|
533
|
+
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
526
534
|
flags: "i"
|
|
527
535
|
}
|
|
528
536
|
],
|
|
537
|
+
conditionMode: "all",
|
|
529
538
|
verdict: "review",
|
|
530
|
-
reason: "
|
|
539
|
+
reason: "git push sends changes to a shared remote"
|
|
531
540
|
},
|
|
532
541
|
{
|
|
533
|
-
name: "
|
|
534
|
-
tool: "
|
|
542
|
+
name: "review-git-destructive",
|
|
543
|
+
tool: "bash",
|
|
535
544
|
conditions: [
|
|
536
545
|
{
|
|
537
546
|
field: "command",
|
|
538
547
|
op: "matches",
|
|
539
|
-
value: "
|
|
548
|
+
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
540
549
|
flags: "i"
|
|
541
550
|
}
|
|
542
551
|
],
|
|
543
|
-
|
|
544
|
-
reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
|
|
545
|
-
},
|
|
546
|
-
{
|
|
547
|
-
name: "shield:aws:review-rds-delete",
|
|
548
|
-
tool: "*",
|
|
549
|
-
conditions: [
|
|
550
|
-
{ field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
|
|
551
|
-
],
|
|
552
|
+
conditionMode: "all",
|
|
552
553
|
verdict: "review",
|
|
553
|
-
reason: "
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
dangerousWords: []
|
|
557
|
-
},
|
|
558
|
-
filesystem: {
|
|
559
|
-
name: "filesystem",
|
|
560
|
-
description: "Protects the local filesystem from dangerous AI operations",
|
|
561
|
-
aliases: ["fs"],
|
|
562
|
-
smartRules: [
|
|
554
|
+
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
555
|
+
},
|
|
556
|
+
// ── Shell safety ──────────────────────────────────────────────────────
|
|
563
557
|
{
|
|
564
|
-
name: "
|
|
558
|
+
name: "review-sudo",
|
|
565
559
|
tool: "bash",
|
|
566
|
-
conditions: [
|
|
567
|
-
|
|
568
|
-
],
|
|
560
|
+
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
561
|
+
conditionMode: "all",
|
|
569
562
|
verdict: "review",
|
|
570
|
-
reason: "
|
|
563
|
+
reason: "Command requires elevated privileges"
|
|
571
564
|
},
|
|
572
565
|
{
|
|
573
|
-
name: "
|
|
566
|
+
name: "review-curl-pipe-shell",
|
|
574
567
|
tool: "bash",
|
|
575
568
|
conditions: [
|
|
576
569
|
{
|
|
577
570
|
field: "command",
|
|
578
|
-
// Narrow to write-indicative operations to avoid approval fatigue on reads.
|
|
579
|
-
// Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
|
|
580
571
|
op: "matches",
|
|
581
|
-
value: "(
|
|
572
|
+
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
573
|
+
flags: "i"
|
|
582
574
|
}
|
|
583
575
|
],
|
|
584
|
-
|
|
585
|
-
|
|
576
|
+
conditionMode: "all",
|
|
577
|
+
verdict: "block",
|
|
578
|
+
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
586
579
|
}
|
|
587
580
|
],
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
dangerousWords: ["wipefs"]
|
|
592
|
-
}
|
|
581
|
+
dlp: { enabled: true, scanIgnoredTools: true }
|
|
582
|
+
},
|
|
583
|
+
environments: {}
|
|
593
584
|
};
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
function validateOverrides(raw) {
|
|
611
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
612
|
-
const result = {};
|
|
613
|
-
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
614
|
-
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
615
|
-
const validRules = {};
|
|
616
|
-
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
617
|
-
if (isShieldVerdict(verdict)) {
|
|
618
|
-
validRules[ruleName] = verdict;
|
|
619
|
-
} else {
|
|
620
|
-
process.stderr.write(
|
|
621
|
-
`[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
|
|
622
|
-
`
|
|
623
|
-
);
|
|
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|$)"
|
|
624
601
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
function readShieldsFile() {
|
|
631
|
-
try {
|
|
632
|
-
const raw = fs.readFileSync(SHIELDS_STATE_FILE, "utf-8");
|
|
633
|
-
if (!raw.trim()) return { active: [] };
|
|
634
|
-
const parsed = JSON.parse(raw);
|
|
635
|
-
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
636
|
-
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
637
|
-
) : [];
|
|
638
|
-
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
639
|
-
} catch (err) {
|
|
640
|
-
if (err.code !== "ENOENT") {
|
|
641
|
-
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
642
|
-
`);
|
|
643
|
-
}
|
|
644
|
-
return { active: [] };
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
function readActiveShields() {
|
|
648
|
-
return readShieldsFile().active;
|
|
649
|
-
}
|
|
650
|
-
function readShieldOverrides() {
|
|
651
|
-
return readShieldsFile().overrides ?? {};
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// src/dlp.ts
|
|
655
|
-
import fs2 from "fs";
|
|
656
|
-
import path4 from "path";
|
|
657
|
-
var DLP_PATTERNS = [
|
|
658
|
-
{ name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
|
|
659
|
-
{ name: "GitHub Token", regex: /\bgh[pous]_[A-Za-z0-9]{36}\b/, severity: "block" },
|
|
660
|
-
// Slack bot tokens: xoxb- + variable segment. Real tokens are ~50–80 chars;
|
|
661
|
-
// lower bound 20 avoids false negatives on partial tokens, upper 100 caps scan cost.
|
|
662
|
-
{ name: "Slack Bot Token", regex: /\bxoxb-[0-9A-Za-z-]{20,100}\b/, severity: "block" },
|
|
663
|
-
{ name: "OpenAI API Key", regex: /\bsk-[a-zA-Z0-9_-]{20,}\b/, severity: "block" },
|
|
664
|
-
{ 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
|
+
},
|
|
665
606
|
{
|
|
666
|
-
name: "
|
|
667
|
-
|
|
668
|
-
|
|
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"
|
|
669
612
|
},
|
|
670
|
-
//
|
|
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.
|
|
671
618
|
{
|
|
672
|
-
name: "
|
|
673
|
-
|
|
674
|
-
|
|
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"
|
|
675
624
|
},
|
|
676
|
-
// NPM auth token in .npmrc format
|
|
677
625
|
{
|
|
678
|
-
name: "
|
|
679
|
-
|
|
680
|
-
|
|
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"
|
|
681
631
|
},
|
|
682
|
-
{
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
// .env, .env.local, .env.production — not .envoy
|
|
692
|
-
/[/\\]\.git-credentials$/i,
|
|
693
|
-
/[/\\]\.npmrc$/i,
|
|
694
|
-
/[/\\]\.docker[/\\]config\.json$/i,
|
|
695
|
-
/[/\\][^/\\]+\.pem$/i,
|
|
696
|
-
/[/\\][^/\\]+\.key$/i,
|
|
697
|
-
/[/\\][^/\\]+\.p12$/i,
|
|
698
|
-
/[/\\][^/\\]+\.pfx$/i,
|
|
699
|
-
/^(?:[a-zA-Z]:)?\/etc\/passwd$/,
|
|
700
|
-
/^(?:[a-zA-Z]:)?\/etc\/shadow$/,
|
|
701
|
-
/^(?:[a-zA-Z]:)?\/etc\/sudoers$/,
|
|
702
|
-
/[/\\]credentials\.json$/i,
|
|
703
|
-
/[/\\]id_rsa$/i,
|
|
704
|
-
/[/\\]id_ed25519$/i,
|
|
705
|
-
/[/\\]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
|
+
}
|
|
706
641
|
];
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
716
|
-
resolved = path4.resolve(cwd, filePath);
|
|
717
|
-
} else {
|
|
718
|
-
return {
|
|
719
|
-
patternName: "Sensitive File Path",
|
|
720
|
-
fieldPath: "file_path",
|
|
721
|
-
redactedSample: filePath,
|
|
722
|
-
severity: "block"
|
|
723
|
-
};
|
|
724
|
-
}
|
|
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
|
+
};
|
|
725
650
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
+
}
|
|
736
669
|
}
|
|
670
|
+
} catch {
|
|
737
671
|
}
|
|
738
672
|
return null;
|
|
739
673
|
}
|
|
740
|
-
function
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
const secret = match[0];
|
|
744
|
-
if (secret.length < 8) return "****";
|
|
745
|
-
const prefix = secret.slice(0, 4);
|
|
746
|
-
const suffix = secret.slice(-4);
|
|
747
|
-
const stars = "*".repeat(Math.min(secret.length - 8, 12));
|
|
748
|
-
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;
|
|
749
677
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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);
|
|
759
727
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const match = scanArgs(value, depth + 1, `${fieldPath}.${key}`);
|
|
765
|
-
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;
|
|
766
732
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
fieldPath,
|
|
776
|
-
redactedSample: maskSecret(text, pattern.regex),
|
|
777
|
-
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 } : {}
|
|
778
741
|
};
|
|
779
742
|
}
|
|
780
743
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
+
);
|
|
790
759
|
}
|
|
791
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
|
+
}
|
|
792
765
|
}
|
|
793
|
-
|
|
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;
|
|
794
784
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
var TRUST_FILE = path5.join(os2.homedir(), ".node9", "trust.json");
|
|
799
|
-
var LOCAL_AUDIT_LOG = path5.join(os2.homedir(), ".node9", "audit.log");
|
|
800
|
-
var HOOK_DEBUG_LOG = path5.join(os2.homedir(), ".node9", "hook-debug.log");
|
|
801
|
-
function checkPause() {
|
|
785
|
+
function tryLoadConfig(filePath) {
|
|
786
|
+
if (!fs3.existsSync(filePath)) return null;
|
|
787
|
+
let raw;
|
|
802
788
|
try {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
);
|
|
811
823
|
}
|
|
812
|
-
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
813
|
-
} catch {
|
|
814
|
-
return { paused: false };
|
|
815
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;
|
|
816
837
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const tmpPath = `${filePath}.${os2.hostname()}.${process.pid}.tmp`;
|
|
821
|
-
fs3.writeFileSync(tmpPath, data, options);
|
|
822
|
-
fs3.renameSync(tmpPath, filePath);
|
|
823
|
-
}
|
|
838
|
+
|
|
839
|
+
// src/utils/regex.ts
|
|
840
|
+
import safeRegex from "safe-regex2";
|
|
824
841
|
var MAX_REGEX_LENGTH = 100;
|
|
825
842
|
var REGEX_CACHE_MAX = 500;
|
|
826
843
|
var regexCache = /* @__PURE__ */ new Map();
|
|
@@ -867,88 +884,170 @@ function getCompiledRegex(pattern, flags = "") {
|
|
|
867
884
|
return null;
|
|
868
885
|
}
|
|
869
886
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}
|
|
884
|
-
|
|
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;
|
|
885
948
|
try {
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
if (fs3.existsSync(TRUST_FILE)) {
|
|
889
|
-
trust = JSON.parse(fs3.readFileSync(TRUST_FILE, "utf-8"));
|
|
890
|
-
}
|
|
891
|
-
} catch {
|
|
892
|
-
}
|
|
893
|
-
const now = Date.now();
|
|
894
|
-
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
895
|
-
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
896
|
-
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
949
|
+
const absolute = path4.resolve(cwd, filePath);
|
|
950
|
+
resolved = fs4.realpathSync.native(absolute);
|
|
897
951
|
} catch (err) {
|
|
898
|
-
|
|
899
|
-
|
|
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
|
+
};
|
|
900
962
|
}
|
|
901
963
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
+
}
|
|
909
975
|
}
|
|
976
|
+
return null;
|
|
910
977
|
}
|
|
911
|
-
function
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
cwd: process.cwd()
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
924
|
-
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
925
|
-
appendToLog(LOCAL_AUDIT_LOG, {
|
|
926
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
927
|
-
tool: toolName,
|
|
928
|
-
args: safeArgs,
|
|
929
|
-
decision,
|
|
930
|
-
checkedBy,
|
|
931
|
-
agent: meta?.agent,
|
|
932
|
-
mcpServer: meta?.mcpServer,
|
|
933
|
-
hostname: os2.hostname()
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
|
-
function tokenize(toolName) {
|
|
937
|
-
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}`;
|
|
938
987
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
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 });
|
|
1042
|
+
const target = text.toLowerCase();
|
|
944
1043
|
const directMatch = isMatch(target);
|
|
945
1044
|
if (directMatch) return true;
|
|
946
1045
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
947
1046
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
948
1047
|
}
|
|
949
|
-
function getNestedValue(obj,
|
|
1048
|
+
function getNestedValue(obj, path10) {
|
|
950
1049
|
if (!obj || typeof obj !== "object") return null;
|
|
951
|
-
return
|
|
1050
|
+
return path10.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
952
1051
|
}
|
|
953
1052
|
function evaluateSmartConditions(args, rule) {
|
|
954
1053
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -1072,494 +1171,727 @@ async function analyzeShellCommand(command) {
|
|
|
1072
1171
|
}
|
|
1073
1172
|
return { actions, paths, allTokens };
|
|
1074
1173
|
}
|
|
1075
|
-
function
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
+
})
|
|
1085
1245
|
);
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
enableHookLogDebug: true,
|
|
1102
|
-
approvalTimeoutMs: 12e4,
|
|
1103
|
-
// 120-second auto-deny timeout
|
|
1104
|
-
flightRecorder: true,
|
|
1105
|
-
approvers: { native: true, browser: true, cloud: false, terminal: true }
|
|
1106
|
-
},
|
|
1107
|
-
policy: {
|
|
1108
|
-
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
1109
|
-
dangerousWords: DANGEROUS_WORDS,
|
|
1110
|
-
ignoredTools: [
|
|
1111
|
-
"list_*",
|
|
1112
|
-
"get_*",
|
|
1113
|
-
"read_*",
|
|
1114
|
-
"describe_*",
|
|
1115
|
-
"read",
|
|
1116
|
-
"glob",
|
|
1117
|
-
"grep",
|
|
1118
|
-
"ls",
|
|
1119
|
-
"notebookread",
|
|
1120
|
-
"notebookedit",
|
|
1121
|
-
"webfetch",
|
|
1122
|
-
"websearch",
|
|
1123
|
-
"exitplanmode",
|
|
1124
|
-
"askuserquestion",
|
|
1125
|
-
"agent",
|
|
1126
|
-
"task*",
|
|
1127
|
-
"toolsearch",
|
|
1128
|
-
"mcp__ide__*",
|
|
1129
|
-
"getDiagnostics"
|
|
1130
|
-
],
|
|
1131
|
-
toolInspection: {
|
|
1132
|
-
bash: "command",
|
|
1133
|
-
shell: "command",
|
|
1134
|
-
run_shell_command: "command",
|
|
1135
|
-
"terminal.execute": "command",
|
|
1136
|
-
"postgres:query": "sql"
|
|
1137
|
-
},
|
|
1138
|
-
snapshot: {
|
|
1139
|
-
tools: [
|
|
1140
|
-
"str_replace_based_edit_tool",
|
|
1141
|
-
"write_file",
|
|
1142
|
-
"edit_file",
|
|
1143
|
-
"create_file",
|
|
1144
|
-
"edit",
|
|
1145
|
-
"replace"
|
|
1146
|
-
],
|
|
1147
|
-
onlyPaths: [],
|
|
1148
|
-
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
1149
|
-
},
|
|
1150
|
-
smartRules: [
|
|
1151
|
-
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
1152
|
-
{
|
|
1153
|
-
name: "block-rm-rf-home",
|
|
1154
|
-
tool: "bash",
|
|
1155
|
-
conditionMode: "all",
|
|
1156
|
-
conditions: [
|
|
1157
|
-
{
|
|
1158
|
-
field: "command",
|
|
1159
|
-
op: "matches",
|
|
1160
|
-
value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
|
|
1161
|
-
},
|
|
1162
|
-
{
|
|
1163
|
-
field: "command",
|
|
1164
|
-
op: "matches",
|
|
1165
|
-
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
1166
|
-
}
|
|
1167
|
-
],
|
|
1168
|
-
verdict: "block",
|
|
1169
|
-
reason: "Recursive delete of home directory is irreversible"
|
|
1170
|
-
},
|
|
1171
|
-
// ── SQL safety ────────────────────────────────────────────────────────
|
|
1172
|
-
{
|
|
1173
|
-
name: "no-delete-without-where",
|
|
1174
|
-
tool: "*",
|
|
1175
|
-
conditions: [
|
|
1176
|
-
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
1177
|
-
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
1178
|
-
],
|
|
1179
|
-
conditionMode: "all",
|
|
1180
|
-
verdict: "review",
|
|
1181
|
-
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
1182
|
-
},
|
|
1183
|
-
{
|
|
1184
|
-
name: "review-drop-truncate-shell",
|
|
1185
|
-
tool: "bash",
|
|
1186
|
-
conditions: [
|
|
1187
|
-
{
|
|
1188
|
-
field: "command",
|
|
1189
|
-
op: "matches",
|
|
1190
|
-
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
1191
|
-
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 {
|
|
1192
1261
|
}
|
|
1193
|
-
|
|
1194
|
-
conditionMode: "all",
|
|
1195
|
-
verdict: "review",
|
|
1196
|
-
reason: "SQL DDL destructive statement inside a shell command"
|
|
1197
|
-
},
|
|
1198
|
-
// ── Git safety ────────────────────────────────────────────────────────
|
|
1199
|
-
{
|
|
1200
|
-
name: "block-force-push",
|
|
1201
|
-
tool: "bash",
|
|
1202
|
-
conditions: [
|
|
1203
|
-
{
|
|
1204
|
-
field: "command",
|
|
1205
|
-
op: "matches",
|
|
1206
|
-
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
1207
|
-
flags: "i"
|
|
1208
|
-
}
|
|
1209
|
-
],
|
|
1210
|
-
conditionMode: "all",
|
|
1211
|
-
verdict: "block",
|
|
1212
|
-
reason: "Force push overwrites remote history and cannot be undone"
|
|
1213
|
-
},
|
|
1214
|
-
{
|
|
1215
|
-
name: "review-git-push",
|
|
1216
|
-
tool: "bash",
|
|
1217
|
-
conditions: [
|
|
1218
|
-
{
|
|
1219
|
-
field: "command",
|
|
1220
|
-
op: "matches",
|
|
1221
|
-
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
1222
|
-
flags: "i"
|
|
1223
|
-
}
|
|
1224
|
-
],
|
|
1225
|
-
conditionMode: "all",
|
|
1226
|
-
verdict: "review",
|
|
1227
|
-
reason: "git push sends changes to a shared remote"
|
|
1228
|
-
},
|
|
1229
|
-
{
|
|
1230
|
-
name: "review-git-destructive",
|
|
1231
|
-
tool: "bash",
|
|
1232
|
-
conditions: [
|
|
1233
|
-
{
|
|
1234
|
-
field: "command",
|
|
1235
|
-
op: "matches",
|
|
1236
|
-
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
1237
|
-
flags: "i"
|
|
1238
|
-
}
|
|
1239
|
-
],
|
|
1240
|
-
conditionMode: "all",
|
|
1241
|
-
verdict: "review",
|
|
1242
|
-
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
1243
|
-
},
|
|
1244
|
-
// ── Shell safety ──────────────────────────────────────────────────────
|
|
1245
|
-
{
|
|
1246
|
-
name: "review-sudo",
|
|
1247
|
-
tool: "bash",
|
|
1248
|
-
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
1249
|
-
conditionMode: "all",
|
|
1250
|
-
verdict: "review",
|
|
1251
|
-
reason: "Command requires elevated privileges"
|
|
1252
|
-
},
|
|
1253
|
-
{
|
|
1254
|
-
name: "review-curl-pipe-shell",
|
|
1255
|
-
tool: "bash",
|
|
1256
|
-
conditions: [
|
|
1257
|
-
{
|
|
1258
|
-
field: "command",
|
|
1259
|
-
op: "matches",
|
|
1260
|
-
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
1261
|
-
flags: "i"
|
|
1262
|
-
}
|
|
1263
|
-
],
|
|
1264
|
-
conditionMode: "all",
|
|
1265
|
-
verdict: "block",
|
|
1266
|
-
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
1262
|
+
}
|
|
1267
1263
|
}
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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 {
|
|
1289
1299
|
}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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 };
|
|
1328
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"
|
|
1329
1531
|
];
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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);
|
|
1340
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
|
+
};
|
|
1341
1585
|
}
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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" };
|
|
1359
1604
|
}
|
|
1360
1605
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
+
};
|
|
1371
1623
|
}
|
|
1372
|
-
if (
|
|
1373
|
-
|
|
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
|
+
};
|
|
1374
1635
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
+
};
|
|
1381
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 };
|
|
1382
1666
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
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');
|
|
1394
1682
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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("");
|
|
1398
1690
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
+
);
|
|
1702
|
+
}
|
|
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) {
|
|
1404
1715
|
try {
|
|
1405
|
-
|
|
1716
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
1406
1717
|
} catch {
|
|
1407
|
-
return false;
|
|
1408
|
-
}
|
|
1409
|
-
})();
|
|
1410
|
-
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1411
|
-
return hit;
|
|
1412
|
-
})
|
|
1413
|
-
);
|
|
1414
|
-
if (isDangerous) {
|
|
1415
|
-
let matchedField;
|
|
1416
|
-
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1417
|
-
const obj = args;
|
|
1418
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
1419
|
-
if (typeof value === "string") {
|
|
1420
|
-
try {
|
|
1421
|
-
if (new RegExp(
|
|
1422
|
-
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1423
|
-
"i"
|
|
1424
|
-
).test(value)) {
|
|
1425
|
-
matchedField = key;
|
|
1426
|
-
break;
|
|
1427
|
-
}
|
|
1428
|
-
} catch {
|
|
1429
|
-
}
|
|
1430
1718
|
}
|
|
1431
1719
|
}
|
|
1432
|
-
|
|
1433
|
-
return {
|
|
1434
|
-
decision: "review",
|
|
1435
|
-
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1436
|
-
matchedWord: matchedDangerousWord,
|
|
1437
|
-
matchedField,
|
|
1438
|
-
tier: 6
|
|
1720
|
+
resolve("deny");
|
|
1439
1721
|
};
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1445
|
-
}
|
|
1446
|
-
return { decision: "allow" };
|
|
1447
|
-
}
|
|
1448
|
-
function isIgnoredTool(toolName) {
|
|
1449
|
-
const config = getConfig();
|
|
1450
|
-
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1451
|
-
}
|
|
1452
|
-
var DAEMON_PORT = 7391;
|
|
1453
|
-
var DAEMON_HOST = "127.0.0.1";
|
|
1454
|
-
function isDaemonRunning() {
|
|
1455
|
-
const pidFile = path5.join(os2.homedir(), ".node9", "daemon.pid");
|
|
1456
|
-
if (fs3.existsSync(pidFile)) {
|
|
1722
|
+
if (signal) {
|
|
1723
|
+
if (signal.aborted) return resolve("deny");
|
|
1724
|
+
signal.addEventListener("abort", onAbort);
|
|
1725
|
+
}
|
|
1457
1726
|
try {
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
+
});
|
|
1462
1777
|
} catch {
|
|
1463
|
-
|
|
1778
|
+
resolve("deny");
|
|
1464
1779
|
}
|
|
1465
|
-
}
|
|
1466
|
-
try {
|
|
1467
|
-
const r = spawnSync("ss", ["-Htnp", `sport = :${DAEMON_PORT}`], {
|
|
1468
|
-
encoding: "utf8",
|
|
1469
|
-
timeout: 500
|
|
1470
|
-
});
|
|
1471
|
-
return r.status === 0 && (r.stdout ?? "").includes(`:${DAEMON_PORT}`);
|
|
1472
|
-
} catch {
|
|
1473
|
-
return false;
|
|
1474
|
-
}
|
|
1780
|
+
});
|
|
1475
1781
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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
|
+
});
|
|
1486
1806
|
}
|
|
1487
|
-
async function
|
|
1488
|
-
const
|
|
1489
|
-
const
|
|
1490
|
-
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1807
|
+
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
1808
|
+
const controller = new AbortController();
|
|
1809
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1491
1810
|
try {
|
|
1492
|
-
const
|
|
1811
|
+
const response = await fetch(creds.apiUrl, {
|
|
1493
1812
|
method: "POST",
|
|
1494
|
-
headers: { "Content-Type": "application/json" },
|
|
1813
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1495
1814
|
body: JSON.stringify({
|
|
1496
1815
|
toolName,
|
|
1497
1816
|
args,
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
...
|
|
1817
|
+
context: {
|
|
1818
|
+
agent: meta?.agent,
|
|
1819
|
+
mcpServer: meta?.mcpServer,
|
|
1820
|
+
hostname: os6.hostname(),
|
|
1821
|
+
cwd: process.cwd(),
|
|
1822
|
+
platform: os6.platform()
|
|
1823
|
+
},
|
|
1824
|
+
...riskMetadata && { riskMetadata }
|
|
1506
1825
|
}),
|
|
1507
|
-
signal:
|
|
1826
|
+
signal: controller.signal
|
|
1508
1827
|
});
|
|
1509
|
-
if (!
|
|
1510
|
-
|
|
1511
|
-
return id;
|
|
1828
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1829
|
+
return await response.json();
|
|
1512
1830
|
} finally {
|
|
1513
|
-
clearTimeout(
|
|
1831
|
+
clearTimeout(timeout);
|
|
1514
1832
|
}
|
|
1515
1833
|
}
|
|
1516
|
-
async function
|
|
1517
|
-
const
|
|
1518
|
-
const
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
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) {
|
|
1522
1863
|
try {
|
|
1523
|
-
const
|
|
1524
|
-
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
|
1875
|
+
});
|
|
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
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
fs7.appendFileSync(
|
|
1886
|
+
HOOK_DEBUG_LOG,
|
|
1887
|
+
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
1888
|
+
`
|
|
1889
|
+
);
|
|
1532
1890
|
}
|
|
1533
1891
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
method: "POST",
|
|
1538
|
-
headers: { "Content-Type": "application/json" },
|
|
1539
|
-
body: JSON.stringify({
|
|
1540
|
-
toolName,
|
|
1541
|
-
args,
|
|
1542
|
-
slackDelegated: true,
|
|
1543
|
-
agent: meta?.agent,
|
|
1544
|
-
mcpServer: meta?.mcpServer,
|
|
1545
|
-
...riskMetadata && { riskMetadata }
|
|
1546
|
-
}),
|
|
1547
|
-
signal: AbortSignal.timeout(3e3)
|
|
1548
|
-
});
|
|
1549
|
-
if (!res.ok) throw new Error("Daemon unreachable");
|
|
1550
|
-
const { id } = await res.json();
|
|
1551
|
-
return id;
|
|
1552
|
-
}
|
|
1553
|
-
async function resolveViaDaemon(id, decision, internalToken) {
|
|
1554
|
-
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1555
|
-
await fetch(`${base}/resolve/${id}`, {
|
|
1556
|
-
method: "POST",
|
|
1557
|
-
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
1558
|
-
body: JSON.stringify({ decision }),
|
|
1559
|
-
signal: AbortSignal.timeout(3e3)
|
|
1560
|
-
});
|
|
1561
|
-
}
|
|
1562
|
-
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");
|
|
1563
1895
|
function notifyActivity(data) {
|
|
1564
1896
|
return new Promise((resolve) => {
|
|
1565
1897
|
try {
|
|
@@ -1801,415 +2133,114 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
|
1801
2133
|
false,
|
|
1802
2134
|
signal,
|
|
1803
2135
|
policyMatchedField,
|
|
1804
|
-
policyMatchedWord
|
|
1805
|
-
);
|
|
1806
|
-
if (decision === "always_allow") {
|
|
1807
|
-
writeTrustSession(toolName, 36e5);
|
|
1808
|
-
return { approved: true, checkedBy: "trust" };
|
|
1809
|
-
}
|
|
1810
|
-
const isApproved = decision === "allow";
|
|
1811
|
-
return {
|
|
1812
|
-
approved: isApproved,
|
|
1813
|
-
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
1814
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1815
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1816
|
-
blockedByLabel: "User Decision (Native)",
|
|
1817
|
-
decisionSource: "native"
|
|
1818
|
-
};
|
|
1819
|
-
})()
|
|
1820
|
-
);
|
|
1821
|
-
}
|
|
1822
|
-
if (daemonEntryId && (approvers.browser || approvers.terminal)) {
|
|
1823
|
-
racePromises.push(
|
|
1824
|
-
(async () => {
|
|
1825
|
-
const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
|
|
1826
|
-
daemonEntryId,
|
|
1827
|
-
signal
|
|
1828
|
-
);
|
|
1829
|
-
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
1830
|
-
const isApproved = daemonDecision === "allow";
|
|
1831
|
-
const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
|
|
1832
|
-
const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
|
|
1833
|
-
return {
|
|
1834
|
-
approved: isApproved,
|
|
1835
|
-
reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
|
|
1836
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1837
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1838
|
-
blockedByLabel: `User Decision (${via})`,
|
|
1839
|
-
decisionSource: src
|
|
1840
|
-
};
|
|
1841
|
-
})()
|
|
1842
|
-
);
|
|
1843
|
-
}
|
|
1844
|
-
if (racePromises.length === 0) {
|
|
1845
|
-
return {
|
|
1846
|
-
approved: false,
|
|
1847
|
-
noApprovalMechanism: true,
|
|
1848
|
-
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
1849
|
-
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
1850
|
-
blockedBy: "no-approval-mechanism",
|
|
1851
|
-
blockedByLabel: explainableLabel
|
|
1852
|
-
};
|
|
1853
|
-
}
|
|
1854
|
-
const finalResult = await new Promise((resolve) => {
|
|
1855
|
-
let resolved = false;
|
|
1856
|
-
let failures = 0;
|
|
1857
|
-
const total = racePromises.length;
|
|
1858
|
-
const finish = (res) => {
|
|
1859
|
-
if (!resolved) {
|
|
1860
|
-
resolved = true;
|
|
1861
|
-
abortController.abort();
|
|
1862
|
-
if (viewerId && internalToken) {
|
|
1863
|
-
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
1864
|
-
() => null
|
|
1865
|
-
);
|
|
1866
|
-
}
|
|
1867
|
-
resolve(res);
|
|
1868
|
-
}
|
|
1869
|
-
};
|
|
1870
|
-
for (const p of racePromises) {
|
|
1871
|
-
p.then(finish).catch((err) => {
|
|
1872
|
-
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
1873
|
-
return;
|
|
1874
|
-
if (err.message === "Abandoned") {
|
|
1875
|
-
finish({
|
|
1876
|
-
approved: false,
|
|
1877
|
-
reason: "Browser dashboard closed without making a decision.",
|
|
1878
|
-
blockedBy: "local-decision",
|
|
1879
|
-
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
1880
|
-
});
|
|
1881
|
-
return;
|
|
1882
|
-
}
|
|
1883
|
-
failures++;
|
|
1884
|
-
if (failures === total && !resolved) {
|
|
1885
|
-
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
1886
|
-
}
|
|
1887
|
-
});
|
|
1888
|
-
}
|
|
1889
|
-
});
|
|
1890
|
-
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
1891
|
-
await resolveNode9SaaS(
|
|
1892
|
-
cloudRequestId,
|
|
1893
|
-
creds,
|
|
1894
|
-
finalResult.approved,
|
|
1895
|
-
finalResult.decisionSource ?? finalResult.checkedBy ?? "local"
|
|
1896
|
-
);
|
|
1897
|
-
}
|
|
1898
|
-
if (!isManual) {
|
|
1899
|
-
appendLocalAudit(
|
|
1900
|
-
toolName,
|
|
1901
|
-
args,
|
|
1902
|
-
finalResult.approved ? "allow" : "deny",
|
|
1903
|
-
finalResult.checkedBy || finalResult.blockedBy || "unknown",
|
|
1904
|
-
meta
|
|
1905
|
-
);
|
|
1906
|
-
}
|
|
1907
|
-
return finalResult;
|
|
1908
|
-
}
|
|
1909
|
-
function getConfig(cwd) {
|
|
1910
|
-
if (!cwd && cachedConfig) return cachedConfig;
|
|
1911
|
-
const globalPath = path5.join(os2.homedir(), ".node9", "config.json");
|
|
1912
|
-
const projectPath = path5.join(cwd ?? process.cwd(), "node9.config.json");
|
|
1913
|
-
const globalConfig = tryLoadConfig(globalPath);
|
|
1914
|
-
const projectConfig = tryLoadConfig(projectPath);
|
|
1915
|
-
const mergedSettings = {
|
|
1916
|
-
...DEFAULT_CONFIG.settings,
|
|
1917
|
-
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
1918
|
-
};
|
|
1919
|
-
const mergedPolicy = {
|
|
1920
|
-
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
1921
|
-
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
1922
|
-
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
1923
|
-
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
1924
|
-
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
1925
|
-
snapshot: {
|
|
1926
|
-
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
1927
|
-
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
1928
|
-
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
1929
|
-
},
|
|
1930
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
1931
|
-
};
|
|
1932
|
-
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
1933
|
-
const applyLayer = (source) => {
|
|
1934
|
-
if (!source) return;
|
|
1935
|
-
const s = source.settings || {};
|
|
1936
|
-
const p = source.policy || {};
|
|
1937
|
-
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
1938
|
-
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
1939
|
-
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
1940
|
-
if (s.enableHookLogDebug !== void 0)
|
|
1941
|
-
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
1942
|
-
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
1943
|
-
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
1944
|
-
if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
|
|
1945
|
-
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
1946
|
-
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
1947
|
-
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
1948
|
-
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
1949
|
-
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
1950
|
-
if (p.toolInspection)
|
|
1951
|
-
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
1952
|
-
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
1953
|
-
if (p.snapshot) {
|
|
1954
|
-
const s2 = p.snapshot;
|
|
1955
|
-
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
1956
|
-
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
1957
|
-
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
1958
|
-
}
|
|
1959
|
-
if (p.dlp) {
|
|
1960
|
-
const d = p.dlp;
|
|
1961
|
-
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
1962
|
-
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
1963
|
-
}
|
|
1964
|
-
const envs = source.environments || {};
|
|
1965
|
-
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
1966
|
-
if (envConfig && typeof envConfig === "object") {
|
|
1967
|
-
const ec = envConfig;
|
|
1968
|
-
mergedEnvironments[envName] = {
|
|
1969
|
-
...mergedEnvironments[envName],
|
|
1970
|
-
// Validate field types before merging — do not blindly spread user input
|
|
1971
|
-
...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
|
|
1972
|
-
};
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
};
|
|
1976
|
-
applyLayer(globalConfig);
|
|
1977
|
-
applyLayer(projectConfig);
|
|
1978
|
-
const shieldOverrides = readShieldOverrides();
|
|
1979
|
-
for (const shieldName of readActiveShields()) {
|
|
1980
|
-
const shield = getShield(shieldName);
|
|
1981
|
-
if (!shield) continue;
|
|
1982
|
-
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
1983
|
-
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
1984
|
-
for (const rule of shield.smartRules) {
|
|
1985
|
-
if (!existingRuleNames.has(rule.name)) {
|
|
1986
|
-
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
1987
|
-
mergedPolicy.smartRules.push(
|
|
1988
|
-
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
1989
|
-
);
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
1993
|
-
for (const word of shield.dangerousWords) {
|
|
1994
|
-
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
1995
|
-
}
|
|
1996
|
-
}
|
|
1997
|
-
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
1998
|
-
for (const rule of ADVISORY_SMART_RULES) {
|
|
1999
|
-
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
2000
|
-
}
|
|
2001
|
-
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
2002
|
-
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
2003
|
-
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
2004
|
-
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
2005
|
-
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
2006
|
-
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
2007
|
-
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
2008
|
-
const result = {
|
|
2009
|
-
settings: mergedSettings,
|
|
2010
|
-
policy: mergedPolicy,
|
|
2011
|
-
environments: mergedEnvironments
|
|
2012
|
-
};
|
|
2013
|
-
if (!cwd) cachedConfig = result;
|
|
2014
|
-
return result;
|
|
2015
|
-
}
|
|
2016
|
-
function tryLoadConfig(filePath) {
|
|
2017
|
-
if (!fs3.existsSync(filePath)) return null;
|
|
2018
|
-
let raw;
|
|
2019
|
-
try {
|
|
2020
|
-
raw = JSON.parse(fs3.readFileSync(filePath, "utf-8"));
|
|
2021
|
-
} catch (err) {
|
|
2022
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2023
|
-
process.stderr.write(
|
|
2024
|
-
`
|
|
2025
|
-
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
2026
|
-
${msg}
|
|
2027
|
-
\u2192 Using default config
|
|
2028
|
-
|
|
2029
|
-
`
|
|
2136
|
+
policyMatchedWord
|
|
2137
|
+
);
|
|
2138
|
+
if (decision === "always_allow") {
|
|
2139
|
+
writeTrustSession(toolName, 36e5);
|
|
2140
|
+
return { approved: true, checkedBy: "trust" };
|
|
2141
|
+
}
|
|
2142
|
+
const isApproved = decision === "allow";
|
|
2143
|
+
return {
|
|
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"
|
|
2150
|
+
};
|
|
2151
|
+
})()
|
|
2030
2152
|
);
|
|
2031
|
-
return null;
|
|
2032
|
-
}
|
|
2033
|
-
const SUPPORTED_VERSION = "1.0";
|
|
2034
|
-
const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
|
|
2035
|
-
const fileVersion = raw?.version;
|
|
2036
|
-
if (fileVersion !== void 0) {
|
|
2037
|
-
const vStr = String(fileVersion);
|
|
2038
|
-
const fileMajor = vStr.split(".")[0];
|
|
2039
|
-
if (fileMajor !== SUPPORTED_MAJOR) {
|
|
2040
|
-
process.stderr.write(
|
|
2041
|
-
`
|
|
2042
|
-
\u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
|
|
2043
|
-
|
|
2044
|
-
`
|
|
2045
|
-
);
|
|
2046
|
-
return null;
|
|
2047
|
-
} else if (vStr !== SUPPORTED_VERSION) {
|
|
2048
|
-
process.stderr.write(
|
|
2049
|
-
`
|
|
2050
|
-
\u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
|
|
2051
|
-
|
|
2052
|
-
`
|
|
2053
|
-
);
|
|
2054
|
-
}
|
|
2055
2153
|
}
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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";
|
|
2165
|
+
return {
|
|
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
|
|
2172
|
+
};
|
|
2173
|
+
})()
|
|
2065
2174
|
);
|
|
2066
2175
|
}
|
|
2067
|
-
|
|
2068
|
-
}
|
|
2069
|
-
function getActiveEnvironment(config) {
|
|
2070
|
-
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
2071
|
-
return config.environments[env] ?? null;
|
|
2072
|
-
}
|
|
2073
|
-
function getCredentials() {
|
|
2074
|
-
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
2075
|
-
if (process.env.NODE9_API_KEY) {
|
|
2176
|
+
if (racePromises.length === 0) {
|
|
2076
2177
|
return {
|
|
2077
|
-
|
|
2078
|
-
|
|
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
|
|
2079
2184
|
};
|
|
2080
2185
|
}
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
apiKey: creds.apiKey,
|
|
2096
|
-
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
2097
|
-
};
|
|
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);
|
|
2098
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
|
+
});
|
|
2099
2220
|
}
|
|
2100
|
-
}
|
|
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
|
+
);
|
|
2101
2229
|
}
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
async function authorizeAction(toolName, args) {
|
|
2105
|
-
const result = await authorizeHeadless(toolName, args);
|
|
2106
|
-
return result.approved;
|
|
2107
|
-
}
|
|
2108
|
-
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
2109
|
-
return fetch(`${creds.apiUrl}/audit`, {
|
|
2110
|
-
method: "POST",
|
|
2111
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2112
|
-
body: JSON.stringify({
|
|
2230
|
+
if (!isManual) {
|
|
2231
|
+
appendLocalAudit(
|
|
2113
2232
|
toolName,
|
|
2114
2233
|
args,
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
mcpServer: meta?.mcpServer,
|
|
2119
|
-
hostname: os2.hostname(),
|
|
2120
|
-
cwd: process.cwd(),
|
|
2121
|
-
platform: os2.platform()
|
|
2122
|
-
}
|
|
2123
|
-
}),
|
|
2124
|
-
signal: AbortSignal.timeout(5e3)
|
|
2125
|
-
}).then(() => {
|
|
2126
|
-
}).catch(() => {
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
2130
|
-
const controller = new AbortController();
|
|
2131
|
-
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2132
|
-
try {
|
|
2133
|
-
const response = await fetch(creds.apiUrl, {
|
|
2134
|
-
method: "POST",
|
|
2135
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2136
|
-
body: JSON.stringify({
|
|
2137
|
-
toolName,
|
|
2138
|
-
args,
|
|
2139
|
-
context: {
|
|
2140
|
-
agent: meta?.agent,
|
|
2141
|
-
mcpServer: meta?.mcpServer,
|
|
2142
|
-
hostname: os2.hostname(),
|
|
2143
|
-
cwd: process.cwd(),
|
|
2144
|
-
platform: os2.platform()
|
|
2145
|
-
},
|
|
2146
|
-
...riskMetadata && { riskMetadata }
|
|
2147
|
-
}),
|
|
2148
|
-
signal: controller.signal
|
|
2149
|
-
});
|
|
2150
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2151
|
-
return await response.json();
|
|
2152
|
-
} finally {
|
|
2153
|
-
clearTimeout(timeout);
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
async function pollNode9SaaS(requestId, creds, signal) {
|
|
2157
|
-
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
2158
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
2159
|
-
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
2160
|
-
while (Date.now() < POLL_DEADLINE) {
|
|
2161
|
-
if (signal.aborted) throw new Error("Aborted");
|
|
2162
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
2163
|
-
try {
|
|
2164
|
-
const pollCtrl = new AbortController();
|
|
2165
|
-
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
2166
|
-
const statusRes = await fetch(statusUrl, {
|
|
2167
|
-
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
2168
|
-
signal: pollCtrl.signal
|
|
2169
|
-
});
|
|
2170
|
-
clearTimeout(pollTimer);
|
|
2171
|
-
if (!statusRes.ok) continue;
|
|
2172
|
-
const { status, reason } = await statusRes.json();
|
|
2173
|
-
if (status === "APPROVED") {
|
|
2174
|
-
return { approved: true, reason };
|
|
2175
|
-
}
|
|
2176
|
-
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
2177
|
-
return { approved: false, reason };
|
|
2178
|
-
}
|
|
2179
|
-
} catch {
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
2183
|
-
}
|
|
2184
|
-
async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
2185
|
-
try {
|
|
2186
|
-
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
2187
|
-
const ctrl = new AbortController();
|
|
2188
|
-
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
2189
|
-
const res = await fetch(resolveUrl, {
|
|
2190
|
-
method: "PATCH",
|
|
2191
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2192
|
-
body: JSON.stringify({
|
|
2193
|
-
decision: approved ? "APPROVED" : "DENIED",
|
|
2194
|
-
...decidedBy && { decidedBy }
|
|
2195
|
-
}),
|
|
2196
|
-
signal: ctrl.signal
|
|
2197
|
-
});
|
|
2198
|
-
clearTimeout(timer);
|
|
2199
|
-
if (!res.ok) {
|
|
2200
|
-
fs3.appendFileSync(
|
|
2201
|
-
HOOK_DEBUG_LOG,
|
|
2202
|
-
`[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
|
|
2203
|
-
`
|
|
2204
|
-
);
|
|
2205
|
-
}
|
|
2206
|
-
} catch (err) {
|
|
2207
|
-
fs3.appendFileSync(
|
|
2208
|
-
HOOK_DEBUG_LOG,
|
|
2209
|
-
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
2210
|
-
`
|
|
2234
|
+
finalResult.approved ? "allow" : "deny",
|
|
2235
|
+
finalResult.checkedBy || finalResult.blockedBy || "unknown",
|
|
2236
|
+
meta
|
|
2211
2237
|
);
|
|
2212
2238
|
}
|
|
2239
|
+
return finalResult;
|
|
2240
|
+
}
|
|
2241
|
+
async function authorizeAction(toolName, args) {
|
|
2242
|
+
const result = await authorizeHeadless(toolName, args);
|
|
2243
|
+
return result.approved;
|
|
2213
2244
|
}
|
|
2214
2245
|
|
|
2215
2246
|
// src/index.ts
|