@node9/proxy 1.42.0 → 1.44.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/dist/cli.js +9770 -9465
- package/dist/cli.mjs +9803 -9501
- package/dist/dashboard.mjs +10 -1
- package/dist/index.js +136 -9
- package/dist/index.mjs +136 -9
- package/package.json +1 -1
package/dist/dashboard.mjs
CHANGED
|
@@ -1264,7 +1264,12 @@ var init_dist = __esm({
|
|
|
1264
1264
|
// SQL-DDL is now owned by the AST detector (analyzeSqlDestructive) so the
|
|
1265
1265
|
// raw-regex smart rule is suppressed for bash — its cond1 read a grep
|
|
1266
1266
|
// alternation's `|` as a shell pipe (`grep "…|mysql…"` → false positive).
|
|
1267
|
-
"review-drop-truncate-shell"
|
|
1267
|
+
"review-drop-truncate-shell",
|
|
1268
|
+
// chmod 777 is now owned by the AST detector (analyzeChmod777) so the raw-
|
|
1269
|
+
// regex smart rule is suppressed for bash — it matched `chmod 777` inside a
|
|
1270
|
+
// `node -e` / `python -c` string literal (a detection pattern, not a run
|
|
1271
|
+
// command) → false positive.
|
|
1272
|
+
"shield:filesystem:review-chmod-777"
|
|
1268
1273
|
]);
|
|
1269
1274
|
FS_OP_CACHE_MAX = 5e3;
|
|
1270
1275
|
fsOpCache = /* @__PURE__ */ new Map();
|
|
@@ -2270,6 +2275,10 @@ var init_config_schema = __esm({
|
|
|
2270
2275
|
// node9's own approver (terminal/native/cloud). Unset → smart default
|
|
2271
2276
|
// (ask for ask-capable agents unless a cloud approver is configured).
|
|
2272
2277
|
reviewChannel: z.enum(["ask", "approver"]).optional(),
|
|
2278
|
+
// When true, agents may call WEAKENING node9 MCP tools (shield_disable,
|
|
2279
|
+
// approver_set). Default (unset/false): those tools refuse over MCP — a human
|
|
2280
|
+
// must run them from the CLI. node9's threat model is the agent itself.
|
|
2281
|
+
mcpAllowWeakening: z.boolean().optional(),
|
|
2273
2282
|
cloudSyncIntervalHours: z.number().positive().optional(),
|
|
2274
2283
|
// Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
|
|
2275
2284
|
// to true; set false to fall back to local-only auditing.
|
package/dist/index.js
CHANGED
|
@@ -75,6 +75,7 @@ __export(audit_exports, {
|
|
|
75
75
|
appendLocalAudit: () => appendLocalAudit,
|
|
76
76
|
appendToLog: () => appendToLog,
|
|
77
77
|
buildArgsPreview: () => buildArgsPreview,
|
|
78
|
+
filePathFromArgs: () => filePathFromArgs,
|
|
78
79
|
generateEventId: () => generateEventId,
|
|
79
80
|
redactSecrets: () => redactSecrets
|
|
80
81
|
});
|
|
@@ -131,6 +132,25 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
|
|
|
131
132
|
cwd: process.cwd()
|
|
132
133
|
});
|
|
133
134
|
}
|
|
135
|
+
function filePathFromArgs(args) {
|
|
136
|
+
let obj = args;
|
|
137
|
+
if (typeof obj === "string") {
|
|
138
|
+
const t = obj.trim();
|
|
139
|
+
if (!t.startsWith("{") || !t.endsWith("}")) return void 0;
|
|
140
|
+
if (!t.includes("file_path") && !t.includes("notebook_path")) return void 0;
|
|
141
|
+
try {
|
|
142
|
+
obj = JSON.parse(t);
|
|
143
|
+
} catch {
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
148
|
+
const o = obj;
|
|
149
|
+
const v = o.file_path ?? o.notebook_path;
|
|
150
|
+
if (typeof v === "string" && v) return v.slice(0, 1024);
|
|
151
|
+
}
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
134
154
|
function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
|
|
135
155
|
const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern);
|
|
136
156
|
const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
|
|
@@ -143,10 +163,19 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
143
163
|
const workingDirField = meta?.workingDir ? { workingDir: meta.workingDir } : {};
|
|
144
164
|
const shell = process.env.SHELL ? import_path.default.basename(process.env.SHELL) : void 0;
|
|
145
165
|
const shellTypeField = shell ? { shellType: shell } : {};
|
|
166
|
+
const editFilePath = filePathFromArgs(args);
|
|
167
|
+
const editFilePathField = editFilePath ? { editFilePath } : {};
|
|
168
|
+
const loopCountField = typeof meta?.loopCount === "number" ? { loopCount: meta.loopCount } : {};
|
|
169
|
+
const transcriptPathField = meta?.transcriptPath ? { transcriptPath: meta.transcriptPath } : {};
|
|
170
|
+
const taintFields = meta?.taintFromEid ? {
|
|
171
|
+
taintFromEid: meta.taintFromEid,
|
|
172
|
+
...meta.taintSource && { taintSource: meta.taintSource }
|
|
173
|
+
} : {};
|
|
174
|
+
const eid = generateEventId();
|
|
146
175
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
147
176
|
// eid first: the outbox shipper dedups on it, and a fixed leading field
|
|
148
177
|
// makes the JSONL easy to eyeball.
|
|
149
|
-
eid
|
|
178
|
+
eid,
|
|
150
179
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
151
180
|
tool: toolName,
|
|
152
181
|
...agentToolNameField,
|
|
@@ -158,6 +187,10 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
158
187
|
...cloudLinkField,
|
|
159
188
|
...workingDirField,
|
|
160
189
|
...shellTypeField,
|
|
190
|
+
...editFilePathField,
|
|
191
|
+
...loopCountField,
|
|
192
|
+
...transcriptPathField,
|
|
193
|
+
...taintFields,
|
|
161
194
|
...testRun,
|
|
162
195
|
agent: meta?.agent,
|
|
163
196
|
mcpServer: meta?.mcpServer,
|
|
@@ -165,6 +198,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
165
198
|
hostname: import_os.default.hostname(),
|
|
166
199
|
platform: import_os.default.platform()
|
|
167
200
|
});
|
|
201
|
+
return eid;
|
|
168
202
|
}
|
|
169
203
|
function appendConfigAudit(entry) {
|
|
170
204
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
@@ -283,6 +317,10 @@ var ConfigFileSchema = import_zod.z.object({
|
|
|
283
317
|
// node9's own approver (terminal/native/cloud). Unset → smart default
|
|
284
318
|
// (ask for ask-capable agents unless a cloud approver is configured).
|
|
285
319
|
reviewChannel: import_zod.z.enum(["ask", "approver"]).optional(),
|
|
320
|
+
// When true, agents may call WEAKENING node9 MCP tools (shield_disable,
|
|
321
|
+
// approver_set). Default (unset/false): those tools refuse over MCP — a human
|
|
322
|
+
// must run them from the CLI. node9's threat model is the agent itself.
|
|
323
|
+
mcpAllowWeakening: import_zod.z.boolean().optional(),
|
|
286
324
|
cloudSyncIntervalHours: import_zod.z.number().positive().optional(),
|
|
287
325
|
// Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
|
|
288
326
|
// to true; set false to fall back to local-only auditing.
|
|
@@ -1274,7 +1312,12 @@ var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
|
1274
1312
|
// SQL-DDL is now owned by the AST detector (analyzeSqlDestructive) so the
|
|
1275
1313
|
// raw-regex smart rule is suppressed for bash — its cond1 read a grep
|
|
1276
1314
|
// alternation's `|` as a shell pipe (`grep "…|mysql…"` → false positive).
|
|
1277
|
-
"review-drop-truncate-shell"
|
|
1315
|
+
"review-drop-truncate-shell",
|
|
1316
|
+
// chmod 777 is now owned by the AST detector (analyzeChmod777) so the raw-
|
|
1317
|
+
// regex smart rule is suppressed for bash — it matched `chmod 777` inside a
|
|
1318
|
+
// `node -e` / `python -c` string literal (a detection pattern, not a run
|
|
1319
|
+
// command) → false positive.
|
|
1320
|
+
"shield:filesystem:review-chmod-777"
|
|
1278
1321
|
]);
|
|
1279
1322
|
var SQL_DB_CLIS = /* @__PURE__ */ new Set([
|
|
1280
1323
|
"psql",
|
|
@@ -1299,6 +1342,62 @@ function analyzeSqlDestructive(command) {
|
|
|
1299
1342
|
description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
|
|
1300
1343
|
};
|
|
1301
1344
|
}
|
|
1345
|
+
var CHMOD_OPEN_PERM_TOKENS = /* @__PURE__ */ new Set(["777", "0777", "a+rwx", "+x"]);
|
|
1346
|
+
var COMMAND_WRAPPERS = /* @__PURE__ */ new Set([
|
|
1347
|
+
"sudo",
|
|
1348
|
+
"doas",
|
|
1349
|
+
"env",
|
|
1350
|
+
"xargs",
|
|
1351
|
+
"time",
|
|
1352
|
+
"nice",
|
|
1353
|
+
"ionice",
|
|
1354
|
+
"nohup",
|
|
1355
|
+
"setsid",
|
|
1356
|
+
"stdbuf",
|
|
1357
|
+
"timeout",
|
|
1358
|
+
"command",
|
|
1359
|
+
"exec"
|
|
1360
|
+
]);
|
|
1361
|
+
function chmodHasOpenPermMode(command) {
|
|
1362
|
+
const f = parseShared(command);
|
|
1363
|
+
if (f === PARSE_FAIL) return false;
|
|
1364
|
+
let found = false;
|
|
1365
|
+
try {
|
|
1366
|
+
syntax.Walk(f, (node) => {
|
|
1367
|
+
if (!node || found) return false;
|
|
1368
|
+
const n = node;
|
|
1369
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1370
|
+
const words = (n.Args || []).map((a) => resolveWordLiteral(a));
|
|
1371
|
+
if (words.length === 0) return true;
|
|
1372
|
+
const name = (words[0] ?? "").toLowerCase();
|
|
1373
|
+
let idx = -1;
|
|
1374
|
+
if (name === "chmod") idx = 0;
|
|
1375
|
+
else if (COMMAND_WRAPPERS.has(name))
|
|
1376
|
+
idx = words.findIndex((w, i) => i > 0 && w?.toLowerCase() === "chmod");
|
|
1377
|
+
if (idx < 0) return true;
|
|
1378
|
+
for (let i = idx + 1; i < words.length; i++) {
|
|
1379
|
+
const w = words[i];
|
|
1380
|
+
if (w !== null && w.startsWith("-")) continue;
|
|
1381
|
+
if (w !== null && CHMOD_OPEN_PERM_TOKENS.has(w.toLowerCase())) found = true;
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
return true;
|
|
1385
|
+
});
|
|
1386
|
+
} catch {
|
|
1387
|
+
return found;
|
|
1388
|
+
}
|
|
1389
|
+
return found;
|
|
1390
|
+
}
|
|
1391
|
+
function analyzeChmod777(command) {
|
|
1392
|
+
if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
|
|
1393
|
+
if (!chmodHasOpenPermMode(command)) return null;
|
|
1394
|
+
return {
|
|
1395
|
+
ruleName: "shield:filesystem:review-chmod-777",
|
|
1396
|
+
verdict: "review",
|
|
1397
|
+
reason: "chmod 777 requires human approval (filesystem shield)",
|
|
1398
|
+
description: "The AI wants to make a file world-writable/executable (chmod 777). This removes the permission protection on the file so any user or process can modify or run it."
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1302
1401
|
function isProtectedHomePath(rawPath) {
|
|
1303
1402
|
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
1304
1403
|
let underHome = false;
|
|
@@ -2264,6 +2363,17 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2264
2363
|
ruleDescription: sqlVerdict.description
|
|
2265
2364
|
};
|
|
2266
2365
|
}
|
|
2366
|
+
const chmodVerdict = analyzeChmod777(bashCommand);
|
|
2367
|
+
if (chmodVerdict) {
|
|
2368
|
+
return {
|
|
2369
|
+
decision: chmodVerdict.verdict,
|
|
2370
|
+
blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
|
|
2371
|
+
reason: chmodVerdict.reason,
|
|
2372
|
+
tier: 2,
|
|
2373
|
+
ruleName: chmodVerdict.ruleName,
|
|
2374
|
+
ruleDescription: chmodVerdict.description
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2267
2377
|
}
|
|
2268
2378
|
if (config.policy.smartRules.length > 0) {
|
|
2269
2379
|
const matchedRule = config.policy.smartRules.find(
|
|
@@ -3737,6 +3847,7 @@ function getConfig(cwd) {
|
|
|
3737
3847
|
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
3738
3848
|
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
3739
3849
|
if (s.reviewChannel !== void 0) mergedSettings.reviewChannel = s.reviewChannel;
|
|
3850
|
+
if (s.mcpAllowWeakening !== void 0) mergedSettings.mcpAllowWeakening = s.mcpAllowWeakening;
|
|
3740
3851
|
if (s.cloudSyncIntervalHours !== void 0)
|
|
3741
3852
|
mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
|
|
3742
3853
|
if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
|
|
@@ -4365,14 +4476,15 @@ async function notifyDaemonViewer(toolName, args, meta, riskMetadata, activityId
|
|
|
4365
4476
|
const { id, allowCount } = await res.json();
|
|
4366
4477
|
return { id, allowCount: allowCount ?? 1 };
|
|
4367
4478
|
}
|
|
4368
|
-
async function notifyTaint(filePath, source) {
|
|
4479
|
+
async function notifyTaint(filePath, source, fromEid) {
|
|
4369
4480
|
if (!isDaemonRunning()) return;
|
|
4370
4481
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
4371
4482
|
try {
|
|
4372
4483
|
await fetch(`${base}/taint`, {
|
|
4373
4484
|
method: "POST",
|
|
4374
4485
|
headers: { "Content-Type": "application/json" },
|
|
4375
|
-
|
|
4486
|
+
// Phase D2 — fromEid is the audit eid that created this taint (edge source).
|
|
4487
|
+
body: JSON.stringify({ path: filePath, source, ...fromEid && { fromEid } }),
|
|
4376
4488
|
signal: AbortSignal.timeout(1e3)
|
|
4377
4489
|
});
|
|
4378
4490
|
} catch {
|
|
@@ -5060,7 +5172,11 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5060
5172
|
if (filePaths.length > 0) {
|
|
5061
5173
|
const taintResult = await checkTaint(filePaths);
|
|
5062
5174
|
if (taintResult.tainted && taintResult.record) {
|
|
5063
|
-
const {
|
|
5175
|
+
const {
|
|
5176
|
+
path: taintedPath,
|
|
5177
|
+
source: taintSource,
|
|
5178
|
+
fromEid: taintFromEid
|
|
5179
|
+
} = taintResult.record;
|
|
5064
5180
|
taintWarning = `\u26A0\uFE0F ${taintedPath} was flagged by ${taintSource} \u2014 this file may contain sensitive data`;
|
|
5065
5181
|
if (config.policy.egress?.enabled) {
|
|
5066
5182
|
const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
@@ -5074,7 +5190,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5074
5190
|
args,
|
|
5075
5191
|
"deny",
|
|
5076
5192
|
isObserveMode ? "observe-mode-taint-egress-would-block" : "taint-egress-block",
|
|
5077
|
-
|
|
5193
|
+
// Phase D2 — the causal taint edge: from the row that created
|
|
5194
|
+
// the taint (taintFromEid) to this block row.
|
|
5195
|
+
{ ...meta, ruleName: `taint-egress:${eg.host}`, taintFromEid, taintSource },
|
|
5078
5196
|
hashAuditArgs
|
|
5079
5197
|
);
|
|
5080
5198
|
if (isObserveMode) {
|
|
@@ -5111,8 +5229,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5111
5229
|
if (dlpMatch) {
|
|
5112
5230
|
const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
|
|
5113
5231
|
if (dlpMatch.severity === "block") {
|
|
5232
|
+
let dlpEid;
|
|
5114
5233
|
if (!isManual)
|
|
5115
|
-
appendLocalAudit(
|
|
5234
|
+
dlpEid = appendLocalAudit(
|
|
5116
5235
|
toolName,
|
|
5117
5236
|
args,
|
|
5118
5237
|
"deny",
|
|
@@ -5125,7 +5244,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5125
5244
|
true
|
|
5126
5245
|
);
|
|
5127
5246
|
if (isWriteTool(toolName) && filePath) {
|
|
5128
|
-
await notifyTaint(filePath, `DLP:${dlpMatch.patternName}
|
|
5247
|
+
await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`, dlpEid);
|
|
5129
5248
|
}
|
|
5130
5249
|
if (isObserveMode) {
|
|
5131
5250
|
return {
|
|
@@ -5217,7 +5336,15 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5217
5336
|
if (loopResult.looping) {
|
|
5218
5337
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|
|
5219
5338
|
if (!isManual)
|
|
5220
|
-
appendLocalAudit(
|
|
5339
|
+
appendLocalAudit(
|
|
5340
|
+
toolName,
|
|
5341
|
+
args,
|
|
5342
|
+
"deny",
|
|
5343
|
+
"loop-detected",
|
|
5344
|
+
// Phase B — carry the loop magnitude so the SaaS shows "looped Nx".
|
|
5345
|
+
{ ...meta, loopCount: loopResult.count },
|
|
5346
|
+
hashAuditArgs
|
|
5347
|
+
);
|
|
5221
5348
|
return {
|
|
5222
5349
|
approved: false,
|
|
5223
5350
|
reason,
|
package/dist/index.mjs
CHANGED
|
@@ -52,6 +52,7 @@ __export(audit_exports, {
|
|
|
52
52
|
appendLocalAudit: () => appendLocalAudit,
|
|
53
53
|
appendToLog: () => appendToLog,
|
|
54
54
|
buildArgsPreview: () => buildArgsPreview,
|
|
55
|
+
filePathFromArgs: () => filePathFromArgs,
|
|
55
56
|
generateEventId: () => generateEventId,
|
|
56
57
|
redactSecrets: () => redactSecrets
|
|
57
58
|
});
|
|
@@ -112,6 +113,25 @@ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
|
|
|
112
113
|
cwd: process.cwd()
|
|
113
114
|
});
|
|
114
115
|
}
|
|
116
|
+
function filePathFromArgs(args) {
|
|
117
|
+
let obj = args;
|
|
118
|
+
if (typeof obj === "string") {
|
|
119
|
+
const t = obj.trim();
|
|
120
|
+
if (!t.startsWith("{") || !t.endsWith("}")) return void 0;
|
|
121
|
+
if (!t.includes("file_path") && !t.includes("notebook_path")) return void 0;
|
|
122
|
+
try {
|
|
123
|
+
obj = JSON.parse(t);
|
|
124
|
+
} catch {
|
|
125
|
+
return void 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
129
|
+
const o = obj;
|
|
130
|
+
const v = o.file_path ?? o.notebook_path;
|
|
131
|
+
if (typeof v === "string" && v) return v.slice(0, 1024);
|
|
132
|
+
}
|
|
133
|
+
return void 0;
|
|
134
|
+
}
|
|
115
135
|
function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
|
|
116
136
|
const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern);
|
|
117
137
|
const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
|
|
@@ -124,10 +144,19 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
124
144
|
const workingDirField = meta?.workingDir ? { workingDir: meta.workingDir } : {};
|
|
125
145
|
const shell = process.env.SHELL ? path.basename(process.env.SHELL) : void 0;
|
|
126
146
|
const shellTypeField = shell ? { shellType: shell } : {};
|
|
147
|
+
const editFilePath = filePathFromArgs(args);
|
|
148
|
+
const editFilePathField = editFilePath ? { editFilePath } : {};
|
|
149
|
+
const loopCountField = typeof meta?.loopCount === "number" ? { loopCount: meta.loopCount } : {};
|
|
150
|
+
const transcriptPathField = meta?.transcriptPath ? { transcriptPath: meta.transcriptPath } : {};
|
|
151
|
+
const taintFields = meta?.taintFromEid ? {
|
|
152
|
+
taintFromEid: meta.taintFromEid,
|
|
153
|
+
...meta.taintSource && { taintSource: meta.taintSource }
|
|
154
|
+
} : {};
|
|
155
|
+
const eid = generateEventId();
|
|
127
156
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
128
157
|
// eid first: the outbox shipper dedups on it, and a fixed leading field
|
|
129
158
|
// makes the JSONL easy to eyeball.
|
|
130
|
-
eid
|
|
159
|
+
eid,
|
|
131
160
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
132
161
|
tool: toolName,
|
|
133
162
|
...agentToolNameField,
|
|
@@ -139,6 +168,10 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
139
168
|
...cloudLinkField,
|
|
140
169
|
...workingDirField,
|
|
141
170
|
...shellTypeField,
|
|
171
|
+
...editFilePathField,
|
|
172
|
+
...loopCountField,
|
|
173
|
+
...transcriptPathField,
|
|
174
|
+
...taintFields,
|
|
142
175
|
...testRun,
|
|
143
176
|
agent: meta?.agent,
|
|
144
177
|
mcpServer: meta?.mcpServer,
|
|
@@ -146,6 +179,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
146
179
|
hostname: os.hostname(),
|
|
147
180
|
platform: os.platform()
|
|
148
181
|
});
|
|
182
|
+
return eid;
|
|
149
183
|
}
|
|
150
184
|
function appendConfigAudit(entry) {
|
|
151
185
|
appendToLog(LOCAL_AUDIT_LOG, {
|
|
@@ -253,6 +287,10 @@ var ConfigFileSchema = z.object({
|
|
|
253
287
|
// node9's own approver (terminal/native/cloud). Unset → smart default
|
|
254
288
|
// (ask for ask-capable agents unless a cloud approver is configured).
|
|
255
289
|
reviewChannel: z.enum(["ask", "approver"]).optional(),
|
|
290
|
+
// When true, agents may call WEAKENING node9 MCP tools (shield_disable,
|
|
291
|
+
// approver_set). Default (unset/false): those tools refuse over MCP — a human
|
|
292
|
+
// must run them from the CLI. node9's threat model is the agent itself.
|
|
293
|
+
mcpAllowWeakening: z.boolean().optional(),
|
|
256
294
|
cloudSyncIntervalHours: z.number().positive().optional(),
|
|
257
295
|
// Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
|
|
258
296
|
// to true; set false to fall back to local-only auditing.
|
|
@@ -1244,7 +1282,12 @@ var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
|
1244
1282
|
// SQL-DDL is now owned by the AST detector (analyzeSqlDestructive) so the
|
|
1245
1283
|
// raw-regex smart rule is suppressed for bash — its cond1 read a grep
|
|
1246
1284
|
// alternation's `|` as a shell pipe (`grep "…|mysql…"` → false positive).
|
|
1247
|
-
"review-drop-truncate-shell"
|
|
1285
|
+
"review-drop-truncate-shell",
|
|
1286
|
+
// chmod 777 is now owned by the AST detector (analyzeChmod777) so the raw-
|
|
1287
|
+
// regex smart rule is suppressed for bash — it matched `chmod 777` inside a
|
|
1288
|
+
// `node -e` / `python -c` string literal (a detection pattern, not a run
|
|
1289
|
+
// command) → false positive.
|
|
1290
|
+
"shield:filesystem:review-chmod-777"
|
|
1248
1291
|
]);
|
|
1249
1292
|
var SQL_DB_CLIS = /* @__PURE__ */ new Set([
|
|
1250
1293
|
"psql",
|
|
@@ -1269,6 +1312,62 @@ function analyzeSqlDestructive(command) {
|
|
|
1269
1312
|
description: "The AI wants to drop or truncate a database table via the shell. This permanently deletes the table structure or all its data."
|
|
1270
1313
|
};
|
|
1271
1314
|
}
|
|
1315
|
+
var CHMOD_OPEN_PERM_TOKENS = /* @__PURE__ */ new Set(["777", "0777", "a+rwx", "+x"]);
|
|
1316
|
+
var COMMAND_WRAPPERS = /* @__PURE__ */ new Set([
|
|
1317
|
+
"sudo",
|
|
1318
|
+
"doas",
|
|
1319
|
+
"env",
|
|
1320
|
+
"xargs",
|
|
1321
|
+
"time",
|
|
1322
|
+
"nice",
|
|
1323
|
+
"ionice",
|
|
1324
|
+
"nohup",
|
|
1325
|
+
"setsid",
|
|
1326
|
+
"stdbuf",
|
|
1327
|
+
"timeout",
|
|
1328
|
+
"command",
|
|
1329
|
+
"exec"
|
|
1330
|
+
]);
|
|
1331
|
+
function chmodHasOpenPermMode(command) {
|
|
1332
|
+
const f = parseShared(command);
|
|
1333
|
+
if (f === PARSE_FAIL) return false;
|
|
1334
|
+
let found = false;
|
|
1335
|
+
try {
|
|
1336
|
+
syntax.Walk(f, (node) => {
|
|
1337
|
+
if (!node || found) return false;
|
|
1338
|
+
const n = node;
|
|
1339
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1340
|
+
const words = (n.Args || []).map((a) => resolveWordLiteral(a));
|
|
1341
|
+
if (words.length === 0) return true;
|
|
1342
|
+
const name = (words[0] ?? "").toLowerCase();
|
|
1343
|
+
let idx = -1;
|
|
1344
|
+
if (name === "chmod") idx = 0;
|
|
1345
|
+
else if (COMMAND_WRAPPERS.has(name))
|
|
1346
|
+
idx = words.findIndex((w, i) => i > 0 && w?.toLowerCase() === "chmod");
|
|
1347
|
+
if (idx < 0) return true;
|
|
1348
|
+
for (let i = idx + 1; i < words.length; i++) {
|
|
1349
|
+
const w = words[i];
|
|
1350
|
+
if (w !== null && w.startsWith("-")) continue;
|
|
1351
|
+
if (w !== null && CHMOD_OPEN_PERM_TOKENS.has(w.toLowerCase())) found = true;
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
return true;
|
|
1355
|
+
});
|
|
1356
|
+
} catch {
|
|
1357
|
+
return found;
|
|
1358
|
+
}
|
|
1359
|
+
return found;
|
|
1360
|
+
}
|
|
1361
|
+
function analyzeChmod777(command) {
|
|
1362
|
+
if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
|
|
1363
|
+
if (!chmodHasOpenPermMode(command)) return null;
|
|
1364
|
+
return {
|
|
1365
|
+
ruleName: "shield:filesystem:review-chmod-777",
|
|
1366
|
+
verdict: "review",
|
|
1367
|
+
reason: "chmod 777 requires human approval (filesystem shield)",
|
|
1368
|
+
description: "The AI wants to make a file world-writable/executable (chmod 777). This removes the permission protection on the file so any user or process can modify or run it."
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1272
1371
|
function isProtectedHomePath(rawPath) {
|
|
1273
1372
|
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
1274
1373
|
let underHome = false;
|
|
@@ -2234,6 +2333,17 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2234
2333
|
ruleDescription: sqlVerdict.description
|
|
2235
2334
|
};
|
|
2236
2335
|
}
|
|
2336
|
+
const chmodVerdict = analyzeChmod777(bashCommand);
|
|
2337
|
+
if (chmodVerdict) {
|
|
2338
|
+
return {
|
|
2339
|
+
decision: chmodVerdict.verdict,
|
|
2340
|
+
blockedByLabel: `project-jail (AST): ${chmodVerdict.ruleName}`,
|
|
2341
|
+
reason: chmodVerdict.reason,
|
|
2342
|
+
tier: 2,
|
|
2343
|
+
ruleName: chmodVerdict.ruleName,
|
|
2344
|
+
ruleDescription: chmodVerdict.description
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2237
2347
|
}
|
|
2238
2348
|
if (config.policy.smartRules.length > 0) {
|
|
2239
2349
|
const matchedRule = config.policy.smartRules.find(
|
|
@@ -3707,6 +3817,7 @@ function getConfig(cwd) {
|
|
|
3707
3817
|
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
3708
3818
|
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
3709
3819
|
if (s.reviewChannel !== void 0) mergedSettings.reviewChannel = s.reviewChannel;
|
|
3820
|
+
if (s.mcpAllowWeakening !== void 0) mergedSettings.mcpAllowWeakening = s.mcpAllowWeakening;
|
|
3710
3821
|
if (s.cloudSyncIntervalHours !== void 0)
|
|
3711
3822
|
mergedSettings.cloudSyncIntervalHours = s.cloudSyncIntervalHours;
|
|
3712
3823
|
if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
|
|
@@ -4335,14 +4446,15 @@ async function notifyDaemonViewer(toolName, args, meta, riskMetadata, activityId
|
|
|
4335
4446
|
const { id, allowCount } = await res.json();
|
|
4336
4447
|
return { id, allowCount: allowCount ?? 1 };
|
|
4337
4448
|
}
|
|
4338
|
-
async function notifyTaint(filePath, source) {
|
|
4449
|
+
async function notifyTaint(filePath, source, fromEid) {
|
|
4339
4450
|
if (!isDaemonRunning()) return;
|
|
4340
4451
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
4341
4452
|
try {
|
|
4342
4453
|
await fetch(`${base}/taint`, {
|
|
4343
4454
|
method: "POST",
|
|
4344
4455
|
headers: { "Content-Type": "application/json" },
|
|
4345
|
-
|
|
4456
|
+
// Phase D2 — fromEid is the audit eid that created this taint (edge source).
|
|
4457
|
+
body: JSON.stringify({ path: filePath, source, ...fromEid && { fromEid } }),
|
|
4346
4458
|
signal: AbortSignal.timeout(1e3)
|
|
4347
4459
|
});
|
|
4348
4460
|
} catch {
|
|
@@ -5030,7 +5142,11 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5030
5142
|
if (filePaths.length > 0) {
|
|
5031
5143
|
const taintResult = await checkTaint(filePaths);
|
|
5032
5144
|
if (taintResult.tainted && taintResult.record) {
|
|
5033
|
-
const {
|
|
5145
|
+
const {
|
|
5146
|
+
path: taintedPath,
|
|
5147
|
+
source: taintSource,
|
|
5148
|
+
fromEid: taintFromEid
|
|
5149
|
+
} = taintResult.record;
|
|
5034
5150
|
taintWarning = `\u26A0\uFE0F ${taintedPath} was flagged by ${taintSource} \u2014 this file may contain sensitive data`;
|
|
5035
5151
|
if (config.policy.egress?.enabled) {
|
|
5036
5152
|
const a = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
@@ -5044,7 +5160,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5044
5160
|
args,
|
|
5045
5161
|
"deny",
|
|
5046
5162
|
isObserveMode ? "observe-mode-taint-egress-would-block" : "taint-egress-block",
|
|
5047
|
-
|
|
5163
|
+
// Phase D2 — the causal taint edge: from the row that created
|
|
5164
|
+
// the taint (taintFromEid) to this block row.
|
|
5165
|
+
{ ...meta, ruleName: `taint-egress:${eg.host}`, taintFromEid, taintSource },
|
|
5048
5166
|
hashAuditArgs
|
|
5049
5167
|
);
|
|
5050
5168
|
if (isObserveMode) {
|
|
@@ -5081,8 +5199,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5081
5199
|
if (dlpMatch) {
|
|
5082
5200
|
const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
|
|
5083
5201
|
if (dlpMatch.severity === "block") {
|
|
5202
|
+
let dlpEid;
|
|
5084
5203
|
if (!isManual)
|
|
5085
|
-
appendLocalAudit(
|
|
5204
|
+
dlpEid = appendLocalAudit(
|
|
5086
5205
|
toolName,
|
|
5087
5206
|
args,
|
|
5088
5207
|
"deny",
|
|
@@ -5095,7 +5214,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5095
5214
|
true
|
|
5096
5215
|
);
|
|
5097
5216
|
if (isWriteTool(toolName) && filePath) {
|
|
5098
|
-
await notifyTaint(filePath, `DLP:${dlpMatch.patternName}
|
|
5217
|
+
await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`, dlpEid);
|
|
5099
5218
|
}
|
|
5100
5219
|
if (isObserveMode) {
|
|
5101
5220
|
return {
|
|
@@ -5187,7 +5306,15 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
5187
5306
|
if (loopResult.looping) {
|
|
5188
5307
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|
|
5189
5308
|
if (!isManual)
|
|
5190
|
-
appendLocalAudit(
|
|
5309
|
+
appendLocalAudit(
|
|
5310
|
+
toolName,
|
|
5311
|
+
args,
|
|
5312
|
+
"deny",
|
|
5313
|
+
"loop-detected",
|
|
5314
|
+
// Phase B — carry the loop magnitude so the SaaS shows "looped Nx".
|
|
5315
|
+
{ ...meta, loopCount: loopResult.count },
|
|
5316
|
+
hashAuditArgs
|
|
5317
|
+
);
|
|
5191
5318
|
return {
|
|
5192
5319
|
approved: false,
|
|
5193
5320
|
reason,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
4
4
|
"description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|