@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.
@@ -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: generateEventId(),
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
- body: JSON.stringify({ path: filePath, source }),
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 { path: taintedPath, source: taintSource } = taintResult.record;
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
- { ...meta, ruleName: `taint-egress:${eg.host}` },
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(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
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: generateEventId(),
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
- body: JSON.stringify({ path: filePath, source }),
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 { path: taintedPath, source: taintSource } = taintResult.record;
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
- { ...meta, ruleName: `taint-egress:${eg.host}` },
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(toolName, args, "deny", "loop-detected", meta, hashAuditArgs);
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.42.0",
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",