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